Compare commits
6
Commits
127858723e
...
0e6b0694e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6b0694e4 | ||
|
|
a19519fee6 | ||
|
|
ba95037d05 | ||
|
|
3de6128cae | ||
|
|
99bcf4a1d3 | ||
|
|
82b0cc485e |
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
-8
@@ -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>
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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()}]);
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{7914:function(e,n,t){var a=t(7842),c=t(5544),r=t(118),u=t(758),i=t.n(u),o=t(6540),s=t(1457),l=t(9703),f=t(6606),h=t(8199);function g(e,n,t){return Number.isFinite(e)?Math.min(t,Math.max(n,e)):n}function v(e){return g(e,0,100)}function d(e,n){return!Number.isFinite(e)||n<=0?0:g(Math.floor(v(e)/100*n),0,n-1)}function m(e,n,t){return Math.abs(n-e)>=t}function p(e){var n=e/750;return{left:36*n,width:Math.max(0,e-72*n)}}var b=t(4848),x=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}],C=100/x.length,S=12;function P(e){var n=x.findIndex(function(n){return e.endsWith(n.pagePath)});return x[n>=0?n:0].pagePath}function T(){var e=i().getCurrentInstance().router,n=e&&e.path?e.path:"pages/index/index";return P(n)}function q(){var e=(0,o.useState)((0,s.eW)((0,s.O4)())),n=(0,c.A)(e,2),t=n[0],a=n[1],u=(0,o.useState)(T),h=(0,c.A)(u,2),g=h[0],q=h[1],N=(0,o.useState)(null),w=(0,c.A)(N,2),A=w[0],j=w[1],M=(0,o.useState)(!1),E=(0,c.A)(M,2),I=E[0],R=E[1],W=(0,o.useRef)(!1),k=(0,o.useRef)(null),O=(0,o.useRef)(0),X=(0,o.useRef)(null);(0,o.useEffect)(function(){var e=function(e){return a(e)};return i().eventCenter.on(l.Wo,e),function(){i().eventCenter.off(l.Wo,e)}},[]),(0,o.useEffect)(function(){var e=function(e){!e||"dark"!==e.theme&&"light"!==e.theme||((0,s.Rq)(e.theme),"auto"===(0,s.O4)()&&a(e.theme))},n=i();return n.onThemeChange&&n.onThemeChange(e),function(){n.offThemeChange&&n.offThemeChange(e)}},[]),(0,o.useEffect)(function(){var e=function(e){var n=i().getCurrentInstance().router,t=n&&n.path?n.path:"pages/index/index";q(P(e||t))};return i().eventCenter.on("tabBarChange",e),function(){i().eventCenter.off("tabBarChange",e)}},[]),(0,o.useEffect)(function(){(0,f.G)(t)},[t]);var y=function(e){q(P(e)),i().switchTab({url:e}),i().eventCenter.trigger("tabBarChange",e)},B=function(e){var n=e.touches&&e.touches[0];if(n){var t=p(i().getSystemInfoSync().windowWidth);X.current=t,O.current=n.clientX,W.current=!1,R(!1),k.current=null,j(null)}},F=function(e){var n=X.current,t=e.touches&&e.touches[0];if(n&&t){if(!W.current){if(!m(O.current,t.clientX,S))return;W.current=!0,R(!0)}var a=v((t.clientX-n.left)/n.width*100);k.current=a,j(a)}},J=function(e){var n=X.current;if(n){var t;if(W.current){var a;t=d(null!==(a=k.current)&&void 0!==a?a:0,x.length),W.current=!1,R(!1),i().nextTick(function(){k.current=null,j(null)})}else{var c=e.changedTouches&&e.changedTouches[0],r=c?c.clientX:O.current;t=d(v((r-n.left)/n.width*100),x.length)}var u=x[t];u&&u.pagePath!==g&&y(u.pagePath),X.current=null,O.current=0}},G=function(){X.current=null,O.current=0,W.current=!1,R(!1),k.current=null,j(null)},L=Math.max(0,x.findIndex(function(e){return e.pagePath===g}));return(0,b.jsxs)(r.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,b.jsx)(r.Ss,{className:"tab-bar-gradient"}),(0,b.jsxs)(r.Ss,{className:"custom-tab-bar theme-".concat(t),onTouchStart:B,onTouchMove:F,onTouchEnd:J,onTouchCancel:G,catchMove:!0,children:[(0,b.jsx)(r.Ss,{className:"tab-bar-caustic"}),(0,b.jsx)(r.Ss,{className:"tab-bar-rim"}),(0,b.jsx)(r.Ss,{className:"tab-indicator ".concat(I?"dragging":""),style:{left:"".concat(null===A?(L+.5)*C:A,"%")}}),x.map(function(e){var n=e.pagePath===g;return(0,b.jsxs)(r.Ss,{className:"tab-item ".concat(n?"active":""),children:[(0,b.jsx)(r._V,{className:"tab-icon-img ".concat(n?"active":""),src:n?e.iconActive:e.icon,mode:"aspectFit"}),(0,b.jsx)(r.EY,{className:"tab-label",children:e.label})]},e.pagePath)})]})]})}Component((0,a.createComponentConfig)(q,"custom-tab-bar/index"))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[907,96,76],function(){return n(7914)});e.O()}]);
|
"use strict";(wx["webpackJsonp"]=wx["webpackJsonp"]||[]).push([[777],{2124:function(n,e,t){var a=t(5712),c=t(1433),r=t(408),u=t(3048),i=t.n(u),o=t(2785),s=t(1679),l=t(1986),f=t(1099),h=t(7042);function g(n,e,t){return Number.isFinite(n)?Math.min(t,Math.max(e,n)):e}function v(n){return g(n,0,100)}function p(n,e){return!Number.isFinite(n)||e<=0?0:g(Math.floor(v(n)/100*e),0,e-1)}function d(n,e,t){return Math.abs(e-n)>=t}function m(n){var e=n/750;return{left:36*e,width:Math.max(0,n-72*e)}}var b=t(6517),x=[{pagePath:"/pages/index/index",label:"\u9996\u9875",icon:(0,h.uq)("/icon/\u9996\u9875.svg"),iconActive:(0,h.uq)("/icon/\u9996\u9875-fill.svg")},{pagePath:"/pages/shop/index",label:"\u5546\u54c1",icon:(0,h.uq)("/icon/\u5546\u54c1.svg"),iconActive:(0,h.uq)("/icon/\u5546\u54c1-fill.svg")},{pagePath:"/pages/designList/index",label:"\u8bbe\u8ba1\u6e05\u5355",icon:(0,h.uq)("/icon/\u8c03\u8272\u76d8.svg"),iconActive:(0,h.uq)("/icon/\u8c03\u8272\u76d8-fill.svg")},{pagePath:"/pages/orders/index",label:"\u8ba2\u5355",icon:(0,h.uq)("/icon/\u5305\u88f9.svg"),iconActive:(0,h.uq)("/icon/\u5305\u88f9-fill.svg")},{pagePath:"/pages/profile/index",label:"\u6211\u7684",icon:(0,h.uq)("/icon/\u4e2a\u4eba.svg"),iconActive:(0,h.uq)("/icon/\u4e2a\u4eba-fill.svg")}],C=100/x.length,P=12;function S(n){var e=x.findIndex(function(e){return n.endsWith(e.pagePath)});return x[e>=0?e:0].pagePath}function T(){var n=i().getCurrentInstance().router,e=n&&n.path?n.path:"pages/index/index";return S(e)}function q(){var n=(0,o.useState)((0,s.eW)((0,s.O4)())),e=(0,c.A)(n,2),t=e[0],a=e[1],u=(0,o.useState)(T),h=(0,c.A)(u,2),g=h[0],q=h[1],N=(0,o.useState)(null),w=(0,c.A)(N,2),A=w[0],j=w[1],M=(0,o.useState)(!1),E=(0,c.A)(M,2),k=E[0],I=E[1],R=(0,o.useRef)(!1),W=(0,o.useRef)(null),O=(0,o.useRef)(0),X=(0,o.useRef)(null);(0,o.useEffect)(function(){var n=function(n){return a(n)};return i().eventCenter.on(l.Wo,n),function(){i().eventCenter.off(l.Wo,n)}},[]),(0,o.useEffect)(function(){var n=function(n){!n||"dark"!==n.theme&&"light"!==n.theme||((0,s.Rq)(n.theme),"auto"===(0,s.O4)()&&a(n.theme))},e=i();return e.onThemeChange&&e.onThemeChange(n),function(){e.offThemeChange&&e.offThemeChange(n)}},[]),(0,o.useEffect)(function(){var n=function(n){var e=i().getCurrentInstance().router,t=e&&e.path?e.path:"pages/index/index";q(S(n||t))};return i().eventCenter.on("tabBarChange",n),function(){i().eventCenter.off("tabBarChange",n)}},[]),(0,o.useEffect)(function(){(0,f.G)(t)},[t]);var y=function(n){q(S(n)),i().switchTab({url:n}),i().eventCenter.trigger("tabBarChange",n)},B=function(n){R.current||n===g||y(n)},F=function(n){var e=n.touches&&n.touches[0];if(e){var t=m(i().getSystemInfoSync().windowWidth);X.current=t,O.current=e.clientX,R.current=!1,I(!1),W.current=null,j(null)}},J=function(n){var e=X.current,t=n.touches&&n.touches[0];if(e&&t){if(!R.current){if(!d(O.current,t.clientX,P))return;R.current=!0,I(!0)}var a=v((t.clientX-e.left)/e.width*100);W.current=a,j(a)}},G=function(n){var e=X.current;if(e){var t;if(R.current){var a;t=p(null!==(a=W.current)&&void 0!==a?a:0,x.length),R.current=!1,I(!1),i().nextTick(function(){W.current=null,j(null)})}else{var c=n.changedTouches&&n.changedTouches[0],r=c?c.clientX:O.current;t=p(v((r-e.left)/e.width*100),x.length)}var u=x[t];u&&u.pagePath!==g&&y(u.pagePath),X.current=null,O.current=0}},L=function(){X.current=null,O.current=0,R.current=!1,I(!1),W.current=null,j(null)},V=Math.max(0,x.findIndex(function(n){return n.pagePath===g}));return(0,b.jsxs)(r.Ss,{className:"tab-bar-root theme-".concat(t),children:[(0,b.jsx)(r.Ss,{className:"tab-bar-gradient"}),(0,b.jsxs)(r.Ss,{className:"custom-tab-bar theme-".concat(t),onTouchStart:F,onTouchMove:J,onTouchEnd:G,onTouchCancel:L,catchMove:!0,children:[(0,b.jsx)(r.Ss,{className:"tab-bar-caustic"}),(0,b.jsx)(r.Ss,{className:"tab-bar-rim"}),(0,b.jsx)(r.Ss,{className:"tab-indicator ".concat(k?"dragging":""),style:{left:"".concat(null===A?(V+.5)*C:A,"%")}}),x.map(function(n){var e=n.pagePath===g;return(0,b.jsxs)(r.Ss,{className:"tab-item ".concat(e?"active":""),onTap:function(){return B(n.pagePath)},onClick:function(){return B(n.pagePath)},children:[(0,b.jsx)(r._V,{className:"tab-icon-img ".concat(e?"active":""),src:e?n.iconActive:n.icon,mode:"aspectFit"}),(0,b.jsx)(r.EY,{className:"tab-label",children:n.label})]},n.pagePath)})]})]})}Component((0,a.createComponentConfig)(q,"custom-tab-bar/index"))}},function(n){var e=function(e){return n(n.s=e)};n.O(0,[907,96,76],function(){return e(2124)});n.O()}]);
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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()}]);
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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()}]);
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,95 @@
|
|||||||
|
# 微信小程序本地开发协议
|
||||||
|
|
||||||
|
> 适用范围:`wechat_wc` 小程序前端及其本机 Docker 联调。
|
||||||
|
> 依据:微信开放文档“小程序开发指南”、页面路由与自定义 tabBar 规范。最后核对:2026-09-16。
|
||||||
|
|
||||||
|
## 1. 目录与构建边界
|
||||||
|
|
||||||
|
- 业务源码只修改 `src/`、`config/` 和受版本管理的配置文件;`dist/` 是 Taro 编译产物,禁止手工修补。
|
||||||
|
- 每次修改小程序源码后执行 `npm run build:weapp`,微信开发者工具固定打开 `wechat_wc/dist`。
|
||||||
|
- `project.config.json`、`app.json` 等由构建生成时,以 `src/app.config.ts` 与项目配置为唯一来源;新增页面、tab 页必须先更新源配置。
|
||||||
|
- 联调接口地址只从 `.env`/构建变量读取,不在页面内写死 `127.0.0.1`、域名或密钥。
|
||||||
|
|
||||||
|
## 2. 页面与路由规范
|
||||||
|
|
||||||
|
微信将页面组织为页面栈;`navigateTo` 只能打开非 tab 页,`switchTab` 只能切到 tab 页。连续发起的路由会排队,因此一次用户操作只能发起一次导航。
|
||||||
|
|
||||||
|
| 场景 | 使用方式 | 本项目约束 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 打开详情、编辑、结算等非 tab 页面 | `Taro.navigateTo` | 由列表项或按钮发起一次,不再在父级触摸结束时二次跳转。 |
|
||||||
|
| 返回上一页 | `Taro.navigateBack` | 返回页在 `useDidShow` 中刷新可能已变更的数据。 |
|
||||||
|
| 切换首页、商品、设计清单、订单、我的 | `Taro.switchTab` | 目标必须在 `src/app.config.ts` 的 `tabBar.list` 中。 |
|
||||||
|
| 替换当前非 tab 页面 | `Taro.redirectTo` | 仅在不应保留返回栈时使用。 |
|
||||||
|
|
||||||
|
- `useEffect(..., [])` 只承担一次初始化;地址、订单、设计清单等从子页返回后可能变更的数据,必须在 `useDidShow` 刷新。
|
||||||
|
- 路由路径比较前统一为 `/pages/...` 格式,避免 `pages/...` 与 `/pages/...` 导致选中态误判。
|
||||||
|
- 跳转函数必须有明确的唯一职责:设置筛选条件、调用一次路由 API;不得混入重复路由、延时跳转或无关 Toast 控制。
|
||||||
|
|
||||||
|
## 3. 自定义 tabBar 规范
|
||||||
|
|
||||||
|
项目使用 `tabBar.custom = true`。微信规范要求仍完整声明 tabBar 页面;自定义组件仅负责渲染,并且每个 tab 页的 tabBar 实例彼此独立。
|
||||||
|
|
||||||
|
- 选中态唯一来源是当前 tab 页路径;进入、返回或外部 `switchTab` 后都应同步一次。
|
||||||
|
- 单击由子 tab 项处理;拖动仅由拖动结束逻辑处理。两者不可同时触发同一次 `switchTab`。
|
||||||
|
- 灰色选中遮罩只跟随一个 `activeTab` 状态;普通切换保留 CSS 的 `left` 过渡,拖动期间才临时关闭过渡。
|
||||||
|
- 自定义 tabBar 根节点维持可命中(`pointer-events: auto`)及正确层级;不得为了修复一个页面而随意改动全部 tabBar 的视觉样式。
|
||||||
|
- 对 tabBar 的任何修改,至少验证:首页 → 我的、我的快捷入口 → 设计清单/订单、拖动切换、返回 tab 页四种路径。
|
||||||
|
|
||||||
|
## 4. 数据、登录与接口规范
|
||||||
|
|
||||||
|
- 账户维度的数据(地址、设计、订单)以后端用户 ID 为准;本地 storage 只能用于离线兜底和首屏缓存。
|
||||||
|
- 真机联调使用 `WX_MOCK_LOGIN=1` 时,后端使用稳定的本地测试用户;不得用一次性的 `wx.login` code 作为用户标识。
|
||||||
|
- 地址、订单、设计列表请求成功后回写对应缓存;请求失败才回退缓存。不要并存两套相互覆盖的业务真相。
|
||||||
|
- DTO/接口字段转换只放在 `src/utils/api/` 的 adapter 中。页面层只消费前端类型,避免每个页面各自转换省市区、订单状态或设计状态。
|
||||||
|
- 对创建订单等写操作使用 requestId 幂等键;页面按钮在请求期间禁用,防止重复提交。
|
||||||
|
|
||||||
|
## 5. Toast、Loading 与错误处理
|
||||||
|
|
||||||
|
- 只关闭当前流程实际开启过的 Loading/Toast。禁止在每次点击前无条件调用 `hideToast()`;没有可关闭 Toast 时会产生运行时告警。
|
||||||
|
- 网络失败应保留可理解的用户提示,并在控制台记录原始错误;不能用空白页、静默吞错或无限 loading 替代错误状态。
|
||||||
|
- 真机控制台中与微信广告、日志目录等系统组件相关的文件不存在提示,需要与本项目业务报错分开判断;优先处理带有本项目页面、接口或栈信息的错误。
|
||||||
|
|
||||||
|
## 6. 本机联调与验收
|
||||||
|
|
||||||
|
1. 启动后端:在 `wxmp_backend` 执行 `docker compose up -d --build`,确认 `http://127.0.0.1:3090/health` 返回 `code: 0`。
|
||||||
|
2. 编译前端:在 `wechat_wc` 执行 `npm run build:weapp`。
|
||||||
|
3. 微信开发者工具打开 `wechat_wc/dist`,本机调试使用对应的 API 基址;真机调试使用已备案、已配置合法域名的 HTTPS 基址。
|
||||||
|
4. 每个涉及导航或状态的改动至少验证:首次进入、返回页面、切换 tab、真机预览四项。
|
||||||
|
5. 验收通过后只提交源码与必要配置/文档;不得把偶然生成的 source map、个人配置或密钥提交到仓库。
|
||||||
|
|
||||||
|
## 7. 修改前检查清单
|
||||||
|
|
||||||
|
- 该需求属于页面、路由、数据还是视觉?只改对应层。
|
||||||
|
- 是否已有公共 API adapter、store 或组件可复用?有则复用,不复制一套逻辑。
|
||||||
|
- 是否会影响 tab 页、页面栈或真机数据?会则补充 `useDidShow`/真机验证。
|
||||||
|
- 是否能用一次状态更新和一次路由调用完成?能则不要叠加事件、计时器或重复 API。
|
||||||
|
- 是否通过 `npm run build:weapp`?未通过不得交付。
|
||||||
|
|
||||||
|
## 8. 官方资料总索引与使用规则
|
||||||
|
|
||||||
|
以下八个官方入口已于 2026-09-16 连通性核对成功。它们是开发依据,不复制官网全文到仓库;每次新增微信能力、组件或部署方式时,必须先查对应入口及其下级规范。
|
||||||
|
|
||||||
|
| 官方资料 | 本项目何时必须查阅 | 当前约束 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [小程序框架参考文档](https://developers.weixin.qq.com/miniprogram/dev/reference/index) | 改页面生命周期、路由、组件模型、渲染或分包 | 本项目使用 Taro 生成小程序代码,但必须遵守微信页面栈、tab 页和生命周期规则。 |
|
||||||
|
| [小程序组件参考文档](https://developers.weixin.qq.com/miniprogram/dev/component/) | 新增或修改 `View`、`ScrollView`、`Input`、`Picker`、`Button`、`Image` 等界面组件 | 先确认属性、事件和基础库兼容性;不以 Web DOM 行为作假设。 |
|
||||||
|
| [小程序 API 参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/) | 调用登录、网络、存储、媒体选择、支付、地址、文件或系统接口 | 所有 `Taro.*` 调用须对应微信 API 能力,并处理 success/fail 和真机差异。 |
|
||||||
|
| [小程序服务端 API 参考文档](https://developers.weixin.qq.com/miniprogram/dev/server/API/) | 接入真实微信登录、支付、消息、内容安全或服务端凭证 | AppSecret、access_token、支付证书只留在后端环境变量;前端不可保存或转发。 |
|
||||||
|
| [微信开发者工具参考文档](https://developers.weixin.qq.com/miniprogram/dev/devtools/devtools) | 编译、预览、真机调试、上传、Source Map、性能诊断 | `dist` 为唯一导入目录;提交前至少完成开发者工具编译和真机预览。 |
|
||||||
|
| [微信云托管参考文档](https://developers.weixin.qq.com/miniprogram/dev/wxcloudservice/wxcloudrun/src/basic/intro) | 评估将 Nest/Docker 后端迁移至微信云托管 | 当前后端仍以 Docker Compose 部署;迁移前必须重新确认镜像、域名、环境变量和网络方案。 |
|
||||||
|
| [微信云开发参考文档](https://developers.weixin.qq.com/miniprogram/dev/wxcloudservice/wxcloud/basis/getting-started) | 评估云函数、云数据库、云存储或云调用 | 当前项目不混用云开发数据源;若启用,先写迁移方案和接口契约,避免与 PostgreSQL 双写。 |
|
||||||
|
| [小程序 AI 能力参考文档](https://developers.weixin.qq.com/miniprogram/dev/ai/guide) | 新增微信原生 AI 能力,或替换现有词云生成链路 | 现有词云走自建后端契约;切换前必须评估权限、资费、数据安全和失败降级。 |
|
||||||
|
|
||||||
|
### 资料使用的最小闭环
|
||||||
|
|
||||||
|
1. 先在表中确定本次改动对应的官方资料。
|
||||||
|
2. 只查与当前能力直接相关的下级页面、接口参数和基础库限制,避免整份文档复制或无目的阅读。
|
||||||
|
3. 将结论落到一个公共 adapter、组件或配置文件;不要让每个页面自行实现同一套兼容逻辑。
|
||||||
|
4. 在开发者工具和真机上验证后,才将“已支持”写入项目文档。
|
||||||
|
|
||||||
|
### 当前已直接采用的官方规则
|
||||||
|
|
||||||
|
- 页面栈与 `switchTab`/`navigateTo` 的目标限制。
|
||||||
|
- `onShow`/`useDidShow` 用于从子页返回后的数据刷新。
|
||||||
|
- 自定义 tabBar 必须完整声明 tab 页,并由组件维护选中态。
|
||||||
|
- 真机调试与合法域名、服务端密钥隔离原则。
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# 微信小程序真机测试说明
|
||||||
|
|
||||||
|
本文用于指导测试人员在 Windows 开发机上启动后端、构建前端,并通过微信开发者工具预览到真实手机。测试重点是确认:小程序页面能够通过 HTTPS 访问后端接口,商品、地址、设计清单和订单等页面可以正常联调。
|
||||||
|
|
||||||
|
## 一、测试环境
|
||||||
|
|
||||||
|
| 项目 | 配置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 前端源码 | `G:\wordcloud_wechat\wechat_wc` |
|
||||||
|
| 前端导入目录 | `G:\wordcloud_wechat\wechat_wc` |
|
||||||
|
| 后端源码 | `G:\wordcloud_wechat\wxmp_backend` |
|
||||||
|
| 小程序 AppID | `wxad998564cf43a7d9` |
|
||||||
|
| 真机后端地址 | `https://wxbackend.tokenleaping.com` |
|
||||||
|
| 本机 Docker 后端地址 | `http://127.0.0.1:3090` |
|
||||||
|
| 商品图片地址 | `https://wordcloudwechat.oss-cn-hangzhou.aliyuncs.com` |
|
||||||
|
|
||||||
|
说明:`127.0.0.1` 只代表当前 Windows 电脑。真实手机不能通过这个地址访问电脑上的 Docker 容器;真机应使用可公开访问的 HTTPS 域名。
|
||||||
|
|
||||||
|
## 二、启动本机 Docker 后端
|
||||||
|
|
||||||
|
本步骤用于确认后端和数据库本身正常,也可供微信开发者工具模拟器联调。
|
||||||
|
|
||||||
|
1. 启动 Docker Desktop,确认左下角显示 Engine running。
|
||||||
|
2. 打开 PowerShell,执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd G:\wordcloud_wechat\wxmp_backend
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 查看容器状态:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
预期 `wxmp-app`、`wxmp-postgres`、`wxmp-redis` 均为运行状态,其中数据库和 Redis 应显示 `healthy`。
|
||||||
|
|
||||||
|
4. 在浏览器检查本机接口:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:3090/health`
|
||||||
|
- `http://127.0.0.1:3090/docs`
|
||||||
|
- `http://127.0.0.1:3090/api/categories`
|
||||||
|
- `http://127.0.0.1:3090/api/products?page=1&pageSize=20`
|
||||||
|
- `http://127.0.0.1:3090/api/products/penbox`
|
||||||
|
|
||||||
|
`/health` 和商品接口返回 HTTP 200,且响应中有 `code: 0`,即可继续测试。
|
||||||
|
|
||||||
|
### 本机登录配置
|
||||||
|
|
||||||
|
本机 Docker 若只用于开发者工具模拟器联调,可在后端 `.env` 使用本地 mock 登录:
|
||||||
|
|
||||||
|
```env
|
||||||
|
WX_APPID=local-dev-appid
|
||||||
|
WX_SECRET=local-dev-secret
|
||||||
|
WX_MOCK_LOGIN=1
|
||||||
|
```
|
||||||
|
|
||||||
|
这是项目《本机容器化联调教程》规定的开发配置,不代表真实微信登录。真实手机登录时,必须改为线上服务器中的真实 `WX_APPID` 与 `WX_SECRET`,并将 `WX_MOCK_LOGIN` 留空;AppSecret 不能提交到 Git。
|
||||||
|
|
||||||
|
## 三、构建微信小程序前端
|
||||||
|
|
||||||
|
1. 打开新的 PowerShell 窗口,执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd G:\wordcloud_wechat\wechat_wc
|
||||||
|
npm run build:weapp
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 构建成功后,产物位于:
|
||||||
|
|
||||||
|
```text
|
||||||
|
G:\wordcloud_wechat\wechat_wc\dist
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 当前真机构建应使用线上地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://wxbackend.tokenleaping.com
|
||||||
|
```
|
||||||
|
|
||||||
|
修改环境变量或配置后,必须重新执行构建;微信开发者工具不会自动读取源码目录中的环境变量。
|
||||||
|
|
||||||
|
## 四、配置微信公众平台合法域名
|
||||||
|
|
||||||
|
登录小程序管理后台,在“开发 → 开发管理 → 开发设置 → 服务器域名”中配置:
|
||||||
|
|
||||||
|
| 类型 | 域名 |
|
||||||
|
| --- | --- |
|
||||||
|
| request 合法域名 | `https://wxbackend.tokenleaping.com` |
|
||||||
|
| downloadFile 合法域名 | `https://wordcloudwechat.oss-cn-hangzhou.aliyuncs.com` |
|
||||||
|
| uploadFile 合法域名 | 使用 COS 直传时配置同一 OSS 域名 |
|
||||||
|
|
||||||
|
配置要求:
|
||||||
|
|
||||||
|
- 只填写 HTTPS 域名,不填写接口路径、端口、`/health` 或 `/api`。
|
||||||
|
- HTTPS 证书必须有效,域名证书需覆盖 `wxbackend.tokenleaping.com`。
|
||||||
|
- 修改服务器域名后,重新编译并重新预览小程序。
|
||||||
|
|
||||||
|
开发者工具中的“不校验合法域名”只适合模拟器临时调试,不能替代公众平台的正式域名配置。
|
||||||
|
|
||||||
|
## 五、导入开发者工具并预览
|
||||||
|
|
||||||
|
1. 打开微信开发者工具。
|
||||||
|
2. 选择“导入项目”。
|
||||||
|
3. 项目目录填写前端源码根目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
G:\wordcloud_wechat\wechat_wc
|
||||||
|
```
|
||||||
|
|
||||||
|
不要选择工作区总目录 `G:\wordcloud_wechat`。根目录中的 `project.config.json` 已声明
|
||||||
|
`miniprogramRoot: "dist/"`,开发者工具会自动把 `dist` 作为小程序运行产物。
|
||||||
|
|
||||||
|
4. AppID 选择:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wxad998564cf43a7d9
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 点击“编译”,确认模拟器能打开首页。
|
||||||
|
6. 点击工具栏“预览”,等待二维码生成。
|
||||||
|
7. 使用已登录该小程序的微信扫描二维码,在手机上打开预览版本。
|
||||||
|
|
||||||
|
## 六、真机功能测试清单
|
||||||
|
|
||||||
|
建议按以下顺序测试,并在每一步记录结果:
|
||||||
|
|
||||||
|
| 序号 | 操作 | 预期结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 启动小程序 | 首页正常显示,无白屏或启动异常 |
|
||||||
|
| 2 | 打开“商品”页 | 显示后端返回的在售商品列表 |
|
||||||
|
| 3 | 下拉刷新商品页 | 请求成功,列表无重复或空白 |
|
||||||
|
| 4 | 点击商品 | 商品详情、价格、规格、图片正常显示 |
|
||||||
|
| 5 | 返回首页 | 首页商品数据正常显示 |
|
||||||
|
| 6 | 打开“设计清单” | 已登录时可读取服务端清单;未登录时提示登录 |
|
||||||
|
| 7 | 新增、编辑、删除地址 | 地址操作成功,默认地址状态正确 |
|
||||||
|
| 8 | 进入 DIY 并保存设计 | 设计清单状态和内容正常更新 |
|
||||||
|
| 9 | 提交订单 | 能创建订单并显示服务端计算的金额 |
|
||||||
|
| 10 | 进入支付 | 若未配置微信支付,显示“支付未配置”等明确提示,不应假装支付成功 |
|
||||||
|
|
||||||
|
商品目录重点验收:页面显示的商品必须来自 `GET /api/products` 和 `GET /api/products/:id`,不能只显示前端写死数据。可在测试数据库中修改一个商品价格,再重新进入商品页,确认页面价格同步变化。
|
||||||
|
|
||||||
|
## 七、如何确认手机确实访问了后端
|
||||||
|
|
||||||
|
在微信开发者工具的“调试器 → Network”中查看请求,商品页至少应看到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET https://wxbackend.tokenleaping.com/api/products?page=1&pageSize=20
|
||||||
|
GET https://wxbackend.tokenleaping.com/api/products/{商品ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应状态应为 `200`,响应体应包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果请求地址仍然是 `127.0.0.1:3090`,说明前端没有使用真机构建配置,需要重新检查环境变量并执行 `npm run build:weapp`。
|
||||||
|
|
||||||
|
## 八、本机 Docker 与真机的关系
|
||||||
|
|
||||||
|
本机 Docker 只负责在 Windows 上运行 Linux 容器中的后端、PostgreSQL 和 Redis。它不会自动获得公网 HTTPS 地址。
|
||||||
|
|
||||||
|
有两种测试方式:
|
||||||
|
|
||||||
|
### 方式 A:直接测试线上 HTTPS 服务(推荐)
|
||||||
|
|
||||||
|
- 前端构建地址使用 `https://wxbackend.tokenleaping.com`。
|
||||||
|
- 微信开发者工具生成预览二维码。
|
||||||
|
- 手机通过互联网访问线上后端。
|
||||||
|
- 本机 Docker 只用于本地接口检查,不影响手机请求。
|
||||||
|
|
||||||
|
### 方式 B:让手机测试本机 Docker 代码
|
||||||
|
|
||||||
|
需要额外配置内网穿透或 FRP:
|
||||||
|
|
||||||
|
1. Windows 上的 `frpc` 将本机 `3090` 转发到服务器上的 FRP 服务端。
|
||||||
|
2. 服务器反向代理将 HTTPS 域名转发到 FRP 通道。
|
||||||
|
3. 前端仍构建为 `https://wxbackend.tokenleaping.com`。
|
||||||
|
4. 确认微信公众平台合法域名已配置,并重新预览。
|
||||||
|
|
||||||
|
仅启动 Docker Desktop,不能让真实手机直接访问 `127.0.0.1`。
|
||||||
|
|
||||||
|
### 当前项目的 FRP 映射
|
||||||
|
|
||||||
|
本项目提供的 `frpc.toml` 使用以下转发关系:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
serverAddr = "43.155.206.17"
|
||||||
|
serverPort = 7000
|
||||||
|
|
||||||
|
[[proxies]]
|
||||||
|
name = "wxbackend"
|
||||||
|
type = "tcp"
|
||||||
|
localIP = "host.docker.internal"
|
||||||
|
localPort = 3090
|
||||||
|
remotePort = 3090
|
||||||
|
```
|
||||||
|
|
||||||
|
对应含义是:FRP 客户端容器把 Windows 主机的 `3090` 转发到服务器 `3090`;服务器上的 HTTPS 反向代理再将 `wxbackend.tokenleaping.com` 转到该端口。`frpc.toml` 中的认证 token 仅保存在本机文件,不要提交到 Git。
|
||||||
|
|
||||||
|
## 九、常见问题
|
||||||
|
|
||||||
|
| 现象 | 处理方法 |
|
||||||
|
| --- | --- |
|
||||||
|
| `request:fail url not in domain list` | 在公众平台添加 request 合法域名,重新编译并预览 |
|
||||||
|
| `ERR_CERT` 或 TLS 错误 | 检查 HTTPS 证书、域名解析和服务器时间 |
|
||||||
|
| 手机请求连接被拒绝 | 不要使用 `127.0.0.1`;确认线上域名或 FRP 转发正常 |
|
||||||
|
| 模拟器正常、手机白屏 | 检查真机构建地址和服务器域名配置,查看 Network 错误 |
|
||||||
|
| 商品图片不显示 | 将 OSS 域名加入 downloadFile 合法域名,并检查图片 URL |
|
||||||
|
| 登录失败 | 检查线上后端的微信 AppID、AppSecret;本机空配置只能测试公开接口 |
|
||||||
|
| 词云或支付无法完成 | 检查对应外部服务和密钥;未配置时属于预期限制 |
|
||||||
|
|
||||||
|
## 十、测试记录模板
|
||||||
|
|
||||||
|
```text
|
||||||
|
测试日期:
|
||||||
|
测试人员:
|
||||||
|
前端提交:
|
||||||
|
后端提交:
|
||||||
|
手机型号 / 微信版本:
|
||||||
|
后端地址:https://wxbackend.tokenleaping.com
|
||||||
|
|
||||||
|
商品列表:通过 / 不通过
|
||||||
|
商品详情:通过 / 不通过
|
||||||
|
地址管理:通过 / 不通过
|
||||||
|
设计清单:通过 / 不通过
|
||||||
|
订单创建:通过 / 不通过
|
||||||
|
支付:已配置并通过 / 未配置 / 失败
|
||||||
|
备注:
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
// 自定义 tabBar 本质是小程序组件;明确输出组件配置,确保其独立 wxss 被加载。
|
||||||
|
component: true,
|
||||||
|
styleIsolation: 'isolated'
|
||||||
|
})
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 160rpx;
|
height: 160rpx;
|
||||||
pointer-events: none;
|
// 部分微信基础库对 pointer-events:none 的父节点命中测试不一致,
|
||||||
|
// 可能导致子节点虽然声明 auto 仍收不到点击。根容器直接参与命中,
|
||||||
|
// 背景渐变单独关闭事件,标签区域保持可点击。
|
||||||
|
pointer-events: auto;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-bar-root.theme-light .tab-bar-gradient {
|
.tab-bar-root.theme-light .tab-bar-gradient {
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ const TAB_CENTER_STEP = 100 / TABS.length
|
|||||||
const DRAG_THRESHOLD_PX = 12
|
const DRAG_THRESHOLD_PX = 12
|
||||||
|
|
||||||
function matchTab(path: string): string {
|
function matchTab(path: string): string {
|
||||||
const idx = TABS.findIndex((t) => path.endsWith(t.pagePath))
|
// 微信路由通常给出 pages/...,配置使用 /pages/...;统一格式后再匹配。
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
|
const idx = TABS.findIndex((t) => normalizedPath.endsWith(t.pagePath))
|
||||||
return TABS[idx >= 0 ? idx : 0].pagePath
|
return TABS[idx >= 0 ? idx : 0].pagePath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ export default function CustomTabBar() {
|
|||||||
const dragPercentRef = useRef<number | null>(null)
|
const dragPercentRef = useRef<number | null>(null)
|
||||||
const startXRef = useRef<number>(0)
|
const startXRef = useRef<number>(0)
|
||||||
const barFrameRef = useRef<{ left: number; width: number } | null>(null)
|
const barFrameRef = useRef<{ left: number; width: number } | null>(null)
|
||||||
|
const switchingRef = useRef<boolean>(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (t: 'light' | 'dark') => setResolvedTheme(t)
|
const handler = (t: 'light' | 'dark') => setResolvedTheme(t)
|
||||||
@@ -96,11 +99,29 @@ export default function CustomTabBar() {
|
|||||||
}, [resolvedTheme])
|
}, [resolvedTheme])
|
||||||
|
|
||||||
const switchTab = (url: string) => {
|
const switchTab = (url: string) => {
|
||||||
setActiveTab(matchTab(url))
|
const target = matchTab(url)
|
||||||
Taro.switchTab({ url })
|
// 官方路由会将连续请求排队;同一触摸同时触发 onTouchEnd/onTap/onClick 时,
|
||||||
|
// 只允许首个请求进入队列,避免遮罩来回跳动。
|
||||||
|
if (switchingRef.current || target === activeTab) return
|
||||||
|
switchingRef.current = true
|
||||||
|
setActiveTab(target)
|
||||||
|
Taro.switchTab({
|
||||||
|
url,
|
||||||
|
complete: () => {
|
||||||
|
Taro.nextTick(() => {
|
||||||
|
switchingRef.current = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
Taro.eventCenter.trigger('tabBarChange', url)
|
Taro.eventCenter.trigger('tabBarChange', url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tapTab = (url: string) => {
|
||||||
|
// 点击标签时直接切换;拖动手势由 onTouchEnd 统一处理,避免一次手势触发两次跳转。
|
||||||
|
if (isDraggingRef.current || url === activeTab) return
|
||||||
|
switchTab(url)
|
||||||
|
}
|
||||||
|
|
||||||
const startDrag = (e: any) => {
|
const startDrag = (e: any) => {
|
||||||
const touch = e.touches && e.touches[0]
|
const touch = e.touches && e.touches[0]
|
||||||
if (!touch) return
|
if (!touch) return
|
||||||
@@ -181,7 +202,12 @@ export default function CustomTabBar() {
|
|||||||
{TABS.map((tab) => {
|
{TABS.map((tab) => {
|
||||||
const isActive = tab.pagePath === activeTab
|
const isActive = tab.pagePath === activeTab
|
||||||
return (
|
return (
|
||||||
<View key={tab.pagePath} className={`tab-item ${isActive ? 'active' : ''}`}>
|
<View
|
||||||
|
key={tab.pagePath}
|
||||||
|
className={`tab-item ${isActive ? 'active' : ''}`}
|
||||||
|
onTap={() => tapTab(tab.pagePath)}
|
||||||
|
onClick={() => tapTab(tab.pagePath)}
|
||||||
|
>
|
||||||
<Image className={`tab-icon-img ${isActive ? 'active' : ''}`} src={isActive ? tab.iconActive : tab.icon} mode='aspectFit' />
|
<Image className={`tab-icon-img ${isActive ? 'active' : ''}`} src={isActive ? tab.iconActive : tab.icon} mode='aspectFit' />
|
||||||
<Text className='tab-label'>{tab.label}</Text>
|
<Text className='tab-label'>{tab.label}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
+45
-27
@@ -2,8 +2,8 @@ import { View, Text, Image, Input, Picker } from '@tarojs/components'
|
|||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { getAddressList, addAddress, updateAddress as updateLocalAddress, deleteAddress as deleteLocalAddress, setAddressList } from '../../utils/store'
|
import { getAddressList, setAddressList, addAddress, updateAddress, deleteAddress } from '../../utils/store'
|
||||||
import { fetchAddresses, createAddress, updateAddress, deleteAddress, setDefaultAddress } from '../../utils/api/address'
|
import { fetchAddresses, createAddress, updateAddress as updateAddressApi, deleteAddress as deleteAddressApi } from '../../utils/api'
|
||||||
import type { AddressItem } from '../../types'
|
import type { AddressItem } from '../../types'
|
||||||
import { useThemeContext } from '../../context/ThemeContext'
|
import { useThemeContext } from '../../context/ThemeContext'
|
||||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||||
@@ -27,14 +27,16 @@ export default function AddressPage() {
|
|||||||
const [detail, setDetail] = useState('')
|
const [detail, setDetail] = useState('')
|
||||||
const [isDefault, setIsDefault] = useState(false)
|
const [isDefault, setIsDefault] = useState(false)
|
||||||
|
|
||||||
const load = async () => {
|
const load = () => {
|
||||||
try {
|
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
|
||||||
const remote = await fetchAddresses()
|
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
|
||||||
setList(remote)
|
setList(getAddressList())
|
||||||
setAddressList(remote)
|
fetchAddresses()
|
||||||
} catch {
|
.then(data => {
|
||||||
setList(getAddressList())
|
setList(data)
|
||||||
}
|
setAddressList(data)
|
||||||
|
})
|
||||||
|
.catch(() => setList(getAddressList()))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,17 +67,15 @@ export default function AddressPage() {
|
|||||||
setShowForm(true)
|
setShowForm(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSetDefault = async (id: string) => {
|
|
||||||
try { const next = await setDefaultAddress(id); setList(prev => prev.map(item => ({ ...item, isDefault: item.id === next.id }))); Taro.showToast({ title: '已设为默认', icon: 'success' }) } catch (error) { Taro.showToast({ title: (error as Error).message || '设置失败', icon: 'none' }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
Taro.showModal({
|
Taro.showModal({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: '删除后将无法恢复该地址',
|
content: '删除后将无法恢复该地址',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
deleteAddress(id).then(() => load()).catch(error => { deleteLocalAddress(id); load(); Taro.showToast({ title: (error as Error).message || '删除失败', icon: 'none' }) })
|
deleteAddressApi(id)
|
||||||
|
.catch(() => deleteAddress(id)) // 失败降级本地删除
|
||||||
|
.finally(load)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -86,22 +86,41 @@ export default function AddressPage() {
|
|||||||
Taro.setClipboardData({ data: text })
|
Taro.setClipboardData({ data: text })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = () => {
|
||||||
if (!name.trim() || !phone.trim() || region.length === 0 || !detail.trim()) {
|
if (!name.trim() || !phone.trim() || region.length === 0 || !detail.trim()) {
|
||||||
Taro.showToast({ title: '请填写完整信息', icon: 'none' })
|
Taro.showToast({ title: '请填写完整信息', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload = { name, phone, region, detail, isDefault }
|
const payload = { name, phone, region, detail, isDefault }
|
||||||
try {
|
const save = editing
|
||||||
const saved = editing ? await updateAddress(editing.id, payload) : await createAddress(payload)
|
? updateAddressApi(editing.id, payload)
|
||||||
const next = editing ? list.map(item => item.id === saved.id ? saved : item) : [saved, ...list]
|
: createAddress(payload)
|
||||||
setList(next); setAddressList(next)
|
save
|
||||||
Taro.showToast({ title: '保存成功', icon: 'success' }); setShowForm(false); resetForm()
|
.then((saved) => {
|
||||||
} catch (error) {
|
// 新地址在返回结算页前立即回写按账号隔离的缓存;
|
||||||
// API 失败时保留原有本地兜底,避免离线编辑丢失。
|
// 结算页 onShow 会据此先展示,再以服务端列表校准。
|
||||||
if (editing) updateLocalAddress(editing.id, payload); else addAddress(payload)
|
const current = getAddressList()
|
||||||
Taro.showToast({ title: (error as Error).message || '保存失败,已暂存本地', icon: 'none' }); setShowForm(false); resetForm(); load()
|
const withoutSaved = current.filter(item => item.id !== saved.id)
|
||||||
}
|
const next = [saved, ...withoutSaved].map(item => (
|
||||||
|
saved.isDefault
|
||||||
|
? { ...item, isDefault: item.id === saved.id }
|
||||||
|
: item
|
||||||
|
))
|
||||||
|
setList(next)
|
||||||
|
setAddressList(next)
|
||||||
|
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 服务端不可达时降级本地保存,联网后进入页面会刷新
|
||||||
|
if (editing) updateAddress(editing.id, payload)
|
||||||
|
else addAddress(payload)
|
||||||
|
Taro.showToast({ title: '网络不可用,已暂存到本地', icon: 'none' })
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setShowForm(false)
|
||||||
|
resetForm()
|
||||||
|
load()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRegionChange = (e: any) => {
|
const onRegionChange = (e: any) => {
|
||||||
@@ -143,7 +162,6 @@ export default function AddressPage() {
|
|||||||
<View className='address-actions'>
|
<View className='address-actions'>
|
||||||
<Text className='address-act' onTap={() => openEdit(item)}>编辑</Text>
|
<Text className='address-act' onTap={() => openEdit(item)}>编辑</Text>
|
||||||
<Text className='address-act' onTap={() => handleCopy(item)}>复制</Text>
|
<Text className='address-act' onTap={() => handleCopy(item)}>复制</Text>
|
||||||
{!item.isDefault && <Text className='address-act' onTap={() => handleSetDefault(item.id)}>设为默认</Text>}
|
|
||||||
<Text className='address-act delete' onTap={() => handleDelete(item.id)}>删除</Text>
|
<Text className='address-act delete' onTap={() => handleDelete(item.id)}>删除</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { View, Text, Image, Button } from '@tarojs/components'
|
import { View, Text, Image, Button } from '@tarojs/components'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { getDesignList, getDefaultAddress, getAddressList, setDesignList, type AddressItem } from '../../utils/store'
|
import { getDesignList, getAddressList, setAddressList, setDesignList, type AddressItem } from '../../utils/store'
|
||||||
import { createDesign, fetchDesign } from '../../utils/api/design'
|
import { createDesign, fetchDesign } from '../../utils/api/design'
|
||||||
import { fetchAddresses } from '../../utils/api/address'
|
import { fetchAddresses } from '../../utils/api/address'
|
||||||
import { createOrder, payOrder } from '../../utils/api/order'
|
import { createOrder, payOrder } from '../../utils/api/order'
|
||||||
@@ -24,6 +24,28 @@ export default function CheckoutPage() {
|
|||||||
const [serverTotal, setServerTotal] = useState<number | null>(null)
|
const [serverTotal, setServerTotal] = useState<number | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// 地址页 navigateBack 后,当前结算页不会重新挂载;因此必须在 onShow 刷新。
|
||||||
|
// 先用本地缓存即时更新,再拉接口以服务端数据为准。
|
||||||
|
const refreshAddresses = useCallback(async () => {
|
||||||
|
const apply = (addresses: AddressItem[]) => {
|
||||||
|
setAddrList(addresses)
|
||||||
|
setAddress(current => (
|
||||||
|
addresses.find(item => item.id === current?.id)
|
||||||
|
|| addresses.find(item => item.isDefault)
|
||||||
|
|| addresses[0]
|
||||||
|
|| null
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const addresses = await fetchAddresses()
|
||||||
|
setAddressList(addresses)
|
||||||
|
apply(addresses)
|
||||||
|
} catch {
|
||||||
|
apply(getAddressList())
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const router = Taro.getCurrentInstance().router
|
const router = Taro.getCurrentInstance().router
|
||||||
const params = router ? router.params : undefined
|
const params = router ? router.params : undefined
|
||||||
@@ -33,19 +55,19 @@ export default function CheckoutPage() {
|
|||||||
try {
|
try {
|
||||||
const remote = await fetchDesign(dId)
|
const remote = await fetchDesign(dId)
|
||||||
setDesign(remote)
|
setDesign(remote)
|
||||||
const addresses = await fetchAddresses()
|
|
||||||
setAddrList(addresses)
|
|
||||||
setAddress(addresses.find(item => item.isDefault) || addresses[0] || null)
|
|
||||||
} catch {
|
} catch {
|
||||||
const list = getDesignList()
|
const list = getDesignList()
|
||||||
const d = list.find(x => x.id === dId)
|
const d = list.find(x => x.id === dId)
|
||||||
setDesign(d || null)
|
setDesign(d || null)
|
||||||
setAddress(getDefaultAddress())
|
|
||||||
setAddrList(getAddressList())
|
|
||||||
}
|
}
|
||||||
|
await refreshAddresses()
|
||||||
}
|
}
|
||||||
void load()
|
void load()
|
||||||
}, [])
|
}, [refreshAddresses])
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
void refreshAddresses()
|
||||||
|
})
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (submitting) return
|
if (submitting) return
|
||||||
@@ -105,6 +127,27 @@ export default function CheckoutPage() {
|
|||||||
|
|
||||||
const hasStickers = design && design.designData && design.designData.stickers && design.designData.stickers.length > 0
|
const hasStickers = design && design.designData && design.designData.stickers && design.designData.stickers.length > 0
|
||||||
const hasLegacyImage = design && design.designData ? design.designData.imageSrc : undefined
|
const hasLegacyImage = design && design.designData ? design.designData.imageSrc : undefined
|
||||||
|
|
||||||
|
// 预览消费贴纸 edits(亮度/色相/对比度 → CSS filter),与 DIY 工作台同一呈现逻辑
|
||||||
|
//(diy-fix-workflow 问题5 的 R3 待办:不消费会导致确认页图片与工作台不一致)
|
||||||
|
const stickerFilter = (s: { edits?: { brightness?: number; hue?: number; contrast?: number } }) => {
|
||||||
|
const ed = s.edits
|
||||||
|
if (!ed) return undefined
|
||||||
|
const b = ed.brightness || 0
|
||||||
|
const h = ed.hue || 0
|
||||||
|
const c = ed.contrast || 0
|
||||||
|
if (b === 0 && h === 0 && c === 0) return undefined
|
||||||
|
return `brightness(${100 + b}%) hue-rotate(${h}deg) contrast(${100 + c}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图层顺序与 WCD 打包规则一致(按 zIndex,无 zIndex 按数组序兜底)
|
||||||
|
const previewStickers: any[] = hasStickers
|
||||||
|
? design.designData!.stickers!
|
||||||
|
.map((s: any, i: number) => ({ s, z: typeof s.zIndex === 'number' ? s.zIndex : i }))
|
||||||
|
.sort((a: { z: number }, b: { z: number }) => a.z - b.z)
|
||||||
|
.map((it: { s: any }) => it.s)
|
||||||
|
: []
|
||||||
|
|
||||||
const formatAddress = (region: string[] | undefined, detail: string) => {
|
const formatAddress = (region: string[] | undefined, detail: string) => {
|
||||||
const regionText = region ? region.join(' ') : ''
|
const regionText = region ? region.join(' ') : ''
|
||||||
return regionText ? `${regionText} ${detail}` : detail
|
return regionText ? `${regionText} ${detail}` : detail
|
||||||
@@ -159,13 +202,14 @@ export default function CheckoutPage() {
|
|||||||
<View className='checkout-canvas surface-card'>
|
<View className='checkout-canvas surface-card'>
|
||||||
<View className='canvas-area' style={maskStyle}>
|
<View className='canvas-area' style={maskStyle}>
|
||||||
{hasStickers ? (
|
{hasStickers ? (
|
||||||
design.designData.stickers.map((s: any) => (
|
previewStickers.map((s: any) => (
|
||||||
<Image
|
<Image
|
||||||
key={s.id}
|
key={s.id}
|
||||||
className='checkout-image'
|
className='checkout-image'
|
||||||
src={s.src}
|
src={s.src}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale || 1})`,
|
transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale || 1})`,
|
||||||
|
filter: stickerFilter(s),
|
||||||
width: s.width || 200,
|
width: s.width || 200,
|
||||||
height: s.height || 200
|
height: s.height || 200
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -136,10 +136,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.design-icon-img {
|
.design-icon-img {
|
||||||
width: 48rpx;
|
// 与订单列表的商品图保持同一规格,避免实拍图被压成图标大小。
|
||||||
height: 48rpx;
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 12rpx;
|
border-radius: 24rpx;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { getDesignList, removeDesigns, setDesignList, type DesignItem } from '../../utils/store'
|
import { getDesignList, removeDesigns, setDesignList, type DesignItem } from '../../utils/store'
|
||||||
import { fetchDesignList, deleteDesigns } from '../../utils/api/design'
|
import { fetchDesignList, deleteDesigns } from '../../utils/api/design'
|
||||||
import { PRODUCT_ICON_MAP } from '../../utils/productConfig'
|
import { getProductById, PRODUCT_ICON_MAP } from '../../utils/productConfig'
|
||||||
import { assetUrl } from '../../utils/asset'
|
import { assetUrl } from '../../utils/asset'
|
||||||
import { useThemeContext } from '../../context/ThemeContext'
|
import { useThemeContext } from '../../context/ThemeContext'
|
||||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||||
@@ -15,19 +15,16 @@ import ScrollTopMask from '../../components/ScrollTopMask'
|
|||||||
|
|
||||||
const STATUS_TABS = [
|
const STATUS_TABS = [
|
||||||
{ code: 'all', label: '全部' },
|
{ code: 'all', label: '全部' },
|
||||||
{ code: 'toDesign', label: '待设计' },
|
{ code: 'undesigned', label: '待设计' },
|
||||||
{ code: 'undesigned', label: '未设计' },
|
|
||||||
{ code: 'designing', label: '设计中' },
|
{ code: 'designing', label: '设计中' },
|
||||||
{ code: 'processing', label: '生产中' },
|
{ code: 'processing', label: '生产中' },
|
||||||
{ code: 'ordered', label: '已下单' }
|
{ code: 'ordered', label: '已下单' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const VISIBLE_STATUS_TABS = STATUS_TABS.filter(tab => tab.code !== 'undesigned')
|
|
||||||
|
|
||||||
const STATUS_STYLE: Record<string, { label: string; cls: string }> = {
|
const STATUS_STYLE: Record<string, { label: string; cls: string }> = {
|
||||||
undesigned: { label: '未设计', cls: 'badge-pink' },
|
undesigned: { label: '待设计', cls: 'badge-pink' },
|
||||||
designing: { label: '设计中', cls: 'badge-blue' },
|
designing: { label: '设计中', cls: 'badge-blue' },
|
||||||
processing: { label: '生产中', cls: 'badge-blue' },
|
processing: { label: '生产中', cls: 'badge-warning' },
|
||||||
ordered: { label: '已下单', cls: 'badge-green' }
|
ordered: { label: '已下单', cls: 'badge-green' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +38,15 @@ export default function DesignListPage() {
|
|||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
fetchDesignList().then(remote => { setList(remote); setDesignList(remote) }).catch(() => setList(getDesignList()))
|
// 缓存优先展示(冷启动不空屏),再拉服务端刷新;
|
||||||
|
// 刷新成功回写缓存并以服务端为准覆盖(旧本地数据不自动合并,route-r2 风险节)
|
||||||
|
setList(getDesignList())
|
||||||
|
fetchDesignList()
|
||||||
|
.then(data => {
|
||||||
|
setList(data)
|
||||||
|
setDesignList(data)
|
||||||
|
})
|
||||||
|
.catch(() => setList(getDesignList()))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const init = useCallback(() => {
|
const init = useCallback(() => {
|
||||||
@@ -65,11 +70,10 @@ export default function DesignListPage() {
|
|||||||
init()
|
init()
|
||||||
}, [init])
|
}, [init])
|
||||||
|
|
||||||
|
// “全部”用于汇总;其余标签与状态一一对应,单条设计只会落在一个栏位。
|
||||||
const filtered = activeTab === 'all'
|
const filtered = activeTab === 'all'
|
||||||
? list
|
? list
|
||||||
: activeTab === 'toDesign'
|
: list.filter(d => d.status === activeTab)
|
||||||
? list.filter(d => d.status === 'undesigned' || d.status === 'designing')
|
|
||||||
: list.filter(d => d.status === activeTab)
|
|
||||||
|
|
||||||
const goDesign = (item: DesignItem) => {
|
const goDesign = (item: DesignItem) => {
|
||||||
if (item.status === 'ordered') {
|
if (item.status === 'ordered') {
|
||||||
@@ -137,7 +141,7 @@ export default function DesignListPage() {
|
|||||||
{/* 状态筛选 */}
|
{/* 状态筛选 */}
|
||||||
<View className='status-tabs'>
|
<View className='status-tabs'>
|
||||||
<ScrollView className='tabs-scroll' scrollX>
|
<ScrollView className='tabs-scroll' scrollX>
|
||||||
{VISIBLE_STATUS_TABS.map(tab => (
|
{STATUS_TABS.map(tab => (
|
||||||
<View
|
<View
|
||||||
key={tab.code}
|
key={tab.code}
|
||||||
className={`chip ${activeTab === tab.code ? 'active' : ''}`}
|
className={`chip ${activeTab === tab.code ? 'active' : ''}`}
|
||||||
@@ -172,6 +176,8 @@ export default function DesignListPage() {
|
|||||||
? item.productIcon
|
? item.productIcon
|
||||||
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg'
|
: PRODUCT_ICON_MAP[item.productId] || '/icon/四角星.svg'
|
||||||
const iconSrc = assetUrl(rawIcon)
|
const iconSrc = assetUrl(rawIcon)
|
||||||
|
// 与商品介绍页一致:商品目录首图是该商品的实拍图;目录缺图时才兼容旧 SVG。
|
||||||
|
const photoSrc = getProductById(item.productId)?.images?.[0] || iconSrc
|
||||||
const isSelected = selected.has(item.id)
|
const isSelected = selected.has(item.id)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -183,7 +189,7 @@ export default function DesignListPage() {
|
|||||||
{managing && (
|
{managing && (
|
||||||
<View className={`batch-checkbox ${isSelected ? 'checked' : ''}`} />
|
<View className={`batch-checkbox ${isSelected ? 'checked' : ''}`} />
|
||||||
)}
|
)}
|
||||||
<Image className='design-icon-img' src={iconSrc} mode='aspectFit' />
|
<Image className='design-icon-img' src={photoSrc} mode='aspectFill' />
|
||||||
<View className='design-info'>
|
<View className='design-info'>
|
||||||
<View className='design-title-row'>
|
<View className='design-title-row'>
|
||||||
<Text className='design-name'>{item.productName}</Text>
|
<Text className='design-name'>{item.productName}</Text>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
@@ -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 呈现(契约 §4:edits 不进 WCD
|
||||||
|
document.json,仅 manifest.meta,src 保持原样对 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>
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -75,6 +76,114 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-clear,
|
||||||
|
.search-reset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
min-width: 80rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&::after { border: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.search-reset {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
.home-section-title { margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-section {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1rpx solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-rail {
|
||||||
|
width: calc(100% + 48rpx);
|
||||||
|
margin: 0 -24rpx -28rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 252rpx;
|
||||||
|
height: 168rpx;
|
||||||
|
margin: 0 0 28rpx 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
vertical-align: top;
|
||||||
|
background: var(--bg-muted);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile:last-child { margin-right: 24rpx; }
|
||||||
|
|
||||||
|
.category-tile-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile-shade {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 84rpx;
|
||||||
|
padding: 14rpx 16rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: linear-gradient(transparent, rgba(20, 18, 16, 0.78));
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile-name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile-action {
|
||||||
|
margin-top: 5rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommend-section {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
padding-top: 32rpx;
|
||||||
|
border-top: 2rpx solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-section { margin-top: 32rpx; }
|
||||||
|
|
||||||
/* 区块标题 */
|
/* 区块标题 */
|
||||||
.home-section-title {
|
.home-section-title {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
+50
-54
@@ -1,4 +1,4 @@
|
|||||||
import { View, Text, Image, Input, Button, Swiper, SwiperItem } from '@tarojs/components'
|
import { View, Text, Image, Input, Button, Swiper, SwiperItem, ScrollView } from '@tarojs/components'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
@@ -70,7 +70,7 @@ const PRODUCT_IMG_MAP: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { theme, resolvedTheme } = useThemeContext()
|
const { resolvedTheme } = useThemeContext()
|
||||||
const safe = useSafeArea()
|
const safe = useSafeArea()
|
||||||
useStatusBar(resolvedTheme)
|
useStatusBar(resolvedTheme)
|
||||||
const [searchKey, setSearchKey] = useState('')
|
const [searchKey, setSearchKey] = useState('')
|
||||||
@@ -81,20 +81,11 @@ export default function Index() {
|
|||||||
Taro.navigateTo({ url: `/pages/product/index?id=${categoryId}` })
|
Taro.navigateTo({ url: `/pages/product/index?id=${categoryId}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateFromShowcase = (item: { categoryId?: string; title: string }) => {
|
const keyword = searchKey.trim().toLocaleLowerCase()
|
||||||
if (item.categoryId) {
|
const isSearching = keyword.length > 0
|
||||||
navigateToProduct(item.categoryId)
|
const filteredCategories = isSearching
|
||||||
return
|
|
||||||
}
|
|
||||||
Taro.showToast({
|
|
||||||
title: `“${item.title}”定制通道即将开放`,
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredCategories = searchKey.trim()
|
|
||||||
? products.filter(
|
? products.filter(
|
||||||
(c) => c.name.includes(searchKey) || c.desc.includes(searchKey)
|
(c) => [c.name, c.desc, c.description].some(value => value?.toLocaleLowerCase().includes(keyword))
|
||||||
)
|
)
|
||||||
: products
|
: products
|
||||||
|
|
||||||
@@ -125,19 +116,22 @@ export default function Index() {
|
|||||||
onInput={handleSearch}
|
onInput={handleSearch}
|
||||||
confirmType='search'
|
confirmType='search'
|
||||||
/>
|
/>
|
||||||
|
{searchKey.length > 0 && (
|
||||||
|
<Button className='search-clear' onClick={() => setSearchKey('')}>清除</Button>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{searchKey.trim() && (
|
{isSearching && (
|
||||||
<View className='search-result-hint'>
|
<View className='search-result-hint'>
|
||||||
<Text className='hint-text'>
|
<Text className='hint-text'>
|
||||||
{filteredCategories.length > 0
|
{filteredCategories.length > 0
|
||||||
? `找到 ${filteredCategories.length} 个相关品类`
|
? `找到 ${filteredCategories.length} 件相关商品`
|
||||||
: '没有找到相关品类,试试看其他关键词'}
|
: '没有找到相关商品,试试看其他关键词'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 定制成品展示 */}
|
{/* 定制成品展示 */}
|
||||||
<View className='showcase-section'>
|
{!isSearching && <View className='showcase-section'>
|
||||||
<Text className='home-section-title'>定制成品展示</Text>
|
<Text className='home-section-title'>定制成品展示</Text>
|
||||||
<Swiper
|
<Swiper
|
||||||
className='showcase-swiper'
|
className='showcase-swiper'
|
||||||
@@ -164,11 +158,40 @@ export default function Index() {
|
|||||||
</SwiperItem>
|
</SwiperItem>
|
||||||
))}
|
))}
|
||||||
</Swiper>
|
</Swiper>
|
||||||
</View>
|
</View>}
|
||||||
|
|
||||||
{/* 热门品类 */}
|
{/* 品类是快捷入口,与下方商品推荐采用不同布局。 */}
|
||||||
<View className='category-section'>
|
{!isSearching && (
|
||||||
<Text className='home-section-title'>热门品类</Text>
|
<View className='category-section'>
|
||||||
|
<View className='section-heading'>
|
||||||
|
<Text className='home-section-title'>热门品类</Text>
|
||||||
|
<Text className='section-subtitle'>按品类,快速开启定制</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView className='category-rail' scrollX enhanced showScrollbar={false}>
|
||||||
|
{products.map(cat => (
|
||||||
|
<View className='category-tile' key={cat.id} onTap={() => navigateToProduct(cat.id)}>
|
||||||
|
<ProductImage
|
||||||
|
className='category-tile-image'
|
||||||
|
src={cat.images[0] || PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
|
||||||
|
fallback={getProductIconImg(cat)}
|
||||||
|
mode='aspectFill'
|
||||||
|
/>
|
||||||
|
<View className='category-tile-shade'>
|
||||||
|
<Text className='category-tile-name'>{cat.name}</Text>
|
||||||
|
<Text className='category-tile-action'>去定制</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索与推荐互斥,共用后端商品卡片。 */}
|
||||||
|
<View className={isSearching ? 'search-results-section' : 'recommend-section'}>
|
||||||
|
<View className='section-heading'>
|
||||||
|
<Text className='home-section-title'>{isSearching ? '搜索结果' : '为你推荐'}</Text>
|
||||||
|
{!isSearching && <Text className='section-subtitle'>挑一件好物,留下你的专属印记</Text>}
|
||||||
|
</View>
|
||||||
<View className='home-grid'>
|
<View className='home-grid'>
|
||||||
{filteredCategories.map((cat) => (
|
{filteredCategories.map((cat) => (
|
||||||
<View
|
<View
|
||||||
@@ -179,7 +202,7 @@ export default function Index() {
|
|||||||
<View className='media-card-image'>
|
<View className='media-card-image'>
|
||||||
<ProductImage
|
<ProductImage
|
||||||
className='media-card-img'
|
className='media-card-img'
|
||||||
src={PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
|
src={cat.images[0] || PRODUCT_IMG_MAP[cat.id] || assetUrl('/icon/四角星.svg')}
|
||||||
fallback={getProductIconImg(cat)}
|
fallback={getProductIconImg(cat)}
|
||||||
mode='aspectFill'
|
mode='aspectFill'
|
||||||
/>
|
/>
|
||||||
@@ -195,40 +218,13 @@ export default function Index() {
|
|||||||
{filteredCategories.length === 0 && (
|
{filteredCategories.length === 0 && (
|
||||||
<View className='empty-category'>
|
<View className='empty-category'>
|
||||||
<Image className='empty-icon-img' src={assetUrl('/icon/搜索.svg')} mode='aspectFit' />
|
<Image className='empty-icon-img' src={assetUrl('/icon/搜索.svg')} mode='aspectFit' />
|
||||||
<Text className='empty-text'>未找到匹配的品类</Text>
|
<Text className='empty-text'>{isSearching ? '未找到匹配的商品' : '暂无在售商品'}</Text>
|
||||||
<Text className='empty-sub'>尝试搜索“笔记本”、“杯垫”等</Text>
|
{isSearching && <Text className='empty-sub'>尝试搜索“笔记本”、“杯垫”等</Text>}
|
||||||
|
{isSearching && <Button className='search-reset' onClick={() => setSearchKey('')}>清除搜索,返回首页</Button>}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 为你推荐 */}
|
|
||||||
<View className='recommend-section'>
|
|
||||||
<Text className='home-section-title'>为你推荐</Text>
|
|
||||||
<View className='home-grid'>
|
|
||||||
{SHOWCASE_LIST.slice(0, 4).map((item, idx) => (
|
|
||||||
<View
|
|
||||||
key={idx}
|
|
||||||
className='media-card'
|
|
||||||
onTap={() => navigateFromShowcase(item)}
|
|
||||||
>
|
|
||||||
<View className='media-card-image'>
|
|
||||||
<ProductImage
|
|
||||||
className='media-card-img'
|
|
||||||
src={item.image}
|
|
||||||
fallback={getProductIconImg(CATEGORIES.find(category => category.id === item.categoryId))}
|
|
||||||
mode='aspectFill'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className='media-card-body'>
|
|
||||||
<Text className='media-card-title'>{item.title}</Text>
|
|
||||||
<Text className='media-card-desc'>{item.desc}</Text>
|
|
||||||
<Text className='media-card-price'>{item.priceText}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -124,11 +124,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders-page .order-icon-img {
|
.orders-page .order-icon-img {
|
||||||
width: 56rpx;
|
width: 100%;
|
||||||
height: 56rpx;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.orders-page .order-info {
|
.orders-page .order-info {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { getOrderList, type OrderItem } from '../../utils/store'
|
import { getOrderList, type OrderItem } from '../../utils/store'
|
||||||
import { fetchOrders, payOrder, cancelOrder, confirmOrder, type ServerOrder } from '../../utils/api/order'
|
import { fetchOrders, payOrder, cancelOrder, confirmOrder, type ServerOrder } from '../../utils/api/order'
|
||||||
import { getProductIconImg } from '../../utils/productConfig'
|
import { getProductById, getProductIconImg } from '../../utils/productConfig'
|
||||||
import { assetUrl } from '../../utils/asset'
|
import { assetUrl } from '../../utils/asset'
|
||||||
import { useThemeContext } from '../../context/ThemeContext'
|
import { useThemeContext } from '../../context/ThemeContext'
|
||||||
import { useSafeArea } from '../../hooks/useSafeArea'
|
import { useSafeArea } from '../../hooks/useSafeArea'
|
||||||
@@ -42,7 +42,8 @@ export default function OrdersPage() {
|
|||||||
const mapOrder = (order: ServerOrder): OrderItem => {
|
const mapOrder = (order: ServerOrder): OrderItem => {
|
||||||
const first = order.items[0]
|
const first = order.items[0]
|
||||||
const statusCode = order.status === 'PENDING' ? 'pending' : order.status === 'PAID' || order.status === 'PROCESSING' ? 'paid' : order.status === 'SHIPPED' ? 'shipping' : order.status === 'COMPLETED' ? 'done' : order.status === 'PAYMENT_EXPIRED' ? 'expired' : 'cancelled'
|
const statusCode = order.status === 'PENDING' ? 'pending' : order.status === 'PAID' || order.status === 'PROCESSING' ? 'paid' : order.status === 'SHIPPED' ? 'shipping' : order.status === 'COMPLETED' ? 'done' : order.status === 'PAYMENT_EXPIRED' ? 'expired' : 'cancelled'
|
||||||
return { id: order.id, productName: first?.name || '定制商品', productIcon: getProductIconImg({ id: first?.productId }), statusCode, date: new Date(order.createdAt).toLocaleString('zh-CN'), price: `¥${Number(order.totalAmount).toFixed(2)}`, count: first?.quantity || 0, sku: order.orderNo, paymentExpiresAt: order.paymentExpiresAt }
|
const productPhoto = getProductById(first?.productId || '')?.images?.[0]
|
||||||
|
return { id: order.id, productName: first?.name || '定制商品', productIcon: productPhoto || getProductIconImg({ id: first?.productId }), statusCode, date: new Date(order.createdAt).toLocaleString('zh-CN'), price: `¥${Number(order.totalAmount).toFixed(2)}`, count: first?.quantity || 0, sku: order.orderNo, paymentExpiresAt: order.paymentExpiresAt }
|
||||||
}
|
}
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try { const remote = await fetchOrders({ page: 1, pageSize: 100 }); setOrders(remote.list.map(mapOrder)) } catch { setOrders(getOrderList()) }
|
try { const remote = await fetchOrders({ page: 1, pageSize: 100 }); setOrders(remote.list.map(mapOrder)) } catch { setOrders(getOrderList()) }
|
||||||
@@ -149,7 +150,7 @@ export default function OrdersPage() {
|
|||||||
</View>
|
</View>
|
||||||
<View className='order-body'>
|
<View className='order-body'>
|
||||||
<View className='order-icon-wrapper'>
|
<View className='order-icon-wrapper'>
|
||||||
<Image className='order-icon-img' src={iconSrc} mode='aspectFit' />
|
<Image className='order-icon-img' src={iconSrc} mode='aspectFill' />
|
||||||
</View>
|
</View>
|
||||||
<View className='order-info'>
|
<View className='order-info'>
|
||||||
<Text className='order-product'>{order.productName}</Text>
|
<Text className='order-product'>{order.productName}</Text>
|
||||||
|
|||||||
@@ -219,15 +219,16 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleQuickClick = (type: string) => {
|
const handleQuickClick = (type: string) => {
|
||||||
Taro.hideToast()
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'toDesign':
|
case 'toDesign':
|
||||||
Taro.setStorageSync('designList:filter', 'toDesign')
|
Taro.setStorageSync('designList:filter', 'undesigned')
|
||||||
Taro.switchTab({ url: '/pages/designList/index' })
|
Taro.switchTab({ url: '/pages/designList/index' })
|
||||||
|
Taro.eventCenter.trigger('tabBarChange', '/pages/designList/index')
|
||||||
break
|
break
|
||||||
case 'pending':
|
case 'pending':
|
||||||
Taro.setStorageSync('orders:filter', 'pending')
|
Taro.setStorageSync('orders:filter', 'pending')
|
||||||
Taro.switchTab({ url: '/pages/orders/index' })
|
Taro.switchTab({ url: '/pages/orders/index' })
|
||||||
|
Taro.eventCenter.trigger('tabBarChange', '/pages/orders/index')
|
||||||
break
|
break
|
||||||
case 'paid':
|
case 'paid':
|
||||||
Taro.setStorageSync('orders:filter', 'paid')
|
Taro.setStorageSync('orders:filter', 'paid')
|
||||||
|
|||||||
@@ -282,7 +282,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(env(safe-area-inset-top, 20px) + 20rpx);
|
top: calc(env(safe-area-inset-top, 20px) + 20rpx);
|
||||||
left: 30rpx;
|
left: 30rpx;
|
||||||
z-index: 50;
|
z-index: 1000;
|
||||||
width: 80rpx;
|
width: 80rpx;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -294,6 +294,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
pointer-events: auto;
|
||||||
transition: opacity 0.2s ease, transform 0.15s ease;
|
transition: opacity 0.2s ease, transform 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { View, Text, ScrollView } from '@tarojs/components'
|
import { View, Text, ScrollView } from '@tarojs/components'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
import { PRODUCTS } from '../../../utils/productConfig'
|
import { PRODUCTS } from '../../../utils/productConfig'
|
||||||
import { fetchProduct } from '../../../utils/api/product'
|
import { fetchProduct } from '../../../utils/api/product'
|
||||||
@@ -9,7 +9,6 @@ import { useSafeArea } from '../../../hooks/useSafeArea'
|
|||||||
import { useStatusBar } from '../../../hooks/useStatusBar'
|
import { useStatusBar } from '../../../hooks/useStatusBar'
|
||||||
import { useThemeContext } from '../../../context/ThemeContext'
|
import { useThemeContext } from '../../../context/ThemeContext'
|
||||||
import ThemedPageMeta from '../../../components/ThemedPageMeta'
|
import ThemedPageMeta from '../../../components/ThemedPageMeta'
|
||||||
import ScrollTopMask from '../../../components/ScrollTopMask'
|
|
||||||
import BottomActionBar from '../../../components/BottomActionBar'
|
import BottomActionBar from '../../../components/BottomActionBar'
|
||||||
import ProductImage from '../../../components/ProductImage'
|
import ProductImage from '../../../components/ProductImage'
|
||||||
|
|
||||||
@@ -21,6 +20,8 @@ export default function ShopDetailPage() {
|
|||||||
const { resolvedTheme } = useThemeContext()
|
const { resolvedTheme } = useThemeContext()
|
||||||
const [winHeight, setWinHeight] = useState(667)
|
const [winHeight, setWinHeight] = useState(667)
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
|
const scrollProgressRef = useRef(0)
|
||||||
|
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const safe = useSafeArea()
|
const safe = useSafeArea()
|
||||||
// 顶部为沉浸式商品图,状态栏始终使用白字。
|
// 顶部为沉浸式商品图,状态栏始终使用白字。
|
||||||
useStatusBar(resolvedTheme, { mode: 'light' })
|
useStatusBar(resolvedTheme, { mode: 'light' })
|
||||||
@@ -28,12 +29,29 @@ export default function ShopDetailPage() {
|
|||||||
const routerParams = Taro.getCurrentInstance().router
|
const routerParams = Taro.getCurrentInstance().router
|
||||||
const routerParamsValues = routerParams ? routerParams.params : undefined
|
const routerParamsValues = routerParams ? routerParams.params : undefined
|
||||||
const productId = routerParamsValues ? routerParamsValues.id : ''
|
const productId = routerParamsValues ? routerParamsValues.id : ''
|
||||||
const [product, setProduct] = useState<ProductCategory | undefined>(PRODUCTS.find(p => p.id === productId))
|
const localProduct = useMemo(() => PRODUCTS.find(p => p.id === productId), [productId])
|
||||||
|
const [product, setProduct] = useState<ProductCategory | undefined>(localProduct)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!productId) return
|
if (!productId) return
|
||||||
fetchProduct(productId).then(setProduct).catch(() => { /* 保留本地兜底 */ })
|
fetchProduct(productId).then(remote => {
|
||||||
}, [productId])
|
// 服务端优先,但介绍文案、规格和图片缺失时保留本地完整资料。
|
||||||
|
if (!localProduct) {
|
||||||
|
setProduct(remote)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProduct({
|
||||||
|
...localProduct,
|
||||||
|
...remote,
|
||||||
|
description: remote.description || localProduct.description,
|
||||||
|
story: remote.story || localProduct.story,
|
||||||
|
scene: remote.scene || localProduct.scene,
|
||||||
|
tags: remote.tags?.length ? remote.tags : localProduct.tags,
|
||||||
|
specs: remote.specs?.length ? remote.specs : localProduct.specs,
|
||||||
|
images: remote.images?.length ? remote.images : localProduct.images,
|
||||||
|
})
|
||||||
|
}).catch(() => setProduct(localProduct))
|
||||||
|
}, [localProduct, productId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -44,19 +62,25 @@ export default function ShopDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 恢复原有的沉浸式收束动画;每帧只合并一次滚动状态,避免连续 setState 堆积。
|
||||||
const handleScroll = useCallback((e: any) => {
|
const handleScroll = useCallback((e: any) => {
|
||||||
const st = (e.detail && e.detail.scrollTop) || 0
|
const scrollTop = (e.detail && e.detail.scrollTop) || 0
|
||||||
const total = winHeight * 1.2
|
scrollProgressRef.current = clamp(scrollTop / (winHeight * 1.2), 0, 1)
|
||||||
const p = clamp(st / total, 0, 1)
|
if (scrollTimerRef.current !== null) return
|
||||||
setProgress(p)
|
scrollTimerRef.current = setTimeout(() => {
|
||||||
|
scrollTimerRef.current = null
|
||||||
|
setProgress(current => Math.abs(current - scrollProgressRef.current) < 0.002 ? current : scrollProgressRef.current)
|
||||||
|
}, 16)
|
||||||
}, [winHeight])
|
}, [winHeight])
|
||||||
|
|
||||||
/* Scroll-progress → multi-stage hero & content animations */
|
useEffect(() => () => {
|
||||||
|
if (scrollTimerRef.current !== null) clearTimeout(scrollTimerRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const heroStyles = useMemo(() => {
|
const heroStyles = useMemo(() => {
|
||||||
const p = progress
|
const p = progress
|
||||||
|
let topCrop = 0
|
||||||
// 1. Image crop insets (vh %)
|
let bottomCrop = 0
|
||||||
let topCrop = 0, bottomCrop = 0
|
|
||||||
if (p <= 0.4) {
|
if (p <= 0.4) {
|
||||||
const t = easeOut(p / 0.4)
|
const t = easeOut(p / 0.4)
|
||||||
topCrop = lerp(0, 32, t)
|
topCrop = lerp(0, 32, t)
|
||||||
@@ -66,60 +90,36 @@ export default function ShopDetailPage() {
|
|||||||
topCrop = lerp(32, 34, t)
|
topCrop = lerp(32, 34, t)
|
||||||
bottomCrop = lerp(38, 41, t)
|
bottomCrop = lerp(38, 41, t)
|
||||||
} else {
|
} else {
|
||||||
topCrop = 34; bottomCrop = 41
|
topCrop = 34
|
||||||
|
bottomCrop = 41
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Hero translateY
|
const translateY = p <= 0.4 ? 0 : p <= 0.7 ? lerp(0, -75, (p - 0.4) / 0.3) : -75
|
||||||
let translateY = 0
|
const heroInfoOpacity = p <= 0.35 ? 1 : p <= 0.48 ? clamp(1 - (p - 0.35) / 0.13, 0, 1) : 0
|
||||||
if (p <= 0.4) translateY = 0
|
const bandOpacity = p <= 0.55 ? 0 : p <= 0.7 ? (p - 0.55) / 0.15 : 1
|
||||||
else if (p <= 0.7) translateY = lerp(0, -75, (p - 0.4) / 0.3)
|
const contentOpacity = p <= 0.05 ? 0 : p <= 0.22 ? easeOut((p - 0.05) / 0.17) : 1
|
||||||
else translateY = -75
|
const contentOffset = p <= 0.05 ? 60 : p <= 0.22 ? lerp(60, 0, easeOut((p - 0.05) / 0.17)) : 0
|
||||||
|
return { topCrop, bottomCrop, translateY, heroInfoOpacity, bandOpacity, contentOpacity, contentOffset, visibleHeight: 100 - topCrop - bottomCrop }
|
||||||
// 3. Hero bottom info opacity (always visible on open, fades after scroll)
|
|
||||||
let heroInfoOpacity = 1
|
|
||||||
if (p <= 0.35) heroInfoOpacity = 1
|
|
||||||
else if (p <= 0.48) heroInfoOpacity = clamp(1 - (p - 0.35) / 0.13, 0, 1)
|
|
||||||
else heroInfoOpacity = 0
|
|
||||||
|
|
||||||
// 4. Transition band opacity
|
|
||||||
let bandOpacity = 0
|
|
||||||
if (p <= 0.55) bandOpacity = 0
|
|
||||||
else if (p <= 0.7) bandOpacity = (p - 0.55) / 0.15
|
|
||||||
else bandOpacity = 1
|
|
||||||
|
|
||||||
// 5. Content reveal
|
|
||||||
let contentOpacity = 1, contentOffset = 0
|
|
||||||
if (p <= 0.05) { contentOpacity = 0; contentOffset = 60 }
|
|
||||||
else if (p <= 0.22) {
|
|
||||||
const t = (p - 0.05) / 0.17
|
|
||||||
contentOpacity = easeOut(t)
|
|
||||||
contentOffset = lerp(60, 0, easeOut(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Back button (fade out as hero scrolls)
|
|
||||||
let backBtnOpacity = 1
|
|
||||||
if (p <= 0.15) backBtnOpacity = clamp(1 - p / 0.15, 0, 1)
|
|
||||||
|
|
||||||
return {
|
|
||||||
topCrop,
|
|
||||||
bottomCrop,
|
|
||||||
translateY,
|
|
||||||
heroInfoOpacity,
|
|
||||||
bandOpacity,
|
|
||||||
contentOpacity,
|
|
||||||
contentOffset,
|
|
||||||
backBtnOpacity,
|
|
||||||
visibleHeight: 100 - topCrop - bottomCrop
|
|
||||||
}
|
|
||||||
}, [progress])
|
}, [progress])
|
||||||
|
|
||||||
const tone = useMemo(() => (product ? product.tone : [28, 22, 18]), [product])
|
const tone = useMemo(() => (product ? product.tone : [28, 22, 18]), [product])
|
||||||
|
const productDescription = product?.description || product?.subtitle || product?.desc || ''
|
||||||
|
const leavingRef = useRef(false)
|
||||||
|
|
||||||
|
const returnToShop = () => {
|
||||||
|
// 详情页的稳定出口是商品 Tab,避免开发者工具直达页面或异常页面栈导致返回失效。
|
||||||
|
if (leavingRef.current) return
|
||||||
|
leavingRef.current = true
|
||||||
|
const target = { url: '/pages/shop/index' }
|
||||||
|
Taro.switchTab(target).catch(() => Taro.reLaunch(target)).catch(() => {
|
||||||
|
leavingRef.current = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return (
|
return (
|
||||||
<View className="shop-detail-page theme-dark">
|
<View className="shop-detail-page theme-dark">
|
||||||
<ThemedPageMeta darkTop />
|
<ThemedPageMeta darkTop />
|
||||||
<ScrollTopMask title="商品详情" targetSelector=".shop-back-btn" showBack />
|
|
||||||
<Text style={{ color: '#fff', padding: '40rpx' }}>商品不存在</Text>
|
<Text style={{ color: '#fff', padding: '40rpx' }}>商品不存在</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -128,7 +128,6 @@ export default function ShopDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<View className="shop-detail-page theme-dark">
|
<View className="shop-detail-page theme-dark">
|
||||||
<ThemedPageMeta darkTop />
|
<ThemedPageMeta darkTop />
|
||||||
<ScrollTopMask title="商品详情" targetSelector=".shop-back-btn" showBack />
|
|
||||||
{/* Fixed Hero */}
|
{/* Fixed Hero */}
|
||||||
<View className="shop-hero-fixed">
|
<View className="shop-hero-fixed">
|
||||||
<View
|
<View
|
||||||
@@ -162,10 +161,7 @@ export default function ShopDetailPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View
|
<View className="shop-hero-info" style={{ opacity: heroStyles.heroInfoOpacity }}>
|
||||||
className="shop-hero-info"
|
|
||||||
style={{ opacity: heroStyles.heroInfoOpacity }}
|
|
||||||
>
|
|
||||||
<Text className="shop-hero-title">{product.name}</Text>
|
<Text className="shop-hero-title">{product.name}</Text>
|
||||||
<Text className="shop-hero-subtitle">{product.subtitle || product.desc}</Text>
|
<Text className="shop-hero-subtitle">{product.subtitle || product.desc}</Text>
|
||||||
<View className="shop-hero-price-row">
|
<View className="shop-hero-price-row">
|
||||||
@@ -193,7 +189,7 @@ export default function ShopDetailPage() {
|
|||||||
className="shop-content"
|
className="shop-content"
|
||||||
style={{ backgroundColor: `rgb(${tone[0]}, ${tone[1]}, ${tone[2]})` }}
|
style={{ backgroundColor: `rgb(${tone[0]}, ${tone[1]}, ${tone[2]})` }}
|
||||||
>
|
>
|
||||||
{/* Transition band — appears as hero scrolls away */}
|
{/* 原有的主图收束后,浮动信息条自然接替标题和价格。 */}
|
||||||
<View
|
<View
|
||||||
className="shop-band"
|
className="shop-band"
|
||||||
style={{
|
style={{
|
||||||
@@ -217,20 +213,25 @@ export default function ShopDetailPage() {
|
|||||||
{/* Main copy blocks */}
|
{/* Main copy blocks */}
|
||||||
<View
|
<View
|
||||||
className="shop-detail-body"
|
className="shop-detail-body"
|
||||||
style={{
|
style={{ opacity: heroStyles.contentOpacity, transform: `translateY(${heroStyles.contentOffset}px)` }}
|
||||||
opacity: heroStyles.contentOpacity,
|
|
||||||
transform: `translateY(${heroStyles.contentOffset}px)`
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View className="shop-content-card">
|
<View className="shop-content-card">
|
||||||
|
<View className="shop-section">
|
||||||
|
<Text className="shop-section-title">商品介绍</Text>
|
||||||
|
<Text className="shop-section-text">{productDescription}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{product.story && (
|
{product.story && (
|
||||||
<View className="shop-section">
|
<>
|
||||||
<Text className="shop-section-title">设计理念</Text>
|
<View className="shop-divider" />
|
||||||
<Text className="shop-section-text">{product.story}</Text>
|
<View className="shop-section">
|
||||||
</View>
|
<Text className="shop-section-title">设计理念</Text>
|
||||||
|
<Text className="shop-section-text">{product.story}</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(product.story && product.scene) && (
|
{product.scene && (
|
||||||
<View className="shop-divider" />
|
<View className="shop-divider" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -253,11 +254,11 @@ export default function ShopDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{((product.story || product.scene) && product.tags && product.tags.length > 0) && (
|
{product.tags && product.tags.length > 0 && (
|
||||||
<View className="shop-divider" />
|
<View className="shop-divider" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{((product.story || product.scene || (product.tags && product.tags.length > 0)) && product.specs && product.specs.length > 0) && (
|
{product.specs && product.specs.length > 0 && (
|
||||||
<View className="shop-divider" />
|
<View className="shop-divider" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -284,8 +285,9 @@ export default function ShopDetailPage() {
|
|||||||
{/* Back button — glass morphism circle */}
|
{/* Back button — glass morphism circle */}
|
||||||
<View
|
<View
|
||||||
className="shop-back-btn"
|
className="shop-back-btn"
|
||||||
onTap={() => Taro.navigateBack()}
|
onTap={returnToShop}
|
||||||
style={{ top: `${safe.menuButtonTop}px`, opacity: heroStyles.backBtnOpacity }}
|
onClick={returnToShop}
|
||||||
|
style={{ top: `${safe.menuButtonTop}px` }}
|
||||||
>
|
>
|
||||||
<Text className="shop-back-arrow">←</Text>
|
<Text className="shop-back-arrow">←</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -299,7 +301,7 @@ export default function ShopDetailPage() {
|
|||||||
<Text className="summary-original">¥{product.originalPrice}</Text>
|
<Text className="summary-original">¥{product.originalPrice}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className="btn-secondary" onTap={() => Taro.navigateBack()}>
|
<View className="btn-secondary" onTap={returnToShop} onClick={returnToShop}>
|
||||||
<Text>返回</Text>
|
<Text>返回</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="btn-primary" onTap={() => Taro.navigateTo({ url: `/pages/product/index?id=${product.id}` })}>
|
<View className="btn-primary" onTap={() => Taro.navigateTo({ url: `/pages/product/index?id=${product.id}` })}>
|
||||||
|
|||||||
+20
-5
@@ -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
|
||||||
|
/** 服务端 createdAt(ISO),展示时取日期部分 */
|
||||||
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
@@ -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 forbidNonWhitelisted:undefined 字段会被 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
@@ -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 驱动。
|
||||||
|
*
|
||||||
|
* fetchDesign(GET /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(→SUBMITTED,DIY 保存设计时) */
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user