Compare commits

...
3 Commits
Author SHA1 Message Date
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
38 changed files with 667 additions and 433 deletions
Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
-8
View File
@@ -133,14 +133,6 @@
</image>
</template>
<template name="tmpl_0_15">
<canvas canvas-id="{{i.p0}}" disable-scroll="{{xs.b(i.p1,!1)}}" binderror="eh" bindtouchstart="eh" bindtouchmove="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongtap="eh" type="{{i.p2}}" style="{{i.st}}" class="{{i.cl}}" bindtap="eh" id="{{i.uid||i.sid}}" data-sid="{{i.sid}}">
<block wx:for="{{i.cn}}" wx:key="sid">
<template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
</block>
</canvas>
</template>
<template name="tmpl_0_8">
<block>{{i.v}}</block>
</template>
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[862],{},function(n){var t=function(t){return n(n.s=t)};n.O(0,[907,96],function(){return t(5838)});n.O()}]);
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[862],{},function(n){var t=function(t){return n(n.s=t)};n.O(0,[907,96],function(){return t(7176)});n.O()}]);
+1 -1
View File
@@ -1 +1 @@
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{7914:function(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
+31 -26
View File
@@ -2,8 +2,8 @@ import { View, Text, Image, Input, Picker } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import './index.scss'
import { getAddressList, addAddress, updateAddress as updateLocalAddress, deleteAddress as deleteLocalAddress, setAddressList } from '../../utils/store'
import { fetchAddresses, createAddress, updateAddress, deleteAddress, setDefaultAddress } from '../../utils/api/address'
import { getAddressList, setAddressList, addAddress, updateAddress, deleteAddress } from '../../utils/store'
import { fetchAddresses, createAddress, updateAddress as updateAddressApi, deleteAddress as deleteAddressApi } from '../../utils/api'
import type { AddressItem } from '../../types'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
@@ -27,14 +27,16 @@ export default function AddressPage() {
const [detail, setDetail] = useState('')
const [isDefault, setIsDefault] = useState(false)
const load = async () => {
try {
const remote = await fetchAddresses()
setList(remote)
setAddressList(remote)
} catch {
const load = () => {
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
setList(getAddressList())
}
fetchAddresses()
.then(data => {
setList(data)
setAddressList(data)
})
.catch(() => setList(getAddressList()))
}
useEffect(() => {
@@ -65,17 +67,15 @@ export default function AddressPage() {
setShowForm(true)
}
const handleSetDefault = async (id: string) => {
try { const next = await setDefaultAddress(id); setList(prev => prev.map(item => ({ ...item, isDefault: item.id === next.id }))); Taro.showToast({ title: '已设为默认', icon: 'success' }) } catch (error) { Taro.showToast({ title: (error as Error).message || '设置失败', icon: 'none' }) }
}
const handleDelete = (id: string) => {
Taro.showModal({
title: '确认删除',
content: '删除后将无法恢复该地址',
success: (res) => {
if (res.confirm) {
deleteAddress(id).then(() => load()).catch(error => { deleteLocalAddress(id); load(); Taro.showToast({ title: (error as Error).message || '删除失败', icon: 'none' }) })
deleteAddressApi(id)
.catch(() => deleteAddress(id)) // 失败降级本地删除
.finally(load)
}
}
})
@@ -86,22 +86,28 @@ export default function AddressPage() {
Taro.setClipboardData({ data: text })
}
const handleSave = async () => {
const handleSave = () => {
if (!name.trim() || !phone.trim() || region.length === 0 || !detail.trim()) {
Taro.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
const payload = { name, phone, region, detail, isDefault }
try {
const saved = editing ? await updateAddress(editing.id, payload) : await createAddress(payload)
const next = editing ? list.map(item => item.id === saved.id ? saved : item) : [saved, ...list]
setList(next); setAddressList(next)
Taro.showToast({ title: '保存成功', icon: 'success' }); setShowForm(false); resetForm()
} catch (error) {
// API 失败时保留原有本地兜底,避免离线编辑丢失。
if (editing) updateLocalAddress(editing.id, payload); else addAddress(payload)
Taro.showToast({ title: (error as Error).message || '保存失败,已暂存本地', icon: 'none' }); setShowForm(false); resetForm(); load()
}
const save = editing
? updateAddressApi(editing.id, payload)
: createAddress(payload)
save
.then(() => Taro.showToast({ title: '保存成功', icon: 'success' }))
.catch(() => {
// 服务端不可达时降级本地保存,联网后进入页面会刷新
if (editing) updateAddress(editing.id, payload)
else addAddress(payload)
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
})
.finally(() => {
setShowForm(false)
resetForm()
load()
})
}
const onRegionChange = (e: any) => {
@@ -143,7 +149,6 @@ export default function AddressPage() {
<View className='address-actions'>
<Text className='address-act' onTap={() => openEdit(item)}></Text>
<Text className='address-act' onTap={() => handleCopy(item)}></Text>
{!item.isDefault && <Text className='address-act' onTap={() => handleSetDefault(item.id)}></Text>}
<Text className='address-act delete' onTap={() => handleDelete(item.id)}></Text>
</View>
</View>
+23 -1
View File
@@ -105,6 +105,27 @@ export default function CheckoutPage() {
const hasStickers = design && design.designData && design.designData.stickers && design.designData.stickers.length > 0
const hasLegacyImage = design && design.designData ? design.designData.imageSrc : undefined
// 预览消费贴纸 edits(亮度/色相/对比度 → CSS filter),与 DIY 工作台同一呈现逻辑
//diy-fix-workflow 问题5 的 R3 待办:不消费会导致确认页图片与工作台不一致)
const stickerFilter = (s: { edits?: { brightness?: number; hue?: number; contrast?: number } }) => {
const ed = s.edits
if (!ed) return undefined
const b = ed.brightness || 0
const h = ed.hue || 0
const c = ed.contrast || 0
if (b === 0 && h === 0 && c === 0) return undefined
return `brightness(${100 + b}%) hue-rotate(${h}deg) contrast(${100 + c}%)`
}
// 图层顺序与 WCD 打包规则一致(按 zIndex,无 zIndex 按数组序兜底)
const previewStickers: any[] = hasStickers
? design.designData!.stickers!
.map((s: any, i: number) => ({ s, z: typeof s.zIndex === 'number' ? s.zIndex : i }))
.sort((a: { z: number }, b: { z: number }) => a.z - b.z)
.map((it: { s: any }) => it.s)
: []
const formatAddress = (region: string[] | undefined, detail: string) => {
const regionText = region ? region.join(' ') : ''
return regionText ? `${regionText} ${detail}` : detail
@@ -159,13 +180,14 @@ export default function CheckoutPage() {
<View className='checkout-canvas surface-card'>
<View className='canvas-area' style={maskStyle}>
{hasStickers ? (
design.designData.stickers.map((s: any) => (
previewStickers.map((s: any) => (
<Image
key={s.id}
className='checkout-image'
src={s.src}
style={{
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale || 1})`,
filter: stickerFilter(s),
width: s.width || 200,
height: s.height || 200
}}
+10 -2
View File
@@ -27,7 +27,7 @@ const VISIBLE_STATUS_TABS = STATUS_TABS.filter(tab => tab.code !== 'undesigned')
const STATUS_STYLE: Record<string, { label: string; cls: string }> = {
undesigned: { label: '未设计', cls: 'badge-pink' },
designing: { label: '设计中', cls: 'badge-blue' },
processing: { label: '生产中', cls: 'badge-blue' },
processing: { label: '生产中', cls: 'badge-warning' },
ordered: { label: '已下单', cls: 'badge-green' }
}
@@ -41,7 +41,15 @@ export default function DesignListPage() {
const [selected, setSelected] = useState<Set<string>>(new Set())
const load = useCallback(() => {
fetchDesignList().then(remote => { setList(remote); setDesignList(remote) }).catch(() => setList(getDesignList()))
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
setList(getDesignList())
fetchDesignList()
.then(data => {
setList(data)
setDesignList(data)
})
.catch(() => setList(getDesignList()))
}, [])
const init = useCallback(() => {
+3
View File
@@ -103,6 +103,8 @@
position: absolute;
max-width: 100%;
max-height: 100%;
/* 问题1 修正:碰撞盒按左上角计算,缩放原点统一为左上角,渲染与碰撞语义自洽 */
transform-origin: top left;
z-index: 5;
border: 2rpx solid transparent;
border-radius: 8rpx;
@@ -391,6 +393,7 @@
position: absolute;
max-width: 100%;
max-height: 100%;
transform-origin: top left;
z-index: 5;
}
+177 -56
View File
@@ -1,11 +1,10 @@
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import './index.scss'
import { getProductById } from '../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign as updateLocalDesign, type DesignItem, type StickerItem } from '../../utils/store'
import { persistDesignMedia } from '../../utils/api'
import { createDesign, updateDesign as updateRemoteDesign } from '../../utils/api/design'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../utils/store'
import { createDesign, updateDesign as updateDesignApi, persistDesignMedia } from '../../utils/api'
import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar'
@@ -27,16 +26,28 @@ export default function DIYPage() {
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const [previewMode, setPreviewMode] = useState(false)
const [hasOverlap, setHasOverlap] = useState(false)
const [isCreatingDesign, setIsCreatingDesign] = useState(false)
useEffect(() => {
const router = Taro.getCurrentInstance().router
const params = router ? router.params : undefined
const source = params ? params.source : undefined
const productId = (params && params.productId) || (params && params.category)
const createDraftForProduct = async (product: any, count: number) => {
const draft: DesignItem = {
/** 进入 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,
@@ -44,29 +55,18 @@ export default function DIYPage() {
unitPrice: product.price,
count,
status: 'designing',
createdAt: new Date().toISOString().slice(0, 10),
createdAt: new Date().toISOString().slice(0, 10)
}
setDesignList([...getDesignList(), newDesign])
setDesignId(newDesign.id)
})
}
// 先写本地草稿,保证离线时仍能编辑;在线时立即换成服务端真实 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)
}
}
useEffect(() => {
const router = Taro.getCurrentInstance().router
const params = router ? router.params : undefined
const source = params ? params.source : undefined
const productId = (params && params.productId) || (params && params.category)
if (source === 'product' && productId) {
const product = getProductById(productId)
@@ -74,7 +74,7 @@ export default function DIYPage() {
setCategory(product)
const count = Number(params ? params.quantity : undefined) || 1
setQuantity(count)
void createDraftForProduct(product, count)
createDesignEntry(product, count)
}
return
}
@@ -88,22 +88,42 @@ export default function DIYPage() {
setQuantity(design.count)
const product = getProductById(design.productId)
if (product) setCategory(product)
if (design.designData ? design.designData.stickers : undefined) {
setStickers(design.designData.stickers)
} else if (design.designData ? design.designData.imageSrc : undefined) {
const dd = design.designData
const rawStickers = dd ? dd.stickers : undefined
if (rawStickers) {
// 问题1 留意点:旧数据 width/height 为图片原始像素语义,读取端按 mask 等比归一化
//(契约实现注记,不做存量迁移);新语义数据(≤mask 短边 60%)原样通过
const mask = (dd && dd.category && dd.category.mask) || (product ? product.mask : undefined)
const normalized: StickerItem[] = mask
? rawStickers.map(s => {
if (s.width > mask.width || s.height > mask.height) {
const k = (Math.min(mask.width, mask.height) * 0.6) / Math.max(s.width, s.height)
return { ...s, width: Math.round(s.width * k), height: Math.round(s.height * k) }
}
return s
})
: rawStickers
setStickers(normalized)
} else if (dd ? dd.imageSrc : undefined) {
// 兼容旧版数据
const s: StickerItem = {
id: 'legacy_' + Date.now(),
src: design.designData.imageSrc,
x: design.designData.imagePos ? design.designData.imagePos.x || 0 : 0,
y: design.designData.imagePos ? design.designData.imagePos.y || 0 : 0,
scale: design.designData.imagePos ? design.designData.imagePos.scale || 1 : 1,
src: dd.imageSrc,
x: dd.imagePos ? dd.imagePos.x || 0 : 0,
y: dd.imagePos ? dd.imagePos.y || 0 : 0,
scale: dd.imagePos ? dd.imagePos.scale || 1 : 1,
width: 200,
height: 200,
isOverlapping: false
}
setStickers([s])
}
// 问题3 修正:继续设计同样进入「设计中」状态(单向状态机,undesigned → designing
if (design.status === 'undesigned') {
updateDesign(dId, { status: 'designing' })
updateDesignApi(dId, { status: 'designing' }).catch(() => {})
}
}
return
}
@@ -113,11 +133,71 @@ export default function DIYPage() {
const found = getProductById(productId)
if (found) {
setCategory(found)
void createDraftForProduct(found, 1)
createDesignEntry(found, 1)
}
}
}, [])
// ---------- 草稿持久化(修正工作流 问题2----------
// 贴纸变化防抖 800ms:本地缓存即时同步 + 服务端静默同步;不推状态(SUBMITTED 只由显式动作触发)
const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const latestRef = useRef({ designId: '', quantity: 1, category: null as any, stickers: [] as StickerItem[] })
latestRef.current = { designId, quantity, category, stickers }
const saveDraft = () => {
const { designId: dId, quantity: qty, category: cat, stickers: st } = latestRef.current
if (!dId || !cat) return
// WCD 红线:基于缓存中服务端最新 designData 合并(保留 wordcloud 分组),只覆盖本页编辑字段
const cached = getDesignList().find(d => d.id === dId)
const designData = {
...(cached && cached.designData ? cached.designData : {}),
version: 1 as const,
category: { id: cat.id, mask: cat.mask, ...(cat.tone ? { tone: cat.tone } : {}) },
stickers: st
}
updateDesign(dId, { designData }) // 本地缓存即时同步(问题4:贴纸编辑页从这里读取)
updateDesignApi(dId, {
item: { productId: cat.id, productName: cat.name, unitPrice: cat.price, count: qty, designData }
}).catch(() => {}) // 草稿静默降级:失败仅落缓存
}
const flushDraft = () => {
if (draftTimerRef.current) {
clearTimeout(draftTimerRef.current)
draftTimerRef.current = null
}
saveDraft()
}
// 隐藏页面(切后台/跳转)时强制落盘,防止防抖窗口内的改动丢失
Taro.useDidHide(flushDraft)
// 卸载时同样落盘
useEffect(() => flushDraft, [])
// eslint-disable-next-line react-hooks/exhaustive-deps
const skipDraftRef = useRef(true)
useEffect(() => {
if (skipDraftRef.current) { skipDraftRef.current = false; return }
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
draftTimerRef.current = setTimeout(saveDraft, 800)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stickers])
// 问题5 留意点:页面栈返回(贴纸编辑页保存后 navigateBack)不触发重新挂载,
// 需在 onShow 从缓存同步最新贴纸(src/edits),否则工作台仍渲染旧 state。
// 首次 onShow 跳过——mount 读取端已做过旧数据归一化,避免被未归一化的缓存覆盖。
const skipShowRef = useRef(true)
Taro.useDidShow(() => {
if (skipShowRef.current) { skipShowRef.current = false; return }
const dId = latestRef.current.designId
if (!dId) return
const cached = getDesignList().find(d => d.id === dId)
const list = cached && cached.designData ? cached.designData.stickers : undefined
if (list && list.length) {
setStickers(checkOverlap(list))
}
})
// 矩形碰撞检测
const checkOverlap = (list: StickerItem[]) => {
const newList = list.map(s => ({ ...s, isOverlapping: false }))
@@ -156,14 +236,17 @@ export default function DIYPage() {
Taro.getImageInfo({
src,
success: (info) => {
// 问题1 修正:归一化到画布坐标空间(width/height = 画布显示像素,契约实现注记)
const maskMin = Math.min(category.mask.width, category.mask.height)
const k = (maskMin * 0.6) / Math.max(info.width, info.height)
const newSticker: StickerItem = {
id: 'stk_' + Date.now(),
src,
x: 0,
y: 0,
scale: 1,
width: info.width,
height: info.height,
width: Math.round(info.width * k),
height: Math.round(info.height * k),
isOverlapping: false
}
const next = [...stickers, newSticker]
@@ -172,15 +255,17 @@ export default function DIYPage() {
setActiveStickerId(newSticker.id)
},
fail: () => {
// fallback 尺寸
// fallback 尺寸(同样按画布空间归一化)
const maskMin = Math.min(category.mask.width, category.mask.height)
const k = (maskMin * 0.6) / 200
const newSticker: StickerItem = {
id: 'stk_' + Date.now(),
src,
x: 0,
y: 0,
scale: 1,
width: 200,
height: 200,
width: Math.round(200 * k),
height: Math.round(200 * k),
isOverlapping: false
}
const next = [...stickers, newSticker]
@@ -212,6 +297,7 @@ export default function DIYPage() {
const handleStickerTouchMove = (e: any) => {
if (!isDragging || !activeStickerId) return
const touch = e.touches[0]
// 需求决定(2026-09-12):贴纸允许拖出画布外自由摆放,不做坐标钳制
const next = stickers.map(s => {
if (s.id !== activeStickerId) return s
return { ...s, x: touch.clientX - startPos.x, y: touch.clientY - startPos.y }
@@ -224,6 +310,7 @@ export default function DIYPage() {
const handleEditSticker = () => {
if (!activeStickerId || !designId) return
flushDraft() // 问题4:跳编辑页前清掉防抖窗口,缓存里保证有最新贴纸
Taro.navigateTo({
url: `/pages/diy/stickerEdit/index?designId=${designId}&stickerId=${activeStickerId}`
})
@@ -238,6 +325,18 @@ export default function DIYPage() {
setStickers(checked)
}
// 问题5 修正:渲染端消费贴纸 edits(亮度/色相/对比度 → CSS filter),
// 与贴纸编辑页同一呈现逻辑;checkout 预览消费 edits 记入 R3 待办
const stickerFilter = (s: StickerItem) => {
const ed = s.edits
if (!ed) return undefined
const b = ed.brightness || 0
const h = ed.hue || 0
const c = ed.contrast || 0
if (b === 0 && h === 0 && c === 0) return undefined
return `brightness(${100 + b}%) hue-rotate(${h}deg) contrast(${100 + c}%)`
}
const goBack = () => {
Taro.navigateBack()
}
@@ -255,22 +354,42 @@ export default function DIYPage() {
}
const handleComplete = async () => {
if (isCreatingDesign) {
Taro.showToast({ title: '正在创建设计,请稍候', icon: 'none' })
return
}
if (hasOverlap) {
Taro.showToast({ title: '贴纸不能重叠', icon: 'none' })
return
}
if (designId) {
if (designId && category) {
// 贴纸/底图持久化(决策#4,R4 负责):本地图 → COS 持久 URL,保证后端 WCD 打包可下载素材
const data = await persistDesignMedia({ stickers, category, version: 1 })
updateLocalDesign(designId, { designData: data })
// category 裁剪为 {id, mask, tone}(阶段0 决策#2;契约约束#3:mask 必须保留)
const trimmedCategory = {
id: category.id,
mask: category.mask,
...(category.tone ? { tone: category.tone } : {})
}
// WCD 红线:基于服务端已有 designData 合并(保留 R4 写入的 wordcloud 分组等),只覆盖本页编辑的字段
const cached = getDesignList().find(d => d.id === designId)
const designData = {
...(cached && cached.designData ? cached.designData : {}),
version: 1 as const,
category: trimmedCategory,
stickers: data.stickers || stickers
}
// 本地缓存同步(checkout 仍读缓存)
updateDesign(designId, { status: 'designing', designData })
try {
await updateRemoteDesign(designId, { designData: data, status: 'designing' })
await updateDesignApi(designId, {
status: 'designing',
item: {
productId: category.id,
productName: category.name,
unitPrice: category.price,
count: quantity,
designData
}
})
} catch {
// 离线草稿继续由本地存储承接;线上草稿会在下一次操作时重试同步。
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
}
}
setPreviewMode(false)
@@ -318,6 +437,7 @@ export default function DIYPage() {
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
opacity: isDragging && activeStickerId === s.id ? 0.8 : 1,
zIndex: activeStickerId === s.id ? 10 : 5,
filter: stickerFilter(s),
width: s.width || 200,
height: s.height || 200
}}
@@ -403,6 +523,7 @@ export default function DIYPage() {
src={s.src}
style={{
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})`,
filter: stickerFilter(s),
width: s.width || 200,
height: s.height || 200
}}
+37
View File
@@ -81,6 +81,16 @@
box-shadow: var(--shadow-card);
}
/* 预览图(Image + CSS filter,替代 Canvas 预览) */
.edit-preview-img {
width: 100%;
height: 480rpx;
background: var(--bg-input);
border-radius: 24rpx;
border: 1rpx solid var(--border-strong);
box-shadow: var(--shadow-card);
}
/* 工具面板 */
.edit-tools {
padding: 24rpx;
@@ -226,3 +236,30 @@
.mt-20 {
margin-top: 24rpx;
}
/* 空态(贴纸未找到) */
.edit-empty {
margin: 24rpx 24rpx 0;
padding: 60rpx 40rpx;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.edit-empty-title {
font-size: 32rpx;
font-weight: 600;
}
.edit-empty-desc {
font-size: 26rpx;
opacity: 0.65;
text-align: center;
}
.edit-empty .btn-primary {
padding: 16rpx 60rpx;
border-radius: 44rpx;
}
+129 -229
View File
@@ -1,10 +1,9 @@
import { View, Text, Canvas, Image } from '@tarojs/components'
import Taro, { createCanvasContext, canvasToTempFilePath } from '@tarojs/taro'
import { useState, useEffect, useRef, useCallback } from 'react'
import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect, useRef } from 'react'
import './index.scss'
import { getProductById } from '../../../utils/productConfig'
import { getDesignList, setDesignList, updateDesign, type DesignItem, type StickerItem } from '../../../utils/store'
import { sketchImage } from '../../../utils/api'
import { getDesignList, updateDesign, type StickerItem } from '../../../utils/store'
import { sketchImage, updateDesign as updateDesignApi } from '../../../utils/api'
import { useThemeContext } from '../../../context/ThemeContext'
import { useSafeArea } from '../../../hooks/useSafeArea'
import { useStatusBar } from '../../../hooks/useStatusBar'
@@ -13,55 +12,59 @@ import ScrollTopMask from '../../../components/ScrollTopMask'
import BottomActionBar from '../../../components/BottomActionBar'
/* ============================================================
使用 Canvas 实现贴纸编辑(亮度/色相/线稿)
贴纸编辑(亮度/色相/对比度/裁剪/线稿)
问题5 修正:edits 参数化保存(src 不变),预览用 Image + CSS filter
所见即所得,不再依赖 Canvas 2D ctx.filter(开发者工具不支持);
DIY 渲染端按同一 edits 套 CSS filter 呈现(契约 §4edits 不进 WCD
document.json,仅 manifest.metasrc 保持原样对 R4 无影响)。
============================================================ */
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
/** 拖动手势滑块组件 */
/** 拖动手势滑块组件(问题5 修正:选择器查询按 id 定位,touchstart 时缓存 rect */
interface SliderProps {
id: string
value: number
min: number
max: number
step?: number
onChange: (val: number) => void
showValue?: boolean
format?: (v: number) => string
}
function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProps) {
const trackRef = useRef<any>(null)
const [dragging, setDragging] = useState(false)
function TouchSlider({ id, value, min, max, onChange, format }: SliderProps) {
const rectRef = useRef<{ left: number; width: number } | null>(null)
const computedFromClientX = (clientX: number) => {
if (!trackRef.current) return value
const query = Taro.createSelectorQuery().in(trackRef.current)
// 注意:这里需要取 track 元素的位置信息
const rect = trackRef.current.getBoundingClientRect ? trackRef.current.getBoundingClientRect() : { left: 0, width: 300 }
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
let raw = min + (max - min) * ratio
if (step > 0) {
raw = Math.round(raw / step) * step
const measure = (cb?: () => void) => {
Taro.createSelectorQuery()
.select(`#${id}`)
.boundingClientRect((rect: any) => {
if (rect && rect.width > 0) rectRef.current = { left: rect.left, width: rect.width }
if (cb) cb()
})
.exec()
}
return clamp(raw, min, max)
const valueFromX = (clientX: number) => {
const rect = rectRef.current
if (!rect || !rect.width) return value
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
return clamp(min + (max - min) * ratio, min, max)
}
const handleTouchStart = (e: any) => {
setDragging(true)
const touch = e.touches && e.touches[0]
const x = touch ? touch.clientX : 0
onChange(computedFromClientX(x))
if (!touch) return
measure(() => onChange(valueFromX(touch.clientX)))
}
const handleTouchMove = (e: any) => {
if (!dragging) return
const touch = e.touches && e.touches[0]
const x = touch ? touch.clientX : 0
onChange(computedFromClientX(x))
if (!touch) return
if (!rectRef.current) {
measure(() => onChange(valueFromX(touch.clientX)))
return
}
const handleTouchEnd = () => {
setDragging(false)
onChange(valueFromX(touch.clientX))
}
const pct = `${((value - min) / (max - min)) * 100}%`
@@ -69,14 +72,14 @@ function TouchSlider({ value, min, max, step = 1, onChange, format }: SliderProp
return (
<View className='slider-wrap'>
<View
id={id}
className='slider-track'
ref={trackRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchEnd={() => { /* rect 已缓存,无需处理 */ }}
>
<View className='slider-fill' style={{ width: pct }} />
<View className={`slider-thumb ${dragging ? 'dragging' : ''}`} style={{ left: pct }} />
<View className='slider-thumb' style={{ left: pct }} />
</View>
<Text className='slider-value'>{format ? format(value) : value}</Text>
</View>
@@ -90,50 +93,15 @@ export default function StickerEditPage() {
const [designId, setDesignId] = useState('')
const [stickerId, setStickerId] = useState('')
const [sticker, setSticker] = useState<StickerItem | null>(null)
const [notFound, setNotFound] = useState(false)
// 裁剪/线稿产生的 src 替换(保存时一并落库;取消则丢弃)
const [pendingSrc, setPendingSrc] = useState<string | null>(null)
// 编辑参数
const [brightness, setBrightness] = useState(0)
const [hue, setHue] = useState(0)
const [contrast, setContrast] = useState(0)
const [loading, setLoading] = useState(false)
const [canvasReady, setCanvasReady] = useState(false)
const canvasRef = useRef<any>(null)
const ctxRef = useRef<any>(null)
const canvasNodeRef = useRef<any>(null)
const canvasSizeRef = useRef<{width:number, height:number}>({width:0,height:0})
// 使用 nextTick + 重试初始化 Canvas(小程序 2D)
const initCanvas = useCallback((st: StickerItem) => {
if (canvasReady) return
Taro.nextTick(() => {
const query = Taro.createSelectorQuery()
query.select('#editCanvas')
.fields({ node: true, size: true })
.exec((res: any) => {
const resFirst = res && res[0]
const canvas = resFirst ? resFirst.node : undefined
if (!canvas) {
setTimeout(() => initCanvas(st), 300)
return
}
const ctx = canvas.getContext('2d')
let cw = (res[0] && res[0].width) || 300
let ch = (res[0] && res[0].height) || 400
if (cw <= 0) cw = 300
if (ch <= 0) ch = 400
const dpr = Taro.getSystemInfoSync().pixelRatio || 1
canvas.width = Math.round(cw * dpr)
canvas.height = Math.round(ch * dpr)
ctx.scale(dpr, dpr)
canvasNodeRef.current = canvas
ctxRef.current = ctx
canvasSizeRef.current = { width: cw, height: ch }
setCanvasReady(true)
drawSticker(ctx, cw, ch, st)
})
})
}, [canvasReady])
useEffect(() => {
const router = Taro.getCurrentInstance().router
@@ -151,96 +119,44 @@ export default function StickerEditPage() {
setBrightness((st.edits && st.edits.brightness) || 0)
setHue((st.edits && st.edits.hue) || 0)
setContrast((st.edits && st.edits.contrast) || 0)
initCanvas(st)
}
}, [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'
// 问题4 留意点:找不到贴纸时给出空态,而不是停在空白 Canvas
setNotFound(true)
}
}, [])
ctx.drawImage(img, x, y, w, h)
ctx.restore()
}
img.onerror = (err: any) => {
console.error('Canvas load image error:', err, st.src)
Taro.showToast({ title: '图片加载失败', icon: 'none' })
}
if (st.src) {
img.src = st.src
}
}
/** 重新绘制(参数变化时) */
const redraw = () => {
if (!sticker || !ctxRef.current || !canvasNodeRef.current) return
const { width, height } = canvasSizeRef.current
drawSticker(ctxRef.current, width, height, sticker, { brightness, hue, contrast })
}
useEffect(() => {
redraw()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [brightness, hue, contrast])
/** 保存:edits 参数化持久化(src 不变),裁剪/线稿的 src 替换一并落库 */
const handleSave = () => {
if (!sticker || !designId) return
setLoading(true)
// 使用 Canvas 2D 导出
// @ts-ignore
Taro.canvasToTempFilePath({
canvas: canvasNodeRef.current,
quality: 0.92,
success: (res: any) => {
const newSrc = res.tempFilePath
const dList = getDesignList()
const dIdx = dList.findIndex(d => d.id === designId)
if (dIdx === -1) { setLoading(false); return }
const stickers = dList[dIdx].designData ? dList[dIdx].designData.stickers || [] : []
if (dIdx === -1) { setLoading(false); setNotFound(true); return }
const d = dList[dIdx]
const stickers = (d.designData && d.designData.stickers) || []
const sIdx = stickers.findIndex(s => s.id === stickerId)
if (sIdx === -1) { setLoading(false); return }
if (sIdx === -1) { setLoading(false); setNotFound(true); return }
stickers[sIdx] = {
...stickers[sIdx],
src: newSrc,
...(pendingSrc ? { src: pendingSrc } : {}),
edits: { brightness, hue, contrast }
}
updateDesign(designId, { designData: { ...dList[dIdx].designData, stickers } })
// WCD 红线:基于服务端已有 designData 合并(保留 wordcloud 分组),只覆盖 stickers
const designData = { ...d.designData, stickers }
updateDesign(designId, { designData }) // 本地缓存同步
// 服务端保存(items 全量替换,必须带全 4 个基本字段);失败暂存本地
updateDesignApi(designId, {
item: {
productId: d.productId,
productName: d.productName,
unitPrice: d.unitPrice,
count: d.count,
designData
}
}).catch(() => Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' }))
setLoading(false)
Taro.showToast({ title: '保存成功', icon: 'success' })
Taro.navigateBack()
},
fail: () => {
setLoading(false)
Taro.showToast({ title: '保存失败', icon: 'none' })
}
})
}
/** 裁剪:调用微信cropImage */
@@ -258,46 +174,61 @@ export default function StickerEditPage() {
success: (res) => {
const newSticker = { ...sticker, src: res.tempFilePath }
setSticker(newSticker)
// 重绘
setTimeout(() => redraw(), 200)
setPendingSrc(res.tempFilePath)
}
})
}
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度 */
/** 线稿:调真实后端接口(POST /api/sketch,带 token);失败降级前端灰度参数 */
const handleSketch = async () => {
if (!sticker) return
setLoading(true)
try {
const res = await sketchImage(sticker.src)
if (res?.imageUrl) {
const newSticker = { ...sticker, src: res.imageUrl, edits: { ...sticker.edits, sketchSrc: res.imageUrl } }
setSticker(newSticker)
setSticker({ ...sticker, src: res.imageUrl })
setPendingSrc(res.imageUrl)
setLoading(false)
setTimeout(() => redraw(), 200)
Taro.showToast({ title: '线稿生成成功', icon: 'success' })
} else {
throw new Error('no imageUrl')
}
} catch {
// 如果接口不可用/失败,降级为前端灰度+边缘检测
applyFrontendSketch()
}
}
/** 前端线稿降级方案:灰度+反相高对比 */
const applyFrontendSketch = () => {
if (!sticker || !ctxRef.current) { setLoading(false); return }
// 如果接口不可用/失败,降级为前端灰度滤镜参数(保存时随 edits 持久化)
Taro.showToast({ title: '使用本地线稿', icon: 'none' })
setBrightness(20)
setContrast(80)
setHue(0)
setLoading(false)
}
}
const bPct = `${((brightness + 100) / 200) * 100}%`
const hPct = `${((hue + 180) / 360) * 100}%`
const cPct = `${((contrast + 100) / 200) * 100}%`
const previewFilter = brightness === 0 && hue === 0 && contrast === 0
? 'none'
: `brightness(${100 + brightness}%) hue-rotate(${hue}deg) contrast(${100 + contrast}%)`
if (notFound) {
return (
<View className={`theme-${resolvedTheme}`}>
<ThemedPageMeta />
<ScrollTopMask title="编辑贴纸" targetSelector=".edit-header" showBack />
<View className='sticker-edit-page'>
<View className='edit-header' style={{ paddingTop: `${safe.statusBarHeight + 12}px` }}>
<Text className='edit-back' onTap={() => Taro.navigateBack()}></Text>
<Text className='edit-title'></Text>
<Text className='edit-save' />
</View>
<View className='edit-empty surface-card mt-20'>
<Text className='edit-empty-title'></Text>
<Text className='edit-empty-desc'></Text>
<View className='btn-primary' onTap={() => Taro.navigateBack()}>
<Text></Text>
</View>
</View>
</View>
</View>
)
}
return (
<View className={`theme-${resolvedTheme}`}>
@@ -311,13 +242,13 @@ export default function StickerEditPage() {
<Text className='edit-save' onTap={handleSave}></Text>
</View>
{/* Canvas 预览 */}
{/* 预览Image + CSS filter 所见即所得(不依赖 Canvas ctx.filter */}
<View className='edit-canvas-wrap'>
<Canvas
id='editCanvas'
type='2d'
className='edit-canvas'
style={{ width: '100%', height: '480rpx' }}
<Image
className='edit-preview-img'
src={sticker ? sticker.src : ''}
mode='aspectFit'
style={{ filter: previewFilter }}
/>
</View>
@@ -336,73 +267,42 @@ export default function StickerEditPage() {
</View>
{/* 亮度 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
// 取得滑轨位置
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-100 + 200 * ratio) / 1) * 1
setBrightness(clamp(v, -100, 100))
}).exec()
}}
>
<View className='edit-row slider-row'>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: bPct }} />
<View className='slider-thumb' style={{ left: bPct }} />
</View>
<Text className='slider-value'>{brightness > 0 ? `+${brightness}` : brightness}</Text>
</View>
<TouchSlider
id='slider-brightness'
value={brightness}
min={-100}
max={100}
onChange={setBrightness}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
{/* 色相 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-180 + 360 * ratio) / 1) * 1
setHue(clamp(v, -180, 180))
}).exec()
}}
>
<View className='edit-row slider-row'>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: hPct }} />
<View className='slider-thumb' style={{ left: hPct }} />
</View>
<Text className='slider-value'>{hue > 0 ? `+${hue}` : hue}</Text>
</View>
<TouchSlider
id='slider-hue'
value={hue}
min={-180}
max={180}
onChange={setHue}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
{/* 对比度 */}
<View className='edit-row slider-row'
onTouchMove={(e) => {
const { clientX } = e.changedTouches[0]
const query = Taro.createSelectorQuery().in(e.currentTarget as any)
query.select('.slider-track').boundingClientRect((rect: any) => {
if (!rect) return
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
const v = Math.round((-100 + 200 * ratio) / 1) * 1
setContrast(clamp(v, -100, 100))
}).exec()
}}
>
<View className='edit-row slider-row'>
<Text className='edit-label'></Text>
<View className='slider-wrap'>
<View className='slider-track'>
<View className='slider-fill' style={{ width: cPct }} />
<View className='slider-thumb' style={{ left: cPct }} />
</View>
<Text className='slider-value'>{contrast > 0 ? `+${contrast}` : contrast}</Text>
</View>
<TouchSlider
id='slider-contrast'
value={contrast}
min={-100}
max={100}
onChange={setContrast}
format={(v) => (v > 0 ? `+${v}` : `${v}`)}
/>
</View>
</View>
+20 -5
View File
@@ -81,8 +81,15 @@ export interface StickerItem {
export interface DesignDataV1 {
/** 结构版本;旧数据缺省视为 v1 */
version?: 1
/** 商品品类:mask 是画布尺寸来源,说明见 docs/mask-config-guide.md */
category?: ProductCategory
/**
* 商品品类(契约 §2 冻结:仅 {id, mask, tone}DIY 保存时裁剪);
* mask 是画布尺寸来源,说明见 docs/mask-config-guide.md
*/
category?: {
id: string
mask: MaskConfig
tone?: [number, number, number]
}
/** 底图(持久 URL),WCD 打包的画布底 */
background?: {
src: string
@@ -146,17 +153,23 @@ export interface WordCloudDispatchResult {
message?: string
}
/** 设计清单条目 */
/** 设计清单条目(与后端 DesignList 一一对应,api-contract-v1 §5 */
export interface DesignItem {
id: string
productId: string
productName: string
productIcon: string
/**
* 条目图标。服务端不存储(契约 forbidNonWhitelisted 拒绝多余字段),
* 页面按 PRODUCT_ICON_MAP[productId] 兜底推导;仅本地缓存/旧数据可能携带
*/
productIcon?: string
unitPrice: number
count: number
/** undesigned/designing 由前端驱动;processing/ordered 由服务端状态映射产生(R3 驱动) */
status: 'undesigned' | 'designing' | 'processing' | 'ordered'
designData?: DesignDataV1
orderId?: string
/** 服务端 createdAtISO),展示时取日期部分 */
createdAt: string
}
@@ -173,7 +186,7 @@ export interface OrderItem {
paymentExpiresAt?: string | null
}
/** 收货地址 */
/** 收货地址(后端 province/city/district/detail 以 region 数组表达,转换只在 api/address.ts */
export interface AddressItem {
id: string
name: string
@@ -181,6 +194,8 @@ export interface AddressItem {
region: string[] // [province, city, district]
detail: string // 门牌号/详细地址
isDefault: boolean
/** 服务端创建时间(ISO 8601),本地缓存数据可能没有 */
createdAt?: string
}
/** 编辑器中的图片状态(向后兼容) */
+74 -24
View File
@@ -1,43 +1,93 @@
/**
* R2 收货地址接口(api-contract-v1 §4)。
*
* 唯一负责 region: [province, city, district] ↔ 后端 province/city/district
* 双向转换的文件;页面与 store 不得出现第二次转换(route-r2 §3)。
*/
import http from '../request'
import type { AddressItem } from '../../types'
type AddressPayload = Omit<AddressItem, 'id' | 'region'> & { region: string[] }
type ServerAddress = { id: string; name: string; phone: string; province: string; city: string; district: string; detail: string; isDefault: boolean }
/** 后端 Address 结构(契约 §4:四个独立地址字段) */
interface ServerAddress {
id: string
name: string
phone: string
province: string
city: string
district: string
detail: string
isDefault: boolean
createdAt?: string
updatedAt?: string
}
const toClient = (address: ServerAddress): AddressItem => ({
id: address.id,
name: address.name,
phone: address.phone,
region: [address.province, address.city, address.district],
detail: address.detail,
isDefault: address.isDefault,
})
/** ServerAddress → 前端 AddressItem(合并省市区为 region 数组) */
function toAddressItem(rec: ServerAddress): AddressItem {
return {
id: rec.id,
name: rec.name,
phone: rec.phone,
region: [rec.province, rec.city, rec.district],
detail: rec.detail,
isDefault: rec.isDefault,
createdAt: rec.createdAt,
}
}
const toServer = (address: Partial<AddressPayload>) => ({
...(address.name === undefined ? {} : { name: address.name }),
...(address.phone === undefined ? {} : { phone: address.phone }),
...(address.region ? { province: address.region[0] || '', city: address.region[1] || '', district: address.region[2] || '' } : {}),
...(address.detail === undefined ? {} : { detail: address.detail }),
...(address.isDefault === undefined ? {} : { isDefault: address.isDefault }),
})
/** 前端 region 数组 → 后端四个独立字段 */
function toServerFields(addr: {
name?: string
phone?: string
region?: string[]
detail?: string
isDefault?: boolean
}): Record<string, unknown> {
// 后端 ValidationPipe forbidNonWhitelistedundefined 字段会被 JSON 序列化丢弃,
// 这里显式展开保证只传后端声明的字段
const body: Record<string, unknown> = {}
if (addr.name !== undefined) body.name = addr.name
if (addr.phone !== undefined) body.phone = addr.phone
if (addr.region !== undefined) {
const [province = '', city = '', district = ''] = addr.region
body.province = province
body.city = city
body.district = district
}
if (addr.detail !== undefined) body.detail = addr.detail
if (addr.isDefault !== undefined) body.isDefault = addr.isDefault
return body
}
/** 我的收货地址列表(默认地址在前) */
export async function fetchAddresses(): Promise<AddressItem[]> {
const rows = await http.get<ServerAddress[]>('/api/addresses')
return rows.map(toClient)
const list = await http.get<ServerAddress[]>('/api/addresses')
return (list || []).map(toAddressItem)
}
export async function createAddress(address: Omit<AddressItem, 'id'>): Promise<AddressItem> {
return toClient(await http.post<ServerAddress>('/api/addresses', toServer(address)))
/** 新增收货地址(首个地址后端自动设为默认) */
export async function createAddress(
addr: Omit<AddressItem, 'id' | 'createdAt'>,
): Promise<AddressItem> {
const rec = await http.post<ServerAddress>('/api/addresses', toServerFields(addr))
return toAddressItem(rec)
}
export async function updateAddress(id: string, patch: Partial<AddressItem>): Promise<AddressItem> {
return toClient(await http.patch<ServerAddress>(`/api/addresses/${id}`, toServer(patch)))
/** 更新本人地址(只传显式给出的字段;isDefault=true 由后端事务保证唯一默认) */
export async function updateAddress(
id: string,
patch: Partial<Omit<AddressItem, 'id' | 'createdAt'>>,
): Promise<AddressItem> {
const rec = await http.patch<ServerAddress>(`/api/addresses/${id}`, toServerFields(patch))
return toAddressItem(rec)
}
/** 删除本人地址(若删的是默认地址,后端自动补偿最新一条为默认) */
export async function deleteAddress(id: string): Promise<void> {
await http.del(`/api/addresses/${id}`)
}
/** 设为默认地址 */
export async function setDefaultAddress(id: string): Promise<AddressItem> {
return toClient(await http.patch<ServerAddress>(`/api/addresses/${id}/default`))
const rec = await http.patch<ServerAddress>(`/api/addresses/${id}/default`)
return toAddressItem(rec)
}
+106 -39
View File
@@ -1,60 +1,127 @@
/**
* R2 设计清单接口(api-contract-v1 §5)。
*
* 映射约定(阶段0 决策,见 docs/r2-workflow.md):
* - 一条前端 DesignItem = 一条后端 DesignList 记录,items 固定 1 个元素;
* - title 由 productName 承担(后端必填);
* - productIcon 不入库(后端 forbidNonWhitelisted 会拒绝),页面按 productId 兜底推导;
* - 状态映射:DRAFT↔undesigned、SUBMITTED↔designing、PROCESSING↔processing、DONE↔ordered
* - 前端只允许提交 designing(→SUBMITTED),PROCESSING/DONE 由 R3 驱动。
*
* fetchDesignGET /api/design-list/:id)由 R3 结算页需要而增加;
* 后端已实现该端点(design-list.controller.ts @Get(':id')),契约 §5 文档补记。
*/
import http from '../request'
import type { DesignDataV1, DesignItem } from '../../types'
import { getProductIconImg } from '../productConfig'
type ServerEntry = { productId: string; productName: string; unitPrice: number; count: number; designData?: DesignDataV1 }
type ServerList = { id: string; title: string; items: ServerEntry[]; status: 'DRAFT' | 'SUBMITTED' | 'PROCESSING' | 'DONE'; orderId?: string; createdAt: string }
const statusToClient = (item: ServerList): DesignItem['status'] => {
if (item.orderId || item.status === 'DONE') return 'ordered'
if (item.status === 'PROCESSING') return 'processing'
if (item.status === 'DRAFT') return 'undesigned'
return 'designing'
/** 后端 DesignListStatus → 前端状态码(契约 §5 映射表) */
const SERVER_STATUS_MAP: Record<string, DesignItem['status']> = {
DRAFT: 'undesigned',
SUBMITTED: 'designing',
PROCESSING: 'processing',
DONE: 'ordered',
}
const statusToServer = (status: DesignItem['status']) =>
status === 'undesigned' ? 'DRAFT' : status === 'designing' ? 'SUBMITTED' : status === 'processing' ? 'PROCESSING' : 'DONE'
/** 清单条目(后端 items[] 固定 1 个元素,契约 §5) */
export interface DesignListEntryPayload {
productId: string
productName: string
unitPrice: number
count: number
designData?: DesignDataV1
}
const toClient = (list: ServerList): DesignItem => {
const item = list.items[0]
/** 后端 DesignList 记录结构 */
interface ServerDesignList {
id: string
userId?: string
title: string
items: (Partial<DesignListEntryPayload> & Record<string, unknown>)[]
status: string
createdAt: string
updatedAt?: string
}
/** ServerDesignList → 前端 DesignItem */
function toDesignItem(rec: ServerDesignList): DesignItem {
const entry = rec.items && rec.items[0]
return {
id: list.id,
productId: item.productId,
productName: item.productName,
productIcon: getProductIconImg({ id: item.productId }),
unitPrice: Number(item.unitPrice),
count: item.count,
status: statusToClient(list),
designData: item.designData,
orderId: list.orderId,
createdAt: list.createdAt,
id: rec.id,
productId: entry?.productId ?? '',
productName: entry?.productName || rec.title,
// productIcon 有意不返回:服务端不存储,页面按 PRODUCT_ICON_MAP[productId] 兜底
unitPrice: entry?.unitPrice ?? 0,
count: entry?.count ?? 1,
status: SERVER_STATUS_MAP[rec.status] ?? 'undesigned',
designData: entry?.designData,
createdAt: (rec.createdAt || '').slice(0, 10),
}
}
/** 我的设计清单(createdAt 倒序) */
export async function fetchDesignList(): Promise<DesignItem[]> {
const rows = await http.get<ServerList[]>('/api/design-list')
return rows.filter(row => row.items?.length > 0).map(toClient)
const list = await http.get<ServerDesignList[]>('/api/design-list')
return (list || []).map(toDesignItem)
}
/** 单条清单详情(R3 结算页使用;后端 @Get(':id'),本人校验) */
export async function fetchDesign(id: string): Promise<DesignItem> {
return toClient(await http.get<ServerList>(`/api/design-list/${id}`))
return toDesignItem(await http.get<ServerDesignList>(`/api/design-list/${id}`))
}
export async function createDesign(item: Omit<DesignItem, 'id' | 'createdAt' | 'status' | 'productIcon'>): Promise<DesignItem> {
return toClient(await http.post<ServerList>('/api/design-list', { title: item.productName, items: [{ productId: item.productId, productName: item.productName, unitPrice: item.unitPrice, count: item.count, designData: item.designData }] }))
/** 创建清单(一条设计一条清单;后端初始状态 DRAFT/undesigned */
export async function createDesign(entry: DesignListEntryPayload): Promise<DesignItem> {
const rec = await http.post<ServerDesignList>('/api/design-list', {
title: entry.productName,
items: [
{
productId: entry.productId,
productName: entry.productName,
unitPrice: entry.unitPrice,
count: entry.count,
...(entry.designData !== undefined ? { designData: entry.designData } : {}),
},
],
})
return toDesignItem(rec)
}
export async function updateDesign(id: string, patch: Partial<DesignItem>): Promise<DesignItem> {
const current = await fetchDesign(id)
const merged = { ...current, ...patch }
const data: Record<string, unknown> = {}
if (patch.designData !== undefined || patch.productId !== undefined || patch.productName !== undefined || patch.unitPrice !== undefined || patch.count !== undefined) {
data.items = [{ productId: merged.productId, productName: merged.productName, unitPrice: merged.unitPrice, count: merged.count, designData: merged.designData }]
export interface UpdateDesignPayload {
/** 新标题(前端一般不传) */
title?: string
/** 状态迁移:前端只允许 designing(→SUBMITTEDDIY 保存设计时) */
status?: 'designing'
/** 条目整体替换(items 是全量覆盖,修改 designData 时必须带全 4 个基本字段) */
item?: DesignListEntryPayload
}
/** 更新本人清单(部分更新:只传显式给出的字段) */
export async function updateDesign(id: string, payload: UpdateDesignPayload): Promise<DesignItem> {
const body: Record<string, unknown> = {}
if (payload.title !== undefined) body.title = payload.title
if (payload.status !== undefined) body.status = 'SUBMITTED'
if (payload.item !== undefined) {
body.items = [
{
productId: payload.item.productId,
productName: payload.item.productName,
unitPrice: payload.item.unitPrice,
count: payload.item.count,
...(payload.item.designData !== undefined ? { designData: payload.item.designData } : {}),
},
]
}
if (patch.productName !== undefined) data.title = patch.productName
if (patch.status !== undefined) data.status = statusToServer(patch.status)
return toClient(await http.patch<ServerList>(`/api/design-list/${id}`, data))
const rec = await http.patch<ServerDesignList>(`/api/design-list/${id}`, body)
return toDesignItem(rec)
}
export async function deleteDesign(id: string): Promise<void> { await http.del(`/api/design-list/${id}`) }
export async function deleteDesigns(ids: string[]): Promise<void> { await http.post('/api/design-list/batch-delete', { ids }) }
/** 删除本人清单 */
export async function deleteDesign(id: string): Promise<void> {
await http.del(`/api/design-list/${id}`)
}
/** 批量删除(后端只删本人条目,返回实际删除数;部分 id 无效不报错) */
export async function deleteDesigns(ids: string[]): Promise<number> {
const res = await http.post<{ deleted: number }>('/api/design-list/batch-delete', { ids })
return res?.deleted ?? 0
}
+7
View File
@@ -1,3 +1,10 @@
/**
* 收货地址本地存储 —— R2 后降级为「离线兜底缓存」层(按 openid 隔离,见 keys.ts)。
* 真实 CRUD 已走 utils/api/address.ts;本层职责:
* - 页面 API 成功 → 回写缓存,供冷启动先展示与未迁移页面(checkout 等)读取;
* - API 失败/断网 → 页面读缓存正常浏览;
* - 联网刷新以服务端为准覆盖缓存,旧本地数据不做自动合并(route-r2 风险节:默认忽略)。
*/
import Taro from '@tarojs/taro'
import type { AddressItem } from '../../types'
import { key } from './keys'
+7
View File
@@ -1,3 +1,10 @@
/**
* 设计清单本地存储 —— R2 后降级为「离线兜底缓存」层(按 openid 隔离,见 keys.ts)。
* 真实 CRUD 已走 utils/api/design.ts;本层职责:
* - 页面 API 成功 → 回写缓存,供冷启动先展示与未迁移页面(checkout 等)读取;
* - API 失败/断网 → 页面读缓存正常浏览;
* - 联网刷新以服务端为准覆盖缓存,旧本地数据不做自动合并(route-r2 风险节:默认忽略)。
*/
import Taro from '@tarojs/taro'
import type { DesignItem } from '../../types'
import { assetUrl } from '../asset'