Compare commits

...
4 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
45 changed files with 852 additions and 466 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> </image>
</template> </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"> <template name="tmpl_0_8">
<block>{{i.v}}</block> <block>{{i.v}}</block>
</template> </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(n,e,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(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(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,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(7914)});n.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
@@ -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
@@ -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 页,并由组件维护选中态。
- 真机调试与合法域名、服务端密钥隔离原则。
+5
View File
@@ -0,0 +1,5 @@
export default definePageConfig({
// 自定义 tabBar 本质是小程序组件;明确输出组件配置,确保其独立 wxss 被加载。
component: true,
styleIsolation: 'isolated'
})
+18 -3
View File
@@ -20,7 +20,9 @@ const TAB_CENTER_STEP = 100 / TABS.length
const DRAG_THRESHOLD_PX = 12 const DRAG_THRESHOLD_PX = 12
function matchTab(path: string): string { 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 return TABS[idx >= 0 ? idx : 0].pagePath
} }
@@ -46,6 +48,7 @@ export default function CustomTabBar() {
const dragPercentRef = useRef<number | null>(null) const dragPercentRef = useRef<number | null>(null)
const startXRef = useRef<number>(0) const startXRef = useRef<number>(0)
const barFrameRef = useRef<{ left: number; width: number } | null>(null) const barFrameRef = useRef<{ left: number; width: number } | null>(null)
const switchingRef = useRef<boolean>(false)
useEffect(() => { useEffect(() => {
const handler = (t: 'light' | 'dark') => setResolvedTheme(t) const handler = (t: 'light' | 'dark') => setResolvedTheme(t)
@@ -96,8 +99,20 @@ export default function CustomTabBar() {
}, [resolvedTheme]) }, [resolvedTheme])
const switchTab = (url: string) => { const switchTab = (url: string) => {
setActiveTab(matchTab(url)) const target = matchTab(url)
Taro.switchTab({ 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) Taro.eventCenter.trigger('tabBarChange', url)
} }
+45 -27
View File
@@ -2,8 +2,8 @@ import { View, Text, Image, Input, Picker } from '@tarojs/components'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import './index.scss' import './index.scss'
import { getAddressList, addAddress, updateAddress as updateLocalAddress, deleteAddress as deleteLocalAddress, setAddressList } from '../../utils/store' import { getAddressList, setAddressList, addAddress, updateAddress, deleteAddress } from '../../utils/store'
import { fetchAddresses, createAddress, updateAddress, deleteAddress, setDefaultAddress } from '../../utils/api/address' import { fetchAddresses, createAddress, updateAddress as updateAddressApi, deleteAddress as deleteAddressApi } from '../../utils/api'
import type { AddressItem } from '../../types' import type { AddressItem } from '../../types'
import { useThemeContext } from '../../context/ThemeContext' import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea' import { useSafeArea } from '../../hooks/useSafeArea'
@@ -27,14 +27,16 @@ export default function AddressPage() {
const [detail, setDetail] = useState('') const [detail, setDetail] = useState('')
const [isDefault, setIsDefault] = useState(false) const [isDefault, setIsDefault] = useState(false)
const load = async () => { const load = () => {
try { // 缓存优先展示(冷启动不空屏),再拉服务端刷新;
const remote = await fetchAddresses() // 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
setList(remote) setList(getAddressList())
setAddressList(remote) fetchAddresses()
} catch { .then(data => {
setList(getAddressList()) setList(data)
} setAddressList(data)
})
.catch(() => setList(getAddressList()))
} }
useEffect(() => { useEffect(() => {
@@ -65,17 +67,15 @@ export default function AddressPage() {
setShowForm(true) 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) => { const handleDelete = (id: string) => {
Taro.showModal({ Taro.showModal({
title: '确认删除', title: '确认删除',
content: '删除后将无法恢复该地址', content: '删除后将无法恢复该地址',
success: (res) => { success: (res) => {
if (res.confirm) { 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 }) Taro.setClipboardData({ data: text })
} }
const handleSave = async () => { const handleSave = () => {
if (!name.trim() || !phone.trim() || region.length === 0 || !detail.trim()) { if (!name.trim() || !phone.trim() || region.length === 0 || !detail.trim()) {
Taro.showToast({ title: '请填写完整信息', icon: 'none' }) Taro.showToast({ title: '请填写完整信息', icon: 'none' })
return return
} }
const payload = { name, phone, region, detail, isDefault } const payload = { name, phone, region, detail, isDefault }
try { const save = editing
const saved = editing ? await updateAddress(editing.id, payload) : await createAddress(payload) ? updateAddressApi(editing.id, payload)
const next = editing ? list.map(item => item.id === saved.id ? saved : item) : [saved, ...list] : createAddress(payload)
setList(next); setAddressList(next) save
Taro.showToast({ title: '保存成功', icon: 'success' }); setShowForm(false); resetForm() .then((saved) => {
} catch (error) { // 新地址在返回结算页前立即回写按账号隔离的缓存;
// API 失败时保留原有本地兜底,避免离线编辑丢失 // 结算页 onShow 会据此先展示,再以服务端列表校准
if (editing) updateLocalAddress(editing.id, payload); else addAddress(payload) const current = getAddressList()
Taro.showToast({ title: (error as Error).message || '保存失败,已暂存本地', icon: 'none' }); setShowForm(false); resetForm(); load() 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) => { const onRegionChange = (e: any) => {
@@ -143,7 +162,6 @@ export default function AddressPage() {
<View className='address-actions'> <View className='address-actions'>
<Text className='address-act' onTap={() => openEdit(item)}></Text> <Text className='address-act' onTap={() => openEdit(item)}></Text>
<Text className='address-act' onTap={() => handleCopy(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> <Text className='address-act delete' onTap={() => handleDelete(item.id)}></Text>
</View> </View>
</View> </View>
+54 -10
View File
@@ -1,8 +1,8 @@
import { View, Text, Image, Button } from '@tarojs/components' import { View, Text, Image, Button } from '@tarojs/components'
import Taro from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import './index.scss' 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 { createDesign, fetchDesign } from '../../utils/api/design'
import { fetchAddresses } from '../../utils/api/address' import { fetchAddresses } from '../../utils/api/address'
import { createOrder, payOrder } from '../../utils/api/order' import { createOrder, payOrder } from '../../utils/api/order'
@@ -24,6 +24,28 @@ export default function CheckoutPage() {
const [serverTotal, setServerTotal] = useState<number | null>(null) const [serverTotal, setServerTotal] = useState<number | null>(null)
const [submitting, setSubmitting] = useState(false) 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(() => { useEffect(() => {
const router = Taro.getCurrentInstance().router const router = Taro.getCurrentInstance().router
const params = router ? router.params : undefined const params = router ? router.params : undefined
@@ -33,19 +55,19 @@ export default function CheckoutPage() {
try { try {
const remote = await fetchDesign(dId) const remote = await fetchDesign(dId)
setDesign(remote) setDesign(remote)
const addresses = await fetchAddresses()
setAddrList(addresses)
setAddress(addresses.find(item => item.isDefault) || addresses[0] || null)
} catch { } catch {
const list = getDesignList() const list = getDesignList()
const d = list.find(x => x.id === dId) const d = list.find(x => x.id === dId)
setDesign(d || null) setDesign(d || null)
setAddress(getDefaultAddress())
setAddrList(getAddressList())
} }
await refreshAddresses()
} }
void load() void load()
}, []) }, [refreshAddresses])
useDidShow(() => {
void refreshAddresses()
})
const handleConfirm = async () => { const handleConfirm = async () => {
if (submitting) return 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 hasStickers = design && design.designData && design.designData.stickers && design.designData.stickers.length > 0
const hasLegacyImage = design && design.designData ? design.designData.imageSrc : undefined 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 formatAddress = (region: string[] | undefined, detail: string) => {
const regionText = region ? region.join(' ') : '' const regionText = region ? region.join(' ') : ''
return regionText ? `${regionText} ${detail}` : detail return regionText ? `${regionText} ${detail}` : detail
@@ -159,13 +202,14 @@ export default function CheckoutPage() {
<View className='checkout-canvas surface-card'> <View className='checkout-canvas surface-card'>
<View className='canvas-area' style={maskStyle}> <View className='canvas-area' style={maskStyle}>
{hasStickers ? ( {hasStickers ? (
design.designData.stickers.map((s: any) => ( previewStickers.map((s: any) => (
<Image <Image
key={s.id} key={s.id}
className='checkout-image' className='checkout-image'
src={s.src} src={s.src}
style={{ style={{
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale || 1})`, transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale || 1})`,
filter: stickerFilter(s),
width: s.width || 200, width: s.width || 200,
height: s.height || 200 height: s.height || 200
}} }}
+4 -3
View File
@@ -136,10 +136,11 @@
} }
.design-icon-img { .design-icon-img {
width: 48rpx; // 与订单列表的商品图保持同一规格,避免实拍图被压成图标大小。
height: 48rpx; width: 96rpx;
height: 96rpx;
flex-shrink: 0; flex-shrink: 0;
border-radius: 12rpx; border-radius: 24rpx;
background: var(--bg-input); background: var(--bg-input);
} }
+19 -13
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import './index.scss' import './index.scss'
import { getDesignList, removeDesigns, setDesignList, type DesignItem } from '../../utils/store' import { getDesignList, removeDesigns, setDesignList, type DesignItem } from '../../utils/store'
import { fetchDesignList, deleteDesigns } from '../../utils/api/design' 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 { assetUrl } from '../../utils/asset'
import { useThemeContext } from '../../context/ThemeContext' import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea' import { useSafeArea } from '../../hooks/useSafeArea'
@@ -15,19 +15,16 @@ import ScrollTopMask from '../../components/ScrollTopMask'
const STATUS_TABS = [ const STATUS_TABS = [
{ code: 'all', label: '全部' }, { code: 'all', label: '全部' },
{ code: 'toDesign', label: '待设计' }, { code: 'undesigned', label: '待设计' },
{ code: 'undesigned', label: '未设计' },
{ code: 'designing', label: '设计中' }, { code: 'designing', label: '设计中' },
{ code: 'processing', label: '生产中' }, { code: 'processing', label: '生产中' },
{ code: 'ordered', label: '已下单' } { code: 'ordered', label: '已下单' }
] ]
const VISIBLE_STATUS_TABS = STATUS_TABS.filter(tab => tab.code !== 'undesigned')
const STATUS_STYLE: Record<string, { label: string; cls: string }> = { const STATUS_STYLE: Record<string, { label: string; cls: string }> = {
undesigned: { label: '设计', cls: 'badge-pink' }, undesigned: { label: '设计', cls: 'badge-pink' },
designing: { label: '设计中', cls: 'badge-blue' }, designing: { label: '设计中', cls: 'badge-blue' },
processing: { label: '生产中', cls: 'badge-blue' }, processing: { label: '生产中', cls: 'badge-warning' },
ordered: { label: '已下单', cls: 'badge-green' } ordered: { label: '已下单', cls: 'badge-green' }
} }
@@ -41,7 +38,15 @@ export default function DesignListPage() {
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const load = useCallback(() => { 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(() => { const init = useCallback(() => {
@@ -65,11 +70,10 @@ export default function DesignListPage() {
init() init()
}, [init]) }, [init])
// “全部”用于汇总;其余标签与状态一一对应,单条设计只会落在一个栏位。
const filtered = activeTab === 'all' const filtered = activeTab === 'all'
? list ? list
: activeTab === 'toDesign' : list.filter(d => d.status === activeTab)
? list.filter(d => d.status === 'undesigned' || d.status === 'designing')
: list.filter(d => d.status === activeTab)
const goDesign = (item: DesignItem) => { const goDesign = (item: DesignItem) => {
if (item.status === 'ordered') { if (item.status === 'ordered') {
@@ -137,7 +141,7 @@ export default function DesignListPage() {
{/* 状态筛选 */} {/* 状态筛选 */}
<View className='status-tabs'> <View className='status-tabs'>
<ScrollView className='tabs-scroll' scrollX> <ScrollView className='tabs-scroll' scrollX>
{VISIBLE_STATUS_TABS.map(tab => ( {STATUS_TABS.map(tab => (
<View <View
key={tab.code} key={tab.code}
className={`chip ${activeTab === tab.code ? 'active' : ''}`} className={`chip ${activeTab === tab.code ? 'active' : ''}`}
@@ -172,6 +176,8 @@ export default function DesignListPage() {
? item.productIcon ? item.productIcon
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg' : PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg'
const iconSrc = assetUrl(rawIcon) const iconSrc = assetUrl(rawIcon)
// 与商品介绍页一致:商品目录首图是该商品的实拍图;目录缺图时才兼容旧 SVG。
const photoSrc = getProductById(item.productId)?.images?.[0] || iconSrc
const isSelected = selected.has(item.id) const isSelected = selected.has(item.id)
return ( return (
<View <View
@@ -183,7 +189,7 @@ export default function DesignListPage() {
{managing && ( {managing && (
<View className={`batch-checkbox ${isSelected ? 'checked' : ''}`} /> <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-info'>
<View className='design-title-row'> <View className='design-title-row'>
<Text className='design-name'>{item.productName}</Text> <Text className='design-name'>{item.productName}</Text>
+3
View File
@@ -103,6 +103,8 @@
position: absolute; position: absolute;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
/* 问题1 修正:碰撞盒按左上角计算,缩放原点统一为左上角,渲染与碰撞语义自洽 */
transform-origin: top left;
z-index: 5; z-index: 5;
border: 2rpx solid transparent; border: 2rpx solid transparent;
border-radius: 8rpx; border-radius: 8rpx;
@@ -391,6 +393,7 @@
position: absolute; position: absolute;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
transform-origin: top left;
z-index: 5; z-index: 5;
} }
+181 -60
View File
@@ -1,11 +1,10 @@
import { View, Text, Image } from '@tarojs/components' import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import './index.scss' import './index.scss'
import { getProductById } from '../../utils/productConfig' import { getProductById } from '../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign as updateLocalDesign, type DesignItem, type StickerItem } from '../../utils/store' import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
import { persistDesignMedia } from '../../utils/api' import { createDesign, updateDesign as updateDesignApi, persistDesignMedia } from '../../utils/api'
import { createDesign, updateDesign as updateRemoteDesign } from '../../utils/api/design'
import { useThemeContext } from '../../context/ThemeContext' import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea' import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar' import { useStatusBar } from '../../hooks/useStatusBar'
@@ -27,7 +26,41 @@ export default function DIYPage() {
const [startPos, setStartPos] = useState({ x: 0, y: 0 }) const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const [previewMode, setPreviewMode] = useState(false) const [previewMode, setPreviewMode] = useState(false)
const [hasOverlap, setHasOverlap] = 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(() => { useEffect(() => {
const router = Taro.getCurrentInstance().router const router = Taro.getCurrentInstance().router
@@ -35,46 +68,13 @@ export default function DIYPage() {
const source = params ? params.source : undefined const source = params ? params.source : undefined
const productId = (params && params.productId) || (params && params.category) 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) { if (source === 'product' && productId) {
const product = getProductById(productId) const product = getProductById(productId)
if (product) { if (product) {
setCategory(product) setCategory(product)
const count = Number(params ? params.quantity : undefined) || 1 const count = Number(params ? params.quantity : undefined) || 1
setQuantity(count) setQuantity(count)
void createDraftForProduct(product, count) createDesignEntry(product, count)
} }
return return
} }
@@ -88,22 +88,42 @@ export default function DIYPage() {
setQuantity(design.count) setQuantity(design.count)
const product = getProductById(design.productId) const product = getProductById(design.productId)
if (product) setCategory(product) if (product) setCategory(product)
if (design.designData ? design.designData.stickers : undefined) { const dd = design.designData
setStickers(design.designData.stickers) const rawStickers = dd ? dd.stickers : undefined
} else if (design.designData ? design.designData.imageSrc : 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 = { const s: StickerItem = {
id: 'legacy_' + Date.now(), id: 'legacy_' + Date.now(),
src: design.designData.imageSrc, src: dd.imageSrc,
x: design.designData.imagePos ? design.designData.imagePos.x || 0 : 0, x: dd.imagePos ? dd.imagePos.x || 0 : 0,
y: design.designData.imagePos ? design.designData.imagePos.y || 0 : 0, y: dd.imagePos ? dd.imagePos.y || 0 : 0,
scale: design.designData.imagePos ? design.designData.imagePos.scale || 1 : 1, scale: dd.imagePos ? dd.imagePos.scale || 1 : 1,
width: 200, width: 200,
height: 200, height: 200,
isOverlapping: false isOverlapping: false
} }
setStickers([s]) setStickers([s])
} }
// 问题3 修正:继续设计同样进入「设计中」状态(单向状态机,undesigned → designing
if (design.status === 'undesigned') {
updateDesign(dId, { status: 'designing' })
updateDesignApi(dId, { status: 'designing' }).catch(() => {})
}
} }
return return
} }
@@ -113,11 +133,71 @@ export default function DIYPage() {
const found = getProductById(productId) const found = getProductById(productId)
if (found) { if (found) {
setCategory(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 checkOverlap = (list: StickerItem[]) => {
const newList = list.map(s => ({ ...s, isOverlapping: false })) const newList = list.map(s => ({ ...s, isOverlapping: false }))
@@ -156,14 +236,17 @@ export default function DIYPage() {
Taro.getImageInfo({ Taro.getImageInfo({
src, src,
success: (info) => { 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 = { const newSticker: StickerItem = {
id: 'stk_' + Date.now(), id: 'stk_' + Date.now(),
src, src,
x: 0, x: 0,
y: 0, y: 0,
scale: 1, scale: 1,
width: info.width, width: Math.round(info.width * k),
height: info.height, height: Math.round(info.height * k),
isOverlapping: false isOverlapping: false
} }
const next = [...stickers, newSticker] const next = [...stickers, newSticker]
@@ -172,15 +255,17 @@ export default function DIYPage() {
setActiveStickerId(newSticker.id) setActiveStickerId(newSticker.id)
}, },
fail: () => { fail: () => {
// fallback 尺寸 // fallback 尺寸(同样按画布空间归一化)
const maskMin = Math.min(category.mask.width, category.mask.height)
const k = (maskMin * 0.6) / 200
const newSticker: StickerItem = { const newSticker: StickerItem = {
id: 'stk_' + Date.now(), id: 'stk_' + Date.now(),
src, src,
x: 0, x: 0,
y: 0, y: 0,
scale: 1, scale: 1,
width: 200, width: Math.round(200 * k),
height: 200, height: Math.round(200 * k),
isOverlapping: false isOverlapping: false
} }
const next = [...stickers, newSticker] const next = [...stickers, newSticker]
@@ -212,6 +297,7 @@ export default function DIYPage() {
const handleStickerTouchMove = (e: any) => { const handleStickerTouchMove = (e: any) => {
if (!isDragging || !activeStickerId) return if (!isDragging || !activeStickerId) return
const touch = e.touches[0] const touch = e.touches[0]
// 需求决定(2026-09-12):贴纸允许拖出画布外自由摆放,不做坐标钳制
const next = stickers.map(s => { const next = stickers.map(s => {
if (s.id !== activeStickerId) return s if (s.id !== activeStickerId) return s
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y } return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
@@ -224,6 +310,7 @@ export default function DIYPage() {
const handleEditSticker = () => { const handleEditSticker = () => {
if (!activeStickerId || !designId) return if (!activeStickerId || !designId) return
flushDraft() // 问题4:跳编辑页前清掉防抖窗口,缓存里保证有最新贴纸
Taro.navigateTo({ Taro.navigateTo({
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}` url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
}) })
@@ -238,6 +325,18 @@ export default function DIYPage() {
setStickers(checked) 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 = () => { const goBack = () => {
Taro.navigateBack() Taro.navigateBack()
} }
@@ -255,22 +354,42 @@ export default function DIYPage() {
} }
const handleComplete = async () => { const handleComplete = async () => {
if (isCreatingDesign) {
Taro.showToast({ title: '正在创建设计,请稍候', icon: 'none' })
return
}
if (hasOverlap) { if (hasOverlap) {
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' }) Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
return return
} }
if (designId) { if (designId && category) {
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材 // 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
const data = await persistDesignMedia({ stickers, category, version: 1 }) 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 { 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 { } catch {
// 离线草稿继续由本地存储承接;线上草稿会在下一次操作时重试同步。 Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
} }
} }
setPreviewMode(false) setPreviewMode(false)
@@ -318,6 +437,7 @@ export default function DIYPage() {
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`, transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1, opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
zIndex: activeStickerId === s.id ? 10 : 5, zIndex: activeStickerId === s.id ? 10 : 5,
filter: stickerFilter(s),
width: s.width || 200, width: s.width || 200,
height: s.height || 200 height: s.height || 200
}} }}
@@ -403,6 +523,7 @@ export default function DIYPage() {
src={s.src} src={s.src}
style={{ style={{
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`, transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
filter: stickerFilter(s),
width: s.width || 200, width: s.width || 200,
height: s.height || 200 height: s.height || 200
}} }}
+37
View File
@@ -81,6 +81,16 @@
box-shadow: var(--shadow-card); 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 { .edit-tools {
padding: 24rpx; padding: 24rpx;
@@ -226,3 +236,30 @@
.mt-20 { .mt-20 {
margin-top: 24rpx; 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 { View, Text, Image } from '@tarojs/components'
import Taro, { createCanvasContext, canvasToTempFilePath } from '@tarojs/taro' import Taro from '@tarojs/taro'
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef } from 'react'
import './index.scss' import './index.scss'
import { getProductById } from '../../../utils/productConfig' import { getDesignList, updateDesign, type StickerItem } from '../../../utils/store'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store' import { sketchImage, updateDesign as updateDesignApi } from '../../../utils/api'
import { sketchImage } from '../../../utils/api'
import { useThemeContext } from '../../../context/ThemeContext' import { useThemeContext } from '../../../context/ThemeContext'
import { useSafeArea } from '../../../hooks/useSafeArea' import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar' import { useStatusBar } from '../../../hooks/useStatusBar'
@@ -13,55 +12,59 @@ import ScrollTopMask from '../../../components/ScrollTopMask'
import BottomActionBar from '../../../components/BottomActionBar' 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) const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
/** 拖动手势滑块组件 */ /** 拖动手势滑块组件(问题5 修正:选择器查询按 id 定位,touchstart 时缓存 rect */
interface SliderProps { interface SliderProps {
id: string
value: number value: number
min: number min: number
max: number max: number
step?: number
onChange: (val: number) => void onChange: (val: number) => void
showValue?: boolean
format?: (v: number) => string format?: (v: number) => string
} }
function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProps) { function TouchSlider({ id, value, min, max, onChange, format }: SliderProps) {
const trackRef = useRef<any>(null) const rectRef = useRef<{ left: number; width: number } | null>(null)
const [dragging, setDragging] = useState(false)
const computedFromClientX = (clientX: number) => { const measure = (cb?: () => void) => {
if (!trackRef.current) return value Taro.createSelectorQuery()
const query = Taro.createSelectorQuery().in(trackRef.current) .select(`#${id}`)
// 注意:这里需要取 track 元素的位置信息 .boundingClientRect((rect: any) => {
const rect = trackRef.current.getBoundingClientRect ? trackRef.current.getBoundingClientRect() : { left: 0, width: 300 } 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) const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
let raw = min + (max - min) * ratio return clamp(min + (max - min) * ratio, min, max)
if (step > 0) {
raw = Math.round(raw / step) * step
}
return clamp(raw, min, max)
} }
const handleTouchStart = (e: any) => { const handleTouchStart = (e: any) => {
setDragging(true)
const touch = e.touches && e.touches[0] const touch = e.touches && e.touches[0]
const x = touch ? touch.clientX : 0 if (!touch) return
onChange(computedFromClientX(x)) measure(() => onChange(valueFromX(touch.clientX)))
} }
const handleTouchMove = (e: any) => { const handleTouchMove = (e: any) => {
if (!dragging) return
const touch = e.touches && e.touches[0] const touch = e.touches && e.touches[0]
const x = touch ? touch.clientX : 0 if (!touch) return
onChange(computedFromClientX(x)) if (!rectRef.current) {
} measure(() => onChange(valueFromX(touch.clientX)))
return
const handleTouchEnd = () => { }
setDragging(false) onChange(valueFromX(touch.clientX))
} }
const pct = `${((value - min) / (max - min)) * 100}%` const pct = `${((value - min) / (max - min)) * 100}%`
@@ -69,14 +72,14 @@ function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProp
return ( return (
<View className='slider-wrap'> <View className='slider-wrap'>
<View <View
id={id}
className='slider-track' className='slider-track'
ref={trackRef}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={() => { /* rect 已缓存,无需处理 */ }}
> >
<View className='slider-fill' style={{ width: pct }} /> <View className='slider-fill' style={{ width: pct }} />
<View className={`slider-thumb ${dragging ? 'dragging' : ''}`} style={{ left: pct }} /> <View className='slider-thumb' style={{ left: pct }} />
</View> </View>
<Text className='slider-value'>{format ? format(value) : value}</Text> <Text className='slider-value'>{format ? format(value) : value}</Text>
</View> </View>
@@ -90,50 +93,15 @@ export default function StickerEditPage() {
const [designId, setDesignId] = useState('') const [designId, setDesignId] = useState('')
const [stickerId, setStickerId] = useState('') const [stickerId, setStickerId] = useState('')
const [sticker, setSticker] = useState<StickerItem | null>(null) 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 [brightness, setBrightness] = useState(0)
const [hue, setHue] = useState(0) const [hue, setHue] = useState(0)
const [contrast, setContrast] = useState(0) const [contrast, setContrast] = useState(0)
const [loading, setLoading] = useState(false) 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(() => { useEffect(() => {
const router = Taro.getCurrentInstance().router const router = Taro.getCurrentInstance().router
@@ -151,96 +119,44 @@ export default function StickerEditPage() {
setBrightness((st.edits && st.edits.brightness) || 0) setBrightness((st.edits && st.edits.brightness) || 0)
setHue((st.edits && st.edits.hue) || 0) setHue((st.edits && st.edits.hue) || 0)
setContrast((st.edits && st.edits.contrast) || 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 = () => { const handleSave = () => {
if (!sticker || !designId) return if (!sticker || !designId) return
setLoading(true) setLoading(true)
// 使用 Canvas 2D 导出 const dList = getDesignList()
// @ts-ignore const dIdx = dList.findIndex(d => d.id === designId)
Taro.canvasToTempFilePath({ if (dIdx === -1) { setLoading(false); setNotFound(true); return }
canvas: canvasNodeRef.current, const d = dList[dIdx]
quality: 0.92, const stickers = (d.designData && d.designData.stickers) || []
success: (res: any) => { const sIdx = stickers.findIndex(s => s.id === stickerId)
const newSrc = res.tempFilePath if (sIdx === -1) { setLoading(false); setNotFound(true); return }
const dList = getDesignList() stickers[sIdx] = {
const dIdx = dList.findIndex(d => d.id === designId) ...stickers[sIdx],
if (dIdx === -1) { setLoading(false); return } ...(pendingSrc ? { src: pendingSrc } : {}),
const stickers = dList[dIdx].designData ? dList[dIdx].designData.stickers || [] : [] edits: { brightness, hue, contrast }
const sIdx = stickers.findIndex(s => s.id === stickerId) }
if (sIdx === -1) { setLoading(false); return } // WCD 红线:基于服务端已有 designData 合并(保留 wordcloud 分组),只覆盖 stickers
stickers[sIdx] = { const designData = { ...d.designData, stickers }
...stickers[sIdx], updateDesign(designId, { designData }) // 本地缓存同步
src: newSrc, // 服务端保存(items 全量替换,必须带全 4 个基本字段);失败暂存本地
edits: { brightness, hue, contrast } updateDesignApi(designId, {
} item: {
updateDesign(designId, { designData: { ...dList[dIdx].designData, stickers } }) productId: d.productId,
Taro.showToast({ title: '保存成功', icon: 'success' }) productName: d.productName,
Taro.navigateBack() unitPrice: d.unitPrice,
}, count: d.count,
fail: () => { designData
setLoading(false)
Taro.showToast({ title: '保存失败', icon: 'none' })
} }
}) }).catch(() => Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' }))
setLoading(false)
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
} }
/** 裁剪:调用微信cropImage */ /** 裁剪:调用微信cropImage */
@@ -258,46 +174,61 @@ export default function StickerEditPage() {
success: (res) => { success: (res) => {
const newSticker = { ...sticker, src: res.tempFilePath } const newSticker = { ...sticker, src: res.tempFilePath }
setSticker(newSticker) setSticker(newSticker)
// 重绘 setPendingSrc(res.tempFilePath)
setTimeout(() => redraw(), 200)
} }
}) })
} }
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */ /** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度参数 */
const handleSketch = async () => { const handleSketch = async () => {
if (!sticker) return if (!sticker) return
setLoading(true) setLoading(true)
try { try {
const res = await sketchImage(sticker.src) const res = await sketchImage(sticker.src)
if (res?.imageUrl) { if (res?.imageUrl) {
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } } setSticker({ ...sticker, src: res.imageUrl })
setSticker(newSticker) setPendingSrc(res.imageUrl)
setLoading(false) setLoading(false)
setTimeout(() => redraw(), 200)
Taro.showToast({ title: '线稿生成成功', icon: 'success' }) Taro.showToast({ title: '线稿生成成功', icon: 'success' })
} else { } else {
throw new Error('no imageUrl') throw new Error('no imageUrl')
} }
} catch { } catch {
// 如果接口不可用/失败,降级为前端灰度+边缘检测 // 如果接口不可用/失败,降级为前端灰度滤镜参数(保存时随 edits 持久化)
applyFrontendSketch() Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
} }
} }
/** 前端线稿降级方案:灰度+反相高对比 */ const previewFilter = brightness === 0 && hue === 0 && contrast === 0
const applyFrontendSketch = () => { ? 'none'
if (!sticker || !ctxRef.current) { setLoading(false); return } : `brightness(${100 + brightness}%) hue-rotate(${hue}deg) contrast(${100 + contrast}%)`
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
}
const bPct = `${((brightness + 100) / 200) * 100}%` if (notFound) {
const hPct = `${((hue + 180) / 360) * 100}%` return (
const cPct = `${((contrast + 100) / 200) * 100}%` <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 ( return (
<View className={`theme-${resolvedTheme}`}> <View className={`theme-${resolvedTheme}`}>
@@ -311,13 +242,13 @@ export default function StickerEditPage() {
<Text className='edit-save' onTap={handleSave}></Text> <Text className='edit-save' onTap={handleSave}></Text>
</View> </View>
{/* Canvas 预览 */} {/* 预览Image + CSS filter 所见即所得(不依赖 Canvas ctx.filter */}
<View className='edit-canvas-wrap'> <View className='edit-canvas-wrap'>
<Canvas <Image
id='editCanvas' className='edit-preview-img'
type='2d' src={sticker ? sticker.src : ''}
className='edit-canvas' mode='aspectFit'
style={{ width: '100%', height: '480rpx' }} style={{ filter: previewFilter }}
/> />
</View> </View>
@@ -336,73 +267,42 @@ export default function StickerEditPage() {
</View> </View>
{/* 亮度 */} {/* 亮度 */}
<View className='edit-row slider-row' <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()
}}
>
<Text className='edit-label'></Text> <Text className='edit-label'></Text>
<View className='slider-wrap'> <TouchSlider
<View className='slider-track'> id='slider-brightness'
<View className='slider-fill' style={{ width: bPct }} /> value={brightness}
<View className='slider-thumb' style={{ left: bPct }} /> min={-100}
</View> max={100}
<Text className='slider-value'>{brightness > 0 ? `+${brightness}` : brightness}</Text> onChange={setBrightness}
</View> format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View> </View>
{/* 色相 */} {/* 色相 */}
<View className='edit-row slider-row' <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()
}}
>
<Text className='edit-label'></Text> <Text className='edit-label'></Text>
<View className='slider-wrap'> <TouchSlider
<View className='slider-track'> id='slider-hue'
<View className='slider-fill' style={{ width: hPct }} /> value={hue}
<View className='slider-thumb' style={{ left: hPct }} /> min={-180}
</View> max={180}
<Text className='slider-value'>{hue > 0 ? `+${hue}` : hue}</Text> onChange={setHue}
</View> format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View> </View>
{/* 对比度 */} {/* 对比度 */}
<View className='edit-row slider-row' <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()
}}
>
<Text className='edit-label'></Text> <Text className='edit-label'></Text>
<View className='slider-wrap'> <TouchSlider
<View className='slider-track'> id='slider-contrast'
<View className='slider-fill' style={{ width: cPct }} /> value={contrast}
<View className='slider-thumb' style={{ left: cPct }} /> min={-100}
</View> max={100}
<Text className='slider-value'>{contrast > 0 ? `+${contrast}` : contrast}</Text> onChange={setContrast}
</View> format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View> </View>
</View> </View>
+3 -2
View File
@@ -124,11 +124,12 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
} }
.orders-page .order-icon-img { .orders-page .order-icon-img {
width: 56rpx; width: 100%;
height: 56rpx; height: 100%;
} }
.orders-page .order-info { .orders-page .order-info {
+4 -3
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
import './index.scss' import './index.scss'
import { getOrderList, type OrderItem } from '../../utils/store' import { getOrderList, type OrderItem } from '../../utils/store'
import { fetchOrders, payOrder, cancelOrder, confirmOrder, type ServerOrder } from '../../utils/api/order' 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 { assetUrl } from '../../utils/asset'
import { useThemeContext } from '../../context/ThemeContext' import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea' import { useSafeArea } from '../../hooks/useSafeArea'
@@ -42,7 +42,8 @@ export default function OrdersPage() {
const mapOrder = (order: ServerOrder): OrderItem => { const mapOrder = (order: ServerOrder): OrderItem => {
const first = order.items[0] 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' 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 () => { const load = async () => {
try { const remote = await fetchOrders({ page: 1, pageSize: 100 }); setOrders(remote.list.map(mapOrder)) } catch { setOrders(getOrderList()) } 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>
<View className='order-body'> <View className='order-body'>
<View className='order-icon-wrapper'> <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>
<View className='order-info'> <View className='order-info'>
<Text className='order-product'>{order.productName}</Text> <Text className='order-product'>{order.productName}</Text>
+3 -2
View File
@@ -219,15 +219,16 @@ export default function ProfilePage() {
} }
const handleQuickClick = (type: string) => { const handleQuickClick = (type: string) => {
Taro.hideToast()
switch (type) { switch (type) {
case 'toDesign': case 'toDesign':
Taro.setStorageSync('designList:filter', 'toDesign') Taro.setStorageSync('designList:filter', 'undesigned')
Taro.switchTab({ url: '/pages/designList/index' }) Taro.switchTab({ url: '/pages/designList/index' })
Taro.eventCenter.trigger('tabBarChange', '/pages/designList/index')
break break
case 'pending': case 'pending':
Taro.setStorageSync('orders:filter', 'pending') Taro.setStorageSync('orders:filter', 'pending')
Taro.switchTab({ url: '/pages/orders/index' }) Taro.switchTab({ url: '/pages/orders/index' })
Taro.eventCenter.trigger('tabBarChange', '/pages/orders/index')
break break
case 'paid': case 'paid':
Taro.setStorageSync('orders:filter', 'paid') Taro.setStorageSync('orders:filter', 'paid')
+20 -5
View File
@@ -81,8 +81,15 @@ export interface StickerItem {
export interface DesignDataV1 { export interface DesignDataV1 {
/** 结构版本;旧数据缺省视为 v1 */ /** 结构版本;旧数据缺省视为 v1 */
version?: 1 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 打包的画布底 */ /** 底图(持久 URL),WCD 打包的画布底 */
background?: { background?: {
src: string src: string
@@ -146,17 +153,23 @@ export interface WordCloudDispatchResult {
message?: string message?: string
} }
/** 设计清单条目 */ /** 设计清单条目(与后端 DesignList 一一对应,api-contract-v1 §5 */
export interface DesignItem { export interface DesignItem {
id: string id: string
productId: string productId: string
productName: string productName: string
productIcon: string /**
* 条目图标。服务端不存储(契约 forbidNonWhitelisted 拒绝多余字段),
* 页面按 PRODUCT_ICON_MAP[productId] 兜底推导;仅本地缓存/旧数据可能携带
*/
productIcon?: string
unitPrice: number unitPrice: number
count: number count: number
/** undesigned/designing 由前端驱动;processing/ordered 由服务端状态映射产生(R3 驱动) */
status: 'undesigned' | 'designing' | 'processing' | 'ordered' status: 'undesigned' | 'designing' | 'processing' | 'ordered'
designData?: DesignDataV1 designData?: DesignDataV1
orderId?: string orderId?: string
/** 服务端 createdAtISO),展示时取日期部分 */
createdAt: string createdAt: string
} }
@@ -173,7 +186,7 @@ export interface OrderItem {
paymentExpiresAt?: string | null paymentExpiresAt?: string | null
} }
/** 收货地址 */ /** 收货地址(后端 province/city/district/detail 以 region 数组表达,转换只在 api/address.ts */
export interface AddressItem { export interface AddressItem {
id: string id: string
name: string name: string
@@ -181,6 +194,8 @@ export interface AddressItem {
region: string[] // [province, city, district] region: string[] // [province, city, district]
detail: string // 门牌号/详细地址 detail: string // 门牌号/详细地址
isDefault: boolean 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 http from '../request'
import type { AddressItem } from '../../types' import type { AddressItem } from '../../types'
type AddressPayload = Omit<AddressItem, 'id' | 'region'> & { region: string[] } /** 后端 Address 结构(契约 §4:四个独立地址字段) */
type ServerAddress = { id: string; name: string; phone: string; province: string; city: string; district: string; detail: string; isDefault: boolean } 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 => ({ /** ServerAddress → 前端 AddressItem(合并省市区为 region 数组) */
id: address.id, function toAddressItem(rec: ServerAddress): AddressItem {
name: address.name, return {
phone: address.phone, id: rec.id,
region: [address.province, address.city, address.district], name: rec.name,
detail: address.detail, phone: rec.phone,
isDefault: address.isDefault, region: [rec.province, rec.city, rec.district],
}) detail: rec.detail,
isDefault: rec.isDefault,
createdAt: rec.createdAt,
}
}
const toServer = (address: Partial<AddressPayload>) => ({ /** 前端 region 数组 → 后端四个独立字段 */
...(address.name === undefined ? {} : { name: address.name }), function toServerFields(addr: {
...(address.phone === undefined ? {} : { phone: address.phone }), name?: string
...(address.region ? { province: address.region[0] || '', city: address.region[1] || '', district: address.region[2] || '' } : {}), phone?: string
...(address.detail === undefined ? {} : { detail: address.detail }), region?: string[]
...(address.isDefault === undefined ? {} : { isDefault: address.isDefault }), 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[]> { export async function fetchAddresses(): Promise<AddressItem[]> {
const rows = await http.get<ServerAddress[]>('/api/addresses') const list = await http.get<ServerAddress[]>('/api/addresses')
return rows.map(toClient) 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> { /** 更新本人地址(只传显式给出的字段;isDefault=true 由后端事务保证唯一默认) */
return toClient(await http.patch<ServerAddress>(`/api/addresses/${id}`, toServer(patch))) 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> { export async function deleteAddress(id: string): Promise<void> {
await http.del(`/api/addresses/${id}`) await http.del(`/api/addresses/${id}`)
} }
/** 设为默认地址 */
export async function setDefaultAddress(id: string): Promise<AddressItem> { 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 http from '../request'
import type { DesignDataV1, DesignItem } from '../../types' import type { DesignDataV1, DesignItem } from '../../types'
import { getProductIconImg } from '../productConfig'
type ServerEntry = { productId: string; productName: string; unitPrice: number; count: number; designData?: DesignDataV1 } /** 后端 DesignListStatus → 前端状态码(契约 §5 映射表) */
type ServerList = { id: string; title: string; items: ServerEntry[]; status: 'DRAFT' | 'SUBMITTED' | 'PROCESSING' | 'DONE'; orderId?: string; createdAt: string } const SERVER_STATUS_MAP: Record<string, DesignItem['status']> = {
DRAFT: 'undesigned',
const statusToClient = (item: ServerList): DesignItem['status'] => { SUBMITTED: 'designing',
if (item.orderId || item.status === 'DONE') return 'ordered' PROCESSING: 'processing',
if (item.status === 'PROCESSING') return 'processing' DONE: 'ordered',
if (item.status === 'DRAFT') return 'undesigned'
return 'designing'
} }
const statusToServer = (status: DesignItem['status']) => /** 清单条目(后端 items[] 固定 1 个元素,契约 §5) */
status === 'undesigned' ? 'DRAFT' : status === 'designing' ? 'SUBMITTED' : status === 'processing' ? 'PROCESSING' : 'DONE' export interface DesignListEntryPayload {
productId: string
productName: string
unitPrice: number
count: number
designData?: DesignDataV1
}
const toClient = (list: ServerList): DesignItem => { /** 后端 DesignList 记录结构 */
const item = list.items[0] 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 { return {
id: list.id, id: rec.id,
productId: item.productId, productId: entry?.productId ?? '',
productName: item.productName, productName: entry?.productName || rec.title,
productIcon: getProductIconImg({ id: item.productId }), // productIcon 有意不返回:服务端不存储,页面按 PRODUCT_ICON_MAP[productId] 兜底
unitPrice: Number(item.unitPrice), unitPrice: entry?.unitPrice ?? 0,
count: item.count, count: entry?.count ?? 1,
status: statusToClient(list), status: SERVER_STATUS_MAP[rec.status] ?? 'undesigned',
designData: item.designData, designData: entry?.designData,
orderId: list.orderId, createdAt: (rec.createdAt || '').slice(0, 10),
createdAt: list.createdAt,
} }
} }
/** 我的设计清单(createdAt 倒序) */
export async function fetchDesignList(): Promise<DesignItem[]> { export async function fetchDesignList(): Promise<DesignItem[]> {
const rows = await http.get<ServerList[]>('/api/design-list') const list = await http.get<ServerDesignList[]>('/api/design-list')
return rows.filter(row => row.items?.length > 0).map(toClient) return (list || []).map(toDesignItem)
} }
/** 单条清单详情(R3 结算页使用;后端 @Get(':id'),本人校验) */
export async function fetchDesign(id: string): Promise<DesignItem> { 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> { /** 创建清单(一条设计一条清单;后端初始状态 DRAFT/undesigned */
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 }] })) 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> { export interface UpdateDesignPayload {
const current = await fetchDesign(id) /** 新标题(前端一般不传) */
const merged = { ...current, ...patch } title?: string
const data: Record<string, unknown> = {} /** 状态迁移:前端只允许 designing(→SUBMITTEDDIY 保存设计时) */
if (patch.designData !== undefined || patch.productId !== undefined || patch.productName !== undefined || patch.unitPrice !== undefined || patch.count !== undefined) { status?: 'designing'
data.items = [{ productId: merged.productId, productName: merged.productName, unitPrice: merged.unitPrice, count: merged.count, designData: merged.designData }] /** 条目整体替换(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 const rec = await http.patch<ServerDesignList>(`/api/design-list/${id}`, body)
if (patch.status !== undefined) data.status = statusToServer(patch.status) return toDesignItem(rec)
return toClient(await http.patch<ServerList>(`/api/design-list/${id}`, data))
} }
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 Taro from '@tarojs/taro'
import type { AddressItem } from '../../types' import type { AddressItem } from '../../types'
import { key } from './keys' 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 Taro from '@tarojs/taro'
import type { DesignItem } from '../../types' import type { DesignItem } from '../../types'
import { assetUrl } from '../asset' import { assetUrl } from '../asset'