From 318f5c2a640e0b81f2203fec880fe506a1d9f3e9 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Tue, 1 Sep 2026 17:15:19 +0800 Subject: [PATCH 01/22] deploy(v2): update h5 build artifacts (new entry & products chunks) --- deploy/h5/index.html | 2 +- deploy/h5/static/js/index.c5073ea1.js | 1 - deploy/h5/static/js/{index.e79d1a25.js => index.f402cc83.js} | 2 +- deploy/h5/static/js/pages-products-products.bf1de6d2.js | 3 --- deploy/h5/static/js/pages-products-products.c41769e2.js | 3 +++ 5 files changed, 5 insertions(+), 6 deletions(-) delete mode 100644 deploy/h5/static/js/index.c5073ea1.js rename deploy/h5/static/js/{index.e79d1a25.js => index.f402cc83.js} (94%) delete mode 100644 deploy/h5/static/js/pages-products-products.bf1de6d2.js create mode 100644 deploy/h5/static/js/pages-products-products.c41769e2.js diff --git a/deploy/h5/index.html b/deploy/h5/index.html index c129930..087be1f 100644 --- a/deploy/h5/index.html +++ b/deploy/h5/index.html @@ -1,2 +1,2 @@ 印美达POD供应链
\ No newline at end of file + document.write('')
\ No newline at end of file diff --git a/deploy/h5/static/js/index.c5073ea1.js b/deploy/h5/static/js/index.c5073ea1.js deleted file mode 100644 index 54a3af0..0000000 --- a/deploy/h5/static/js/index.c5073ea1.js +++ /dev/null @@ -1 +0,0 @@ -(function(e){function t(t){for(var o,i,c=t[0],u=t[1],d=t[2],s=0,f=[];s>>/g,">"),e=e.split(">").map((function(e){return e.trim().split(" ").join("').descendant('")})).join("').child('"),function(t,n,o){return new t(n,o).descendant(e)}},d=function(){function e(t){var n=arguments.length>1&&void 0!==arguments[1]&&arguments[1];(0,r.default)(this,e),this.nodes=[t],this.all=n}return(0,i.default)(e,[{key:"child",value:function(e){var t=[];if(this.all)this.nodes.forEach((function(n){var o;(o=t).push.apply(o,(0,a.default)(n.$children.filter((function(t){return c(t,e)}))))}));else if(this.nodes.length>0){var n=this.nodes[0].$children.find((function(t){return c(t,e)}));t=n?[n]:[]}return this.nodes=t,this}},{key:"descendant",value:function(e){var t=this,n=[];return this.nodes.forEach((function(o){(function(){var e=!1;return function t(n,o){if(!e&&"function"===typeof o)for(var a=n.$children,r=0;!e&&r",nbsp:" ",amp:"&",quot:'"'};return e.replace(/&(lt|gt|nbsp|amp|quot);/gi,(function(e,n){return t[n]}))},t.html2Escape=function(e){return e?e.replace(/[<>&"]/g,(function(e){return{"<":"<",">":">","&":"&",'"':"""}[e]})):e},n("5c47"),n("a1c1")},4045:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.getRelationNodes=function(e){if(!this.$unicom)throw"this.getRelationNodes()需与p-f-unicom配合使用!";return this.$unicom("@"+e)}},"5da8":function(e,t,n){"use strict";n.d(t,"b",(function(){return o})),n.d(t,"c",(function(){return a})),n.d(t,"a",(function(){}));var o=function(){var e=this.$createElement,t=this._self._c||e;return t("App",{attrs:{keepAliveInclude:this.keepAliveInclude}})},a=[]},"792a":function(e,t,n){"use strict";var o=n("9d8b"),a=n.n(o);a.a},"7a41":function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0,n("5c47"),n("2c10"),n("c9b5"),n("bf0f"),n("ab80"),n("473f"),n("f7a5");var o=function(e,t,n){return Object(e)!==e||(Array.isArray(t)||(t=t.toString().match(/[^.[\]]+/g)||[]),t.slice(0,-1).reduce((function(e,n,o){return Object(e[n])===e[n]?e[n]:e[n]=Math.abs(t[o+1])>>0===+t[o+1]?[]:{}}),e)[t[t.length-1]]=n),e};t.default=o},"95d5":function(e,t,n){var o=n("c86c");t=o(!1),t.push([e.i,"/* 全局样式 - 印美达 Inkreach 微信小程序 */uni-page-body{--color-primary:#ff6800;--color-primary-light:#fff0e5;--color-primary-dark:#e55a00;--color-secondary:#f26522;--color-dark:#121212;--color-text-primary:#333;--color-text-secondary:#666;--color-text-tertiary:#999;--color-bg-page:#f5f5f5;--color-bg-card:#fff;--color-border:#eee;--color-tag-bg:#f2f2f2;--color-success:#52c41a;--color-warning:#faad14;--radius-sm:%?8?%;--radius-md:%?12?%;--radius-lg:%?20?%;--radius-xl:%?32?%;--spacing-xs:%?8?%;--spacing-sm:%?16?%;--spacing-md:%?24?%;--spacing-lg:%?32?%;--spacing-xl:%?48?%;font-size:%?28?%;color:var(--color-text-primary);background-color:var(--color-bg-page);font-family:CusPingFangSC,-apple-system,BlinkMacSystemFont,PingFang SC,Helvetica Neue,sans-serif}body.?%PAGE?%{background-color:var(--color-bg-page)}\n/* 通用样式 */.container{min-height:100vh;background-color:var(--color-bg-page)}.flex-row{display:flex;flex-direction:row;align-items:center}.flex-col{display:flex;flex-direction:column}.flex-center{display:flex;align-items:center;justify-content:center}.flex-between{display:flex;align-items:center;justify-content:space-between}.flex-1{flex:1}.flex-wrap{display:flex;flex-wrap:wrap}\n/* 文字样式 */.text-primary{color:var(--color-primary)}.text-secondary{color:var(--color-text-secondary)}.text-tertiary{color:var(--color-text-tertiary)}.text-white{color:#fff}.text-bold{font-weight:600}.text-center{text-align:center}.text-sm{font-size:%?24?%}.text-md{font-size:%?28?%}.text-lg{font-size:%?32?%}.text-xl{font-size:%?40?%}.text-xxl{font-size:%?56?%}\n/* 间距 */.mt-xs{margin-top:%?8?%}.mt-sm{margin-top:%?16?%}.mt-md{margin-top:%?24?%}.mt-lg{margin-top:%?32?%}.mt-xl{margin-top:%?48?%}.mb-xs{margin-bottom:%?8?%}.mb-sm{margin-bottom:%?16?%}.mb-md{margin-bottom:%?24?%}.mb-lg{margin-bottom:%?32?%}.p-sm{padding:%?16?%}.p-md{padding:%?24?%}.p-lg{padding:%?32?%}.px-md{padding-left:%?24?%;padding-right:%?24?%}.px-lg{padding-left:%?32?%;padding-right:%?32?%}\n/* 按钮 */.btn-primary{display:inline-flex;align-items:center;justify-content:center;background-color:var(--color-primary);color:#fff;font-size:%?28?%;font-weight:500;padding:%?20?% %?48?%;border-radius:var(--radius-lg);border:none;line-height:1.5}.btn-primary::after{border:none}.btn-outline{display:inline-flex;align-items:center;justify-content:center;background-color:initial;color:var(--color-primary);font-size:%?28?%;font-weight:500;padding:%?20?% %?48?%;border-radius:var(--radius-lg);border:%?2?% solid var(--color-primary);line-height:1.5}.btn-outline::after{border:none}\n/* 卡片 */.card{background-color:var(--color-bg-card);border-radius:var(--radius-md);padding:%?24?%;box-shadow:0 %?2?% %?12?% rgba(0,0,0,.04)}\n/* 标签 */.tag{display:inline-flex;align-items:center;padding:%?4?% %?16?%;border-radius:var(--radius-sm);font-size:%?20?%;background-color:var(--color-tag-bg);color:var(--color-text-secondary)}.tag-primary{background-color:var(--color-primary-light);color:var(--color-primary)}\n/* 分割线 */.divider{width:100%;height:%?1?%;background-color:var(--color-border)}\n/* 安全区底部 */.safe-bottom{padding-bottom:env(safe-area-inset-bottom)}\n/* 全局隐藏横向滚动条(scroll-view / webkit) */::-webkit-scrollbar{display:none;width:0;height:0;background:transparent}",""]),e.exports=t},"98bce":function(e,t,n){"use strict";n.r(t);var o=n("efa3"),a=n.n(o);for(var r in o)["default"].indexOf(r)<0&&function(e){n.d(t,e,(function(){return o[e]}))}(r);t["default"]=a.a},"9d8b":function(e,t,n){var o=n("95d5");o.__esModule&&(o=o.default),"string"===typeof o&&(o=[[e.i,o,""]]),o.locals&&(e.exports=o.locals);var a=n("967d").default;a("37b6588b",o,!0,{sourceMap:!1,shadowMode:!1})},ac9f:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var o=null,a=function(e,t,n){null!==o&&clearTimeout(o),o=setTimeout((function(){e[t]()}),n)};t.default=a},bad1:function(e,t,n){"use strict";(function(e){var t=n("f5bd").default;n("473f"),n("bf0f"),n("de6c"),n("5c47"),n("a1c1");var o=t(n("9b8e")),a={keys:function(){return[]}};e["____73E5929____"]=!0,delete e["____73E5929____"],e.__uniConfig={tabBar:{color:"#999999",selectedColor:"#FF6800",backgroundColor:"#ffffff",borderStyle:"white",list:[{pagePath:"pages/index/index",text:"首页",iconPath:"static/icons/tabbar/tab_home_gray.png",selectedIconPath:"static/icons/tabbar/tab_home_orange.png",redDot:!1,badge:""},{pagePath:"pages/products/products",text:"产品",iconPath:"static/icons/tabbar/tab_product_gray.png",selectedIconPath:"static/icons/tabbar/tab_product_orange.png",redDot:!1,badge:""}]},style:"v2",sitemapLocation:"sitemap.json",lazyCodeLoading:"requiredComponents",globalStyle:{navigationBarBackgroundColor:"#ffffff",navigationBarTextStyle:"black",navigationBarTitleText:"印美达 Inkreach",backgroundColor:"#f5f5f5",backgroundTextStyle:"dark",enablePullDownRefresh:!1}},e.__uniConfig.compilerVersion="5.24",e.__uniConfig.darkmode=!1,e.__uniConfig.themeConfig={},e.__uniConfig.uniPlatform="h5",e.__uniConfig.appId="__UNI__73E5929",e.__uniConfig.appName="inkreach-miniprogram",e.__uniConfig.appVersion="1.0.0",e.__uniConfig.appVersionCode="100",e.__uniConfig.router={mode:"history",base:"/h5/"},e.__uniConfig.publicPath="/h5/",e.__uniConfig["async"]={loading:"AsyncLoading",error:"AsyncError",delay:200,timeout:6e4},e.__uniConfig.debug=!1,e.__uniConfig.networkTimeout={request:6e4,connectSocket:6e4,uploadFile:6e4,downloadFile:6e4},e.__uniConfig.sdkConfigs={},e.__uniConfig.qqMapKey=void 0,e.__uniConfig.googleMapKey=void 0,e.__uniConfig.aMapKey=void 0,e.__uniConfig.aMapSecurityJsCode=void 0,e.__uniConfig.aMapServiceHost=void 0,e.__uniConfig.locale="",e.__uniConfig.fallbackLocale=void 0,e.__uniConfig.locales=a.keys().reduce((function(e,t){var n=t.replace(/\.\/(uni-app.)?(.*).json/,"$2"),o=a(t);return Object.assign(e[n]||(e[n]={}),o.common||o),e}),{}),e.__uniConfig.nvue={"flex-direction":"column"},e.__uniConfig.__webpack_chunk_load__=n.e,o.default.component("pages-index-index",(function(e){var t={component:n.e("pages-index-index").then(function(){return e(n("3dd4"))}.bind(null,n)).catch(n.oe),delay:__uniConfig["async"].delay,timeout:__uniConfig["async"].timeout};return __uniConfig["async"]["loading"]&&(t.loading={name:"SystemAsyncLoading",render:function(e){return e(__uniConfig["async"]["loading"])}}),__uniConfig["async"]["error"]&&(t.error={name:"SystemAsyncError",render:function(e){return e(__uniConfig["async"]["error"])}}),t})),o.default.component("pages-products-products",(function(e){var t={component:n.e("pages-products-products").then(function(){return e(n("379b"))}.bind(null,n)).catch(n.oe),delay:__uniConfig["async"].delay,timeout:__uniConfig["async"].timeout};return __uniConfig["async"]["loading"]&&(t.loading={name:"SystemAsyncLoading",render:function(e){return e(__uniConfig["async"]["loading"])}}),__uniConfig["async"]["error"]&&(t.error={name:"SystemAsyncError",render:function(e){return e(__uniConfig["async"]["error"])}}),t})),o.default.component("pages-product-detail-product-detail",(function(e){var t={component:n.e("pages-product-detail-product-detail").then(function(){return e(n("0292"))}.bind(null,n)).catch(n.oe),delay:__uniConfig["async"].delay,timeout:__uniConfig["async"].timeout};return __uniConfig["async"]["loading"]&&(t.loading={name:"SystemAsyncLoading",render:function(e){return e(__uniConfig["async"]["loading"])}}),__uniConfig["async"]["error"]&&(t.error={name:"SystemAsyncError",render:function(e){return e(__uniConfig["async"]["error"])}}),t})),e.__uniRoutes=[{path:"/",alias:"/pages/index/index",component:{render:function(e){return e("Page",{props:Object.assign({isQuit:!0,isEntry:!0,isTabBar:!0,tabBarIndex:0},__uniConfig.globalStyle,{navigationBarTitleText:"印美达 Inkreach",navigationStyle:"custom",usingComponents:{}})},[e("pages-index-index",{slot:"page"})])}},meta:{id:1,name:"pages-index-index",isNVue:!1,maxWidth:0,pagePath:"pages/index/index",isQuit:!0,isEntry:!0,isTabBar:!0,tabBarIndex:0,windowTop:0}},{path:"/pages/products/products",component:{render:function(e){return e("Page",{props:Object.assign({isQuit:!0,isTabBar:!0,tabBarIndex:1},__uniConfig.globalStyle,{navigationBarTitleText:"产品中心",navigationStyle:"custom",usingComponents:{}})},[e("pages-products-products",{slot:"page"})])}},meta:{id:2,name:"pages-products-products",isNVue:!1,maxWidth:0,pagePath:"pages/products/products",isQuit:!0,isTabBar:!0,tabBarIndex:1,windowTop:0}},{path:"/pages/product-detail/product-detail",component:{render:function(e){return e("Page",{props:Object.assign({},__uniConfig.globalStyle,{navigationBarTitleText:"产品详情",navigationStyle:"custom",usingComponents:{}})},[e("pages-product-detail-product-detail",{slot:"page"})])}},meta:{name:"pages-product-detail-product-detail",isNVue:!1,maxWidth:0,pagePath:"pages/product-detail/product-detail",windowTop:0}},{path:"/choose-location",component:{render:function(e){return e("Page",{props:{navigationStyle:"custom"}},[e("system-choose-location",{slot:"page"})])}},meta:{name:"choose-location",pagePath:"/choose-location"}},{path:"/open-location",component:{render:function(e){return e("Page",{props:{navigationStyle:"custom"}},[e("system-open-location",{slot:"page"})])}},meta:{name:"open-location",pagePath:"/open-location"}}],e.UniApp&&new e.UniApp}).call(this,n("0ee4"))},beb3:function(e,t,n){"use strict";(function(e){n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.getTabBar=function(){return{setData:function(t){var n,o,a,r;"function"===typeof(null===(n=this.$mp)||void 0===n||null===(o=n.page)||void 0===o?void 0:o.getTabBar)&&null!==(a=this.$mp)&&void 0!==a&&null!==(r=a.page)&&void 0!==r&&r.getTabBar()?this.$mp.page.getTabBar().setData(t):e.log("当前平台不支持getTabBar(),已稍作处理,详细请参见相关文档。")}}}}).call(this,n("ba7c")["default"])},c31e:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.parseEventDynamicCode=function(e,t){"function"===typeof this[t]&&this[t](e)}},cb0d:function(e,t,n){"use strict";var o=n("f5bd").default;Object.defineProperty(t,"__esModule",{value:!0}),t.setData=function(e){var t=this,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null;Object.keys(e).forEach((function(n){if((0,a.default)(t,n,e[n]),r.test(n)&&n.endsWith("Clone")){var o=n.replace(/Clone$/,"");t.$options&&t.$options.propsData[o]&&t.$emit("update:".concat(o),e[n])}})),this.$forceUpdate(),"function"==typeof n&&this.$nextTick(n)},n("bf0f"),n("2797"),n("5c47"),n("0506"),n("dc8a"),n("a1c1"),n("20f3"),n("f7a5"),n("6a54"),n("9327");var a=o(n("7a41"));o(n("ac9f"));var r=/^([^\x00-\xff]|[a-zA-Z_$])([^\x00-\xff]|[a-zA-Z0-9_$])*$/},d575:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.handleDataset=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};e&&!e.currentTarget&&(t.tagId?e.currentTarget={id:t.tagId}:e.currentTarget={dataset:t})}},dc38:function(e,t,n){"use strict";n("6a54");var o=n("f5bd").default;Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var a=o(n("9b1b")),r=n("ff7a"),i=n("00f0"),c=n("d575"),u=n("3f573"),d=n("c31e"),l=n("beb3"),s=n("4045"),f=n("372e"),p=n("cb0d"),g={install:function(e,t){e.mixin((0,a.default)((0,a.default)({},r.pageLifetimes),{},{methods:{clone:i.clone,handleDataset:c.handleDataset,escape2Html:u.escape2Html,html2Escape:u.html2Escape,parseEventDynamicCode:d.parseEventDynamicCode,getTabBar:l.getTabBar,getRelationNodes:s.getRelationNodes,zpSelectComponent:f.selectComponent,zpSelectAllComponents:f.selectAllComponents,setData:p.setData}}))}};t.default=g},e4af:function(e,t,n){"use strict";var o=n("f5bd").default,a=o(n("9b1b"));n("3dde"),n("a8b2"),n("1480"),n("6e4a"),n("bad1"),n("d6b2");var r=o(n("3c6c")),i=o(n("dc38")),c=o(n("9b8e"));c.default.use(i.default),c.default.config.productionTip=!1,r.default.mpType="app";var u=new c.default((0,a.default)({},r.default));u.$mount()},efa3:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var o={data:function(){return{}},globalData:{userInfo:null,baseUrl:"https://api.inkreach.com"},onLaunch:function(){var e=uni.getWindowInfo();this.globalData.statusBarHeight=e.statusBarHeight,this.globalData.windowWidth=e.windowWidth,this.globalData.windowHeight=e.windowHeight}};t.default=o},ff7a:function(e,t,n){"use strict";function o(e,t){e.$children.map((function(e){"function"==typeof e[t]&&e[t](),o(e,t)}))}n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.pageLifetimes=void 0,n("fd3c");var a={onLoad:function(){var e=this;uni.onWindowResize((function(t){o(e,"handlePageResize")}))},onShow:function(){o(this,"handlePageShow")},onHide:function(){o(this,"handlePageHide")},onResize:function(){}};t.pageLifetimes=a}}); \ No newline at end of file diff --git a/deploy/h5/static/js/index.e79d1a25.js b/deploy/h5/static/js/index.f402cc83.js similarity index 94% rename from deploy/h5/static/js/index.e79d1a25.js rename to deploy/h5/static/js/index.f402cc83.js index ba6ecaa..57a431b 100644 --- a/deploy/h5/static/js/index.e79d1a25.js +++ b/deploy/h5/static/js/index.f402cc83.js @@ -1 +1 @@ -(function(e){function t(t){for(var o,i,c=t[0],u=t[1],d=t[2],s=0,f=[];s>>/g,">"),e=e.split(">").map((function(e){return e.trim().split(" ").join("').descendant('")})).join("').child('"),function(t,n,o){return new t(n,o).descendant(e)}},d=function(){function e(t){var n=arguments.length>1&&void 0!==arguments[1]&&arguments[1];(0,r.default)(this,e),this.nodes=[t],this.all=n}return(0,i.default)(e,[{key:"child",value:function(e){var t=[];if(this.all)this.nodes.forEach((function(n){var o;(o=t).push.apply(o,(0,a.default)(n.$children.filter((function(t){return c(t,e)}))))}));else if(this.nodes.length>0){var n=this.nodes[0].$children.find((function(t){return c(t,e)}));t=n?[n]:[]}return this.nodes=t,this}},{key:"descendant",value:function(e){var t=this,n=[];return this.nodes.forEach((function(o){(function(){var e=!1;return function t(n,o){if(!e&&"function"===typeof o)for(var a=n.$children,r=0;!e&&r",nbsp:" ",amp:"&",quot:'"'};return e.replace(/&(lt|gt|nbsp|amp|quot);/gi,(function(e,n){return t[n]}))},t.html2Escape=function(e){return e?e.replace(/[<>&"]/g,(function(e){return{"<":"<",">":">","&":"&",'"':"""}[e]})):e},n("5c47"),n("a1c1")},4045:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.getRelationNodes=function(e){if(!this.$unicom)throw"this.getRelationNodes()需与p-f-unicom配合使用!";return this.$unicom("@"+e)}},"5da8":function(e,t,n){"use strict";n.d(t,"b",(function(){return o})),n.d(t,"c",(function(){return a})),n.d(t,"a",(function(){}));var o=function(){var e=this.$createElement,t=this._self._c||e;return t("App",{attrs:{keepAliveInclude:this.keepAliveInclude}})},a=[]},"792a":function(e,t,n){"use strict";var o=n("9d8b"),a=n.n(o);a.a},"7a41":function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0,n("5c47"),n("2c10"),n("c9b5"),n("bf0f"),n("ab80"),n("473f"),n("f7a5");var o=function(e,t,n){return Object(e)!==e||(Array.isArray(t)||(t=t.toString().match(/[^.[\]]+/g)||[]),t.slice(0,-1).reduce((function(e,n,o){return Object(e[n])===e[n]?e[n]:e[n]=Math.abs(t[o+1])>>0===+t[o+1]?[]:{}}),e)[t[t.length-1]]=n),e};t.default=o},"95d5":function(e,t,n){var o=n("c86c");t=o(!1),t.push([e.i,"/* 全局样式 - 印美达 Inkreach 微信小程序 */uni-page-body{--color-primary:#ff6800;--color-primary-light:#fff0e5;--color-primary-dark:#e55a00;--color-secondary:#f26522;--color-dark:#121212;--color-text-primary:#333;--color-text-secondary:#666;--color-text-tertiary:#999;--color-bg-page:#f5f5f5;--color-bg-card:#fff;--color-border:#eee;--color-tag-bg:#f2f2f2;--color-success:#52c41a;--color-warning:#faad14;--radius-sm:%?8?%;--radius-md:%?12?%;--radius-lg:%?20?%;--radius-xl:%?32?%;--spacing-xs:%?8?%;--spacing-sm:%?16?%;--spacing-md:%?24?%;--spacing-lg:%?32?%;--spacing-xl:%?48?%;font-size:%?28?%;color:var(--color-text-primary);background-color:var(--color-bg-page);font-family:CusPingFangSC,-apple-system,BlinkMacSystemFont,PingFang SC,Helvetica Neue,sans-serif}body.?%PAGE?%{background-color:var(--color-bg-page)}\n/* 通用样式 */.container{min-height:100vh;background-color:var(--color-bg-page)}.flex-row{display:flex;flex-direction:row;align-items:center}.flex-col{display:flex;flex-direction:column}.flex-center{display:flex;align-items:center;justify-content:center}.flex-between{display:flex;align-items:center;justify-content:space-between}.flex-1{flex:1}.flex-wrap{display:flex;flex-wrap:wrap}\n/* 文字样式 */.text-primary{color:var(--color-primary)}.text-secondary{color:var(--color-text-secondary)}.text-tertiary{color:var(--color-text-tertiary)}.text-white{color:#fff}.text-bold{font-weight:600}.text-center{text-align:center}.text-sm{font-size:%?24?%}.text-md{font-size:%?28?%}.text-lg{font-size:%?32?%}.text-xl{font-size:%?40?%}.text-xxl{font-size:%?56?%}\n/* 间距 */.mt-xs{margin-top:%?8?%}.mt-sm{margin-top:%?16?%}.mt-md{margin-top:%?24?%}.mt-lg{margin-top:%?32?%}.mt-xl{margin-top:%?48?%}.mb-xs{margin-bottom:%?8?%}.mb-sm{margin-bottom:%?16?%}.mb-md{margin-bottom:%?24?%}.mb-lg{margin-bottom:%?32?%}.p-sm{padding:%?16?%}.p-md{padding:%?24?%}.p-lg{padding:%?32?%}.px-md{padding-left:%?24?%;padding-right:%?24?%}.px-lg{padding-left:%?32?%;padding-right:%?32?%}\n/* 按钮 */.btn-primary{display:inline-flex;align-items:center;justify-content:center;background-color:var(--color-primary);color:#fff;font-size:%?28?%;font-weight:500;padding:%?20?% %?48?%;border-radius:var(--radius-lg);border:none;line-height:1.5}.btn-primary::after{border:none}.btn-outline{display:inline-flex;align-items:center;justify-content:center;background-color:initial;color:var(--color-primary);font-size:%?28?%;font-weight:500;padding:%?20?% %?48?%;border-radius:var(--radius-lg);border:%?2?% solid var(--color-primary);line-height:1.5}.btn-outline::after{border:none}\n/* 卡片 */.card{background-color:var(--color-bg-card);border-radius:var(--radius-md);padding:%?24?%;box-shadow:0 %?2?% %?12?% rgba(0,0,0,.04)}\n/* 标签 */.tag{display:inline-flex;align-items:center;padding:%?4?% %?16?%;border-radius:var(--radius-sm);font-size:%?20?%;background-color:var(--color-tag-bg);color:var(--color-text-secondary)}.tag-primary{background-color:var(--color-primary-light);color:var(--color-primary)}\n/* 分割线 */.divider{width:100%;height:%?1?%;background-color:var(--color-border)}\n/* 安全区底部 */.safe-bottom{padding-bottom:env(safe-area-inset-bottom)}\n/* 全局隐藏横向滚动条(scroll-view / webkit) */::-webkit-scrollbar{display:none;width:0;height:0;background:transparent}",""]),e.exports=t},"98bce":function(e,t,n){"use strict";n.r(t);var o=n("efa3"),a=n.n(o);for(var r in o)["default"].indexOf(r)<0&&function(e){n.d(t,e,(function(){return o[e]}))}(r);t["default"]=a.a},"9d8b":function(e,t,n){var o=n("95d5");o.__esModule&&(o=o.default),"string"===typeof o&&(o=[[e.i,o,""]]),o.locals&&(e.exports=o.locals);var a=n("967d").default;a("37b6588b",o,!0,{sourceMap:!1,shadowMode:!1})},ac9f:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var o=null,a=function(e,t,n){null!==o&&clearTimeout(o),o=setTimeout((function(){e[t]()}),n)};t.default=a},bad1:function(e,t,n){"use strict";(function(e){var t=n("f5bd").default;n("473f"),n("bf0f"),n("de6c"),n("5c47"),n("a1c1");var o=t(n("9b8e")),a={keys:function(){return[]}};e["____73E5929____"]=!0,delete e["____73E5929____"],e.__uniConfig={tabBar:{color:"#999999",selectedColor:"#FF6800",backgroundColor:"#ffffff",borderStyle:"white",list:[{pagePath:"pages/index/index",text:"首页",iconPath:"static/icons/tabbar/tab_home_gray.png",selectedIconPath:"static/icons/tabbar/tab_home_orange.png",redDot:!1,badge:""},{pagePath:"pages/products/products",text:"产品",iconPath:"static/icons/tabbar/tab_product_gray.png",selectedIconPath:"static/icons/tabbar/tab_product_orange.png",redDot:!1,badge:""}]},style:"v2",sitemapLocation:"sitemap.json",lazyCodeLoading:"requiredComponents",globalStyle:{navigationBarBackgroundColor:"#ffffff",navigationBarTextStyle:"black",navigationBarTitleText:"印美达 Inkreach",backgroundColor:"#f5f5f5",backgroundTextStyle:"dark",enablePullDownRefresh:!1}},e.__uniConfig.compilerVersion="5.24",e.__uniConfig.darkmode=!1,e.__uniConfig.themeConfig={},e.__uniConfig.uniPlatform="h5",e.__uniConfig.appId="__UNI__73E5929",e.__uniConfig.appName="inkreach-miniprogram",e.__uniConfig.appVersion="1.0.0",e.__uniConfig.appVersionCode="100",e.__uniConfig.router={mode:"history",base:"/v2/h5/"},e.__uniConfig.publicPath="/v2/h5/",e.__uniConfig["async"]={loading:"AsyncLoading",error:"AsyncError",delay:200,timeout:6e4},e.__uniConfig.debug=!1,e.__uniConfig.networkTimeout={request:6e4,connectSocket:6e4,uploadFile:6e4,downloadFile:6e4},e.__uniConfig.sdkConfigs={},e.__uniConfig.qqMapKey=void 0,e.__uniConfig.googleMapKey=void 0,e.__uniConfig.aMapKey=void 0,e.__uniConfig.aMapSecurityJsCode=void 0,e.__uniConfig.aMapServiceHost=void 0,e.__uniConfig.locale="",e.__uniConfig.fallbackLocale=void 0,e.__uniConfig.locales=a.keys().reduce((function(e,t){var n=t.replace(/\.\/(uni-app.)?(.*).json/,"$2"),o=a(t);return Object.assign(e[n]||(e[n]={}),o.common||o),e}),{}),e.__uniConfig.nvue={"flex-direction":"column"},e.__uniConfig.__webpack_chunk_load__=n.e,o.default.component("pages-index-index",(function(e){var t={component:n.e("pages-index-index").then(function(){return e(n("3dd4"))}.bind(null,n)).catch(n.oe),delay:__uniConfig["async"].delay,timeout:__uniConfig["async"].timeout};return __uniConfig["async"]["loading"]&&(t.loading={name:"SystemAsyncLoading",render:function(e){return e(__uniConfig["async"]["loading"])}}),__uniConfig["async"]["error"]&&(t.error={name:"SystemAsyncError",render:function(e){return e(__uniConfig["async"]["error"])}}),t})),o.default.component("pages-products-products",(function(e){var t={component:n.e("pages-products-products").then(function(){return e(n("379b"))}.bind(null,n)).catch(n.oe),delay:__uniConfig["async"].delay,timeout:__uniConfig["async"].timeout};return __uniConfig["async"]["loading"]&&(t.loading={name:"SystemAsyncLoading",render:function(e){return e(__uniConfig["async"]["loading"])}}),__uniConfig["async"]["error"]&&(t.error={name:"SystemAsyncError",render:function(e){return e(__uniConfig["async"]["error"])}}),t})),o.default.component("pages-product-detail-product-detail",(function(e){var t={component:n.e("pages-product-detail-product-detail").then(function(){return e(n("0292"))}.bind(null,n)).catch(n.oe),delay:__uniConfig["async"].delay,timeout:__uniConfig["async"].timeout};return __uniConfig["async"]["loading"]&&(t.loading={name:"SystemAsyncLoading",render:function(e){return e(__uniConfig["async"]["loading"])}}),__uniConfig["async"]["error"]&&(t.error={name:"SystemAsyncError",render:function(e){return e(__uniConfig["async"]["error"])}}),t})),e.__uniRoutes=[{path:"/",alias:"/pages/index/index",component:{render:function(e){return e("Page",{props:Object.assign({isQuit:!0,isEntry:!0,isTabBar:!0,tabBarIndex:0},__uniConfig.globalStyle,{navigationBarTitleText:"印美达 Inkreach",navigationStyle:"custom",usingComponents:{}})},[e("pages-index-index",{slot:"page"})])}},meta:{id:1,name:"pages-index-index",isNVue:!1,maxWidth:0,pagePath:"pages/index/index",isQuit:!0,isEntry:!0,isTabBar:!0,tabBarIndex:0,windowTop:0}},{path:"/pages/products/products",component:{render:function(e){return e("Page",{props:Object.assign({isQuit:!0,isTabBar:!0,tabBarIndex:1},__uniConfig.globalStyle,{navigationBarTitleText:"产品中心",navigationStyle:"custom",usingComponents:{}})},[e("pages-products-products",{slot:"page"})])}},meta:{id:2,name:"pages-products-products",isNVue:!1,maxWidth:0,pagePath:"pages/products/products",isQuit:!0,isTabBar:!0,tabBarIndex:1,windowTop:0}},{path:"/pages/product-detail/product-detail",component:{render:function(e){return e("Page",{props:Object.assign({},__uniConfig.globalStyle,{navigationBarTitleText:"产品详情",navigationStyle:"custom",usingComponents:{}})},[e("pages-product-detail-product-detail",{slot:"page"})])}},meta:{name:"pages-product-detail-product-detail",isNVue:!1,maxWidth:0,pagePath:"pages/product-detail/product-detail",windowTop:0}},{path:"/choose-location",component:{render:function(e){return e("Page",{props:{navigationStyle:"custom"}},[e("system-choose-location",{slot:"page"})])}},meta:{name:"choose-location",pagePath:"/choose-location"}},{path:"/open-location",component:{render:function(e){return e("Page",{props:{navigationStyle:"custom"}},[e("system-open-location",{slot:"page"})])}},meta:{name:"open-location",pagePath:"/open-location"}}],e.UniApp&&new e.UniApp}).call(this,n("0ee4"))},beb3:function(e,t,n){"use strict";(function(e){n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.getTabBar=function(){return{setData:function(t){var n,o,a,r;"function"===typeof(null===(n=this.$mp)||void 0===n||null===(o=n.page)||void 0===o?void 0:o.getTabBar)&&null!==(a=this.$mp)&&void 0!==a&&null!==(r=a.page)&&void 0!==r&&r.getTabBar()?this.$mp.page.getTabBar().setData(t):e.log("当前平台不支持getTabBar(),已稍作处理,详细请参见相关文档。")}}}}).call(this,n("ba7c")["default"])},c31e:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.parseEventDynamicCode=function(e,t){"function"===typeof this[t]&&this[t](e)}},cb0d:function(e,t,n){"use strict";var o=n("f5bd").default;Object.defineProperty(t,"__esModule",{value:!0}),t.setData=function(e){var t=this,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null;Object.keys(e).forEach((function(n){if((0,a.default)(t,n,e[n]),r.test(n)&&n.endsWith("Clone")){var o=n.replace(/Clone$/,"");t.$options&&t.$options.propsData[o]&&t.$emit("update:".concat(o),e[n])}})),this.$forceUpdate(),"function"==typeof n&&this.$nextTick(n)},n("bf0f"),n("2797"),n("5c47"),n("0506"),n("dc8a"),n("a1c1"),n("20f3"),n("f7a5"),n("6a54"),n("9327");var a=o(n("7a41"));o(n("ac9f"));var r=/^([^\x00-\xff]|[a-zA-Z_$])([^\x00-\xff]|[a-zA-Z0-9_$])*$/},d575:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.handleDataset=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};e&&!e.currentTarget&&(t.tagId?e.currentTarget={id:t.tagId}:e.currentTarget={dataset:t})}},dc38:function(e,t,n){"use strict";n("6a54");var o=n("f5bd").default;Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var a=o(n("9b1b")),r=n("ff7a"),i=n("00f0"),c=n("d575"),u=n("3f573"),d=n("c31e"),l=n("beb3"),s=n("4045"),f=n("372e"),p=n("cb0d"),g={install:function(e,t){e.mixin((0,a.default)((0,a.default)({},r.pageLifetimes),{},{methods:{clone:i.clone,handleDataset:c.handleDataset,escape2Html:u.escape2Html,html2Escape:u.html2Escape,parseEventDynamicCode:d.parseEventDynamicCode,getTabBar:l.getTabBar,getRelationNodes:s.getRelationNodes,zpSelectComponent:f.selectComponent,zpSelectAllComponents:f.selectAllComponents,setData:p.setData}}))}};t.default=g},e4af:function(e,t,n){"use strict";var o=n("f5bd").default,a=o(n("9b1b"));n("3dde"),n("a8b2"),n("1480"),n("6e4a"),n("bad1"),n("d6b2");var r=o(n("3c6c")),i=o(n("dc38")),c=o(n("9b8e"));c.default.use(i.default),c.default.config.productionTip=!1,r.default.mpType="app";var u=new c.default((0,a.default)({},r.default));u.$mount()},efa3:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var o={data:function(){return{}},globalData:{userInfo:null,baseUrl:"https://api.inkreach.com"},onLaunch:function(){var e=uni.getWindowInfo();this.globalData.statusBarHeight=e.statusBarHeight,this.globalData.windowWidth=e.windowWidth,this.globalData.windowHeight=e.windowHeight}};t.default=o},ff7a:function(e,t,n){"use strict";function o(e,t){e.$children.map((function(e){"function"==typeof e[t]&&e[t](),o(e,t)}))}n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.pageLifetimes=void 0,n("fd3c");var a={onLoad:function(){var e=this;uni.onWindowResize((function(t){o(e,"handlePageResize")}))},onShow:function(){o(this,"handlePageShow")},onHide:function(){o(this,"handlePageHide")},onResize:function(){}};t.pageLifetimes=a}}); \ No newline at end of file +(function(e){function t(t){for(var o,i,c=t[0],u=t[1],l=t[2],s=0,f=[];s>>/g,">"),e=e.split(">").map((function(e){return e.trim().split(" ").join("').descendant('")})).join("').child('"),function(t,n,o){return new t(n,o).descendant(e)}},l=function(){function e(t){var n=arguments.length>1&&void 0!==arguments[1]&&arguments[1];(0,r.default)(this,e),this.nodes=[t],this.all=n}return(0,i.default)(e,[{key:"child",value:function(e){var t=[];if(this.all)this.nodes.forEach((function(n){var o;(o=t).push.apply(o,(0,a.default)(n.$children.filter((function(t){return c(t,e)}))))}));else if(this.nodes.length>0){var n=this.nodes[0].$children.find((function(t){return c(t,e)}));t=n?[n]:[]}return this.nodes=t,this}},{key:"descendant",value:function(e){var t=this,n=[];return this.nodes.forEach((function(o){(function(){var e=!1;return function t(n,o){if(!e&&"function"===typeof o)for(var a=n.$children,r=0;!e&&r",nbsp:" ",amp:"&",quot:'"'};return e.replace(/&(lt|gt|nbsp|amp|quot);/gi,(function(e,n){return t[n]}))},t.html2Escape=function(e){return e?e.replace(/[<>&"]/g,(function(e){return{"<":"<",">":">","&":"&",'"':"""}[e]})):e},n("5c47"),n("a1c1")},4045:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.getRelationNodes=function(e){if(!this.$unicom)throw"this.getRelationNodes()需与p-f-unicom配合使用!";return this.$unicom("@"+e)}},"5da8":function(e,t,n){"use strict";n.d(t,"b",(function(){return o})),n.d(t,"c",(function(){return a})),n.d(t,"a",(function(){}));var o=function(){var e=this.$createElement,t=this._self._c||e;return t("App",{attrs:{keepAliveInclude:this.keepAliveInclude}})},a=[]},"792a":function(e,t,n){"use strict";var o=n("9d8b"),a=n.n(o);a.a},"7a41":function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0,n("5c47"),n("2c10"),n("c9b5"),n("bf0f"),n("ab80"),n("473f"),n("f7a5");var o=function(e,t,n){return Object(e)!==e||(Array.isArray(t)||(t=t.toString().match(/[^.[\]]+/g)||[]),t.slice(0,-1).reduce((function(e,n,o){return Object(e[n])===e[n]?e[n]:e[n]=Math.abs(t[o+1])>>0===+t[o+1]?[]:{}}),e)[t[t.length-1]]=n),e};t.default=o},"95d5":function(e,t,n){var o=n("c86c");t=o(!1),t.push([e.i,"/* 全局样式 - 印美达 Inkreach 微信小程序 */uni-page-body{--color-primary:#ff6800;--color-primary-light:#fff0e5;--color-primary-dark:#e55a00;--color-secondary:#f26522;--color-dark:#121212;--color-text-primary:#333;--color-text-secondary:#666;--color-text-tertiary:#999;--color-bg-page:#f5f5f5;--color-bg-card:#fff;--color-border:#eee;--color-tag-bg:#f2f2f2;--color-success:#52c41a;--color-warning:#faad14;--radius-sm:%?8?%;--radius-md:%?12?%;--radius-lg:%?20?%;--radius-xl:%?32?%;--spacing-xs:%?8?%;--spacing-sm:%?16?%;--spacing-md:%?24?%;--spacing-lg:%?32?%;--spacing-xl:%?48?%;font-size:%?28?%;color:var(--color-text-primary);background-color:var(--color-bg-page);font-family:CusPingFangSC,-apple-system,BlinkMacSystemFont,PingFang SC,Helvetica Neue,sans-serif}body.?%PAGE?%{background-color:var(--color-bg-page)}\n/* 通用样式 */.container{min-height:100vh;background-color:var(--color-bg-page)}.flex-row{display:flex;flex-direction:row;align-items:center}.flex-col{display:flex;flex-direction:column}.flex-center{display:flex;align-items:center;justify-content:center}.flex-between{display:flex;align-items:center;justify-content:space-between}.flex-1{flex:1}.flex-wrap{display:flex;flex-wrap:wrap}\n/* 文字样式 */.text-primary{color:var(--color-primary)}.text-secondary{color:var(--color-text-secondary)}.text-tertiary{color:var(--color-text-tertiary)}.text-white{color:#fff}.text-bold{font-weight:600}.text-center{text-align:center}.text-sm{font-size:%?24?%}.text-md{font-size:%?28?%}.text-lg{font-size:%?32?%}.text-xl{font-size:%?40?%}.text-xxl{font-size:%?56?%}\n/* 间距 */.mt-xs{margin-top:%?8?%}.mt-sm{margin-top:%?16?%}.mt-md{margin-top:%?24?%}.mt-lg{margin-top:%?32?%}.mt-xl{margin-top:%?48?%}.mb-xs{margin-bottom:%?8?%}.mb-sm{margin-bottom:%?16?%}.mb-md{margin-bottom:%?24?%}.mb-lg{margin-bottom:%?32?%}.p-sm{padding:%?16?%}.p-md{padding:%?24?%}.p-lg{padding:%?32?%}.px-md{padding-left:%?24?%;padding-right:%?24?%}.px-lg{padding-left:%?32?%;padding-right:%?32?%}\n/* 按钮 */.btn-primary{display:inline-flex;align-items:center;justify-content:center;background-color:var(--color-primary);color:#fff;font-size:%?28?%;font-weight:500;padding:%?20?% %?48?%;border-radius:var(--radius-lg);border:none;line-height:1.5}.btn-primary::after{border:none}.btn-outline{display:inline-flex;align-items:center;justify-content:center;background-color:initial;color:var(--color-primary);font-size:%?28?%;font-weight:500;padding:%?20?% %?48?%;border-radius:var(--radius-lg);border:%?2?% solid var(--color-primary);line-height:1.5}.btn-outline::after{border:none}\n/* 卡片 */.card{background-color:var(--color-bg-card);border-radius:var(--radius-md);padding:%?24?%;box-shadow:0 %?2?% %?12?% rgba(0,0,0,.04)}\n/* 标签 */.tag{display:inline-flex;align-items:center;padding:%?4?% %?16?%;border-radius:var(--radius-sm);font-size:%?20?%;background-color:var(--color-tag-bg);color:var(--color-text-secondary)}.tag-primary{background-color:var(--color-primary-light);color:var(--color-primary)}\n/* 分割线 */.divider{width:100%;height:%?1?%;background-color:var(--color-border)}\n/* 安全区底部 */.safe-bottom{padding-bottom:env(safe-area-inset-bottom)}\n/* 全局隐藏横向滚动条(scroll-view / webkit) */::-webkit-scrollbar{display:none;width:0;height:0;background:transparent}",""]),e.exports=t},"98bce":function(e,t,n){"use strict";n.r(t);var o=n("efa3"),a=n.n(o);for(var r in o)["default"].indexOf(r)<0&&function(e){n.d(t,e,(function(){return o[e]}))}(r);t["default"]=a.a},"9d8b":function(e,t,n){var o=n("95d5");o.__esModule&&(o=o.default),"string"===typeof o&&(o=[[e.i,o,""]]),o.locals&&(e.exports=o.locals);var a=n("967d").default;a("37b6588b",o,!0,{sourceMap:!1,shadowMode:!1})},ac9f:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var o=null,a=function(e,t,n){null!==o&&clearTimeout(o),o=setTimeout((function(){e[t]()}),n)};t.default=a},bad1:function(e,t,n){"use strict";(function(e){var t=n("f5bd").default;n("473f"),n("bf0f"),n("de6c"),n("5c47"),n("a1c1");var o=t(n("9b8e")),a={keys:function(){return[]}};e["____73E5929____"]=!0,delete e["____73E5929____"],e.__uniConfig={tabBar:{color:"#999999",selectedColor:"#FF6800",backgroundColor:"#ffffff",borderStyle:"white",list:[{pagePath:"pages/index/index",text:"首页",iconPath:"static/icons/tabbar/tab_home_gray.png",selectedIconPath:"static/icons/tabbar/tab_home_orange.png",redDot:!1,badge:""},{pagePath:"pages/products/products",text:"产品",iconPath:"static/icons/tabbar/tab_product_gray.png",selectedIconPath:"static/icons/tabbar/tab_product_orange.png",redDot:!1,badge:""}]},style:"v2",sitemapLocation:"sitemap.json",lazyCodeLoading:"requiredComponents",globalStyle:{navigationBarBackgroundColor:"#ffffff",navigationBarTextStyle:"black",navigationBarTitleText:"印美达 Inkreach",backgroundColor:"#f5f5f5",backgroundTextStyle:"dark",enablePullDownRefresh:!1}},e.__uniConfig.compilerVersion="5.24",e.__uniConfig.darkmode=!1,e.__uniConfig.themeConfig={},e.__uniConfig.uniPlatform="h5",e.__uniConfig.appId="__UNI__73E5929",e.__uniConfig.appName="inkreach-miniprogram",e.__uniConfig.appVersion="1.0.0",e.__uniConfig.appVersionCode="100",e.__uniConfig.router={mode:"history",base:"/v2/h5/"},e.__uniConfig.publicPath="/v2/h5/",e.__uniConfig["async"]={loading:"AsyncLoading",error:"AsyncError",delay:200,timeout:6e4},e.__uniConfig.debug=!1,e.__uniConfig.networkTimeout={request:6e4,connectSocket:6e4,uploadFile:6e4,downloadFile:6e4},e.__uniConfig.sdkConfigs={},e.__uniConfig.qqMapKey=void 0,e.__uniConfig.googleMapKey=void 0,e.__uniConfig.aMapKey=void 0,e.__uniConfig.aMapSecurityJsCode=void 0,e.__uniConfig.aMapServiceHost=void 0,e.__uniConfig.locale="",e.__uniConfig.fallbackLocale=void 0,e.__uniConfig.locales=a.keys().reduce((function(e,t){var n=t.replace(/\.\/(uni-app.)?(.*).json/,"$2"),o=a(t);return Object.assign(e[n]||(e[n]={}),o.common||o),e}),{}),e.__uniConfig.nvue={"flex-direction":"column"},e.__uniConfig.__webpack_chunk_load__=n.e,o.default.component("pages-index-index",(function(e){var t={component:n.e("pages-index-index").then(function(){return e(n("3dd4"))}.bind(null,n)).catch(n.oe),delay:__uniConfig["async"].delay,timeout:__uniConfig["async"].timeout};return __uniConfig["async"]["loading"]&&(t.loading={name:"SystemAsyncLoading",render:function(e){return e(__uniConfig["async"]["loading"])}}),__uniConfig["async"]["error"]&&(t.error={name:"SystemAsyncError",render:function(e){return e(__uniConfig["async"]["error"])}}),t})),o.default.component("pages-products-products",(function(e){var t={component:n.e("pages-products-products").then(function(){return e(n("379b"))}.bind(null,n)).catch(n.oe),delay:__uniConfig["async"].delay,timeout:__uniConfig["async"].timeout};return __uniConfig["async"]["loading"]&&(t.loading={name:"SystemAsyncLoading",render:function(e){return e(__uniConfig["async"]["loading"])}}),__uniConfig["async"]["error"]&&(t.error={name:"SystemAsyncError",render:function(e){return e(__uniConfig["async"]["error"])}}),t})),o.default.component("pages-product-detail-product-detail",(function(e){var t={component:n.e("pages-product-detail-product-detail").then(function(){return e(n("0292"))}.bind(null,n)).catch(n.oe),delay:__uniConfig["async"].delay,timeout:__uniConfig["async"].timeout};return __uniConfig["async"]["loading"]&&(t.loading={name:"SystemAsyncLoading",render:function(e){return e(__uniConfig["async"]["loading"])}}),__uniConfig["async"]["error"]&&(t.error={name:"SystemAsyncError",render:function(e){return e(__uniConfig["async"]["error"])}}),t})),e.__uniRoutes=[{path:"/",alias:"/pages/index/index",component:{render:function(e){return e("Page",{props:Object.assign({isQuit:!0,isEntry:!0,isTabBar:!0,tabBarIndex:0},__uniConfig.globalStyle,{navigationBarTitleText:"印美达 Inkreach",navigationStyle:"custom",usingComponents:{}})},[e("pages-index-index",{slot:"page"})])}},meta:{id:1,name:"pages-index-index",isNVue:!1,maxWidth:0,pagePath:"pages/index/index",isQuit:!0,isEntry:!0,isTabBar:!0,tabBarIndex:0,windowTop:0}},{path:"/pages/products/products",component:{render:function(e){return e("Page",{props:Object.assign({isQuit:!0,isTabBar:!0,tabBarIndex:1},__uniConfig.globalStyle,{navigationBarTitleText:"产品中心",navigationStyle:"custom",usingComponents:{}})},[e("pages-products-products",{slot:"page"})])}},meta:{id:2,name:"pages-products-products",isNVue:!1,maxWidth:0,pagePath:"pages/products/products",isQuit:!0,isTabBar:!0,tabBarIndex:1,windowTop:0}},{path:"/pages/product-detail/product-detail",component:{render:function(e){return e("Page",{props:Object.assign({},__uniConfig.globalStyle,{navigationBarTitleText:"产品详情",navigationStyle:"custom",usingComponents:{}})},[e("pages-product-detail-product-detail",{slot:"page"})])}},meta:{name:"pages-product-detail-product-detail",isNVue:!1,maxWidth:0,pagePath:"pages/product-detail/product-detail",windowTop:0}},{path:"/choose-location",component:{render:function(e){return e("Page",{props:{navigationStyle:"custom"}},[e("system-choose-location",{slot:"page"})])}},meta:{name:"choose-location",pagePath:"/choose-location"}},{path:"/open-location",component:{render:function(e){return e("Page",{props:{navigationStyle:"custom"}},[e("system-open-location",{slot:"page"})])}},meta:{name:"open-location",pagePath:"/open-location"}}],e.UniApp&&new e.UniApp}).call(this,n("0ee4"))},beb3:function(e,t,n){"use strict";(function(e){n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.getTabBar=function(){return{setData:function(t){var n,o,a,r;"function"===typeof(null===(n=this.$mp)||void 0===n||null===(o=n.page)||void 0===o?void 0:o.getTabBar)&&null!==(a=this.$mp)&&void 0!==a&&null!==(r=a.page)&&void 0!==r&&r.getTabBar()?this.$mp.page.getTabBar().setData(t):e.log("当前平台不支持getTabBar(),已稍作处理,详细请参见相关文档。")}}}}).call(this,n("ba7c")["default"])},c31e:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.parseEventDynamicCode=function(e,t){"function"===typeof this[t]&&this[t](e)}},cb0d:function(e,t,n){"use strict";var o=n("f5bd").default;Object.defineProperty(t,"__esModule",{value:!0}),t.setData=function(e){var t=this,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null;Object.keys(e).forEach((function(n){if((0,a.default)(t,n,e[n]),r.test(n)&&n.endsWith("Clone")){var o=n.replace(/Clone$/,"");t.$options&&t.$options.propsData[o]&&t.$emit("update:".concat(o),e[n])}})),this.$forceUpdate(),"function"==typeof n&&this.$nextTick(n)},n("bf0f"),n("2797"),n("5c47"),n("0506"),n("dc8a"),n("a1c1"),n("20f3"),n("f7a5"),n("6a54"),n("9327");var a=o(n("7a41"));o(n("ac9f"));var r=/^([^\x00-\xff]|[a-zA-Z_$])([^\x00-\xff]|[a-zA-Z0-9_$])*$/},d575:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.handleDataset=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};e&&!e.currentTarget&&(t.tagId?e.currentTarget={id:t.tagId}:e.currentTarget={dataset:t})}},dc38:function(e,t,n){"use strict";n("6a54");var o=n("f5bd").default;Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var a=o(n("9b1b")),r=n("ff7a"),i=n("00f0"),c=n("d575"),u=n("3f573"),l=n("c31e"),d=n("beb3"),s=n("4045"),f=n("372e"),p=n("cb0d"),g={install:function(e,t){e.mixin((0,a.default)((0,a.default)({},r.pageLifetimes),{},{methods:{clone:i.clone,handleDataset:c.handleDataset,escape2Html:u.escape2Html,html2Escape:u.html2Escape,parseEventDynamicCode:l.parseEventDynamicCode,getTabBar:d.getTabBar,getRelationNodes:s.getRelationNodes,zpSelectComponent:f.selectComponent,zpSelectAllComponents:f.selectAllComponents,setData:p.setData}}))}};t.default=g},e4af:function(e,t,n){"use strict";var o=n("f5bd").default,a=o(n("9b1b"));n("3dde"),n("a8b2"),n("1480"),n("6e4a"),n("bad1"),n("d6b2");var r=o(n("3c6c")),i=o(n("dc38")),c=o(n("9b8e"));c.default.use(i.default),c.default.config.productionTip=!1,r.default.mpType="app";var u=new c.default((0,a.default)({},r.default));u.$mount()},efa3:function(e,t,n){"use strict";n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var o={data:function(){return{}},globalData:{userInfo:null,baseUrl:"https://api.inkreach.com"},onLaunch:function(){var e=uni.getWindowInfo();this.globalData.statusBarHeight=e.statusBarHeight,this.globalData.windowWidth=e.windowWidth,this.globalData.windowHeight=e.windowHeight}};t.default=o},ff7a:function(e,t,n){"use strict";function o(e,t){e.$children.map((function(e){"function"==typeof e[t]&&e[t](),o(e,t)}))}n("6a54"),Object.defineProperty(t,"__esModule",{value:!0}),t.pageLifetimes=void 0,n("fd3c");var a={onLoad:function(){var e=this;uni.onWindowResize((function(t){o(e,"handlePageResize")}))},onShow:function(){o(this,"handlePageShow")},onHide:function(){o(this,"handlePageHide")},onResize:function(){}};t.pageLifetimes=a}}); \ No newline at end of file diff --git a/deploy/h5/static/js/pages-products-products.bf1de6d2.js b/deploy/h5/static/js/pages-products-products.bf1de6d2.js deleted file mode 100644 index ae8fea6..0000000 --- a/deploy/h5/static/js/pages-products-products.bf1de6d2.js +++ /dev/null @@ -1,3 +0,0 @@ -(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["pages-products-products"],{"013b":function(t,e,n){"use strict";n.r(e);var r=n("cb1a"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},"033b":function(t,e,n){"use strict";var r=n("8a79"),i=n.n(r);i.a},"0554":function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",{staticClass:"right-content"},[t.subCategories.length>0?n("v-uni-view",{staticClass:"sub-categories",attrs:{id:"subCategories"}},t._l(t.subCategories,(function(e,r){return n("v-uni-view",{key:r,staticClass:"sub-cat-item",class:{active:t.activeSubId===e.id},on:{click:function(n){arguments[0]=n=t.$handleEvent(n),t.selectSub(e.id)}}},[t._v(t._s(e.name))])})),1):t._e(),n("v-uni-scroll-view",{staticClass:"product-scroll",style:{height:t.scrollHeight+"px"},attrs:{"scroll-y":!0,"lower-threshold":100,"scroll-top":t.scrollTopVal,"scroll-with-animation":!1},on:{scrolltolower:function(e){arguments[0]=e=t.$handleEvent(e),t.onScrollToLower.apply(void 0,arguments)}}},[t.list.length>0?n("v-uni-view",{staticClass:"product-grid"},t._l(t.list,(function(e,r){return n("v-uni-view",{key:r,staticClass:"product-card",on:{click:function(n){arguments[0]=n=t.$handleEvent(n),t.onTapProduct(e)}}},[n("v-uni-view",{staticClass:"product-img"},[e.image?n("v-uni-image",{staticClass:"product-image",attrs:{src:e.image,mode:"aspectFill","lazy-load":!0}}):t._e()],1),n("v-uni-view",{staticClass:"product-info"},[n("v-uni-text",{staticClass:"product-name"},[t._v(t._s(e.name))]),n("v-uni-view",{staticClass:"price-row"},[n("v-uni-text",{staticClass:"product-price"},[t._v("¥"+t._s(e.price))]),n("v-uni-text",{staticClass:"price-unit"},[t._v("起")]),t.showCountryTag&&e.countryName?n("v-uni-view",{staticClass:"country-tag"},[e.countryIcon?n("v-uni-image",{staticClass:"country-tag-icon",attrs:{src:e.countryIcon,mode:"aspectFill"}}):t._e(),n("v-uni-text",{staticClass:"country-tag-name"},[t._v(t._s(e.countryName))])],1):t._e()],1)],1)],1)})),1):n("v-uni-view",{staticClass:"product-empty"},[n("v-uni-text",{staticClass:"empty-icon"},[t._v("📦")]),n("v-uni-text",{staticClass:"empty-text"},[t._v("没有找到相关商品")])],1),t.list.length>0?n("v-uni-view",{staticClass:"load-tip"},[t.loading?n("v-uni-text",[t._v("加载中...")]):t.hasMore?t._e():n("v-uni-text",[t._v("没有更多了")])],1):t._e()],1)],1)},i=[]},"0b77":function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",{staticClass:"country-bar",attrs:{id:"countryBar"}},[n("v-uni-view",{staticClass:"country-inner"},t._l(t.list,(function(e,r){return n("v-uni-view",{key:r,staticClass:"country-item",class:{active:t.selectedId===e.id},on:{click:function(n){arguments[0]=n=t.$handleEvent(n),t.selectItem(e)}}},[n("v-uni-image",{staticClass:"country-icon",attrs:{src:e.icon,mode:"aspectFill"}}),n("v-uni-text",{staticClass:"country-name"},[t._v(t._s(e.name))])],1)})),1)],1)},i=[]},"114e":function(t,e,n){"use strict";var r=n("85c1"),i=n("181d");i(r.JSON,"JSON",!0)},"160f":function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",{staticClass:"search-bar",attrs:{id:"searchBar"}},[n("v-uni-view",{staticClass:"status-bar-placeholder",style:{height:t.statusBarHeight+"px"}}),n("v-uni-view",{staticClass:"search-input-wrap"},[n("v-uni-image",{staticClass:"search-icon",attrs:{src:"/static/icons/products/icon_search.png",mode:"aspectFit"}}),n("v-uni-input",{staticClass:"search-input",attrs:{placeholder:"搜索产品","placeholder-class":"search-placeholder",value:t.keyword},on:{input:function(e){arguments[0]=e=t.$handleEvent(e),t.onInput.apply(void 0,arguments)},blur:function(e){arguments[0]=e=t.$handleEvent(e),t.onBlur.apply(void 0,arguments)},confirm:function(e){arguments[0]=e=t.$handleEvent(e),t.onConfirm.apply(void 0,arguments)}}}),n("v-uni-view",{staticClass:"search-btn",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onSearch.apply(void 0,arguments)}}},[t._v("搜索")])],1)],1)},i=[]},1998:function(t,e,n){"use strict";n.r(e);var r=n("d8da"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},"1c98":function(t,e,n){"use strict";n.r(e);var r=n("b5e9"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},"256a":function(t,e,n){"use strict";var r=n("6118"),i=n.n(r);i.a},"257f":function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("5c47");var r={name:"SearchBar",props:{keyword:{type:String,default:""}},data:function(){return{statusBarHeight:20}},created:function(){try{var t=uni.getSystemInfoSync(),e=t.statusBarHeight;this.statusBarHeight="number"===typeof e&&e>0?e:20}catch(n){this.statusBarHeight=20}},mounted:function(){var t=this;this.$nextTick((function(){return t.emitHeight()}))},methods:{emitHeight:function(){var t=this,e=uni.createSelectorQuery().in(this);e.select("#searchBar").boundingClientRect(),e.exec((function(e){e&&e[0]&&t.$emit("height-change",e[0].height)}))},onInput:function(t){this.$emit("input",t.detail.value)},onBlur:function(){this.$emit("blur")},onConfirm:function(){this.$emit("blur")},onSearch:function(){this.$emit("search")}}};e.default=r},2634:function(t,e,n){"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(){ -/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ -e.default=function(){return t};var t={},n=Object.prototype,i=n.hasOwnProperty,a=Object.defineProperty||function(t,e,n){t[e]=n.value},o="function"==typeof Symbol?Symbol:{},c=o.iterator||"@@iterator",s=o.asyncIterator||"@@asyncIterator",u=o.toStringTag||"@@toStringTag";function l(t,e,n){return Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}),t[e]}try{l({},"")}catch($){l=function(t,e,n){return t[e]=n}}function d(t,e,n,r){var i=e&&e.prototype instanceof p?e:p,o=Object.create(i.prototype),c=new P(r||[]);return a(o,"_invoke",{value:_(t,n,c)}),o}function f(t,e,n){try{return{type:"normal",arg:t.call(e,n)}}catch($){return{type:"throw",arg:$}}}t.wrap=d;var h={};function p(){}function v(){}function g(){}var b={};l(b,c,(function(){return this}));var m=Object.getPrototypeOf,y=m&&m(m(F([])));y&&y!==n&&i.call(y,c)&&(b=y);var x=g.prototype=p.prototype=Object.create(b);function w(t){["next","throw","return"].forEach((function(e){l(t,e,(function(t){return this._invoke(e,t)}))}))}function C(t,e){var n;a(this,"_invoke",{value:function(a,o){function c(){return new e((function(n,c){(function n(a,o,c,s){var u=f(t[a],t,o);if("throw"!==u.type){var l=u.arg,d=l.value;return d&&"object"==(0,r.default)(d)&&i.call(d,"__await")?e.resolve(d.__await).then((function(t){n("next",t,c,s)}),(function(t){n("throw",t,c,s)})):e.resolve(d).then((function(t){l.value=t,c(l)}),(function(t){return n("throw",t,c,s)}))}s(u.arg)})(a,o,n,c)}))}return n=n?n.then(c,c):c()}})}function _(t,e,n){var r="suspendedStart";return function(i,a){if("executing"===r)throw new Error("Generator is already running");if("completed"===r){if("throw"===i)throw a;return H()}for(n.method=i,n.arg=a;;){var o=n.delegate;if(o){var c=S(o,n);if(c){if(c===h)continue;return c}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if("suspendedStart"===r)throw r="completed",n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);r="executing";var s=f(t,e,n);if("normal"===s.type){if(r=n.done?"completed":"suspendedYield",s.arg===h)continue;return{value:s.arg,done:n.done}}"throw"===s.type&&(r="completed",n.method="throw",n.arg=s.arg)}}}function S(t,e){var n=e.method,r=t.iterator[n];if(void 0===r)return e.delegate=null,"throw"===n&&t.iterator["return"]&&(e.method="return",e.arg=void 0,S(t,e),"throw"===e.method)||"return"!==n&&(e.method="throw",e.arg=new TypeError("The iterator does not provide a '"+n+"' method")),h;var i=f(r,t.iterator,e.arg);if("throw"===i.type)return e.method="throw",e.arg=i.arg,e.delegate=null,h;var a=i.arg;return a?a.done?(e[t.resultName]=a.value,e.next=t.nextLoc,"return"!==e.method&&(e.method="next",e.arg=void 0),e.delegate=null,h):a:(e.method="throw",e.arg=new TypeError("iterator result is not an object"),e.delegate=null,h)}function E(t){var e={tryLoc:t[0]};1 in t&&(e.catchLoc=t[1]),2 in t&&(e.finallyLoc=t[2],e.afterLoc=t[3]),this.tryEntries.push(e)}function k(t){var e=t.completion||{};e.type="normal",delete e.arg,t.completion=e}function P(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(E,this),this.reset(!0)}function F(t){if(t){var e=t[c];if(e)return e.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var n=-1,r=function e(){for(;++n=0;--r){var a=this.tryEntries[r],o=a.completion;if("root"===a.tryLoc)return n("end");if(a.tryLoc<=this.prev){var c=i.call(a,"catchLoc"),s=i.call(a,"finallyLoc");if(c&&s){if(this.prev=0;--n){var r=this.tryEntries[n];if(r.tryLoc<=this.prev&&i.call(r,"finallyLoc")&&this.prev=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),k(n),h}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var r=n.completion;if("throw"===r.type){var i=r.arg;k(n)}return i}}throw new Error("illegal catch attempt")},delegateYield:function(t,e,n){return this.delegate={iterator:F(t),resultName:e,nextLoc:n},"next"===this.method&&(this.arg=void 0),h}},t},n("6a54"),n("01a2"),n("e39c"),n("bf0f"),n("844d"),n("18f7"),n("de6c"),n("3872e"),n("4e9b"),n("114e"),n("c240"),n("926e"),n("7a76"),n("c9b5"),n("aa9c"),n("2797"),n("8a8d"),n("dc69"),n("f7a5");var r=function(t){return t&&t.__esModule?t:{default:t}}(n("fcf3"))},"2e80":function(t,e,n){"use strict";var r=n("6194"),i=n.n(r);i.a},"2fdc":function(t,e,n){"use strict";function r(t,e,n,r,i,a,o){try{var c=t[a](o),s=c.value}catch(u){return void n(u)}c.done?e(s):Promise.resolve(s).then(r,i)}n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(t){return function(){var e=this,n=arguments;return new Promise((function(i,a){var o=t.apply(e,n);function c(t){r(o,i,a,c,s,"next",t)}function s(t){r(o,i,a,c,s,"throw",t)}c(void 0)}))}},n("bf0f")},"379b":function(t,e,n){"use strict";n.r(e);var r=n("ac71"),i=n("1c98");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("c9fb");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"40f9504e",null,!1,r["a"],void 0);e["default"]=c.exports},"3872e":function(t,e,n){"use strict";var r=n("f259");r("asyncIterator")},"3a6c":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* 侧边栏 - 固定宽度 180rpx(=90px),白底 + 右侧 1px #EEEEEE 边框 */.sidebar[data-v-44522978]{width:%?180?%;height:100%;background:#fff;border-right:%?1?% solid #eee;box-sizing:border-box;flex-shrink:0}\n/* 单项 - 90x46 (180rpx x 92rpx),居中文字 */.sidebar-item[data-v-44522978]{position:relative;width:100%;height:%?92?%;display:flex;align-items:center;justify-content:center;background:#fff;box-sizing:border-box}\n/* 选中态 - #FFF8F3 背景 */.sidebar-item.active[data-v-44522978]{background:#fff8f3}\n/* 选中指示器 - 左侧 3x16 (6rpx x 32rpx) 橙色 圆角 2px */.sidebar-indicator[data-v-44522978]{position:absolute;left:0;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);width:%?6?%;height:%?32?%;background:#ff6800;border-radius:%?4?%}\n/* 文字 - 13px Medium 选中橙色 未选中 #666666 */.sidebar-text[data-v-44522978]{font-size:%?26?%;font-weight:500;color:#666;text-align:center;line-height:%?34?%}.sidebar-item.active .sidebar-text[data-v-44522978]{color:#ff6800}",""]),t.exports=e},"3db2":function(t,e,n){"use strict";var r=n("56d6"),i=n.n(r);i.a},4672:function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",{staticClass:"active-filters",attrs:{id:"activeFilters"}},[n("v-uni-view",{staticClass:"filter-entry",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onOpen.apply(void 0,arguments)}}},[n("v-uni-image",{staticClass:"filter-entry-icon",attrs:{src:"/static/icons/products/icon_filter.png",mode:"aspectFit"}}),0===t.list.length?n("v-uni-text",{staticClass:"filter-entry-text"},[t._v("筛选")]):t._e()],1),n("v-uni-scroll-view",{staticClass:"tags-scroll",attrs:{"scroll-x":!0}},[n("v-uni-view",{staticClass:"tags-inner"},t._l(t.list,(function(e,r){return n("v-uni-view",{key:r,staticClass:"active-filter-tag"},[n("v-uni-text",{staticClass:"filter-text"},[t._v(t._s(e.label))]),n("v-uni-text",{staticClass:"filter-remove",on:{click:function(n){arguments[0]=n=t.$handleEvent(n),t.onRemove(e.key)}}},[t._v("×")])],1)})),1)],1),t.list.length>0?n("v-uni-view",{staticClass:"clear-all",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onClearAll.apply(void 0,arguments)}}},[t._v("清除")]):t._e()],1)},i=[]},"4e08":function(t,e,n){"use strict";n.r(e);var r=n("ddd7"),i=n("1998");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("3db2");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"151f5bbc",null,!1,r["a"],void 0);e["default"]=c.exports},"4e2e":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* 顶部筛选条 - 白底,筛选图标 + 横向滚动标签 + 清除,同一行 */.active-filters[data-v-ddd0342c]{display:flex;align-items:center;padding:%?16?% %?20?%;background:#fff;border-bottom:%?1?% solid #eee}\n/* 筛选入口(设计稿 1962-300:图标 18x18px + "筛选"12px Medium,间距 4px → 8rpx) */.filter-entry[data-v-ddd0342c]{display:flex;align-items:center;gap:%?8?%;padding:%?8?% %?16?% %?8?% 0;flex-shrink:0}.filter-entry-icon[data-v-ddd0342c]{width:%?35?%;height:%?35?%}.filter-entry-text[data-v-ddd0342c]{font-size:%?23?%;font-weight:500;color:#000}\n/* 标签区:横向滚动不换行 */.tags-scroll[data-v-ddd0342c]{flex:1;min-width:0;white-space:nowrap}.tags-inner[data-v-ddd0342c]{display:inline-flex;align-items:center;gap:%?16?%;padding:%?4?% 0}\n/* 单个标签 - padding 6px 10px 圆角 60px,不换行 */.active-filter-tag[data-v-ddd0342c]{display:inline-flex;align-items:center;gap:%?8?%;padding:%?12?% %?20?%;background:#fff;border:%?1?% solid #ff6800;border-radius:%?60?%;line-height:1.2;flex-shrink:0}\n/* 文字 12px Medium 橙色 */.filter-text[data-v-ddd0342c]{font-size:%?24?%;font-weight:500;color:#ff6800}.filter-remove[data-v-ddd0342c]{font-size:%?24?%;color:#ff6800}.clear-all[data-v-ddd0342c]{padding-left:%?16?%;flex-shrink:0;font-size:%?24?%;color:#999}',""]),t.exports=e},"4e9b":function(t,e,n){"use strict";var r=n("6aa6"),i=n("f259"),a=n("181d");i("toStringTag"),a(r("Symbol"),"Symbol")},"4ee1":function(t,e,n){"use strict";n.r(e);var r=n("8a21"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},"55cb":function(t,e,n){var r=n("9b13");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("34f557c4",r,!0,{sourceMap:!1,shadowMode:!1})},"562d":function(t,e,n){"use strict";var r=n("8bdb"),i=n("bb80"),a=n("e34c"),o=RangeError,c=String.fromCharCode,s=String.fromCodePoint,u=i([].join),l=!!s&&1!==s.length;r({target:"String",stat:!0,arity:1,forced:l},{fromCodePoint:function(t){var e,n=[],r=arguments.length,i=0;while(r>i){if(e=+arguments[i++],a(e,1114111)!==e)throw new o(e+" is not a valid code point");n[i]=e<65536?c(e):c(55296+((e-=65536)>>10),e%1024+56320)}return u(n,"")}})},"56d6":function(t,e,n){var r=n("82e9");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("2d57e015",r,!0,{sourceMap:!1,shadowMode:!1})},"5fa4":function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("64aa"),n("5c47");var r={name:"ProductGrid",props:{list:{type:Array,default:function(){return[]}},subCategories:{type:Array,default:function(){return[]}},activeSubId:{type:[String,Number],default:""},hasMore:{type:Boolean,default:!0},loading:{type:Boolean,default:!1},scrollTopVal:{type:Number,default:0},showCountryTag:{type:Boolean,default:!1},scrollHeight:{type:Number,default:400}},mounted:function(){var t=this;this.$nextTick((function(){return t.emitSubHeight()}))},watch:{"subCategories.length":function(){var t=this;this.$nextTick((function(){return t.emitSubHeight()}))}},methods:{emitSubHeight:function(){var t=this;if(this.subCategories&&0!==this.subCategories.length){var e=uni.createSelectorQuery().in(this);e.select("#subCategories").boundingClientRect(),e.exec((function(e){e&&e[0]?t.$emit("sub-height-change",e[0].height):t.$emit("sub-height-change",0)}))}else this.$emit("sub-height-change",0)},selectSub:function(t){this.$emit("sub-select",t)},onTapProduct:function(t){this.$emit("product-click",t)},onScrollToLower:function(){this.$emit("scroll-to-lower")}}};e.default=r},6118:function(t,e,n){var r=n("3a6c");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("7154b976",r,!0,{sourceMap:!1,shadowMode:!1})},6194:function(t,e,n){var r=n("c51b");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("74904422",r,!0,{sourceMap:!1,shadowMode:!1})},"635a":function(t,e,n){n("0c26"),n("5c47"),n("2c10"),n("f7a5"),t.exports={truncateProductTitle:function(t){if(null==t)return"";if("string"!==typeof t)try{t=String(t)}catch(i){return""}var e=t.trim();if(!e)return"";var n=e.match(/[\s\u3000\u00a0]/);if(!n)return e;var r=n.index;return r<=0?e:e.slice(0,r)}}},"772e":function(t,e,n){"use strict";n("6a54");var r=n("f5bd").default;Object.defineProperty(e,"__esModule",{value:!0}),e.BASE_URL=e.ASSET_BASE=void 0,e.buildUrl=s,e.default=void 0;var i=r(n("9b1b"));n("5c47"),n("0506"),n("a1c1"),n("9db6"),n("c223"),n("bf0f");var a="https://official.inkreach.cc",o=a+"/v2-api/";e.BASE_URL=o;e.ASSET_BASE="https://official.inkreach.cc/";var c={"Content-Type":"application/json"};function s(t){if(!t)return"";if(/^https?:\/\//i.test(t))return t;var e=t.replace(/^\/+/,"");if(e.startsWith("assets/"))return"https://official.inkreach.cc/"+e;var n=o.replace(/\/+$/,"");return e?"".concat(n,"/").concat(e):n}function u(t){var e=Object.assign({},c,t||{}),n=uni.getStorageSync("token");return n&&(e["Authorization"]="Bearer ".concat(n)),e}function l(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return new Promise((function(e,n){uni.request({url:s(t.url||""),method:(t.method||"GET").toUpperCase(),data:t.data||{},header:u(t.header),timeout:3e4,dataType:"json",success:e,fail:n})}))}l.get=function(t,e,n){return l((0,i.default)({url:t,method:"GET",data:e},n))},l.post=function(t,e,n){return l((0,i.default)({url:t,method:"POST",data:e},n))},l.put=function(t,e,n){return l((0,i.default)({url:t,method:"PUT",data:e},n))},l.delete=function(t,e,n){return l((0,i.default)({url:t,method:"DELETE",data:e},n))};var d=l;e.default=d},"7d70":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* 国家筛选条 - 白底 + 底部 1px #EEEEEE 边框\n Figma: Country Filter Bar 1511:195, y=6 起始, 行高约 31px(padding 6+6) */.country-bar[data-v-c0e80af4]{width:100%;background:#fff;border-bottom:%?1?% solid #eee;padding:%?12?% %?20?%;box-sizing:border-box}\n/* 换行布局 - UI-0011: 标签上下间距 +2px → row-gap 20rpx */.country-inner[data-v-c0e80af4]{display:flex;flex-wrap:wrap;row-gap:%?20?%;-webkit-column-gap:%?20?%;column-gap:%?20?%}\n/* 单个标签 - padding 4px 10px 圆角 60px Figma;UI-0011: 国旗与名称间距 4px → 8rpx */.country-item[data-v-c0e80af4]{display:flex;align-items:center;gap:%?8?%;padding:%?8?% %?20?%; /* 4px 10px */background:#f7f7f7;border-radius:%?60?%;line-height:1.2;border:%?1?% solid transparent;box-sizing:border-box}\n/* 选中态 - #FFF3EB 背景 + 0.5px 橙色边框 + 边框宽度 0.5px Figma */.country-item.active[data-v-c0e80af4]{background:#fff3eb;border-color:#ff6800}.country-item.active .country-name[data-v-c0e80af4]{color:#ff6800}\n/* 国家图标 - Figma 18x13 / 18x14 矩形, 圆角 2px,不是圆形 */.country-icon[data-v-c0e80af4]{width:%?36?%;height:%?26?%;border-radius:%?4?%; /* 2px */flex-shrink:0;display:block}\n/* 文字 12px Medium */.country-name[data-v-c0e80af4]{font-size:%?24?%;font-weight:500;color:#000}",""]),t.exports=e},"82e9":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,'.filter-overlay[data-v-151f5bbc]{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:998;opacity:0;visibility:hidden;transition:all .3s}.filter-overlay.show[data-v-151f5bbc]{opacity:1;visibility:visible}.filter-drawer[data-v-151f5bbc]{position:fixed;top:0;right:%?-600?%;width:%?600?%;height:100%;background:#fff;z-index:999;display:flex;flex-direction:column;transition:right .3s}.filter-drawer.show[data-v-151f5bbc]{right:0}.filter-drawer-header[data-v-151f5bbc]{display:flex;align-items:center;justify-content:space-between;padding:%?32?%;border-bottom:%?2?% solid #f5f5f5}.filter-drawer-title[data-v-151f5bbc]{font-size:%?32?%;font-weight:600;color:#1a1a1a}.filter-drawer-close[data-v-151f5bbc]{font-size:%?32?%;color:#999}.filter-drawer-body[data-v-151f5bbc]{flex:1;\n /* 框架默认 width:100% + padding 时 content-box 会超宽 64rpx,\n 导致右侧"最高价"输入框被挤出屏幕,必须 border-box */width:100%;box-sizing:border-box;padding:0 %?32?%}.filter-group[data-v-151f5bbc]{padding:%?32?% 0;border-bottom:%?2?% solid #f5f5f5}.filter-group-title[data-v-151f5bbc]{display:block;font-size:%?28?%;font-weight:600;color:#1a1a1a;margin-bottom:%?20?%}.filter-options[data-v-151f5bbc]{display:flex;flex-wrap:wrap;gap:%?16?%}.filter-option[data-v-151f5bbc]{padding:%?14?% %?28?%;font-size:%?24?%;color:#666;background:#f5f5f5;border-radius:%?30?%;border:%?2?% solid transparent}.filter-option.selected[data-v-151f5bbc]{background:#fff0e5;color:#ff6800;border-color:#ff6800}.filter-price-range[data-v-151f5bbc]{display:flex;align-items:center;justify-content:space-between}.price-input[data-v-151f5bbc]{\n /* 固定宽度,不依赖 flex 收缩(uni-input 自定义元素收缩行为不可靠) */width:%?230?%;box-sizing:border-box;height:%?72?%;padding:0 %?20?%;background:#f5f5f5;border-radius:%?12?%;border:%?2?% solid transparent;font-size:%?26?%;color:#333;text-align:center}\n/* 校验失败冒红 */.price-input.input-error[data-v-151f5bbc]{border-color:red;background:#fff0f0}.price-divider[data-v-151f5bbc]{font-size:%?28?%;color:#999}.filter-drawer-footer[data-v-151f5bbc]{display:flex;gap:%?24?%;padding:%?24?% %?32?%;border-top:%?2?% solid #f5f5f5}.filter-reset[data-v-151f5bbc]{flex:1;height:%?80?%;display:flex;align-items:center;justify-content:center;background:#f5f5f5;border-radius:%?40?%;font-size:%?28?%;color:#666}.filter-confirm[data-v-151f5bbc]{flex:1;height:%?80?%;display:flex;align-items:center;justify-content:center;background:#ff6800;border-radius:%?40?%;font-size:%?28?%;font-weight:600;color:#fff}',""]),t.exports=e},"84e8":function(t,e,n){"use strict";n.r(e);var r=n("f880"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},8701:function(t,e,n){"use strict";n.r(e);var r=n("4672"),i=n("013b");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("dd5a");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"ddd0342c",null,!1,r["a"],void 0);e["default"]=c.exports},"8a21":function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("64aa"),n("5c47");var r={name:"CountryBar",props:{list:{type:Array,default:function(){return[]}},selectedId:{type:[String,Number],default:""}},mounted:function(){var t=this;this.$nextTick((function(){return t.emitHeight()}))},watch:{"list.length":function(){var t=this;this.$nextTick((function(){return t.emitHeight()}))}},methods:{emitHeight:function(){var t=this,e=uni.createSelectorQuery().in(this);e.select("#countryBar").boundingClientRect(),e.exec((function(e){e&&e[0]&&t.$emit("height-change",e[0].height)}))},selectItem:function(t){this.$emit("select",t.id)}}};e.default=r},"8a79":function(t,e,n){var r=n("7d70");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("a98bd9a6",r,!0,{sourceMap:!1,shadowMode:!1})},"8a8d":function(t,e,n){"use strict";var r=n("8bdb"),i=n("8c4f");r({target:"Object",stat:!0},{setPrototypeOf:i})},"926e":function(t,e,n){"use strict";var r=n("8bdb"),i=n("af9e"),a=n("1099"),o=n("c337"),c=n("1d57"),s=i((function(){o(1)}));r({target:"Object",stat:!0,forced:s,sham:!c},{getPrototypeOf:function(t){return o(a(t))}})},9270:function(t,e,n){"use strict";n.r(e);var r=n("0554"),i=n("c68c");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("2e80");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"b2389a9e",null,!1,r["a"],void 0);e["default"]=c.exports},"9ac8":function(t,e,n){"use strict";var r=n("55cb"),i=n.n(r);i.a},"9b13":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* 设计稿:左右留白 10px(20rpx),下留白 6px(12rpx),搜索框 209x32px(402x62rpx);上边距 56rpx 让搜索栏整体下移 */.search-bar[data-v-3de25ba6]{padding:%?10?% %?20?% %?12?%;background:#fff;box-sizing:border-box}\n/* 状态栏占位:高度由 JS 内联指定(statusBarHeight) */.status-bar-placeholder[data-v-3de25ba6]{width:100%}.search-input-wrap[data-v-3de25ba6]{display:flex;align-items:center;height:%?62?%;padding:0 %?4?% 0 %?21?%;background:#f5f5f5;border-radius:%?31?%;box-sizing:border-box;\n /* 小程序端:只占左侧大部分宽度(设计稿 402rpx) */\n\n\n /* 非小程序(H5/App):占整行 */\nwidth:100%\n}.search-icon[data-v-3de25ba6]{width:%?31?%;height:%?31?%;flex-shrink:0;margin-right:%?8?%}.search-input[data-v-3de25ba6]{flex:1;min-width:0;font-size:%?25?%;color:#333}.search-placeholder[data-v-3de25ba6]{color:#929292}\n/* 设计稿:搜索按钮 50x28px(96x54rpx),#FF6800,圆角 14px(27rpx),距边缘 2px(4rpx) */.search-btn[data-v-3de25ba6]{display:flex;align-items:center;justify-content:center;width:%?96?%;height:%?54?%;flex-shrink:0;background:#ff6800;border-radius:%?27?%;font-size:%?25?%;color:#fff}",""]),t.exports=e},"9da0":function(t,e,n){"use strict";n.r(e);var r=n("160f"),i=n("e945");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("9ac8");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"3de25ba6",null,!1,r["a"],void 0);e["default"]=c.exports},ac71:function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",{staticClass:"products-page",style:{height:t.windowHeight+"px"}},[n("SearchBar",{attrs:{keyword:t.searchKeyword},on:{input:function(e){arguments[0]=e=t.$handleEvent(e),t.onSearchInput.apply(void 0,arguments)},blur:function(e){arguments[0]=e=t.$handleEvent(e),t.onSearchBlur.apply(void 0,arguments)},search:function(e){arguments[0]=e=t.$handleEvent(e),t.onSearchBlur.apply(void 0,arguments)},"height-change":function(e){arguments[0]=e=t.$handleEvent(e),t.onSearchBarHeight.apply(void 0,arguments)}}}),n("CountryBar",{attrs:{list:t.countries,"selected-id":t.selectedCountry},on:{select:function(e){arguments[0]=e=t.$handleEvent(e),t.selectCountry.apply(void 0,arguments)},"height-change":function(e){arguments[0]=e=t.$handleEvent(e),t.onCountryBarHeight.apply(void 0,arguments)}}}),n("v-uni-view",{staticClass:"content-body"},[n("CategorySidebar",{attrs:{list:t.categories,"active-id":t.activeCategory},on:{select:function(e){arguments[0]=e=t.$handleEvent(e),t.selectCategory.apply(void 0,arguments)}}}),n("v-uni-view",{staticClass:"right-wrap"},[n("ActiveFilters",{attrs:{list:t.activeFilters},on:{open:function(e){arguments[0]=e=t.$handleEvent(e),t.toggleFilter.apply(void 0,arguments)},remove:function(e){arguments[0]=e=t.$handleEvent(e),t.removeFilter.apply(void 0,arguments)},"clear-all":function(e){arguments[0]=e=t.$handleEvent(e),t.clearAllFilters.apply(void 0,arguments)},"height-change":function(e){arguments[0]=e=t.$handleEvent(e),t.onActiveFiltersHeight.apply(void 0,arguments)}}}),n("ProductGrid",{attrs:{list:t.products,"sub-categories":t.subCategories,"active-sub-id":t.activeSubCategory,"has-more":t.hasMore,loading:t.loadingMore,"scroll-top-val":t.scrollTopVal,"scroll-height":t.productScrollHeight,"show-country-tag":!t.selectedCountry},on:{"sub-select":function(e){arguments[0]=e=t.$handleEvent(e),t.selectSubCategory.apply(void 0,arguments)},"product-click":function(e){arguments[0]=e=t.$handleEvent(e),t.goDetail.apply(void 0,arguments)},"scroll-to-lower":function(e){arguments[0]=e=t.$handleEvent(e),t.onScrollToLower.apply(void 0,arguments)},"sub-height-change":function(e){arguments[0]=e=t.$handleEvent(e),t.onSubCategoriesHeight.apply(void 0,arguments)}}})],1)],1),n("FilterDrawer",{attrs:{visible:t.showFilter,groups:t.filterGroups},on:{close:function(e){arguments[0]=e=t.$handleEvent(e),t.closeFilter.apply(void 0,arguments)},"toggle-tag":function(e){arguments[0]=e=t.$handleEvent(e),t.toggleFilterTag.apply(void 0,arguments)},reset:function(e){arguments[0]=e=t.$handleEvent(e),t.resetFilters.apply(void 0,arguments)},confirm:function(e){arguments[0]=e=t.$handleEvent(e),t.confirmFilters.apply(void 0,arguments)}}})],1)},i=[]},ad9b:function(t,e,n){"use strict";n("6a54");var r=n("f5bd").default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,e.getCategories=o,e.getCountries=c,e.getGoods=l,e.getGoodsDetail=d,e.getHomeGoods=f,e.getTagGroups=u,e.getTags=s;var i=r(n("772e")),a="/public";function o(t){return i.default.get("".concat(a,"/categories"),t)}function c(){return i.default.get("".concat(a,"/countries"))}function s(){return i.default.get("".concat(a,"/tags"))}function u(t){return i.default.get("".concat(a,"/tag-groups"),t)}function l(t){return i.default.get("".concat(a,"/goods"),t)}function d(t){return i.default.get("".concat(a,"/goods/").concat(t))}function f(t){return i.default.get("".concat(a,"/home-goods"),t)}var h={getCategories:o,getCountries:c,getTags:s,getTagGroups:u,getGoods:l,getGoodsDetail:d,getHomeGoods:f};e.default=h},b5e9:function(t,e,n){"use strict";(function(t){n("6a54");var r=n("f5bd").default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("fd3c"),n("bf0f"),n("18f7"),n("de6c"),n("2797"),n("aa9c"),n("d4b5"),n("c223"),n("0c26"),n("8f71"),n("5c47"),n("2c10"),n("562d"),n("aa77"),n("5ef2"),n("dd2b"),n("a1c1");var i=r(n("b7c7")),a=r(n("5de6")),o=r(n("2634")),c=r(n("2fdc")),s=r(n("9da0")),u=r(n("c115")),l=r(n("8701")),d=r(n("cbae")),f=r(n("9270")),h=r(n("4e08")),p=n("ad9b"),v=n("772e"),g=n("635a"),b={components:{SearchBar:s.default,CountryBar:u.default,ActiveFilters:l.default,CategorySidebar:d.default,ProductGrid:f.default,FilterDrawer:h.default},watch:{"activeFilters.length":function(){this.recomputeScrollHeight()},"subCategories.length":function(){this.recomputeScrollHeight()}},data:function(){return{searchKeyword:"",selectedCountry:"",countries:[],activeCategory:"all",categories:[{id:"all",name:"全部商品"}],subCategories:[],activeSubCategory:"",activeFilters:[],showFilter:!1,filterGroups:[],minPrice:"",maxPrice:"",pagination:{total:0,current:1,pageSize:10},hasMore:!0,loadingMore:!1,products:[],windowHeight:667,scrollTopVal:0,productScrollHeight:400,_searchBarH:0,_countryBarH:0,_activeFiltersH:0,_subCategoriesH:0}},onLoad:function(){var e=this;return(0,c.default)((0,o.default)().mark((function n(){var r,i,a;return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:try{r=uni.getSystemInfoSync(),i=r.tabBarHeight||50,e.windowHeight=(r.screenHeight||750)-i,t.log("[onLoad] screenHeight="+r.screenHeight+", tabBarHeight="+i+" → windowHeight="+e.windowHeight)}catch(o){e.windowHeight=667}return a=e.loadGoods(1),n.next=4,e.initData();case 4:return e.loadSubCategories("all"),n.next=7,a;case 7:e.recomputeScrollHeight();case 8:case"end":return n.stop()}}),n)})))()},methods:{initData:function(){var e=this;return(0,c.default)((0,o.default)().mark((function n(){var r,i,a;return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:return n.prev=0,n.next=3,(0,p.getCountries)();case 3:return r=n.sent,i=r.data&&r.data.data||[],a=i.map((function(t){return{id:t.id,name:t.countryName,icon:(0,v.buildUrl)(t.countryIcon||"")}})),e.countries=a,n.next=9,e.loadCategoryData("");case 9:t.log("=== [Products API Test] ==="),t.log("[1] countries:",a.length,"条"),t.log("=== [Test End] ==="),n.next=17;break;case 14:n.prev=14,n.t0=n["catch"](0),t.error("[Products initData Error]",n.t0);case 17:case"end":return n.stop()}}),n,null,[[0,14]])})))()},loadCategoryData:function(e){var n=this;return(0,c.default)((0,o.default)().mark((function r(){var i,c,s,u,l,d,f,h,v;return(0,o.default)().wrap((function(r){while(1)switch(r.prev=r.next){case 0:return r.prev=0,i=e?{countryId:e}:{},r.next=4,Promise.all([(0,p.getCategories)(i),(0,p.getTagGroups)(i)]);case 4:c=r.sent,s=(0,a.default)(c,2),u=s[0],l=s[1],d=u.data&&u.data.data||[],f=[{id:"all",name:"全部商品",children:[],productCount:0}],d.forEach((function(t){f.push({id:t.id,name:t.categoryName,children:t.children||[],productCount:t.productCount||0})})),h=l.data&&l.data.data||[],v=h.map((function(t){return{id:t.id,title:t.groupName,tags:(t.tags||[]).map((function(t){return{id:t.id,name:t.tagName,color:t.tagColor,fontColor:t.tagFontColor,productCount:t.productCount||0}})),selected:[]}})),n.categories=f,n.filterGroups=v,n.activeCategory="all",n.subCategories=[],n.activeSubCategory="",t.log("[loadCategoryData] countryId="+(e||"all")+", categories="+f.length+", filterGroups="+v.length),r.next=24;break;case 21:r.prev=21,r.t0=r["catch"](0),t.error("[loadCategoryData Error]",r.t0);case 24:case"end":return r.stop()}}),r,null,[[0,21]])})))()},loadGoods:function(e){var n=this;return(0,c.default)((0,o.default)().mark((function r(){var i,a,c,s,u,l,d,f;return(0,o.default)().wrap((function(r){while(1)switch(r.prev=r.next){case 0:return r.prev=0,i=e||n.pagination.current||1,a=n.pagination.pageSize||10,c=n.buildQueryParams(i),t.log("[loadGoods] params:",JSON.stringify(c)),r.next=7,(0,p.getGoods)(c);case 7:s=r.sent,u=s.data&&s.data.data||{items:[],total:0,page:1,pageSize:10},l=u.items||[],d=l.map((function(t){return{id:t.goodId,name:(0,g.truncateProductTitle)(t.goodName),price:t.price,image:t.image,countryName:t.country&&t.country.countryName||"",countryIcon:t.country&&t.country.countryIcon?(0,v.buildUrl)(t.country.countryIcon):""}})),f=u.total||0,1===i?(n.products=d,n.resetScrollToTop()):n.products=n.products.concat(d),n.pagination={total:f,current:i,pageSize:a},n.hasMore=n.products.length0})).map((function(t){return{tagGroupId:String(t.id),tagIds:(t.selected||[]).map((function(t){return String(t)}))}}));return i.length>0&&(e.tags=JSON.stringify(i)),e},codeToEmoji:function(t){if(!t)return"🌐";var e=t.match(/\/([^/]+)\.\w+$/);if(!e)return"🌐";var n=e[1].toUpperCase();if(2!==n.length)return"🌐";var r=127462;return String.fromCodePoint(r+n.charCodeAt(0)-65)+String.fromCodePoint(r+n.charCodeAt(1)-65)},loadSubCategories:function(t){var e=[];if("all"!==t){var n=this.categories.find((function(e){return e.id===t}));n&&n.children&&n.children.length>0&&(e=n.children.map((function(t){return{id:t.id,name:t.categoryName}})))}this.subCategories=e,this.activeSubCategory=""},onSearchInput:function(t){this.searchKeyword=t},onSearchBlur:function(){this.loadGoods(1)},onScrollToLower:function(){!this.loadingMore&&this.hasMore&&(this.loadingMore=!0,this.loadGoods(this.pagination.current+1))},resetScrollToTop:function(){var t=this;this.scrollTopVal=99999,this.$nextTick((function(){t.scrollTopVal=0}))},onSearchBarHeight:function(t){this._searchBarH=t,this.recomputeScrollHeight()},onCountryBarHeight:function(t){this._countryBarH=t,this.recomputeScrollHeight()},onActiveFiltersHeight:function(t){this._activeFiltersH=t,this.recomputeScrollHeight()},onSubCategoriesHeight:function(t){this._subCategoriesH=t,this.recomputeScrollHeight()},recomputeScrollHeight:function(){var e=this._searchBarH+this._countryBarH+this._activeFiltersH+this._subCategoriesH,n=this.windowHeight-e;n<200&&(n=200),n>this.windowHeight&&(n=this.windowHeight),this.productScrollHeight=Math.round(n),t.log("[recomputeScrollHeight] windowHeight="+this.windowHeight+", searchBar="+this._searchBarH+", countryBar="+this._countryBarH+", activeFilters="+this._activeFiltersH+", subCategories="+this._subCategoriesH+" → scrollHeight="+this.productScrollHeight)},selectCountry:function(t){var e=this;return(0,c.default)((0,o.default)().mark((function n(){var r,i;return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:if(e.selectedCountry!==t){n.next=8;break}return e.selectedCountry="",e.activeFilters=e.activeFilters.filter((function(t){return"country"!==t.key})),n.next=5,e.loadCategoryData("");case 5:return n.next=7,e.loadGoods(1);case 7:return n.abrupt("return");case 8:return r=e.countries.find((function(e){return e.id===t})),i=e.activeFilters.filter((function(t){return"country"!==t.key})),r&&i.push({key:"country",label:r.name}),e.selectedCountry=t,e.activeFilters=i,n.next=15,e.loadCategoryData(t);case 15:return n.next=17,e.loadGoods(1);case 17:case"end":return n.stop()}}),n)})))()},selectCategory:function(t){var e=this;return(0,c.default)((0,o.default)().mark((function n(){return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:return e.activeCategory=t,e.loadSubCategories(t),n.next=4,e.loadGoods(1);case 4:case"end":return n.stop()}}),n)})))()},selectSubCategory:function(t){var e=this;return(0,c.default)((0,o.default)().mark((function n(){return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:return e.activeSubCategory=t,n.next=3,e.loadGoods(1);case 3:case"end":return n.stop()}}),n)})))()},toggleFilter:function(){this.showFilter=!this.showFilter},closeFilter:function(){this.showFilter=!1},toggleFilterTag:function(t){var e=t.groupIndex,n=t.tagId,r=(0,i.default)(this.filterGroups),a=r[e];if(a){var o=(0,i.default)(a.selected),c=o.indexOf(n);c>-1?o.splice(c,1):o.push(n),a.selected=o,this.filterGroups=r}},removeFilter:function(t){var e=this;return(0,c.default)((0,o.default)().mark((function n(){var r,i,a;return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:if(r=e.activeFilters.filter((function(e){return e.key!==t})),"country"!==t){n.next=9;break}return e.selectedCountry="",e.activeFilters=r,n.next=6,e.loadCategoryData("");case 6:return n.next=8,e.loadGoods(1);case 8:return n.abrupt("return");case 9:if(0!==t.indexOf("tag_")){n.next=17;break}return i=t.replace("tag_",""),a=e.filterGroups.map((function(t){return String(t.id)===String(i)?Object.assign({},t,{selected:[]}):t})),e.activeFilters=r,e.filterGroups=a,n.next=16,e.loadGoods(1);case 16:return n.abrupt("return");case 17:e.activeFilters=r;case 18:case"end":return n.stop()}}),n)})))()},clearAllFilters:function(){var t=this;return(0,c.default)((0,o.default)().mark((function e(){var n,r;return(0,o.default)().wrap((function(e){while(1)switch(e.prev=e.next){case 0:if(n=t.activeFilters.some((function(t){return"country"===t.key})),t.activeFilters=[],t.minPrice="",t.maxPrice="",!n&&!t.selectedCountry){e.next=8;break}return t.selectedCountry="",e.next=8,t.loadCategoryData("");case 8:return r=t.filterGroups.map((function(t){return Object.assign({},t,{selected:[]})})),t.filterGroups=r,e.next=12,t.loadGoods(1);case 12:case"end":return e.stop()}}),e)})))()},resetFilters:function(){var t=this.filterGroups.map((function(t){return Object.assign({},t,{selected:[]})}));this.filterGroups=t},confirmFilters:function(t){var e=this;return(0,c.default)((0,o.default)().mark((function n(){var r,i;return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:return t=t||{},e.minPrice=t.minPrice||"",e.maxPrice=t.maxPrice||"",r=[],i=e.activeFilters.find((function(t){return"country"===t.key})),i&&r.push(i),e.filterGroups.forEach((function(t){if(t.selected.length>0){var e=t.tags.filter((function(e){return t.selected.indexOf(e.id)>-1})).map((function(t){return t.name}));e.length>0&&r.push({key:"tag_"+t.id,label:e.join(",")})}})),e.activeFilters=r,e.showFilter=!1,n.next=11,e.loadGoods(1);case 11:case"end":return n.stop()}}),n)})))()},goDetail:function(t){uni.navigateTo({url:"/pages/product-detail/product-detail?id=".concat(t.id)})}}};e.default=b}).call(this,n("ba7c")["default"])},c115:function(t,e,n){"use strict";n.r(e);var r=n("0b77"),i=n("4ee1");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("033b");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"c0e80af4",null,!1,r["a"],void 0);e["default"]=c.exports},c240:function(t,e,n){"use strict";var r=n("181d");r(Math,"Math",!0)},c51b:function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* 右侧内容区 - 上下分:顶部条件 + 下方滚动\n 不再依赖 flex:1 撑高度,scroll-view 用父组件传入的固定高度 */.right-content[data-v-b2389a9e]{display:flex;flex-direction:column;background:#ececec /* UI-0012: 与页面大背景统一为 #ECECEC */}\n/* 顶部子分类条件 - padding 6px 10px 圆角 60px */.sub-categories[data-v-b2389a9e]{display:flex;flex-wrap:wrap;gap:%?16?%;padding:%?20?% %?20?%;background:#fff;border-bottom:%?1?% solid #eee}.sub-cat-item[data-v-b2389a9e]{padding:%?12?% %?20?%;background:#fff;border-radius:%?60?%;font-size:%?24?%;font-weight:500;color:#000;line-height:1.2}\n/* 选中态 - 白底+橙色边框0.5px+橙色文字 */.sub-cat-item.active[data-v-b2389a9e]{border:%?1?% solid #ff6800;color:#ff6800}\n/* 滚动商品区 - 高度由父组件通过 :style 传入(px 固定值),\n 符合 uni-app / 微信小程序官方文档「scroll-y 需给固定 height」的要求 */.product-scroll[data-v-b2389a9e]{width:100%}\n/* 商品网格 - 固定2列 grid,不会因商品个数变化而拉伸占行 */.product-grid[data-v-b2389a9e]{display:grid;grid-template-columns:repeat(2,1fr);gap:%?16?%;padding:%?20?%}.product-card[data-v-b2389a9e]{min-width:0;background:#fff;border-radius:%?12?%;overflow:hidden;box-sizing:border-box}\n/* 图片 135x120 - 用 padding-bottom 撑比例 */.product-img[data-v-b2389a9e]{position:relative;width:100%;padding-bottom:88.89%; /* 120/135 */background:#f7f7f7;overflow:hidden}.product-image[data-v-b2389a9e]{position:absolute;top:0;left:0;width:100%;height:100%}\n/* 商品信息 - 内边距 10px */.product-info[data-v-b2389a9e]{padding:%?20?%}\n/* 商品名 13px Medium #000000 */.product-name[data-v-b2389a9e]{display:block;font-size:%?26?%;font-weight:500;color:#000;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n/* 价格行 - 价格 + "起"字横向排列 */.price-row[data-v-b2389a9e]{display:flex;align-items:baseline;margin-top:%?16?%}\n/* 价格 15px Heavy #FF0000 红色 */.product-price[data-v-b2389a9e]{font-size:%?30?%;font-weight:900;color:red;line-height:1.2}\n/* "起"字 - 比价格字体小,其他样式相同 */.price-unit[data-v-b2389a9e]{font-size:%?22?%;font-weight:900;color:red;line-height:1.2;margin-left:%?4?%}\n/* 国家标签 - 价格右侧,国旗图标 + 国家名 */.country-tag[data-v-b2389a9e]{display:inline-flex;align-items:center;gap:%?6?%;margin-left:%?12?%;padding:%?4?% %?12?%;background:#f7f7f7;border-radius:%?20?%;max-width:50%;box-sizing:border-box;overflow:hidden}.country-tag-icon[data-v-b2389a9e]{width:%?28?%;height:%?20?%;border-radius:%?4?%;flex-shrink:0}.country-tag-name[data-v-b2389a9e]{font-size:%?20?%;font-weight:500;color:#666;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n/* 底部加载提示 */.load-tip[data-v-b2389a9e]{padding:%?32?%;text-align:center;font-size:%?24?%;color:#999}\n/* 空状态 */.product-empty[data-v-b2389a9e]{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:%?120?% 0}.empty-icon[data-v-b2389a9e]{font-size:%?96?%;margin-bottom:%?24?%}.empty-text[data-v-b2389a9e]{font-size:%?26?%;color:#999}',""]),t.exports=e},c68c:function(t,e,n){"use strict";n.r(e);var r=n("5fa4"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},c9fb:function(t,e,n){"use strict";var r=n("fcba"),i=n.n(r);i.a},cb1a:function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("5c47");var r={name:"ActiveFilters",props:{list:{type:Array,default:function(){return[]}}},mounted:function(){var t=this;this.$nextTick((function(){return t.emitHeight()}))},watch:{"list.length":function(){var t=this;this.$nextTick((function(){return t.emitHeight()}))}},methods:{emitHeight:function(){var t=this,e=uni.createSelectorQuery().in(this);e.select("#activeFilters").boundingClientRect(),e.exec((function(e){e&&e[0]?t.$emit("height-change",e[0].height):t.$emit("height-change",0)}))},onOpen:function(){this.$emit("open")},onRemove:function(t){this.$emit("remove",t)},onClearAll:function(){this.$emit("clear-all")}}};e.default=r},cbae:function(t,e,n){"use strict";n.r(e);var r=n("dda1"),i=n("84e8");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("256a");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"44522978",null,!1,r["a"],void 0);e["default"]=c.exports},d8da:function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("5c47"),n("a1c1"),n("5ef2"),n("f7a5"),n("e838");var r={name:"FilterDrawer",props:{visible:{type:Boolean,default:!1},groups:{type:Array,default:function(){return[]}}},data:function(){return{minPrice:"",maxPrice:"",minError:!1,maxError:!1,statusBarHeight:20}},created:function(){try{var t=uni.getSystemInfoSync(),e=t.statusBarHeight;this.statusBarHeight="number"===typeof e&&e>0?e:20}catch(n){this.statusBarHeight=20}},methods:{sanitizePrice:function(t){if(""===t||null===t||void 0===t)return"";var e=String(t).replace(/[^\d.]/g,""),n=e.indexOf(".");return n>-1&&(e=e.slice(0,n+1)+e.slice(n+1).replace(/\./g,"")),e},validateSingle:function(t){var e=this.sanitizePrice(this[t]);if(this[t]=e,""===e)return!0;var n=parseFloat(e);return!(isNaN(n)||n<0)||(uni.showToast({title:"价格不能小于0",icon:"none"}),this[t]="",!1)},flashError:function(t){var e=this;this[t]=!0,setTimeout((function(){e[t]=!1}),1500)},onMinBlur:function(){if(this.validateSingle("minPrice")){var t=parseFloat(this.minPrice),e=parseFloat(this.maxPrice);""!==this.minPrice&&""!==this.maxPrice&&t>e&&(this.minPrice=this.maxPrice,this.flashError("minError"),uni.showToast({title:"最低价不能大于最高价,已自动调整",icon:"none"}))}},onMaxBlur:function(){if(this.validateSingle("maxPrice")){var t=parseFloat(this.minPrice),e=parseFloat(this.maxPrice);""!==this.maxPrice&&""!==this.minPrice&&e-1},onToggle:function(t,e){this.$emit("toggle-tag",{groupIndex:t,tagId:e})},onReset:function(){this.minPrice="",this.maxPrice="",this.minError=!1,this.maxError=!1,this.$emit("reset")},onConfirm:function(){this.validatePrices()&&this.$emit("confirm",{minPrice:this.minPrice,maxPrice:this.maxPrice})},onClose:function(){this.$emit("close")}}};e.default=r},dd5a:function(t,e,n){"use strict";var r=n("fb2e"),i=n.n(r);i.a},dda1:function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-scroll-view",{staticClass:"sidebar",attrs:{"scroll-y":!0}},t._l(t.list,(function(e,r){return n("v-uni-view",{key:r,staticClass:"sidebar-item",class:{active:t.activeId===e.id},on:{click:function(n){arguments[0]=n=t.$handleEvent(n),t.selectItem(e.id)}}},[t.activeId===e.id?n("v-uni-view",{staticClass:"sidebar-indicator"}):t._e(),n("v-uni-text",{staticClass:"sidebar-text"},[t._v(t._s(e.name))])],1)})),1)},i=[]},ddd7:function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",[n("v-uni-view",{staticClass:"filter-overlay",class:{show:t.visible},on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onClose.apply(void 0,arguments)}}}),n("v-uni-view",{staticClass:"filter-drawer",class:{show:t.visible},style:{paddingTop:t.statusBarHeight+"px"}},[n("v-uni-view",{staticClass:"filter-drawer-header"},[n("v-uni-text",{staticClass:"filter-drawer-title"},[t._v("筛选条件")]),n("v-uni-text",{staticClass:"filter-drawer-close",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onClose.apply(void 0,arguments)}}},[t._v("✕")])],1),n("v-uni-scroll-view",{staticClass:"filter-drawer-body",attrs:{"scroll-y":!0}},t._l(t.groups,(function(e,r){return n("v-uni-view",{key:r,staticClass:"filter-group"},[n("v-uni-text",{staticClass:"filter-group-title"},[t._v(t._s(e.title))]),n("v-uni-view",{staticClass:"filter-options"},t._l(e.tags,(function(i,a){return n("v-uni-view",{key:a,staticClass:"filter-option",class:{selected:t.isSelected(e,i.id)},on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onToggle(r,i.id)}}},[t._v(" "+t._s(i.name)+" ")])})),1)],1)})),1),n("v-uni-view",{staticClass:"filter-drawer-footer"},[n("v-uni-view",{staticClass:"filter-reset",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onReset.apply(void 0,arguments)}}},[t._v("重置")]),n("v-uni-view",{staticClass:"filter-confirm",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onConfirm.apply(void 0,arguments)}}},[t._v("确定")])],1)],1)],1)},i=[]},e945:function(t,e,n){"use strict";n.r(e);var r=n("257f"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},f880:function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("64aa");var r={name:"CategorySidebar",props:{list:{type:Array,default:function(){return[]}},activeId:{type:[String,Number],default:"all"}},methods:{selectItem:function(t){this.$emit("select",t)}}};e.default=r},fb2e:function(t,e,n){var r=n("4e2e");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("ae77e90c",r,!0,{sourceMap:!1,shadowMode:!1})},fcba:function(t,e,n){var r=n("ffc6");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("d134fb5e",r,!0,{sourceMap:!1,shadowMode:!1})},ffc6:function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,"/* 货盘页 - 页面容器样式 */\n/* 各区块样式已移入 pages/products/components/ 下对应组件 */.products-page[data-v-40f9504e]{display:flex;flex-direction:column;overflow:hidden; /* 唯一滚动源 = scroll-view,整页绝不兜底滚动 */background:#ececec /* UI-0012: 大背景灰统一为 #ECECEC */}\n/* 主体内容 - 左右分栏 */.content-body[data-v-40f9504e]{flex:1;display:flex;overflow:hidden;min-height:0}\n/* 右侧容器 - 自动占满剩余宽度(高度自动撑开,内部由 ProductGrid 自己滚动)\n flex:1 与 height:100% 冲突,必须二选一,否则 flex 链路断裂 */.right-wrap[data-v-40f9504e]{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}",""]),t.exports=e}}]); \ No newline at end of file diff --git a/deploy/h5/static/js/pages-products-products.c41769e2.js b/deploy/h5/static/js/pages-products-products.c41769e2.js new file mode 100644 index 0000000..9763f61 --- /dev/null +++ b/deploy/h5/static/js/pages-products-products.c41769e2.js @@ -0,0 +1,3 @@ +(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["pages-products-products"],{"013b":function(t,e,n){"use strict";n.r(e);var r=n("cb1a"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},"033b":function(t,e,n){"use strict";var r=n("8a79"),i=n.n(r);i.a},"0554":function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",{staticClass:"right-content"},[t.subCategories.length>0?n("v-uni-view",{staticClass:"sub-categories",attrs:{id:"subCategories"}},t._l(t.subCategories,(function(e,r){return n("v-uni-view",{key:r,staticClass:"sub-cat-item",class:{active:t.activeSubId===e.id},on:{click:function(n){arguments[0]=n=t.$handleEvent(n),t.selectSub(e.id)}}},[t._v(t._s(e.name))])})),1):t._e(),n("v-uni-scroll-view",{staticClass:"product-scroll",style:{height:t.scrollHeight+"px"},attrs:{"scroll-y":!0,"lower-threshold":100,"scroll-top":t.scrollTopVal,"scroll-with-animation":!1},on:{scrolltolower:function(e){arguments[0]=e=t.$handleEvent(e),t.onScrollToLower.apply(void 0,arguments)}}},[t.list.length>0?n("v-uni-view",{staticClass:"product-grid"},t._l(t.list,(function(e,r){return n("v-uni-view",{key:r,staticClass:"product-card",on:{click:function(n){arguments[0]=n=t.$handleEvent(n),t.onTapProduct(e)}}},[n("v-uni-view",{staticClass:"product-img"},[e.image?n("v-uni-image",{staticClass:"product-image",attrs:{src:e.image,mode:"aspectFill","lazy-load":!0}}):t._e()],1),n("v-uni-view",{staticClass:"product-info"},[n("v-uni-text",{staticClass:"product-name"},[t._v(t._s(e.name))]),n("v-uni-view",{staticClass:"price-row"},[n("v-uni-text",{staticClass:"product-price"},[t._v("¥"+t._s(e.price))]),n("v-uni-text",{staticClass:"price-unit"},[t._v("起")]),t.showCountryTag&&e.countryName?n("v-uni-view",{staticClass:"country-tag"},[e.countryIcon?n("v-uni-image",{staticClass:"country-tag-icon",attrs:{src:e.countryIcon,mode:"aspectFill"}}):t._e(),n("v-uni-text",{staticClass:"country-tag-name"},[t._v(t._s(e.countryName))])],1):t._e()],1)],1)],1)})),1):n("v-uni-view",{staticClass:"product-empty"},[n("v-uni-text",{staticClass:"empty-icon"},[t._v("📦")]),n("v-uni-text",{staticClass:"empty-text"},[t._v("没有找到相关商品")])],1),t.list.length>0?n("v-uni-view",{staticClass:"load-tip"},[t.loading?n("v-uni-text",[t._v("加载中...")]):t.hasMore?t._e():n("v-uni-text",[t._v("没有更多了")])],1):t._e()],1)],1)},i=[]},"0b77":function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",{staticClass:"country-bar",attrs:{id:"countryBar"}},[n("v-uni-view",{staticClass:"country-inner"},t._l(t.list,(function(e,r){return n("v-uni-view",{key:r,staticClass:"country-item",class:{active:t.selectedId===e.id},on:{click:function(n){arguments[0]=n=t.$handleEvent(n),t.selectItem(e)}}},[n("v-uni-image",{staticClass:"country-icon",attrs:{src:e.icon,mode:"aspectFill"}}),n("v-uni-text",{staticClass:"country-name"},[t._v(t._s(e.name))])],1)})),1)],1)},i=[]},"114e":function(t,e,n){"use strict";var r=n("85c1"),i=n("181d");i(r.JSON,"JSON",!0)},"160f":function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",{staticClass:"search-bar",attrs:{id:"searchBar"}},[n("v-uni-view",{staticClass:"status-bar-placeholder",style:{height:t.statusBarHeight+"px"}}),n("v-uni-view",{staticClass:"search-input-wrap"},[n("v-uni-image",{staticClass:"search-icon",attrs:{src:"/static/icons/products/icon_search.png",mode:"aspectFit"}}),n("v-uni-input",{staticClass:"search-input",attrs:{placeholder:"搜索产品","placeholder-class":"search-placeholder",value:t.keyword},on:{input:function(e){arguments[0]=e=t.$handleEvent(e),t.onInput.apply(void 0,arguments)},blur:function(e){arguments[0]=e=t.$handleEvent(e),t.onBlur.apply(void 0,arguments)},confirm:function(e){arguments[0]=e=t.$handleEvent(e),t.onConfirm.apply(void 0,arguments)}}}),n("v-uni-view",{staticClass:"search-btn",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onSearch.apply(void 0,arguments)}}},[t._v("搜索")])],1)],1)},i=[]},1998:function(t,e,n){"use strict";n.r(e);var r=n("d8da"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},"1c98":function(t,e,n){"use strict";n.r(e);var r=n("b5e9"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},"256a":function(t,e,n){"use strict";var r=n("6118"),i=n.n(r);i.a},"257f":function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("5c47");var r={name:"SearchBar",props:{keyword:{type:String,default:""}},data:function(){return{statusBarHeight:20}},created:function(){try{var t=uni.getSystemInfoSync(),e=t.statusBarHeight;this.statusBarHeight="number"===typeof e&&e>0?e:20}catch(n){this.statusBarHeight=20}},mounted:function(){var t=this;this.$nextTick((function(){return t.emitHeight()}))},methods:{emitHeight:function(){var t=this,e=uni.createSelectorQuery().in(this);e.select("#searchBar").boundingClientRect(),e.exec((function(e){e&&e[0]&&t.$emit("height-change",e[0].height)}))},onInput:function(t){this.$emit("input",t.detail.value)},onBlur:function(){this.$emit("blur")},onConfirm:function(){this.$emit("blur")},onSearch:function(){this.$emit("search")}}};e.default=r},2634:function(t,e,n){"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(){ +/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ +e.default=function(){return t};var t={},n=Object.prototype,i=n.hasOwnProperty,a=Object.defineProperty||function(t,e,n){t[e]=n.value},o="function"==typeof Symbol?Symbol:{},c=o.iterator||"@@iterator",s=o.asyncIterator||"@@asyncIterator",u=o.toStringTag||"@@toStringTag";function l(t,e,n){return Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}),t[e]}try{l({},"")}catch($){l=function(t,e,n){return t[e]=n}}function d(t,e,n,r){var i=e&&e.prototype instanceof p?e:p,o=Object.create(i.prototype),c=new P(r||[]);return a(o,"_invoke",{value:_(t,n,c)}),o}function f(t,e,n){try{return{type:"normal",arg:t.call(e,n)}}catch($){return{type:"throw",arg:$}}}t.wrap=d;var h={};function p(){}function v(){}function g(){}var b={};l(b,c,(function(){return this}));var m=Object.getPrototypeOf,y=m&&m(m(F([])));y&&y!==n&&i.call(y,c)&&(b=y);var x=g.prototype=p.prototype=Object.create(b);function w(t){["next","throw","return"].forEach((function(e){l(t,e,(function(t){return this._invoke(e,t)}))}))}function C(t,e){var n;a(this,"_invoke",{value:function(a,o){function c(){return new e((function(n,c){(function n(a,o,c,s){var u=f(t[a],t,o);if("throw"!==u.type){var l=u.arg,d=l.value;return d&&"object"==(0,r.default)(d)&&i.call(d,"__await")?e.resolve(d.__await).then((function(t){n("next",t,c,s)}),(function(t){n("throw",t,c,s)})):e.resolve(d).then((function(t){l.value=t,c(l)}),(function(t){return n("throw",t,c,s)}))}s(u.arg)})(a,o,n,c)}))}return n=n?n.then(c,c):c()}})}function _(t,e,n){var r="suspendedStart";return function(i,a){if("executing"===r)throw new Error("Generator is already running");if("completed"===r){if("throw"===i)throw a;return H()}for(n.method=i,n.arg=a;;){var o=n.delegate;if(o){var c=S(o,n);if(c){if(c===h)continue;return c}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if("suspendedStart"===r)throw r="completed",n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);r="executing";var s=f(t,e,n);if("normal"===s.type){if(r=n.done?"completed":"suspendedYield",s.arg===h)continue;return{value:s.arg,done:n.done}}"throw"===s.type&&(r="completed",n.method="throw",n.arg=s.arg)}}}function S(t,e){var n=e.method,r=t.iterator[n];if(void 0===r)return e.delegate=null,"throw"===n&&t.iterator["return"]&&(e.method="return",e.arg=void 0,S(t,e),"throw"===e.method)||"return"!==n&&(e.method="throw",e.arg=new TypeError("The iterator does not provide a '"+n+"' method")),h;var i=f(r,t.iterator,e.arg);if("throw"===i.type)return e.method="throw",e.arg=i.arg,e.delegate=null,h;var a=i.arg;return a?a.done?(e[t.resultName]=a.value,e.next=t.nextLoc,"return"!==e.method&&(e.method="next",e.arg=void 0),e.delegate=null,h):a:(e.method="throw",e.arg=new TypeError("iterator result is not an object"),e.delegate=null,h)}function E(t){var e={tryLoc:t[0]};1 in t&&(e.catchLoc=t[1]),2 in t&&(e.finallyLoc=t[2],e.afterLoc=t[3]),this.tryEntries.push(e)}function k(t){var e=t.completion||{};e.type="normal",delete e.arg,t.completion=e}function P(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(E,this),this.reset(!0)}function F(t){if(t){var e=t[c];if(e)return e.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var n=-1,r=function e(){for(;++n=0;--r){var a=this.tryEntries[r],o=a.completion;if("root"===a.tryLoc)return n("end");if(a.tryLoc<=this.prev){var c=i.call(a,"catchLoc"),s=i.call(a,"finallyLoc");if(c&&s){if(this.prev=0;--n){var r=this.tryEntries[n];if(r.tryLoc<=this.prev&&i.call(r,"finallyLoc")&&this.prev=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),k(n),h}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var r=n.completion;if("throw"===r.type){var i=r.arg;k(n)}return i}}throw new Error("illegal catch attempt")},delegateYield:function(t,e,n){return this.delegate={iterator:F(t),resultName:e,nextLoc:n},"next"===this.method&&(this.arg=void 0),h}},t},n("6a54"),n("01a2"),n("e39c"),n("bf0f"),n("844d"),n("18f7"),n("de6c"),n("3872e"),n("4e9b"),n("114e"),n("c240"),n("926e"),n("7a76"),n("c9b5"),n("aa9c"),n("2797"),n("8a8d"),n("dc69"),n("f7a5");var r=function(t){return t&&t.__esModule?t:{default:t}}(n("fcf3"))},"2bd4":function(t,e,n){"use strict";var r=n("2e05"),i=n.n(r);i.a},"2e05":function(t,e,n){var r=n("2f4d");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("631578cf",r,!0,{sourceMap:!1,shadowMode:!1})},"2e80":function(t,e,n){"use strict";var r=n("6194"),i=n.n(r);i.a},"2f4d":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,'.filter-overlay[data-v-4812d3d1]{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:998;opacity:0;visibility:hidden;transition:all .3s}.filter-overlay.show[data-v-4812d3d1]{opacity:1;visibility:visible}.filter-drawer[data-v-4812d3d1]{position:fixed;top:0;right:%?-600?%;width:%?600?%;height:100%;\n /* 内联 paddingTop(状态栏高度)计入总高,否则 100% + padding 会把底部按钮顶出屏幕 */box-sizing:border-box;background:#fff;z-index:999;display:flex;flex-direction:column;transition:right .3s}.filter-drawer.show[data-v-4812d3d1]{right:0}.filter-drawer-header[data-v-4812d3d1]{display:flex;align-items:center;justify-content:space-between;padding:%?32?%;border-bottom:%?2?% solid #f5f5f5}.filter-drawer-title[data-v-4812d3d1]{font-size:%?32?%;font-weight:600;color:#1a1a1a}.filter-drawer-close[data-v-4812d3d1]{font-size:%?32?%;color:#999}.filter-drawer-body[data-v-4812d3d1]{flex:1;\n /* 框架默认 width:100% + padding 时 content-box 会超宽 64rpx,\n 导致右侧"最高价"输入框被挤出屏幕,必须 border-box */width:100%;box-sizing:border-box;padding:0 %?32?%}.filter-group[data-v-4812d3d1]{padding:%?32?% 0;border-bottom:%?2?% solid #f5f5f5}.filter-group-title[data-v-4812d3d1]{display:block;font-size:%?28?%;font-weight:600;color:#1a1a1a;margin-bottom:%?20?%}.filter-options[data-v-4812d3d1]{display:flex;flex-wrap:wrap;gap:%?16?%}.filter-option[data-v-4812d3d1]{padding:%?14?% %?28?%;font-size:%?24?%;color:#666;background:#f5f5f5;border-radius:%?30?%;border:%?2?% solid transparent}.filter-option.selected[data-v-4812d3d1]{background:#fff0e5;color:#ff6800;border-color:#ff6800}.filter-price-range[data-v-4812d3d1]{display:flex;align-items:center;justify-content:space-between}.price-input[data-v-4812d3d1]{\n /* 固定宽度,不依赖 flex 收缩(uni-input 自定义元素收缩行为不可靠) */width:%?230?%;box-sizing:border-box;height:%?72?%;padding:0 %?20?%;background:#f5f5f5;border-radius:%?12?%;border:%?2?% solid transparent;font-size:%?26?%;color:#333;text-align:center}\n/* 校验失败冒红 */.price-input.input-error[data-v-4812d3d1]{border-color:red;background:#fff0f0}.price-divider[data-v-4812d3d1]{font-size:%?28?%;color:#999}.filter-drawer-footer[data-v-4812d3d1]{display:flex;gap:%?24?%;padding:%?24?% %?32?%;border-top:%?2?% solid #f5f5f5}.filter-reset[data-v-4812d3d1]{flex:1;height:%?80?%;display:flex;align-items:center;justify-content:center;background:#f5f5f5;border-radius:%?40?%;font-size:%?28?%;color:#666}.filter-confirm[data-v-4812d3d1]{flex:1;height:%?80?%;display:flex;align-items:center;justify-content:center;background:#ff6800;border-radius:%?40?%;font-size:%?28?%;font-weight:600;color:#fff}',""]),t.exports=e},"2fdc":function(t,e,n){"use strict";function r(t,e,n,r,i,a,o){try{var c=t[a](o),s=c.value}catch(u){return void n(u)}c.done?e(s):Promise.resolve(s).then(r,i)}n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=function(t){return function(){var e=this,n=arguments;return new Promise((function(i,a){var o=t.apply(e,n);function c(t){r(o,i,a,c,s,"next",t)}function s(t){r(o,i,a,c,s,"throw",t)}c(void 0)}))}},n("bf0f")},"379b":function(t,e,n){"use strict";n.r(e);var r=n("ec41"),i=n("1c98");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("b691");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"50fc61a8",null,!1,r["a"],void 0);e["default"]=c.exports},"3872e":function(t,e,n){"use strict";var r=n("f259");r("asyncIterator")},"3a6c":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* 侧边栏 - 固定宽度 180rpx(=90px),白底 + 右侧 1px #EEEEEE 边框 */.sidebar[data-v-44522978]{width:%?180?%;height:100%;background:#fff;border-right:%?1?% solid #eee;box-sizing:border-box;flex-shrink:0}\n/* 单项 - 90x46 (180rpx x 92rpx),居中文字 */.sidebar-item[data-v-44522978]{position:relative;width:100%;height:%?92?%;display:flex;align-items:center;justify-content:center;background:#fff;box-sizing:border-box}\n/* 选中态 - #FFF8F3 背景 */.sidebar-item.active[data-v-44522978]{background:#fff8f3}\n/* 选中指示器 - 左侧 3x16 (6rpx x 32rpx) 橙色 圆角 2px */.sidebar-indicator[data-v-44522978]{position:absolute;left:0;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);width:%?6?%;height:%?32?%;background:#ff6800;border-radius:%?4?%}\n/* 文字 - 13px Medium 选中橙色 未选中 #666666 */.sidebar-text[data-v-44522978]{font-size:%?26?%;font-weight:500;color:#666;text-align:center;line-height:%?34?%}.sidebar-item.active .sidebar-text[data-v-44522978]{color:#ff6800}",""]),t.exports=e},4672:function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",{staticClass:"active-filters",attrs:{id:"activeFilters"}},[n("v-uni-view",{staticClass:"filter-entry",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onOpen.apply(void 0,arguments)}}},[n("v-uni-image",{staticClass:"filter-entry-icon",attrs:{src:"/static/icons/products/icon_filter.png",mode:"aspectFit"}}),0===t.list.length?n("v-uni-text",{staticClass:"filter-entry-text"},[t._v("筛选")]):t._e()],1),n("v-uni-scroll-view",{staticClass:"tags-scroll",attrs:{"scroll-x":!0}},[n("v-uni-view",{staticClass:"tags-inner"},t._l(t.list,(function(e,r){return n("v-uni-view",{key:r,staticClass:"active-filter-tag"},[n("v-uni-text",{staticClass:"filter-text"},[t._v(t._s(e.label))]),n("v-uni-text",{staticClass:"filter-remove",on:{click:function(n){arguments[0]=n=t.$handleEvent(n),t.onRemove(e.key)}}},[t._v("×")])],1)})),1)],1),t.list.length>0?n("v-uni-view",{staticClass:"clear-all",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onClearAll.apply(void 0,arguments)}}},[t._v("清除")]):t._e()],1)},i=[]},"4cd5":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,"/* 货盘页 - 页面容器样式 */\n/* 各区块样式已移入 pages/products/components/ 下对应组件 */.products-page[data-v-50fc61a8]{display:flex;flex-direction:column;overflow:hidden; /* 唯一滚动源 = scroll-view,整页绝不兜底滚动 */background:#ececec /* UI-0012: 大背景灰统一为 #ECECEC */}\n/* 主体内容 - 左右分栏 */.content-body[data-v-50fc61a8]{flex:1;display:flex;overflow:hidden;min-height:0}\n/* 右侧容器 - 自动占满剩余宽度(高度自动撑开,内部由 ProductGrid 自己滚动)\n flex:1 与 height:100% 冲突,必须二选一,否则 flex 链路断裂 */.right-wrap[data-v-50fc61a8]{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}",""]),t.exports=e},"4e08":function(t,e,n){"use strict";n.r(e);var r=n("6c7f"),i=n("1998");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("2bd4");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"4812d3d1",null,!1,r["a"],void 0);e["default"]=c.exports},"4e2e":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* 顶部筛选条 - 白底,筛选图标 + 横向滚动标签 + 清除,同一行 */.active-filters[data-v-ddd0342c]{display:flex;align-items:center;padding:%?16?% %?20?%;background:#fff;border-bottom:%?1?% solid #eee}\n/* 筛选入口(设计稿 1962-300:图标 18x18px + "筛选"12px Medium,间距 4px → 8rpx) */.filter-entry[data-v-ddd0342c]{display:flex;align-items:center;gap:%?8?%;padding:%?8?% %?16?% %?8?% 0;flex-shrink:0}.filter-entry-icon[data-v-ddd0342c]{width:%?35?%;height:%?35?%}.filter-entry-text[data-v-ddd0342c]{font-size:%?23?%;font-weight:500;color:#000}\n/* 标签区:横向滚动不换行 */.tags-scroll[data-v-ddd0342c]{flex:1;min-width:0;white-space:nowrap}.tags-inner[data-v-ddd0342c]{display:inline-flex;align-items:center;gap:%?16?%;padding:%?4?% 0}\n/* 单个标签 - padding 6px 10px 圆角 60px,不换行 */.active-filter-tag[data-v-ddd0342c]{display:inline-flex;align-items:center;gap:%?8?%;padding:%?12?% %?20?%;background:#fff;border:%?1?% solid #ff6800;border-radius:%?60?%;line-height:1.2;flex-shrink:0}\n/* 文字 12px Medium 橙色 */.filter-text[data-v-ddd0342c]{font-size:%?24?%;font-weight:500;color:#ff6800}.filter-remove[data-v-ddd0342c]{font-size:%?24?%;color:#ff6800}.clear-all[data-v-ddd0342c]{padding-left:%?16?%;flex-shrink:0;font-size:%?24?%;color:#999}',""]),t.exports=e},"4e9b":function(t,e,n){"use strict";var r=n("6aa6"),i=n("f259"),a=n("181d");i("toStringTag"),a(r("Symbol"),"Symbol")},"4ee1":function(t,e,n){"use strict";n.r(e);var r=n("8a21"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},"55cb":function(t,e,n){var r=n("9b13");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("34f557c4",r,!0,{sourceMap:!1,shadowMode:!1})},"562d":function(t,e,n){"use strict";var r=n("8bdb"),i=n("bb80"),a=n("e34c"),o=RangeError,c=String.fromCharCode,s=String.fromCodePoint,u=i([].join),l=!!s&&1!==s.length;r({target:"String",stat:!0,arity:1,forced:l},{fromCodePoint:function(t){var e,n=[],r=arguments.length,i=0;while(r>i){if(e=+arguments[i++],a(e,1114111)!==e)throw new o(e+" is not a valid code point");n[i]=e<65536?c(e):c(55296+((e-=65536)>>10),e%1024+56320)}return u(n,"")}})},"5fa4":function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("64aa"),n("5c47");var r={name:"ProductGrid",props:{list:{type:Array,default:function(){return[]}},subCategories:{type:Array,default:function(){return[]}},activeSubId:{type:[String,Number],default:""},hasMore:{type:Boolean,default:!0},loading:{type:Boolean,default:!1},scrollTopVal:{type:Number,default:0},showCountryTag:{type:Boolean,default:!1},scrollHeight:{type:Number,default:400}},mounted:function(){var t=this;this.$nextTick((function(){return t.emitSubHeight()}))},watch:{"subCategories.length":function(){var t=this;this.$nextTick((function(){return t.emitSubHeight()}))}},methods:{emitSubHeight:function(){var t=this;if(this.subCategories&&0!==this.subCategories.length){var e=uni.createSelectorQuery().in(this);e.select("#subCategories").boundingClientRect(),e.exec((function(e){e&&e[0]?t.$emit("sub-height-change",e[0].height):t.$emit("sub-height-change",0)}))}else this.$emit("sub-height-change",0)},selectSub:function(t){this.$emit("sub-select",t)},onTapProduct:function(t){this.$emit("product-click",t)},onScrollToLower:function(){this.$emit("scroll-to-lower")}}};e.default=r},6118:function(t,e,n){var r=n("3a6c");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("7154b976",r,!0,{sourceMap:!1,shadowMode:!1})},6194:function(t,e,n){var r=n("c51b");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("74904422",r,!0,{sourceMap:!1,shadowMode:!1})},"635a":function(t,e,n){n("0c26"),n("5c47"),n("2c10"),n("f7a5"),t.exports={truncateProductTitle:function(t){if(null==t)return"";if("string"!==typeof t)try{t=String(t)}catch(i){return""}var e=t.trim();if(!e)return"";var n=e.match(/[\s\u3000\u00a0]/);if(!n)return e;var r=n.index;return r<=0?e:e.slice(0,r)}}},"6c7f":function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",[n("v-uni-view",{staticClass:"filter-overlay",class:{show:t.visible},on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onClose.apply(void 0,arguments)}}}),n("v-uni-view",{staticClass:"filter-drawer",class:{show:t.visible},style:{paddingTop:t.statusBarHeight+"px"}},[n("v-uni-view",{staticClass:"filter-drawer-header"},[n("v-uni-text",{staticClass:"filter-drawer-title"},[t._v("筛选条件")]),n("v-uni-text",{staticClass:"filter-drawer-close",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onClose.apply(void 0,arguments)}}},[t._v("✕")])],1),n("v-uni-scroll-view",{staticClass:"filter-drawer-body",attrs:{"scroll-y":!0}},t._l(t.groups,(function(e,r){return n("v-uni-view",{key:r,staticClass:"filter-group"},[n("v-uni-text",{staticClass:"filter-group-title"},[t._v(t._s(e.title))]),n("v-uni-view",{staticClass:"filter-options"},t._l(e.tags,(function(i,a){return n("v-uni-view",{key:a,staticClass:"filter-option",class:{selected:t.isSelected(e,i.id)},on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onToggle(r,i.id)}}},[t._v(" "+t._s(i.name)+" ")])})),1)],1)})),1),n("v-uni-view",{staticClass:"filter-drawer-footer"},[n("v-uni-view",{staticClass:"filter-reset",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onReset.apply(void 0,arguments)}}},[t._v("重置")]),n("v-uni-view",{staticClass:"filter-confirm",on:{click:function(e){arguments[0]=e=t.$handleEvent(e),t.onConfirm.apply(void 0,arguments)}}},[t._v("确定")])],1)],1)],1)},i=[]},"772e":function(t,e,n){"use strict";n("6a54");var r=n("f5bd").default;Object.defineProperty(e,"__esModule",{value:!0}),e.BASE_URL=e.ASSET_BASE=void 0,e.buildUrl=s,e.default=void 0;var i=r(n("9b1b"));n("5c47"),n("0506"),n("a1c1"),n("9db6"),n("c223"),n("bf0f");var a="https://official.inkreach.cc",o=a+"/v2-api/";e.BASE_URL=o;e.ASSET_BASE="https://official.inkreach.cc/";var c={"Content-Type":"application/json"};function s(t){if(!t)return"";if(/^https?:\/\//i.test(t))return t;var e=t.replace(/^\/+/,"");if(e.startsWith("assets/"))return"https://official.inkreach.cc/"+e;var n=o.replace(/\/+$/,"");return e?"".concat(n,"/").concat(e):n}function u(t){var e=Object.assign({},c,t||{}),n=uni.getStorageSync("token");return n&&(e["Authorization"]="Bearer ".concat(n)),e}function l(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return new Promise((function(e,n){uni.request({url:s(t.url||""),method:(t.method||"GET").toUpperCase(),data:t.data||{},header:u(t.header),timeout:3e4,dataType:"json",success:e,fail:n})}))}l.get=function(t,e,n){return l((0,i.default)({url:t,method:"GET",data:e},n))},l.post=function(t,e,n){return l((0,i.default)({url:t,method:"POST",data:e},n))},l.put=function(t,e,n){return l((0,i.default)({url:t,method:"PUT",data:e},n))},l.delete=function(t,e,n){return l((0,i.default)({url:t,method:"DELETE",data:e},n))};var d=l;e.default=d},"7d70":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* 国家筛选条 - 白底 + 底部 1px #EEEEEE 边框\n Figma: Country Filter Bar 1511:195, y=6 起始, 行高约 31px(padding 6+6) */.country-bar[data-v-c0e80af4]{width:100%;background:#fff;border-bottom:%?1?% solid #eee;padding:%?12?% %?20?%;box-sizing:border-box}\n/* 换行布局 - UI-0011: 标签上下间距 +2px → row-gap 20rpx */.country-inner[data-v-c0e80af4]{display:flex;flex-wrap:wrap;row-gap:%?20?%;-webkit-column-gap:%?20?%;column-gap:%?20?%}\n/* 单个标签 - padding 4px 10px 圆角 60px Figma;UI-0011: 国旗与名称间距 4px → 8rpx */.country-item[data-v-c0e80af4]{display:flex;align-items:center;gap:%?8?%;padding:%?8?% %?20?%; /* 4px 10px */background:#f7f7f7;border-radius:%?60?%;line-height:1.2;border:%?1?% solid transparent;box-sizing:border-box}\n/* 选中态 - #FFF3EB 背景 + 0.5px 橙色边框 + 边框宽度 0.5px Figma */.country-item.active[data-v-c0e80af4]{background:#fff3eb;border-color:#ff6800}.country-item.active .country-name[data-v-c0e80af4]{color:#ff6800}\n/* 国家图标 - Figma 18x13 / 18x14 矩形, 圆角 2px,不是圆形 */.country-icon[data-v-c0e80af4]{width:%?36?%;height:%?26?%;border-radius:%?4?%; /* 2px */flex-shrink:0;display:block}\n/* 文字 12px Medium */.country-name[data-v-c0e80af4]{font-size:%?24?%;font-weight:500;color:#000}",""]),t.exports=e},"84e8":function(t,e,n){"use strict";n.r(e);var r=n("f880"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},8701:function(t,e,n){"use strict";n.r(e);var r=n("4672"),i=n("013b");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("dd5a");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"ddd0342c",null,!1,r["a"],void 0);e["default"]=c.exports},"8a21":function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("64aa"),n("5c47");var r={name:"CountryBar",props:{list:{type:Array,default:function(){return[]}},selectedId:{type:[String,Number],default:""}},mounted:function(){var t=this;this.$nextTick((function(){return t.emitHeight()}))},watch:{"list.length":function(){var t=this;this.$nextTick((function(){return t.emitHeight()}))}},methods:{emitHeight:function(){var t=this,e=uni.createSelectorQuery().in(this);e.select("#countryBar").boundingClientRect(),e.exec((function(e){e&&e[0]&&t.$emit("height-change",e[0].height)}))},selectItem:function(t){this.$emit("select",t.id)}}};e.default=r},"8a79":function(t,e,n){var r=n("7d70");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("a98bd9a6",r,!0,{sourceMap:!1,shadowMode:!1})},"8a8d":function(t,e,n){"use strict";var r=n("8bdb"),i=n("8c4f");r({target:"Object",stat:!0},{setPrototypeOf:i})},"926e":function(t,e,n){"use strict";var r=n("8bdb"),i=n("af9e"),a=n("1099"),o=n("c337"),c=n("1d57"),s=i((function(){o(1)}));r({target:"Object",stat:!0,forced:s,sham:!c},{getPrototypeOf:function(t){return o(a(t))}})},9270:function(t,e,n){"use strict";n.r(e);var r=n("0554"),i=n("c68c");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("2e80");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"b2389a9e",null,!1,r["a"],void 0);e["default"]=c.exports},"9ac8":function(t,e,n){"use strict";var r=n("55cb"),i=n.n(r);i.a},"9b13":function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* 设计稿:左右留白 10px(20rpx),下留白 6px(12rpx),搜索框 209x32px(402x62rpx);上边距 56rpx 让搜索栏整体下移 */.search-bar[data-v-3de25ba6]{padding:%?10?% %?20?% %?12?%;background:#fff;box-sizing:border-box}\n/* 状态栏占位:高度由 JS 内联指定(statusBarHeight) */.status-bar-placeholder[data-v-3de25ba6]{width:100%}.search-input-wrap[data-v-3de25ba6]{display:flex;align-items:center;height:%?62?%;padding:0 %?4?% 0 %?21?%;background:#f5f5f5;border-radius:%?31?%;box-sizing:border-box;\n /* 小程序端:只占左侧大部分宽度(设计稿 402rpx) */\n\n\n /* 非小程序(H5/App):占整行 */\nwidth:100%\n}.search-icon[data-v-3de25ba6]{width:%?31?%;height:%?31?%;flex-shrink:0;margin-right:%?8?%}.search-input[data-v-3de25ba6]{flex:1;min-width:0;font-size:%?25?%;color:#333}.search-placeholder[data-v-3de25ba6]{color:#929292}\n/* 设计稿:搜索按钮 50x28px(96x54rpx),#FF6800,圆角 14px(27rpx),距边缘 2px(4rpx) */.search-btn[data-v-3de25ba6]{display:flex;align-items:center;justify-content:center;width:%?96?%;height:%?54?%;flex-shrink:0;background:#ff6800;border-radius:%?27?%;font-size:%?25?%;color:#fff}",""]),t.exports=e},"9da0":function(t,e,n){"use strict";n.r(e);var r=n("160f"),i=n("e945");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("9ac8");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"3de25ba6",null,!1,r["a"],void 0);e["default"]=c.exports},ad9b:function(t,e,n){"use strict";n("6a54");var r=n("f5bd").default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,e.getCategories=o,e.getCountries=c,e.getGoods=l,e.getGoodsDetail=d,e.getHomeGoods=f,e.getTagGroups=u,e.getTags=s;var i=r(n("772e")),a="/public";function o(t){return i.default.get("".concat(a,"/categories"),t)}function c(){return i.default.get("".concat(a,"/countries"))}function s(){return i.default.get("".concat(a,"/tags"))}function u(t){return i.default.get("".concat(a,"/tag-groups"),t)}function l(t){return i.default.get("".concat(a,"/goods"),t)}function d(t){return i.default.get("".concat(a,"/goods/").concat(t))}function f(t){return i.default.get("".concat(a,"/home-goods"),t)}var h={getCategories:o,getCountries:c,getTags:s,getTagGroups:u,getGoods:l,getGoodsDetail:d,getHomeGoods:f};e.default=h},b5e9:function(t,e,n){"use strict";(function(t){n("6a54");var r=n("f5bd").default;Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("fd3c"),n("bf0f"),n("18f7"),n("de6c"),n("2797"),n("aa9c"),n("8f71"),n("5ef2"),n("d4b5"),n("c223"),n("0c26"),n("5c47"),n("2c10"),n("562d"),n("aa77"),n("dd2b"),n("a1c1");var i=r(n("b7c7")),a=r(n("5de6")),o=r(n("2634")),c=r(n("2fdc")),s=r(n("9da0")),u=r(n("c115")),l=r(n("8701")),d=r(n("cbae")),f=r(n("9270")),h=r(n("4e08")),p=n("ad9b"),v=n("772e"),g=n("635a"),b={components:{SearchBar:s.default,CountryBar:u.default,ActiveFilters:l.default,CategorySidebar:d.default,ProductGrid:f.default,FilterDrawer:h.default},watch:{"activeFilters.length":function(){this.recomputeScrollHeight()},"subCategories.length":function(){this.recomputeScrollHeight()}},data:function(){return{searchKeyword:"",selectedCountry:"",countries:[],activeCategory:"all",categories:[{id:"all",name:"全部商品"}],subCategories:[],activeSubCategory:"",activeFilters:[],showFilter:!1,filterGroups:[],minPrice:"",maxPrice:"",pagination:{total:0,current:1,pageSize:10},hasMore:!0,loadingMore:!1,products:[],windowHeight:667,scrollTopVal:0,productScrollHeight:400,_searchBarH:0,_countryBarH:0,_activeFiltersH:0,_subCategoriesH:0}},onLoad:function(){var e=this;return(0,c.default)((0,o.default)().mark((function n(){var r,i,a;return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:try{r=uni.getSystemInfoSync(),i=r.tabBarHeight||50,e.windowHeight=(r.screenHeight||750)-i,t.log("[onLoad] screenHeight="+r.screenHeight+", tabBarHeight="+i+" → windowHeight="+e.windowHeight)}catch(o){e.windowHeight=667}return a=e.loadGoods(1),n.next=4,e.initData();case 4:return e.loadSubCategories("all"),n.next=7,a;case 7:e.recomputeScrollHeight();case 8:case"end":return n.stop()}}),n)})))()},methods:{initData:function(){var e=this;return(0,c.default)((0,o.default)().mark((function n(){var r,i,a;return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:return n.prev=0,n.next=3,(0,p.getCountries)();case 3:return r=n.sent,i=r.data&&r.data.data||[],a=i.map((function(t){return{id:t.id,name:t.countryName,icon:(0,v.buildUrl)(t.countryIcon||"")}})),e.countries=a,n.next=9,e.loadCategoryData("");case 9:t.log("=== [Products API Test] ==="),t.log("[1] countries:",a.length,"条"),t.log("=== [Test End] ==="),n.next=17;break;case 14:n.prev=14,n.t0=n["catch"](0),t.error("[Products initData Error]",n.t0);case 17:case"end":return n.stop()}}),n,null,[[0,14]])})))()},loadCategoryData:function(e){var n=this;return(0,c.default)((0,o.default)().mark((function r(){var i,c,s,u,l,d,f,h,v,g;return(0,o.default)().wrap((function(r){while(1)switch(r.prev=r.next){case 0:return r.prev=0,i=e?{countryId:e}:{},r.next=4,Promise.all([(0,p.getCategories)(i),(0,p.getTagGroups)(i)]);case 4:c=r.sent,s=(0,a.default)(c,2),u=s[0],l=s[1],d=u.data&&u.data.data||[],f=[{id:"all",name:"全部商品",children:[],productCount:0}],d.forEach((function(t){f.push({id:t.id,name:t.categoryName,children:t.children||[],productCount:t.productCount||0})})),h=l.data&&l.data.data||[],v=["物流渠道","印刷工艺"],g=h.filter((function(t){return-1===v.indexOf(t.groupName)})).map((function(t){return{id:t.id,title:t.groupName,tags:(t.tags||[]).map((function(t){return{id:t.id,name:t.tagName,color:t.tagColor,fontColor:t.tagFontColor,productCount:t.productCount||0}})),selected:[]}})),n.categories=f,n.filterGroups=g,n.activeCategory="all",n.subCategories=[],n.activeSubCategory="",t.log("[loadCategoryData] countryId="+(e||"all")+", categories="+f.length+", filterGroups="+g.length),r.next=25;break;case 22:r.prev=22,r.t0=r["catch"](0),t.error("[loadCategoryData Error]",r.t0);case 25:case"end":return r.stop()}}),r,null,[[0,22]])})))()},loadGoods:function(e){var n=this;return(0,c.default)((0,o.default)().mark((function r(){var i,a,c,s,u,l,d,f;return(0,o.default)().wrap((function(r){while(1)switch(r.prev=r.next){case 0:return r.prev=0,i=e||n.pagination.current||1,a=n.pagination.pageSize||10,c=n.buildQueryParams(i),t.log("[loadGoods] params:",JSON.stringify(c)),r.next=7,(0,p.getGoods)(c);case 7:s=r.sent,u=s.data&&s.data.data||{items:[],total:0,page:1,pageSize:10},l=u.items||[],d=l.map((function(t){return{id:t.goodId,name:(0,g.truncateProductTitle)(t.goodName),price:t.price,image:t.image,countryName:t.country&&t.country.countryName||"",countryIcon:t.country&&t.country.countryIcon?(0,v.buildUrl)(t.country.countryIcon):""}})),f=u.total||0,1===i?(n.products=d,n.resetScrollToTop()):n.products=n.products.concat(d),n.pagination={total:f,current:i,pageSize:a},n.hasMore=n.products.length0})).map((function(t){return{tagGroupId:String(t.id),tagIds:(t.selected||[]).map((function(t){return String(t)}))}}));return i.length>0&&(e.tags=JSON.stringify(i)),e},codeToEmoji:function(t){if(!t)return"🌐";var e=t.match(/\/([^/]+)\.\w+$/);if(!e)return"🌐";var n=e[1].toUpperCase();if(2!==n.length)return"🌐";var r=127462;return String.fromCodePoint(r+n.charCodeAt(0)-65)+String.fromCodePoint(r+n.charCodeAt(1)-65)},loadSubCategories:function(t){var e=[];if("all"!==t){var n=this.categories.find((function(e){return e.id===t}));n&&n.children&&n.children.length>0&&(e=n.children.map((function(t){return{id:t.id,name:t.categoryName}})))}this.subCategories=e,this.activeSubCategory=""},onSearchInput:function(t){this.searchKeyword=t},onSearchBlur:function(){this.loadGoods(1)},onScrollToLower:function(){!this.loadingMore&&this.hasMore&&(this.loadingMore=!0,this.loadGoods(this.pagination.current+1))},resetScrollToTop:function(){var t=this;this.scrollTopVal=99999,this.$nextTick((function(){t.scrollTopVal=0}))},onSearchBarHeight:function(t){this._searchBarH=t,this.recomputeScrollHeight()},onCountryBarHeight:function(t){this._countryBarH=t,this.recomputeScrollHeight()},onActiveFiltersHeight:function(t){this._activeFiltersH=t,this.recomputeScrollHeight()},onSubCategoriesHeight:function(t){this._subCategoriesH=t,this.recomputeScrollHeight()},recomputeScrollHeight:function(){var e=this._searchBarH+this._countryBarH+this._activeFiltersH+this._subCategoriesH,n=this.windowHeight-e;n<200&&(n=200),n>this.windowHeight&&(n=this.windowHeight),this.productScrollHeight=Math.round(n),t.log("[recomputeScrollHeight] windowHeight="+this.windowHeight+", searchBar="+this._searchBarH+", countryBar="+this._countryBarH+", activeFilters="+this._activeFiltersH+", subCategories="+this._subCategoriesH+" → scrollHeight="+this.productScrollHeight)},selectCountry:function(t){var e=this;return(0,c.default)((0,o.default)().mark((function n(){var r,i;return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:if(e.selectedCountry!==t){n.next=8;break}return e.selectedCountry="",e.activeFilters=e.activeFilters.filter((function(t){return"country"!==t.key})),n.next=5,e.loadCategoryData("");case 5:return n.next=7,e.loadGoods(1);case 7:return n.abrupt("return");case 8:return r=e.countries.find((function(e){return e.id===t})),i=e.activeFilters.filter((function(t){return"country"!==t.key})),r&&i.push({key:"country",label:r.name}),e.selectedCountry=t,e.activeFilters=i,n.next=15,e.loadCategoryData(t);case 15:return n.next=17,e.loadGoods(1);case 17:case"end":return n.stop()}}),n)})))()},selectCategory:function(t){var e=this;return(0,c.default)((0,o.default)().mark((function n(){return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:return e.activeCategory=t,e.loadSubCategories(t),n.next=4,e.loadGoods(1);case 4:case"end":return n.stop()}}),n)})))()},selectSubCategory:function(t){var e=this;return(0,c.default)((0,o.default)().mark((function n(){return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:return e.activeSubCategory=t,n.next=3,e.loadGoods(1);case 3:case"end":return n.stop()}}),n)})))()},toggleFilter:function(){this.showFilter=!this.showFilter},closeFilter:function(){this.showFilter=!1},toggleFilterTag:function(t){var e=t.groupIndex,n=t.tagId,r=(0,i.default)(this.filterGroups),a=r[e];if(a){var o=(0,i.default)(a.selected),c=o.indexOf(n);c>-1?o.splice(c,1):o.push(n),a.selected=o,this.filterGroups=r}},removeFilter:function(t){var e=this;return(0,c.default)((0,o.default)().mark((function n(){var r,i,a;return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:if(r=e.activeFilters.filter((function(e){return e.key!==t})),"country"!==t){n.next=9;break}return e.selectedCountry="",e.activeFilters=r,n.next=6,e.loadCategoryData("");case 6:return n.next=8,e.loadGoods(1);case 8:return n.abrupt("return");case 9:if(0!==t.indexOf("tag_")){n.next=17;break}return i=t.replace("tag_",""),a=e.filterGroups.map((function(t){return String(t.id)===String(i)?Object.assign({},t,{selected:[]}):t})),e.activeFilters=r,e.filterGroups=a,n.next=16,e.loadGoods(1);case 16:return n.abrupt("return");case 17:e.activeFilters=r;case 18:case"end":return n.stop()}}),n)})))()},clearAllFilters:function(){var t=this;return(0,c.default)((0,o.default)().mark((function e(){var n,r;return(0,o.default)().wrap((function(e){while(1)switch(e.prev=e.next){case 0:if(n=t.activeFilters.some((function(t){return"country"===t.key})),t.activeFilters=[],t.minPrice="",t.maxPrice="",!n&&!t.selectedCountry){e.next=8;break}return t.selectedCountry="",e.next=8,t.loadCategoryData("");case 8:return r=t.filterGroups.map((function(t){return Object.assign({},t,{selected:[]})})),t.filterGroups=r,e.next=12,t.loadGoods(1);case 12:case"end":return e.stop()}}),e)})))()},resetFilters:function(){var t=this.filterGroups.map((function(t){return Object.assign({},t,{selected:[]})}));this.filterGroups=t},confirmFilters:function(t){var e=this;return(0,c.default)((0,o.default)().mark((function n(){var r,i;return(0,o.default)().wrap((function(n){while(1)switch(n.prev=n.next){case 0:return t=t||{},e.minPrice=t.minPrice||"",e.maxPrice=t.maxPrice||"",r=[],i=e.activeFilters.find((function(t){return"country"===t.key})),i&&r.push(i),e.filterGroups.forEach((function(t){if(t.selected.length>0){var e=t.tags.filter((function(e){return t.selected.indexOf(e.id)>-1})).map((function(t){return t.name}));e.length>0&&r.push({key:"tag_"+t.id,label:e.join(",")})}})),e.activeFilters=r,e.showFilter=!1,n.next=11,e.loadGoods(1);case 11:case"end":return n.stop()}}),n)})))()},goDetail:function(t){uni.navigateTo({url:"/pages/product-detail/product-detail?id=".concat(t.id)})}}};e.default=b}).call(this,n("ba7c")["default"])},b691:function(t,e,n){"use strict";var r=n("e100"),i=n.n(r);i.a},c115:function(t,e,n){"use strict";n.r(e);var r=n("0b77"),i=n("4ee1");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("033b");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"c0e80af4",null,!1,r["a"],void 0);e["default"]=c.exports},c240:function(t,e,n){"use strict";var r=n("181d");r(Math,"Math",!0)},c51b:function(t,e,n){var r=n("c86c");e=r(!1),e.push([t.i,'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n/* 右侧内容区 - 上下分:顶部条件 + 下方滚动\n 不再依赖 flex:1 撑高度,scroll-view 用父组件传入的固定高度 */.right-content[data-v-b2389a9e]{display:flex;flex-direction:column;background:#ececec /* UI-0012: 与页面大背景统一为 #ECECEC */}\n/* 顶部子分类条件 - padding 6px 10px 圆角 60px */.sub-categories[data-v-b2389a9e]{display:flex;flex-wrap:wrap;gap:%?16?%;padding:%?20?% %?20?%;background:#fff;border-bottom:%?1?% solid #eee}.sub-cat-item[data-v-b2389a9e]{padding:%?12?% %?20?%;background:#fff;border-radius:%?60?%;font-size:%?24?%;font-weight:500;color:#000;line-height:1.2}\n/* 选中态 - 白底+橙色边框0.5px+橙色文字 */.sub-cat-item.active[data-v-b2389a9e]{border:%?1?% solid #ff6800;color:#ff6800}\n/* 滚动商品区 - 高度由父组件通过 :style 传入(px 固定值),\n 符合 uni-app / 微信小程序官方文档「scroll-y 需给固定 height」的要求 */.product-scroll[data-v-b2389a9e]{width:100%}\n/* 商品网格 - 固定2列 grid,不会因商品个数变化而拉伸占行 */.product-grid[data-v-b2389a9e]{display:grid;grid-template-columns:repeat(2,1fr);gap:%?16?%;padding:%?20?%}.product-card[data-v-b2389a9e]{min-width:0;background:#fff;border-radius:%?12?%;overflow:hidden;box-sizing:border-box}\n/* 图片 135x120 - 用 padding-bottom 撑比例 */.product-img[data-v-b2389a9e]{position:relative;width:100%;padding-bottom:88.89%; /* 120/135 */background:#f7f7f7;overflow:hidden}.product-image[data-v-b2389a9e]{position:absolute;top:0;left:0;width:100%;height:100%}\n/* 商品信息 - 内边距 10px */.product-info[data-v-b2389a9e]{padding:%?20?%}\n/* 商品名 13px Medium #000000 */.product-name[data-v-b2389a9e]{display:block;font-size:%?26?%;font-weight:500;color:#000;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n/* 价格行 - 价格 + "起"字横向排列 */.price-row[data-v-b2389a9e]{display:flex;align-items:baseline;margin-top:%?16?%}\n/* 价格 15px Heavy #FF0000 红色 */.product-price[data-v-b2389a9e]{font-size:%?30?%;font-weight:900;color:red;line-height:1.2}\n/* "起"字 - 比价格字体小,其他样式相同 */.price-unit[data-v-b2389a9e]{font-size:%?22?%;font-weight:900;color:red;line-height:1.2;margin-left:%?4?%}\n/* 国家标签 - 价格右侧,国旗图标 + 国家名 */.country-tag[data-v-b2389a9e]{display:inline-flex;align-items:center;gap:%?6?%;margin-left:%?12?%;padding:%?4?% %?12?%;background:#f7f7f7;border-radius:%?20?%;max-width:50%;box-sizing:border-box;overflow:hidden}.country-tag-icon[data-v-b2389a9e]{width:%?28?%;height:%?20?%;border-radius:%?4?%;flex-shrink:0}.country-tag-name[data-v-b2389a9e]{font-size:%?20?%;font-weight:500;color:#666;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n/* 底部加载提示 */.load-tip[data-v-b2389a9e]{padding:%?32?%;text-align:center;font-size:%?24?%;color:#999}\n/* 空状态 */.product-empty[data-v-b2389a9e]{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:%?120?% 0}.empty-icon[data-v-b2389a9e]{font-size:%?96?%;margin-bottom:%?24?%}.empty-text[data-v-b2389a9e]{font-size:%?26?%;color:#999}',""]),t.exports=e},c68c:function(t,e,n){"use strict";n.r(e);var r=n("5fa4"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},cb1a:function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("5c47");var r={name:"ActiveFilters",props:{list:{type:Array,default:function(){return[]}}},mounted:function(){var t=this;this.$nextTick((function(){return t.emitHeight()}))},watch:{"list.length":function(){var t=this;this.$nextTick((function(){return t.emitHeight()}))}},methods:{emitHeight:function(){var t=this,e=uni.createSelectorQuery().in(this);e.select("#activeFilters").boundingClientRect(),e.exec((function(e){e&&e[0]?t.$emit("height-change",e[0].height):t.$emit("height-change",0)}))},onOpen:function(){this.$emit("open")},onRemove:function(t){this.$emit("remove",t)},onClearAll:function(){this.$emit("clear-all")}}};e.default=r},cbae:function(t,e,n){"use strict";n.r(e);var r=n("dda1"),i=n("84e8");for(var a in i)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return i[t]}))}(a);n("256a");var o=n("828b"),c=Object(o["a"])(i["default"],r["b"],r["c"],!1,null,"44522978",null,!1,r["a"],void 0);e["default"]=c.exports},d8da:function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("5c47"),n("a1c1"),n("5ef2"),n("f7a5"),n("e838");var r={name:"FilterDrawer",props:{visible:{type:Boolean,default:!1},groups:{type:Array,default:function(){return[]}}},data:function(){return{minPrice:"",maxPrice:"",minError:!1,maxError:!1,statusBarHeight:20}},created:function(){try{var t=uni.getSystemInfoSync(),e=t.statusBarHeight;this.statusBarHeight="number"===typeof e&&e>0?e:20}catch(n){this.statusBarHeight=20}},methods:{sanitizePrice:function(t){if(""===t||null===t||void 0===t)return"";var e=String(t).replace(/[^\d.]/g,""),n=e.indexOf(".");return n>-1&&(e=e.slice(0,n+1)+e.slice(n+1).replace(/\./g,"")),e},validateSingle:function(t){var e=this.sanitizePrice(this[t]);if(this[t]=e,""===e)return!0;var n=parseFloat(e);return!(isNaN(n)||n<0)||(uni.showToast({title:"价格不能小于0",icon:"none"}),this[t]="",!1)},flashError:function(t){var e=this;this[t]=!0,setTimeout((function(){e[t]=!1}),1500)},onMinBlur:function(){if(this.validateSingle("minPrice")){var t=parseFloat(this.minPrice),e=parseFloat(this.maxPrice);""!==this.minPrice&&""!==this.maxPrice&&t>e&&(this.minPrice=this.maxPrice,this.flashError("minError"),uni.showToast({title:"最低价不能大于最高价,已自动调整",icon:"none"}))}},onMaxBlur:function(){if(this.validateSingle("maxPrice")){var t=parseFloat(this.minPrice),e=parseFloat(this.maxPrice);""!==this.maxPrice&&""!==this.minPrice&&e-1},onToggle:function(t,e){this.$emit("toggle-tag",{groupIndex:t,tagId:e})},onReset:function(){this.minPrice="",this.maxPrice="",this.minError=!1,this.maxError=!1,this.$emit("reset")},onConfirm:function(){this.validatePrices()&&this.$emit("confirm",{minPrice:this.minPrice,maxPrice:this.maxPrice})},onClose:function(){this.$emit("close")}}};e.default=r},dd5a:function(t,e,n){"use strict";var r=n("fb2e"),i=n.n(r);i.a},dda1:function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-scroll-view",{staticClass:"sidebar",attrs:{"scroll-y":!0}},t._l(t.list,(function(e,r){return n("v-uni-view",{key:r,staticClass:"sidebar-item",class:{active:t.activeId===e.id},on:{click:function(n){arguments[0]=n=t.$handleEvent(n),t.selectItem(e.id)}}},[t.activeId===e.id?n("v-uni-view",{staticClass:"sidebar-indicator"}):t._e(),n("v-uni-text",{staticClass:"sidebar-text"},[t._v(t._s(e.name))])],1)})),1)},i=[]},e100:function(t,e,n){var r=n("4cd5");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("3508c482",r,!0,{sourceMap:!1,shadowMode:!1})},e945:function(t,e,n){"use strict";n.r(e);var r=n("257f"),i=n.n(r);for(var a in r)["default"].indexOf(a)<0&&function(t){n.d(e,t,(function(){return r[t]}))}(a);e["default"]=i.a},ec41:function(t,e,n){"use strict";n.d(e,"b",(function(){return r})),n.d(e,"c",(function(){return i})),n.d(e,"a",(function(){}));var r=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("v-uni-view",{staticClass:"products-page",style:{height:t.windowHeight+"px"}},[n("SearchBar",{attrs:{keyword:t.searchKeyword},on:{input:function(e){arguments[0]=e=t.$handleEvent(e),t.onSearchInput.apply(void 0,arguments)},blur:function(e){arguments[0]=e=t.$handleEvent(e),t.onSearchBlur.apply(void 0,arguments)},search:function(e){arguments[0]=e=t.$handleEvent(e),t.onSearchBlur.apply(void 0,arguments)},"height-change":function(e){arguments[0]=e=t.$handleEvent(e),t.onSearchBarHeight.apply(void 0,arguments)}}}),n("CountryBar",{attrs:{list:t.countries,"selected-id":t.selectedCountry},on:{select:function(e){arguments[0]=e=t.$handleEvent(e),t.selectCountry.apply(void 0,arguments)},"height-change":function(e){arguments[0]=e=t.$handleEvent(e),t.onCountryBarHeight.apply(void 0,arguments)}}}),n("v-uni-view",{staticClass:"content-body"},[n("CategorySidebar",{attrs:{list:t.categories,"active-id":t.activeCategory},on:{select:function(e){arguments[0]=e=t.$handleEvent(e),t.selectCategory.apply(void 0,arguments)}}}),n("v-uni-view",{staticClass:"right-wrap"},[n("ActiveFilters",{attrs:{list:t.activeFilters},on:{open:function(e){arguments[0]=e=t.$handleEvent(e),t.toggleFilter.apply(void 0,arguments)},remove:function(e){arguments[0]=e=t.$handleEvent(e),t.removeFilter.apply(void 0,arguments)},"clear-all":function(e){arguments[0]=e=t.$handleEvent(e),t.clearAllFilters.apply(void 0,arguments)},"height-change":function(e){arguments[0]=e=t.$handleEvent(e),t.onActiveFiltersHeight.apply(void 0,arguments)}}}),n("ProductGrid",{attrs:{list:t.products,"sub-categories":t.subCategories,"active-sub-id":t.activeSubCategory,"has-more":t.hasMore,loading:t.loadingMore,"scroll-top-val":t.scrollTopVal,"scroll-height":t.productScrollHeight,"show-country-tag":!t.selectedCountry},on:{"sub-select":function(e){arguments[0]=e=t.$handleEvent(e),t.selectSubCategory.apply(void 0,arguments)},"product-click":function(e){arguments[0]=e=t.$handleEvent(e),t.goDetail.apply(void 0,arguments)},"scroll-to-lower":function(e){arguments[0]=e=t.$handleEvent(e),t.onScrollToLower.apply(void 0,arguments)},"sub-height-change":function(e){arguments[0]=e=t.$handleEvent(e),t.onSubCategoriesHeight.apply(void 0,arguments)}}})],1)],1),n("FilterDrawer",{attrs:{visible:t.showFilter,groups:t.filterGroups},on:{close:function(e){arguments[0]=e=t.$handleEvent(e),t.closeFilter.apply(void 0,arguments)},"toggle-tag":function(e){arguments[0]=e=t.$handleEvent(e),t.toggleFilterTag.apply(void 0,arguments)},reset:function(e){arguments[0]=e=t.$handleEvent(e),t.resetFilters.apply(void 0,arguments)},confirm:function(e){arguments[0]=e=t.$handleEvent(e),t.confirmFilters.apply(void 0,arguments)}}})],1)},i=[]},f880:function(t,e,n){"use strict";n("6a54"),Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0,n("64aa");var r={name:"CategorySidebar",props:{list:{type:Array,default:function(){return[]}},activeId:{type:[String,Number],default:"all"}},methods:{selectItem:function(t){this.$emit("select",t)}}};e.default=r},fb2e:function(t,e,n){var r=n("4e2e");r.__esModule&&(r=r.default),"string"===typeof r&&(r=[[t.i,r,""]]),r.locals&&(t.exports=r.locals);var i=n("967d").default;i("ae77e90c",r,!0,{sourceMap:!1,shadowMode:!1})}}]); \ No newline at end of file From 01fe444f93d51dc106e76e5abb874d7ee48357b7 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Tue, 1 Sep 2026 19:00:24 +0800 Subject: [PATCH 02/22] docs: add category sort reference table (country > level2 > level3 order) --- 排序表.md | 233 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 排序表.md diff --git a/排序表.md b/排序表.md new file mode 100644 index 0000000..1674419 --- /dev/null +++ b/排序表.md @@ -0,0 +1,233 @@ +全部 +# 工厂直发国家/地区列表 + +# 美国 +## 男士T恤 +### DG001 180G纯棉T恤 (JSA002) +### DG004 230G水洗T恤(JSA003) +### DG013 230G脏洗T恤 +### JSA006 全棉重磅T恤 +### JSS001 美国足球球衣T恤 +### DG015 180G纯棉T恤 +### C1717 207G重磅纯棉T恤 +## 女士T恤 +### DG100 180G女士彩棉T恤 +### DG101 180G纯棉女士露脐T恤 +### DG102 高弹力女款露脐 +### TDG110 180G大码彩棉女T恤 +### DG120 牛奶丝T恤 +### DG150 180G纯棉女士T恤 +### DG502 180G女士圆领长袖T恤 +### DG601 牛奶丝裙子 +### JSA008 180G女士短装T恤 +### JSA011 冰丝螺纹V领女士T恤 +### JSE008 180g女士水洗露脐装 +## 童装T恤 +### DG012 180G青少年纯棉T恤 +### DG014 190G小童T恤 +### DG301 190G纯棉哈哈衣 +### DG503 190G小童长袖 +### T5000B 180G吉尔丹童装T恤 +### JSA004 190G牛奶丝小童T恤 +## 男士短裤 +### DG201 300G脏洗短裤 +### DG202 260G水洗短裤(JSD007) +### DG206 240G涤纶休闲短裤 +### JSD001 180G高弹纤维男士短裤 +## 女士短裤 +### DG205 牛奶丝短裤 +### JSD002 180G高弹纤维女士短裤 +### JSD003 女士短款瑜伽运动裤 +### JSD004 女士高弹中长款瑜伽运动裤 +### JSD005 女士高弹瑜伽长裤 +## 童装短裤 +### DG204 190G小童短裤 +## 男士背心 +### VS001 180G男款背心(JSA005) +### VS003 210G水洗背心 (JSA007) +## 女士背心 +### VS002 180G女款彩棉背心 +### VS004 V领彩棉背心 +### JSA009 180G女士镂空背心 +## 男士卫衣 +### DF009 270G连帽卫衣 +### DF010 400G水洗做旧连帽卫衣 +### JSB001 280G薄绒男士带帽卫衣 +### JSB002 380G薄绒男士迷彩卫衣 +### JSB003 380G男士薄绒圆领卫衣 +## 女士卫衣 +### DG501 250G女士圆领卫衣 +## 童装卫衣 +### DG504 280G小童圆领卫衣 +## 男士长裤 +### DG210 280G高级抓绒慢跑卫裤 +## 内衣 +### DG170G170G女士无痕三角内裤 +### JSA010 270G高弹力女士抹胸 +### JSE001 180G女士无痕内衣 +## 家居 +### USIU002 280G法兰绒毛毯 +### USIU005 法兰绒地垫/科技布地垫 +### USIU012 天鹅绒地毯 +## 配饰 +### YSM01 7X7全棉磨毛帽子 +### YSM02 全棉斜纹拼色帽子(黑+卡其) +# 英国 +## 男士T恤 +### GBTM001 180g纯棉T恤 +### GBTM010 成人POLO衫 +### GBTM011长袖T +### GBTM013 230G水洗T恤 +## 女士T恤 +### GBTF004 180g女装纯棉T恤 +## 男士卫衣 +### GBHM002 280g圆领卫衣 +### GBHM003 280g连帽卫衣 +## 童装T恤 +### GBTK005 180g童装纯棉T恤 +### GBTK008 190g童装POLO短袖 +### GBTK012 婴儿长袖哈哈衣 +## 童装卫衣 +### GBHK006 280g童装圆领卫衣 +### GBHK007 280g童装连帽卫衣 +## 家居配饰 +### GBIU014 280g法兰绒毛毯 +### GBIU015 超细纤维沙滩巾 +### GBIU016 条形桌垫地垫 +## 印花烫贴 +### GBIU009 定制立体印花烫画贴 +## 桌面用品 +### GBIU017 鼠标垫 +# 日本 +## 男士T恤 +### JPTM001 200g纯棉T恤 +### JPTM004 250g纯棉渐变T恤 +### JPTM006 200g纯棉长袖 +### JPTM007 230g水洗T恤 +## 童装T恤 +### JPTK015 200g童装纯棉T恤 +## 男士背心 +### JPTM008 纯棉背心 +### JPTM002 230g水洗背心 +## 男士卫衣 +### JPHM009 280g连帽卫衣 +### JPHM010 280g圆领卫衣 +### JPPX012 280g连帽卫衣+卫裤套装 +### JPPX013 圆领卫衣+卫裤套装 +## 下装(短裤 & 长裤) +### JPSM018 240G华棉短裤 +### JPPM011 男士/女士320克华棉卫裤 +## 家居配饰 +### JPIU020 沙滩巾 +### JPIU021 280G法兰绒毛毯 +### JPIU014 帆布袋 +# 墨西哥 +## 男士T恤 +### METB001 180g纯棉男士T恤 +### PET001 男士涤纶T恤 +### MESXT001 230g水洗T恤 +## 女士T恤 +### METP001 180g纯棉女装T恤 +## 童装T恤 +### METN001 180g纯棉童装T恤 +## 男士卫衣 +### HM01 280g连帽卫衣 +## 男士背心 +### TBB001 篮球圆领背心 +## 下装(短裤 & 长裤) +### SFB 运动短裤 +# 巴西 +## 男士T恤 +### BRTM001 180g纯棉T恤 +### BRTM002 水洗T恤 +## 女士T恤 +### BRTF004 女款紧身露背T恤 +### BRTF007 女士短款露脐T恤 +# 中东 +## 男士T恤 +### SATU001 180g男装纯棉T恤 +## 女士T恤 +### SATU001 180g女装纯棉T恤 +## 男士卫衣 +### SAHM003 270g连帽卫衣 +## 童装T恤 +### SATK004 180g童装纯棉T恤 +# 波兰 +## 男士T恤 +### PLTM001 190g纯棉T恤(QLTC9000) +### PLTM031 大码纯棉T恤(QLTC9000) +### PLTM008 190G纯棉彩色T恤(QLTC9000) +### PLTM006 230G水洗T恤(PLTM006) +### PLTM024 水洗破洞T恤(SS508) +## 女士T恤 +### PLTF004 女款紧身T恤(SS5015) +### PLTF026 女款修身长袖T恤(WT01180) +### PLTF027 女款插肩袖短款(SST507) +### PLTF028 女款露脐T恤(SST508) +## 男士卫衣 +### PLHM002 圆领卫衣(TWZ360) +### PLHM003 连帽卫衣(MWZKD360) +## 童装T恤 +### PLTK005 童装纯棉T恤(KT01180) +### PLHM012 女士童装短款纯棉T恤(SST506) +### PLHM013 女士童插肩短袖T恤(SST5010) +### PLTK015 女士童短款落肩纯棉T恤(DTG180) +### PLTK016 童装纯色插肩短袖T恤(DTG180) +## 女士卫衣 +### PLHF029 女士短款长袖帽衫卫衣(SS513) +## 下装(短裤 & 长裤) +### PLHM011 女款短裤(SS510250) +### PLPM014 男式卫裤(WKC260) +### PLSU025 男女同款五分裤(QP01280) +## 童装卫衣 +### PLHM009 男女童同款抓毛圆领卫衣(TGZ360) +### PLHM010 男女童同款抓毛连帽卫衣(MGZ360) +## 男士夹克 +### PLJM030 牛仔外套(DKJKT0010) +# 西班牙 +## 男士T恤 +### ESTM002 190g纯棉T恤 +## 童装T恤 +### ESTK005 童装纯棉T恤 +## 男士卫衣 +### ESHM003 圆领卫衣 +### ESHM004 连帽卫衣 +# 德国 +## 男士T恤 +### DETM001 180g纯棉T恤 +### DETM002 230水洗T恤 +## 男士卫衣 +### DEHM003 250g连帽有绳卫衣 +## 下装(短裤 & 长裤) +### DEPM004 255g卫裤 +# 意大利 +## 男士T恤 +### ITTM001 180g纯棉T恤 +## 女士T恤 +### ITFM005 180G女士纯棉短袖T恤 +## 童装T恤 +### ITTK002 150G童装圆领短袖T恤 +# 加拿大 +## 男士T恤 +### CATM001 180g纯棉T恤 +### CATM006 180G成人足球运动服 +### CATM007 230G纯棉水洗T恤 +## 男士卫衣 +### CAHM002 280g连帽卫衣 +# 澳大利亚 +## 男士T恤 +### AUTM001 180g纯棉T恤 +### AUTM002 220g水洗T恤 +## 男士背心 +### AUTM003 200g纯棉背心 +# 韩国 +## 男士T恤 +### KRTM001 200g纯棉T恤 +### KRTM004 200G纯棉长袖 +## 男士卫衣 +### KRHM002 圆领卫衣 +### KRHM003 连帽卫衣 +# 中国(国内工厂) +## 男士T恤 +### 180g纯棉T恤 +### 230g水洗T恤 From 88f60ba2e2936c485ead505573e49e2dd82465f8 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Tue, 1 Sep 2026 19:26:50 +0800 Subject: [PATCH 03/22] docs: add category-sort implementation plan (single categoryId, dual-path filter) --- plans/feature/category-sort-feature.md | 727 +++++++++++++++++++++++++ 1 file changed, 727 insertions(+) create mode 100644 plans/feature/category-sort-feature.md diff --git a/plans/feature/category-sort-feature.md b/plans/feature/category-sort-feature.md new file mode 100644 index 0000000..5548ab2 --- /dev/null +++ b/plans/feature/category-sort-feature.md @@ -0,0 +1,727 @@ +# 分类排序(category-tree-sort)实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `GET /public/goods` 默认排序变为「国家 → 二级分类 → 三级分类(款)→ good_priority」,`GET /public/categories` 内容源切换为新树(国家根→二级→三级款),支持 1/2/3 级任意筛选组合,接口输入输出 DTO 结构完全不变。 + +**Architecture:** `categories` 新增 `sort_order`(二/三级顺序)与 `country_id`(树根关联国家,一级顺序复用 `countries.sort_order`)。`getGoods` 内存中按四层键排序(量级 295,分组/分页本就内存执行);`categoryId` 仍是单个 id(DTO 不动)+ 双通道过滤(新树走 `origin_goods.sds_category_id`,老树回退 `goods.category_id`)。数据一次性回填脚本解析 `排序表.md`。 + +**Tech Stack:** NestJS + Prisma + PostgreSQL + Jest(真实库集成测试) + +**已确认的决策:** +1. **前端调用形态(真实 URL 已确认)**:无论选中哪一层,`categoryId` 永远只传**一个**节点 id(DTO 完全不动,无逗号多 id),可选伴随 `countryId`。goods 全挂老树,老树节点天然跨国家: + - 只选老树节点:`?categoryId=659`(女士服装,跨国家)→ 按 ①国家→②新树二级→③新树款→④priority 排("美国的女士T恤全排完到下个国家") + - 选到老树叶子:`?categoryId=666`(女士/裤装,跨国家)→ 同上 + - 国家 + 分类:`?countryId=49&categoryId=658` → 该国内按 ②③④ + - 只选国家:`?countryId=49` → 同上 + - 什么都不选 → 全局 ①②③④ + (以上全部由"绝对排序键 + 子集过滤"自然满足,无特殊分支逻辑) +2. 款内顺序复用现有 `good_priority`(只需保证三级款之间顺序正确) +3. 「中东」改为「沙特」写入 countries;防晒衣(good_id=137) 不处理(排序沉底) +4. `/public/categories` 切换为新树内容源(结构不变);空国家/空二级(productCount=0)不返回,与旧行为、`getCountries` 口径一致。切换后前端将传新树 id(某国的二级/款),`categoryId` 过滤走双通道兼容两种 id + +**硬性约束:** +- 所有 public 接口入参、出参 DTO 字段**一个都不能变**,只改内部逻辑 +- 已有测试必须全部通过,不允许跳过 +- 共享开发库(deploy-v2-postgres-1):禁止 reset;迁移用「手写 SQL + db execute + migrate resolve」,先跑 `prisma migrate status` +- 回滚保障:`backups/inkreach_snapshot_20260901_185233.dump`(全库快照已做);代码回滚 = 删 feature 分支 + +**执行环境(宿主机无 node,统一用容器跑):** + +```bash +# 测试 / prisma 命令统一模板(挂载源码 + 加入 postgres 网络) +docker run --rm --network deploy-v2_default \ + -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ + -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx <命令> +``` + +**排序键定义(getGoods DEFAULT):** + +``` +① countries.sort_order(经 goods.country_id) 缺失 → 排最后 +② 叶子.parent_category_id 的 sort_order(二级) 缺失 → 排最后 +③ 叶子自身 sort_order(三级,经 origin_goods.sds_category_id)缺失 → 排最后 +④ good_priority desc, id asc(现有字段,不变) +``` + +--- + +### Task 1: Schema 迁移 — categories 加 sort_order + country_id + +**Files:** +- Modify: `apps/api/prisma/schema.prisma`(Category model 约 L157-174、Country model 约 L141-154) +- Create: `apps/api/prisma/migrations/20260901000000_add_category_sort_fields/migration.sql` + +- [ ] **Step 1.1: 先检查迁移状态(共享库防漂移)** + +```bash +docker run --rm --network deploy-v2_default \ + -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ + -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx prisma migrate status +``` +Expected: `Database schema is up to date!`。若报 drift/已存在类错误,按 AGENTS.md 经验用 `migrate resolve` 处理,禁止 reset。 + +- [ ] **Step 1.2: 修改 schema.prisma** + +Category model:`sdsCategoryId` 行后新增两行,并新增 relation: + +```prisma + sortOrder Int @default(0) @map("sort_order") + countryId BigInt? @map("country_id") + country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction) +``` + +Country model:`families ProductFamily[]` 后新增: + +```prisma + categories Category[] +``` + +Category model 底部 `@@index([parentCategoryId])` 后新增: + +```prisma + @@index([countryId]) +``` + +- [ ] **Step 1.3: 手写迁移 SQL(应用与登记分离,避免 shadow DB)** + +```bash +mkdir -p apps/api/prisma/migrations/20260901000000_add_category_sort_fields +``` + +创建 `apps/api/prisma/migrations/20260901000000_add_category_sort_fields/migration.sql`: + +```sql +-- AlterTable +ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "categories" ADD COLUMN "country_id" BIGINT; + +-- ForeignKey +ALTER TABLE "categories" ADD CONSTRAINT "categories_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE SET NULL ON UPDATE NO ACTION; + +-- CreateIndex +CREATE INDEX "categories_country_id_idx" ON "categories"("country_id"); +``` + +- [ ] **Step 1.4: 应用 SQL、登记迁移、重新生成 client** + +```bash +docker run --rm --network deploy-v2_default \ + -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ + -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \ + npx prisma db execute --file prisma/migrations/20260901000000_add_category_sort_fields/migration.sql + +docker run --rm --network deploy-v2_default \ + -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ + -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \ + npx prisma migrate resolve --applied 20260901000000_add_category_sort_fields + +docker run --rm --network deploy-v2_default \ + -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ + -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx prisma generate +``` +Expected: 三条命令均成功。 + +- [ ] **Step 1.5: 验证列存在** + +```bash +docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "\d categories" | grep -E 'sort_order|country_id' +``` +Expected: 两列均存在(`sort_order integer not null default 0`、`country_id bigint`)。 + +- [ ] **Step 1.6: Commit** + +```bash +git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/20260901000000_add_category_sort_fields +git commit -m "feat(api): add sort_order and country_id to categories" +``` + +--- + +### Task 2: 数据回填脚本 — 排序表 → sort_order + 沙特 + 根关联国家 + +**Files:** +- Create: `apps/api/prisma/backfill-category-sort-order.ts` + +**行为(幂等,可重复执行):** +1. 解析仓库根 `排序表.md`:`# ` = 一级国家(跳过「全部」「# 工厂直发国家/地区列表」)、`## ` = 二级、`### ` = 三级 +2. 国家名 → 新树根 category 映射(写死映射表);`中东` 条目:countries upsert「沙特」+ 根重命名 `沙特本地工厂直发` +3. 根节点写入 `country_id`(关联 countries 行;`国内工厂` 无对应国家行 → 保持 null) +4. 每个根内:二级按出现顺序写 `sort_order=1..n`;三级在所属二级范围内写 `sort_order=1..n` +5. 匹配规则:先按规范化名称(trim+压缩空白)精确匹配;失败再按「首个货号 token」前缀匹配(如 `GBTM011长袖T` 命中 `GBTM011长袖T`);**跨根禁止匹配** +6. 未匹配条目打印 `UNMATCHED:` 清单并退出码 1;`中国(国内工厂)` 跳过 countries 部分,sort_order 照常回填 +7. countries.sort_order 按表内顺序 1..13 重写(美国 英国 日本 墨西哥 巴西 沙特 波兰 西班牙 德国 意大利 加拿大 澳大利亚 韩国) + +- [ ] **Step 2.1: 编写脚本** + +```ts +/** + * 一次性回填:解析 排序表.md → categories.sort_order(二/三级)+ country_id(根) + * + countries.sort_order + 沙特国家行 + 中东根更名 + * 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复 + */ +import { PrismaClient } from '@prisma/client'; +import { readFileSync } from 'fs'; + +const prisma = new PrismaClient(); + +// 排序表国家名 → 新树根分类名 +const ROOT_MAP: Record = { + '美国': '美国工厂直发', + '英国': '英国本地直发', + '日本': '日本本地工厂直发', + '墨西哥': '墨西哥工厂本地直发', + '巴西': '巴西本地工厂直发', + '中东': '中东本地工厂直发', // 同时更名沙特 + '波兰': '欧洲波兰工厂直发', + '西班牙': '欧洲西班牙工厂直发', + '德国': '欧洲德国工厂本地直发', + '意大利': '欧洲意大利工厂直发', + '加拿大': '加拿大本地工厂直发', + '澳大利亚': '澳大利亚本地工厂直发', + '韩国': '韩国本地直发', + '中国(国内工厂)': '国内工厂', +}; + +const norm = (s: string) => s.trim().replace(/\s+/g, ''); +const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0]; + +async function main() { + const raw = readFileSync('/opt/inkreach-v2/排序表.md', 'utf8'); + let country: string | null = null; + let l2: string | null = null; + const tree: Array<{ country: string; l2: string; l3: string }> = []; + const countryOrder: string[] = []; + for (const line of raw.split('\n')) { + const t = line.trim(); + if (t.startsWith('# ')) { + const name = t.slice(2).trim(); + if (name === '全部' || name.includes('工厂直发国家')) continue; + country = name; + if (!countryOrder.includes(name)) countryOrder.push(name); + } else if (t.startsWith('## ') && country) { + l2 = t.slice(3).trim(); + } else if (t.startsWith('### ') && country && l2) { + tree.push({ country, l2, l3: t.slice(4).trim() }); + } + } + + const roots = await prisma.category.findMany({ + where: { parentCategoryId: null, sdsCategoryId: { not: null } }, + include: { children: { include: { children: true } } }, + }); + const rootByName = new Map(roots.map((r) => [r.categoryName, r])); + const unmatched: string[] = []; + + // 1) countries:沙特 upsert + 顺序重写 + const countryIdByName = new Map(); + for (let i = 0; i < countryOrder.length; i++) { + const name = countryOrder[i]; + const dbCountry = name === '中东' ? '沙特' : name; + const sortOrder = i + 1; + const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } }); + if (existing) { + await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } }); + countryIdByName.set(dbCountry, existing.id); + } else if (dbCountry === '沙特') { + const created = await prisma.country.create({ data: { countryName: '沙特', sortOrder } }); + countryIdByName.set('沙特', created.id); + } + } + + // 2) 中东根 → 沙特 + const meRoot = rootByName.get('中东本地工厂直发'); + if (meRoot) { + await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } }); + rootByName.set('沙特本地工厂直发', meRoot); + } + + // 3) 根节点关联 country_id + for (const [tableName, rootName] of Object.entries(ROOT_MAP)) { + const root = rootByName.get(tableName === '中东' ? '沙特本地工厂直发' : rootName); + if (!root) { unmatched.push(`ROOT MISS: ${tableName}`); continue; } + const dbCountry = tableName === '中东' ? '沙特' : tableName === '中国(国内工厂)' ? null : tableName; + const cid = dbCountry ? (countryIdByName.get(dbCountry) ?? null) : null; + await prisma.category.update({ where: { id: root.id }, data: { countryId: cid } }); + } + + // 4) 二/三级 sort_order + for (const countryName of countryOrder) { + const rootName = ROOT_MAP[countryName]; + const root = rootByName.get(countryName === '中东' ? '沙特本地工厂直发' : rootName); + if (!root) continue; // 已在 ROOT MISS 记录 + const l2s = root.children; + const l2NamesInOrder: string[] = []; + for (const row of tree) if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2); + for (let i = 0; i < l2NamesInOrder.length; i++) { + const target = l2NamesInOrder[i]; + const mid = l2s.find((m) => norm(m.categoryName) === norm(target)); + if (!mid) { unmatched.push(`L2 MISS: ${countryName} / ${target}`); continue; } + await prisma.category.update({ where: { id: mid.id }, data: { sortOrder: i + 1 } }); + const leaves = mid.children; + const l3Names = tree.filter((r) => r.country === countryName && r.l2 === target).map((r) => r.l3); + for (let j = 0; j < l3Names.length; j++) { + const want = norm(l3Names[j]); + const code = norm(codeOf(l3Names[j])); + const leaf = + leaves.find((l) => norm(l.categoryName) === want) ?? + (code ? leaves.find((l) => norm(l.categoryName).startsWith(code)) : undefined); + if (!leaf) { unmatched.push(`L3 MISS: ${countryName} / ${target} / ${l3Names[j]}`); continue; } + await prisma.category.update({ where: { id: leaf.id }, data: { sortOrder: j + 1 } }); + } + } + } + + if (unmatched.length) { + console.error(`UNMATCHED (${unmatched.length}):\n` + unmatched.join('\n')); + process.exit(1); + } + console.log('backfill done'); +} + +main().finally(() => prisma.$disconnect()); +``` + +- [ ] **Step 2.2: 执行脚本** + +```bash +docker run --rm --network deploy-v2_default \ + -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ + -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \ + npx ts-node prisma/backfill-category-sort-order.ts +``` +Expected: `backfill done`,退出码 0。已知候选 UNMATCHED:美国/内衣 `DG170G170G女士无痕三角内裤`(库内为 `DG701 170G女士无痕三角内裤`,货号前缀 `DG170G170G` 不匹配)→ 将排序表.md 该行改为与库名一致后重跑;其余逐条人工核对。 + +- [ ] **Step 2.3: SQL 验证回填结果** + +```bash +docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c " +select country_name, sort_order from countries order by sort_order;" -c " +select root.category_name, root.country_id, mid.category_name, mid.sort_order, count(leaf.category_id) leaves +from categories root +join categories mid on mid.parent_category_id = root.category_id +left join categories leaf on leaf.parent_category_id = mid.category_id +where root.parent_category_id is null and root.sds_category_id is not null +group by 1,2,3,4 order by 1, mid.sort_order;" | head -70 +``` +Expected: countries 13 行(沙特=6);美国根下 男士T恤=1、女士T恤=2…;各二级下三级 sort_order 连续 1..n;每个新树根 country_id 非空(国内工厂除外)。 + +抽查美国/男士T恤三级顺序: + +```bash +docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -tAc " +select category_name || ' | ' || sort_order from categories +where parent_category_id = (select category_id from categories where category_name='男士T恤' and parent_category_id=(select category_id from categories where category_name='美国工厂直发')) +order by sort_order;" | head -8 +``` +Expected: 第一行 `DG001 180G纯棉T恤 (JSA002) | 1`,第二行 `DG004 230G水洗T恤(JSA003) | 2`。 + +- [ ] **Step 2.4: Commit** + +```bash +git add apps/api/prisma/backfill-category-sort-order.ts +git commit -m "feat(api): backfill category/country sort order from reference table" +``` + +--- + +### Task 3: getGoods 默认排序(TDD) + +**Files:** +- Modify: `apps/api/src/public/public.service.ts`(getGoods 约 L186-271、PUBLIC_GOOD_LIST_INCLUDE 约 L68-77) +- Test: `apps/api/src/public/public.service.spec.ts`(追加 describe,遵循现有真实库集成测试风格,fixture 带 `stamp` 唯一化) + +- [ ] **Step 3.1: 写失败测试** + +在 `public.service.spec.ts` 追加: + +```ts +describe('getGoods tree-order sorting', () => { + // 结构: 国家A(sort=1)>MidA>LeafA1(sort=1,2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1 + // 期望默认顺序: A款1 -> A款2 -> B款1,同款内 priority desc;B 的 priority=99 也不能越级 + const stamp2 = `treeorder-${Date.now()}`; + let orderedGoodIds: bigint[] = []; + + beforeAll(async () => { + const cA = await prisma.country.create({ data: { countryName: `TA ${stamp2}`, sortOrder: 1 } }); + const cB = await prisma.country.create({ data: { countryName: `TB ${stamp2}`, sortOrder: 2 } }); + const midA = await prisma.category.create({ data: { categoryName: `MidA ${stamp2}`, sdsCategoryId: `ma-${stamp2}`, sortOrder: 1 } }); + const leafA1 = await prisma.category.create({ data: { categoryName: `LeafA1 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: `la1-${stamp2}`, sortOrder: 1 } }); + const leafA2 = await prisma.category.create({ data: { categoryName: `LeafA2 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: `la2-${stamp2}`, sortOrder: 2 } }); + const midB = await prisma.category.create({ data: { categoryName: `MidB ${stamp2}`, sdsCategoryId: `mb-${stamp2}`, sortOrder: 2 } }); + const leafB1 = await prisma.category.create({ data: { categoryName: `LeafB1 ${stamp2}`, parentCategoryId: midB.id, sdsCategoryId: `lb1-${stamp2}`, sortOrder: 2 } }); + + const mk = async (countryId: bigint, sdsCat: string, name: string, priority: number) => { + const og = await prisma.originGood.create({ data: { sdsGoodId: `${name}-${stamp2}`, goodName: name, sdsCategoryId: sdsCat } }); + const fam = await prisma.productFamily.create({ data: { familyName: `f-${name}-${stamp2}`, primaryOriginGoodId: og.id } }); + await prisma.originGood.update({ where: { id: og.id }, data: { familyId: fam.id } }); + return prisma.good.create({ data: { goodName: name, originGoodId: og.id, familyId: fam.id, countryId, categoryId: leafA1.id, goodPriority: priority } }); + }; + const a1Low = await mk(cA.id, `la1-${stamp2}`, 'A1Low', 1); + const a1High = await mk(cA.id, `la1-${stamp2}`, 'A1High', 9); + const a2 = await mk(cA.id, `la2-${stamp2}`, 'A2', 0); + const b1 = await mk(cB.id, `lb1-${stamp2}`, 'B1', 99); + orderedGoodIds = [a1High.id, a1Low.id, a2.id, b1.id]; + }); + + it('DEFAULT: country > l2 > l3 > priority', async () => { + const res = await service.getGoods({ page: 1, pageSize: 50 }); + const idx = res.items.map((i) => BigInt(i.goodId)); + const pos = orderedGoodIds.map((id) => idx.indexOf(id)); + expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中 + expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序 + expect(idx.indexOf(orderedGoodIds[0])).toBeLessThan(idx.indexOf(orderedGoodIds[1])); // 同款内 priority desc + expect(idx.indexOf(orderedGoodIds[1])).toBeLessThan(idx.indexOf(orderedGoodIds[2])); // 款顺序 + expect(idx.indexOf(orderedGoodIds[2])).toBeLessThan(idx.indexOf(orderedGoodIds[3])); // 国家/分类顺序优先于 priority + }); +}); +``` + +- [ ] **Step 3.2: 跑测试确认失败** + +```bash +docker run --rm --network deploy-v2_default -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx jest src/public/public.service.spec.ts -t 'tree-order' +``` +Expected: FAIL(现顺序按 goodPriority desc,B1 会排最前)。 + +- [ ] **Step 3.3: 实现** + +`public.service.ts`: + +1) `PUBLIC_GOOD_LIST_INCLUDE.originGood.select` 增加 `sdsCategoryId: true` + +2) 新增类型与私有方法: + +```ts +interface TreeOrderMeta { + countryOrder: Map; + leafOrder: Map; // key: origin_goods.sds_category_id +} + +private async loadTreeOrderMeta(): Promise { + const [countries, leaves] = await Promise.all([ + this.prisma.country.findMany({ select: { id: true, sortOrder: true } }), + this.prisma.$queryRaw>` + SELECT leaf.sds_category_id, + COALESCE(mid.sort_order, 2147483647) AS c2, + COALESCE(leaf.sort_order, 2147483647) AS c3 + FROM categories leaf + JOIN categories mid ON mid.category_id = leaf.parent_category_id + WHERE leaf.sds_category_id IS NOT NULL AND leaf.sds_category_id <> ''`, + ]); + return { + countryOrder: new Map(countries.map((c) => [c.id.toString(), c.sortOrder])), + leafOrder: new Map(leaves.map((l) => [l.sds_category_id, { c2: Number(l.c2), c3: Number(l.c3) }])), + }; +} + +private compareByTreeOrder(meta: TreeOrderMeta, a: PublicGoodListRow, b: PublicGoodListRow): number { + const MAX = Number.MAX_SAFE_INTEGER; + const key = (g: PublicGoodListRow): [number, number, number, number, number] => [ + meta.countryOrder.get(g.countryId.toString()) ?? MAX, + meta.leafOrder.get(g.originGood.sdsCategoryId)?.c2 ?? MAX, + meta.leafOrder.get(g.originGood.sdsCategoryId)?.c3 ?? MAX, + -g.goodPriority, + Number(g.id), + ]; + const ka = key(a); + const kb = key(b); + for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return ka[i] - kb[i]; + return 0; +} +``` + +3) `getGoods`:DEFAULT 分支的 DB `orderBy` 改为 `[{ id: 'asc' }]`(排序移内存),`findMany` 之后、分组之前插入: + +```ts +if (!query.sort || query.sort === 'DEFAULT') { + const meta = await this.loadTreeOrderMeta(); + rows.sort((a, b) => this.compareByTreeOrder(meta, a, b)); +} +``` +(分组代表行 = 树序第一条,与现有「排序最前为代表」契约一致。) + +- [ ] **Step 3.4: 跑测试确认通过(含既有用例)** + +```bash +docker run --rm ... npx jest src/public/public.service.spec.ts +``` +Expected: 全部 PASS(现有 getGoods 断言若依赖 DEFAULT 全局顺序按新规则修正期望;fixture 多为同国家同分类,一般不受影响)。 + +- [ ] **Step 3.5: Commit** + +```bash +git add apps/api/src/public/public.service.ts apps/api/src/public/public.service.spec.ts +git commit -m "feat(api): order public goods by country > category > leaf > priority" +``` + +--- + +### Task 4: categoryId 新树双通道过滤(TDD) + +**Files:** +- Modify: `apps/api/src/public/public.service.ts`(getGoods categoryId 过滤块,约 L194-196) + +**语义(DTO 完全不动,`categoryId` 仍是单个 id):** +- 传入 id 属于新树(自身/子孙存在 sds_category_id)→ 收集该节点全部子孙叶子的 `sds_category_id` 集合,过滤改为 `where.originGood.sdsCategoryId in 集合`(选二级 = 该二级下所有款) +- 传入 id 属于老树(如 658/659/666)→ 回退现行逻辑 `where.categoryId in collectCategoryDescendants(...)`(老树节点天然跨国家,行为不变) +- 新树 originGood 条件与 minPrice/maxPrice 的 originGood 条件合并为同一对象,互不覆盖 + +**真实调用形态(验收基准):** +- `?categoryId=659`(老树一级,跨国家)→ 全部女士服装商品,按 ①国家→②新树二级→③新树款→④priority +- `?categoryId=666`(老树叶子,跨国家)→ 同上 +- `?countryId=49&categoryId=658` → 英国内按 ②③④ +- 切新树后前端会传新树 id(如某国"男士T恤"或某款)→ 走 sds 通道 + +- [ ] **Step 4.1: 写失败测试** + +```ts +describe('getGoods categoryId filter (dual-path)', () => { + it('new-tree mid id filters via originGood.sdsCategoryId', async () => { + const midA = await prisma.category.findFirst({ where: { categoryName: `MidA ${stamp2}` } }); + const res = await service.getGoods({ page: 1, pageSize: 50, categoryId: midA!.id.toString() }); + const names = res.items.map((i) => i.goodName).filter((n) => n.includes(stamp2)); + expect(names.length).toBe(3); // A1Low/A1High/A2 + expect(names.some((n) => n.startsWith('A2'))).toBe(true); + expect(names.every((n) => !n.startsWith('B1'))).toBe(true); // B1 不属于 MidA + }); + + it('new-tree leaf id filters to that leaf only', async () => { + const leafA2 = await prisma.category.findFirst({ where: { categoryName: `LeafA2 ${stamp2}` } }); + const res = await service.getGoods({ page: 1, pageSize: 50, categoryId: leafA2!.id.toString() }); + const names = res.items.map((i) => i.goodName).filter((n) => n.includes(stamp2)); + expect(names.length).toBe(1); + expect(names[0].startsWith('A2')).toBe(true); + }); + + it('categoryId + minPrice merge originGood filters', async () => { + const midA = await prisma.category.findFirst({ where: { categoryName: `MidA ${stamp2}` } }); + const res = await service.getGoods({ + page: 1, pageSize: 50, categoryId: midA!.id.toString(), minPrice: '999999', + }); + expect(res.items.filter((i) => i.goodName.includes(stamp2)).length).toBe(0); + }); +}); +``` +(老树 id 路径由既有用例覆盖:原始 fixture 的分类无 sds_category_id,走回退分支,行为不变。) + +- [ ] **Step 4.2: 跑测试确认失败** + +同 Task 3 命令,`-t 'categoryId'`。Expected: FAIL(新树 mid/leaf id 走旧路径查空)。 + +- [ ] **Step 4.3: 实现** + +`getGoods` 内替换原 categoryId 过滤块: + +```ts +if (query.categoryId) { + const sds = await this.resolveSdsCategoryFilter(BigInt(query.categoryId)); + if (sds) { + where.originGood = { + ...(where.originGood as Prisma.OriginGoodWhereInput | undefined), + sdsCategoryId: { in: sds }, + }; + } else { + where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) }; + } +} +``` + +新增方法: + +```ts +/** + * 新树过滤:节点自身或子孙存在 sds_category_id → 返回全部子孙(含自身)的 + * sds_category_id 集合(选二级 = 其下所有款);否则返回 null(老树回退)。 + * 根/二级自身的 sds id 不出现在 origin_goods 上,多收集无害。 + */ +private async resolveSdsCategoryFilter(rootId: bigint): Promise { + const ids: bigint[] = [rootId]; + let frontier: bigint[] = [rootId]; + while (frontier.length) { + const children = await this.prisma.category.findMany({ + where: { parentCategoryId: { in: frontier } }, + select: { id: true }, + }); + frontier = children.map((c) => c.id); + ids.push(...frontier); + } + const rows = await this.prisma.category.findMany({ + where: { id: { in: ids }, sdsCategoryId: { not: null } }, + select: { sdsCategoryId: true }, + }); + const sds = rows.map((r) => r.sdsCategoryId!).filter((s) => s !== ''); + return sds.length ? sds : null; +} +``` + +- [ ] **Step 4.4: 跑全部 public 测试确认通过** + +```bash +docker run --rm ... npx jest src/public +``` +Expected: 全部 PASS(老树回退路径保证旧行为不变)。 + +- [ ] **Step 4.5: Commit** + +```bash +git add apps/api/src/public/public.service.ts apps/api/src/public/public.service.spec.ts +git commit -m "feat(api): support new-tree categoryId filter via origin sds category" +``` + +--- + +### Task 5: getCategoriesTree 切换新树(TDD) + +**Files:** +- Modify: `apps/api/src/public/public.service.ts`(getCategoriesTree 约 L85-127) + +**语义:** +- 内容源:新树(根 = `parentCategoryId=null AND sdsCategoryId!=null`),老树不再返回 +- 排序:根按 `country.sortOrder`(null 最后)→ 根 sortOrder;二级/三级按各自 `sort_order` +- `countryId` 过滤:根的 `country_id` 精确匹配(不再按 goods 归属) +- productCount:叶子 = 关联 origin_goods.sds_category_id 的在售族化 goods 数(`family_id IS NOT NULL AND delisted=false`);二级/根 = 后代求和(现有 `buildTree` 语义) +- productCount=0 的二级/根剪掉(空国家不出现,如沙特在配货前不展示) + +- [ ] **Step 5.1: 写失败测试** + +```ts +describe('getCategoriesTree (new tree)', () => { + it('returns new-tree roots ordered by country sort_order with counts', async () => { + const tree = await service.getCategoriesTree(); + const names = tree.map((n) => n.categoryName); + // 新树根在返回中,老树根不在 + expect(names).toContain(`RootA ${stamp2}`); + expect(names).not.toContain(`Pub Cat ${stamp}`); // 老树 fixture(无 sds_category_id) + const rootA = tree.find((n) => n.categoryName === `RootA ${stamp2}`)!; + expect(rootA.children.map((c) => c.categoryName)).toEqual([`MidA ${stamp2}`]); + expect(rootA.children[0].children.length).toBe(2); // LeafA1/LeafA2 + expect(rootA.children[0].children[0].productCount).toBe(2); // A1Low/A1High + expect(rootA.children[0].children[1].productCount).toBe(1); // A2 + expect(rootA.productCount).toBe(3); + // 国家顺序:RootA(sort=1) 在 RootB(sort=2) 前 + expect(names.indexOf(`RootA ${stamp2}`)).toBeLessThan(names.indexOf(`RootB ${stamp2}`)); + }); + + it('countryId filters to that country root', async () => { + const cA = await prisma.country.findFirst({ where: { countryName: `TA ${stamp2}` } }); + // Task 2 回填不会给测试 fixture 关联 country_id,此处手动关联 + const rootA = await prisma.category.findFirst({ where: { categoryName: `RootA ${stamp2}` } }); + await prisma.category.update({ where: { id: rootA!.id }, data: { countryId: cA!.id } }); + const tree = await service.getCategoriesTree(cA!.id.toString()); + expect(tree.map((n) => n.categoryName)).toEqual([`RootA ${stamp2}`]); + }); +}); +``` + +注意:Task 3 fixture 创建根时未设 countryId,本 describe 的 beforeAll 中补齐:RootA→cA、RootB→cB(同上 update 方式),并给 leafA1/leafA2 的 goods 数依赖 Task 3 fixture(A1 两条、A2 一条)。若 describe 执行顺序影响 fixture 创建,将根/二级/叶子与国家的关联移入本 describe 自己的 beforeAll(按 stamp2 查询)。 + +- [ ] **Step 5.2: 跑测试确认失败** + +```bash +docker run --rm ... npx jest src/public/public.service.spec.ts -t 'new tree' +``` +Expected: FAIL(现实现返回老树,`RootA` 不在结果中)。 + +- [ ] **Step 5.3: 实现** + +`getCategoriesTree` 整体替换: + +```ts +async getCategoriesTree(countryId?: string): Promise { + const countryFilter = countryId ? { countryId: BigInt(countryId) } : {}; + const roots = await this.prisma.category.findMany({ + where: { parentCategoryId: null, sdsCategoryId: { not: null }, ...countryFilter }, + orderBy: [{ country: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, { id: 'asc' }], + include: { + children: { + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + include: { children: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] } }, + }, + }, + }); + + const countRows = await this.prisma.$queryRaw>` + SELECT og.sds_category_id AS sds, COUNT(*) AS cnt + FROM goods g + JOIN origin_goods og ON og.origin_good_id = g.origin_good_id + WHERE g.family_id IS NOT NULL + AND og.delisted = false + AND og.sds_category_id IS NOT NULL + ${countryId ? Prisma.sql`AND g.country_id = ${BigInt(countryId)}` : Prisma.empty} + GROUP BY og.sds_category_id`; + const counts = new Map(countRows.map((r) => [r.sds, Number(r.cnt)])); + + const build = (row: (typeof roots)[number]): PublicCategoryNodeDto => { + const children = row.children.map(build).filter((c) => c.productCount > 0); + const own = row.sdsCategoryId ? (counts.get(row.sdsCategoryId) ?? 0) : 0; + const node = PublicCategoryNodeDto.from(row, children); + node.productCount = own + children.reduce((sum, c) => sum + c.productCount, 0); + return node; + }; + return roots.map(build).filter((r) => r.productCount > 0); +} +``` +(原 `buildTree` 若无其他调用方则删除;`Prisma.sql`/`Prisma.empty` 需 import `Prisma`,已有。) + +- [ ] **Step 5.4: 跑全部 public 测试确认通过** + +```bash +docker run --rm ... npx jest src/public +``` +Expected: 全部 PASS。既有 `getCategoriesTree` 用例(老树断言)改为新树断言或按新语义重写。 + +- [ ] **Step 5.5: Commit** + +```bash +git add apps/api/src/public/public.service.ts apps/api/src/public/public.service.spec.ts +git commit -m "feat(api): serve public categories tree from sds country-category tree" +``` + +--- + +### Task 6: 全量验证 + 交付 + +- [ ] **Step 6.1: api 全量测试** + +```bash +docker run --rm --network deploy-v2_default -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx jest +``` +Expected: 全部 PASS。任何失败必须修复(含其他模块被 schema 变更波及的用例)。 + +- [ ] **Step 6.2: verification-before-completion 自检清单** + +- [ ] `prisma migrate status` 干净(无未应用/未登记迁移) +- [ ] 回填 UNMATCHED 清单为空(或已逐条人工处理并记录) +- [ ] 冒烟(真实调用形态):`/public/goods?pageSize=10` DEFAULT = 美国 DG001 族 → DG004 族 …;`/public/goods?categoryId=659`(女士服装,跨国)按国家顺序排;`/public/goods?categoryId=666` 同上;`/public/goods?countryId=49&categoryId=658` 只返回英国男装且按 ②③④;`/public/categories` 返回新树且沙特(空)不出现 +- [ ] DTO 字段逐个对比改动前后(PublicGoodDto / PublicCategoryNodeDto / 分页结构)无增删 +- [ ] `git status` 干净,全部提交 + +- [ ] **Step 6.3: 合并回 refactor/v2** + +```bash +git checkout refactor/v2 && git merge --no-ff feature/category-tree-sort -m "merge: feature/category-tree-sort (public goods tree-order + new category tree)" +git branch -d feature/category-tree-sort +``` +(本仓库 v2 工作线为 `refactor/v2`;不推远端,部署时机由用户确认。) + +- [ ] **Step 6.4: 重建 v2-api 容器使新逻辑生效(部署步骤,执行前向用户确认)** + +```bash +cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --build api +``` + +- [ ] **Step 6.5: 按项目规则沉淀** + +- 更新 `docs/references/structs.md`(categories 新字段、public 排序语义) +- 更新 `docs/references/` 使用文档与 `README.md`(新排序规则、categoryId 多 id 用法) +- 通用经验追加到 `AGENTS.md`(如:共享库迁移「手写 SQL + db execute + resolve」通道、树形排序的「绝对排序键 + 子集过滤」模式) + +--- + +## 范围外(明确不做) + +- `getHomeGoods`(首页位次排序维持 position.indexVal 优先,另行需求再调) +- 防晒衣(good_id=137) 的 sds_category_id=2393 修复(排序中沉底) +- admin 后台分类拖拽排序 UI(本期用脚本回填;后续如需可视化调整再立项) +- 中东根更名后 SDS 同步覆盖名称的持久对抗(重跑回填脚本即可恢复) From 6973f863b5551608f4cf7045038183cd1a28fdc8 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 09:27:08 +0800 Subject: [PATCH 04/22] chore: ignore local db snapshots (backups/) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1876ca3..57ba706 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ deploy/data-dump.json uploads/ .pnpm-store/ data-cleaning/ -backups/.zcode/ +backups/ From 3d431f8375e8f2512bba12538bb2ab129a0a0241 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 09:36:17 +0800 Subject: [PATCH 05/22] docs(plan): add backup/rollback section, fix backfill run channel and sink semantics --- plans/feature/category-sort-feature.md | 49 +++++++++++++++++++++----- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/plans/feature/category-sort-feature.md b/plans/feature/category-sort-feature.md index 5548ab2..6d6f629 100644 --- a/plans/feature/category-sort-feature.md +++ b/plans/feature/category-sort-feature.md @@ -24,7 +24,10 @@ - 所有 public 接口入参、出参 DTO 字段**一个都不能变**,只改内部逻辑 - 已有测试必须全部通过,不允许跳过 - 共享开发库(deploy-v2-postgres-1):禁止 reset;迁移用「手写 SQL + db execute + migrate resolve」,先跑 `prisma migrate status` -- 回滚保障:`backups/inkreach_snapshot_20260901_185233.dump`(全库快照已做);代码回滚 = 删 feature 分支 +- 回滚保障(均已就绪,见文末「备份与回滚」): + - DB 全库快照:`backups/inkreach_snapshot_20260901_185233.dump`(pg_dump -Fc) + - 运行容器快照:镜像 `inkreach-api-snapshot:20260901`(docker commit 自 deploy-v2-api-1,含当前代码与依赖) + - 代码回滚 = 删 feature 分支 **执行环境(宿主机无 node,统一用容器跑):** @@ -38,9 +41,11 @@ docker run --rm --network deploy-v2_default \ **排序键定义(getGoods DEFAULT):** ``` -① countries.sort_order(经 goods.country_id) 缺失 → 排最后 -② 叶子.parent_category_id 的 sort_order(二级) 缺失 → 排最后 -③ 叶子自身 sort_order(三级,经 origin_goods.sds_category_id)缺失 → 排最后 +① countries.sort_order(经 goods.country_id) 缺失 → MAX_SAFE_INTEGER +② 叶子.parent_category_id 的 sort_order(二级) 缺失 → MAX_SAFE_INTEGER +③ 叶子自身 sort_order(三级,经 origin_goods.sds_category_id)缺失 → MAX_SAFE_INTEGER +(缺失沉底发生在「所属国家分组内」:如防晒衣(137) 的 sds_category_id=2393 不在新树, + 它会排到日本组末尾,不会破坏国家间顺序,可照常售卖/搜索) ④ good_priority desc, id asc(现有字段,不变) ``` @@ -187,7 +192,7 @@ const norm = (s: string) => s.trim().replace(/\s+/g, ''); const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0]; async function main() { - const raw = readFileSync('/opt/inkreach-v2/排序表.md', 'utf8'); + const raw = readFileSync(process.env.SORT_TABLE_PATH ?? '/repo/排序表.md', 'utf8'); let country: string | null = null; let l2: string | null = null; const tree: Array<{ country: string; l2: string; l3: string }> = []; @@ -285,10 +290,13 @@ main().finally(() => prisma.$disconnect()); - [ ] **Step 2.2: 执行脚本** ```bash +# ts-node 不在 apps/api 依赖中,用 tsc 编译后以 node 运行; +# 仓库根只读挂载到 /repo 以读取排序表.md docker run --rm --network deploy-v2_default \ -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ - -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \ - npx ts-node prisma/backfill-category-sort-order.ts + -e SORT_TABLE_PATH=/repo/排序表.md \ + -v /opt/inkreach-v2:/repo:ro -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \ + sh -c "npx tsc prisma/backfill-category-sort-order.ts --module commonjs --target es2020 --esModuleInterop --skipLibCheck --outDir /tmp/bf && node /tmp/bf/backfill-category-sort-order.js" ``` Expected: `backfill done`,退出码 0。已知候选 UNMATCHED:美国/内衣 `DG170G170G女士无痕三角内裤`(库内为 `DG701 170G女士无痕三角内裤`,货号前缀 `DG170G170G` 不匹配)→ 将排序表.md 该行改为与库名一致后重跑;其余逐条人工核对。 @@ -693,7 +701,7 @@ Expected: 全部 PASS。任何失败必须修复(含其他模块被 schema 变 - [ ] `prisma migrate status` 干净(无未应用/未登记迁移) - [ ] 回填 UNMATCHED 清单为空(或已逐条人工处理并记录) -- [ ] 冒烟(真实调用形态):`/public/goods?pageSize=10` DEFAULT = 美国 DG001 族 → DG004 族 …;`/public/goods?categoryId=659`(女士服装,跨国)按国家顺序排;`/public/goods?categoryId=666` 同上;`/public/goods?countryId=49&categoryId=658` 只返回英国男装且按 ②③④;`/public/categories` 返回新树且沙特(空)不出现 +- [ ] (先完成 Step 6.4 重建容器)冒烟(真实调用形态):`/public/goods?pageSize=10` DEFAULT = 美国 DG001 族 → DG004 族 …;`/public/goods?categoryId=659`(女士服装,跨国)按国家顺序排;`/public/goods?categoryId=666` 同上;`/public/goods?countryId=49&categoryId=658` 只返回英国男装且按 ②③④;`/public/categories` 返回新树且沙特(空)不出现 - [ ] DTO 字段逐个对比改动前后(PublicGoodDto / PublicCategoryNodeDto / 分页结构)无增删 - [ ] `git status` 干净,全部提交 @@ -725,3 +733,28 @@ cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --bu - 防晒衣(good_id=137) 的 sds_category_id=2393 修复(排序中沉底) - admin 后台分类拖拽排序 UI(本期用脚本回填;后续如需可视化调整再立项) - 中东根更名后 SDS 同步覆盖名称的持久对抗(重跑回填脚本即可恢复) + +--- + +## 备份与回滚(已就绪) + +| 资产 | 位置 | 时间 | +|---|---|---| +| DB 全库快照 | `backups/inkreach_snapshot_20260901_185233.dump`(pg_dump -Fc,3.4M) | 2026-09-01 | +| 运行容器快照 | docker 镜像 `inkreach-api-snapshot:20260901`(711MB,commit 自 deploy-v2-api-1) | 2026-09-01 | +| 代码 | 分支 `feature/category-tree-sort`(develop/refactor-v2 未动) | 实时 | + +**DB 恢复(覆盖式,先停 api 容器避免写入竞争):** + +```bash +cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml stop api +docker exec -i deploy-v2-postgres-1 pg_restore -U inkreach -d inkreach --clean --if-exists \ + < /opt/inkreach-v2/backups/inkreach_snapshot_20260901_185233.dump +cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml start api +``` + +**api 容器恢复(快照镜像另起实例做比对/应急):** + +```bash +docker run -d --name deploy-v2-api-snapshot --network deploy-v2_default inkreach-api-snapshot:20260901 +``` From d01c1e1b974ade1c0d23958c6daf6c9d410e737e Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 10:00:29 +0800 Subject: [PATCH 06/22] docs(plan): keep legacy tree as nav, treat sds leaves as style-order metadata, drop tree-switch tasks --- plans/feature/category-sort-feature.md | 388 +++++-------------------- 1 file changed, 66 insertions(+), 322 deletions(-) diff --git a/plans/feature/category-sort-feature.md b/plans/feature/category-sort-feature.md index 6d6f629..6a13d2d 100644 --- a/plans/feature/category-sort-feature.md +++ b/plans/feature/category-sort-feature.md @@ -2,23 +2,22 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** `GET /public/goods` 默认排序变为「国家 → 二级分类 → 三级分类(款)→ good_priority」,`GET /public/categories` 内容源切换为新树(国家根→二级→三级款),支持 1/2/3 级任意筛选组合,接口输入输出 DTO 结构完全不变。 +**Goal:** `GET /public/goods` 默认排序变为「国家 → 分类(款所属二级)→ 款 → good_priority」,把排序表中每个"款"下的 1~N 个已配置商品排到该款的位置上。**分类树保持老树完全不动**,接口输入输出 DTO 结构完全不变。 -**Architecture:** `categories` 新增 `sort_order`(二/三级顺序)与 `country_id`(树根关联国家,一级顺序复用 `countries.sort_order`)。`getGoods` 内存中按四层键排序(量级 295,分组/分页本就内存执行);`categoryId` 仍是单个 id(DTO 不动)+ 双通道过滤(新树走 `origin_goods.sds_category_id`,老树回退 `goods.category_id`)。数据一次性回填脚本解析 `排序表.md`。 +**Architecture:** 领域模型:老树(男士服装→T恤)是正式分类体系,小程序导航与 `categoryId` 过滤全部照旧;新树(国家根→男士T恤→DG001…)的二级/三级节点**只作为"款排序元数据层"**——"款"是合并后的具体衣服(一个款经 family 合并映射 1~N 个源商品/已配置商品)。`categories` 新增 `sort_order` 承载款顺序;`getGoods` 内存中按四层键排序(量级 295,分组/分页本就内存执行),商品经 `origin_goods.sds_category_id` 找到所属款。数据一次性回填脚本解析 `排序表.md`。 **Tech Stack:** NestJS + Prisma + PostgreSQL + Jest(真实库集成测试) **已确认的决策:** -1. **前端调用形态(真实 URL 已确认)**:无论选中哪一层,`categoryId` 永远只传**一个**节点 id(DTO 完全不动,无逗号多 id),可选伴随 `countryId`。goods 全挂老树,老树节点天然跨国家: - - 只选老树节点:`?categoryId=659`(女士服装,跨国家)→ 按 ①国家→②新树二级→③新树款→④priority 排("美国的女士T恤全排完到下个国家") - - 选到老树叶子:`?categoryId=666`(女士/裤装,跨国家)→ 同上 - - 国家 + 分类:`?countryId=49&categoryId=658` → 该国内按 ②③④ - - 只选国家:`?countryId=49` → 同上 - - 什么都不选 → 全局 ①②③④ - (以上全部由"绝对排序键 + 子集过滤"自然满足,无特殊分支逻辑) -2. 款内顺序复用现有 `good_priority`(只需保证三级款之间顺序正确) -3. 「中东」改为「沙特」写入 countries;防晒衣(good_id=137) 不处理(排序沉底) -4. `/public/categories` 切换为新树内容源(结构不变);空国家/空二级(productCount=0)不返回,与旧行为、`getCountries` 口径一致。切换后前端将传新树 id(某国的二级/款),`categoryId` 过滤走双通道兼容两种 id +1. **分类树不动**:`/public/categories` 继续返回老树,老树分类是对的 +2. **款 = 排序单元**:排序表的三级(DG001…)是款,不是分类;一个款在"已配置"里有 1~多个商品(同款不同工艺/物流拆分的,经 family 合并),它们排在一起、占该款的位置(如 DG004 的商品整体排在 DG001 后面) +3. **前端调用形态(真实 URL 已确认)**:`categoryId` 永远只传一个老树节点 id,可选伴随 `countryId`,DTO 不动: + - 只选分类:`?categoryId=659`(女士服装,跨国家)→ ①国家→②款顺序→③priority("美国的女士T恤各款排完到下个国家") + - 选到叶子:`?categoryId=666` → 同上 + - 国家 + 分类:`?countryId=49&categoryId=658` → 该国内按 ②③ + - 什么都不选 → 全局 ①②③ + (全部由"绝对排序键 + 子集过滤"自然满足,无特殊分支) +4. 「中东」改为「沙特」写入 countries(需求方口径);新树根改名「沙特本地工厂直发」;防晒衣(good_id=137) 的款不在树中(sds_category_id=2393 无对应节点),排序在日本组内沉底,不修复 **硬性约束:** - 所有 public 接口入参、出参 DTO 字段**一个都不能变**,只改内部逻辑 @@ -38,24 +37,25 @@ docker run --rm --network deploy-v2_default \ -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx <命令> ``` -**排序键定义(getGoods DEFAULT):** +**排序键定义(getGoods DEFAULT,商品经 origin_goods.sds_category_id 定位到款):** ``` -① countries.sort_order(经 goods.country_id) 缺失 → MAX_SAFE_INTEGER -② 叶子.parent_category_id 的 sort_order(二级) 缺失 → MAX_SAFE_INTEGER -③ 叶子自身 sort_order(三级,经 origin_goods.sds_category_id)缺失 → MAX_SAFE_INTEGER -(缺失沉底发生在「所属国家分组内」:如防晒衣(137) 的 sds_category_id=2393 不在新树, - 它会排到日本组末尾,不会破坏国家间顺序,可照常售卖/搜索) +① countries.sort_order(经 goods.country_id) 缺失 → MAX_SAFE_INTEGER +② 款所属二级(如"男士T恤")的 sort_order 缺失 → MAX_SAFE_INTEGER +③ 款自身的 sort_order 缺失 → MAX_SAFE_INTEGER ④ good_priority desc, id asc(现有字段,不变) ``` +(缺失沉底发生在「所属国家分组内」:如防晒衣(137) 的 sds_category_id=2393 不在新树, + 它会排到日本组末尾,不会破坏国家间顺序,可照常售卖/搜索。 + 同一款下 1~N 个商品(单面/双面印花等)共享 ②③,仅靠 ④ 分先后,自然聚在一起。) --- -### Task 1: Schema 迁移 — categories 加 sort_order + country_id +### Task 1: Schema 迁移 — categories 加 sort_order **Files:** -- Modify: `apps/api/prisma/schema.prisma`(Category model 约 L157-174、Country model 约 L141-154) -- Create: `apps/api/prisma/migrations/20260901000000_add_category_sort_fields/migration.sql` +- Modify: `apps/api/prisma/schema.prisma`(Category model 约 L157-174) +- Create: `apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql` - [ ] **Step 1.1: 先检查迁移状态(共享库防漂移)** @@ -68,44 +68,23 @@ Expected: `Database schema is up to date!`。若报 drift/已存在类错误, - [ ] **Step 1.2: 修改 schema.prisma** -Category model:`sdsCategoryId` 行后新增两行,并新增 relation: +Category model 的 `sdsCategoryId` 行后新增: ```prisma sortOrder Int @default(0) @map("sort_order") - countryId BigInt? @map("country_id") - country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction) -``` - -Country model:`families ProductFamily[]` 后新增: - -```prisma - categories Category[] -``` - -Category model 底部 `@@index([parentCategoryId])` 后新增: - -```prisma - @@index([countryId]) ``` - [ ] **Step 1.3: 手写迁移 SQL(应用与登记分离,避免 shadow DB)** ```bash -mkdir -p apps/api/prisma/migrations/20260901000000_add_category_sort_fields +mkdir -p apps/api/prisma/migrations/20260901000000_add_category_sort_order ``` -创建 `apps/api/prisma/migrations/20260901000000_add_category_sort_fields/migration.sql`: +创建 `apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql`: ```sql -- AlterTable ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0; -ALTER TABLE "categories" ADD COLUMN "country_id" BIGINT; - --- ForeignKey -ALTER TABLE "categories" ADD CONSTRAINT "categories_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE SET NULL ON UPDATE NO ACTION; - --- CreateIndex -CREATE INDEX "categories_country_id_idx" ON "categories"("country_id"); ``` - [ ] **Step 1.4: 应用 SQL、登记迁移、重新生成 client** @@ -114,12 +93,12 @@ CREATE INDEX "categories_country_id_idx" ON "categories"("country_id"); docker run --rm --network deploy-v2_default \ -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \ - npx prisma db execute --file prisma/migrations/20260901000000_add_category_sort_fields/migration.sql + npx prisma db execute --file prisma/migrations/20260901000000_add_category_sort_order/migration.sql docker run --rm --network deploy-v2_default \ -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine \ - npx prisma migrate resolve --applied 20260901000000_add_category_sort_fields + npx prisma migrate resolve --applied 20260901000000_add_category_sort_order docker run --rm --network deploy-v2_default \ -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ @@ -130,39 +109,38 @@ Expected: 三条命令均成功。 - [ ] **Step 1.5: 验证列存在** ```bash -docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "\d categories" | grep -E 'sort_order|country_id' +docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c "\d categories" | grep sort_order ``` -Expected: 两列均存在(`sort_order integer not null default 0`、`country_id bigint`)。 +Expected: `sort_order | integer | | not null | 0` - [ ] **Step 1.6: Commit** ```bash -git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/20260901000000_add_category_sort_fields -git commit -m "feat(api): add sort_order and country_id to categories" +git add apps/api/prisma/schema.prisma apps/api/prisma/migrations/20260901000000_add_category_sort_order +git commit -m "feat(api): add sort_order column to categories" ``` --- -### Task 2: 数据回填脚本 — 排序表 → sort_order + 沙特 + 根关联国家 +### Task 2: 数据回填脚本 — 排序表 → sort_order + 沙特 **Files:** - Create: `apps/api/prisma/backfill-category-sort-order.ts` **行为(幂等,可重复执行):** -1. 解析仓库根 `排序表.md`:`# ` = 一级国家(跳过「全部」「# 工厂直发国家/地区列表」)、`## ` = 二级、`### ` = 三级 -2. 国家名 → 新树根 category 映射(写死映射表);`中东` 条目:countries upsert「沙特」+ 根重命名 `沙特本地工厂直发` -3. 根节点写入 `country_id`(关联 countries 行;`国内工厂` 无对应国家行 → 保持 null) -4. 每个根内:二级按出现顺序写 `sort_order=1..n`;三级在所属二级范围内写 `sort_order=1..n` -5. 匹配规则:先按规范化名称(trim+压缩空白)精确匹配;失败再按「首个货号 token」前缀匹配(如 `GBTM011长袖T` 命中 `GBTM011长袖T`);**跨根禁止匹配** -6. 未匹配条目打印 `UNMATCHED:` 清单并退出码 1;`中国(国内工厂)` 跳过 countries 部分,sort_order 照常回填 -7. countries.sort_order 按表内顺序 1..13 重写(美国 英国 日本 墨西哥 巴西 沙特 波兰 西班牙 德国 意大利 加拿大 澳大利亚 韩国) +1. 解析 `排序表.md`:`# ` = 一级国家(跳过「全部」「# 工厂直发国家/地区列表」)、`## ` = 二级(男士T恤等)、`### ` = 款 +2. 国家名 → 新树根 category 映射(写死映射表);`中东` 条目:countries upsert「沙特」(sort_order=表内顺序 6)+ 根重命名「沙特本地工厂直发」 +3. 每个根内:二级按出现顺序写 `sort_order=1..n`;款在所属二级范围内写 `sort_order=1..n` +4. 匹配规则:先按规范化名称(trim+压缩空白)精确匹配;失败再按「首个货号 token」前缀匹配(如 `GBTM011长袖T` 命中 `GBTM011长袖T`);**跨根禁止匹配** +5. 未匹配条目打印 `UNMATCHED:` 清单并退出码 1(人工对齐排序表/库名后重跑);`中国(国内工厂)` 跳过 countries 部分,sort_order 照常回填 +6. countries.sort_order 按表内顺序 1..13 重写(美国 英国 日本 墨西哥 巴西 沙特 波兰 西班牙 德国 意大利 加拿大 澳大利亚 韩国) - [ ] **Step 2.1: 编写脚本** ```ts /** - * 一次性回填:解析 排序表.md → categories.sort_order(二/三级)+ country_id(根) - * + countries.sort_order + 沙特国家行 + 中东根更名 + * 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order + * + 沙特国家行 + 中东根更名 * 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复 */ import { PrismaClient } from '@prisma/client'; @@ -219,7 +197,6 @@ async function main() { const unmatched: string[] = []; // 1) countries:沙特 upsert + 顺序重写 - const countryIdByName = new Map(); for (let i = 0; i < countryOrder.length; i++) { const name = countryOrder[i]; const dbCountry = name === '中东' ? '沙特' : name; @@ -227,10 +204,8 @@ async function main() { const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } }); if (existing) { await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } }); - countryIdByName.set(dbCountry, existing.id); } else if (dbCountry === '沙特') { - const created = await prisma.country.create({ data: { countryName: '沙特', sortOrder } }); - countryIdByName.set('沙特', created.id); + await prisma.country.create({ data: { countryName: '沙特', sortOrder } }); } } @@ -241,20 +216,11 @@ async function main() { rootByName.set('沙特本地工厂直发', meRoot); } - // 3) 根节点关联 country_id - for (const [tableName, rootName] of Object.entries(ROOT_MAP)) { - const root = rootByName.get(tableName === '中东' ? '沙特本地工厂直发' : rootName); - if (!root) { unmatched.push(`ROOT MISS: ${tableName}`); continue; } - const dbCountry = tableName === '中东' ? '沙特' : tableName === '中国(国内工厂)' ? null : tableName; - const cid = dbCountry ? (countryIdByName.get(dbCountry) ?? null) : null; - await prisma.category.update({ where: { id: root.id }, data: { countryId: cid } }); - } - - // 4) 二/三级 sort_order + // 3) 二级/款 sort_order for (const countryName of countryOrder) { const rootName = ROOT_MAP[countryName]; const root = rootByName.get(countryName === '中东' ? '沙特本地工厂直发' : rootName); - if (!root) continue; // 已在 ROOT MISS 记录 + if (!root) { unmatched.push(`ROOT MISS: ${countryName}`); continue; } const l2s = root.children; const l2NamesInOrder: string[] = []; for (const row of tree) if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2); @@ -305,16 +271,16 @@ Expected: `backfill done`,退出码 0。已知候选 UNMATCHED:美国/内衣 ```bash docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -c " select country_name, sort_order from countries order by sort_order;" -c " -select root.category_name, root.country_id, mid.category_name, mid.sort_order, count(leaf.category_id) leaves +select root.category_name, mid.category_name, mid.sort_order, count(leaf.category_id) leaves from categories root join categories mid on mid.parent_category_id = root.category_id left join categories leaf on leaf.parent_category_id = mid.category_id where root.parent_category_id is null and root.sds_category_id is not null -group by 1,2,3,4 order by 1, mid.sort_order;" | head -70 +group by 1,2,3 order by 1, mid.sort_order;" | head -70 ``` -Expected: countries 13 行(沙特=6);美国根下 男士T恤=1、女士T恤=2…;各二级下三级 sort_order 连续 1..n;每个新树根 country_id 非空(国内工厂除外)。 +Expected: countries 13 行(沙特=6);美国根下 男士T恤=1、女士T恤=2…;各二级下款 sort_order 连续 1..n。 -抽查美国/男士T恤三级顺序: +抽查美国/男士T恤款顺序: ```bash docker exec deploy-v2-postgres-1 psql -U inkreach -d inkreach -tAc " @@ -372,7 +338,7 @@ describe('getGoods tree-order sorting', () => { orderedGoodIds = [a1High.id, a1Low.id, a2.id, b1.id]; }); - it('DEFAULT: country > l2 > l3 > priority', async () => { + it('DEFAULT: country > mid > leaf > priority', async () => { const res = await service.getGoods({ page: 1, pageSize: 50 }); const idx = res.items.map((i) => BigInt(i.goodId)); const pos = orderedGoodIds.map((id) => idx.indexOf(id)); @@ -380,7 +346,7 @@ describe('getGoods tree-order sorting', () => { expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序 expect(idx.indexOf(orderedGoodIds[0])).toBeLessThan(idx.indexOf(orderedGoodIds[1])); // 同款内 priority desc expect(idx.indexOf(orderedGoodIds[1])).toBeLessThan(idx.indexOf(orderedGoodIds[2])); // 款顺序 - expect(idx.indexOf(orderedGoodIds[2])).toBeLessThan(idx.indexOf(orderedGoodIds[3])); // 国家/分类顺序优先于 priority + expect(idx.indexOf(orderedGoodIds[2])).toBeLessThan(idx.indexOf(orderedGoodIds[3])); // 国家/款顺序优先于 priority }); }); ``` @@ -447,7 +413,7 @@ if (!query.sort || query.sort === 'DEFAULT') { rows.sort((a, b) => this.compareByTreeOrder(meta, a, b)); } ``` -(分组代表行 = 树序第一条,与现有「排序最前为代表」契约一致。) +(分组代表行 = 树序第一条,与现有「排序最前为代表」契约一致;同一款下 1~N 个商品共享 ②③ 键,仅靠 ④ 分先后。) - [ ] **Step 3.4: 跑测试确认通过(含既有用例)** @@ -460,278 +426,56 @@ Expected: 全部 PASS(现有 getGoods 断言若依赖 DEFAULT 全局顺序按 ```bash git add apps/api/src/public/public.service.ts apps/api/src/public/public.service.spec.ts -git commit -m "feat(api): order public goods by country > category > leaf > priority" +git commit -m "feat(api): order public goods by country > mid-category > style > priority" ``` --- -### Task 4: categoryId 新树双通道过滤(TDD) +### Task 4: 全量验证 + 交付 -**Files:** -- Modify: `apps/api/src/public/public.service.ts`(getGoods categoryId 过滤块,约 L194-196) - -**语义(DTO 完全不动,`categoryId` 仍是单个 id):** -- 传入 id 属于新树(自身/子孙存在 sds_category_id)→ 收集该节点全部子孙叶子的 `sds_category_id` 集合,过滤改为 `where.originGood.sdsCategoryId in 集合`(选二级 = 该二级下所有款) -- 传入 id 属于老树(如 658/659/666)→ 回退现行逻辑 `where.categoryId in collectCategoryDescendants(...)`(老树节点天然跨国家,行为不变) -- 新树 originGood 条件与 minPrice/maxPrice 的 originGood 条件合并为同一对象,互不覆盖 - -**真实调用形态(验收基准):** -- `?categoryId=659`(老树一级,跨国家)→ 全部女士服装商品,按 ①国家→②新树二级→③新树款→④priority -- `?categoryId=666`(老树叶子,跨国家)→ 同上 -- `?countryId=49&categoryId=658` → 英国内按 ②③④ -- 切新树后前端会传新树 id(如某国"男士T恤"或某款)→ 走 sds 通道 - -- [ ] **Step 4.1: 写失败测试** - -```ts -describe('getGoods categoryId filter (dual-path)', () => { - it('new-tree mid id filters via originGood.sdsCategoryId', async () => { - const midA = await prisma.category.findFirst({ where: { categoryName: `MidA ${stamp2}` } }); - const res = await service.getGoods({ page: 1, pageSize: 50, categoryId: midA!.id.toString() }); - const names = res.items.map((i) => i.goodName).filter((n) => n.includes(stamp2)); - expect(names.length).toBe(3); // A1Low/A1High/A2 - expect(names.some((n) => n.startsWith('A2'))).toBe(true); - expect(names.every((n) => !n.startsWith('B1'))).toBe(true); // B1 不属于 MidA - }); - - it('new-tree leaf id filters to that leaf only', async () => { - const leafA2 = await prisma.category.findFirst({ where: { categoryName: `LeafA2 ${stamp2}` } }); - const res = await service.getGoods({ page: 1, pageSize: 50, categoryId: leafA2!.id.toString() }); - const names = res.items.map((i) => i.goodName).filter((n) => n.includes(stamp2)); - expect(names.length).toBe(1); - expect(names[0].startsWith('A2')).toBe(true); - }); - - it('categoryId + minPrice merge originGood filters', async () => { - const midA = await prisma.category.findFirst({ where: { categoryName: `MidA ${stamp2}` } }); - const res = await service.getGoods({ - page: 1, pageSize: 50, categoryId: midA!.id.toString(), minPrice: '999999', - }); - expect(res.items.filter((i) => i.goodName.includes(stamp2)).length).toBe(0); - }); -}); -``` -(老树 id 路径由既有用例覆盖:原始 fixture 的分类无 sds_category_id,走回退分支,行为不变。) - -- [ ] **Step 4.2: 跑测试确认失败** - -同 Task 3 命令,`-t 'categoryId'`。Expected: FAIL(新树 mid/leaf id 走旧路径查空)。 - -- [ ] **Step 4.3: 实现** - -`getGoods` 内替换原 categoryId 过滤块: - -```ts -if (query.categoryId) { - const sds = await this.resolveSdsCategoryFilter(BigInt(query.categoryId)); - if (sds) { - where.originGood = { - ...(where.originGood as Prisma.OriginGoodWhereInput | undefined), - sdsCategoryId: { in: sds }, - }; - } else { - where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) }; - } -} -``` - -新增方法: - -```ts -/** - * 新树过滤:节点自身或子孙存在 sds_category_id → 返回全部子孙(含自身)的 - * sds_category_id 集合(选二级 = 其下所有款);否则返回 null(老树回退)。 - * 根/二级自身的 sds id 不出现在 origin_goods 上,多收集无害。 - */ -private async resolveSdsCategoryFilter(rootId: bigint): Promise { - const ids: bigint[] = [rootId]; - let frontier: bigint[] = [rootId]; - while (frontier.length) { - const children = await this.prisma.category.findMany({ - where: { parentCategoryId: { in: frontier } }, - select: { id: true }, - }); - frontier = children.map((c) => c.id); - ids.push(...frontier); - } - const rows = await this.prisma.category.findMany({ - where: { id: { in: ids }, sdsCategoryId: { not: null } }, - select: { sdsCategoryId: true }, - }); - const sds = rows.map((r) => r.sdsCategoryId!).filter((s) => s !== ''); - return sds.length ? sds : null; -} -``` - -- [ ] **Step 4.4: 跑全部 public 测试确认通过** - -```bash -docker run --rm ... npx jest src/public -``` -Expected: 全部 PASS(老树回退路径保证旧行为不变)。 - -- [ ] **Step 4.5: Commit** - -```bash -git add apps/api/src/public/public.service.ts apps/api/src/public/public.service.spec.ts -git commit -m "feat(api): support new-tree categoryId filter via origin sds category" -``` - ---- - -### Task 5: getCategoriesTree 切换新树(TDD) - -**Files:** -- Modify: `apps/api/src/public/public.service.ts`(getCategoriesTree 约 L85-127) - -**语义:** -- 内容源:新树(根 = `parentCategoryId=null AND sdsCategoryId!=null`),老树不再返回 -- 排序:根按 `country.sortOrder`(null 最后)→ 根 sortOrder;二级/三级按各自 `sort_order` -- `countryId` 过滤:根的 `country_id` 精确匹配(不再按 goods 归属) -- productCount:叶子 = 关联 origin_goods.sds_category_id 的在售族化 goods 数(`family_id IS NOT NULL AND delisted=false`);二级/根 = 后代求和(现有 `buildTree` 语义) -- productCount=0 的二级/根剪掉(空国家不出现,如沙特在配货前不展示) - -- [ ] **Step 5.1: 写失败测试** - -```ts -describe('getCategoriesTree (new tree)', () => { - it('returns new-tree roots ordered by country sort_order with counts', async () => { - const tree = await service.getCategoriesTree(); - const names = tree.map((n) => n.categoryName); - // 新树根在返回中,老树根不在 - expect(names).toContain(`RootA ${stamp2}`); - expect(names).not.toContain(`Pub Cat ${stamp}`); // 老树 fixture(无 sds_category_id) - const rootA = tree.find((n) => n.categoryName === `RootA ${stamp2}`)!; - expect(rootA.children.map((c) => c.categoryName)).toEqual([`MidA ${stamp2}`]); - expect(rootA.children[0].children.length).toBe(2); // LeafA1/LeafA2 - expect(rootA.children[0].children[0].productCount).toBe(2); // A1Low/A1High - expect(rootA.children[0].children[1].productCount).toBe(1); // A2 - expect(rootA.productCount).toBe(3); - // 国家顺序:RootA(sort=1) 在 RootB(sort=2) 前 - expect(names.indexOf(`RootA ${stamp2}`)).toBeLessThan(names.indexOf(`RootB ${stamp2}`)); - }); - - it('countryId filters to that country root', async () => { - const cA = await prisma.country.findFirst({ where: { countryName: `TA ${stamp2}` } }); - // Task 2 回填不会给测试 fixture 关联 country_id,此处手动关联 - const rootA = await prisma.category.findFirst({ where: { categoryName: `RootA ${stamp2}` } }); - await prisma.category.update({ where: { id: rootA!.id }, data: { countryId: cA!.id } }); - const tree = await service.getCategoriesTree(cA!.id.toString()); - expect(tree.map((n) => n.categoryName)).toEqual([`RootA ${stamp2}`]); - }); -}); -``` - -注意:Task 3 fixture 创建根时未设 countryId,本 describe 的 beforeAll 中补齐:RootA→cA、RootB→cB(同上 update 方式),并给 leafA1/leafA2 的 goods 数依赖 Task 3 fixture(A1 两条、A2 一条)。若 describe 执行顺序影响 fixture 创建,将根/二级/叶子与国家的关联移入本 describe 自己的 beforeAll(按 stamp2 查询)。 - -- [ ] **Step 5.2: 跑测试确认失败** - -```bash -docker run --rm ... npx jest src/public/public.service.spec.ts -t 'new tree' -``` -Expected: FAIL(现实现返回老树,`RootA` 不在结果中)。 - -- [ ] **Step 5.3: 实现** - -`getCategoriesTree` 整体替换: - -```ts -async getCategoriesTree(countryId?: string): Promise { - const countryFilter = countryId ? { countryId: BigInt(countryId) } : {}; - const roots = await this.prisma.category.findMany({ - where: { parentCategoryId: null, sdsCategoryId: { not: null }, ...countryFilter }, - orderBy: [{ country: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, { id: 'asc' }], - include: { - children: { - orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], - include: { children: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] } }, - }, - }, - }); - - const countRows = await this.prisma.$queryRaw>` - SELECT og.sds_category_id AS sds, COUNT(*) AS cnt - FROM goods g - JOIN origin_goods og ON og.origin_good_id = g.origin_good_id - WHERE g.family_id IS NOT NULL - AND og.delisted = false - AND og.sds_category_id IS NOT NULL - ${countryId ? Prisma.sql`AND g.country_id = ${BigInt(countryId)}` : Prisma.empty} - GROUP BY og.sds_category_id`; - const counts = new Map(countRows.map((r) => [r.sds, Number(r.cnt)])); - - const build = (row: (typeof roots)[number]): PublicCategoryNodeDto => { - const children = row.children.map(build).filter((c) => c.productCount > 0); - const own = row.sdsCategoryId ? (counts.get(row.sdsCategoryId) ?? 0) : 0; - const node = PublicCategoryNodeDto.from(row, children); - node.productCount = own + children.reduce((sum, c) => sum + c.productCount, 0); - return node; - }; - return roots.map(build).filter((r) => r.productCount > 0); -} -``` -(原 `buildTree` 若无其他调用方则删除;`Prisma.sql`/`Prisma.empty` 需 import `Prisma`,已有。) - -- [ ] **Step 5.4: 跑全部 public 测试确认通过** - -```bash -docker run --rm ... npx jest src/public -``` -Expected: 全部 PASS。既有 `getCategoriesTree` 用例(老树断言)改为新树断言或按新语义重写。 - -- [ ] **Step 5.5: Commit** - -```bash -git add apps/api/src/public/public.service.ts apps/api/src/public/public.service.spec.ts -git commit -m "feat(api): serve public categories tree from sds country-category tree" -``` - ---- - -### Task 6: 全量验证 + 交付 - -- [ ] **Step 6.1: api 全量测试** +- [ ] **Step 4.1: api 全量测试** ```bash docker run --rm --network deploy-v2_default -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx jest ``` Expected: 全部 PASS。任何失败必须修复(含其他模块被 schema 变更波及的用例)。 -- [ ] **Step 6.2: verification-before-completion 自检清单** +- [ ] **Step 4.2: verification-before-completion 自检清单** - [ ] `prisma migrate status` 干净(无未应用/未登记迁移) - [ ] 回填 UNMATCHED 清单为空(或已逐条人工处理并记录) -- [ ] (先完成 Step 6.4 重建容器)冒烟(真实调用形态):`/public/goods?pageSize=10` DEFAULT = 美国 DG001 族 → DG004 族 …;`/public/goods?categoryId=659`(女士服装,跨国)按国家顺序排;`/public/goods?categoryId=666` 同上;`/public/goods?countryId=49&categoryId=658` 只返回英国男装且按 ②③④;`/public/categories` 返回新树且沙特(空)不出现 -- [ ] DTO 字段逐个对比改动前后(PublicGoodDto / PublicCategoryNodeDto / 分页结构)无增删 +- [ ] 冒烟(真实调用形态,先完成 Step 4.4 重建容器):`/public/goods?pageSize=10` DEFAULT = 美国 DG001 族 → DG004 族 …;`/public/goods?categoryId=659`(女士服装,跨国)按国家顺序排;`/public/goods?categoryId=666` 同上;`/public/goods?countryId=49&categoryId=658` 只返回英国男装且按 ②③④;`/public/categories` 输出与改造前完全一致(老树,结构内容均不变) +- [ ] DTO 字段逐个对比改动前后(PublicGoodDto / 分页结构)无增删 - [ ] `git status` 干净,全部提交 -- [ ] **Step 6.3: 合并回 refactor/v2** +- [ ] **Step 4.3: 合并回 refactor/v2** ```bash -git checkout refactor/v2 && git merge --no-ff feature/category-tree-sort -m "merge: feature/category-tree-sort (public goods tree-order + new category tree)" +git checkout refactor/v2 && git merge --no-ff feature/category-tree-sort -m "merge: feature/category-tree-sort (public goods style-order sorting)" git branch -d feature/category-tree-sort ``` (本仓库 v2 工作线为 `refactor/v2`;不推远端,部署时机由用户确认。) -- [ ] **Step 6.4: 重建 v2-api 容器使新逻辑生效(部署步骤,执行前向用户确认)** +- [ ] **Step 4.4: 重建 v2-api 容器使新逻辑生效(部署步骤,执行前向用户确认)** ```bash cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --build api ``` -- [ ] **Step 6.5: 按项目规则沉淀** +- [ ] **Step 4.5: 按项目规则沉淀** -- 更新 `docs/references/structs.md`(categories 新字段、public 排序语义) -- 更新 `docs/references/` 使用文档与 `README.md`(新排序规则、categoryId 多 id 用法) -- 通用经验追加到 `AGENTS.md`(如:共享库迁移「手写 SQL + db execute + resolve」通道、树形排序的「绝对排序键 + 子集过滤」模式) +- 更新 `docs/references/structs.md`(categories.sort_order 新字段、public 排序语义、款/family 领域说明) +- 更新 `docs/references/` 使用文档与 `README.md`(新排序规则) +- 通用经验追加到 `AGENTS.md`(如:共享库迁移「手写 SQL + db execute + resolve」通道、「绝对排序键 + 子集过滤」模式、排序表驱动回填的幂等脚本设计) --- ## 范围外(明确不做) +- **分类树接口与导航**(老树不动;新树仅作款排序元数据层) - `getHomeGoods`(首页位次排序维持 position.indexVal 优先,另行需求再调) -- 防晒衣(good_id=137) 的 sds_category_id=2393 修复(排序中沉底) -- admin 后台分类拖拽排序 UI(本期用脚本回填;后续如需可视化调整再立项) +- 防晒衣(good_id=137) 的 sds_category_id=2393 修复(排序中日本组内沉底) +- admin 后台款顺序拖拽管理(本期用脚本回填;后续如需可视化调整再立项) - 中东根更名后 SDS 同步覆盖名称的持久对抗(重跑回填脚本即可恢复) --- @@ -742,7 +486,7 @@ cd /opt/inkreach-v2/deploy && docker compose -f docker-compose.v2.yml up -d --bu |---|---|---| | DB 全库快照 | `backups/inkreach_snapshot_20260901_185233.dump`(pg_dump -Fc,3.4M) | 2026-09-01 | | 运行容器快照 | docker 镜像 `inkreach-api-snapshot:20260901`(711MB,commit 自 deploy-v2-api-1) | 2026-09-01 | -| 代码 | 分支 `feature/category-tree-sort`(develop/refactor-v2 未动) | 实时 | +| 代码 | 分支 `feature/category-tree-sort`(refactor/v2 未动) | 实时 | **DB 恢复(覆盖式,先停 api 容器避免写入竞争):** From dc90712c4f35b2c5099a8ba57112faacf2fcf610 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 10:16:29 +0800 Subject: [PATCH 07/22] feat(api): add sort_order column to categories --- .../20260901000000_add_category_sort_order/migration.sql | 2 ++ apps/api/prisma/schema.prisma | 1 + 2 files changed, 3 insertions(+) create mode 100644 apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql diff --git a/apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql b/apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql new file mode 100644 index 0000000..67ec1e2 --- /dev/null +++ b/apps/api/prisma/migrations/20260901000000_add_category_sort_order/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2833af3..489ad57 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -161,6 +161,7 @@ model Category { categoryName String @map("category_name") categoryIcon String? @map("category_icon") sdsCategoryId String? @unique @map("sds_category_id") + sortOrder Int @default(0) @map("sort_order") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) From 3ccddc59824190cfc0bdb4b4b23ae72e3808f3bf Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 10:22:49 +0800 Subject: [PATCH 08/22] feat(api): backfill category/country sort order from reference table --- .../prisma/backfill-category-sort-order.ts | 133 ++++++++++++++++++ 排序表.md | 6 +- 2 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 apps/api/prisma/backfill-category-sort-order.ts diff --git a/apps/api/prisma/backfill-category-sort-order.ts b/apps/api/prisma/backfill-category-sort-order.ts new file mode 100644 index 0000000..27ba60e --- /dev/null +++ b/apps/api/prisma/backfill-category-sort-order.ts @@ -0,0 +1,133 @@ +/** + * 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order + * + 沙特国家行 + 中东根更名 + * 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复 + */ +import { PrismaClient } from '@prisma/client'; +import { readFileSync } from 'fs'; + +const prisma = new PrismaClient(); + +// 排序表国家名 → 新树根分类名(与库内 category_name 精确对应) +const ROOT_MAP: Record = { + '美国': '美国工厂直发', + '英国': '英国本地直发', + '日本': '日本本地工厂直发', + '墨西哥': '墨西哥工厂本地直发', + '巴西': '巴西本地工厂直发', + '中东': '中东本地工厂直发', // 同时更名沙特 + '波兰': '欧洲波兰工厂直发', + '西班牙': '欧洲西班牙工厂直发', + '德国': '欧洲德国工厂本地直发', + '意大利': '欧洲意大利工厂直发', + '加拿大': '加拿大本地工厂直发', + '澳大利亚': '澳大利亚本地工厂直发', + '韩国': '韩国本地直发', + '中国(国内工厂)': '国内工厂', +}; + +const norm = (s: string) => s.trim().replace(/\s+/g, ''); +const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0]; + +async function main() { + const raw = readFileSync(process.env.SORT_TABLE_PATH ?? '/repo/排序表.md', 'utf8'); + let country: string | null = null; + let l2: string | null = null; + const tree: Array<{ country: string; l2: string; l3: string }> = []; + const countryOrder: string[] = []; + for (const line of raw.split('\n')) { + const t = line.trim(); + if (t.startsWith('# ')) { + const name = t.slice(2).trim(); + if (name === '全部' || name.includes('工厂直发国家')) continue; + country = name; + if (!countryOrder.includes(name)) countryOrder.push(name); + } else if (t.startsWith('## ') && country) { + l2 = t.slice(3).trim(); + } else if (t.startsWith('### ') && country && l2) { + tree.push({ country, l2, l3: t.slice(4).trim() }); + } + } + console.log(`parsed: ${countryOrder.length} countries, ${tree.length} leaves`); + + const roots = await prisma.category.findMany({ + where: { parentCategoryId: null, sdsCategoryId: { not: null } }, + include: { children: { include: { children: true } } }, + }); + const rootByName = new Map(roots.map((r) => [r.categoryName, r])); + const unmatched: string[] = []; + + // 1) countries:沙特 upsert + 顺序重写(中国无国家行,跳过) + for (let i = 0; i < countryOrder.length; i++) { + const name = countryOrder[i]; + const dbCountry = name === '中东' ? '沙特' : name; + if (dbCountry === '中国(国内工厂)') continue; + const sortOrder = i + 1; + const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } }); + if (existing) { + await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } }); + } else if (dbCountry === '沙特') { + await prisma.country.create({ data: { countryName: '沙特', sortOrder } }); + console.log('created country: 沙特'); + } + } + + // 2) 中东根 → 沙特 + const meRoot = rootByName.get('中东本地工厂直发'); + if (meRoot) { + await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } }); + rootByName.set('沙特本地工厂直发', meRoot); + console.log('renamed root: 中东本地工厂直发 -> 沙特本地工厂直发'); + } + + // 3) 二级/款 sort_order + for (const countryName of countryOrder) { + const rootName = countryName === '中东' ? '沙特本地工厂直发' : ROOT_MAP[countryName]; + const root = rootByName.get(rootName); + if (!root) { + unmatched.push(`ROOT MISS: ${countryName} (expect root "${rootName}")`); + continue; + } + const l2s = root.children; + const l2NamesInOrder: string[] = []; + for (const row of tree) { + if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2); + } + for (let i = 0; i < l2NamesInOrder.length; i++) { + const target = l2NamesInOrder[i]; + const mid = l2s.find((m) => norm(m.categoryName) === norm(target)); + if (!mid) { + unmatched.push(`L2 MISS: ${countryName} / ${target}`); + continue; + } + await prisma.category.update({ where: { id: mid.id }, data: { sortOrder: i + 1 } }); + const leaves = mid.children; + const l3Names = tree.filter((r) => r.country === countryName && r.l2 === target).map((r) => r.l3); + for (let j = 0; j < l3Names.length; j++) { + const want = norm(l3Names[j]); + const code = norm(codeOf(l3Names[j])); + const leaf = + leaves.find((l) => norm(l.categoryName) === want) ?? + (code ? leaves.find((l) => norm(l.categoryName).startsWith(code)) : undefined); + if (!leaf) { + unmatched.push(`L3 MISS: ${countryName} / ${target} / ${l3Names[j]}`); + continue; + } + await prisma.category.update({ where: { id: leaf.id }, data: { sortOrder: j + 1 } }); + } + } + } + + if (unmatched.length) { + console.error(`UNMATCHED (${unmatched.length}):\n` + unmatched.join('\n')); + process.exit(1); + } + console.log('backfill done'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/排序表.md b/排序表.md index 1674419..740b944 100644 --- a/排序表.md +++ b/排序表.md @@ -14,7 +14,7 @@ ### DG100 180G女士彩棉T恤 ### DG101 180G纯棉女士露脐T恤 ### DG102 高弹力女款露脐 -### TDG110 180G大码彩棉女T恤 +### DG110 180G大码彩棉女T恤 ### DG120 牛奶丝T恤 ### DG150 180G纯棉女士T恤 ### DG502 180G女士圆领长袖T恤 @@ -27,7 +27,7 @@ ### DG014 190G小童T恤 ### DG301 190G纯棉哈哈衣 ### DG503 190G小童长袖 -### T5000B 180G吉尔丹童装T恤 +### 5000B 180G吉尔丹童装T恤 ### JSA004 190G牛奶丝小童T恤 ## 男士短裤 ### DG201 300G脏洗短裤 @@ -62,7 +62,7 @@ ## 男士长裤 ### DG210 280G高级抓绒慢跑卫裤 ## 内衣 -### DG170G170G女士无痕三角内裤 +### DG701 170G女士无痕三角内裤 ### JSA010 270G高弹力女士抹胸 ### JSE001 180G女士无痕内衣 ## 家居 From bea0bf14d8db3fa8b6de87ae777c52063b0ed6ca Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 10:27:23 +0800 Subject: [PATCH 09/22] feat(api): order public goods by country > mid-category > style > priority --- apps/api/src/public/public.service.spec.ts | 107 +++++++++++++++++++++ apps/api/src/public/public.service.ts | 70 ++++++++++++-- 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index b6be652..bfdd95b 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -621,4 +621,111 @@ describe('PublicService', () => { } }); }); + + describe('getGoods tree-order sorting (国家→二级→款→priority)', () => { + // 结构: 国家A(sort=1)>MidA>LeafA1(sort=1, 2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1 + // 期望默认顺序: A款1(priority desc) -> A款2 -> B款1;B 的 priority=99 也不能越级 + const stamp2 = `${stamp}-treeorder`; + const sdsA1 = `la1-${stamp2}`; + const sdsA2 = `la2-${stamp2}`; + const sdsB1 = `lb1-${stamp2}`; + const trash = { + goodIds: [] as bigint[], + familyIds: [] as bigint[], + originGoodIds: [] as bigint[], + categoryIds: [] as bigint[], + countryIds: [] as bigint[], + }; + let orderedFamilyIds: string[] = []; + + beforeAll(async () => { + const cA = await prisma.country.create({ + data: { countryName: `TreeOrder A ${stamp2}`, sortOrder: 1 }, + }); + const cB = await prisma.country.create({ + data: { countryName: `TreeOrder B ${stamp2}`, sortOrder: 2 }, + }); + trash.countryIds = [cA.id, cB.id]; + const midA = await prisma.category.create({ + data: { categoryName: `TreeOrder MidA ${stamp2}`, sdsCategoryId: `ma-${stamp2}`, sortOrder: 1 }, + }); + const leafA1 = await prisma.category.create({ + data: { categoryName: `TreeOrder LeafA1 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA1, sortOrder: 1 }, + }); + const leafA2 = await prisma.category.create({ + data: { categoryName: `TreeOrder LeafA2 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA2, sortOrder: 2 }, + }); + const midB = await prisma.category.create({ + data: { categoryName: `TreeOrder MidB ${stamp2}`, sdsCategoryId: `mb-${stamp2}`, sortOrder: 2 }, + }); + const leafB1 = await prisma.category.create({ + data: { categoryName: `TreeOrder LeafB1 ${stamp2}`, parentCategoryId: midB.id, sdsCategoryId: sdsB1, sortOrder: 1 }, + }); + trash.categoryIds = [leafA1.id, leafA2.id, leafB1.id, midA.id, midB.id]; + + const mk = async ( + countryId: bigint, + sdsCategoryId: string, + name: string, + priority: number, + ) => { + const og = await prisma.originGood.create({ + data: { sdsGoodId: `to-${name}-${stamp2}`, goodName: name, sdsCategoryId }, + }); + trash.originGoodIds.push(og.id); + const fam = await prisma.productFamily.create({ + data: { familyName: `to-fam-${name}-${stamp2}`, primaryOriginGoodId: og.id }, + }); + trash.familyIds.push(fam.id); + await prisma.originGood.update({ where: { id: og.id }, data: { familyId: fam.id } }); + const good = await prisma.good.create({ + data: { + goodName: `TO${stamp2}-${name}`, + originGoodId: og.id, + familyId: fam.id, + countryId, + categoryId: sdsCategoryId === sdsA1 ? leafA1.id : sdsCategoryId === sdsA2 ? leafA2.id : leafB1.id, + goodPriority: priority, + }, + }); + trash.goodIds.push(good.id); + return fam.id.toString(); + }; + + const a1Low = await mk(cA.id, sdsA1, 'A1Low', 1); + const a1High = await mk(cA.id, sdsA1, 'A1High', 9); + const a2 = await mk(cA.id, sdsA2, 'A2', 0); + const b1 = await mk(cB.id, sdsB1, 'B1', 99); + orderedFamilyIds = [a1High, a1Low, a2, b1]; + }); + + afterAll(async () => { + await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined); + await prisma.productFamily.deleteMany({ where: { id: { in: trash.familyIds } } }).catch(() => undefined); + await prisma.originGood.deleteMany({ where: { id: { in: trash.originGoodIds } } }).catch(() => undefined); + for (const id of trash.categoryIds) { + await prisma.category.delete({ where: { id } }).catch(() => undefined); + } + await prisma.country.deleteMany({ where: { id: { in: trash.countryIds } } }).catch(() => undefined); + }); + + it('DEFAULT: country > mid > leaf > priority (cross-country priority cannot jump the queue)', async () => { + const res = await service.getGoods({ + page: 1, + pageSize: 100, + keyword: `TO${stamp2}`, // 唯一前缀圈定本夹具 4 条,避免全库分页截断 + }); + expect(res.total).toBe(4); + const idx = res.items.map((i) => i.goodId); + const pos = orderedFamilyIds.map((id) => idx.indexOf(id)); + expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中 + expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序 + // 同款内 priority desc + expect(idx.indexOf(orderedFamilyIds[0])).toBeLessThan(idx.indexOf(orderedFamilyIds[1])); + // 款顺序:LeafA1 -> LeafA2 + expect(idx.indexOf(orderedFamilyIds[1])).toBeLessThan(idx.indexOf(orderedFamilyIds[2])); + // 国家/款顺序优先于 priority:B1(99) 不能排到 A2(0) 前面 + expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3])); + }); + }); }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 12883a1..31378f6 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -71,13 +71,19 @@ const PUBLIC_GOOD_LIST_INCLUDE = { tag: { include: { tagGroup: true } }, position: true, originGood: { - select: { sdsGoodId: true, goodImage: true, goodPrice: true }, + select: { sdsGoodId: true, goodImage: true, goodPrice: true, sdsCategoryId: true }, }, goodTags: { include: { tag: { include: { tagGroup: true } } } }, } satisfies Prisma.GoodInclude; type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>; +interface TreeOrderMeta { + countryOrder: Map; + /** key: origin_goods.sds_category_id → 款所属二级(c2)/款(c3) 的顺序值 */ + leafOrder: Map; +} + @Injectable() export class PublicService { constructor(private readonly prisma: PrismaService) {} @@ -220,12 +226,7 @@ export class PublicService { ? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }] : query.sort === 'NEWEST' ? [{ createdAt: 'desc' }, { id: 'asc' }] - : [ - { goodPriority: 'desc' }, - { position: { indexVal: 'asc' } }, - { createdAt: 'desc' }, - { id: 'asc' }, - ]; + : [{ id: 'asc' }]; // DEFAULT:排序移到内存做(树序,见下) // 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID); // 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配 @@ -235,6 +236,15 @@ export class PublicService { include: PUBLIC_GOOD_LIST_INCLUDE, orderBy, }); + if (!query.sort || query.sort === 'DEFAULT') { + // 默认排序 = 款序树:国家 → 款所属二级 → 款 → 优先级。 + // 款顺序存于新树 categories.sort_order(回填自排序表),商品经 + // origin_goods.sds_category_id 定位到款;同一款下多条 Good 共享 + // 前三层键,仅按 goodPriority 分先后。缺键(如款不在树中)沉到 + // 所属国家分组末尾。 + const meta = await this.loadTreeOrderMeta(); + rows.sort((a, b) => this.compareByTreeOrder(meta, a, b)); + } const familyMinPrices = await this.loadFamilyMinPrices(); const grouped = new Map(); for (const good of rows) { @@ -270,6 +280,52 @@ export class PublicService { }; } + /** + * 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order + * (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。 + */ + private async loadTreeOrderMeta(): Promise { + const [countries, leaves] = await Promise.all([ + this.prisma.country.findMany({ select: { id: true, sortOrder: true } }), + this.prisma.$queryRaw< + Array<{ sds_category_id: string; c2: number; c3: number }> + >` + SELECT leaf.sds_category_id, + COALESCE(mid.sort_order, 2147483647) AS c2, + COALESCE(leaf.sort_order, 2147483647) AS c3 + FROM categories leaf + JOIN categories mid ON mid.category_id = leaf.parent_category_id + WHERE leaf.sds_category_id IS NOT NULL AND leaf.sds_category_id <> '' + `, + ]); + return { + countryOrder: new Map(countries.map((c) => [c.id.toString(), c.sortOrder])), + leafOrder: new Map( + leaves.map((l) => [l.sds_category_id, { c2: Number(l.c2), c3: Number(l.c3) }]), + ), + }; + } + + private compareByTreeOrder(meta: TreeOrderMeta, a: PublicGoodListRow, b: PublicGoodListRow): number { + const MAX = Number.MAX_SAFE_INTEGER; + const key = (g: PublicGoodListRow): [number, number, number, number, number] => { + const leaf = meta.leafOrder.get(g.originGood.sdsCategoryId ?? ''); + return [ + meta.countryOrder.get(g.countryId.toString()) ?? MAX, + leaf?.c2 ?? MAX, + leaf?.c3 ?? MAX, + -(g.goodPriority ?? 0), + Number(g.id), + ]; + }; + const ka = key(a); + const kb = key(b); + for (let i = 0; i < ka.length; i++) { + if (ka[i] !== kb[i]) return ka[i] - kb[i]; + } + return 0; + } + /** * 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include * 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传 From 76eb849b492eb7d74678f46d4585c322e39f5c79 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 10:33:54 +0800 Subject: [PATCH 10/22] docs: record style-order semantics, container-run pitfalls and lessons --- AGENTS.md | 6 +++++- docs/references/structs.md | 2 +- plans/feature/category-sort-feature.md | 12 +++++++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 86cbe70..17417f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,4 +95,8 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 - **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied ` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。 - **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。 - **跑全量 jest 前先停 dev server**:`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。 -- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。 +- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。 +- **pnpm 仓库容器化执行要挂仓库根**:pnpm 的 node_modules 是相对符号链接指向根 `.pnpm` store,容器里只挂子包目录(如 `apps/api:/app`)会断链报 "Cannot find module";必须挂整个仓库根并 `-w` 到子包。另外 Prisma 引擎与系统 libssl 版本强绑定:node:20-alpine 缺 libssl1.1 会报 engine 加载失败,直接复用项目自身的运行镜像(如 deploy-v2-api)跑 prisma/jest 最稳。 +- **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`;schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名。 +- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。 +- **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。 diff --git a/docs/references/structs.md b/docs/references/structs.md index a490f82..d58744c 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -113,7 +113,7 @@ apps/api/ | `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 | | `/public/tags` `GET` | 公开标签列表(带 `group` 字段,按 group 排序) | 公开 | | `/public/tag-groups` `GET` | 公开标签分组列表 | 公开 | -| `/public/goods` `GET` | 分页商品(**族化契约:一族一条**,`goodId`=族ID,`price`=族起价;无族商品不返回;支持 `countryId/categoryId/tags(JSON)/keyword/minPrice/maxPrice/sort/page/pageSize`) | 公开 | +| `/public/goods` `GET` | 分页商品(**族化契约:一族一条**,`goodId`=族ID,`price`=族起价;无族商品不返回;支持 `countryId/categoryId/tags(JSON)/keyword/minPrice/maxPrice/sort/page/pageSize`)。**默认排序 = 款序树**:国家(`countries.sort_order`) → 款所属二级(`categories.sort_order`) → 款(`categories.sort_order`) → `good_priority`;商品经 `origin_goods.sds_category_id` 定位到款(新树三级节点=合并后的款,顺序值由 `排序表.md` 经回填脚本写入),同一款下多条 Good 聚在一起、款内按优先级分先后;`sort=PRICE_ASC/PRICE_DESC/NEWEST` 不受影响 | 公开 | | `/public/goods/:id` `GET` | 款级详情,`:id` = **族 ID**(唯一公开键,SDS 链接 ID 404);公共字段取代表 Good,`variants` = 全体族成员 ∪ 旧副源(去重),`sizeChart/packageSpecs` = 族并集;默认输出 `family` 块(并集尺码表/包装 + 严格五维价格矩阵 尺码×颜色×印花数量×工艺×物流 + 族起价);`PUBLIC_DETAIL_FROM_FAMILY=false` 应急回退 | 公开 | | `/categories` `/tags` `/tag-groups` `/countries` `/positions` | 后台 CRUD | JWT | | `/countries/sort` `PATCH` | 批量保存国家拖拽排序(`items=[{id,sortOrder}]` 全量提交;公开/后台国家列表均按 sortOrder 排序) | JWT | diff --git a/plans/feature/category-sort-feature.md b/plans/feature/category-sort-feature.md index 6a13d2d..26f7ca0 100644 --- a/plans/feature/category-sort-feature.md +++ b/plans/feature/category-sort-feature.md @@ -28,13 +28,19 @@ - 运行容器快照:镜像 `inkreach-api-snapshot:20260901`(docker commit 自 deploy-v2-api-1,含当前代码与依赖) - 代码回滚 = 删 feature 分支 -**执行环境(宿主机无 node,统一用容器跑):** +**执行环境(宿主机无 node;已验证的正确方式——挂仓库根 + 复用运行镜像):** ```bash -# 测试 / prisma 命令统一模板(挂载源码 + 加入 postgres 网络) +# ⚠️ 两个坑(实测踩过): +# 1) pnpm 的 node_modules 是相对符号链接指向根 .pnpm store, +# 只挂 apps/api:/app 会断链(Cannot find module)——必须挂整个仓库根 +# 2) node:20-alpine 缺 libssl1.1,Prisma engine 加载失败—— +# 直接用项目运行镜像 deploy-v2-api(libssl 已匹配) docker run --rm --network deploy-v2_default \ -e DATABASE_URL='postgresql://inkreach:2628adbdf875727ae1b5556b08cb00452bb4e1f80490f6b6@postgres:5432/inkreach' \ - -v /opt/inkreach-v2/apps/api:/app -w /app node:20-alpine npx <命令> + -v /opt/inkreach-v2:/repo -w /repo/apps/api deploy-v2-api npx +# 只读 prisma 元数据(migrate status 等)也可轻量挂载: +# -v /opt/inkreach-v2/apps/api/prisma:/app/prisma deploy-v2-api npx prisma migrate status ``` **排序键定义(getGoods DEFAULT,商品经 origin_goods.sds_category_id 定位到款):** From a0ad6da9c78ae9bef2715a46c8d847000e587615 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 11:01:11 +0800 Subject: [PATCH 11/22] chore(api): add Saudi Arabia flag icon --- apps/api/public/product-center/countries/sa.png | Bin 0 -> 3003 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/api/public/product-center/countries/sa.png diff --git a/apps/api/public/product-center/countries/sa.png b/apps/api/public/product-center/countries/sa.png new file mode 100644 index 0000000000000000000000000000000000000000..e4519b7d77b29ccd73609d1d62ec6a3e48390ce6 GIT binary patch literal 3003 zcmV;s3qb`N7_8vV}F0ofM+LDjg5BrE4qMqh%crlHELzwVh-`*hzJS513a7JqGyP3sXoq zgbo5J527CM0-?5u!6R*3fB`g@{a3@+h$~ByEi!G03tNo<8z6PX}g+M4r)FR6J&7 zK>OEW%4T(mLB_hP)z8Nrj2kXVaqbvXEg2_WO*)3ig_!v05~>FUcy!%_ z_=B7-Yb=NhM_pF)7#XUi#0$g0YpW%H?-Wup1Y+U?{bhDZ{s|eQN$)=(CR655U)S7( z1XKcDf{Hw4KSfkpSYw!!VL!1#>La(g6qO7iL()J(dcY))A;0*xWI>jJgw&b8le|e& zeBz!Vt>%%II7HJu?H$nU@4{}9Hn6a4HRKGS*}n#or9(jbl_`T_#NG>y{Xio3K69s) zFZqm4Kr)~)u6Cx~7O|zrUvj}Lo`D3W0Pzw}+9wIwIjhwgBp&ZJH}ZS4V~XX;?03!sY|5sy&A^&c2pB|?KSXpMCn=(~VfX8w|Z zRXh$!==Y+F%$7+ka~+ty*#jM2T+qa$T_n`^x9zwd$rGv!fl8?XjC?>ZF@FglQ9lPJ zj5@I;qGgg9E&$h(Eill<0s1`>;sK-SU!M49O~9bp0nHNeVKN0$dFJmZ5Iwd8k3+v6 z&yvhz=2!>57xxGyBmio47!`qumwe7cd~~XH*vUW~sChudX8wK;%vKiJ&|OR{6A`R3 zXTV5DJwnm$5U4Kklb8fhYZ2;B+0Mcu?7@@(1}(r4=I_VADQl6C)<~Nf=(>>t=fJ8j zj0rv1&I0j8^R(72!fXf*BabV@ve1Km8fHTcH1V0cPk@X15+U>FWjzON44aD(A1@pN zB1f9G17_!FoZaHl>|jipjI|;0Wh@VBwoj!Ob3h#CuHs^(^$elrUiqgIoI(>$xKx5z z-bHI?<5K=`$EelZi`LJuut?IS)=P~^6iN6{lS5SN)ig8$=B^rG)-9Prr12FgUz&JGgN_@ZMzGLy6w_$F?X>wnqy5$58pK@ z)0R*cx=yros*E8a+vQ^s$&oyjM(da3%iM(OqHYiMGELv3l9u&omO+cjn7K3UhZ+te z39S*A&=5v}DY6D_5jg}#Uu4WxTCiLGF2i1fXxb3RDp7fPNOfsYh@~+XY4?~rO{4%7 z5wU%kh?HDtw-`wPslI&Q1+<$Vk?`2imolf05-ai)kpflmSV)`lZV?VJ=rDgZS;Dxx zA@(sK)wt*bwK4kpZ18EZNjuM`gcKO$NaO&+#waS2cr`FmW4F)clXM+*ts#G)Wgso17B>`v4&JTk& zsmau2(zktROo9HCkd+5RY~Ll3WA2QVJ1!V+?Ew0ZG{kFW`eNr|>7>ZnzDM`GyDCmX zJADfD%oBZa_s~8r>(KZ33ZsBmA!UOhjSeKs%%AbD`$HEw(n0VMehJYrsGMEMC?}n89_ZpR zN)xiWB1IRY*lO0P<){tyDYY@FB8USGceIRUGk?ZUziI?j(u_pJ{_`U6C9xg&GeX@Z z200+r75n)fXyPm^k`#Y7jKg3Ezk==*8h*@nBf{JnH@0=+8ICzGS9JxM+Du_=JoS zDe+@ccKOU>&lVw4uC^9znjJ_&+k?@RcuBLFzfROeJLs@sGB%gE1F#{+ruU#R9m&6( z-0;$+(;|B_z>NjZI#0C&IcTab&~9<{I%j}yz?blWR1qc*$sgL8 z1De$-u+RYu1|CcTXtxT?o!M$S@G&8)k-;2VW8gFl-tM9@&|IM92&8v3slzF zFqlF+fYnZ!xr=+%7Fa-}ZwJh#Q3rShDtVwLEx7#prBC%mr0g0wl9;~%OtF1J6$feo zn2SZ`Zoykfvmw%#SJCr)Oz3BjScc0|2JOfqo9=9g9QM(2+)v~KU?MUk)O2Am1ueV8 z+*O0P9RR73WY9(jxIyR!Fyyk>4($5oG9m4Ri2d(`a@>!V1yg?BT_*G(Fmr(OJ?5^` z(#3x9AAi-5g3r_9aGD+PZ=g>vi(CPtdn^xVZIPckY&gg>%z9AyX8gGA! zGI!N9NeLCE=#8=qB0V?`HIYXFQx0ggWvCtT2^lV&V|7#lD@WX)NhlcA`54%pVg6!y zmv6mt_Nvd$t{nu>7ayL}PKX=?w7%_s7_$S8uxZyCT%alI5bcq`6p?euJ!5_cR-?!K z>9WqIqatrb0(kNquwx|h141>qpp8>fz9-A3*RDcfO5TNmLuAU}k>~gX?fOlOk?pbt z8uC1#?GyPc;w~GseIj-S&>oZw3759~b>1OloGL=3D|O&3)H-Y!T)Z}ZTx-i2w!)Jl zk9=^lEAle@CmVM5Me@N}&?Zvwc@s<#DeM962aJ*Yav98JtUWz8SaOiaHOa9hQVrSk z1K@2!KN6AeO&J~jPFKW(HQ);p8e*J>$f)oJ-|(4r_%iMrgc1j_x>9o38h3jTfcYnnHFyC|GTij{}f1Kf>D2k#eilQirq9}@@ zD2k#eilS`V?s8kRWQp6FE?I7Cmh`xlR{po{l)qou002ovPDHLkV1k4@l<5Ef literal 0 HcmV?d00001 From b32a5575dfe3fb288129b1a36729fd5387bd7bda Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 11:19:49 +0800 Subject: [PATCH 12/22] deploy(nginx): route /assets/product-center/ to v2 api for shared icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沙特国旗等新增图标只需放入 v2 仓库 public 目录;nginx 按最长前缀把 product-center 静态资源转发给 v2 api,其余 /assets/、/uploads/ 仍走 v1。 同步沉淀 AGENTS.md:入口 nginx 真实挂载源在 v1 仓库、文件型 bind mount 的 inode 陷阱。 --- AGENTS.md | 10 ++++++---- deploy/nginx/admin.conf | 7 +++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 17417f0..5b3faba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,11 +92,13 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 ### 经验沉淀(跨项目通用) +- **入口 nginx 的真实挂载源在 v1 仓库**:线上 official.inkreach.cc 的 nginx 容器由 `/opt/inkreach/deploy` 创建,挂载的是那边的 `nginx/admin.conf` 与 `certbot/acme` 证书;v2 仓库 `deploy/nginx/admin.conf` 只是同步副本。改线上路由必须改 v1 的文件,再在该目录 `docker compose up -d --force-recreate admin`。v1/v2 目录同名 → compose 项目名相同 → pgdata/uploads 数据卷共享,重建不丢数据。 +- **文件型 bind mount 的 inode 陷阱**:编辑器保存常替换 inode,容器内仍看到旧内容(`:ro` 挂载连 docker cp 都被拒);`docker compose restart` 也不会重新解析挂载。要让新文件内容生效只能 `--force-recreate`,新服务的挂载尽量挂目录而非单个文件。 - **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied ` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。 - **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。 - **跑全量 jest 前先停 dev server**:`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。 -- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。 -- **pnpm 仓库容器化执行要挂仓库根**:pnpm 的 node_modules 是相对符号链接指向根 `.pnpm` store,容器里只挂子包目录(如 `apps/api:/app`)会断链报 "Cannot find module";必须挂整个仓库根并 `-w` 到子包。另外 Prisma 引擎与系统 libssl 版本强绑定:node:20-alpine 缺 libssl1.1 会报 engine 加载失败,直接复用项目自身的运行镜像(如 deploy-v2-api)跑 prisma/jest 最稳。 -- **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`;schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名。 -- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。 +- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。 +- **pnpm 仓库容器化执行要挂仓库根**:pnpm 的 node_modules 是相对符号链接指向根 `.pnpm` store,容器里只挂子包目录(如 `apps/api:/app`)会断链报 "Cannot find module";必须挂整个仓库根并 `-w` 到子包。另外 Prisma 引擎与系统 libssl 版本强绑定:node:20-alpine 缺 libssl1.1 会报 engine 加载失败,直接复用项目自身的运行镜像(如 deploy-v2-api)跑 prisma/jest 最稳。 +- **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`;schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名。 +- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。 - **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。 diff --git a/deploy/nginx/admin.conf b/deploy/nginx/admin.conf index acd9911..c2d1c19 100644 --- a/deploy/nginx/admin.conf +++ b/deploy/nginx/admin.conf @@ -98,6 +98,13 @@ server { proxy_set_header Host $host; } + # Product-center icons maintained in the v2 repo — serve from the v2 api + # (more specific prefix than /assets/, so it takes precedence) + location /assets/product-center/ { + proxy_pass http://v2-api:3001/assets/product-center/; + proxy_set_header Host $host; + } + # Website placeholder until the public site is deployed location / { return 302 /admin/; From 63820a3e6f3ff36fa1df3b43bbd8e83a21040d14 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 14:58:42 +0800 Subject: [PATCH 13/22] fix(public): unify family representative selection with detail endpoint List/home endpoints picked the family representative row via goods[0], which drifted with the list sort parameter (cheapest under PRICE_ASC, lowest id under DEFAULT ties), causing goodName and other representative-derived fields to differ from the detail endpoint. Extract pickFamilyRepresentative (goodPriority desc -> createdAt desc -> id asc, same as getGoodByFamilyId) and use it in getGoods and getHomeGoods grouping. Group ordering and API contracts unchanged. --- apps/api/src/public/public.service.spec.ts | 114 ++++++++++++++++++ apps/api/src/public/public.service.ts | 38 ++++-- .../fix/public-goodname-representative-fix.md | 67 ++++++++++ 3 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 plans/fix/public-goodname-representative-fix.md diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index bfdd95b..67fa782 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -728,4 +728,118 @@ describe('PublicService', () => { expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3])); }); }); + + describe('family representative row consistency (列表/首页代表行对齐详情)', () => { + // 同族两条 Good:同 priority=10,Low 的 id 更小/createdAt 更早/价格更低/位置更好, + // High 的 createdAt 更新。详情代表行规则 = priority desc → createdAt desc → id asc + // → 详情永远取 High;列表/首页必须与详情一致,而不是随排序参数漂移到 Low。 + const stamp3 = `${stamp}-rep`; + let repFamilyId: bigint; + let trash = { + goodIds: [] as bigint[], + positionIds: [] as bigint[], + originGoodIds: [] as bigint[], + }; + const repLowName = `Rep Low ${stamp3}`; + const repHighName = `Rep High ${stamp3}`; + + beforeAll(async () => { + const posLow = await prisma.position.create({ + data: { indexVal: 1, countryId, categoryId }, + }); + const posHigh = await prisma.position.create({ + data: { indexVal: 5, countryId, categoryId }, + }); + trash.positionIds = [posLow.id, posHigh.id]; + + const ogLow = await prisma.originGood.create({ + data: { sdsGoodId: `rep-low-${stamp3}`, goodName: repLowName, goodPrice: 10 }, + }); + const ogHigh = await prisma.originGood.create({ + data: { sdsGoodId: `rep-high-${stamp3}`, goodName: repHighName, goodPrice: 20 }, + }); + trash.originGoodIds = [ogLow.id, ogHigh.id]; + + const family = await prisma.productFamily.create({ + data: { familyName: `rep-fam-${stamp3}`, primaryOriginGoodId: ogLow.id }, + }); + repFamilyId = family.id; + await prisma.originGood.updateMany({ + where: { id: { in: [ogLow.id, ogHigh.id] } }, + data: { familyId: family.id }, + }); + + const gLow = await prisma.good.create({ + data: { + goodName: repLowName, + originGoodId: ogLow.id, + familyId: family.id, + countryId, + categoryId, + goodPriority: 10, + positionId: posLow.id, + createdAt: new Date(stamp), + }, + }); + const gHigh = await prisma.good.create({ + data: { + goodName: repHighName, + originGoodId: ogHigh.id, + familyId: family.id, + countryId, + categoryId, + goodPriority: 10, + positionId: posHigh.id, + createdAt: new Date(stamp + 60_000), + }, + }); + trash.goodIds = [gLow.id, gHigh.id]; + }); + + afterAll(async () => { + await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined); + await prisma.position + .deleteMany({ where: { id: { in: trash.positionIds } } }) + .catch(() => undefined); + await prisma.productFamily.delete({ where: { id: repFamilyId } }).catch(() => undefined); + await prisma.originGood + .deleteMany({ where: { id: { in: trash.originGoodIds } } }) + .catch(() => undefined); + }); + + it('DEFAULT 列表代表行与详情一致(priority 并列时取 createdAt 最新,而非 id 最小)', async () => { + const detail = await service.getGood(repFamilyId.toString()); + expect(detail.goodName).toBe(repHighName); + + const list = await service.getGoods({ + page: 1, + pageSize: 50, + keyword: 'Rep ', // 本文件夹具唯一前缀,圈定本族(goodName: Rep Low/High) + }); + const ours = list.items.filter((i) => i.goodId === repFamilyId.toString()); + expect(ours).toHaveLength(1); + expect(ours[0].goodName).toBe(detail.goodName); + }); + + it('PRICE_ASC 列表代表行不漂移到价格更低的成员', async () => { + const detail = await service.getGood(repFamilyId.toString()); + const list = await service.getGoods({ + page: 1, + pageSize: 50, + keyword: 'Rep ', + sort: 'PRICE_ASC', + }); + const ours = list.items.filter((i) => i.goodId === repFamilyId.toString()); + expect(ours).toHaveLength(1); + expect(ours[0].goodName).toBe(detail.goodName); + }); + + it('home-goods 代表行与详情一致(不取位置更好的成员)', async () => { + const detail = await service.getGood(repFamilyId.toString()); + const home = await service.getHomeGoods({ limit: 50, countryId: countryId.toString() }); + const ours = home.filter((h) => h.goodId === repFamilyId.toString()); + expect(ours).toHaveLength(1); + expect(ours[0].goodName).toBe(detail.goodName); + }); + }); }); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 31378f6..e90d26d 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -254,7 +254,7 @@ export class PublicService { else grouped.set(key, [good]); } let items = [...grouped.values()].map((goods) => { - const rep = goods[0]; + const rep = this.pickFamilyRepresentative(goods); const dto = this.toPublicGood(rep); // 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价 const familyMin = rep.familyId @@ -421,16 +421,20 @@ export class PublicService { take: query.limit, }); const familyMinPrices = await this.loadFamilyMinPrices(); - // 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit - const seen = new Set(); - const items: PublicGoodDto[] = []; + // 首页同样按族去重(一族只出一条),代表行选取与详情/列表一致 + const grouped = new Map(); for (const good of rows) { const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`; - if (seen.has(key)) continue; - seen.add(key); - const dto = this.toPublicGood(good); - const familyMin = good.familyId - ? familyMinPrices.get(good.familyId.toString()) + const bucket = grouped.get(key); + if (bucket) bucket.push(good); + else grouped.set(key, [good]); + } + const items: PublicGoodDto[] = []; + for (const goods of grouped.values()) { + const rep = this.pickFamilyRepresentative(goods); + const dto = this.toPublicGood(rep); + const familyMin = rep.familyId + ? familyMinPrices.get(rep.familyId.toString()) : undefined; if (familyMin !== undefined) dto.price = familyMin; items.push(dto); @@ -438,6 +442,22 @@ export class PublicService { return items.slice(0, query.limit); } + /** + * 族代表行选取:与详情 getGoodByFamilyId 的 orderBy 保持一致 + * (goodPriority desc → createdAt desc → id asc), + * 保证列表/首页与详情返回的 goodName、主图、分类等代表行字段一致。 + */ + private pickFamilyRepresentative( + goods: T[], + ): T { + return [...goods].sort( + (a, b) => + (b.goodPriority ?? 0) - (a.goodPriority ?? 0) || + b.createdAt.getTime() - a.createdAt.getTime() || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + )[0]; + } + /** 入参用精简行类型:列表(PublicGoodListRow)与详情(PublicGoodRow,字段超集)都能传 */ private toPublicGood(good: PublicGoodListRow): PublicGoodDto { const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) => diff --git a/plans/fix/public-goodname-representative-fix.md b/plans/fix/public-goodname-representative-fix.md new file mode 100644 index 0000000..e40909d --- /dev/null +++ b/plans/fix/public-goodname-representative-fix.md @@ -0,0 +1,67 @@ +# Fix:公开接口族代表行选取规则统一(列表/首页对齐详情) + +日期:2026-09-02 +类型:Bug 修复(fix) +影响面:`apps/api/src/public/public.service.ts` 及其测试 + +## 背景与问题 + +公开接口中,族(productFamily)对外只暴露一条"代表行",`goodName`、主图、国家、分类、标签、`goodPriority`、`createdAt` 等字段全部来自代表行。但三个接口的代表行选取规则不一致: + +| 接口 | 代表行选取规则 | 位置 | +| --- | --- | --- | +| 详情 `GET /public/goods/{goodId}` | `goodPriority desc → createdAt desc → id asc` | `getGoodByFamilyId` | +| 列表 `GET /public/goods` | `goods[0]`(随列表排序参数变化:DEFAULT 平局取 id 最小;价格排序取价格极值行;NEWEST 取最新行) | `getGoods` 分组处 | +| 首页 `GET /public/home-goods` | `goods[0]`(按 position 顺序的第一条) | `getHomeGoods` | + +后果:同一族内多条 Good 名称不同时,列表返回的 `goodName` 与详情不一致;且列表换排序参数后名称还会变。 + +## 目标 + +- 列表与首页的族代表行选取规则与详情完全一致:`goodPriority desc → createdAt desc → id asc`。 +- 列表排序逻辑(DEFAULT 树序 / PRICE / NEWEST)与分组顺序完全不变。 +- 公开 API 输入输出数据结构不变(硬性约束)。 + +## 非目标 + +- 不改动详情逻辑、族去重契约、树序排序实现。 +- 不处理无族(自定义商品)——其分组内只有一条,不受影响。 + +## 方案(已确认:方案 A + 首页一起改) + +在 `public.service.ts` 中新增私有方法,组内显式选取代表行,`getGoods` 与 `getHomeGoods` 分组处统一调用: + +```ts +/** 族代表行:与详情 getGoodByFamilyId 的 orderBy 保持一致 + * (goodPriority desc → createdAt desc → id asc),保证列表/首页/详情字段一致 */ +private pickFamilyRepresentative( + goods: T[], +): T { + return [...goods].sort( + (a, b) => + (b.goodPriority ?? 0) - (a.goodPriority ?? 0) || + b.createdAt.getTime() - a.createdAt.getTime() || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + )[0]; +} +``` + +- `getGoods`:`const rep = this.pickFamilyRepresentative(goods);` +- `getHomeGoods`:同上替换 `goods[0]`。 +- 组序不受影响:同族成员共享国家/款,最高 `goodPriority` 相同,仅平局细则变化,树序键 c1/c2/c3 与 max(priority) 均不变。 + +## 测试计划(TDD) + +测试文件:`apps/api/src/public/public.service.spec.ts`(沿用现有夹具风格)。 + +1. **列表 vs 详情一致性**:构造同族两条 Good,`goodPriority` 相同、`createdAt` 不同、名称不同 → + `GET /public/goods` 列表项的 `goodName` === 详情接口返回的 `goodName`。 +2. **优先级优先**:族内两条 Good 优先级不同 → 列表代表行取优先级最高的那条(即使它 id 更大/创建更早)。 +3. **价格排序下名称不漂移**:`sort=PRICE_ASC` 时,价格较低但优先级低的成员不是代表行,列表 `goodName` 与 DEFAULT 排序一致。 +4. **首页一致性**:位置配置同族多条 → 首页返回的 `goodName` 与详情一致。 +5. 既有用例全部保持通过。 + +## 风险与回滚 + +- 纯内存选取逻辑变更,无 schema/数据变更,无部署数据风险。 +- 回滚:还原 commit 即可。 From ae880ff346c0a461f41d584153c1175990d024cf Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 14:59:29 +0800 Subject: [PATCH 14/22] docs(agent): add representative-row convergence lesson --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 5b3faba..24e0233 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,3 +102,4 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 - **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`;schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名。 - **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。 - **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。 +- **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。 From 1ffd73a1afe8f273428cb0c918d61c756f575699 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 15:52:41 +0800 Subject: [PATCH 15/22] docs(plan): add price-matrix option sort fix plan --- plans/fix/price-matrix-option-sort-fix.md | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 plans/fix/price-matrix-option-sort-fix.md diff --git a/plans/fix/price-matrix-option-sort-fix.md b/plans/fix/price-matrix-option-sort-fix.md new file mode 100644 index 0000000..39298d8 --- /dev/null +++ b/plans/fix/price-matrix-option-sort-fix.md @@ -0,0 +1,57 @@ +# Fix:价格矩阵选项组按词表序输出(印花/工艺/物流按钮顺序稳定) + +日期:2026-09-02 +类型:Bug 修复(fix) +影响面:`apps/api/src/product-families/family-recompute.service.ts` 及其测试、一次性全库重算 + +## 背景与问题 + +详情接口 `family.priceMatrix` 的 `printCounts / crafts / logistics` 是物化进 JSONB 的字符串数组,H5 按数组原序渲染印花/工艺/物流按钮组(无前端排序)。数组顺序 = 重算时 Set 去重的"首次遇到序",且成员查询 `originGoods` 无 `orderBy`,导致: + +1. 每个族的按钮顺序不一致(实测 goodId=1 为 `['双面印花','单面印花']`、`['不包邮','包邮']`,与词表相反); +2. 同一族两次重算顺序理论上可能漂移(Postgres 无顺序保证)。 + +对比:尺码/颜色按钮组有 `variants.sortOrder`,顺序稳定;本修复把印花/工艺/物流对齐到同样可预期。 + +## 目标顺序(业务确认) + +| 维度 | 展示顺序 | +| --- | --- | +| printCount | 单面印花 → 双面印花 | +| craft | 烫画 → 直喷 → 不打印 | +| logistics | 不包邮 → 包邮 | + +无论族内实际出现哪个子集,相对顺序恒定;词表外自由文本(如 CUSTOM 的"海运")沉到最后,相互间保持首次遇到序。 + +## 数据边界(2026-09-02 全库实测) + +- crafts 中出现 `双面印花`×2、logistics 中出现 `海运`×2:来自 3 条 CUSTOM 链接 craftLabel/logisticsLabel 自由文本(含维度填错,属数据清理问题,本修复只保证其沉底,不改数据)。 + +## 方案 + +1. `derivePriceMatrix`:聚合(Set 去重)+ 人工覆盖 append 之后,对三个数组做稳定排序: + - 新增 `OPTION_DISPLAY_ORDER` 常量(printCount/craft/logistics 三组词表序,logistics 与 `DIM_VALUES` 顺序相反是业务要求;`DIM_VALUES` 组合生成语义不动); + - 排序键 = 词表下标,未知值 = `canon.length`(沉底),稳定排序保持未知值间首次遇到序。 +2. `recomputeFamily` 成员查询加 `orderBy: { id: 'asc' }`,使"遇到序"确定(未知值之间顺序也确定)。 +3. 只影响 `autoManaged` 族物化;非自动族人工字段照旧不碰(现有不变量)。 +4. 一次性脚本:逐族调用 `recomputeFamily` 全库刷新存量(166 个族)。 + +## 非目标 + +- 不改 API 输出结构(仍是 `string[]`),前端零改动; +- 不清理 CUSTOM 维度填错数据(另行处理)。 + +## 测试计划(TDD) + +测试文件:`apps/api/src/product-families/family-recompute.service.spec.ts`(`derivePriceMatrix` 为纯函数,可直接单测)。 + +1. **打乱输入仍输出词表序**:成员顺序颠倒,crafts 输出仍 `烫画→直喷→不打印`(修复前为遇到序,RED)。 +2. **子集保序**:只含 `直喷+烫画` → 输出 `烫画,直喷`;只含 `双面` → 单元素。 +3. **未知值沉底**:logistics 含 `海运` → `不包邮,包邮,海运`;未知值相互保持遇到序。 +4. **覆盖 append 值参与同一排序**:override 新增取值后数组仍词表序。 +5. **重算幂等(含顺序)**:同输入跑两遍输出 deep-equal。 +6. 既有用例全绿;全量 jest + tsc 通过。 + +## 风险与回滚 + +- 纯物化顺序变化,无 schema/契约变更;回滚 = revert commit + 再跑一次全库重算(旧逻辑会把顺序"洗回"遇到序,仅顺序变化无数据损坏)。 From a84b3b5daaf5592bbdd1ee414dbf21b0a1bc65d8 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 16:00:12 +0800 Subject: [PATCH 16/22] fix(product-families): sort price-matrix option arrays by canonical display order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit printCounts/crafts/logistics were materialized in first-encounter order (originGoods had no orderBy), so the H5 print/craft/logistics button groups rendered in an unstable, per-family arbitrary order. Add OPTION_DISPLAY_ORDER (单面→双面, 烫画→直喷→不打印, 不包邮→包邮) and stable-sort the aggregated arrays before materializing; out-of-vocabulary free-text labels (e.g. 海运) sink to the end preserving encounter order. Fix traversal order with orderBy id asc so recomputes are fully deterministic. Add recompute:families script for one-off re-materialize. --- apps/api/package.json | 3 +- apps/api/prisma/recompute-all-families.ts | 40 +++++++++ .../family-recompute.service.spec.ts | 85 ++++++++++++++++++- .../family-recompute.service.ts | 28 +++++- 4 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 apps/api/prisma/recompute-all-families.ts diff --git a/apps/api/package.json b/apps/api/package.json index 4c8c08d..132e526 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,7 +24,8 @@ "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts", "import:product-detail": "ts-node prisma/import-product-detail.ts", "backfill:product-families": "ts-node prisma/backfill-product-families.ts", - "organize": "ts-node prisma/backfill-product-families.ts" + "organize": "ts-node prisma/backfill-product-families.ts", + "recompute:families": "ts-node prisma/recompute-all-families.ts" }, "dependencies": { "@nestjs/axios": "^3.0.1", diff --git a/apps/api/prisma/recompute-all-families.ts b/apps/api/prisma/recompute-all-families.ts new file mode 100644 index 0000000..77c7f62 --- /dev/null +++ b/apps/api/prisma/recompute-all-families.ts @@ -0,0 +1,40 @@ +/** + * 全量族重算(一次性维护脚本): + * 逐族调用 FamilyRecomputeService.recomputeFamily,刷新物化字段 + * (并集尺码表/包装规则 + 五维价格矩阵,含选项组词表序排序)。 + * autoManaged=false 的族内部只置 stale,不会覆盖人工物化字段。 + * + * 运行:pnpm --filter @inkreach/api recompute:families + */ +import { PrismaService } from '../src/prisma/prisma.service'; +import { FamilyRecomputeService } from '../src/product-families/family-recompute.service'; + +async function main() { + const prisma = new PrismaService(); + await prisma.onModuleInit(); + const recompute = new FamilyRecomputeService(prisma); + + const families = await prisma.productFamily.findMany({ + select: { id: true }, + orderBy: { id: 'asc' }, + }); + let ok = 0; + let failed = 0; + for (const { id } of families) { + try { + await recompute.recomputeFamily(id); + ok++; + } catch (error) { + failed++; + console.error(`family ${id} recompute failed: ${String(error)}`); + } + } + console.log(`[recompute:families] total=${families.length} ok=${ok} failed=${failed}`); + await prisma.onModuleDestroy(); + if (failed > 0) process.exit(1); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/api/src/product-families/family-recompute.service.spec.ts b/apps/api/src/product-families/family-recompute.service.spec.ts index 9c80615..b238c99 100644 --- a/apps/api/src/product-families/family-recompute.service.spec.ts +++ b/apps/api/src/product-families/family-recompute.service.spec.ts @@ -1,6 +1,6 @@ import { Test } from '@nestjs/testing'; import { Prisma } from '@prisma/client'; -import { FamilyRecomputeService } from './family-recompute.service'; +import { FamilyRecomputeService, derivePriceMatrix } from './family-recompute.service'; import { ProductFamiliesService } from './product-families.service'; import { OrganizeService } from './organize.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -368,3 +368,86 @@ describe('FamilyRecomputeService', () => { spy.mockRestore(); }); }); + +/** + * derivePriceMatrix 纯函数单测:选项组排序(词表序 + 未知值沉底)。 + * 业务确认的展示顺序:printCount 单面→双面;craft 烫画→直喷→不打印;logistics 不包邮→包邮。 + * 词表外自由文本(CUSTOM craftLabel/logisticsLabel,如"海运")沉底,相互间保持首次遇到序。 + */ +describe('derivePriceMatrix 选项组排序', () => { + const mkMember = ( + id: number, + over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null }, + ) => + ({ + id: BigInt(id), + source: over.source ?? 'SDS', + originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })), + variants: [], + craftLabel: over.craftLabel ?? null, + logisticsLabel: over.logisticsLabel ?? null, + }) as any; + + it('成员顺序无关:三个维度恒为词表序', () => { + // 故意让"遇到序"与目标词表序全部相反(双面在前/直喷在前/不包邮在前) + const members = [ + mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }), + mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }), + ]; + const m = derivePriceMatrix(members, []); + expect(m.printCounts).toEqual(['单面印花', '双面印花']); + expect(m.crafts).toEqual(['烫画', '直喷']); + expect(m.logistics).toEqual(['不包邮', '包邮']); + }); + + it('子集保序:只出现部分取值时相对顺序不变', () => { + const m = derivePriceMatrix([mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] })], []); + expect(m.printCounts).toEqual(['双面印花']); + expect(m.crafts).toEqual(['直喷']); + expect(m.logistics).toEqual(['不包邮']); + }); + + it('未知自由文本沉底且相互保持首次遇到序', () => { + const members = [ + // CUSTOM:自由文本标签 + tags 提供印花数量(无印花维度不进矩阵的既有语义) + mkMember(1, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }), + mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '水洗', logisticsLabel: '空运' }), + mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }), + ]; + const m = derivePriceMatrix(members, []); + expect(m.printCounts).toEqual(['单面印花', '双面印花']); + expect(m.crafts).toEqual(['烫画', '丝印', '水洗']); + expect(m.logistics).toEqual(['包邮', '海运', '空运']); + }); + + it('人工覆盖新增取值参与同一排序', () => { + const members = [mkMember(1, { tags: ['烫画', '单面印花', '包邮'] })]; + const overrides = [ + { + sizeId: 'size_M', + colorId: 'color_red', + printCount: '四面印花', + craft: '丝印', + logistics: '海运', + price: '40', + }, + ] as any; + const m = derivePriceMatrix(members, overrides); + expect(m.printCounts).toEqual(['单面印花', '四面印花']); + expect(m.crafts).toEqual(['烫画', '丝印']); + expect(m.logistics).toEqual(['包邮', '海运']); + }); + + it('幂等:同输入多跑两遍输出全等(含顺序),与成员遍历顺序无关', () => { + const members = [ + mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }), + mkMember(2, { source: 'CUSTOM', craftLabel: '丝印', logisticsLabel: '海运' }), + mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }), + ]; + const first = derivePriceMatrix(members, []); + const second = derivePriceMatrix(members, []); + const reversed = derivePriceMatrix([...members].reverse(), []); + expect(JSON.stringify(second)).toBe(JSON.stringify(first)); + expect(JSON.stringify(reversed)).toBe(JSON.stringify(first)); + }); +}); diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts index f7ff66c..daa3d26 100644 --- a/apps/api/src/product-families/family-recompute.service.ts +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -71,6 +71,26 @@ const DIM_VALUES = { } as const satisfies Record; type DimKey = keyof typeof DIM_VALUES; + +/** + * 选项组展示顺序(业务确认):printCount 单面→双面;craft 烫画→直喷→不打印;logistics 不包邮→包邮。 + * 与 DIM_VALUES 的"组合生成序"是两回事,物化前对聚合数组按此稳定排序; + * 词表外自由文本(CUSTOM 标签,如"海运")沉底,相互间保持首次遇到序。 + */ +const OPTION_DISPLAY_ORDER = { + printCount: ['单面印花', '双面印花'], + craft: ['烫画', '直喷', '不打印'], + logistics: ['不包邮', '包邮'], +} as const; + +function optionRank(value: string, canon: readonly string[]): number { + const i = canon.indexOf(value); + return i === -1 ? canon.length : i; +} + +function sortOptions(values: string[], canon: readonly string[]): string[] { + return [...values].sort((a, b) => optionRank(a, canon) - optionRank(b, canon)); +} export interface MatrixCombo { printCount: string; craft: string; @@ -268,9 +288,9 @@ export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): return { sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })), colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })), - printCounts, - crafts, - logistics: logisticsOptions, + printCounts: sortOptions(printCounts, OPTION_DISPLAY_ORDER.printCount), + crafts: sortOptions(crafts, OPTION_DISPLAY_ORDER.craft), + logistics: sortOptions(logisticsOptions, OPTION_DISPLAY_ORDER.logistics), rows, }; } @@ -328,6 +348,8 @@ export class FamilyRecomputeService { include: { originGoods: { where: { delisted: false }, + // 固定遍历序:保证"首次遇到序"(未知自由文本取值的相对顺序)确定性,重算幂等 + orderBy: { id: 'asc' }, include: { detail: true, variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } }, From 5f81b183882ef196f27aec93cace2a5e12328bd7 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 16:08:35 +0800 Subject: [PATCH 17/22] docs(agent): add materialized-array order determinism lesson --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 24e0233..a72444e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,3 +103,4 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 - **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。 - **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。 - **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。 +- **"物化数组的顺序必须有确定性来源"**:把聚合结果物化进 JSONB(如 price_matrix 的 printCounts/crafts/logistics)时,"Set 去重保首现序"不是顺序保证——源查询没有 orderBy 时 Postgres 不承诺返回序,同一数据两次重算顺序可能漂移。必须:① 聚合后按业务词表序稳定排序(词表外自由文本沉底);② 源查询显式 `orderBy` 固定遍历序。测试用"打乱输入顺序断言输出全等"覆盖顺序确定性。 From fb85a56444a4d16afd4095b252f7234c4eded73b Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 16:31:26 +0800 Subject: [PATCH 18/22] docs(plan): add heat-transfer craft tag feature plan --- .../heat-transfer-craft-tag-feature.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 plans/feature/heat-transfer-craft-tag-feature.md diff --git a/plans/feature/heat-transfer-craft-tag-feature.md b/plans/feature/heat-transfer-craft-tag-feature.md new file mode 100644 index 0000000..984d9ab --- /dev/null +++ b/plans/feature/heat-transfer-craft-tag-feature.md @@ -0,0 +1,49 @@ +# Feature:印刷工艺新增"热转印"选项 + +日期:2026-09-02 +类型:新功能(feature) +影响面:`apps/api/src/product-families/family-recompute.service.ts`(两处词表常量)+ 测试 + `tags` 表新增一行 + +## 背景 + +业务需要新增印刷工艺"热转印"。标签由 `tags` 表驱动(`GET /tags` 全量返回,管理端打标用),但价格矩阵的维度词表在后端硬编码,需同步放开准入。 + +## 现状(2026-09-02 分析确认) + +- `tags` 表:印刷工艺组(tag_group_id=3)现有 烫画(1)/直喷(2)/不打印(3),有 `POST /tags` 接口(JwtAuthGuard,重名 409) +- 矩阵维度词表 [DIM_VALUES.craft](file:///opt/inkreach-v2/apps/api/src/product-families/family-recompute.service.ts) = `['烫画','直喷','不打印']` 硬编码,`memberMatrixCombos` 按它过滤链接标签——词表外的标签会导致链接不进矩阵 +- 展示序词表 `OPTION_DISPLAY_ORDER.craft` 同样硬编码,词表外沉底 +- 名称自动派生规则(`auto-tag-rules.ts` / admin `origin-name.ts`)**本次不改**:SDS 链接不会自动派生热转印,只能人工打标 + +## 方案 + +1. `DIM_VALUES.craft` 追加 `'热转印'`(末尾)——矩阵认可该工艺标签,SDS/CUSTOM 链接人工打标后均可进矩阵 +2. `OPTION_DISPLAY_ORDER.craft` 追加 `'热转印'`(末尾)——按钮组显示在 不打印 之后(业务确认排序表先不改,放最后) +3. `POST /tags { tagName: '热转印', tagGroupId: '3', sortOrder: 4 }` 创建标签行(管理端标签栏立即可见/可选) +4. **不给任何存量链接打标**——纯增量能力,存量族矩阵零变化 + +## 明确不做 + +- 不改自动派生规则(名称含"热转印"仍派生默认烫画,如需后续再议) +- 不给存量链接打标、不重算存量族 +- 不动 自建商品 craftLabel 维度填错的历史数据 + +## 测试计划(TDD) + +测试文件:`family-recompute.service.spec.ts`(memberMatrixCombos / derivePriceMatrix 纯函数) + +1. `memberMatrixCombos`:链接标签 [单面印花, 热转印, 包邮] → 产出 `{ printCount:'单面印花', craft:'热转印', logistics:'包邮' }`(修复前被词表过滤 → combos 为空,RED) +2. `derivePriceMatrix`:crafts 输出 `烫画→直喷→不打印→热转印`(热转印作为词表内值参与排序,不再沉底) +3. 既有"未知自由文本沉底"用例(丝印/水洗)仍应沉底——热转印是词表内,丝印/水洗仍是词表外 +4. 全量 jest + tsc 通过 + +## 线上验证 + +1. `GET /tags` 返回 热转印(tagGroupId=3, sortOrder=4) +2. 抽查存量族 priceMatrix.crafts 与改动前一致(无新增值) +3. 无链接打标 → 公开详情 crafts 不含 热转印 + +## 回滚 + +- 代码:revert 两行常量(未打标状态零影响) +- 标签:无引用可直接删(`origin_good_tags` 无该行) From 8903afdc822ea3d350eb328dfa4036e4ca184a3e Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 16:33:41 +0800 Subject: [PATCH 19/22] =?UTF-8?q?feat(product-families):=20add=20heat-tran?= =?UTF-8?q?sfer=20(=E7=83=AD=E8=BD=AC=E5=8D=B0)=20craft=20to=20matrix=20vo?= =?UTF-8?q?cabularies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append 热转印 to DIM_VALUES.craft so links manually tagged with the new tag enter the price matrix (previously the closed vocabulary filtered it out and the link's prices would silently disappear), and to OPTION_DISPLAY_ORDER.craft so the button group renders it after 不打印. No derivation rules changed: existing links are unaffected until an operator explicitly tags them. --- .../family-recompute.service.spec.ts | 52 ++++++++++++++++++- .../family-recompute.service.ts | 6 +-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/apps/api/src/product-families/family-recompute.service.spec.ts b/apps/api/src/product-families/family-recompute.service.spec.ts index b238c99..efd7cbb 100644 --- a/apps/api/src/product-families/family-recompute.service.spec.ts +++ b/apps/api/src/product-families/family-recompute.service.spec.ts @@ -1,6 +1,10 @@ import { Test } from '@nestjs/testing'; import { Prisma } from '@prisma/client'; -import { FamilyRecomputeService, derivePriceMatrix } from './family-recompute.service'; +import { + FamilyRecomputeService, + derivePriceMatrix, + memberMatrixCombos, +} from './family-recompute.service'; import { ProductFamiliesService } from './product-families.service'; import { OrganizeService } from './organize.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -441,7 +445,7 @@ describe('derivePriceMatrix 选项组排序', () => { it('幂等:同输入多跑两遍输出全等(含顺序),与成员遍历顺序无关', () => { const members = [ mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }), - mkMember(2, { source: 'CUSTOM', craftLabel: '丝印', logisticsLabel: '海运' }), + mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }), mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }), ]; const first = derivePriceMatrix(members, []); @@ -451,3 +455,47 @@ describe('derivePriceMatrix 选项组排序', () => { expect(JSON.stringify(reversed)).toBe(JSON.stringify(first)); }); }); + +/** + * 印刷工艺新增"热转印":矩阵维度词表放开准入 + 展示序追加末尾。 + * 自动派生规则不改 —— SDS 链接只能人工打标;不打标的链接/族零影响。 + */ +describe('热转印工艺', () => { + const mkMember = ( + id: number, + over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null }, + ) => + ({ + id: BigInt(id), + source: over.source ?? 'SDS', + originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })), + variants: [], + craftLabel: over.craftLabel ?? null, + logisticsLabel: over.logisticsLabel ?? null, + }) as any; + + it('memberMatrixCombos:打标热转印的链接产出矩阵组合(此前被词表过滤为空)', () => { + const combos = memberMatrixCombos({ tagNames: ['单面印花', '热转印', '包邮'] }); + expect(combos).toEqual([{ printCount: '单面印花', craft: '热转印', logistics: '包邮' }]); + }); + + it('derivePriceMatrix:crafts 展示序为 烫画→直喷→不打印→热转印(词表内,不再沉底)', () => { + const members = [ + mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }), + mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }), + mkMember(3, { tags: ['直喷', '单面印花', '不包邮'] }), + mkMember(4, { tags: ['不打印', '单面印花', '包邮'] }), + ]; + const m = derivePriceMatrix(members, []); + expect(m.crafts).toEqual(['烫画', '直喷', '不打印', '热转印']); + }); + + it('词表外自由文本(丝印/水洗)仍然沉底,与热转印区分', () => { + const members = [ + mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }), + mkMember(2, { source: 'CUSTOM', tags: ['单面印花'], craftLabel: '丝印', logisticsLabel: '包邮' }), + ]; + const m = derivePriceMatrix(members, []); + expect(m.crafts).toEqual(['热转印', '丝印']); + }); +}); diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts index daa3d26..c3b3ad0 100644 --- a/apps/api/src/product-families/family-recompute.service.ts +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -66,20 +66,20 @@ type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>; /** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */ const DIM_VALUES = { printCount: ['单面印花', '双面印花'], - craft: ['烫画', '直喷', '不打印'], + craft: ['烫画', '直喷', '不打印', '热转印'], logistics: ['包邮', '不包邮'], } as const satisfies Record; type DimKey = keyof typeof DIM_VALUES; /** - * 选项组展示顺序(业务确认):printCount 单面→双面;craft 烫画→直喷→不打印;logistics 不包邮→包邮。 + * 选项组展示顺序(业务确认):printCount 单面→双面;craft 烫画→直喷→不打印→热转印;logistics 不包邮→包邮。 * 与 DIM_VALUES 的"组合生成序"是两回事,物化前对聚合数组按此稳定排序; * 词表外自由文本(CUSTOM 标签,如"海运")沉底,相互间保持首次遇到序。 */ const OPTION_DISPLAY_ORDER = { printCount: ['单面印花', '双面印花'], - craft: ['烫画', '直喷', '不打印'], + craft: ['烫画', '直喷', '不打印', '热转印'], logistics: ['不包邮', '包邮'], } as const; From b81d944c386d2b68b8049a8a6c0e928a65bd0849 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Wed, 2 Sep 2026 16:37:56 +0800 Subject: [PATCH 20/22] docs(agent): add dimension-value onboarding lesson --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index a72444e..0a4717f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,3 +104,4 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 - **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。 - **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。 - **"物化数组的顺序必须有确定性来源"**:把聚合结果物化进 JSONB(如 price_matrix 的 printCounts/crafts/logistics)时,"Set 去重保首现序"不是顺序保证——源查询没有 orderBy 时 Postgres 不承诺返回序,同一数据两次重算顺序可能漂移。必须:① 聚合后按业务词表序稳定排序(词表外自由文本沉底);② 源查询显式 `orderBy` 固定遍历序。测试用"打乱输入顺序断言输出全等"覆盖顺序确定性。 +- **"新增维度取值 = 标签行 + 两处词表常量,缺一不可"**:工艺/物流/印花数量这类封闭维度新增可选值时,只往 `tags` 表插行不够——矩阵维度词表 `DIM_VALUES` 和展示序 `OPTION_DISPLAY_ORDER` 是硬编码常量,必须同步追加,否则打了新标签的链接会被词表过滤导致价格格子静默消失(比报错更危险)。同时确认自动派生规则(auto-tag-rules / admin origin-name)是否要认识新值;存量数据零影响的前提是"只放开准入,不主动打标"。 From 9eafac7204e0f2368d3d2fda03ce8751ad7303d8 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 3 Sep 2026 01:04:22 +0800 Subject: [PATCH 21/22] =?UTF-8?q?fix(goods):=20=E5=95=86=E5=93=81=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=90=8D=E7=BB=9F=E4=B8=80=E3=80=8C=E5=93=81=E5=90=8D?= =?UTF-8?q?=20SKU=E3=80=8D=E2=80=94=E2=80=94=E5=89=A5=20ASCII=20=E5=9E=8B?= =?UTF-8?q?=E5=8F=B7=E9=99=90=E5=AE=9A=E8=AF=8D=E3=80=81=E7=BA=AF=E6=AC=BE?= =?UTF-8?q?=E5=8F=B7=E8=A1=A5=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 左栏商品名显示成光款号(如 PLTK016)的根因是双层解析不一致:api 写路径按 首括号对解析产出「童装…(DTG180) PLTK016」,admin 展示解析按末括号取品名 误显示成 PLTK016。 - admin parseLinkName 对齐 api 首括号对解析 + 无 SKU 段中文品名守卫, cleanLinkName 剥离 ASCII 型号限定词(中文括号内容保留) - api 新增 cleanGoodDisplayName/describePureSkuGoodName,接入 create/update/ batchCreate(batchCreate 补上缺失的 normalize);纯款号按 SDS 分类名补描述 - 新增幂等存量修复脚本 fix:good-names(默认 dry-run,--apply 写库) - 已执行:V2 栈 34 条限定词名、V1 栈 213 条原始链接名规范化,复跑幂等 0 Tests: api 22 suites/199 passed, admin 27 passed, tsc clean --- AGENTS.md | 5 + apps/admin/src/utils/origin-name.spec.ts | 32 +++ apps/admin/src/utils/origin-name.ts | 35 +++- apps/api/package.json | 2 + apps/api/prisma/fix-pure-sku-good-names.ts | 42 ++++ apps/api/src/goods/goods.service.spec.ts | 224 ++++++++++++++++++++- apps/api/src/goods/goods.service.ts | 177 +++++++++++++++- docs/references/product-center.md | 18 +- docs/references/structs.md | 8 +- plans/fix/pure-sku-good-name-fix.md | 77 +++++++ 10 files changed, 605 insertions(+), 15 deletions(-) create mode 100644 apps/api/prisma/fix-pure-sku-good-names.ts create mode 100644 plans/fix/pure-sku-good-name-fix.md diff --git a/AGENTS.md b/AGENTS.md index 0a4717f..a2161aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,3 +105,8 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 - **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。 - **"物化数组的顺序必须有确定性来源"**:把聚合结果物化进 JSONB(如 price_matrix 的 printCounts/crafts/logistics)时,"Set 去重保首现序"不是顺序保证——源查询没有 orderBy 时 Postgres 不承诺返回序,同一数据两次重算顺序可能漂移。必须:① 聚合后按业务词表序稳定排序(词表外自由文本沉底);② 源查询显式 `orderBy` 固定遍历序。测试用"打乱输入顺序断言输出全等"覆盖顺序确定性。 - **"新增维度取值 = 标签行 + 两处词表常量,缺一不可"**:工艺/物流/印花数量这类封闭维度新增可选值时,只往 `tags` 表插行不够——矩阵维度词表 `DIM_VALUES` 和展示序 `OPTION_DISPLAY_ORDER` 是硬编码常量,必须同步追加,否则打了新标签的链接会被词表过滤导致价格格子静默消失(比报错更危险)。同时确认自动派生规则(auto-tag-rules / admin origin-name)是否要认识新值;存量数据零影响的前提是"只放开准入,不主动打标"。 +- **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态。 +- **严禁直接热修运行容器内的代码**:本次线上 v2 镜像内被塞过未提交的词表改动(热转印),仓库重建镜像即复发且难排查;所有修复必须落在仓库并重建部署。排查"线上行为与代码不符"时先 `md5sum` 比对容器内源文件与仓库。 +- **两端"同构"解析器必须连测试用例也同构**:admin 与 api 各有一份链接名解析器,本次展示 bug(`品名(DTG180) SKU` 显示成光款号)正是两端取括号策略不一致(api 首括号对 vs admin 末括号)所致。改任何一端解析行为时,另一端同样输入的用例必须同步补上(origin-name.spec ↔ origin-name.parser.spec/goods.service.spec)。 +- **共享开发库不可达时用一次性 docker postgres 跑集成测试**:`docker run -d --name inkreach-test-pg -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=inkreach_test -p 127.0.0.1:54329:5432 postgres:16-alpine` → `DATABASE_URL=… npx prisma migrate deploy` → jest/vitest 指向该库;用完 `docker rm -f`。夹具自包含的套件在新库上直接绿。 +- **宿主机即部署机时,改生产数据前先 `docker inspect` 拿容器真实注入的环境变量**:deploy/.env 里的密码可能与运行容器不一致(v2 栈独立 env);对两个栈的库做数据修复时,dry-run 清单必须逐栈分别核对后再 apply。 diff --git a/apps/admin/src/utils/origin-name.spec.ts b/apps/admin/src/utils/origin-name.spec.ts index 6ae3737..ec589d4 100644 --- a/apps/admin/src/utils/origin-name.spec.ts +++ b/apps/admin/src/utils/origin-name.spec.ts @@ -80,6 +80,38 @@ describe('parseLinkName / cleanLinkName', () => { }); }); +describe('已规范化的「品名(型号限定词) SKU」防误解析 + 限定词剥离', () => { + it('品名内含(限定词)的已规范化名不再解析(否则展示会变成光款号)', () => { + expect(parseLinkName('童装纯色插肩短袖T恤(DTG180) PLTK016')).toBeNull(); + expect(parseLinkName('牛奶丝T恤(女款) DG601')).toBeNull(); + expect(parseLinkName('180G纯棉T恤 (JSA002) DG001')).toBeNull(); + }); + + it('cleanLinkName 对已规范化名剥离 ASCII 型号限定词', () => { + expect(cleanLinkName('童装纯色插肩短袖T恤(DTG180) PLTK016')).toBe( + '童装纯色插肩短袖T恤 PLTK016', + ); + expect(cleanLinkName('180G纯棉T恤 (JSA002) DG001')).toBe('180G纯棉T恤 DG001'); + }); + + it('原始链接名品名内含限定词时,解析后一并剥离', () => { + expect( + cleanLinkName('波兰(包邮)童装纯色插肩短袖T恤(DTG180)-PLTK016-双面印花'), + ).toBe('童装纯色插肩短袖T恤 PLTK016'); + }); + + it('中文括号内容(如(女款))不剥离', () => { + expect(cleanLinkName('牛奶丝T恤(女款) DG601')).toBe('牛奶丝T恤(女款) DG601'); + }); + + it('无 SKU 段的单段链接名仍按首括号对解析(与 api 端一致)', () => { + expect(parseLinkName('波兰(包邮)童装T恤')).toEqual({ + productName: '童装T恤', + skuCode: null, + }); + }); +}); + describe('deriveLinkTagNames', () => { it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => { expect(deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual([ diff --git a/apps/admin/src/utils/origin-name.ts b/apps/admin/src/utils/origin-name.ts index 935c7b1..fc68608 100644 --- a/apps/admin/src/utils/origin-name.ts +++ b/apps/admin/src/utils/origin-name.ts @@ -27,27 +27,46 @@ export interface ParsedLinkName { /** * 解析链接名称:`国家(物流备注)品名-SKU-工艺位置[-·仓库名]` → 品名 + 型号。 - * 与 api 端 origin-name.parser.ts 同构;解析失败(无「国家(备注)」前缀结构)返回 null, - * 调用方应回退显示原始名称 —— 手动维护的品名不走该解析。 + * 与 api 端 origin-name.parser.ts 同构(首括号对解析,半角/全角混用先归一); + * 解析失败(无「国家(备注)」前缀结构)返回 null,调用方应回退显示原始名称 + * —— 手动维护的品名不走该解析。 */ export function parseLinkName(name: string | null | undefined): ParsedLinkName | null { if (!name) return null; const segs = name.split('-').map((s) => s.trim()); const head = segs[0] ?? ''; - const closeIdx = Math.max(head.lastIndexOf(')'), head.lastIndexOf(')')); - if (closeIdx <= 0 || closeIdx === head.length - 1) return null; - const productName = head.slice(closeIdx + 1).trim(); + const normalized = head.replace(/\(/g, '(').replace(/\)/g, ')'); + const openAt = normalized.indexOf('('); + const closeAt = openAt >= 0 ? normalized.indexOf(')', openAt) : -1; + if (closeAt <= openAt || closeAt === normalized.length - 1) return null; + const productName = normalized.slice(closeAt + 1).trim(); if (!productName) return null; + // 无 SKU 段时仅当括号后的品名含中文才视为链接名 —— 防把已规范化的 + // 「品名(型号限定词) SKU」(如「童装…(DTG180) PLTK016」)的尾段款号误当品名 + if (segs.length < 2 && !/[\u4e00-\u9fff]/.test(productName)) return null; const skuCode = segs[1] || null; return { productName, skuCode }; } -/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名 */ +/** ASCII 型号限定词括号组(SDS 品名内的供应商/工艺型号,如(DTG180)(JSA002)) */ +const ASCII_PAREN_GROUP = /(?:(|\()[A-Za-z0-9]+(?:)|\))/g; + +/** 展示名剥离 ASCII 型号限定词;剥空(名称仅剩限定词)时原样返回 */ +export function stripAsciiParenQualifiers(name: string): string { + const stripped = name.replace(ASCII_PAREN_GROUP, ' ').replace(/\s+/g, ' ').trim(); + return stripped || name; +} + +/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名。品名内的 ASCII 型号限定词一并剥离 */ export function cleanLinkName(name: string | null | undefined): string { if (!name) return ''; const parsed = parseLinkName(name); - if (!parsed) return name; - return parsed.skuCode ? `${parsed.productName} ${parsed.skuCode}` : parsed.productName; + const base = parsed + ? parsed.skuCode + ? `${parsed.productName} ${parsed.skuCode}` + : parsed.productName + : name; + return stripAsciiParenQualifiers(base); } const CRAFT_KEYWORD_MAP: Record = { diff --git a/apps/api/package.json b/apps/api/package.json index 132e526..197efa2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,8 @@ "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts", "import:product-detail": "ts-node prisma/import-product-detail.ts", "backfill:product-families": "ts-node prisma/backfill-product-families.ts", + "backfill:product-families": "ts-node prisma/backfill-product-families.ts", + "fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts", "organize": "ts-node prisma/backfill-product-families.ts", "recompute:families": "ts-node prisma/recompute-all-families.ts" }, diff --git a/apps/api/prisma/fix-pure-sku-good-names.ts b/apps/api/prisma/fix-pure-sku-good-names.ts new file mode 100644 index 0000000..8e9a06a --- /dev/null +++ b/apps/api/prisma/fix-pure-sku-good-names.ts @@ -0,0 +1,42 @@ +/** + * 存量修复脚本(幂等):把纯款号商品名(如 `PLTK016`)按主链接 SDS 分类名 + * 补成「描述名 款号」(如 `童装纯色插肩短袖T恤 PLTK016`)。 + * + * 运行: + * pnpm --filter @inkreach/api fix:good-names # dry-run,只打印清单 + * pnpm --filter @inkreach/api fix:good-names -- --apply # 实际写库 + * origin_goods.good_name 为 SDS 纯镜像(每小时同步覆盖),本脚本只改 goods.good_name。 + */ +import { PrismaService } from '../src/prisma/prisma.service'; +import { SyncService } from '../src/sync/sync.service'; +import { FamilyRecomputeService } from '../src/product-families/family-recompute.service'; +import { GoodsService } from '../src/goods/goods.service'; + +async function main() { + const dryRun = !process.argv.includes('--apply'); + const prisma = new PrismaService(); + await prisma.onModuleInit(); + const recompute = new FamilyRecomputeService(prisma); + // 回填路径不触发详情同步,SyncService 仅作占位依赖 + const goods = new GoodsService( + prisma, + { queueProductDetailSync: async () => undefined } as unknown as SyncService, + recompute, + ); + + const result = await goods.backfillPureSkuGoodNames({ dryRun }); + const mode = dryRun ? 'DRY-RUN(未写库,加 --apply 执行)' : 'APPLIED'; + console.log(`[fix:good-names] ${mode} renamed=${result.renamed} unmapped=${result.unmapped.length}`); + for (const r of result.renames) { + console.log(` #${r.id} ${r.from} -> ${r.to}`); + } + for (const u of result.unmapped) { + console.log(` #${u.id} ${u.from} -> (无分类映射,保留原名)`); + } + await prisma.onModuleDestroy(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/api/src/goods/goods.service.spec.ts b/apps/api/src/goods/goods.service.spec.ts index 839c587..c2119af 100644 --- a/apps/api/src/goods/goods.service.spec.ts +++ b/apps/api/src/goods/goods.service.spec.ts @@ -3,7 +3,12 @@ import { BadRequestException, NotFoundException, } from '@nestjs/common'; -import { GoodsService } from './goods.service'; +import { + cleanGoodDisplayName, + describePureSkuGoodName, + GoodsService, + isPureSkuGoodName, +} from './goods.service'; import { PrismaService } from '../prisma/prisma.service'; import { SyncService } from '../sync/sync.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; @@ -312,6 +317,223 @@ describe('GoodsService', () => { }); }); + describe('pure sku good name(纯款号补描述)', () => { + const pskuCode = `PSKU${stamp}`; + const pskuDesc = `童装纯色测试插肩T恤${stamp}`; + let pskuOriginGoodId: bigint; + let bareOriginGoodId: bigint; + + beforeAll(async () => { + await prisma.category.create({ + data: { + categoryName: `${pskuCode} ${pskuDesc}`, + sdsCategoryId: `psku-cat-${stamp}`, + }, + }); + const pskuOg = await prisma.originGood.create({ + data: { + sdsGoodId: `sds-psku-${stamp}`, + goodName: pskuCode, + sdsCategoryId: `psku-cat-${stamp}`, + }, + }); + pskuOriginGoodId = pskuOg.id; + const bareOg = await prisma.originGood.create({ + data: { + sdsGoodId: `sds-psku-bare-${stamp}`, + goodName: `PSKB${stamp}`, + }, + }); + bareOriginGoodId = bareOg.id; + }); + + afterAll(async () => { + await prisma.good.deleteMany({ + where: { OR: [{ goodName: { contains: pskuCode } }, { goodName: { contains: `PSKB${stamp}` } }] }, + }); + await prisma.originGood.deleteMany({ + where: { id: { in: [pskuOriginGoodId, bareOriginGoodId] } }, + }); + await prisma.category.deleteMany({ + where: { sdsCategoryId: `psku-cat-${stamp}` }, + }); + }); + + it('isPureSkuGoodName:仅纯字母数字为真', () => { + expect(isPureSkuGoodName('PLTK016')).toBe(true); + expect(isPureSkuGoodName(' pltk016 ')).toBe(true); + expect(isPureSkuGoodName('PSKU1')).toBe(true); + expect(isPureSkuGoodName('童装纯色插肩短袖T恤 PLTK016')).toBe(false); + expect(isPureSkuGoodName('德国(不包邮)T恤-DG001-烫画')).toBe(false); + expect(isPureSkuGoodName('230g水洗T恤 DETM002')).toBe(false); + expect(isPureSkuGoodName('P')).toBe(false); + expect(isPureSkuGoodName('')).toBe(false); + }); + + it('cleanGoodDisplayName:品名内的 ASCII 型号限定词剥离,中文括号内容保留', () => { + expect( + cleanGoodDisplayName('波兰(包邮)童装纯色插肩短袖T恤(DTG180)-PLTK016-双面印花'), + ).toBe('童装纯色插肩短袖T恤 PLTK016'); + expect(cleanGoodDisplayName('童装纯色插肩短袖T恤(DTG180) PLTK016')).toBe( + '童装纯色插肩短袖T恤 PLTK016', + ); + expect(cleanGoodDisplayName('180G纯棉T恤 (JSA002) DG001')).toBe('180G纯棉T恤 DG001'); + expect(cleanGoodDisplayName('牛奶丝T恤(女款) DG601')).toBe('牛奶丝T恤(女款) DG601'); + expect(cleanGoodDisplayName('230g水洗T恤 DETM002')).toBe('230g水洗T恤 DETM002'); + }); + + it('describePureSkuGoodName:标准分类名剥款号补描述', () => { + expect( + describePureSkuGoodName('PLTK016', 'PLTK016 童装纯色插肩短袖T恤'), + ).toBe('童装纯色插肩短袖T恤 PLTK016'); + }); + + it('describePureSkuGoodName:分类名无款号前缀时用全名', () => { + expect( + describePureSkuGoodName('PLTK016', '童装纯色插肩短袖T恤'), + ).toBe('童装纯色插肩短袖T恤 PLTK016'); + }); + + it('describePureSkuGoodName:分类名即款号/空分类名/非纯款号名原样返回', () => { + expect(describePureSkuGoodName('PLTK016', 'PLTK016')).toBe('PLTK016'); + expect(describePureSkuGoodName('PLTK016', null)).toBe('PLTK016'); + expect(describePureSkuGoodName('PLTK016', undefined)).toBe('PLTK016'); + expect( + describePureSkuGoodName('童装T恤 PLTK016', 'PLTK016 童装T恤'), + ).toBe('童装T恤 PLTK016'); + expect(describePureSkuGoodName('230g水洗T恤 DETM002', 'PLTK016 童装T恤')).toBe( + '230g水洗T恤 DETM002', + ); + }); + + it('describePureSkuGoodName:分类名中的 ASCII 型号限定词一并剥离', () => { + expect( + describePureSkuGoodName('PLTK016', 'PLTK016 童装纯色插肩短袖T恤(DTG180)'), + ).toBe('童装纯色插肩短袖T恤 PLTK016'); + }); + + it('create:纯款号名按主链接 SDS 分类名补描述', async () => { + const created = await service.create({ + goodName: pskuCode, + originGoodId: Number(pskuOriginGoodId), + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + try { + expect(created.goodName).toBe(`${pskuDesc} ${pskuCode}`); + } finally { + await prisma.good.delete({ where: { id: BigInt(created.id) } }); + } + }); + + it('create:主链接无 sdsCategoryId 映射时纯款号原样保留', async () => { + const created = await service.create({ + goodName: `PSKB${stamp}`, + originGoodId: Number(bareOriginGoodId), + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + try { + expect(created.goodName).toBe(`PSKB${stamp}`); + } finally { + await prisma.good.delete({ where: { id: BigInt(created.id) } }); + } + }); + + it('update:改成纯款号名按商品主链接分类名补描述', async () => { + const created = await service.create({ + goodName: `Goods Test ${stamp} psku-rename`, + originGoodId: Number(pskuOriginGoodId), + countryId: Number(countryId), + categoryId: Number(categoryId), + }); + try { + const updated = await service.update(BigInt(created.id), { + goodName: pskuCode, + }); + expect(updated.goodName).toBe(`${pskuDesc} ${pskuCode}`); + } finally { + await prisma.good.delete({ where: { id: BigInt(created.id) } }); + } + }); + + it('batchCreate:纯款号链接名补描述、完整链接名规范化', async () => { + const created = await service.batchCreate({ + countryId: Number(countryId), + categoryId: Number(categoryId), + items: [{ originGoodId: Number(pskuOriginGoodId) }], + }); + try { + expect(created[0]?.goodName).toBe(`${pskuDesc} ${pskuCode}`); + } finally { + await prisma.good.deleteMany({ + where: { id: { in: created.map((g) => BigInt(g.id)) } }, + }); + } + }); + + it('backfillPureSkuGoodNames:dry-run 只列不改,apply 改名且幂等', async () => { + // 绕过写路径直写库,模拟存量纯款号名 + const row = await prisma.good.create({ + data: { + goodName: pskuCode, + originGoodId: pskuOriginGoodId, + countryId, + categoryId, + }, + }); + try { + // dry-run / apply 均用 goodId 限定在本夹具,避免测试副作用波及共享库真实数据 + const dry = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id }); + expect(dry.renames).toEqual([ + { id: row.id.toString(), from: pskuCode, to: `${pskuDesc} ${pskuCode}` }, + ]); + expect( + (await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName, + ).toBe(pskuCode); + + const applied = await service.backfillPureSkuGoodNames({ dryRun: false, goodId: row.id }); + expect(applied.renamed).toBe(1); + expect( + (await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName, + ).toBe(`${pskuDesc} ${pskuCode}`); + + // 幂等:已改名的行不再出现在后续清单 + const again = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id }); + expect(again.renames).toEqual([]); + } finally { + await prisma.good.delete({ where: { id: row.id } }); + } + }); + + it('backfillPureSkuGoodNames:剥离「品名(型号限定词) SKU」存量名中的限定词', async () => { + const row = await prisma.good.create({ + data: { + goodName: `${pskuDesc}(DTG180) ${pskuCode}`, + originGoodId: pskuOriginGoodId, + countryId, + categoryId, + }, + }); + try { + const dry = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id }); + expect(dry.renames).toEqual([ + { + id: row.id.toString(), + from: `${pskuDesc}(DTG180) ${pskuCode}`, + to: `${pskuDesc} ${pskuCode}`, + }, + ]); + await service.backfillPureSkuGoodNames({ dryRun: false, goodId: row.id }); + expect( + (await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName, + ).toBe(`${pskuDesc} ${pskuCode}`); + } finally { + await prisma.good.delete({ where: { id: row.id } }); + } + }); + }); + describe('merged origin goods', () => { it('creates a good with merged origin goods and reads them back', async () => { const created = await service.create({ diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index d7a99f7..b5ea7ce 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -72,6 +72,48 @@ export function normalizeGoodName(name: string): string { return name; } +/** ASCII 型号限定词括号组(SDS 品名内的供应商/工艺型号,如(DTG180)(JSA002)) */ +const ASCII_PAREN_GROUP = /(?:(|\()[A-Za-z0-9]+(?:)|\))/g; + +/** 展示名剥离 ASCII 型号限定词;剥空(名称仅剩限定词)时原样返回 */ +export function stripAsciiParenQualifiers(name: string): string { + const stripped = name.replace(ASCII_PAREN_GROUP, ' ').replace(/\s+/g, ' ').trim(); + return stripped || name; +} + +/** + * 商品展示名清洗:规范化 + 剥离 ASCII 型号限定词。 + * SDS 品名常带型号限定词(如 `童装…(DTG180)`),展示名统一剥成「品名 SKU」。 + */ +export function cleanGoodDisplayName(name: string): string { + return stripAsciiParenQualifiers(normalizeGoodName(name)); +} + +/** 纯款号名:仅字母/数字(如 `PLTK016`),链接名解析不出品名即此类 */ +export function isPureSkuGoodName(name: string): boolean { + return /^[A-Za-z0-9]{2,}$/.test(name.trim()); +} + +/** + * 纯款号展示名兜底:用 SDS 分类名补描述。 + * `PLTK016` + 分类名 `PLTK016 童装纯色插肩短袖T恤` → `童装纯色插肩短袖T恤 PLTK016`。 + * 分类名首 token 仅当为纯字母数字时视为款号剥离(与 auto-group 的 + * codeFromCategoryName 语义一致),描述中的 ASCII 型号限定词一并剥离; + * 非纯款号名、无分类名或剥后为空时原样返回。 + */ +export function describePureSkuGoodName( + name: string, + categoryName: string | null | undefined, +): string { + if (!isPureSkuGoodName(name) || !categoryName?.trim()) return name; + const sku = name.trim(); + const tokens = categoryName.trim().split(/\s+/); + const desc = stripAsciiParenQualifiers( + (/^[A-Za-z0-9]+$/.test(tokens[0] ?? '') ? tokens.slice(1) : tokens).join(' '), + ); + return desc ? `${desc} ${sku}` : name; +} + @Injectable() export class GoodsService { constructor( @@ -149,12 +191,12 @@ export class GoodsService { // Good 的族是派生数据:主链接所属族 const primary = await tx.originGood.findUnique({ where: { id: BigInt(dto.originGoodId) }, - select: { familyId: true }, + select: { familyId: true, sdsCategoryId: true }, }); const tagIds = await this.stripAutoGroupTags(dto.tagIds ?? [], primary?.familyId ?? null); const created = await tx.good.create({ data: { - goodName: normalizeGoodName(dto.goodName), + goodName: await this.resolveGoodName(tx, dto.goodName, primary?.sdsCategoryId), goodImage: dto.goodImage, originGoodId: BigInt(dto.originGoodId), familyId: primary?.familyId ?? null, @@ -344,7 +386,13 @@ export class GoodsService { await this.ensureMergedOriginGoods(mergedIds); } const data: Prisma.GoodUpdateInput = {}; - if (dto.goodName !== undefined) data.goodName = normalizeGoodName(dto.goodName); + if (dto.goodName !== undefined) { + data.goodName = await this.resolveGoodName( + this.prisma, + dto.goodName, + await this.sdsCategoryIdForGood(id, dto.originGoodId), + ); + } if (dto.originGoodId !== undefined) { await this.ensureOriginGood(dto.originGoodId); data.originGood = { connect: { id: BigInt(dto.originGoodId) } }; @@ -518,7 +566,11 @@ export class GoodsService { } const row = await tx.good.create({ data: { - goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`, + goodName: await this.resolveGoodName( + tx, + og.goodName ?? `Origin Good ${og.sdsGoodId}`, + og.sdsCategoryId, + ), goodImage: og.goodImage, originGoodId: og.id, familyId: og.familyId, @@ -585,6 +637,81 @@ export class GoodsService { return result; } + /** + * 存量修复(幂等):商品展示名统一为「品名 SKU」—— + * 1. 剥离 ASCII 型号限定词(如 `童装…(DTG180) PLTK016` → `童装… PLTK016`), + * 顺带把结构完整的原始链接名规范化; + * 2. 清洗后仍为纯款号(如 `PLTK016`)的,按主链接 SDS 分类名补描述。 + * dryRun 只返回清单不写库;goodId 可将范围限定到单个商品(测试用)。 + * 纯款号但无分类映射(补不出描述名)的行保持原样并计入 unmapped。 + */ + async backfillPureSkuGoodNames(opts: { + dryRun: boolean; + goodId?: bigint; + }): Promise<{ + applied: boolean; + renamed: number; + renames: { id: string; from: string; to: string }[]; + unmapped: { id: string; from: string }[]; + }> { + const rows = await this.prisma.good.findMany({ + where: opts.goodId !== undefined ? { id: opts.goodId } : {}, + select: { + id: true, + goodName: true, + originGood: { select: { sdsCategoryId: true } }, + }, + }); + const cleaned = rows.map((row) => { + const name = cleanGoodDisplayName(row.goodName); + return { row, name, pure: isPureSkuGoodName(name) }; + }); + const sdsCategoryIds = [ + ...new Set( + cleaned + .filter((c) => c.pure) + .map((c) => c.row.originGood?.sdsCategoryId) + .filter((v): v is string => Boolean(v)), + ), + ]; + const categories = sdsCategoryIds.length + ? await this.prisma.category.findMany({ + where: { sdsCategoryId: { in: sdsCategoryIds } }, + select: { sdsCategoryId: true, categoryName: true }, + }) + : []; + const categoryNameBySdsId = new Map( + categories.map((c) => [c.sdsCategoryId, c.categoryName]), + ); + + const renames: { id: string; from: string; to: string }[] = []; + const unmapped: { id: string; from: string }[] = []; + for (const { row, name, pure } of cleaned) { + const categoryName = + pure && row.originGood?.sdsCategoryId + ? categoryNameBySdsId.get(row.originGood.sdsCategoryId) + : undefined; + const to = describePureSkuGoodName(name, categoryName); + if (to === row.goodName) { + if (pure) unmapped.push({ id: row.id.toString(), from: row.goodName }); + } else { + renames.push({ id: row.id.toString(), from: row.goodName, to }); + } + } + + if (!opts.dryRun && renames.length > 0) { + await this.prisma.$transaction( + renames.map((r) => + this.prisma.good.update({ + where: { id: BigInt(r.id) }, + data: { goodName: r.to }, + }), + ), + ); + } + return { applied: !opts.dryRun, renamed: renames.length, renames, unmapped }; + } + /** * Walk the category tree and return the requested id + all of its * descendants. We use a level-by-level BFS to keep the queries small @@ -606,6 +733,48 @@ export class GoodsService { return ids; } + /** + * 写路径名称兜底:规范化后仍为纯款号(如 `PLTK016`,SDS 部分链接名只有款号) + * 时,用主链接 SDS 分类名(形如 `PLTK016 童装纯色插肩短袖T恤`)补成「描述名 款号」。 + * 非纯款号名不做任何额外查询。 + */ + private async resolveGoodName( + db: Pick, + rawName: string, + sdsCategoryId: string | null | undefined, + ): Promise { + const normalized = cleanGoodDisplayName(rawName); + if (!isPureSkuGoodName(normalized) || !sdsCategoryId) return normalized; + const category = await db.category.findUnique({ + where: { sdsCategoryId }, + select: { categoryName: true }, + }); + return describePureSkuGoodName(normalized, category?.categoryName); + } + + /** 商品主链接(可为本次切换后的)的 sdsCategoryId;无链接或未分类返回 null */ + private async sdsCategoryIdForGood( + goodId: bigint, + overrideOriginGoodId?: number, + ): Promise { + let originGoodId: bigint | null = null; + if (overrideOriginGoodId !== undefined) { + originGoodId = BigInt(overrideOriginGoodId); + } else { + const good = await this.prisma.good.findUnique({ + where: { id: goodId }, + select: { originGoodId: true }, + }); + originGoodId = good?.originGoodId ?? null; + } + if (originGoodId === null) return null; + const og = await this.prisma.originGood.findUnique({ + where: { id: originGoodId }, + select: { sdsCategoryId: true }, + }); + return og?.sdsCategoryId ?? null; + } + private async ensureOriginGood(id: number) { const og = await this.prisma.originGood.findUnique({ where: { id: BigInt(id) }, diff --git a/docs/references/product-center.md b/docs/references/product-center.md index eaff6e5..3982898 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -207,7 +207,23 @@ curl -X POST /product-families/organize -H "$AUTH" # 后台「整理 **链接名称展示解析**:后台所有链接名展示位(左右树、族成员列表、配置/编辑弹窗、搜索候选) 统一解析为「品名 型号」(如 `美国(包邮)180g纯棉T恤成人款-DG001-单面印花` → `180g纯棉T恤成人款 DG001`),悬停 tooltip 保留原始全名;搜索同时匹配原始名与解析名 -(`utils/origin-name.ts#cleanLinkName`)。 +(`utils/origin-name.ts#cleanLinkName`)。解析按**首括号对**取品名(与 api 端 +`origin-name.parser.ts#splitSegment1` 同构);无 SKU 段时仅当括号后品名含中文才视为 +链接名——防止已规范化的「品名(型号限定词) SKU」(如 `童装…(DTG180) PLTK016`) +被误解析成光款号。 + +**商品展示名规范化与存量修复(2026-09)**:`goods.good_name` 统一存「品名 SKU」—— + +- 写路径(`goods.service.ts` create/update/batchCreate 经 `resolveGoodName`): + 结构完整的原始链接名规范化为「品名 SKU」,品名内的 **ASCII 型号限定词** + (如 `(DTG180)`/`(JSA002)`,供应商或工艺型号)一律剥离(中文括号内容如 + `(女款)`保留);清洗后仍为**纯款号**(如 `PLTK016`,SDS 部分链接名只有款号) + 时按主链接 SDS 分类名补描述(`PLTK016 童装纯色插肩短袖T恤` → + `童装纯色插肩短袖T恤 PLTK016`);batchCreate 同样走规范化;自定义商品 + (createCustom/updateCustomContent)的人工命名不受影响; +- 存量修复脚本:`pnpm --filter @inkreach/api fix:good-names`(默认 dry-run 打印 + 旧名→新名清单,`-- --apply` 写库,幂等可重跑;`origin_goods.good_name` 为 SDS + 纯镜像不动,只改 `goods.good_name`)。 diff --git a/docs/references/structs.md b/docs/references/structs.md index d58744c..cd238b3 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -47,7 +47,8 @@ apps/api/ ├── prisma/ │ ├── schema.prisma # 数据模型(OriginGood/ProductFamily/FamilyPriceOverride/Country/Category/Tag/Position/Good/User/SyncLog) │ ├── migrations/ # Prisma migrate 历史 -│ └── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等) +│ ├── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等) +│ └── fix-pure-sku-good-names.ts # 商品名存量修复脚本(剥型号限定词/纯款号补描述,幂等,dry-run 默认) ├── src/ │ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化 │ ├── app.module.ts # 根模块,聚合所有业务模块 @@ -59,8 +60,13 @@ apps/api/ │ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序) │ ├── positions/ # 坑位 CRUD(受 JWT 保护) │ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管) +<<<<<<< HEAD │ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules) │ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 +======= +│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group(并入已有族优先,familyNameKey 族语义键)/ attachToMatchingFamily(单链接自动归族)/ consolidateFragments(碎片族合并)/ 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules,含 热转印→烫画 别名) +│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 + 展示名规范化(品名 SKU,剥 ASCII 型号限定词,纯款号按 SDS 分类名补描述) +>>>>>>> c3be8ab (fix(goods): 商品展示名统一「品名 SKU」——剥 ASCII 型号限定词、纯款号补描述) │ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志 │ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情 │ └── common/ # 全局装饰器 / 过滤器 / 拦截器 diff --git a/plans/fix/pure-sku-good-name-fix.md b/plans/fix/pure-sku-good-name-fix.md new file mode 100644 index 0000000..de88590 --- /dev/null +++ b/plans/fix/pure-sku-good-name-fix.md @@ -0,0 +1,77 @@ +# 修复左栏商品名显示成光款号(PLTK016 → 童装纯色插肩短袖T恤 PLTK016) + +- 分支:`bugfix/pure-sku-good-name-yeuimu`(自 `develop`) +- 日期:2026-09-02 +- 类型:Bug 修复 / 数据修复 + +## 问题 + +后台商品配置页(GoodsView)左栏部分商品只显示纯款号(如 `PLTK016`),期望显示 +「描述名 款号」(如 `童装纯色插肩短袖T恤 PLTK016`)。 + +## 根因(两层) + +1. **展示解析误判(主因,V2 栈 34 条)**:部分 SDS 品名自带 ASCII 型号限定词, + 写路径 `normalizeGoodName`(api `splitSegment1` 首括号对解析)产出的展示名为 + `童装纯色插肩短袖T恤(DTG180) PLTK016`;而 admin 展示解析器 + `parseLinkName` 用**最后一个** `)` 之后的文本当品名 → 显示成光秃秃 `PLTK016`。 +2. **纯款号名(防御路径,当期两库 0 条)**:SDS 部分链接名只有款号,解析不出 + 品名时 `normalizeGoodName` 原样入库,左栏显示裸款号;且 `batchCreate` 完全 + 未过 normalize。 + +描述名权威来源:SDS 分类名 `categories.category_name`(`PLTK016 童装纯色插肩短袖T恤`, +`auto-group` 族名同源),去首 token 款号即得。 + +## 边界与约束 + +- `origin_goods.good_name` 是 SDS 纯镜像、每小时被同步覆盖 —— **不动镜像**, + 只修 `goods.good_name`(官网展示名,前台官网直接透传,同步受益)。 +- 中文括号内容(如 `(女款)`)不是型号限定词,保留。 +- CUSTOM 商品(createCustom/updateCustomContent)人工命名不受影响。 + +## 实现 + +### admin(apps/admin/src/utils/origin-name.ts) + +- `parseLinkName` 对齐 api 端:首括号对解析 + 半/全角归一;无 SKU 段时仅当 + 括号后品名含中文才视为链接名(防「品名(限定词) SKU」误解析)。 +- 新增 `stripAsciiParenQualifiers`;`cleanLinkName` 展示统一剥离 ASCII 型号限定词。 + +### api(apps/api/src/goods/goods.service.ts) + +- 导出 `stripAsciiParenQualifiers` / `cleanGoodDisplayName`(规范化 + 剥限定词)/ + `isPureSkuGoodName` / `describePureSkuGoodName`(纯款号按 SDS 分类名补描述, + 分类名中的限定词一并剥离)。 +- `resolveGoodName` 私有助手接入 `create` / `update` / `batchCreate`(batchCreate + 顺带补上缺失的 normalize);`sdsCategoryIdForGood` 取(可切换后的)主链接分类。 +- `backfillPureSkuGoodNames({ dryRun, goodId? })`:存量修复,幂等,支持 goodId + 限定(测试隔离用)。 + +### 脚本 + +- `apps/api/prisma/fix-pure-sku-good-names.ts`(薄壳,手动构造依赖), + npm script `fix:good-names`;默认 dry-run,`-- --apply` 写库。 + +## 测试(TDD) + +- admin `origin-name.spec.ts`:+5(误解析防护、限定词剥离、中文括号保留、 + 首括号单段解析),共 23 绿。 +- api `goods.service.spec.ts`:+9(纯函数边界、create/update/batchCreate 补描述、 + 无映射保留、backfill dry-run/apply/幂等/限定词剥离),共 30 绿。 +- 全量:api 22 套件 199 测试绿(临时 docker postgres + `prisma migrate deploy`, + 共享开发库 192.168.124.92 当期不可达);admin vitest 2 文件 27 测试绿; + 两端 `tsc --noEmit` 零错误。 + +## 落库结果(2026-09-02 执行) + +- V2 栈(deploy-v2-postgres-1):34 条 `品名(限定词) SKU` → `品名 SKU` + (含 #263 `童装纯色插肩短袖T恤 PLTK016`)。 +- V1 栈(deploy-postgres-1):213 条原始链接名 → 规范化「品名 SKU」(后台展示 + 等价,官网卡片由原始链接名变为干净品名)。 +- 两库复跑 dry-run `renamed=0 unmapped=0`,幂等验证通过;数据已干净,线上旧 + 前端镜像无需等重部署即正确显示;代码修复防新增。 + +## 权限评估 + +无新增端点/操作,全部复用既有已控权限路径(PATCH /goods/:id 等);CLI 脚本为 +运维通道 —— 无需新增权限项。 From 37353470e7b78d32ba4573c72262f1696e9564f6 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Thu, 3 Sep 2026 01:20:17 +0800 Subject: [PATCH 22/22] =?UTF-8?q?fix(categories):=20=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E5=90=8D=E5=8E=BB=E6=8B=AC=E5=8F=B7=E2=80=94=E2=80=94=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=85=A5=E5=BA=93=E6=95=B4=E6=AE=B5=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=8B=AC=E5=8F=B7=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 左侧分类树/官网分类名的括号段(型号限定词(JSA002)与内容性括号 (黑+卡其)/(短裤 & 长裤))整段删除。分类名每小时被 SDS 同步覆盖, 清洗落在 syncCategories 入库路径(cleanCategoryDisplayName,款号 token 保留,剥空守卫),另附幂等存量脚本 fix:category-names。 已执行:V1/V2 各 33 条分类名去括号,复跑幂等 0。注意 api 容器重建 部署前旧镜像的下一轮同步会把名称写回原文,部署后自动恢复干净。 Tests: api 23 suites/206 passed(含新增 category-name.spec 7 例),tsc clean --- AGENTS.md | 1 + apps/api/package.json | 2 + apps/api/prisma/fix-category-parens.ts | 51 +++++++++++++++++++++ apps/api/src/sync/category-name.spec.ts | 46 +++++++++++++++++++ apps/api/src/sync/sync.service.ts | 22 ++++++++- docs/references/product-center.md | 9 ++++ docs/references/structs.md | 3 +- plans/fix/category-name-parens-fix.md | 60 +++++++++++++++++++++++++ 8 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 apps/api/prisma/fix-category-parens.ts create mode 100644 apps/api/src/sync/category-name.spec.ts create mode 100644 plans/fix/category-name-parens-fix.md diff --git a/AGENTS.md b/AGENTS.md index a2161aa..994139e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,3 +110,4 @@ Prisma Postgres 最佳实践: prisma-postgres 技能 - **两端"同构"解析器必须连测试用例也同构**:admin 与 api 各有一份链接名解析器,本次展示 bug(`品名(DTG180) SKU` 显示成光款号)正是两端取括号策略不一致(api 首括号对 vs admin 末括号)所致。改任何一端解析行为时,另一端同样输入的用例必须同步补上(origin-name.spec ↔ origin-name.parser.spec/goods.service.spec)。 - **共享开发库不可达时用一次性 docker postgres 跑集成测试**:`docker run -d --name inkreach-test-pg -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=inkreach_test -p 127.0.0.1:54329:5432 postgres:16-alpine` → `DATABASE_URL=… npx prisma migrate deploy` → jest/vitest 指向该库;用完 `docker rm -f`。夹具自包含的套件在新库上直接绿。 - **宿主机即部署机时,改生产数据前先 `docker inspect` 拿容器真实注入的环境变量**:deploy/.env 里的密码可能与运行容器不一致(v2 栈独立 env);对两个栈的库做数据修复时,dry-run 清单必须逐栈分别核对后再 apply。 +- **改"镜像字段"的数据前先查覆盖路径**:分类名每小时被 `syncCategories` 用 SDS 原名无条件覆盖——直接改库/界面改名都会被下一轮同步冲回。此类字段的清洗必须写进同步入库路径(如 `cleanCategoryDisplayName`),存量靠回填脚本;数据先改而镜像未重部署的窗口期内会被冲回一次,重部署后自动恢复。 diff --git a/apps/api/package.json b/apps/api/package.json index 197efa2..40fa435 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,8 @@ "backfill:product-families": "ts-node prisma/backfill-product-families.ts", "backfill:product-families": "ts-node prisma/backfill-product-families.ts", "fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts", + "fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts", + "fix:category-names": "ts-node prisma/fix-category-parens.ts", "organize": "ts-node prisma/backfill-product-families.ts", "recompute:families": "ts-node prisma/recompute-all-families.ts" }, diff --git a/apps/api/prisma/fix-category-parens.ts b/apps/api/prisma/fix-category-parens.ts new file mode 100644 index 0000000..bc00977 --- /dev/null +++ b/apps/api/prisma/fix-category-parens.ts @@ -0,0 +1,51 @@ +/** + * 存量修复脚本(幂等):分类名去括号 —— 删除所有括号段(型号限定词/内容性括号, + * 整段删除)。分类名每小时被 SDS 同步覆盖,清洗已落在 syncCategories 入库路径; + * 本脚本用于部署后立即生效(不等下一轮同步)或修人工建的历史分类。 + * + * 运行: + * pnpm --filter @inkreach/api fix:category-names # dry-run,只打印清单 + * pnpm --filter @inkreach/api fix:category-names -- --apply # 实际写库 + */ +import { PrismaService } from '../src/prisma/prisma.service'; +import { cleanCategoryDisplayName } from '../src/sync/sync.service'; + +async function main() { + const apply = process.argv.includes('--apply'); + const prisma = new PrismaService(); + await prisma.onModuleInit(); + + const rows = await prisma.category.findMany({ + select: { id: true, categoryName: true }, + orderBy: { id: 'asc' }, + }); + const renames = rows + .map((row) => ({ + id: row.id, + from: row.categoryName, + to: cleanCategoryDisplayName(row.categoryName) ?? row.categoryName, + })) + .filter((r) => r.from !== r.to); + + if (apply && renames.length > 0) { + await prisma.$transaction( + renames.map((r) => + prisma.category.update({ + where: { id: r.id }, + data: { categoryName: r.to }, + }), + ), + ); + } + const mode = apply ? 'APPLIED' : 'DRY-RUN(未写库,加 --apply 执行)'; + console.log(`[fix:category-names] ${mode} renamed=${renames.length}`); + for (const r of renames) { + console.log(` #${r.id} ${r.from} -> ${r.to}`); + } + await prisma.onModuleDestroy(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/api/src/sync/category-name.spec.ts b/apps/api/src/sync/category-name.spec.ts new file mode 100644 index 0000000..a538ad9 --- /dev/null +++ b/apps/api/src/sync/category-name.spec.ts @@ -0,0 +1,46 @@ +import { cleanCategoryDisplayName } from './sync.service'; + +describe('cleanCategoryDisplayName(分类名去括号,同步入库清洗)', () => { + it('删除型号限定词括号段(含名称后带空格的形态)', () => { + expect(cleanCategoryDisplayName('DG001 180G纯棉T恤 (JSA002)')).toBe( + 'DG001 180G纯棉T恤', + ); + expect(cleanCategoryDisplayName('DG004 230G水洗T恤(JSA003)')).toBe( + 'DG004 230G水洗T恤', + ); + }); + + it('删除内容性括号段(整段删除)', () => { + expect(cleanCategoryDisplayName('YSM02 全棉斜纹拼色帽子(黑+卡其)')).toBe( + 'YSM02 全棉斜纹拼色帽子', + ); + expect(cleanCategoryDisplayName('下装(短裤 & 长裤)')).toBe('下装'); + }); + + it('多个括号段一并删除并折叠空白', () => { + expect(cleanCategoryDisplayName('T恤(短袖)(男款)成人')).toBe('T恤 成人'); + }); + + it('半角括号同样处理', () => { + expect(cleanCategoryDisplayName('DG001 180G纯棉T恤 (JSA002)')).toBe( + 'DG001 180G纯棉T恤', + ); + }); + + it('无括号名称原样返回', () => { + expect(cleanCategoryDisplayName('PLTK016 童装纯色插肩短袖T恤')).toBe( + 'PLTK016 童装纯色插肩短袖T恤', + ); + }); + + it('剥空守卫:名称仅剩括号段时原样返回', () => { + expect(cleanCategoryDisplayName('(JSA002)')).toBe('(JSA002)'); + expect(cleanCategoryDisplayName('(短裤 & 长裤)')).toBe('(短裤 & 长裤)'); + }); + + it('空/null 输入容错', () => { + expect(cleanCategoryDisplayName('')).toBe(''); + expect(cleanCategoryDisplayName(null)).toBe(null); + expect(cleanCategoryDisplayName(undefined)).toBe(undefined); + }); +}); diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index 934cf2c..1a75895 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -24,6 +24,24 @@ export interface CategorySyncResult { deletedStale: number; } +/** 全角/半角括号段(非嵌套),分类名清洗用 */ +const PAREN_GROUP = /([^()()]*)|\([^()()]*\)/g; + +/** + * 分类展示名清洗:删除所有括号段(型号限定词如 `(JSA002)`、内容性括号如 + * `(黑+卡其)`/`(短裤 & 长裤)`,整段删除),折叠多余空白。剥空(名称仅剩 + * 括号段)时原样返回。分类名每小时被 SDS 同步覆盖,清洗必须在此入库路径生效 + * 才能持久;款号 token(如 `DG001`)不受影响。 + */ +export function cleanCategoryDisplayName(name: string | null | undefined): string | null | undefined { + if (!name) return name; + let cleaned = name.replace(PAREN_GROUP, ' '); + // 嵌套/残缺括号剥除后可能留下空括号对 + cleaned = cleaned.replace(/(\s*)|\(\s*\)/g, ' '); + cleaned = cleaned.replace(/\s+/g, ' ').trim(); + return cleaned || name; +} + export interface ProductSyncResult { inserted: number; updated: number; @@ -185,7 +203,7 @@ export class SyncService { await tx.category.create({ data: { sdsCategoryId: node.sdsId, - categoryName: node.name, + categoryName: cleanCategoryDisplayName(node.name) ?? node.name, categoryIcon: node.icon ?? null, }, }); @@ -194,7 +212,7 @@ export class SyncService { await tx.category.update({ where: { id: existing.id }, data: { - categoryName: node.name, + categoryName: cleanCategoryDisplayName(node.name) ?? node.name, categoryIcon: node.icon ?? null, }, }); diff --git a/docs/references/product-center.md b/docs/references/product-center.md index 3982898..7c4761b 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -225,6 +225,15 @@ curl -X POST /product-families/organize -H "$AUTH" # 后台「整理 旧名→新名清单,`-- --apply` 写库,幂等可重跑;`origin_goods.good_name` 为 SDS 纯镜像不动,只改 `goods.good_name`)。 +**分类名去括号(2026-09)**:`categories.category_name` 不再保留任何括号段—— +型号限定词(`DG001 180G纯棉T恤(JSA002)`)与内容性括号(`下装(短裤 & 长裤)`) +**整段删除**(`YSM02 全棉斜纹拼色帽子(黑+卡其)` → `YSM02 全棉斜纹拼色帽子`)。 +分类名每小时被 SDS 同步覆盖,清洗在 `syncCategories` 入库路径生效 +(`sync.service.ts#cleanCategoryDisplayName`,款号 token 如 `DG001` 保留);存量 +修复脚本 `pnpm --filter @inkreach/api fix:category-names`(dry-run 默认, +`-- --apply` 写库,幂等,覆盖人工建的历史分类)。auto-group 族名、纯款号补描述 +(describePureSkuGoodName)均以分类名为源,自动随之变干净。 + diff --git a/docs/references/structs.md b/docs/references/structs.md index cd238b3..2f5308b 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -48,7 +48,8 @@ apps/api/ │ ├── schema.prisma # 数据模型(OriginGood/ProductFamily/FamilyPriceOverride/Country/Category/Tag/Position/Good/User/SyncLog) │ ├── migrations/ # Prisma migrate 历史 │ ├── backfill-product-families.ts # 产品族回填脚本(解析列→自动建族→全量重算,幂等) -│ └── fix-pure-sku-good-names.ts # 商品名存量修复脚本(剥型号限定词/纯款号补描述,幂等,dry-run 默认) +│ ├── fix-pure-sku-good-names.ts # 商品名存量修复脚本(剥型号限定词/纯款号补描述,幂等,dry-run 默认) +│ └── fix-category-parens.ts # 分类名去括号存量修复脚本(整段删除括号段,幂等,dry-run 默认) ├── src/ │ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化 │ ├── app.module.ts # 根模块,聚合所有业务模块 diff --git a/plans/fix/category-name-parens-fix.md b/plans/fix/category-name-parens-fix.md new file mode 100644 index 0000000..402b2d5 --- /dev/null +++ b/plans/fix/category-name-parens-fix.md @@ -0,0 +1,60 @@ +# 分类名去括号(DG001 180G纯棉T恤(JSA002) → DG001 180G纯棉T恤) + +- 分支:`bugfix/category-name-parens-yeuimu`(自 `develop`) +- 日期:2026-09-03 +- 类型:Bug 修复 / 数据修复 +- 前置:`pure-sku-good-name-fix.md`(商品名已统一「品名 SKU」,goods 表已无括号) + +## 问题 + +后台左侧分类树(admin 左栏 + 官网分类树/商品卡共用)部分分类名带括号,两栈各 +33 条,分两类: + +- 型号限定词:`DG001 180G纯棉T恤 (JSA002)` +- 内容性括号:`YSM02 全棉斜纹拼色帽子(黑+卡其)`、`下装(短裤 & 长裤)` + +用户要求:括号段**整段删除**。 + +## 关键约束 + +`categories.category_name` 是每小时 cron(`syncCategories`)从 SDS 分类树**强制覆盖** +的镜像(update 分支无条件写 `node.name`)——只改库会被下一轮同步冲回。清洗必须 +落在**同步入库路径**才能持久;镜像其余字段(icon/树结构)行为不变。 + +## 实现 + +### 1. 同步入库清洗(apps/api/src/sync/sync.service.ts) + +- [x] 导出纯函数 `cleanCategoryDisplayName(name)`:删除所有 `(…)`/`(…)` + 括号段(含半角),折叠多余空白;剥空(名称仅剩括号段)时原样返回。 +- [x] `syncCategories` upsert 的 create/update 分支均写清洗后的名称 + (SDS 原文不再落库;款号 token `DG001` 保留,codeFromCategoryName 不受影响)。 + +### 2. 存量回填脚本 + +- [x] `apps/api/prisma/fix-category-parens.ts` + npm script `fix:category-names` + (默认 dry-run 打印 旧名 → 新名,`-- --apply` 写库,幂等;覆盖 SDS 与人工 + 建的分类)。 + +### 3. 测试(TDD) + +- [x] `src/sync/category-name.spec.ts`:型号限定词/内容性括号/多括号段/半角/ + 剥空守卫/无括号原样/空白折叠。 +- [x] 全量 api 测试零回归(一次性 docker postgres)。 + +## 落库(2026-09-03 执行) + +- [x] V1/V2 各 33 条分类名去括号,复跑 dry-run 0 条。 +- 注意:**api 容器重建部署前**,旧镜像的每小时同步会把名称写回带括号原文; + 部署带清洗逻辑的新镜像后,下一轮同步自动恢复干净(无需再跑脚本)。 + +## 已知影响 + +- `下装(短裤 & 长裤)` 两行同名分类清洗后均为 `下装`(清洗前已是同文重复, + 树中原即并列两行,视觉不变差)。 +- `configure:product-center-icons` 脚本按中文名幂等更新图标:其词表若含带括号 + 名称将匹配不到已清洗行,需要时更新词表(本次核查无带括号配置项受影响)。 + +## 权限评估 + +无新增端点/操作;CLI 脚本为运维通道 —— 无需新增权限项。