Compare commits

...
6 Commits
Author SHA1 Message Date
lai_hong 0e6b0694e4 fix(miniprogram): stabilize synced order and design views 2026-09-16 19:45:59 +08:00
lhmin0604andClaude a19519fee6 style(checkout): 消除 previewStickers 链式调用的隐式 any
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-15 07:03:36 +08:00
lhmin0604andClaude ba95037d05 fix(checkout): 设计效果确认页消费贴纸 edits 滤镜 + 按 zIndex 排层
与 DIY 工作台同一 stickerFilter 逻辑(diy-fix-workflow 问题5 的 R3 待办),
修复贴纸编辑保存后确认页预览与工作台图片不一致;
图层顺序改为按 zIndex(无 zIndex 按数组序兜底),与 WCD 打包规则一致。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-15 06:06:42 +08:00
lhmin0604andClaude 3de6128cae restore(r2): 恢复被 R3 整树提交覆盖的 R2 功能层(契约实现为准)
8fdf0b1(R3) 基于 a13e380 却用旧工作区副本覆盖了 R2 域文件,导致终版
(feat/r1-catalog) 上 R2 功能丢失。本提交在不改动 R1/R3 域文件的前提下恢复:

- 整文件恢复(纯 R2 域):diy/index.tsx+scss(批次①②③全部修正)、
  stickerEdit(批次③)、api/address.ts(region 转换收口)、
  store/design.ts+address.ts(缓存层语义文档)、address 页(真机验收版)
- 手工合并:api/design.ts(R2 契约实现为基底 + R3 需要的 fetchDesign)、
  types/index.ts(DesignDataV1.category 收窄 {id,mask,tone}、productIcon
  optional、AddressItem.createdAt 恢复;保留 R1 ProductCategory 扩展与
  R3 OrderItem 扩展)、designList 页(缓存优先 load + processing 徽标)
- 保留不动(其他链路域):checkout/orders/orderDetail/shop/index/profile、
  api/order.ts、api/product.ts、productAdapter、request.ts(R3 构建期注入)、
  keys.ts(R3 mock 处理)、product 页(R1 已实现等价服务端写入)

