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> </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
+32 -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,28 @@ 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(() => Taro.showToast({ title: '保存成功', icon: 'success' }))
} catch (error) { .catch(() => {
// API 失败时保留原有本地兜底,避免离线编辑丢失。 // 服务端不可达时降级本地保存,联网后进入页面会刷新
if (editing) updateLocalAddress(editing.id, payload); else addAddress(payload) if (editing) updateAddress(editing.id, payload)
Taro.showToast({ title: (error as Error).message || '保存失败,已暂存本地', icon: 'none' }); setShowForm(false); resetForm(); load() else addAddress(payload)
} Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
})
.finally(() => {
setShowForm(false)
resetForm()
load()
})
} }
const onRegionChange = (e: any) => { const onRegionChange = (e: any) => {
@@ -143,7 +149,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>
+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 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 +180,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
}} }}
+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 }> = { 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 +41,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(() => {
+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>
+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'