验证:build:weapp 通过;tsc 错误集合与恢复前对比,R1/R3 文件零变化。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-15 05:28:50 +08:00
lai_hong 99bcf4a1d3 fix(ui): restore tab bar and detail navigation 2026-09-14 18:38:14 +08:00
lai_hong 82b0cc485e chore: snapshot frontend before home search redesign 2026-09-14 10:08:18 +08:00
54 changed files with 1348 additions and 603 deletions
Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
-8
View File
@@ -133,14 +133,6 @@
</image>
</template>
<template name="tmpl_0_15">
<canvas canvas-id="{{i.p0}}" disable-scroll="{{xs.b(i.p1,!1)}}" binderror="eh" bindtouchstart="eh" bindtouchmove="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongtap="eh" type="{{i.p2}}" style="{{i.st}}" class="{{i.cl}}" bindtap="eh" id="{{i.uid||i.sid}}" data-sid="{{i.sid}}">
<block wx:for="{{i.cn}}" wx:key="sid">
<template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
</block>
</canvas>
</template>
<template name="tmpl_0_8">
<block>{{i.v}}</block>
</template>
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[862],{},function(n){var t=function(t){return n(n.s=t)};n.O(0,[907,96],function(){return t(5838)});n.O()}]);
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[862],{},function(n){var t=function(t){return n(n.s=t)};n.O(0,[907,96],function(){return t(7176)});n.O()}]);
+1 -1
View File
@@ -1 +1 @@
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{7914:function(e,n,t){var a=t(7842),c=t(5544),r=t(118),u=t(758),i=t.n(u),o=t(6540),s=t(1457),l=t(9703),f=t(6606),h=t(8199);function g(e,n,t){return Number.isFinite(e)?Math.min(t,Math.max(n,e)):n}function v(e){return g(e,0,100)}function d(e,n){return!Number.isFinite(e)||n<=0?0:g(Math.floor(v(e)/100*n),0,n-1)}function m(e,n,t){return Math.abs(n-e)>=t}function p(e){var n=e/750;return{left:36*n,width:Math.max(0,e-72*n)}}var b=t(4848),x=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}],C=100/x.length,S=12;function P(e){var n=x.findIndex(function(n){return e.endsWith(n.pagePath)});return x[n>=0?n:0].pagePath}function T(){var e=i().getCurrentInstance().router,n=e&&e.path?e.path:"pages/index/index";return P(n)}function q(){var e=(0,o.useState)((0,s.eW)((0,s.O4)())),n=(0,c.A)(e,2),t=n[0],a=n[1],u=(0,o.useState)(T),h=(0,c.A)(u,2),g=h[0],q=h[1],N=(0,o.useState)(null),w=(0,c.A)(N,2),A=w[0],j=w[1],M=(0,o.useState)(!1),E=(0,c.A)(M,2),I=E[0],R=E[1],W=(0,o.useRef)(!1),k=(0,o.useRef)(null),O=(0,o.useRef)(0),X=(0,o.useRef)(null);(0,o.useEffect)(function(){var e=function(e){return a(e)};return i().eventCenter.on(l.Wo,e),function(){i().eventCenter.off(l.Wo,e)}},[]),(0,o.useEffect)(function(){var e=function(e){!e||"dark"!==e.theme&&"light"!==e.theme||((0,s.Rq)(e.theme),"auto"===(0,s.O4)()&&a(e.theme))},n=i();return n.onThemeChange&&n.onThemeChange(e),function(){n.offThemeChange&&n.offThemeChange(e)}},[]),(0,o.useEffect)(function(){var e=function(e){var n=i().getCurrentInstance().router,t=n&&n.path?n.path:"pages/index/index";q(P(e||t))};return i().eventCenter.on("tabBarChange",e),function(){i().eventCenter.off("tabBarChange",e)}},[]),(0,o.useEffect)(function(){(0,f.G)(t)},[t]);var y=function(e){q(P(e)),i().switchTab({url:e}),i().eventCenter.trigger("tabBarChange",e)},B=function(e){var n=e.touches&&e.touches[0];if(n){var t=p(i().getSystemInfoSync().windowWidth);X.current=t,O.current=n.clientX,W.current=!1,R(!1),k.current=null,j(null)}},F=function(e){var n=X.current,t=e.touches&&e.touches[0];if(n&&t){if(!W.current){if(!m(O.current,t.clientX,S))return;W.current=!0,R(!0)}var a=v((t.clientX-n.left)/n.width*100);k.current=a,j(a)}},J=function(e){var n=X.current;if(n){var t;if(W.current){var a;t=d(null!==(a=k.current)&&void 0!==a?a:0,x.length),W.current=!1,R(!1),i().nextTick(function(){k.current=null,j(null)})}else{var c=e.changedTouches&&e.changedTouches[0],r=c?c.clientX:O.current;t=d(v((r-n.left)/n.width*100),x.length)}var u=x[t];u&&u.pagePath!==g&&y(u.pagePath),X.current=null,O.current=0}},G=function(){X.current=null,O.current=0,W.current=!1,R(!1),k.current=null,j(null)},L=Math.max(0,x.findIndex(function(e){return e.pagePath===g}));return(0,b.jsxs)(r.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,b.jsx)(r.Ss,{className:"tab-bar-gradient"}),(0,b.jsxs)(r.Ss,{className:"custom-tab-bar theme-".concat(t),onTouchStart:B,onTouchMove:F,onTouchEnd:J,onTouchCancel:G,catchMove:!0,children:[(0,b.jsx)(r.Ss,{className:"tab-bar-caustic"}),(0,b.jsx)(r.Ss,{className:"tab-bar-rim"}),(0,b.jsx)(r.Ss,{className:"tab-indicator ".concat(I?"dragging":""),style:{left:"".concat(null===A?(L+.5)*C:A,"%")}}),x.map(function(e){var n=e.pagePath===g;return(0,b.jsxs)(r.Ss,{className:"tab-item ".concat(n?"active":""),children:[(0,b.jsx)(r._V,{className:"tab-icon-img ".concat(n?"active":""),src:n?e.iconActive:e.icon,mode:"aspectFit"}),(0,b.jsx)(r.EY,{className:"tab-label",children:e.label})]},e.pagePath)})]})]})}Component((0,a.createComponentConfig)(q,"custom-tab-bar/index"))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[907,96,76],function(){return n(7914)});e.O()}]);
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{2124:function(n,e,t){var a=t(5712),c=t(1433),r=t(408),u=t(3048),i=t.n(u),o=t(2785),s=t(1679),l=t(1986),f=t(1099),h=t(7042);function g(n,e,t){return Number.isFinite(n)?Math.min(t,Math.max(e,n)):e}function v(n){return g(n,0,100)}function p(n,e){return!Number.isFinite(n)||e<=0?0:g(Math.floor(v(n)/100*e),0,e-1)}function d(n,e,t){return Math.abs(e-n)>=t}function m(n){var e=n/750;return{left:36*e,width:Math.max(0,n-72*e)}}var b=t(6517),x=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}],C=100/x.length,P=12;function S(n){var e=x.findIndex(function(e){return n.endsWith(e.pagePath)});return x[e>=0?e:0].pagePath}function T(){var n=i().getCurrentInstance().router,e=n&&n.path?n.path:"pages/index/index";return S(e)}function q(){var n=(0,o.useState)((0,s.eW)((0,s.O4)())),e=(0,c.A)(n,2),t=e[0],a=e[1],u=(0,o.useState)(T),h=(0,c.A)(u,2),g=h[0],q=h[1],N=(0,o.useState)(null),w=(0,c.A)(N,2),A=w[0],j=w[1],M=(0,o.useState)(!1),E=(0,c.A)(M,2),k=E[0],I=E[1],R=(0,o.useRef)(!1),W=(0,o.useRef)(null),O=(0,o.useRef)(0),X=(0,o.useRef)(null);(0,o.useEffect)(function(){var n=function(n){return a(n)};return i().eventCenter.on(l.Wo,n),function(){i().eventCenter.off(l.Wo,n)}},[]),(0,o.useEffect)(function(){var n=function(n){!n||"dark"!==n.theme&&"light"!==n.theme||((0,s.Rq)(n.theme),"auto"===(0,s.O4)()&&a(n.theme))},e=i();return e.onThemeChange&&e.onThemeChange(n),function(){e.offThemeChange&&e.offThemeChange(n)}},[]),(0,o.useEffect)(function(){var n=function(n){var e=i().getCurrentInstance().router,t=e&&e.path?e.path:"pages/index/index";q(S(n||t))};return i().eventCenter.on("tabBarChange",n),function(){i().eventCenter.off("tabBarChange",n)}},[]),(0,o.useEffect)(function(){(0,f.G)(t)},[t]);var y=function(n){q(S(n)),i().switchTab({url:n}),i().eventCenter.trigger("tabBarChange",n)},B=function(n){R.current||n===g||y(n)},F=function(n){var e=n.touches&&n.touches[0];if(e){var t=m(i().getSystemInfoSync().windowWidth);X.current=t,O.current=e.clientX,R.current=!1,I(!1),W.current=null,j(null)}},J=function(n){var e=X.current,t=n.touches&&n.touches[0];if(e&&t){if(!R.current){if(!d(O.current,t.clientX,P))return;R.current=!0,I(!0)}var a=v((t.clientX-e.left)/e.width*100);W.current=a,j(a)}},G=function(n){var e=X.current;if(e){var t;if(R.current){var a;t=p(null!==(a=W.current)&&void 0!==a?a:0,x.length),R.current=!1,I(!1),i().nextTick(function(){W.current=null,j(null)})}else{var c=n.changedTouches&&n.changedTouches[0],r=c?c.clientX:O.current;t=p(v((r-e.left)/e.width*100),x.length)}var u=x[t];u&&u.pagePath!==g&&y(u.pagePath),X.current=null,O.current=0}},L=function(){X.current=null,O.current=0,R.current=!1,I(!1),W.current=null,j(null)},V=Math.max(0,x.findIndex(function(n){return n.pagePath===g}));return(0,b.jsxs)(r.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,b.jsx)(r.Ss,{className:"tab-bar-gradient"}),(0,b.jsxs)(r.Ss,{className:"custom-tab-bar theme-".concat(t),onTouchStart:F,onTouchMove:J,onTouchEnd:G,onTouchCancel:L,catchMove:!0,children:[(0,b.jsx)(r.Ss,{className:"tab-bar-caustic"}),(0,b.jsx)(r.Ss,{className:"tab-bar-rim"}),(0,b.jsx)(r.Ss,{className:"tab-indicator ".concat(k?"dragging":""),style:{left:"".concat(null===A?(V+.5)*C:A,"%")}}),x.map(function(n){var e=n.pagePath===g;return(0,b.jsxs)(r.Ss,{className:"tab-item ".concat(e?"active":""),onTap:function(){return B(n.pagePath)},onClick:function(){return B(n.pagePath)},children:[(0,b.jsx)(r._V,{className:"tab-icon-img ".concat(e?"active":""),src:e?n.iconActive:n.icon,mode:"aspectFit"}),(0,b.jsx)(r.EY,{className:"tab-label",children:n.label})]},n.pagePath)})]})]})}Component((0,a.createComponentConfig)(q,"custom-tab-bar/index"))}},function(n){var e=function(e){return n(n.s=e)};n.O(0,[907,96,76],function(){return e(2124)});n.O()}]);
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[301],{6855:function(e,t,n){var a=n(7842),s=n(118),c=n(9703),r=n(9201),i=n(5414),l=n(9674),o=n(3053),m=n(4848);function g(){var e=(0,c.wR)(),t=(e.theme,e.resolvedTheme),n=(0,r.c)();(0,i.l)(t);var a=[{title:"\u4e00\u3001\u7528\u6237\u6743\u8d23",content:"\u7528\u6237\u786e\u8ba4\u63d0\u4ea4\u7684\u8bbe\u8ba1\u5185\u5bb9\u4e0d\u4fb5\u72af\u7b2c\u4e09\u65b9\u77e5\u8bc6\u4ea7\u6743\uff0c\u5b9a\u5236\u5546\u54c1\u4e00\u7ecf\u786e\u8ba4\u4e0b\u5355\u5373\u8fdb\u5165\u751f\u4ea7\u6d41\u7a0b\uff0c\u975e\u8d28\u91cf\u95ee\u9898\u4e0d\u652f\u6301\u9000\u6362\u3002"},{title:"\u4e8c\u3001\u9690\u79c1\u4fdd\u62a4",content:"\u6211\u4eec\u4e25\u683c\u4fdd\u62a4\u7528\u6237\u4e2a\u4eba\u4fe1\u606f\uff0c\u5934\u50cf\u4e0e\u6635\u79f0\u4ec5\u7528\u4e8e\u63d0\u5347\u670d\u52a1\u4f53\u9a8c\uff0c\u4e0d\u4e0e\u7b2c\u4e09\u65b9\u5206\u4eab\u3002"},{title:"\u4e09\u3001\u552e\u540e\u8bf4\u660e",content:"\u6536\u5230\u5546\u54c1\u540e\u5982\u6709\u8d28\u91cf\u95ee\u9898\uff0c\u8bf7\u57287\u5929\u5185\u8054\u7cfb\u5ba2\u670d\uff0c\u6211\u4eec\u5c06\u4e3a\u60a8\u514d\u8d39\u91cd\u505a\u6216\u9000\u6b3e\u3002"},{title:"\u56db\u3001\u5b9a\u5236\u6d41\u7a0b",content:"1. \u9009\u62e9\u5546\u54c1\u54c1\u7c7b\u5e76\u52a0\u5165\u8bbe\u8ba1\u6e05\u5355\n2. \u5728DIY\u5de5\u4f5c\u53f0\u4e0a\u4f20\u56fe\u7247\u6216\u8f93\u5165\u6587\u5b57\n3. \u786e\u8ba4\u8bbe\u8ba1\u6548\u679c\u5e76\u63d0\u4ea4\u8ba2\u5355\n4. \u652f\u4ed8\u540e\u8fdb\u5165\u751f\u4ea7\uff0c\u7ea63-7\u4e2a\u5de5\u4f5c\u65e5\u53d1\u8d27\n5. \u7269\u6d41\u8ddf\u8e2a\u81f3\u786e\u8ba4\u6536\u8d27"}];return(0,m.jsxs)(s.Ss,{className:"theme-".concat(t),children:[(0,m.jsx)(l.A,{}),(0,m.jsx)(o.A,{title:"\u5b9a\u5236\u534f\u8bae",targetSelector:".agreement-title"}),(0,m.jsx)(s.Ss,{className:"agreement-page",children:(0,m.jsxs)(s.Ss,{className:"agreement-content",style:{marginTop:"".concat(n.statusBarHeight+12,"px")},children:[(0,m.jsx)(s.EY,{className:"agreement-title",children:"\u5b9a\u5236\u534f\u8bae"}),a.map(function(e,t){return(0,m.jsxs)(s.Ss,{className:"agreement-section surface-card",children:[(0,m.jsx)(s.EY,{className:"agreement-h3",children:e.title}),(0,m.jsx)(s.EY,{className:"agreement-p",children:e.content})]},t)})]})})]})}var u={navigationStyle:"custom",enablePageMeta:!0,navigationBarTitleText:"\u5b9a\u5236\u534f\u8bae"};Page((0,a.createPageConfig)(g,"pages/agreement/index",{root:{cn:[]}},u||{}))}},function(e){var t=function(t){return e(e.s=t)};e.O(0,[907,96,76],function(){return t(6855)});e.O()}]);
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[301],{1834:function(e,t,n){var a=n(5712),s=n(408),c=n(1986),r=n(1872),i=n(4521),l=n(9093),o=n(2728),m=n(6517);function g(){var e=(0,c.wR)(),t=(e.theme,e.resolvedTheme),n=(0,r.c)();(0,i.l)(t);var a=[{title:"\u4e00\u3001\u7528\u6237\u6743\u8d23",content:"\u7528\u6237\u786e\u8ba4\u63d0\u4ea4\u7684\u8bbe\u8ba1\u5185\u5bb9\u4e0d\u4fb5\u72af\u7b2c\u4e09\u65b9\u77e5\u8bc6\u4ea7\u6743\uff0c\u5b9a\u5236\u5546\u54c1\u4e00\u7ecf\u786e\u8ba4\u4e0b\u5355\u5373\u8fdb\u5165\u751f\u4ea7\u6d41\u7a0b\uff0c\u975e\u8d28\u91cf\u95ee\u9898\u4e0d\u652f\u6301\u9000\u6362\u3002"},{title:"\u4e8c\u3001\u9690\u79c1\u4fdd\u62a4",content:"\u6211\u4eec\u4e25\u683c\u4fdd\u62a4\u7528\u6237\u4e2a\u4eba\u4fe1\u606f\uff0c\u5934\u50cf\u4e0e\u6635\u79f0\u4ec5\u7528\u4e8e\u63d0\u5347\u670d\u52a1\u4f53\u9a8c\uff0c\u4e0d\u4e0e\u7b2c\u4e09\u65b9\u5206\u4eab\u3002"},{title:"\u4e09\u3001\u552e\u540e\u8bf4\u660e",content:"\u6536\u5230\u5546\u54c1\u540e\u5982\u6709\u8d28\u91cf\u95ee\u9898\uff0c\u8bf7\u57287\u5929\u5185\u8054\u7cfb\u5ba2\u670d\uff0c\u6211\u4eec\u5c06\u4e3a\u60a8\u514d\u8d39\u91cd\u505a\u6216\u9000\u6b3e\u3002"},{title:"\u56db\u3001\u5b9a\u5236\u6d41\u7a0b",content:"1. \u9009\u62e9\u5546\u54c1\u54c1\u7c7b\u5e76\u52a0\u5165\u8bbe\u8ba1\u6e05\u5355\n2. \u5728DIY\u5de5\u4f5c\u53f0\u4e0a\u4f20\u56fe\u7247\u6216\u8f93\u5165\u6587\u5b57\n3. \u786e\u8ba4\u8bbe\u8ba1\u6548\u679c\u5e76\u63d0\u4ea4\u8ba2\u5355\n4. \u652f\u4ed8\u540e\u8fdb\u5165\u751f\u4ea7\uff0c\u7ea63-7\u4e2a\u5de5\u4f5c\u65e5\u53d1\u8d27\n5. \u7269\u6d41\u8ddf\u8e2a\u81f3\u786e\u8ba4\u6536\u8d27"}];return(0,m.jsxs)(s.Ss,{className:"theme-".concat(t),children:[(0,m.jsx)(l.A,{}),(0,m.jsx)(o.A,{title:"\u5b9a\u5236\u534f\u8bae",targetSelector:".agreement-title"}),(0,m.jsx)(s.Ss,{className:"agreement-page",children:(0,m.jsxs)(s.Ss,{className:"agreement-content",style:{marginTop:"".concat(n.statusBarHeight+12,"px")},children:[(0,m.jsx)(s.EY,{className:"agreement-title",children:"\u5b9a\u5236\u534f\u8bae"}),a.map(function(e,t){return(0,m.jsxs)(s.Ss,{className:"agreement-section surface-card",children:[(0,m.jsx)(s.EY,{className:"agreement-h3",children:e.title}),(0,m.jsx)(s.EY,{className:"agreement-p",children:e.content})]},t)})]})})]})}var u={navigationStyle:"custom",enablePageMeta:!0,navigationBarTitleText:"\u5b9a\u5236\u534f\u8bae"};Page((0,a.createPageConfig)(g,"pages/agreement/index",{root:{cn:[]}},u||{}))}},function(e){var t=function(t){return e(e.s=t)};e.O(0,[907,96,76],function(){return t(1834)});e.O()}]);
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[205],{6296:function(s,e,a){var c=a(7842),n=a(5544),r=a(118),i=a(758),t=a.n(i),o=a(6540),l=a(9703),h=a(9201),d=a(5414),p=a(9674),u=a(3053),m=a(5234),x=a(2371),g=a(1133),j=a(4848);function f(){var s=(0,l.wR)(),e=s.resolvedTheme,a=(0,h.c)();(0,d.l)(e);var c=(0,o.useState)(m.zv),i=(0,n.A)(c,2),f=i[0],N=i[1],S=(0,o.useState)(!1),v=(0,n.A)(S,2),b=v[0],E=v[1];(0,o.useEffect)(function(){(0,x.j0)({page:1,pageSize:100}).then(function(s){N(s.list),E(!1)}).catch(function(){return E(!0)})},[]);var w=function(s){t().navigateTo({url:"/pages/shop/detail/index?id=".concat(s)})};return(0,j.jsxs)(r.Ss,{className:"shop-page theme-".concat(e),children:[(0,j.jsx)(p.A,{}),(0,j.jsx)(u.A,{title:"\u667a\u7ed8\u7cbe\u9009",targetSelector:".shop-header"}),(0,j.jsxs)(r.Ss,{className:"shop-list",children:[(0,j.jsxs)(r.Ss,{className:"shop-header",style:{paddingTop:"".concat(a.statusBarHeight+12,"px")},children:[(0,j.jsx)(r.EY,{className:"shop-header-title",children:"\u667a\u7ed8\u7cbe\u9009"}),(0,j.jsx)(r.EY,{className:"shop-header-sub",children:"\u6fc0\u5149\u5fae\u96d5\u5b9a\u5236\u597d\u7269"}),b&&(0,j.jsx)(r.EY,{className:"shop-header-sub",children:"\u7f51\u7edc\u4e0d\u53ef\u7528\uff0c\u5f53\u524d\u663e\u793a\u672c\u5730\u7f13\u5b58"})]}),(0,j.jsx)(r.Ss,{className:"shop-grid",children:f.map(function(s){var e=s.images[0]||s.iconImg||"";return(0,j.jsxs)(r.Ss,{className:"shop-card",onTap:function(){return w(s.id)},children:[(0,j.jsx)(r.Ss,{className:"shop-card-img-wrap",children:(0,j.jsx)(g.A,{className:"shop-card-img",src:e,fallback:s.iconImg||s.icon,mode:"aspectFill",lazyLoad:!0})}),(0,j.jsxs)(r.Ss,{className:"shop-card-body",children:[(0,j.jsx)(r.EY,{className:"shop-card-name",children:s.name}),(0,j.jsx)(r.EY,{className:"shop-card-desc",children:s.desc||""}),(0,j.jsxs)(r.EY,{className:"shop-card-price",children:["\xa5",s.price]})]})]},s.id)})})]})]})}var N={navigationStyle:"custom",enablePageMeta:!0};Page((0,c.createPageConfig)(f,"pages/shop/index",{root:{cn:[]}},N||{}))}},function(s){var e=function(e){return s(s.s=e)};s.O(0,[907,96,76],function(){return e(6296)});s.O()}]);
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[205],{3825:function(s,e,a){var c=a(5712),n=a(1433),r=a(408),i=a(3048),t=a.n(i),o=a(2785),l=a(1986),h=a(1872),d=a(4521),p=a(9093),u=a(2728),m=a(8723),x=a(846),g=a(1070),j=a(6517);function f(){var s=(0,l.wR)(),e=s.resolvedTheme,a=(0,h.c)();(0,d.l)(e);var c=(0,o.useState)(m.zv),i=(0,n.A)(c,2),f=i[0],N=i[1],S=(0,o.useState)(!1),v=(0,n.A)(S,2),b=v[0],E=v[1];(0,o.useEffect)(function(){(0,x.j0)({page:1,pageSize:100}).then(function(s){N(s.list),E(!1)}).catch(function(){return E(!0)})},[]);var w=function(s){t().navigateTo({url:"/pages/shop/detail/index?id=".concat(s)})};return(0,j.jsxs)(r.Ss,{className:"shop-page theme-".concat(e),children:[(0,j.jsx)(p.A,{}),(0,j.jsx)(u.A,{title:"\u667a\u7ed8\u7cbe\u9009",targetSelector:".shop-header"}),(0,j.jsxs)(r.Ss,{className:"shop-list",children:[(0,j.jsxs)(r.Ss,{className:"shop-header",style:{paddingTop:"".concat(a.statusBarHeight+12,"px")},children:[(0,j.jsx)(r.EY,{className:"shop-header-title",children:"\u667a\u7ed8\u7cbe\u9009"}),(0,j.jsx)(r.EY,{className:"shop-header-sub",children:"\u6fc0\u5149\u5fae\u96d5\u5b9a\u5236\u597d\u7269"}),b&&(0,j.jsx)(r.EY,{className:"shop-header-sub",children:"\u7f51\u7edc\u4e0d\u53ef\u7528\uff0c\u5f53\u524d\u663e\u793a\u672c\u5730\u7f13\u5b58"})]}),(0,j.jsx)(r.Ss,{className:"shop-grid",children:f.map(function(s){var e=s.images[0]||s.iconImg||"";return(0,j.jsxs)(r.Ss,{className:"shop-card",onTap:function(){return w(s.id)},children:[(0,j.jsx)(r.Ss,{className:"shop-card-img-wrap",children:(0,j.jsx)(g.A,{className:"shop-card-img",src:e,fallback:s.iconImg||s.icon,mode:"aspectFill",lazyLoad:!0})}),(0,j.jsxs)(r.Ss,{className:"shop-card-body",children:[(0,j.jsx)(r.EY,{className:"shop-card-name",children:s.name}),(0,j.jsx)(r.EY,{className:"shop-card-desc",children:s.desc||""}),(0,j.jsxs)(r.EY,{className:"shop-card-price",children:["\xa5",s.price]})]})]},s.id)})})]})]})}var N={navigationStyle:"custom",enablePageMeta:!0};Page((0,c.createPageConfig)(f,"pages/shop/index",{root:{cn:[]}},N||{}))}},function(s){var e=function(e){return s(s.s=e)};s.O(0,[907,96,76],function(){return e(3825)});s.O()}]);
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+95
View File
@@ -0,0 +1,95 @@
# 微信小程序本地开发协议
> 适用范围:`wechat_wc` 小程序前端及其本机 Docker 联调。
> 依据:微信开放文档“小程序开发指南”、页面路由与自定义 tabBar 规范。最后核对:2026-09-16。
## 1. 目录与构建边界
- 业务源码只修改 `src/``config/` 和受版本管理的配置文件;`dist/` 是 Taro 编译产物,禁止手工修补。
- 每次修改小程序源码后执行 `npm run build:weapp`,微信开发者工具固定打开 `wechat_wc/dist`
- `project.config.json``app.json` 等由构建生成时,以 `src/app.config.ts` 与项目配置为唯一来源;新增页面、tab 页必须先更新源配置。
- 联调接口地址只从 `.env`/构建变量读取,不在页面内写死 `127.0.0.1`、域名或密钥。
## 2. 页面与路由规范
微信将页面组织为页面栈;`navigateTo` 只能打开非 tab 页,`switchTab` 只能切到 tab 页。连续发起的路由会排队,因此一次用户操作只能发起一次导航。
| 场景 | 使用方式 | 本项目约束 |
| --- | --- | --- |
| 打开详情、编辑、结算等非 tab 页面 | `Taro.navigateTo` | 由列表项或按钮发起一次,不再在父级触摸结束时二次跳转。 |
| 返回上一页 | `Taro.navigateBack` | 返回页在 `useDidShow` 中刷新可能已变更的数据。 |
| 切换首页、商品、设计清单、订单、我的 | `Taro.switchTab` | 目标必须在 `src/app.config.ts``tabBar.list` 中。 |
| 替换当前非 tab 页面 | `Taro.redirectTo` | 仅在不应保留返回栈时使用。 |
- `useEffect(..., [])` 只承担一次初始化;地址、订单、设计清单等从子页返回后可能变更的数据,必须在 `useDidShow` 刷新。
- 路由路径比较前统一为 `/pages/...` 格式,避免 `pages/...``/pages/...` 导致选中态误判。
- 跳转函数必须有明确的唯一职责:设置筛选条件、调用一次路由 API;不得混入重复路由、延时跳转或无关 Toast 控制。
## 3. 自定义 tabBar 规范
项目使用 `tabBar.custom = true`。微信规范要求仍完整声明 tabBar 页面;自定义组件仅负责渲染,并且每个 tab 页的 tabBar 实例彼此独立。
- 选中态唯一来源是当前 tab 页路径;进入、返回或外部 `switchTab` 后都应同步一次。
- 单击由子 tab 项处理;拖动仅由拖动结束逻辑处理。两者不可同时触发同一次 `switchTab`
- 灰色选中遮罩只跟随一个 `activeTab` 状态;普通切换保留 CSS 的 `left` 过渡,拖动期间才临时关闭过渡。
- 自定义 tabBar 根节点维持可命中(`pointer-events: auto`)及正确层级;不得为了修复一个页面而随意改动全部 tabBar 的视觉样式。
- 对 tabBar 的任何修改,至少验证:首页 → 我的、我的快捷入口 → 设计清单/订单、拖动切换、返回 tab 页四种路径。
## 4. 数据、登录与接口规范
- 账户维度的数据(地址、设计、订单)以后端用户 ID 为准;本地 storage 只能用于离线兜底和首屏缓存。
- 真机联调使用 `WX_MOCK_LOGIN=1` 时,后端使用稳定的本地测试用户;不得用一次性的 `wx.login` code 作为用户标识。
- 地址、订单、设计列表请求成功后回写对应缓存;请求失败才回退缓存。不要并存两套相互覆盖的业务真相。
- DTO/接口字段转换只放在 `src/utils/api/` 的 adapter 中。页面层只消费前端类型,避免每个页面各自转换省市区、订单状态或设计状态。
- 对创建订单等写操作使用 requestId 幂等键;页面按钮在请求期间禁用,防止重复提交。
## 5. Toast、Loading 与错误处理
- 只关闭当前流程实际开启过的 Loading/Toast。禁止在每次点击前无条件调用 `hideToast()`;没有可关闭 Toast 时会产生运行时告警。
- 网络失败应保留可理解的用户提示,并在控制台记录原始错误;不能用空白页、静默吞错或无限 loading 替代错误状态。
- 真机控制台中与微信广告、日志目录等系统组件相关的文件不存在提示,需要与本项目业务报错分开判断;优先处理带有本项目页面、接口或栈信息的错误。
## 6. 本机联调与验收
1. 启动后端:在 `wxmp_backend` 执行 `docker compose up -d --build`,确认 `http://127.0.0.1:3090/health` 返回 `code: 0`
2. 编译前端:在 `wechat_wc` 执行 `npm run build:weapp`
3. 微信开发者工具打开 `wechat_wc/dist`,本机调试使用对应的 API 基址;真机调试使用已备案、已配置合法域名的 HTTPS 基址。
4. 每个涉及导航或状态的改动至少验证:首次进入、返回页面、切换 tab、真机预览四项。
5. 验收通过后只提交源码与必要配置/文档;不得把偶然生成的 source map、个人配置或密钥提交到仓库。
## 7. 修改前检查清单
- 该需求属于页面、路由、数据还是视觉?只改对应层。
- 是否已有公共 API adapter、store 或组件可复用?有则复用,不复制一套逻辑。
- 是否会影响 tab 页、页面栈或真机数据?会则补充 `useDidShow`/真机验证。
- 是否能用一次状态更新和一次路由调用完成?能则不要叠加事件、计时器或重复 API。
- 是否通过 `npm run build:weapp`?未通过不得交付。
## 8. 官方资料总索引与使用规则
以下八个官方入口已于 2026-09-16 连通性核对成功。它们是开发依据,不复制官网全文到仓库;每次新增微信能力、组件或部署方式时,必须先查对应入口及其下级规范。
| 官方资料 | 本项目何时必须查阅 | 当前约束 |
| --- | --- | --- |
| [小程序框架参考文档](https://developers.weixin.qq.com/miniprogram/dev/reference/index) | 改页面生命周期、路由、组件模型、渲染或分包 | 本项目使用 Taro 生成小程序代码,但必须遵守微信页面栈、tab 页和生命周期规则。 |
| [小程序组件参考文档](https://developers.weixin.qq.com/miniprogram/dev/component/) | 新增或修改 `View``ScrollView``Input``Picker``Button``Image` 等界面组件 | 先确认属性、事件和基础库兼容性;不以 Web DOM 行为作假设。 |
| [小程序 API 参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/) | 调用登录、网络、存储、媒体选择、支付、地址、文件或系统接口 | 所有 `Taro.*` 调用须对应微信 API 能力,并处理 success/fail 和真机差异。 |
| [小程序服务端 API 参考文档](https://developers.weixin.qq.com/miniprogram/dev/server/API/) | 接入真实微信登录、支付、消息、内容安全或服务端凭证 | AppSecret、access_token、支付证书只留在后端环境变量;前端不可保存或转发。 |
| [微信开发者工具参考文档](https://developers.weixin.qq.com/miniprogram/dev/devtools/devtools) | 编译、预览、真机调试、上传、Source Map、性能诊断 | `dist` 为唯一导入目录;提交前至少完成开发者工具编译和真机预览。 |
| [微信云托管参考文档](https://developers.weixin.qq.com/miniprogram/dev/wxcloudservice/wxcloudrun/src/basic/intro) | 评估将 Nest/Docker 后端迁移至微信云托管 | 当前后端仍以 Docker Compose 部署;迁移前必须重新确认镜像、域名、环境变量和网络方案。 |
| [微信云开发参考文档](https://developers.weixin.qq.com/miniprogram/dev/wxcloudservice/wxcloud/basis/getting-started) | 评估云函数、云数据库、云存储或云调用 | 当前项目不混用云开发数据源;若启用,先写迁移方案和接口契约,避免与 PostgreSQL 双写。 |
| [小程序 AI 能力参考文档](https://developers.weixin.qq.com/miniprogram/dev/ai/guide) | 新增微信原生 AI 能力,或替换现有词云生成链路 | 现有词云走自建后端契约;切换前必须评估权限、资费、数据安全和失败降级。 |
### 资料使用的最小闭环
1. 先在表中确定本次改动对应的官方资料。
2. 只查与当前能力直接相关的下级页面、接口参数和基础库限制,避免整份文档复制或无目的阅读。
3. 将结论落到一个公共 adapter、组件或配置文件;不要让每个页面自行实现同一套兼容逻辑。
4. 在开发者工具和真机上验证后,才将“已支持”写入项目文档。
### 当前已直接采用的官方规则
- 页面栈与 `switchTab`/`navigateTo` 的目标限制。
- `onShow`/`useDidShow` 用于从子页返回后的数据刷新。
- 自定义 tabBar 必须完整声明 tab 页,并由组件维护选中态。
- 真机调试与合法域名、服务端密钥隔离原则。
+236
View File
@@ -0,0 +1,236 @@
# 微信小程序真机测试说明
本文用于指导测试人员在 Windows 开发机上启动后端、构建前端,并通过微信开发者工具预览到真实手机。测试重点是确认:小程序页面能够通过 HTTPS 访问后端接口,商品、地址、设计清单和订单等页面可以正常联调。
## 一、测试环境
| 项目 | 配置 |
| --- | --- |
| 前端源码 | `G:\wordcloud_wechat\wechat_wc` |
| 前端导入目录 | `G:\wordcloud_wechat\wechat_wc` |
| 后端源码 | `G:\wordcloud_wechat\wxmp_backend` |
| 小程序 AppID | `wxad998564cf43a7d9` |
| 真机后端地址 | `https://wxbackend.tokenleaping.com` |
| 本机 Docker 后端地址 | `http://127.0.0.1:3090` |
| 商品图片地址 | `https://wordcloudwechat.oss-cn-hangzhou.aliyuncs.com` |
说明:`127.0.0.1` 只代表当前 Windows 电脑。真实手机不能通过这个地址访问电脑上的 Docker 容器;真机应使用可公开访问的 HTTPS 域名。
## 二、启动本机 Docker 后端
本步骤用于确认后端和数据库本身正常,也可供微信开发者工具模拟器联调。
1. 启动 Docker Desktop,确认左下角显示 Engine running。
2. 打开 PowerShell,执行:
```powershell
cd G:\wordcloud_wechat\wxmp_backend
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
```
3. 查看容器状态:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml ps
```
预期 `wxmp-app`、`wxmp-postgres`、`wxmp-redis` 均为运行状态,其中数据库和 Redis 应显示 `healthy`。
4. 在浏览器检查本机接口:
- `http://127.0.0.1:3090/health`
- `http://127.0.0.1:3090/docs`
- `http://127.0.0.1:3090/api/categories`
- `http://127.0.0.1:3090/api/products?page=1&pageSize=20`
- `http://127.0.0.1:3090/api/products/penbox`
`/health` 和商品接口返回 HTTP 200,且响应中有 `code: 0`,即可继续测试。
### 本机登录配置
本机 Docker 若只用于开发者工具模拟器联调,可在后端 `.env` 使用本地 mock 登录:
```env
WX_APPID=local-dev-appid
WX_SECRET=local-dev-secret
WX_MOCK_LOGIN=1
```
这是项目《本机容器化联调教程》规定的开发配置,不代表真实微信登录。真实手机登录时,必须改为线上服务器中的真实 `WX_APPID` 与 `WX_SECRET`,并将 `WX_MOCK_LOGIN` 留空;AppSecret 不能提交到 Git。
## 三、构建微信小程序前端
1. 打开新的 PowerShell 窗口,执行:
```powershell
cd G:\wordcloud_wechat\wechat_wc
npm run build:weapp
```
2. 构建成功后,产物位于:
```text
G:\wordcloud_wechat\wechat_wc\dist
```
3. 当前真机构建应使用线上地址:
```text
https://wxbackend.tokenleaping.com
```
修改环境变量或配置后,必须重新执行构建;微信开发者工具不会自动读取源码目录中的环境变量。
## 四、配置微信公众平台合法域名
登录小程序管理后台,在“开发 → 开发管理 → 开发设置 → 服务器域名”中配置:
| 类型 | 域名 |
| --- | --- |
| request 合法域名 | `https://wxbackend.tokenleaping.com` |
| downloadFile 合法域名 | `https://wordcloudwechat.oss-cn-hangzhou.aliyuncs.com` |
| uploadFile 合法域名 | 使用 COS 直传时配置同一 OSS 域名 |
配置要求:
- 只填写 HTTPS 域名,不填写接口路径、端口、`/health` 或 `/api`。
- HTTPS 证书必须有效,域名证书需覆盖 `wxbackend.tokenleaping.com`。
- 修改服务器域名后,重新编译并重新预览小程序。
开发者工具中的“不校验合法域名”只适合模拟器临时调试,不能替代公众平台的正式域名配置。
## 五、导入开发者工具并预览
1. 打开微信开发者工具。
2. 选择“导入项目”。
3. 项目目录填写前端源码根目录:
```text
G:\wordcloud_wechat\wechat_wc
```
不要选择工作区总目录 `G:\wordcloud_wechat`。根目录中的 `project.config.json` 已声明
`miniprogramRoot: "dist/"`,开发者工具会自动把 `dist` 作为小程序运行产物。
4. AppID 选择:
```text
wxad998564cf43a7d9
```
5. 点击“编译”,确认模拟器能打开首页。
6. 点击工具栏“预览”,等待二维码生成。
7. 使用已登录该小程序的微信扫描二维码,在手机上打开预览版本。
## 六、真机功能测试清单
建议按以下顺序测试,并在每一步记录结果:
| 序号 | 操作 | 预期结果 |
| --- | --- | --- |
| 1 | 启动小程序 | 首页正常显示,无白屏或启动异常 |
| 2 | 打开“商品”页 | 显示后端返回的在售商品列表 |
| 3 | 下拉刷新商品页 | 请求成功,列表无重复或空白 |
| 4 | 点击商品 | 商品详情、价格、规格、图片正常显示 |
| 5 | 返回首页 | 首页商品数据正常显示 |
| 6 | 打开“设计清单” | 已登录时可读取服务端清单;未登录时提示登录 |
| 7 | 新增、编辑、删除地址 | 地址操作成功,默认地址状态正确 |
| 8 | 进入 DIY 并保存设计 | 设计清单状态和内容正常更新 |
| 9 | 提交订单 | 能创建订单并显示服务端计算的金额 |
| 10 | 进入支付 | 若未配置微信支付,显示“支付未配置”等明确提示,不应假装支付成功 |
商品目录重点验收:页面显示的商品必须来自 `GET /api/products` 和 `GET /api/products/:id`,不能只显示前端写死数据。可在测试数据库中修改一个商品价格,再重新进入商品页,确认页面价格同步变化。
## 七、如何确认手机确实访问了后端
在微信开发者工具的“调试器 → Network”中查看请求,商品页至少应看到:
```text
GET https://wxbackend.tokenleaping.com/api/products?page=1&pageSize=20
GET https://wxbackend.tokenleaping.com/api/products/{商品ID}
```
响应状态应为 `200`,响应体应包含:
```json
{
"code": 0,
"message": "ok",
"data": {}
}
```
如果请求地址仍然是 `127.0.0.1:3090`,说明前端没有使用真机构建配置,需要重新检查环境变量并执行 `npm run build:weapp`。
## 八、本机 Docker 与真机的关系
本机 Docker 只负责在 Windows 上运行 Linux 容器中的后端、PostgreSQL 和 Redis。它不会自动获得公网 HTTPS 地址。
有两种测试方式:
### 方式 A:直接测试线上 HTTPS 服务(推荐)
- 前端构建地址使用 `https://wxbackend.tokenleaping.com`。
- 微信开发者工具生成预览二维码。
- 手机通过互联网访问线上后端。
- 本机 Docker 只用于本地接口检查,不影响手机请求。
### 方式 B:让手机测试本机 Docker 代码
需要额外配置内网穿透或 FRP
1. Windows 上的 `frpc` 将本机 `3090` 转发到服务器上的 FRP 服务端。
2. 服务器反向代理将 HTTPS 域名转发到 FRP 通道。
3. 前端仍构建为 `https://wxbackend.tokenleaping.com`。
4. 确认微信公众平台合法域名已配置,并重新预览。
仅启动 Docker Desktop,不能让真实手机直接访问 `127.0.0.1`。
### 当前项目的 FRP 映射
本项目提供的 `frpc.toml` 使用以下转发关系:
```toml
serverAddr = "43.155.206.17"
serverPort = 7000
[[proxies]]
name = "wxbackend"
type = "tcp"
localIP = "host.docker.internal"
localPort = 3090
remotePort = 3090
```
对应含义是:FRP 客户端容器把 Windows 主机的 `3090` 转发到服务器 `3090`;服务器上的 HTTPS 反向代理再将 `wxbackend.tokenleaping.com` 转到该端口。`frpc.toml` 中的认证 token 仅保存在本机文件,不要提交到 Git。
## 九、常见问题
| 现象 | 处理方法 |
| --- | --- |
| `request:fail url not in domain list` | 在公众平台添加 request 合法域名,重新编译并预览 |
| `ERR_CERT` 或 TLS 错误 | 检查 HTTPS 证书、域名解析和服务器时间 |
| 手机请求连接被拒绝 | 不要使用 `127.0.0.1`;确认线上域名或 FRP 转发正常 |
| 模拟器正常、手机白屏 | 检查真机构建地址和服务器域名配置,查看 Network 错误 |
| 商品图片不显示 | 将 OSS 域名加入 downloadFile 合法域名,并检查图片 URL |
| 登录失败 | 检查线上后端的微信 AppID、AppSecret;本机空配置只能测试公开接口 |
| 词云或支付无法完成 | 检查对应外部服务和密钥;未配置时属于预期限制 |
## 十、测试记录模板
```text
测试日期:
测试人员:
前端提交:
后端提交:
手机型号 / 微信版本:
后端地址:https://wxbackend.tokenleaping.com
商品列表:通过 / 不通过
商品详情:通过 / 不通过
地址管理:通过 / 不通过
设计清单:通过 / 不通过
订单创建:通过 / 不通过
支付:已配置并通过 / 未配置 / 失败
备注:
```
+5
View File
@@ -0,0 +1,5 @@
export default definePageConfig({
// 自定义 tabBar 本质是小程序组件;明确输出组件配置,确保其独立 wxss 被加载。
component: true,
styleIsolation: 'isolated'
})
+5 -1
View File
@@ -4,7 +4,10 @@
right: 0;
bottom: 0;
height: 160rpx;
pointer-events: none;
// 部分微信基础库对 pointer-events:none 的父节点命中测试不一致,
// 可能导致子节点虽然声明 auto 仍收不到点击。根容器直接参与命中,
// 背景渐变单独关闭事件,标签区域保持可点击。
pointer-events: auto;
z-index: 999;
}
@@ -14,6 +17,7 @@
right: 0;
bottom: 0;
height: 100%;
pointer-events: none;
}
.tab-bar-root.theme-light .tab-bar-gradient {
+30 -4
View File
@@ -20,7 +20,9 @@ const TAB_CENTER_STEP = 100 / TABS.length
const DRAG_THRESHOLD_PX = 12
function matchTab(path: string): string {
const idx = TABS.findIndex((t) => path.endsWith(t.pagePath))
// 微信路由通常给出 pages/...,配置使用 /pages/...;统一格式后再匹配。
const normalizedPath = path.startsWith('/') ? path : `/${path}`
const idx = TABS.findIndex((t) => normalizedPath.endsWith(t.pagePath))
return TABS[idx >= 0 ? idx : 0].pagePath
}
@@ -46,6 +48,7 @@ export default function CustomTabBar() {
const dragPercentRef = useRef<number | null>(null)
const startXRef = useRef<number>(0)
const barFrameRef = useRef<{ left: number; width: number } | null>(null)
const switchingRef = useRef<boolean>(false)
useEffect(() => {
const handler = (t: 'light' | 'dark') => setResolvedTheme(t)
@@ -96,11 +99,29 @@ export default function CustomTabBar() {
}, [resolvedTheme])
const switchTab = (url: string) => {
setActiveTab(matchTab(url))
Taro.switchTab({ url })
const target = matchTab(url)
// 官方路由会将连续请求排队;同一触摸同时触发 onTouchEnd/onTap/onClick 时,
// 只允许首个请求进入队列,避免遮罩来回跳动。
if (switchingRef.current || target === activeTab) return
switchingRef.current = true
setActiveTab(target)
Taro.switchTab({
url,
complete: () => {
Taro.nextTick(() => {
switchingRef.current = false
})
}
})
Taro.eventCenter.trigger('tabBarChange', url)
}
const tapTab = (url: string) => {
// 点击标签时直接切换;拖动手势由 onTouchEnd 统一处理,避免一次手势触发两次跳转。
if (isDraggingRef.current || url === activeTab) return
switchTab(url)
}
const startDrag = (e: any) => {
const touch = e.touches && e.touches[0]
if (!touch) return
@@ -181,7 +202,12 @@ export default function CustomTabBar() {
{TABS.map((tab) => {
const isActive = tab.pagePath === activeTab
return (
<View key={tab.pagePath} className={`tab-item ${isActive ? 'active' : ''}`}>
<View
key={tab.pagePath}
className={`tab-item ${isActive ? 'active' : ''}`}
onTap={() => tapTab(tab.pagePath)}
onClick={() => tapTab(tab.pagePath)}
>
<Image className={`tab-icon-img ${isActive ? 'active' : ''}`} src={isActive ? tab.iconActive : tab.icon} mode='aspectFit' />
<Text className='tab-label'>{tab.label}</Text>
</View>
+45 -27
View File
@@ -2,8 +2,8 @@ import { View, Text, Image, Input, Picker } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import './index.scss'
import { getAddressList, addAddress, updateAddress as updateLocalAddress, deleteAddress as deleteLocalAddress, setAddressList } from '../../utils/store'
import { fetchAddresses, createAddress, updateAddress, deleteAddress, setDefaultAddress } from '../../utils/api/address'
import { getAddressList, setAddressList, addAddress, updateAddress, deleteAddress } from '../../utils/store'
import { fetchAddresses, createAddress, updateAddress as updateAddressApi, deleteAddress as deleteAddressApi } from '../../utils/api'
import type { AddressItem } from '../../types'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
@@ -27,14 +27,16 @@ export default function AddressPage() {
const [detail, setDetail] = useState('')
const [isDefault, setIsDefault] = useState(false)
const load = async () => {
try {
const remote = await fetchAddresses()
setList(remote)
setAddressList(remote)
} catch {
setList(getAddressList())
}
const load = () => {
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
setList(getAddressList())
fetchAddresses()
.then(data => {
setList(data)
setAddressList(data)
})
.catch(() => setList(getAddressList()))
}
useEffect(() => {
@@ -65,17 +67,15 @@ export default function AddressPage() {
setShowForm(true)
}
const handleSetDefault = async (id: string) => {
try { const next = await setDefaultAddress(id); setList(prev => prev.map(item => ({ ...item, isDefault: item.id === next.id }))); Taro.showToast({ title: '已设为默认', icon: 'success' }) } catch (error) { Taro.showToast({ title: (error as Error).message || '设置失败', icon: 'none' }) }
}
const handleDelete = (id: string) => {
Taro.showModal({
title: '确认删除',
content: '删除后将无法恢复该地址',
success: (res) => {
if (res.confirm) {
deleteAddress(id).then(() => load()).catch(error => { deleteLocalAddress(id); load(); Taro.showToast({ title: (error as Error).message || '删除失败', icon: 'none' }) })
deleteAddressApi(id)
.catch(() => deleteAddress(id)) // 失败降级本地删除
.finally(load)
}
}
})
@@ -86,22 +86,41 @@ export default function AddressPage() {
Taro.setClipboardData({ data: text })
}
const handleSave = async () => {
const handleSave = () => {
if (!name.trim() || !phone.trim() || region.length === 0 || !detail.trim()) {
Taro.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
const payload = { name, phone, region, detail, isDefault }
try {
const saved = editing ? await updateAddress(editing.id, payload) : await createAddress(payload)
const next = editing ? list.map(item => item.id === saved.id ? saved : item) : [saved, ...list]
setList(next); setAddressList(next)
Taro.showToast({ title: '保存成功', icon: 'success' }); setShowForm(false); resetForm()
} catch (error) {
// API 失败时保留原有本地兜底,避免离线编辑丢失
if (editing) updateLocalAddress(editing.id, payload); else addAddress(payload)
Taro.showToast({ title: (error as Error).message || '保存失败,已暂存本地', icon: 'none' }); setShowForm(false); resetForm(); load()
}
const save = editing
? updateAddressApi(editing.id, payload)
: createAddress(payload)
save
.then((saved) => {
// 新地址在返回结算页前立即回写按账号隔离的缓存;
// 结算页 onShow 会据此先展示,再以服务端列表校准
const current = getAddressList()
const withoutSaved = current.filter(item => item.id !== saved.id)
const next = [saved, ...withoutSaved].map(item => (
saved.isDefault
? { ...item, isDefault: item.id === saved.id }
: item
))
setList(next)
setAddressList(next)
Taro.showToast({ title: '保存成功', icon: 'success' })
})
.catch(() => {
// 服务端不可达时降级本地保存,联网后进入页面会刷新
if (editing) updateAddress(editing.id, payload)
else addAddress(payload)
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
})
.finally(() => {
setShowForm(false)
resetForm()
load()
})
}
const onRegionChange = (e: any) => {
@@ -143,7 +162,6 @@ export default function AddressPage() {
<View className='address-actions'>
<Text className='address-act' onTap={() => openEdit(item)}></Text>
<Text className='address-act' onTap={() => handleCopy(item)}></Text>
{!item.isDefault && <Text className='address-act' onTap={() => handleSetDefault(item.id)}></Text>}
<Text className='address-act delete' onTap={() => handleDelete(item.id)}></Text>
</View>
</View>
+54 -10
View File
@@ -1,8 +1,8 @@
import { View, Text, Image, Button } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { useState, useEffect, useCallback } from 'react'
import './index.scss'
import { getDesignList, getDefaultAddress, getAddressList, setDesignList, type AddressItem } from '../../utils/store'
import { getDesignList, getAddressList, setAddressList, setDesignList, type AddressItem } from '../../utils/store'
import { createDesign, fetchDesign } from '../../utils/api/design'
import { fetchAddresses } from '../../utils/api/address'
import { createOrder, payOrder } from '../../utils/api/order'
@@ -24,6 +24,28 @@ export default function CheckoutPage() {
const [serverTotal, setServerTotal] = useState<number | null>(null)
const [submitting, setSubmitting] = useState(false)
// 地址页 navigateBack 后,当前结算页不会重新挂载;因此必须在 onShow 刷新。
// 先用本地缓存即时更新,再拉接口以服务端数据为准。
const refreshAddresses = useCallback(async () => {
const apply = (addresses: AddressItem[]) => {
setAddrList(addresses)
setAddress(current => (
addresses.find(item => item.id === current?.id)
|| addresses.find(item => item.isDefault)
|| addresses[0]
|| null
))
}
try {
const addresses = await fetchAddresses()
setAddressList(addresses)
apply(addresses)
} catch {
apply(getAddressList())
}
}, [])
useEffect(() => {
const router = Taro.getCurrentInstance().router
const params = router ? router.params : undefined
@@ -33,19 +55,19 @@ export default function CheckoutPage() {
try {
const remote = await fetchDesign(dId)
setDesign(remote)
const addresses = await fetchAddresses()
setAddrList(addresses)
setAddress(addresses.find(item => item.isDefault) || addresses[0] || null)
} catch {
const list = getDesignList()
const d = list.find(x => x.id === dId)
setDesign(d || null)
setAddress(getDefaultAddress())
setAddrList(getAddressList())
}
await refreshAddresses()
}
void load()
}, [])
}, [refreshAddresses])
useDidShow(() => {
void refreshAddresses()
})
const handleConfirm = async () => {
if (submitting) return
@@ -105,6 +127,27 @@ export default function CheckoutPage() {
const hasStickers = design && design.designData && design.designData.stickers && design.designData.stickers.length > 0
const hasLegacyImage = design && design.designData ? design.designData.imageSrc : undefined
// 预览消费贴纸 edits(亮度/色相/对比度 → CSS filter),与 DIY 工作台同一呈现逻辑
//diy-fix-workflow 问题5 的 R3 待办:不消费会导致确认页图片与工作台不一致)
const stickerFilter = (s: { edits?: { brightness?: number; hue?: number; contrast?: number } }) => {
const ed = s.edits
if (!ed) return undefined
const b = ed.brightness || 0
const h = ed.hue || 0
const c = ed.contrast || 0
if (b === 0 && h === 0 && c === 0) return undefined
return `brightness(${100 + b}%) hue-rotate(${h}deg) contrast(${100 + c}%)`
}
// 图层顺序与 WCD 打包规则一致(按 zIndex,无 zIndex 按数组序兜底)
const previewStickers: any[] = hasStickers
? design.designData!.stickers!
.map((s: any, i: number) => ({ s, z: typeof s.zIndex === 'number' ? s.zIndex : i }))
.sort((a: { z: number }, b: { z: number }) => a.z - b.z)
.map((it: { s: any }) => it.s)
: []
const formatAddress = (region: string[] | undefined, detail: string) => {
const regionText = region ? region.join(' ') : ''
return regionText ? `${regionText} ${detail}` : detail
@@ -159,13 +202,14 @@ export default function CheckoutPage() {
<View className='checkout-canvas surface-card'>
<View className='canvas-area' style={maskStyle}>
{hasStickers ? (
design.designData.stickers.map((s: any) => (
previewStickers.map((s: any) => (
<Image
key={s.id}
className='checkout-image'
src={s.src}
style={{
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale || 1})`,
filter: stickerFilter(s),
width: s.width || 200,
height: s.height || 200
}}
+4 -3
View File
@@ -136,10 +136,11 @@
}
.design-icon-img {
width: 48rpx;
height: 48rpx;
// 与订单列表的商品图保持同一规格,避免实拍图被压成图标大小。
width: 96rpx;
height: 96rpx;
flex-shrink: 0;
border-radius: 12rpx;
border-radius: 24rpx;
background: var(--bg-input);
}
+19 -13
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import './index.scss'
import { getDesignList, removeDesigns, setDesignList, type DesignItem } from '../../utils/store'
import { fetchDesignList, deleteDesigns } from '../../utils/api/design'
import { PRODUCT_ICON_MAP } from '../../utils/productConfig'
import { getProductById, PRODUCT_ICON_MAP } from '../../utils/productConfig'
import { assetUrl } from '../../utils/asset'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
@@ -15,19 +15,16 @@ import ScrollTopMask from '../../components/ScrollTopMask'
const STATUS_TABS = [
{ code: 'all', label: '全部' },
{ code: 'toDesign', label: '待设计' },
{ code: 'undesigned', label: '未设计' },
{ code: 'undesigned', label: '待设计' },
{ code: 'designing', label: '设计中' },
{ code: 'processing', label: '生产中' },
{ code: 'ordered', label: '已下单' }
]
const VISIBLE_STATUS_TABS = STATUS_TABS.filter(tab => tab.code !== 'undesigned')
const STATUS_STYLE: Record<string, { label: string; cls: string }> = {
undesigned: { label: '设计', cls: 'badge-pink' },
undesigned: { label: '设计', cls: 'badge-pink' },
designing: { label: '设计中', cls: 'badge-blue' },
processing: { label: '生产中', cls: 'badge-blue' },
processing: { label: '生产中', cls: 'badge-warning' },
ordered: { label: '已下单', cls: 'badge-green' }
}
@@ -41,7 +38,15 @@ export default function DesignListPage() {
const [selected, setSelected] = useState<Set<string>>(new Set())
const load = useCallback(() => {
fetchDesignList().then(remote => { setList(remote); setDesignList(remote) }).catch(() => setList(getDesignList()))
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
setList(getDesignList())
fetchDesignList()
.then(data => {
setList(data)
setDesignList(data)
})
.catch(() => setList(getDesignList()))
}, [])
const init = useCallback(() => {
@@ -65,11 +70,10 @@ export default function DesignListPage() {
init()
}, [init])
// “全部”用于汇总;其余标签与状态一一对应,单条设计只会落在一个栏位。
const filtered = activeTab === 'all'
? list
: activeTab === 'toDesign'
? list.filter(d => d.status === 'undesigned' || d.status === 'designing')
: list.filter(d => d.status === activeTab)
: list.filter(d => d.status === activeTab)
const goDesign = (item: DesignItem) => {
if (item.status === 'ordered') {
@@ -137,7 +141,7 @@ export default function DesignListPage() {
{/* 状态筛选 */}
<View className='status-tabs'>
<ScrollView className='tabs-scroll' scrollX>
{VISIBLE_STATUS_TABS.map(tab => (
{STATUS_TABS.map(tab => (
<View
key={tab.code}
className={`chip ${activeTab === tab.code ? 'active' : ''}`}
@@ -172,6 +176,8 @@ export default function DesignListPage() {
? item.productIcon
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg'
const iconSrc = assetUrl(rawIcon)
// 与商品介绍页一致:商品目录首图是该商品的实拍图;目录缺图时才兼容旧 SVG。
const photoSrc = getProductById(item.productId)?.images?.[0] || iconSrc
const isSelected = selected.has(item.id)
return (
<View
@@ -183,7 +189,7 @@ export default function DesignListPage() {
{managing && (
<View className={`batch-checkbox ${isSelected ? 'checked' : ''}`} />
)}
<Image className='design-icon-img' src={iconSrc} mode='aspectFit' />
<Image className='design-icon-img' src={photoSrc} mode='aspectFill' />
<View className='design-info'>
<View className='design-title-row'>
<Text className='design-name'>{item.productName}</Text>
+3
View File
@@ -103,6 +103,8 @@
position: absolute;
max-width: 100%;
max-height: 100%;
/* 问题1 修正:碰撞盒按左上角计算,缩放原点统一为左上角,渲染与碰撞语义自洽 */
transform-origin: top left;
z-index: 5;
border: 2rpx solid transparent;
border-radius: 8rpx;
@@ -391,6 +393,7 @@
position: absolute;
max-width: 100%;
max-height: 100%;
transform-origin: top left;
z-index: 5;
}
+181 -60
View File
@@ -1,11 +1,10 @@
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import './index.scss'
import { getProductById } from '../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign as updateLocalDesign, type DesignItem, type StickerItem } from '../../utils/store'
import { persistDesignMedia } from '../../utils/api'
import { createDesign, updateDesign as updateRemoteDesign } from '../../utils/api/design'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
import { createDesign, updateDesign as updateDesignApi, persistDesignMedia } from '../../utils/api'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
@@ -27,7 +26,41 @@ export default function DIYPage() {
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const [previewMode, setPreviewMode] = useState(false)
const [hasOverlap, setHasOverlap] = useState(false)
const [isCreatingDesign, setIsCreatingDesign] = useState(false)
/** 进入 DIY 即创建清单条目,并推进到「设计中」(问题3 修正:进入工作台即 SUBMITTED),失败降级本地 */
const createDesignEntry = (product: { id: string; name: string; price: number; icon: string }, count: number) => {
createDesign({
productId: product.id,
productName: product.name,
unitPrice: product.price,
count
})
.then(item => {
setDesignList([item, ...getDesignList()]) // 回写缓存,checkout 等页面仍读缓存
setDesignId(item.id)
return updateDesignApi(item.id, { status: 'designing' })
.then(updated => {
setDesignList(getDesignList().map(d => d.id === item.id ? { ...d, status: updated.status } : d))
})
.catch(() => {
setDesignList(getDesignList().map(d => d.id === item.id ? { ...d, status: 'designing' as const } : d))
})
})
.catch(() => {
const newDesign: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: product.icon,
unitPrice: product.price,
count,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10)
}
setDesignList([...getDesignList(), newDesign])
setDesignId(newDesign.id)
})
}
useEffect(() => {
const router = Taro.getCurrentInstance().router
@@ -35,46 +68,13 @@ export default function DIYPage() {
const source = params ? params.source : undefined
const productId = (params && params.productId) || (params && params.category)
const createDraftForProduct = async (product: any, count: number) => {
const draft: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
productName: product.name,
productIcon: product.icon,
unitPrice: product.price,
count,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10),
}
// 先写本地草稿,保证离线时仍能编辑;在线时立即换成服务端真实 id。
setDesignList([...getDesignList(), draft])
setDesignId(draft.id)
setIsCreatingDesign(true)
try {
const saved = await createDesign({
productId: draft.productId,
productName: draft.productName,
unitPrice: draft.unitPrice,
count: draft.count,
designData: draft.designData,
})
setDesignList(getDesignList().map(item => item.id === draft.id ? saved : item))
setDesignId(saved.id)
} catch {
// 保留本地草稿;结算页会在网络恢复后继续走本地兜底。
} finally {
setIsCreatingDesign(false)
}
}
if (source === 'product' && productId) {
const product = getProductById(productId)
if (product) {
setCategory(product)
const count = Number(params ? params.quantity : undefined) || 1
setQuantity(count)
void createDraftForProduct(product, count)
createDesignEntry(product, count)
}
return
}
@@ -88,22 +88,42 @@ export default function DIYPage() {
setQuantity(design.count)
const product = getProductById(design.productId)
if (product) setCategory(product)
if (design.designData ? design.designData.stickers : undefined) {
setStickers(design.designData.stickers)
} else if (design.designData ? design.designData.imageSrc : undefined) {
const dd = design.designData
const rawStickers = dd ? dd.stickers : undefined
if (rawStickers) {
// 问题1 留意点:旧数据 width/height 为图片原始像素语义,读取端按 mask 等比归一化
//(契约实现注记,不做存量迁移);新语义数据(≤mask 短边 60%)原样通过
const mask = (dd && dd.category && dd.category.mask) || (product ? product.mask : undefined)
const normalized: StickerItem[] = mask
? rawStickers.map(s => {
if (s.width > mask.width || s.height > mask.height) {
const k = (Math.min(mask.width, mask.height) * 0.6) / Math.max(s.width, s.height)
return { ...s, width: Math.round(s.width * k), height: Math.round(s.height * k) }
}
return s
})
: rawStickers
setStickers(normalized)
} else if (dd ? dd.imageSrc : undefined) {
// 兼容旧版数据
const s: StickerItem = {
id: 'legacy_' + Date.now(),
src: design.designData.imageSrc,
x: design.designData.imagePos ? design.designData.imagePos.x || 0 : 0,
y: design.designData.imagePos ? design.designData.imagePos.y || 0 : 0,
scale: design.designData.imagePos ? design.designData.imagePos.scale || 1 : 1,
src: dd.imageSrc,
x: dd.imagePos ? dd.imagePos.x || 0 : 0,
y: dd.imagePos ? dd.imagePos.y || 0 : 0,
scale: dd.imagePos ? dd.imagePos.scale || 1 : 1,
width: 200,
height: 200,
isOverlapping: false
}
setStickers([s])
}
// 问题3 修正:继续设计同样进入「设计中」状态(单向状态机,undesigned → designing
if (design.status === 'undesigned') {
updateDesign(dId, { status: 'designing' })
updateDesignApi(dId, { status: 'designing' }).catch(() => {})
}
}
return
}
@@ -113,11 +133,71 @@ export default function DIYPage() {
const found = getProductById(productId)
if (found) {
setCategory(found)
void createDraftForProduct(found, 1)
createDesignEntry(found, 1)
}
}
}, [])
// ---------- 草稿持久化(修正工作流 问题2----------
// 贴纸变化防抖 800ms:本地缓存即时同步 + 服务端静默同步;不推状态(SUBMITTED 只由显式动作触发)
const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const latestRef = useRef({ designId: '', quantity: 1, category: null as any, stickers: [] as StickerItem[] })
latestRef.current = { designId, quantity, category, stickers }
const saveDraft = () => {
const { designId: dId, quantity: qty, category: cat, stickers: st } = latestRef.current
if (!dId || !cat) return
// WCD 红线:基于缓存中服务端最新 designData 合并(保留 wordcloud 分组),只覆盖本页编辑字段
const cached = getDesignList().find(d => d.id === dId)
const designData = {
...(cached && cached.designData ? cached.designData : {}),
version: 1 as const,
category: { id: cat.id, mask: cat.mask, ...(cat.tone ? { tone: cat.tone } : {}) },
stickers: st
}
updateDesign(dId, { designData }) // 本地缓存即时同步(问题4:贴纸编辑页从这里读取)
updateDesignApi(dId, {
item: { productId: cat.id, productName: cat.name, unitPrice: cat.price, count: qty, designData }
}).catch(() => {}) // 草稿静默降级:失败仅落缓存
}
const flushDraft = () => {
if (draftTimerRef.current) {
clearTimeout(draftTimerRef.current)
draftTimerRef.current = null
}
saveDraft()
}
// 隐藏页面(切后台/跳转)时强制落盘,防止防抖窗口内的改动丢失
Taro.useDidHide(flushDraft)
// 卸载时同样落盘
useEffect(() => flushDraft, [])
// eslint-disable-next-line react-hooks/exhaustive-deps
const skipDraftRef = useRef(true)
useEffect(() => {
if (skipDraftRef.current) { skipDraftRef.current = false; return }
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
draftTimerRef.current = setTimeout(saveDraft, 800)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stickers])
// 问题5 留意点:页面栈返回(贴纸编辑页保存后 navigateBack)不触发重新挂载,
// 需在 onShow 从缓存同步最新贴纸(src/edits),否则工作台仍渲染旧 state。
// 首次 onShow 跳过——mount 读取端已做过旧数据归一化,避免被未归一化的缓存覆盖。
const skipShowRef = useRef(true)
Taro.useDidShow(() => {
if (skipShowRef.current) { skipShowRef.current = false; return }
const dId = latestRef.current.designId
if (!dId) return
const cached = getDesignList().find(d => d.id === dId)
const list = cached && cached.designData ? cached.designData.stickers : undefined
if (list && list.length) {
setStickers(checkOverlap(list))
}
})
// 矩形碰撞检测
const checkOverlap = (list: StickerItem[]) => {
const newList = list.map(s => ({ ...s, isOverlapping: false }))
@@ -156,14 +236,17 @@ export default function DIYPage() {
Taro.getImageInfo({
src,
success: (info) => {
// 问题1 修正:归一化到画布坐标空间(width/height = 画布显示像素,契约实现注记)
const maskMin = Math.min(category.mask.width, category.mask.height)
const k = (maskMin * 0.6) / Math.max(info.width, info.height)
const newSticker: StickerItem = {
id: 'stk_' + Date.now(),
src,
x: 0,
y: 0,
scale: 1,
width: info.width,
height: info.height,
width: Math.round(info.width * k),
height: Math.round(info.height * k),
isOverlapping: false
}
const next = [...stickers, newSticker]
@@ -172,15 +255,17 @@ export default function DIYPage() {
setActiveStickerId(newSticker.id)
},
fail: () => {
// fallback 尺寸
// fallback 尺寸(同样按画布空间归一化)
const maskMin = Math.min(category.mask.width, category.mask.height)
const k = (maskMin * 0.6) / 200
const newSticker: StickerItem = {
id: 'stk_' + Date.now(),
src,
x: 0,
y: 0,
scale: 1,
width: 200,
height: 200,
width: Math.round(200 * k),
height: Math.round(200 * k),
isOverlapping: false
}
const next = [...stickers, newSticker]
@@ -212,6 +297,7 @@ export default function DIYPage() {
const handleStickerTouchMove = (e: any) => {
if (!isDragging || !activeStickerId) return
const touch = e.touches[0]
// 需求决定(2026-09-12):贴纸允许拖出画布外自由摆放,不做坐标钳制
const next = stickers.map(s => {
if (s.id !== activeStickerId) return s
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
@@ -224,6 +310,7 @@ export default function DIYPage() {
const handleEditSticker = () => {
if (!activeStickerId || !designId) return
flushDraft() // 问题4:跳编辑页前清掉防抖窗口,缓存里保证有最新贴纸
Taro.navigateTo({
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
})
@@ -238,6 +325,18 @@ export default function DIYPage() {
setStickers(checked)
}
// 问题5 修正:渲染端消费贴纸 edits(亮度/色相/对比度 → CSS filter),
// 与贴纸编辑页同一呈现逻辑;checkout 预览消费 edits 记入 R3 待办
const stickerFilter = (s: StickerItem) => {
const ed = s.edits
if (!ed) return undefined
const b = ed.brightness || 0
const h = ed.hue || 0
const c = ed.contrast || 0
if (b === 0 && h === 0 && c === 0) return undefined
return `brightness(${100 + b}%) hue-rotate(${h}deg) contrast(${100 + c}%)`
}
const goBack = () => {
Taro.navigateBack()
}
@@ -255,22 +354,42 @@ export default function DIYPage() {
}
const handleComplete = async () => {
if (isCreatingDesign) {
Taro.showToast({ title: '正在创建设计,请稍候', icon: 'none' })
return
}
if (hasOverlap) {
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
return
}
if (designId) {
if (designId && category) {
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
const data = await persistDesignMedia({ stickers, category, version: 1 })
updateLocalDesign(designId, { designData: data })
// category 裁剪为 {id, mask, tone}(阶段0 决策#2;契约约束#3:mask 必须保留)
const trimmedCategory = {
id: category.id,
mask: category.mask,
...(category.tone ? { tone: category.tone } : {})
}
// WCD 红线:基于服务端已有 designData 合并(保留 R4 写入的 wordcloud 分组等),只覆盖本页编辑的字段
const cached = getDesignList().find(d => d.id === designId)
const designData = {
...(cached && cached.designData ? cached.designData : {}),
version: 1 as const,
category: trimmedCategory,
stickers: data.stickers || stickers
}
// 本地缓存同步(checkout 仍读缓存)
updateDesign(designId, { status: 'designing', designData })
try {
await updateRemoteDesign(designId, { designData: data, status: 'designing' })
await updateDesignApi(designId, {
status: 'designing',
item: {
productId: category.id,
productName: category.name,
unitPrice: category.price,
count: quantity,
designData
}
})
} catch {
// 离线草稿继续由本地存储承接;线上草稿会在下一次操作时重试同步。
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
}
}
setPreviewMode(false)
@@ -318,6 +437,7 @@ export default function DIYPage() {
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
zIndex: activeStickerId === s.id ? 10 : 5,
filter: stickerFilter(s),
width: s.width || 200,
height: s.height || 200
}}
@@ -403,6 +523,7 @@ export default function DIYPage() {
src={s.src}
style={{
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
filter: stickerFilter(s),
width: s.width || 200,
height: s.height || 200
}}
+37
View File
@@ -81,6 +81,16 @@
box-shadow: var(--shadow-card);
}
/* 预览图(Image + CSS filter,替代 Canvas 预览) */
.edit-preview-img {
width: 100%;
height: 480rpx;
background: var(--bg-input);
border-radius: 24rpx;
border: 1rpx solid var(--border-strong);
box-shadow: var(--shadow-card);
}
/* 工具面板 */
.edit-tools {
padding: 24rpx;
@@ -226,3 +236,30 @@
.mt-20 {
margin-top: 24rpx;
}
/* 空态(贴纸未找到) */
.edit-empty {
margin: 24rpx 24rpx 0;
padding: 60rpx 40rpx;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.edit-empty-title {
font-size: 32rpx;
font-weight: 600;
}
.edit-empty-desc {
font-size: 26rpx;
opacity: 0.65;
text-align: center;
}
.edit-empty .btn-primary {
padding: 16rpx 60rpx;
border-radius: 44rpx;
}
+142 -242
View File
@@ -1,10 +1,9 @@
import { View, Text, Canvas, Image } from '@tarojs/components'
import Taro, { createCanvasContext, canvasToTempFilePath } from '@tarojs/taro'
import { useState, useEffect, useRef, useCallback } from 'react'
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useRef } from 'react'
import './index.scss'
import { getProductById } from '../../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
import { sketchImage } from '../../../utils/api'
import { getDesignList, updateDesign, type StickerItem } from '../../../utils/store'
import { sketchImage, updateDesign as updateDesignApi } from '../../../utils/api'
import { useThemeContext } from '../../../context/ThemeContext'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
@@ -13,55 +12,59 @@ import ScrollTopMask from '../../../components/ScrollTopMask'
import BottomActionBar from '../../../components/BottomActionBar'
/* ============================================================
使用 Canvas 实现贴纸编辑(亮度/色相/线稿)
贴纸编辑(亮度/色相/对比度/裁剪/线稿)
问题5 修正:edits 参数化保存(src 不变),预览用 Image + CSS filter
所见即所得,不再依赖 Canvas 2D ctx.filter(开发者工具不支持);
DIY 渲染端按同一 edits 套 CSS filter 呈现(契约 §4edits 不进 WCD
document.json,仅 manifest.metasrc 保持原样对 R4 无影响)。
============================================================ */
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
/** 拖动手势滑块组件 */
/** 拖动手势滑块组件(问题5 修正:选择器查询按 id 定位,touchstart 时缓存 rect */
interface SliderProps {
id: string
value: number
min: number
max: number
step?: number
onChange: (val: number) => void
showValue?: boolean
format?: (v: number) => string
}
function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProps) {
const trackRef = useRef<any>(null)
const [dragging, setDragging] = useState(false)
function TouchSlider({ id, value, min, max, onChange, format }: SliderProps) {
const rectRef = useRef<{ left: number; width: number } | null>(null)
const computedFromClientX = (clientX: number) => {
if (!trackRef.current) return value
const query = Taro.createSelectorQuery().in(trackRef.current)
// 注意:这里需要取 track 元素的位置信息
const rect = trackRef.current.getBoundingClientRect ? trackRef.current.getBoundingClientRect() : { left: 0, width: 300 }
const measure = (cb?: () => void) => {
Taro.createSelectorQuery()
.select(`#${id}`)
.boundingClientRect((rect: any) => {
if (rect && rect.width > 0) rectRef.current = { left: rect.left, width: rect.width }
if (cb) cb()
})
.exec()
}
const valueFromX = (clientX: number) => {
const rect = rectRef.current
if (!rect || !rect.width) return value
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
let raw = min + (max - min) * ratio
if (step > 0) {
raw = Math.round(raw / step) * step
}
return clamp(raw, min, max)
return clamp(min + (max - min) * ratio, min, max)
}
const handleTouchStart = (e: any) => {
setDragging(true)
const touch = e.touches && e.touches[0]
const x = touch ? touch.clientX : 0
onChange(computedFromClientX(x))
if (!touch) return
measure(() => onChange(valueFromX(touch.clientX)))
}
const handleTouchMove = (e: any) => {
if (!dragging) return
const touch = e.touches && e.touches[0]
const x = touch ? touch.clientX : 0
onChange(computedFromClientX(x))
}
const handleTouchEnd = () => {
setDragging(false)
if (!touch) return
if (!rectRef.current) {
measure(() => onChange(valueFromX(touch.clientX)))
return
}
onChange(valueFromX(touch.clientX))
}
const pct = `${((value - min) / (max - min)) * 100}%`
@@ -69,14 +72,14 @@ function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProp
return (
<View className='slider-wrap'>
<View
id={id}
className='slider-track'
ref={trackRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchEnd={() => { /* rect 已缓存,无需处理 */ }}
>
<View className='slider-fill' style={{ width: pct }} />
<View className={`slider-thumb ${dragging ? 'dragging' : ''}`} style={{ left: pct }} />
<View className='slider-thumb' style={{ left: pct }} />
</View>
<Text className='slider-value'>{format ? format(value) : value}</Text>
</View>
@@ -90,50 +93,15 @@ export default function StickerEditPage() {
const [designId, setDesignId] = useState('')
const [stickerId, setStickerId] = useState('')
const [sticker, setSticker] = useState<StickerItem | null>(null)
const [notFound, setNotFound] = useState(false)
// 裁剪/线稿产生的 src 替换(保存时一并落库;取消则丢弃)
const [pendingSrc, setPendingSrc] = useState<string | null>(null)
// 编辑参数
const [brightness, setBrightness] = useState(0)
const [hue, setHue] = useState(0)
const [contrast, setContrast] = useState(0)
const [loading, setLoading] = useState(false)
const [canvasReady, setCanvasReady] = useState(false)
const canvasRef = useRef<any>(null)
const ctxRef = useRef<any>(null)
const canvasNodeRef = useRef<any>(null)
const canvasSizeRef = useRef<{width:number, height:number}>({width:0,height:0})
// 使用 nextTick + 重试初始化 Canvas(小程序 2D)
const initCanvas = useCallback((st: StickerItem) => {
if (canvasReady) return
Taro.nextTick(() => {
const query = Taro.createSelectorQuery()
query.select('#editCanvas')
.fields({ node: true, size: true })
.exec((res: any) => {
const resFirst = res && res[0]
const canvas = resFirst ? resFirst.node : undefined
if (!canvas) {
setTimeout(() => initCanvas(st), 300)
return
}
const ctx = canvas.getContext('2d')
let cw = (res[0] && res[0].width) || 300
let ch = (res[0] && res[0].height) || 400
if (cw <= 0) cw = 300
if (ch <= 0) ch = 400
const dpr = Taro.getSystemInfoSync().pixelRatio || 1
canvas.width = Math.round(cw * dpr)
canvas.height = Math.round(ch * dpr)
ctx.scale(dpr, dpr)
canvasNodeRef.current = canvas
ctxRef.current = ctx
canvasSizeRef.current = { width: cw, height: ch }
setCanvasReady(true)
drawSticker(ctx, cw, ch, st)
})
})
}, [canvasReady])
useEffect(() => {
const router = Taro.getCurrentInstance().router
@@ -151,96 +119,44 @@ export default function StickerEditPage() {
setBrightness((st.edits && st.edits.brightness) || 0)
setHue((st.edits && st.edits.hue) || 0)
setContrast((st.edits && st.edits.contrast) || 0)
initCanvas(st)
} else {
// 问题4 留意点:找不到贴纸时给出空态,而不是停在空白 Canvas
setNotFound(true)
}
}, [initCanvas])
/** canvas 上绘制带滤镜的贴纸 */
const drawSticker = (ctx: any, cw: number, ch: number, st: StickerItem, opts?: { brightness: number; hue: number; contrast: number }) => {
if (!ctx || !canvasNodeRef.current) return
const canvas = canvasNodeRef.current
// 防护:cw/ch 为 0 时容易绘制异常
if (!cw || !ch) return
const imgW = st.width || 200
const imgH = st.height || 200
const img = canvas.createImage()
img.onload = () => {
ctx.clearRect(0, 0, cw, ch)
ctx.save()
// 居中缩放显示(限制最大为画板 90%)
const scale = Math.min(cw / imgW, ch / imgH, 4) * 0.9
const w = imgW * scale
const h = imgH * scale
const x = (cw - w) / 2
const y = (ch - h) / 2
// 应用滤镜(小程序 Canvas 2D 支持 filter 属性 2.16.0+
const b = opts ? opts.brightness : brightness
const hVal = opts ? opts.hue : hue
const c = opts ? opts.contrast : contrast
if (b !== 0 || hVal !== 0 || c !== 0) {
ctx.filter = `brightness(${100 + b}%) hue-rotate(${hVal}deg) contrast(${100 + c}%)`
} else {
ctx.filter = 'none'
}
ctx.drawImage(img, x, y, w, h)
ctx.restore()
}
img.onerror = (err: any) => {
console.error('Canvas load image error:', err, st.src)
Taro.showToast({ title: '图片加载失败', icon: 'none' })
}
if (st.src) {
img.src = st.src
}
}
/** 重新绘制(参数变化时) */
const redraw = () => {
if (!sticker || !ctxRef.current || !canvasNodeRef.current) return
const { width, height } = canvasSizeRef.current
drawSticker(ctxRef.current, width, height, sticker, { brightness, hue, contrast })
}
useEffect(() => {
redraw()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [brightness, hue, contrast])
}, [])
/** 保存:edits 参数化持久化(src 不变),裁剪/线稿的 src 替换一并落库 */
const handleSave = () => {
if (!sticker || !designId) return
setLoading(true)
// 使用 Canvas 2D 导出
// @ts-ignore
Taro.canvasToTempFilePath({
canvas: canvasNodeRef.current,
quality: 0.92,
success: (res: any) => {
const newSrc = res.tempFilePath
const dList = getDesignList()
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx === -1) { setLoading(false); return }
const stickers = dList[dIdx].designData ? dList[dIdx].designData.stickers || [] : []
const sIdx = stickers.findIndex(s => s.id === stickerId)
if (sIdx === -1) { setLoading(false); return }
stickers[sIdx] = {
...stickers[sIdx],
src: newSrc,
edits: { brightness, hue, contrast }
}
updateDesign(designId, { designData: { ...dList[dIdx].designData, stickers } })
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
},
fail: () => {
setLoading(false)
Taro.showToast({ title: '保存失败', icon: 'none' })
const dList = getDesignList()
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx === -1) { setLoading(false); setNotFound(true); return }
const d = dList[dIdx]
const stickers = (d.designData && d.designData.stickers) || []
const sIdx = stickers.findIndex(s => s.id === stickerId)
if (sIdx === -1) { setLoading(false); setNotFound(true); return }
stickers[sIdx] = {
...stickers[sIdx],
...(pendingSrc ? { src: pendingSrc } : {}),
edits: { brightness, hue, contrast }
}
// WCD 红线:基于服务端已有 designData 合并(保留 wordcloud 分组),只覆盖 stickers
const designData = { ...d.designData, stickers }
updateDesign(designId, { designData }) // 本地缓存同步
// 服务端保存(items 全量替换,必须带全 4 个基本字段);失败暂存本地
updateDesignApi(designId, {
item: {
productId: d.productId,
productName: d.productName,
unitPrice: d.unitPrice,
count: d.count,
designData
}
})
}).catch(() => Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' }))
setLoading(false)
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
}
/** 裁剪:调用微信cropImage */
@@ -258,46 +174,61 @@ export default function StickerEditPage() {
success: (res) => {
const newSticker = { ...sticker, src: res.tempFilePath }
setSticker(newSticker)
// 重绘
setTimeout(() => redraw(), 200)
setPendingSrc(res.tempFilePath)
}
})
}
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度参数 */
const handleSketch = async () => {
if (!sticker) return
setLoading(true)
try {
const res = await sketchImage(sticker.src)
if (res?.imageUrl) {
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } }
setSticker(newSticker)
setSticker({ ...sticker, src: res.imageUrl })
setPendingSrc(res.imageUrl)
setLoading(false)
setTimeout(() => redraw(), 200)
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
} else {
throw new Error('no imageUrl')
}
} catch {
// 如果接口不可用/失败,降级为前端灰度+边缘检测
applyFrontendSketch()
// 如果接口不可用/失败,降级为前端灰度滤镜参数(保存时随 edits 持久化)
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
}
}
/** 前端线稿降级方案:灰度+反相高对比 */
const applyFrontendSketch = () => {
if (!sticker || !ctxRef.current) { setLoading(false); return }
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
}
const previewFilter = brightness === 0 && hue === 0 && contrast === 0
? 'none'
: `brightness(${100 + brightness}%) hue-rotate(${hue}deg) contrast(${100 + contrast}%)`
const bPct = `${((brightness + 100) / 200) * 100}%`
const hPct = `${((hue + 180) / 360) * 100}%`
const cPct = `${((contrast + 100) / 200) * 100}%`
if (notFound) {
return (
<View className={`theme-${resolvedTheme}`}>
<ThemedPageMeta />
<ScrollTopMask title="编辑贴纸" targetSelector=".edit-header" showBack />
<View className='sticker-edit-page'>
<View className='edit-header' style={{ paddingTop: `${safe.statusBarHeight + 12}px` }}>
<Text className='edit-back' onTap={() => Taro.navigateBack()}></Text>
<Text className='edit-title'></Text>
<Text className='edit-save' />
</View>
<View className='edit-empty surface-card mt-20'>
<Text className='edit-empty-title'></Text>
<Text className='edit-empty-desc'></Text>
<View className='btn-primary' onTap={() => Taro.navigateBack()}>
<Text></Text>
</View>
</View>
</View>
</View>
)
}
return (
<View className={`theme-${resolvedTheme}`}>
@@ -311,13 +242,13 @@ export default function StickerEditPage() {
<Text className='edit-save' onTap={handleSave}></Text>
</View>
{/* Canvas 预览 */}
{/* 预览Image + CSS filter 所见即所得(不依赖 Canvas ctx.filter */}
<View className='edit-canvas-wrap'>
<Canvas
id='editCanvas'
type='2d'
className='edit-canvas'
style={{ width: '100%', height: '480rpx' }}
<Image
className='edit-preview-img'
src={sticker ? sticker.src : ''}
mode='aspectFit'
style={{ filter: previewFilter }}
/>
</View>
@@ -336,73 +267,42 @@ export default function StickerEditPage() {
</View>
{/* 亮度 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
// 取得滑轨位置
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-100 + 200 * ratio) / 1) * 1
setBrightness(clamp(v, -100, 100))
}).exec()
}}
>
<View className='edit-row slider-row'>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: bPct }} />
<View className='slider-thumb' style={{ left: bPct }} />
</View>
<Text className='slider-value'>{brightness > 0 ? `+${brightness}` : brightness}</Text>
</View>
<TouchSlider
id='slider-brightness'
value={brightness}
min={-100}
max={100}
onChange={setBrightness}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
{/* 色相 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-180 + 360 * ratio) / 1) * 1
setHue(clamp(v, -180, 180))
}).exec()
}}
>
<View className='edit-row slider-row'>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: hPct }} />
<View className='slider-thumb' style={{ left: hPct }} />
</View>
<Text className='slider-value'>{hue > 0 ? `+${hue}` : hue}</Text>
</View>
<TouchSlider
id='slider-hue'
value={hue}
min={-180}
max={180}
onChange={setHue}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
{/* 对比度 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-100 + 200 * ratio) / 1) * 1
setContrast(clamp(v, -100, 100))
}).exec()
}}
>
<View className='edit-row slider-row'>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: cPct }} />
<View className='slider-thumb' style={{ left: cPct }} />
</View>
<Text className='slider-value'>{contrast > 0 ? `+${contrast}` : contrast}</Text>
</View>
<TouchSlider
id='slider-contrast'
value={contrast}
min={-100}
max={100}
onChange={setContrast}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
</View>
+109
View File
@@ -47,6 +47,7 @@
.search-input {
flex: 1;
min-width: 0;
height: 88rpx;
font-size: 28rpx;
color: var(--text-primary);
@@ -75,6 +76,114 @@
display: block;
}
.search-clear,
.search-reset {
margin: 0;
padding: 0 16rpx;
min-width: 80rpx;
font-size: 26rpx;
line-height: 72rpx;
color: var(--accent-primary);
background: transparent;
&::after { border: none; }
}
.search-clear { flex-shrink: 0; }
.search-reset {
margin-top: 24rpx;
border-radius: 999rpx;
background: var(--bg-card);
}
.section-heading {
margin-bottom: 24rpx;
.home-section-title { margin: 0; }
}
.section-subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: var(--text-secondary);
}
.category-section {
margin-top: 32rpx;
padding: 28rpx 24rpx;
border-radius: 24rpx;
background: var(--bg-card);
border: 1rpx solid var(--border);
}
.category-rail {
width: calc(100% + 48rpx);
margin: 0 -24rpx -28rpx;
white-space: nowrap;
}
.category-tile {
position: relative;
display: inline-flex;
width: 252rpx;
height: 168rpx;
margin: 0 0 28rpx 24rpx;
overflow: hidden;
border-radius: 20rpx;
vertical-align: top;
background: var(--bg-muted);
box-shadow: var(--shadow-card);
}
.category-tile:last-child { margin-right: 24rpx; }
.category-tile-image {
width: 100%;
height: 100%;
}
.category-tile-shade {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 84rpx;
padding: 14rpx 16rpx;
box-sizing: border-box;
background: linear-gradient(transparent, rgba(20, 18, 16, 0.78));
}
.category-tile-name {
overflow: hidden;
color: #fff;
font-size: 27rpx;
font-weight: 700;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-tile-action {
margin-top: 5rpx;
color: rgba(255, 255, 255, 0.8);
font-size: 20rpx;
line-height: 1.2;
}
.recommend-section {
margin-top: 40rpx;
padding-top: 32rpx;
border-top: 2rpx solid var(--border);
}
.search-results-section { margin-top: 32rpx; }
/* 区块标题 */
.home-section-title {
display: block;
+50 -54
View File
@@ -1,4 +1,4 @@
import { View, Text, Image, Input, Button, Swiper, SwiperItem } from '@tarojs/components'
import { View, Text, Image, Input, Button, Swiper, SwiperItem, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import './index.scss'
@@ -70,7 +70,7 @@ const PRODUCT_IMG_MAP: Record<string, string> = {
}
export default function Index() {
const { theme, resolvedTheme } = useThemeContext()
const { resolvedTheme } = useThemeContext()
const safe = useSafeArea()
useStatusBar(resolvedTheme)
const [searchKey, setSearchKey] = useState('')
@@ -81,20 +81,11 @@ export default function Index() {
Taro.navigateTo({ url: `/pages/product/index?id=${categoryId}` })
}
const navigateFromShowcase = (item: { categoryId?: string; title: string }) => {
if (item.categoryId) {
navigateToProduct(item.categoryId)
return
}
Taro.showToast({
title: `${item.title}”定制通道即将开放`,
icon: 'none'
})
}
const filteredCategories = searchKey.trim()
const keyword = searchKey.trim().toLocaleLowerCase()
const isSearching = keyword.length > 0
const filteredCategories = isSearching
? products.filter(
(c) => c.name.includes(searchKey) || c.desc.includes(searchKey)
(c) => [c.name, c.desc, c.description].some(value => value?.toLocaleLowerCase().includes(keyword))
)
: products
@@ -125,19 +116,22 @@ export default function Index() {
onInput={handleSearch}
confirmType='search'
/>
{searchKey.length > 0 && (
<Button className='search-clear' onClick={() => setSearchKey('')}></Button>
)}
</View>
{searchKey.trim() && (
{isSearching && (
<View className='search-result-hint'>
<Text className='hint-text'>
{filteredCategories.length > 0
? `找到 ${filteredCategories.length} 相关品`
: '没有找到相关品,试试看其他关键词'}
? `找到 ${filteredCategories.length} 相关`
: '没有找到相关品,试试看其他关键词'}
</Text>
</View>
)}
{/* 定制成品展示 */}
<View className='showcase-section'>
{!isSearching && <View className='showcase-section'>
<Text className='home-section-title'></Text>
<Swiper
className='showcase-swiper'
@@ -164,11 +158,40 @@ export default function Index() {
</SwiperItem>
))}
</Swiper>
</View>
</View>}
{/* 热门品类 */}
<View className='category-section'>
<Text className='home-section-title'></Text>
{/* 品类是快捷入口,与下方商品推荐采用不同布局。 */}
{!isSearching && (
<View className='category-section'>
<View className='section-heading'>
<Text className='home-section-title'></Text>
<Text className='section-subtitle'></Text>
</View>
<ScrollView className='category-rail' scrollX enhanced showScrollbar={false}>
{products.map(cat => (
<View className='category-tile' key={cat.id} onTap={() => navigateToProduct(cat.id)}>
<ProductImage
className='category-tile-image'
src={cat.images[0] || PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
fallback={getProductIconImg(cat)}
mode='aspectFill'
/>
<View className='category-tile-shade'>
<Text className='category-tile-name'>{cat.name}</Text>
<Text className='category-tile-action'></Text>
</View>
</View>
))}
</ScrollView>
</View>
)}
{/* 搜索与推荐互斥,共用后端商品卡片。 */}
<View className={isSearching ? 'search-results-section' : 'recommend-section'}>
<View className='section-heading'>
<Text className='home-section-title'>{isSearching ? '搜索结果' : '为你推荐'}</Text>
{!isSearching && <Text className='section-subtitle'></Text>}
</View>
<View className='home-grid'>
{filteredCategories.map((cat) => (
<View
@@ -179,7 +202,7 @@ export default function Index() {
<View className='media-card-image'>
<ProductImage
className='media-card-img'
src={PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
src={cat.images[0] || PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
fallback={getProductIconImg(cat)}
mode='aspectFill'
/>
@@ -195,40 +218,13 @@ export default function Index() {
{filteredCategories.length === 0 && (
<View className='empty-category'>
<Image className='empty-icon-img' src={assetUrl('/icon/搜索.svg')} mode='aspectFit' />
<Text className='empty-text'></Text>
<Text className='empty-sub'></Text>
<Text className='empty-text'>{isSearching ? '未找到匹配的商品' : '暂无在售商品'}</Text>
{isSearching && <Text className='empty-sub'></Text>}
{isSearching && <Button className='search-reset' onClick={() => setSearchKey('')}></Button>}
</View>
)}
</View>
{/* 为你推荐 */}
<View className='recommend-section'>
<Text className='home-section-title'></Text>
<View className='home-grid'>
{SHOWCASE_LIST.slice(0, 4).map((item, idx) => (
<View
key={idx}
className='media-card'
onTap={() => navigateFromShowcase(item)}
>
<View className='media-card-image'>
<ProductImage
className='media-card-img'
src={item.image}
fallback={getProductIconImg(CATEGORIES.find(category => category.id === item.categoryId))}
mode='aspectFill'
/>
</View>
<View className='media-card-body'>
<Text className='media-card-title'>{item.title}</Text>
<Text className='media-card-desc'>{item.desc}</Text>
<Text className='media-card-price'>{item.priceText}</Text>
</View>
</View>
))}
</View>
</View>
</View>
</View>
)
+3 -2
View File
@@ -124,11 +124,12 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.orders-page .order-icon-img {
width: 56rpx;
height: 56rpx;
width: 100%;
height: 100%;
}
.orders-page .order-info {
+4 -3
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
import './index.scss'
import { getOrderList, type OrderItem } from '../../utils/store'
import { fetchOrders, payOrder, cancelOrder, confirmOrder, type ServerOrder } from '../../utils/api/order'
import { getProductIconImg } from '../../utils/productConfig'
import { getProductById, getProductIconImg } from '../../utils/productConfig'
import { assetUrl } from '../../utils/asset'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
@@ -42,7 +42,8 @@ export default function OrdersPage() {
const mapOrder = (order: ServerOrder): OrderItem => {
const first = order.items[0]
const statusCode = order.status === 'PENDING' ? 'pending' : order.status === 'PAID' || order.status === 'PROCESSING' ? 'paid' : order.status === 'SHIPPED' ? 'shipping' : order.status === 'COMPLETED' ? 'done' : order.status === 'PAYMENT_EXPIRED' ? 'expired' : 'cancelled'
return { id: order.id, productName: first?.name || '定制商品', productIcon: getProductIconImg({ id: first?.productId }), statusCode, date: new Date(order.createdAt).toLocaleString('zh-CN'), price: `¥${Number(order.totalAmount).toFixed(2)}`, count: first?.quantity || 0, sku: order.orderNo, paymentExpiresAt: order.paymentExpiresAt }
const productPhoto = getProductById(first?.productId || '')?.images?.[0]
return { id: order.id, productName: first?.name || '定制商品', productIcon: productPhoto || getProductIconImg({ id: first?.productId }), statusCode, date: new Date(order.createdAt).toLocaleString('zh-CN'), price: `¥${Number(order.totalAmount).toFixed(2)}`, count: first?.quantity || 0, sku: order.orderNo, paymentExpiresAt: order.paymentExpiresAt }
}
const load = async () => {
try { const remote = await fetchOrders({ page: 1, pageSize: 100 }); setOrders(remote.list.map(mapOrder)) } catch { setOrders(getOrderList()) }
@@ -149,7 +150,7 @@ export default function OrdersPage() {
</View>
<View className='order-body'>
<View className='order-icon-wrapper'>
<Image className='order-icon-img' src={iconSrc} mode='aspectFit' />
<Image className='order-icon-img' src={iconSrc} mode='aspectFill' />
</View>
<View className='order-info'>
<Text className='order-product'>{order.productName}</Text>
+3 -2
View File
@@ -219,15 +219,16 @@ export default function ProfilePage() {
}
const handleQuickClick = (type: string) => {
Taro.hideToast()
switch (type) {
case 'toDesign':
Taro.setStorageSync('designList:filter', 'toDesign')
Taro.setStorageSync('designList:filter', 'undesigned')
Taro.switchTab({ url: '/pages/designList/index' })
Taro.eventCenter.trigger('tabBarChange', '/pages/designList/index')
break
case 'pending':
Taro.setStorageSync('orders:filter', 'pending')
Taro.switchTab({ url: '/pages/orders/index' })
Taro.eventCenter.trigger('tabBarChange', '/pages/orders/index')
break
case 'paid':
Taro.setStorageSync('orders:filter', 'paid')
+2 -1
View File
@@ -282,7 +282,7 @@
position: fixed;
top: calc(env(safe-area-inset-top, 20px) + 20rpx);
left: 30rpx;
z-index: 50;
z-index: 1000;
width: 80rpx;
height: 80rpx;
border-radius: 50%;
@@ -294,6 +294,7 @@
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
transition: opacity 0.2s ease, transform 0.15s ease;
}
+79 -77
View File
@@ -1,6 +1,6 @@
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import './index.scss'
import { PRODUCTS } from '../../../utils/productConfig'
import { fetchProduct } from '../../../utils/api/product'
@@ -9,7 +9,6 @@ import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
import { useThemeContext } from '../../../context/ThemeContext'
import ThemedPageMeta from '../../../components/ThemedPageMeta'
import ScrollTopMask from '../../../components/ScrollTopMask'
import BottomActionBar from '../../../components/BottomActionBar'
import ProductImage from '../../../components/ProductImage'
@@ -21,6 +20,8 @@ export default function ShopDetailPage() {
const { resolvedTheme } = useThemeContext()
const [winHeight, setWinHeight] = useState(667)
const [progress, setProgress] = useState(0)
const scrollProgressRef = useRef(0)
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const safe = useSafeArea()
// 顶部为沉浸式商品图,状态栏始终使用白字。
useStatusBar(resolvedTheme, { mode: 'light' })
@@ -28,12 +29,29 @@ export default function ShopDetailPage() {
const routerParams = Taro.getCurrentInstance().router
const routerParamsValues = routerParams ? routerParams.params : undefined
const productId = routerParamsValues ? routerParamsValues.id : ''
const [product, setProduct] = useState<ProductCategory | undefined>(PRODUCTS.find(p => p.id === productId))
const localProduct = useMemo(() => PRODUCTS.find(p => p.id === productId), [productId])
const [product, setProduct] = useState<ProductCategory | undefined>(localProduct)
useEffect(() => {
if (!productId) return
fetchProduct(productId).then(setProduct).catch(() => { /* 保留本地兜底 */ })
}, [productId])
fetchProduct(productId).then(remote => {
// 服务端优先,但介绍文案、规格和图片缺失时保留本地完整资料。
if (!localProduct) {
setProduct(remote)
return
}
setProduct({
...localProduct,
...remote,
description: remote.description || localProduct.description,
story: remote.story || localProduct.story,
scene: remote.scene || localProduct.scene,
tags: remote.tags?.length ? remote.tags : localProduct.tags,
specs: remote.specs?.length ? remote.specs : localProduct.specs,
images: remote.images?.length ? remote.images : localProduct.images,
})
}).catch(() => setProduct(localProduct))
}, [localProduct, productId])
useEffect(() => {
try {
@@ -44,19 +62,25 @@ export default function ShopDetailPage() {
}
}, [])
// 恢复原有的沉浸式收束动画;每帧只合并一次滚动状态,避免连续 setState 堆积。
const handleScroll = useCallback((e: any) => {
const st = (e.detail && e.detail.scrollTop) || 0
const total = winHeight * 1.2
const p = clamp(st / total, 0, 1)
setProgress(p)
const scrollTop = (e.detail && e.detail.scrollTop) || 0
scrollProgressRef.current = clamp(scrollTop / (winHeight * 1.2), 0, 1)
if (scrollTimerRef.current !== null) return
scrollTimerRef.current = setTimeout(() => {
scrollTimerRef.current = null
setProgress(current => Math.abs(current - scrollProgressRef.current) < 0.002 ? current : scrollProgressRef.current)
}, 16)
}, [winHeight])
/* Scroll-progress → multi-stage hero & content animations */
useEffect(() => () => {
if (scrollTimerRef.current !== null) clearTimeout(scrollTimerRef.current)
}, [])
const heroStyles = useMemo(() => {
const p = progress
// 1. Image crop insets (vh %)
let topCrop = 0, bottomCrop = 0
let topCrop = 0
let bottomCrop = 0
if (p <= 0.4) {
const t = easeOut(p / 0.4)
topCrop = lerp(0, 32, t)
@@ -66,60 +90,36 @@ export default function ShopDetailPage() {
topCrop = lerp(32, 34, t)
bottomCrop = lerp(38, 41, t)
} else {
topCrop = 34; bottomCrop = 41
topCrop = 34
bottomCrop = 41
}
// 2. Hero translateY
let translateY = 0
if (p <= 0.4) translateY = 0
else if (p <= 0.7) translateY = lerp(0, -75, (p - 0.4) / 0.3)
else translateY = -75
// 3. Hero bottom info opacity (always visible on open, fades after scroll)
let heroInfoOpacity = 1
if (p <= 0.35) heroInfoOpacity = 1
else if (p <= 0.48) heroInfoOpacity = clamp(1 - (p - 0.35) / 0.13, 0, 1)
else heroInfoOpacity = 0
// 4. Transition band opacity
let bandOpacity = 0
if (p <= 0.55) bandOpacity = 0
else if (p <= 0.7) bandOpacity = (p - 0.55) / 0.15
else bandOpacity = 1
// 5. Content reveal
let contentOpacity = 1, contentOffset = 0
if (p <= 0.05) { contentOpacity = 0; contentOffset = 60 }
else if (p <= 0.22) {
const t = (p - 0.05) / 0.17
contentOpacity = easeOut(t)
contentOffset = lerp(60, 0, easeOut(t))
}
// 6. Back button (fade out as hero scrolls)
let backBtnOpacity = 1
if (p <= 0.15) backBtnOpacity = clamp(1 - p / 0.15, 0, 1)
return {
topCrop,
bottomCrop,
translateY,
heroInfoOpacity,
bandOpacity,
contentOpacity,
contentOffset,
backBtnOpacity,
visibleHeight: 100 - topCrop - bottomCrop
}
const translateY = p <= 0.4 ? 0 : p <= 0.7 ? lerp(0, -75, (p - 0.4) / 0.3) : -75
const heroInfoOpacity = p <= 0.35 ? 1 : p <= 0.48 ? clamp(1 - (p - 0.35) / 0.13, 0, 1) : 0
const bandOpacity = p <= 0.55 ? 0 : p <= 0.7 ? (p - 0.55) / 0.15 : 1
const contentOpacity = p <= 0.05 ? 0 : p <= 0.22 ? easeOut((p - 0.05) / 0.17) : 1
const contentOffset = p <= 0.05 ? 60 : p <= 0.22 ? lerp(60, 0, easeOut((p - 0.05) / 0.17)) : 0
return { topCrop, bottomCrop, translateY, heroInfoOpacity, bandOpacity, contentOpacity, contentOffset, visibleHeight: 100 - topCrop - bottomCrop }
}, [progress])
const tone = useMemo(() => (product ? product.tone : [28, 22, 18]), [product])
const productDescription = product?.description || product?.subtitle || product?.desc || ''
const leavingRef = useRef(false)
const returnToShop = () => {
// 详情页的稳定出口是商品 Tab,避免开发者工具直达页面或异常页面栈导致返回失效。
if (leavingRef.current) return
leavingRef.current = true
const target = { url: '/pages/shop/index' }
Taro.switchTab(target).catch(() => Taro.reLaunch(target)).catch(() => {
leavingRef.current = false
})
}
if (!product) {
return (
<View className="shop-detail-page theme-dark">
<ThemedPageMeta darkTop />
<ScrollTopMask title="商品详情" targetSelector=".shop-back-btn" showBack />
<Text style={{ color: '#fff', padding: '40rpx' }}></Text>
</View>
)
@@ -128,7 +128,6 @@ export default function ShopDetailPage() {
return (
<View className="shop-detail-page theme-dark">
<ThemedPageMeta darkTop />
<ScrollTopMask title="商品详情" targetSelector=".shop-back-btn" showBack />
{/* Fixed Hero */}
<View className="shop-hero-fixed">
<View
@@ -162,10 +161,7 @@ export default function ShopDetailPage() {
}}
/>
<View
className="shop-hero-info"
style={{ opacity: heroStyles.heroInfoOpacity }}
>
<View className="shop-hero-info" style={{ opacity: heroStyles.heroInfoOpacity }}>
<Text className="shop-hero-title">{product.name}</Text>
<Text className="shop-hero-subtitle">{product.subtitle || product.desc}</Text>
<View className="shop-hero-price-row">
@@ -193,7 +189,7 @@ export default function ShopDetailPage() {
className="shop-content"
style={{ backgroundColor: `rgb(${tone[0]}, ${tone[1]}, ${tone[2]})` }}
>
{/* Transition band — appears as hero scrolls away */}
{/* 原有的主图收束后,浮动信息条自然接替标题和价格。 */}
<View
className="shop-band"
style={{
@@ -217,20 +213,25 @@ export default function ShopDetailPage() {
{/* Main copy blocks */}
<View
className="shop-detail-body"
style={{
opacity: heroStyles.contentOpacity,
transform: `translateY(${heroStyles.contentOffset}px)`
}}
style={{ opacity: heroStyles.contentOpacity, transform: `translateY(${heroStyles.contentOffset}px)` }}
>
<View className="shop-content-card">
<View className="shop-section">
<Text className="shop-section-title"></Text>
<Text className="shop-section-text">{productDescription}</Text>
</View>
{product.story && (
<View className="shop-section">
<Text className="shop-section-title"></Text>
<Text className="shop-section-text">{product.story}</Text>
</View>
<>
<View className="shop-divider" />
<View className="shop-section">
<Text className="shop-section-title"></Text>
<Text className="shop-section-text">{product.story}</Text>
</View>
</>
)}
{(product.story && product.scene) && (
{product.scene && (
<View className="shop-divider" />
)}
@@ -253,11 +254,11 @@ export default function ShopDetailPage() {
</View>
)}
{((product.story || product.scene) && product.tags && product.tags.length > 0) && (
{product.tags && product.tags.length > 0 && (
<View className="shop-divider" />
)}
{((product.story || product.scene || (product.tags && product.tags.length > 0)) && product.specs && product.specs.length > 0) && (
{product.specs && product.specs.length > 0 && (
<View className="shop-divider" />
)}
@@ -284,8 +285,9 @@ export default function ShopDetailPage() {
{/* Back button — glass morphism circle */}
<View
className="shop-back-btn"
onTap={() => Taro.navigateBack()}
style={{ top: `${safe.menuButtonTop}px`, opacity: heroStyles.backBtnOpacity }}
onTap={returnToShop}
onClick={returnToShop}
style={{ top: `${safe.menuButtonTop}px` }}
>
<Text className="shop-back-arrow"></Text>
</View>
@@ -299,7 +301,7 @@ export default function ShopDetailPage() {
<Text className="summary-original">¥{product.originalPrice}</Text>
)}
</View>
<View className="btn-secondary" onTap={() => Taro.navigateBack()}>
<View className="btn-secondary" onTap={returnToShop} onClick={returnToShop}>
<Text></Text>
</View>
<View className="btn-primary" onTap={() => Taro.navigateTo({ url: `/pages/product/index?id=${product.id}` })}>
+20 -5
View File
@@ -81,8 +81,15 @@ export interface StickerItem {
export interface DesignDataV1 {
/** 结构版本;旧数据缺省视为 v1 */
version?: 1
/** 商品品类:mask 是画布尺寸来源,说明见 docs/mask-config-guide.md */
category?: ProductCategory
/**
* 商品品类(契约 §2 冻结:仅 {id, mask, tone}DIY 保存时裁剪);
* mask 是画布尺寸来源,说明见 docs/mask-config-guide.md
*/
category?: {
id: string
mask: MaskConfig
tone?: [number, number, number]
}
/** 底图(持久 URL),WCD 打包的画布底 */
background?: {
src: string
@@ -146,17 +153,23 @@ export interface WordCloudDispatchResult {
message?: string
}
/** 设计清单条目 */
/** 设计清单条目(与后端 DesignList 一一对应,api-contract-v1 §5 */
export interface DesignItem {
id: string
productId: string
productName: string
productIcon: string
/**
* 条目图标。服务端不存储(契约 forbidNonWhitelisted 拒绝多余字段),
* 页面按 PRODUCT_ICON_MAP[productId] 兜底推导;仅本地缓存/旧数据可能携带
*/
productIcon?: string
unitPrice: number
count: number
/** undesigned/designing 由前端驱动;processing/ordered 由服务端状态映射产生(R3 驱动) */
status: 'undesigned' | 'designing' | 'processing' | 'ordered'
designData?: DesignDataV1
orderId?: string
/** 服务端 createdAtISO),展示时取日期部分 */
createdAt: string
}
@@ -173,7 +186,7 @@ export interface OrderItem {
paymentExpiresAt?: string | null
}
/** 收货地址 */
/** 收货地址(后端 province/city/district/detail 以 region 数组表达,转换只在 api/address.ts */
export interface AddressItem {
id: string
name: string
@@ -181,6 +194,8 @@ export interface AddressItem {
region: string[] // [province, city, district]
detail: string // 门牌号/详细地址
isDefault: boolean
/** 服务端创建时间(ISO 8601),本地缓存数据可能没有 */
createdAt?: string
}
/** 编辑器中的图片状态(向后兼容) */
+74 -24
View File
@@ -1,43 +1,93 @@
/**
* R2 收货地址接口(api-contract-v1 §4)。
*
* 唯一负责 region: [province, city, district] ↔ 后端 province/city/district
* 双向转换的文件;页面与 store 不得出现第二次转换(route-r2 §3)。
*/
import http from '../request'
import type { AddressItem } from '../../types'
type AddressPayload = Omit<AddressItem, 'id' | 'region'> & { region: string[] }
type ServerAddress = { id: string; name: string; phone: string; province: string; city: string; district: string; detail: string; isDefault: boolean }
/** 后端 Address 结构(契约 §4:四个独立地址字段) */
interface ServerAddress {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
createdAt?: string
updatedAt?: string
}
const toClient = (address: ServerAddress): AddressItem => ({
id: address.id,
name: address.name,
phone: address.phone,
region: [address.province, address.city, address.district],
detail: address.detail,
isDefault: address.isDefault,
})
/** ServerAddress → 前端 AddressItem(合并省市区为 region 数组) */
function toAddressItem(rec: ServerAddress): AddressItem {
return {
id: rec.id,
name: rec.name,
phone: rec.phone,
region: [rec.province, rec.city, rec.district],
detail: rec.detail,
isDefault: rec.isDefault,
createdAt: rec.createdAt,
}
}
const toServer = (address: Partial<AddressPayload>) => ({
...(address.name === undefined ? {} : { name: address.name }),
...(address.phone === undefined ? {} : { phone: address.phone }),
...(address.region ? { province: address.region[0] || '', city: address.region[1] || '', district: address.region[2] || '' } : {}),
...(address.detail === undefined ? {} : { detail: address.detail }),
...(address.isDefault === undefined ? {} : { isDefault: address.isDefault }),
})
/** 前端 region 数组 → 后端四个独立字段 */
function toServerFields(addr: {
name?: string
phone?: string
region?: string[]
detail?: string
isDefault?: boolean
}): Record<string, unknown> {
// 后端 ValidationPipe forbidNonWhitelistedundefined 字段会被 JSON 序列化丢弃,
// 这里显式展开保证只传后端声明的字段
const body: Record<string, unknown> = {}
if (addr.name !== undefined) body.name = addr.name
if (addr.phone !== undefined) body.phone = addr.phone
if (addr.region !== undefined) {
const [province = '', city = '', district = ''] = addr.region
body.province = province
body.city = city
body.district = district
}
if (addr.detail !== undefined) body.detail = addr.detail
if (addr.isDefault !== undefined) body.isDefault = addr.isDefault
return body
}
/** 我的收货地址列表(默认地址在前) */
export async function fetchAddresses(): Promise<AddressItem[]> {
const rows = await http.get<ServerAddress[]>('/api/addresses')
return rows.map(toClient)
const list = await http.get<ServerAddress[]>('/api/addresses')
return (list || []).map(toAddressItem)
}
export async function createAddress(address: Omit<AddressItem, 'id'>): Promise<AddressItem> {
return toClient(await http.post<ServerAddress>('/api/addresses', toServer(address)))
/** 新增收货地址(首个地址后端自动设为默认) */
export async function createAddress(
addr: Omit<AddressItem, 'id' | 'createdAt'>,
): Promise<AddressItem> {
const rec = await http.post<ServerAddress>('/api/addresses', toServerFields(addr))
return toAddressItem(rec)
}
export async function updateAddress(id: string, patch: Partial<AddressItem>): Promise<AddressItem> {
return toClient(await http.patch<ServerAddress>(`/api/addresses/${id}`, toServer(patch)))
/** 更新本人地址(只传显式给出的字段;isDefault=true 由后端事务保证唯一默认) */
export async function updateAddress(
id: string,
patch: Partial<Omit<AddressItem, 'id' | 'createdAt'>>,
): Promise<AddressItem> {
const rec = await http.patch<ServerAddress>(`/api/addresses/${id}`, toServerFields(patch))
return toAddressItem(rec)
}
/** 删除本人地址(若删的是默认地址,后端自动补偿最新一条为默认) */
export async function deleteAddress(id: string): Promise<void> {
await http.del(`/api/addresses/${id}`)
}
/** 设为默认地址 */
export async function setDefaultAddress(id: string): Promise<AddressItem> {
return toClient(await http.patch<ServerAddress>(`/api/addresses/${id}/default`))
const rec = await http.patch<ServerAddress>(`/api/addresses/${id}/default`)
return toAddressItem(rec)
}
+106 -39
View File
@@ -1,60 +1,127 @@
/**
* R2 设计清单接口(api-contract-v1 §5)。
*
* 映射约定(阶段0 决策,见 docs/r2-workflow.md):
* - 一条前端 DesignItem = 一条后端 DesignList 记录,items 固定 1 个元素;
* - title 由 productName 承担(后端必填);
* - productIcon 不入库(后端 forbidNonWhitelisted 会拒绝),页面按 productId 兜底推导;
* - 状态映射:DRAFT↔undesigned、SUBMITTED↔designing、PROCESSING↔processing、DONE↔ordered
* - 前端只允许提交 designing(→SUBMITTED),PROCESSING/DONE 由 R3 驱动。
*
* fetchDesignGET /api/design-list/:id)由 R3 结算页需要而增加;
* 后端已实现该端点(design-list.controller.ts @Get(':id')),契约 §5 文档补记。
*/
import http from '../request'
import type { DesignDataV1, DesignItem } from '../../types'
import { getProductIconImg } from '../productConfig'
type ServerEntry = { productId: string; productName: string; unitPrice: number; count: number; designData?: DesignDataV1 }
type ServerList = { id: string; title: string; items: ServerEntry[]; status: 'DRAFT' | 'SUBMITTED' | 'PROCESSING' | 'DONE'; orderId?: string; createdAt: string }
const statusToClient = (item: ServerList): DesignItem['status'] => {
if (item.orderId || item.status === 'DONE') return 'ordered'
if (item.status === 'PROCESSING') return 'processing'
if (item.status === 'DRAFT') return 'undesigned'
return 'designing'
/** 后端 DesignListStatus → 前端状态码(契约 §5 映射表) */
const SERVER_STATUS_MAP: Record<string, DesignItem['status']> = {
DRAFT: 'undesigned',
SUBMITTED: 'designing',
PROCESSING: 'processing',
DONE: 'ordered',
}
const statusToServer = (status: DesignItem['status']) =>
status === 'undesigned' ? 'DRAFT' : status === 'designing' ? 'SUBMITTED' : status === 'processing' ? 'PROCESSING' : 'DONE'
/** 清单条目(后端 items[] 固定 1 个元素,契约 §5) */
export interface DesignListEntryPayload {
productId: string
productName: string
unitPrice: number
count: number
designData?: DesignDataV1
}
const toClient = (list: ServerList): DesignItem => {
const item = list.items[0]
/** 后端 DesignList 记录结构 */
interface ServerDesignList {
id: string
userId?: string
title: string
items: (Partial<DesignListEntryPayload> & Record<string, unknown>)[]
status: string
createdAt: string
updatedAt?: string
}
/** ServerDesignList → 前端 DesignItem */
function toDesignItem(rec: ServerDesignList): DesignItem {
const entry = rec.items && rec.items[0]
return {
id: list.id,
productId: item.productId,
productName: item.productName,
productIcon: getProductIconImg({ id: item.productId }),
unitPrice: Number(item.unitPrice),
count: item.count,
status: statusToClient(list),
designData: item.designData,
orderId: list.orderId,
createdAt: list.createdAt,
id: rec.id,
productId: entry?.productId ?? '',
productName: entry?.productName || rec.title,
// productIcon 有意不返回:服务端不存储,页面按 PRODUCT_ICON_MAP[productId] 兜底
unitPrice: entry?.unitPrice ?? 0,
count: entry?.count ?? 1,
status: SERVER_STATUS_MAP[rec.status] ?? 'undesigned',
designData: entry?.designData,
createdAt: (rec.createdAt || '').slice(0, 10),
}
}
/** 我的设计清单(createdAt 倒序) */
export async function fetchDesignList(): Promise<DesignItem[]> {
const rows = await http.get<ServerList[]>('/api/design-list')
return rows.filter(row => row.items?.length > 0).map(toClient)
const list = await http.get<ServerDesignList[]>('/api/design-list')
return (list || []).map(toDesignItem)
}
/** 单条清单详情(R3 结算页使用;后端 @Get(':id'),本人校验) */
export async function fetchDesign(id: string): Promise<DesignItem> {
return toClient(await http.get<ServerList>(`/api/design-list/${id}`))
return toDesignItem(await http.get<ServerDesignList>(`/api/design-list/${id}`))
}
export async function createDesign(item: Omit<DesignItem, 'id' | 'createdAt' | 'status' | 'productIcon'>): Promise<DesignItem> {
return toClient(await http.post<ServerList>('/api/design-list', { title: item.productName, items: [{ productId: item.productId, productName: item.productName, unitPrice: item.unitPrice, count: item.count, designData: item.designData }] }))
/** 创建清单(一条设计一条清单;后端初始状态 DRAFT/undesigned */
export async function createDesign(entry: DesignListEntryPayload): Promise<DesignItem> {
const rec = await http.post<ServerDesignList>('/api/design-list', {
title: entry.productName,
items: [
{
productId: entry.productId,
productName: entry.productName,
unitPrice: entry.unitPrice,
count: entry.count,
...(entry.designData !== undefined ? { designData: entry.designData } : {}),
},
],
})
return toDesignItem(rec)
}
export async function updateDesign(id: string, patch: Partial<DesignItem>): Promise<DesignItem> {
const current = await fetchDesign(id)
const merged = { ...current, ...patch }
const data: Record<string, unknown> = {}
if (patch.designData !== undefined || patch.productId !== undefined || patch.productName !== undefined || patch.unitPrice !== undefined || patch.count !== undefined) {
data.items = [{ productId: merged.productId, productName: merged.productName, unitPrice: merged.unitPrice, count: merged.count, designData: merged.designData }]
export interface UpdateDesignPayload {
/** 新标题(前端一般不传) */
title?: string
/** 状态迁移:前端只允许 designing(→SUBMITTEDDIY 保存设计时) */
status?: 'designing'
/** 条目整体替换(items 是全量覆盖,修改 designData 时必须带全 4 个基本字段) */
item?: DesignListEntryPayload
}
/** 更新本人清单(部分更新:只传显式给出的字段) */
export async function updateDesign(id: string, payload: UpdateDesignPayload): Promise<DesignItem> {
const body: Record<string, unknown> = {}
if (payload.title !== undefined) body.title = payload.title
if (payload.status !== undefined) body.status = 'SUBMITTED'
if (payload.item !== undefined) {
body.items = [
{
productId: payload.item.productId,
productName: payload.item.productName,
unitPrice: payload.item.unitPrice,
count: payload.item.count,
...(payload.item.designData !== undefined ? { designData: payload.item.designData } : {}),
},
]
}
if (patch.productName !== undefined) data.title = patch.productName
if (patch.status !== undefined) data.status = statusToServer(patch.status)
return toClient(await http.patch<ServerList>(`/api/design-list/${id}`, data))
const rec = await http.patch<ServerDesignList>(`/api/design-list/${id}`, body)
return toDesignItem(rec)
}
export async function deleteDesign(id: string): Promise<void> { await http.del(`/api/design-list/${id}`) }
export async function deleteDesigns(ids: string[]): Promise<void> { await http.post('/api/design-list/batch-delete', { ids }) }
/** 删除本人清单 */
export async function deleteDesign(id: string): Promise<void> {
await http.del(`/api/design-list/${id}`)
}
/** 批量删除(后端只删本人条目,返回实际删除数;部分 id 无效不报错) */
export async function deleteDesigns(ids: string[]): Promise<number> {
const res = await http.post<{ deleted: number }>('/api/design-list/batch-delete', { ids })
return res?.deleted ?? 0
}
+7
View File
@@ -1,3 +1,10 @@
/**
* 收货地址本地存储 —— R2 后降级为「离线兜底缓存」层(按 openid 隔离,见 keys.ts)。
* 真实 CRUD 已走 utils/api/address.ts;本层职责:
* - 页面 API 成功 → 回写缓存,供冷启动先展示与未迁移页面(checkout 等)读取;
* - API 失败/断网 → 页面读缓存正常浏览;
* - 联网刷新以服务端为准覆盖缓存,旧本地数据不做自动合并(route-r2 风险节:默认忽略)。
*/
import Taro from '@tarojs/taro'
import type { AddressItem } from '../../types'
import { key } from './keys'
+7
View File
@@ -1,3 +1,10 @@
/**
* 设计清单本地存储 —— R2 后降级为「离线兜底缓存」层(按 openid 隔离,见 keys.ts)。
* 真实 CRUD 已走 utils/api/design.ts;本层职责:
* - 页面 API 成功 → 回写缓存,供冷启动先展示与未迁移页面(checkout 等)读取;
* - API 失败/断网 → 页面读缓存正常浏览;
* - 联网刷新以服务端为准覆盖缓存,旧本地数据不做自动合并(route-r2 风险节:默认忽略)。
*/
import Taro from '@tarojs/taro'
import type { DesignItem } from '../../types'
import { assetUrl } from '../asset'