Author SHA1 Message Date
yeuimu a84b3b5daa fix(product-families): sort price-matrix option arrays by canonical display order
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.
2026-09-02 16:00:12 +08:00
yeuimu 1ffd73a1af docs(plan): add price-matrix option sort fix plan 2026-09-02 15:52:41 +08:00
yeuimu ae880ff346 docs(agent): add representative-row convergence lesson 2026-09-02 14:59:29 +08:00
yeuimu 26a5b08b56 merge: bugfix/public-goodname-representative (列表/首页族代表行对齐详情) 2026-09-02 14:58:59 +08:00
yeuimu 63820a3e6f 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.
2026-09-02 14:58:42 +08:00
yeuimu b32a5575df deploy(nginx): route /assets/product-center/ to v2 api for shared icons
沙特国旗等新增图标只需放入 v2 仓库 public 目录;nginx 按最长前缀把
product-center 静态资源转发给 v2 api,其余 /assets/、/uploads/ 仍走 v1。
同步沉淀 AGENTS.md:入口 nginx 真实挂载源在 v1 仓库、文件型 bind mount 的
inode 陷阱。
2026-09-02 11:19:49 +08:00
yeuimu a0ad6da9c7 chore(api): add Saudi Arabia flag icon 2026-09-02 11:01:11 +08:00
yeuimu 2bec7822f0 merge: feature/category-tree-sort (public goods style-order sorting) 2026-09-02 10:34:12 +08:00
yeuimu 76eb849b49 docs: record style-order semantics, container-run pitfalls and lessons 2026-09-02 10:33:54 +08:00
yeuimu bea0bf14d8 feat(api): order public goods by country > mid-category > style > priority 2026-09-02 10:27:23 +08:00
yeuimu 3ccddc5982 feat(api): backfill category/country sort order from reference table 2026-09-02 10:22:49 +08:00
yeuimu dc90712c4f feat(api): add sort_order column to categories 2026-09-02 10:16:29 +08:00
yeuimu d01c1e1b97 docs(plan): keep legacy tree as nav, treat sds leaves as style-order metadata, drop tree-switch tasks 2026-09-02 10:00:29 +08:00
yeuimu 3d431f8375 docs(plan): add backup/rollback section, fix backfill run channel and sink semantics 2026-09-02 09:36:17 +08:00
yeuimu 6973f863b5 chore: ignore local db snapshots (backups/) 2026-09-02 09:27:08 +08:00
yeuimu 88f60ba2e2 docs: add category-sort implementation plan (single categoryId, dual-path filter) 2026-09-01 19:26:50 +08:00
yeuimu 01fe444f93 docs: add category sort reference table (country > level2 > level3 order) 2026-09-01 19:00:24 +08:00
yeuimu 318f5c2a64 deploy(v2): update h5 build artifacts (new entry & products chunks) 2026-09-01 17:15:19 +08:00
yeuimu 007ad2c831 deploy(v2): h5 build with base /v2/h5, admin dockerfile & nginx route, shared compose 2026-09-01 16:26:22 +08:00
yeuimu 60992cb3d9 deploy: new h5 build, /v2/h5 route, restore /v2-api & /public proxy
- replace deploy/h5 with latest h5 build (base /h5/)
- nginx: add /v2/h5/ passthrough to v2-admin
- nginx: restore lost /v2-api/ and /public/ proxy locations
- compose: join inkreach-shared network so v1 admin can resolve v2-admin
2026-09-01 16:21:45 +08:00
yeuimu 62f968c1c1 chore(api): add miniprogram static assets to /app/public (served at /assets/miniprogram) 2026-08-31 19:02:23 +08:00
yeuimu f55e7ba4bb merge: develop (fixture junk cleanup + badge text) into refactor/v2 2026-08-30 03:15:23 +08:00
yeuimu a5884c638c merge: fix/test-fixture-junk-badge into develop 2026-08-30 03:15:23 +08:00
yeuimu 674d38a9a7 fix(api,admin): stop test fixture junk leaking to shared dev DB; badge text x个配置
清理标签筛选里的「Upd {ts}-v2」空分组与原产品库树里的
「Tree Cat {ts}」垃圾分类——两者均为集成测试夹具残留:
- tag-groups spec「改名」用例把分组改成 -v2 后缀,afterAll 按原名
  删除永远漏掉它(累积 31 个空分组);修复:改名前把 v2 名一并登记
- origin-goods spec getTree 用例的 finally 先删 good,被
  goodOriginGood 外键限制挡住且错误被 .catch 吞掉,分类/商品/国家/
  链接全部残留;修复:先删引用行再删 good
- 已清库:31 个 Upd 空分组、Tree Cat 分类 + Tree Good 商品 +
  Tree Country 国家 + 2 条 tree-a/b 链接;复跑两 spec 复核零残留

左树一族一行徽标文案按用户反馈去掉「N 条链接」,仅显示「M 个配置」
(memberCount 字段保留在 DTO,悬浮弹层行为不变)

验证:tag-groups + origin-goods spec 13/13 绿且零残留;admin
vue-tsc 干净 + vitest 22/22
2026-08-30 03:15:18 +08:00
yeuimu 102d61c181 merge: develop (family composition badge) into refactor/v2 2026-08-30 03:07:21 +08:00
yeuimu 12882e7d5a merge: fix/family-composition-badge into develop 2026-08-30 03:07:20 +08:00
yeuimu a756c8c3c8 fix(admin,api): always show family composition badge N条链接·M个配置
按用户反馈(PLTM006 明明能查到配置数却不显示):左树徽标此前只在
同分类内 ≥2 个配置时才出现,单配置族一行上什么都不显示,而右树同族
链接都挂着绿色已配置徽标,两边数字对不上,反复造成「配置数没显示」
的误解。

- goods API:GOOD_INCLUDE 的 family 带 _count.originGoods,
  GoodDto.originGood.family 增加 memberCount(族成员链接数)
- admin:distinctByFamily 记录 linkCount;一族一行的徽标常显
  「N 条链接 · M 个配置」(链接数=族成员数,配置数=该族官网商品数),
  悬浮仍列出全部配置可打开
- 文档同步徽标语义

效果:PLTM006 行显示「4 条链接 · 1 个配置」(真值:4 条链接仅配了
1 个官网商品);PLTF026 的「4 条链接 · 4 个配置」同屏可对比,其中
4 个配置为历史同国家重复配置,建议另行清理。

验证:admin vue-tsc 干净 + vitest 22/22;api goods spec 18/18;
Prisma _count 查询实测返回 originGoods=4
2026-08-30 03:07:16 +08:00
yeuimu ab42cb49a3 merge: develop (block family re-config) into refactor/v2 2026-08-30 02:55:33 +08:00
yeuimu 5d089bdb43 merge: fix/block-family-reconfig into develop 2026-08-30 02:55:32 +08:00
yeuimu 49d28c8d4f fix(admin): block re-config for family-configured links
按用户反馈:族已配置后不允许再配置(撤回上一轮的「再配置」入口):
- 「配置」按钮仅在本链接及其同款族均未配置时显示(configuredCount
  与 familyConfiguredCount 均为 0),族已配置的链接只显示「同款已配置」徽标
- openConfigModal 增加统一拦截(按钮与右树拖拽两条路径都覆盖):
  已配置/族已配置的链接弹出警告并阻止打开配置弹窗,避免产生官网
  不可见的冗余商品
- 文档同步:撤回「再配置」语义,恢复拦截说明

已核实 PLTF004(族 786)实际仅 1 个官网商品,「四个配置」是右树
族聚合徽标造成的观感误解;149 个族无一跨分类,左树一族一行计数无误。

验证:admin vue-tsc 干净 + vitest 22/22 绿
2026-08-30 02:55:28 +08:00
yeuimu ab51052d26 merge: develop (family-configured badge fix) into refactor/v2 2026-08-30 02:40:44 +08:00
yeuimu 7ff12fc69b merge: fix/family-configured-badge into develop 2026-08-30 02:40:43 +08:00
yeuimu fa4a95af98 fix(admin): relabel family-configured badge to 同款已配置; button 再配置
按用户反馈修正右树「已配置徽标 + 配置按钮」并存的矛盾观感:
- 徽标改为链接级真值:本链接未单独建商品但所属族已配置时显示
  「同款已配置」(此前沿用拦截时代的「已配置」文案,与恢复出的
  配置入口语义打架)
- 该场景下按钮文案改为「再配置」,悬浮说明指向调整归族/加配国家;
  本链接自身已配置时按钮照旧隐藏,行为不变
- 文档撤回过时的「族已配置后配置入口会被拦截」说法(6f2d836 已
  撤回拦截,徽标文案此番补齐)

验证:admin vue-tsc 干净 + vitest 22/22 绿
2026-08-30 02:40:39 +08:00
yeuimu ff8aad171c merge: develop (organize refactor) into refactor/v2 2026-08-30 01:27:28 +08:00
yeuimu b6c19ae859 merge: refactor/organize-script-dev into develop (local only, not pushed) 2026-08-30 01:27:26 +08:00
yeuimu 93ac525c55 refactor(api): parsing out of runtime — pure-mirror sync, explicit organize, auto aggregate recompute
解析去运行时化(三层架构,plans/refactor/organize-script-refactor.md):
- 同步 = 纯镜像:upsertOriginGood 不再写解析列、不再自动挂族;详情同步仍触发重算
- 整理 = 显式人工动作(OrganizeService):解析列回填 → 派生标签(人工接管永不
  覆盖)→ auto-group 建族 → 全量重算;入口 CLI(pnpm --filter @inkreach/api
  organize)+ POST /product-families/organize + 后台「整理」按钮
- 重算 = 纯结构化聚合:不再按名称重派生标签(防上游改名倒灌,回归测试覆盖);
  矩阵维度只认标签/CUSTOM 显式标签,未整理成员不进矩阵;工艺=不打印时
  印花数量以单面占位(纯结构化规则);「恢复自动」走整理的单链接派生
- 派生默认补齐(脚本层假设):名称无单/双面且工艺非不打印 → 印花数量单面印花
- goods 服务建品/更新后仅镜像标签+重算(不派生);含商品名入库规范化
  (normalizeGoodName,管理员输入边界质检)
- organize.service.spec 由 tag-sync spec 迁移 + 防倒灌回归;sync/recompute/
  public/families spec 全部适配;API 173/173,admin typecheck+22/22
2026-08-30 01:27:01 +08:00
yeuimu 976f308e3c Merge remote-tracking branch 'origin/develop' into develop 2026-08-29 09:13:11 +08:00
yeuimu 64a6fa7004 merge: fix/size-chart-local-size-code-dev into develop (local only, not pushed) 2026-08-29 08:55:16 +08:00
yeuimu b5875d33b2 fix(sync): drop local size-code column (P/M/G/GG) from SDS size charts
巴西/西语市场上游尺码表带一个名为「尺码」的列,值是本地尺码码
P/M/G/GG(=S/M/L/XL),与行首尺码重复且非测量值;解析时整列丢弃。
其他非数字参考列(身高/体重/建议体重)保留。存量 2 条详情已修复
并重算受影响族。
2026-08-29 08:55:16 +08:00
yeuimu ce283dff78 merge: fix/origin-name-mixed-bracket-dev into develop (local only, not pushed) 2026-08-29 02:36:26 +08:00
yeuimu 6498a198ef fix(parser): normalize half/full-width bracket mix in link name segment 1
SDS 上游存在 '美国(不包邮)…' 混用括号:先归一为全角再配对,
否则国家/物流/品名三字段全部解析失败
2026-08-29 02:36:26 +08:00
yeuimu 7c46f15967 merge: feature/admin-ui-polish-dev into develop (local only, not pushed) 2026-08-29 02:32:41 +08:00
yeuimu d625b40873 fix(admin): merge-candidates by SKU/family; config dialog merge block always shown; cfg badge adjacency
- 修正合并同名候选匹配:此前用名称 3 段组键(含工艺段),工艺写法不同的
  同款(如 光板不打印 vs 单面印花(前印))全部漏配导致候选为空、合并同名
  区块被 v-if 隐藏 —— 用户看到的"checkbox 和搜索框被吞"即此因。
  改为 族归属精确匹配 或 款号(名称第 2 段,如 BRTF004)一致,全局跨分类;
  区块常显,无候选时空态提示
- 修左树「N 个配置」徽标错位:good-name-col 改 flex 布局,徽标紧贴名称
- 验证:admin typecheck + 22/22 + 构建绿;DB 实测 BRTF004 光板链接候选池
  恢复(单面印花(前印),同族 857)
2026-08-29 02:16:35 +08:00
yeuimu 6f2d836ef5 fix(admin): one row per family on left tree; restore right-tree config entry
按用户反馈调整上一批优化:
- 左树撤回族组展开层(虚线层+重复名称行):改为一族只显示一行(排序最前的
  代表商品),行内「N 个配置」徽标悬浮列出该族全部配置(名称/国家·分类/打开
  按钮),不再有额外层级与重复名称
- 右树恢复配置入口:撤回「族已配置拦截」,本链接未单独建商品即显示「配置」
  按钮(含族已配置情形,供调整归族/重新配置),拖拽路径同步解除拦截
- 国家模式拖拽排序回退为分组内简单排序(族组节点已不存在)
- 文档同步更新徽标/入口语义

验证:admin typecheck + 22/22 + 构建绿;浏览器实测美国分组 25 行一族一行、
「8 个配置」徽标悬浮列表可打开、国内工厂深层成族链接显示「配置」按钮
2026-08-29 01:53:09 +08:00
yeuimu 5e8183c220 feat(admin): family-folded left tree, toolbar regroup, sync log humanize
视觉走查驱动的后台优化(feature/admin-ui-polish-dev,确认后再合并):
- 左树(国家/品类模式)同款多配置折叠为一行族组节点:代表名 + "N 个配置"
  徽标,展开看各行;locateInLeftTree 沿父链展开保证定位有效;国家模式
  拖拽排序适配族嵌套(同容器内排序,按折叠后展示顺序保序重分配优先级)
- 工具栏分区重排:模式切换(品类/国家/全局)移至最左,筛选组居中,
  新增自定义商品靠右,消除 1680 宽下的折行错乱
- 商品行属性小字/国家徽标颜色加深提升对比度
- 右树原产品行加拖拽提示 title
- 数据同步日志人话化:inserted/updated/total/delisted 等报文渲染为
  "新增 0 · 更新 536 · 共计 536",原始报文进 tooltip
- 计划文档 plans/feature/admin-ui-polish-feature.md(含一项误报修正:
  右树 29/3 计数为截图裁切误读,实际 29/33 无 bug)

验证:admin typecheck + 22/22 + 构建绿;浏览器实机复拍国家模式折叠、
族组展开、同步页文案均生效
2026-08-28 23:22:04 +08:00
yeuimu 4ddbf486bd merge: fix/country-mode-good-reorder-dev into develop (local only, not pushed) 2026-08-28 21:37:27 +08:00
yeuimu 0e451d07e6 fix(admin): drag-to-reorder goods inside country groups
国家模式左树商品节点支持拖拽排序:行内 before/after 指示线,落点即保存。
优先级采用保序重排(该国家现有优先级集合不变、按新顺序重新分配),
尽量少扰动全局/品类维度的既有排序;跨国家拖动不支持(商品归属固定)。
2026-08-28 21:37:27 +08:00
yeuimu aba90cb011 merge: fix/config-dialog-global-siblings-dev into develop (local only, not pushed) 2026-08-28 20:04:30 +08:00
yeuimu fdfe435ecd fix(admin): config dialog merge candidates search globally by name group
合并同名候选不再局限于当前分类节点:collectSiblings 改为按名称 3 段组键
(sameOriginGroup)遍历整棵右树收集同款链接(含已配置,勾选归族幂等),
跨分类的同款链接(如 BRTF004 分布在多个分类组)也能勾选归族
2026-08-28 20:04:29 +08:00
yeuimu a14b8ecf4a merge: feat/country-sort-and-sync-btn-dev into develop (local only, not pushed) 2026-08-28 20:01:04 +08:00
yeuimu 1e07c62fc8 feat(admin): country drag-sort + always-available member detail sync
- Country 加 sort_order(默认 0 保持 id 序);PATCH /countries/sort 批量保存
  顺序(对齐 /tags/sort 模式);findAll 与公开 /public/countries 均按
  sortOrder 排序
- CountriesView 表格改为可拖拽行列表:拖动松开即全量保存新顺序,失败回滚
- 商品编辑弹窗成员展开面板:同步详情按钮常驻(已同步显示 重新同步详情),
  不再只在未同步态出现
- goods.service.spec 的 FamilyRecomputeService mock 补齐 syncFamilyTags 等
  方法(全量并行时其他套件的扫名归族会把本套件夹具收进族,create/update
  会调用到,mock 缺方法导致偶发 TypeError)
- api 164/164、admin typecheck+22/22+构建全绿
2026-08-28 20:00:56 +08:00
yeuimu 77f9c05346 merge: fix/tree-badge-family-aware-dev into develop (local only, not pushed) 2026-08-28 19:16:22 +08:00
yeuimu fe6ab3825d fix(admin): family-aware right-tree badges; flatten union size-chart cells
- 右树徽标/定位/配置按钮改按族语义:族内任一链接已配置 → 全族显示绿色
  已配置(本链接未单独建商品时标题注明并入该款),配置入口在全族未配置时
  才出现;拖拽已配置族链接配置时拦截提示(公开契约一族一款,重复商品官网
  不可见);分组头计数与仅未配置筛选同步族感知(AUTM003/DG120 场景)
- 编辑弹窗族级尺码表把 measurements 数组摊平成列键(与成员级渲染一致),
  修复列头在、格子全空的问题
2026-08-28 19:16:15 +08:00
yeuimu 8a6db1bdee docs(agents): lessons — stop dev server before full jest; Chinese sort assertions 2026-08-28 18:51:22 +08:00
yeuimu d0102ec4e8 merge: feature/public-family-id-dev home-goods test into develop (local only, not pushed) 2026-08-28 18:44:25 +08:00
yeuimu 63b436fe41 test(public): cover home-goods family dedupe 2026-08-28 18:44:24 +08:00
yeuimu 5a7a2841de merge: feature/public-family-id-dev into develop (local only, not pushed) 2026-08-28 18:43:33 +08:00
yeuimu 8a052773cd feat(public): family-first contract — goodId=familyId, strict 5-dim price matrix
公开契约族化(前端只需知道款号/族):
- GET /public/goods 一族一条(goodId=族ID,price=族起价,分页作用于分组后);
  无族商品(自定义)不进任何公开端点(列表/首页/分类树/标签统计)
- GET /public/goods/:goodId 仅认族 ID;公共字段取代表 Good,变体=全体成员并集,
  尺码表/包装规格=族物化并集;旧 SDS 链接 ID 寻址 404
- priceMatrix 严格五维:尺码×颜色×印花数量×工艺×物流;维度来源改为链接级
  标签(人工接管按人工标签),弃用原始 craftLabel;CUSTOM 成员尊重显式标签
- 名称派生补裸「单面/双面」写法(直喷双面→双面印花+直喷,18 条存量链接修复)
- family_price_overrides 加 print_count 列(五键唯一),PUT/DELETE/校验五键化
- admin 编辑弹窗矩阵消费适配(SKU 列直读 printCount,成员格子三维匹配,
  改价 payload 带 printCount)
- 存量 339 族已全量重算;api 162/162、admin 22/22、双端构建绿
2026-08-28 18:43:30 +08:00
yeuimu e5ec022834 merge: fix/swagger-csp-dev into develop (local only, not pushed) 2026-08-28 17:50:39 +08:00
yeuimu e018f16d0b fix(api): drop helmet CSP upgrade-insecure-requests so Swagger UI loads over plain http 2026-08-28 17:50:39 +08:00
yeuimu b055aedd3e merge: fix/swagger-proxy-dev into develop (local only, not pushed) 2026-08-28 17:43:41 +08:00
yeuimu 1ef82c4c14 fix(admin): proxy /api/docs without prefix strip so Swagger UI works through vite dev server 2026-08-28 17:43:41 +08:00
yeuimu f07e843130 fix(admin): move family union tabs to the bottom of edit dialog 2026-08-28 17:37:58 +08:00
yeuimu 6054cb556a docs: family union tabs and searchable merge list 2026-08-28 17:27:53 +08:00
yeuimu d4515631b1 merge: feat/family-detail-tabs-dev into develop (local only, not pushed) 2026-08-28 17:26:41 +08:00
yeuimu c7e6c35fbf feat(admin): family-level size/package/SKU union tabs in edit dialog; searchable merge-sibling list in config dialog 2026-08-28 17:26:41 +08:00
yeuimu 6d9305b878 merge: fix/config-dialog-siblings-dev into develop (local only, not pushed) 2026-08-28 17:11:36 +08:00
yeuimu 7c85f7fe9f fix(admin): config dialog lists all same-category sibling links (incl. configured); drop merge tip text 2026-08-28 17:11:36 +08:00
yeuimu f280b1daf5 docs: reflect simplified member price table 2026-08-28 17:03:12 +08:00
yeuimu d67858c69b merge: fix/session-restore-and-price-table-dev into develop (local only, not pushed) 2026-08-28 17:02:26 +08:00
yeuimu c2d50c9fa3 fix(admin): restore session via /auth/me in router guard (refresh no longer logs out); simplify member price table (no title/source/action columns) 2026-08-28 17:02:25 +08:00
yeuimu 9863346c6d merge: fix/goods-view-feedback-dev into develop (local only, not pushed) 2026-08-28 16:55:08 +08:00
yeuimu f28f51155f feat(goods): per-member detail tabs in edit dialog; revert right-tree to raw names; split configured/family badges; add GET /origin-goods/:id 2026-08-28 16:55:07 +08:00
yeuimu 753b554413 merge: feature/goods-dialog-split-dev into develop (local only, not pushed) 2026-08-28 16:40:48 +08:00
yeuimu 10197c47a8 refactor(admin): split GoodsView into dialog components; fix(GoodsView): raw member names, no primary badges, member price grid; feat(tag): 光板 maps to 不打印 2026-08-28 16:40:48 +08:00
yeuimu 34d84a7a7c merge: feature/link-tags-and-sku-dims-dev into develop (local only, not pushed) 2026-08-28 16:00:52 +08:00
yeuimu 6f22a6fd1f feat(admin): expandable family members with per-link tag config + SKU pricing dims columns 2026-08-28 16:00:46 +08:00
yeuimu 08201f18a4 feat(product-family): link-level tags (origin_good_tags) with manual override + member detail enrichment 2026-08-28 16:00:46 +08:00
yeuimu 0a2302c4e2 更新按钮尺寸 2026-08-28 15:42:39 +08:00
yeuimu 3ddc4c87e8 更新忽略文件 2026-08-28 15:34:24 +08:00
yeuimu 37b52c5ebb merge: feature/family-link-tags-ui-dev into develop (local only, not pushed) 2026-08-28 15:33:17 +08:00
yeuimu 1128167aef feat(admin): family-aware goods view - auto tags readonly, clean link names, config badges, locate highlight 2026-08-28 15:33:11 +08:00
yeuimu ee336968a0 feat(product-family): derive tags per link name (印花数量/工艺/物流 rules) 2026-08-28 15:33:11 +08:00
yeuimu bbd925070a 部署h5 2026-08-28 15:23:54 +08:00
yeuimu f5a3b8c840 merge: bring in public media/variant dedup from origin/develop (local only) 2026-08-28 15:15:17 +08:00
yeuimu 6395d9e6ab feat(product-family): family-derived tags replace manual logistics/craft tagging 2026-08-28 14:57:26 +08:00
yeuimu eedfbb344e feat(product-family): auto-derive logistics/craft tags from family members 2026-08-28 14:57:25 +08:00
yeuimu a13931c84c revert(admin): keep original admin layout with family replacing primary/secondary sources 2026-08-28 14:35:34 +08:00
yeuimu 353e51e871 revert(admin): restore original layout, replace primary/secondary source UI with family members 2026-08-28 14:35:34 +08:00
yeuimu 7b17fff86f fix(admin): fail loudly when dev port 5173 is occupied 2026-08-28 14:21:37 +08:00
yeuimu f919577ddd feat(product-family): goods view family integration, family-based public variant union 2026-08-28 14:14:59 +08:00
yeuimu 4eadb67c36 feat(product-family): replace legacy good merge with family mechanism in config view and public detail 2026-08-28 14:14:58 +08:00
yeuimu 14c53504f0 feat(product-family): public detail family block and good family derivation 2026-08-28 13:40:19 +08:00
yeuimu 6418aa7302 feat(api): public good detail family block behind gray-release flag 2026-08-28 13:40:19 +08:00
yeuimu b4b9fbbe60 feat(api): good family_id column with backfill 2026-08-28 13:34:42 +08:00
yeuimu ba3c6d2d4f feat(admin): product family management tab, price override matrix and family grouping 2026-08-28 13:30:38 +08:00
yeuimu d70a03f6fa docs: admin product family references 2026-08-28 13:30:37 +08:00
yeuimu fd7ca5d53f feat(admin): goods view family badges and sibling matching 2026-08-28 13:29:54 +08:00
yeuimu 07f3134f79 feat(admin): product family management tab 2026-08-28 13:28:37 +08:00
yeuimu dcd2b235b9 feat(admin): family match util 2026-08-28 13:25:31 +08:00
yeuimu c492469d70 feat(admin): product families api client and types 2026-08-28 13:25:10 +08:00
yeuimu 8fad8de73e docs(agents): add lessons for db drift resolution and shared-db test isolation 2026-08-28 12:53:10 +08:00
yeuimu e49eebcedd fix(product-family): tolerant auto-group member attach 2026-08-28 12:52:22 +08:00
yeuimu ab450c350e fix(api): tolerant member attach in auto-group under concurrent cleanup 2026-08-28 12:52:22 +08:00
yeuimu f7c4252a91 feat(product-family): merge SPU family layer phase-1 backend 2026-08-28 12:50:49 +08:00
yeuimu 502b7eba6e docs: update references for product families; fix controller import and test isolation 2026-08-28 12:50:42 +08:00
yeuimu c8531bfe08 feat(api): category-based family grouping, tree family info and custom goods family attribution 2026-08-28 12:43:58 +08:00
yeuimu d63f1a6f35 feat(api): product families backfill script 2026-08-28 12:36:39 +08:00
yeuimu d20a933e34 feat(api): sync hooks for parsing, auto-attach and family recompute 2026-08-28 12:33:33 +08:00
yeuimu f68898dea4 feat(api): product families module with CRUD, auto-group, members, custom members and price overrides 2026-08-28 12:30:57 +08:00
yeuimu de0f5509b1 feat(api): add family recompute service with union and price matrix 2026-08-28 12:27:29 +08:00
yeuimu c07b28def7 feat(api): add product_families schema and migration 2026-08-28 12:24:24 +08:00
yeuimu 60946b5f64 feat(api): add origin good name parser 2026-08-28 12:23:35 +08:00
yeuimu 37b45a4679 docs(plans): add product family merge phase-1 implementation plan 2026-08-28 12:22:23 +08:00
yeuimu 573549eb15 docs(specs): reuse public endpoints and include admin frontend scope 2026-08-28 12:04:35 +08:00
yeuimu 14d77fbab2 docs(specs): allow manual price overrides and custom family members 2026-08-28 12:01:13 +08:00
yeuimu 1186d615e2 docs(specs): add product family merge design 2026-08-28 11:45:18 +08:00
yeuimu 270e516f94 fix(public): media images are objects ({id,url,sortOrder}) - handle both shapes in mergeMedia 2026-08-28 11:22:53 +08:00
yeuimu d826c5bdd4 feat(public): merge secondary media gallery images with URL dedup 2026-08-28 11:19:27 +08:00
yeuimu 457792b729 feat(public): dedupe merged variants and merge specs/options from secondary origins 2026-08-28 11:05:34 +08:00
yeuimu 8233dda07a fix(admin): initial goods load also uses pageSize 1000 2026-08-28 09:49:40 +08:00
yeuimu cbdb507be9 fix(api): allow pageSize up to 1000 so admin goods tree can load full list 2026-08-28 09:36:15 +08:00
yeuimu b3bd10de06 fix(admin): load all goods in left tree (pageSize 1000) so low-priority new goods are searchable 2026-08-28 09:31:02 +08:00
yeuimu e7582bbd6e fix(deploy): add .dockerignore so host node_modules does not break image build 2026-08-27 19:13:57 +08:00
yeuimu 4c50189538 merge: multi-origin-good-merge-zll into develop 2026-08-27 18:51:28 +08:00
yeuimu 4bebb9e4b2 docs: document merged origin goods feature 2026-08-27 18:49:17 +08:00
yeuimu ac29a0c7bf feat(admin): show merged count badge on good nodes 2026-08-27 18:45:51 +08:00
yeuimu ac3a39f40d feat(admin): manage merged origin goods in good edit modal 2026-08-27 18:43:30 +08:00
yeuimu 491e74b905 feat(admin): add origin name grouping utils with vitest coverage 2026-08-27 18:40:22 +08:00
yeuimu 0aa6cbf1d9 feat(admin): suggest and merge sibling origin goods in config modal 2026-08-27 18:40:13 +08:00
yeuimu 697b82a64b fix(admin): resolve pre-existing template type errors blocking build 2026-08-27 18:40:08 +08:00
yeuimu f06dfffbda feat(public): resolve goods by secondary sds id and merge variants 2026-08-27 18:20:25 +08:00
yeuimu d9ecd04747 feat(origin-goods): include merged secondary references in tree stats 2026-08-27 18:18:29 +08:00
yeuimu 9c279ae393 feat(goods): support merged secondary origin goods in create/update/detail 2026-08-27 18:15:56 +08:00
yeuimu 848eed0b6f feat(db): add good_origin_goods junction table for merged origin goods 2026-08-27 18:09:00 +08:00
yeuimu 043d2463a6 docs(plan): add multi origin good merge design and implementation plan 2026-08-27 17:08:06 +08:00
yeuimu 005ab5b585 chore: restore files unintentionally deleted in 6c61a4e 2026-08-26 17:54:35 +08:00
yeuimu 6c61a4e871 feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
2026-08-26 14:23:09 +08:00
yeuimu be0b90e68f feat(security): HttpOnly cookie sessions, token revocation, and RBAC
- Add User.role (enum Role/ADMIN) and User.tokenVersion with migration
- Login now issues short-lived access token (30m default) + 7d refresh
  token, both embedding tokenVersion and a typ discriminator
- Tokens delivered via HttpOnly SameSite cookies (ir_at, ir_rt scoped
  to /auth); refresh token never leaves the cookie
- New endpoints: POST /auth/refresh (rotation), GET /auth/me,
  POST /auth/logout (bumps tokenVersion, revoking all tokens)
- JWT strategy accepts bearer or cookie, rejects refresh tokens, and
  verifies tokenVersion + user existence on every request
- Global RolesGuard: authenticated routes require ADMIN unless widened
  via @Roles(...)
- Admin SPA: session fully cookie-based, no token in localStorage;
  router guard restores session via /auth/me; axios auto-refreshes once
  on 401; stale localStorage keys cleaned up
2026-08-22 12:04:56 +08:00
yeuimu 755b40aded merge: security hardening fixes 2026-08-22 11:55:14 +08:00
yeuimu 9c1106586a fix(security): harden auth, upload, and API configuration
- Lock public registration to first-user bootstrap (403 afterwards)
- Require JwtAuthGuard on upload + whitelist png/jpg/webp/gif (SVG/XSS blocked)
- Add global throttling (login/register 5/min, upload 10/min)
- Add helmet security headers; serve uploads with nosniff
- Replace permissive CORS (origin:true+credentials) with CORS_ORIGINS whitelist
- Disable Swagger outside development; sanitize 500 error responses
- Enforce 32+ char JWT_SECRET; make token expiry configurable (TOKEN_EXPIRES_IN)
- Re-check user in DB on every JWT validation (revocation on user delete)
- Dummy bcrypt compare to prevent login user-enumeration via timing
- Map malformed BigInt inputs to 400 instead of 500
- Widen .gitignore to .env* and add apps/api/.env.example
- Disable Nuxt devtools and sourcemaps
2026-08-22 11:55:13 +08:00
yeuimu 9ed569f5bc fix(admin): simplify product forms 2026-08-21 14:54:08 +08:00
yeuimu b04623ebdd feat(goods): add editable custom products 2026-08-21 14:45:42 +08:00
yeuimu a4151607c5 fix(sync): hydrate all origin product details 2026-08-21 14:05:15 +08:00
yeuimu 4cc99f3f23 fix(docs): describe tags as grouped array 2026-08-21 11:44:25 +08:00
yeuimu 8b38d6fef7 refactor(api): group public tag filters 2026-08-21 11:34:57 +08:00
622 changed files with 86002 additions and 63810 deletions
+26
View File
@@ -0,0 +1,26 @@
# 依赖与包管理缓存(容器内会重新安装,禁止从宿主机带入)
node_modules
**/node_modules
.pnpm-store
.pnpm-debug.log
# 构建产物
dist
**/dist
.turbo
# Git 与本地文件
.git
.gitignore
.dockerignore
# 测试与文档
coverage
**/*.spec.ts
**/*.test.ts
# 运行数据与本地产物
data-cleaning
deploy/backups
apps/api/uploads
*.log
+11 -44
View File
@@ -1,46 +1,13 @@
# Dependencies node_modules/
node_modules dist/
.turbo/
# Build outputs
dist
.output
.nuxt
.nitro
.data
.cache
# TypeScript
*.tsbuildinfo
# Logs
logs
*.log *.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Environment
.env .env
.env.local .env.*
.env.development # Deployment runtime data (certs, ACME state, data dumps)
.env.*.local deploy/certbot/
deploy/data-dump.json
# OS uploads/
.DS_Store .pnpm-store/
data-cleaning/
# IDE backups/
.idea
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
# Coverage
coverage
# Nuxt
.output
.nuxt
.nitro
.cache
+14
View File
@@ -89,3 +89,17 @@ Prisma Postgres 最佳实践: prisma-postgres 技能
参考 nestjs-best-practices 技能 参考 nestjs-best-practices 技能
> 你可以自己增加规范 > 你可以自己增加规范
### 经验沉淀(跨项目通用)
- **入口 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 <name>` 把已存在的迁移标记为已应用,再 `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` 过滤圈定,断言既稳定又能看到完整顺序。
- **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。
- **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。
+1
View File
@@ -106,6 +106,7 @@ pnpm --filter @inkreach/api test # 单元测试
pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client
pnpm --filter @inkreach/api prisma:migrate # 运行迁移 pnpm --filter @inkreach/api prisma:migrate # 运行迁移
pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio
pnpm --filter @inkreach/api backfill:product-families # 产品族回填(解析链接名→自动建族→全量重算,幂等)
# 官网 # 官网
pnpm --filter @inkreach/website dev # 开发 pnpm --filter @inkreach/website dev # 开发
+3 -1
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
@@ -27,6 +28,7 @@
"unplugin-auto-import": "^21.0.0", "unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.0.12", "vite": "^8.0.12",
"vitest": "^4.1.10",
"vue-tsc": "^3.2.8" "vue-tsc": "^3.2.8"
} }
} }
+5
View File
@@ -32,4 +32,9 @@ export const countriesApi = {
deleteCountry: (id: string) => { deleteCountry: (id: string) => {
return request.delete(`/countries/${id}`) return request.delete(`/countries/${id}`)
}, },
// Batch save drag-reorder (items = full list in the new order)
sortCountries: (items: Array<{ id: string; sortOrder: number }>) => {
return request.patch<any, Country[]>('/countries/sort', { items })
},
} }
+10
View File
@@ -8,6 +8,8 @@ import type {
BatchPriorityRequest, BatchPriorityRequest,
GoodsFilter, GoodsFilter,
PaginatedResult, PaginatedResult,
CreateCustomGoodRequest,
UpdateCustomGoodContentRequest,
} from '@/types' } from '@/types'
export const goodsApi = { export const goodsApi = {
@@ -26,6 +28,14 @@ export const goodsApi = {
return request.post<any, Good>('/goods', data) return request.post<any, Good>('/goods', data)
}, },
createCustomGood: (data: CreateCustomGoodRequest) => {
return request.post<any, GoodDetail>('/goods/custom', data)
},
updateCustomGoodContent: (id: string, data: UpdateCustomGoodContentRequest) => {
return request.patch<any, GoodDetail>(`/goods/${id}/custom-content`, data)
},
// Update good // Update good
updateGood: (id: string, data: UpdateGoodRequest) => { updateGood: (id: string, data: UpdateGoodRequest) => {
return request.patch<any, Good>(`/goods/${id}`, data) return request.patch<any, Good>(`/goods/${id}`, data)
+20 -1
View File
@@ -1,5 +1,5 @@
import request from './request' import request from './request'
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types' import type { OriginGood, OriginGoodsTreeResponse, OriginGoodTagsResult, PaginatedResult } from '@/types'
export const originGoodsApi = { export const originGoodsApi = {
getTree: () => { getTree: () => {
@@ -9,4 +9,23 @@ export const originGoodsApi = {
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => { getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params }) return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
}, },
// 单链接详情(detail + variants,成员展开面板用)
getOriginGood: (id: string) => {
return request.get<any, Record<string, any>>(`/origin-goods/${id}`)
},
getTags: (id: string) => {
return request.get<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`)
},
// 人工接管链接标签(全量替换,自动同步不再覆盖)
updateTags: (id: string, tagIds: string[]) => {
return request.put<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`, { tagIds: tagIds.map(Number) })
},
// 恢复自动派生(清掉人工标签)
resetTags: (id: string) => {
return request.delete<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`)
},
} }
+76
View File
@@ -0,0 +1,76 @@
import request from './request'
import type {
AutoGroupPreviewGroup,
FamilyPriceOverrideRow,
PaginatedResult,
ProductFamily,
} from '@/types'
export const productFamiliesApi = {
list: (params: { keyword?: string; page?: number; pageSize?: number }) => {
return request.get<any, PaginatedResult<ProductFamily>>('/product-families', { params })
},
detail: (id: string) => {
return request.get<any, ProductFamily>(`/product-families/${id}`)
},
create: (data: {
familyName: string
familyCode?: string
familyImage?: string
originGoodIds?: string[]
primaryOriginGoodId?: string
}) => {
return request.post<any, ProductFamily>('/product-families', data)
},
patch: (id: string, data: Record<string, unknown>) => {
return request.patch<any, ProductFamily>(`/product-families/${id}`, data)
},
autoGroup: (apply: boolean) => {
return request.post<any, { applied: number; groups: AutoGroupPreviewGroup[] }>(
'/product-families/auto-group',
{ apply },
)
},
/** 整理原产品库(显式人工动作):解析列回填 → 派生标签 → 自动建族 → 全量重算 */
organize: () => {
return request.post<any, {
labelsParsed: number
labelsUnparsable: number
linksUpdated: number
goodsUpdated: number
familiesCreated: number
familiesRecomputed: number
}>('/product-families/organize')
},
updateMembers: (id: string, data: { addOriginGoodIds?: string[]; removeOriginGoodIds?: string[] }) => {
return request.post<any, ProductFamily>(`/product-families/${id}/members`, data)
},
createCustomMember: (id: string, data: Record<string, unknown>) => {
return request.post<any, unknown>(`/product-families/${id}/members/custom`, data)
},
recompute: (id: string) => {
return request.post<any, ProductFamily>(`/product-families/${id}/recompute`)
},
listOverrides: (id: string) => {
return request.get<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`)
},
putOverrides: (id: string, items: Array<Record<string, unknown>>) => {
return request.put<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`, { items })
},
deleteOverrides: (id: string, cells: Array<Record<string, unknown>>) => {
return request.delete<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`, {
data: { cells },
})
},
}
+27 -18
View File
@@ -1,5 +1,5 @@
import axios from 'axios' import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios' import type { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import router from '@/router' import router from '@/router'
@@ -9,22 +9,10 @@ const request: AxiosInstance = axios.create({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
// Session tokens live in HttpOnly cookies — send them along.
withCredentials: true,
}) })
// Request interceptor
request.interceptors.request.use(
(config: AxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
// Response interceptor // Response interceptor
request.interceptors.response.use( request.interceptors.response.use(
(response: AxiosResponse) => { (response: AxiosResponse) => {
@@ -35,15 +23,36 @@ request.interceptors.response.use(
} }
return body return body
}, },
(error: AxiosError) => { async (error: AxiosError) => {
// Access token expired: try the refresh cookie once, then retry the
// original request. A second 401 (refresh failed) logs the user out.
const config = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined
if (
error.response?.status === 401 &&
config &&
!config._retried &&
!config.url?.includes('/auth/login') &&
!config.url?.includes('/auth/refresh')
) {
config._retried = true
try {
await axios.post(
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`,
{},
{ withCredentials: true },
)
return request.request(config)
} catch {
// fall through to the 401 handling below
}
}
if (error.response) { if (error.response) {
const { status, data } = error.response const { status, data } = error.response
switch (status) { switch (status) {
case 401: case 401:
ElMessage.error('Unauthorized, please login') ElMessage.error('Unauthorized, please login')
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login') router.push('/login')
break break
case 403: case 403:
+4
View File
@@ -11,12 +11,14 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside'] ElAside: typeof import('element-plus/es')['ElAside']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCascader: typeof import('element-plus/es')['ElCascader'] ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
@@ -33,6 +35,7 @@ declare module 'vue' {
ElImage: typeof import('element-plus/es')['ElImage'] ElImage: typeof import('element-plus/es')['ElImage']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer'] ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain'] ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
@@ -42,6 +45,7 @@ declare module 'vue' {
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabPane: typeof import('element-plus/es')['ElTabPane']
+7 -2
View File
@@ -28,14 +28,19 @@ const routes: RouteRecordRaw[] = [
] ]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory('/v2/admin/'),
routes, routes,
}) })
router.beforeEach((to) => { router.beforeEach(async (to) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const isPublic = to.meta?.public === true const isPublic = to.meta?.public === true
// 会话在 HttpOnly cookie 里:刷新后先经 /auth/me 恢复登录态,再判定是否放行
if (!authStore.isLoggedIn && !isPublic) {
await authStore.ensureSessionChecked()
}
if (!authStore.isLoggedIn && !isPublic) { if (!authStore.isLoggedIn && !isPublic) {
return { path: '/login', replace: true } return { path: '/login', replace: true }
} }
+26 -35
View File
@@ -4,49 +4,41 @@ import type { LoginRequest, User } from '@/types'
import { authApi } from '@/api/auth' import { authApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
// Token persisted to localStorage // The session lives in HttpOnly cookies set by the API; nothing
const token = ref<string>(localStorage.getItem('token') || '') // security-relevant is stored client-side. `user` is just UI state,
// restored from the server via /auth/me on app start.
const user = ref<User | null>(null)
const sessionChecked = ref(false)
// User persisted to localStorage (parsed if available) // Tokens moved to HttpOnly cookies; clean up any stale values from the
const user = ref<User | null>(loadUser()) // previous localStorage-based session.
const isLoggedIn = computed(() => !!token.value)
function loadUser(): User | null {
const raw = localStorage.getItem('user')
if (!raw) return null
try {
return JSON.parse(raw) as User
} catch {
return null
}
}
function setToken(newToken: string) {
token.value = newToken
if (newToken) {
localStorage.setItem('token', newToken)
} else {
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('user')
const isLoggedIn = computed(() => !!user.value)
// Restore the session once per app start. The router guard awaits this
// so a page refresh on a protected route does not bounce to /login.
async function ensureSessionChecked() {
if (sessionChecked.value) return
sessionChecked.value = true
try {
user.value = await authApi.getCurrentUser()
} catch {
user.value = null
} }
} }
function setUser(newUser: User | null) { function setUser(newUser: User | null) {
user.value = newUser user.value = newUser
if (newUser) {
localStorage.setItem('user', JSON.stringify(newUser))
} else {
localStorage.removeItem('user')
}
} }
async function login(payload: LoginRequest) { async function login(payload: LoginRequest) {
const res = await authApi.login(payload) as any; const res = await authApi.login(payload) as any
const accessToken: string = res.accessToken ?? res.data?.accessToken ?? ''; const userData: User | null = res.user ?? res.data?.user ?? null
const userData: User | null = res.user ?? res.data?.user ?? null; sessionChecked.value = true
if (accessToken) setToken(accessToken); setUser(userData)
if (userData) setUser(userData); return res
return res;
} }
async function fetchCurrentUser() { async function fetchCurrentUser() {
@@ -61,14 +53,13 @@ export const useAuthStore = defineStore('auth', () => {
} catch { } catch {
// Ignore network errors during logout // Ignore network errors during logout
} }
setToken('')
setUser(null) setUser(null)
} }
return { return {
token,
user, user,
isLoggedIn, isLoggedIn,
ensureSessionChecked,
login, login,
fetchCurrentUser, fetchCurrentUser,
logout, logout,
+185
View File
@@ -42,6 +42,17 @@ export interface Good {
position?: Position position?: Position
createdAt: string createdAt: string
updatedAt: string updatedAt: string
mergedOriginGoods?: MergedOriginGoodSummary[]
}
export interface MergedOriginGoodSummary {
id: string
sdsGoodId: string
goodName: string | null
goodImage: string | null
goodPrice: string | null
hasDetail: boolean
variantCount: number
} }
export interface OriginGoodDetail { export interface OriginGoodDetail {
@@ -68,6 +79,49 @@ export interface OriginGoodVariant {
[key: string]: unknown [key: string]: unknown
} }
export interface CustomGoodVariantRequest {
sku: string
sizeId?: string | null
sizeName?: string | null
colorId?: string | null
colorName?: string | null
colorHex?: string | null
imageUrl?: string | null
price?: string | null
originalPrice?: string | null
weightG?: string | null
boxLengthCm?: string | null
boxWidthCm?: string | null
boxHeightCm?: string | null
designData?: Record<string, unknown> | null
enabled?: boolean
sortOrder?: number
}
export interface CustomGoodDetailRequest {
productCode?: string | null
englishName?: string | null
productionCycleHours?: number | null
minWeightG?: string | null
productionProcess?: string | null
materialDescription?: string | null
blankDesignUrl?: string | null
detailsPageVideoUrl?: string | null
textureName?: string | null
reminder?: string | null
productPerformance?: string | null
applicableScenarios?: string | null
washingInstructions?: string | null
specialDescription?: string | null
designExplanation?: string | null
designArea?: string | null
pictureRequest?: string | null
sizeChart?: Record<string, unknown> | null
packageSpecs?: Record<string, unknown> | null
options?: Record<string, unknown> | null
media?: Record<string, unknown> | null
}
export interface GoodDetail extends Good { export interface GoodDetail extends Good {
originDetail: OriginGoodDetail | null originDetail: OriginGoodDetail | null
variants: OriginGoodVariant[] variants: OriginGoodVariant[]
@@ -82,6 +136,28 @@ export interface CreateGoodRequest {
tagIds?: number[] tagIds?: number[]
positionId?: number positionId?: number
goodPriority?: number goodPriority?: number
mergedOriginGoodIds?: number[]
}
export interface CreateCustomGoodRequest {
goodName: string
goodImage?: string
goodPrice?: string | null
countryId: number
categoryId: number
tagIds?: number[]
positionId?: number
goodPriority?: number
detail?: CustomGoodDetailRequest
variants?: CustomGoodVariantRequest[]
}
export interface UpdateCustomGoodContentRequest {
goodName?: string
goodImage?: string | null
goodPrice?: string | null
detail?: CustomGoodDetailRequest
variants?: CustomGoodVariantRequest[]
} }
export interface UpdateGoodRequest { export interface UpdateGoodRequest {
@@ -93,6 +169,7 @@ export interface UpdateGoodRequest {
tagIds?: number[] tagIds?: number[]
positionId?: number | null positionId?: number | null
goodPriority?: number goodPriority?: number
mergedOriginGoodIds?: number[]
} }
export interface BatchCreateItem { export interface BatchCreateItem {
@@ -271,6 +348,8 @@ export interface OriginGood {
goodImage: string | null goodImage: string | null
goodPrice: string | null goodPrice: string | null
sdsGoodId: string sdsGoodId: string
source: 'SDS' | 'CUSTOM'
isCustom: boolean
sdsCategoryId: string | null sdsCategoryId: string | null
delisted?: boolean delisted?: boolean
createdAt: string createdAt: string
@@ -281,6 +360,7 @@ export interface OriginGood {
sizeRowCount?: number sizeRowCount?: number
packageRowCount?: number packageRowCount?: number
productCode?: string | null productCode?: string | null
family?: { familyId: string; familyCode: string | null; familyName: string; memberCount?: number } | null
} }
// Origin Goods Tree types // Origin Goods Tree types
@@ -299,6 +379,10 @@ export interface OriginGoodsTreeNode {
variantCount: number variantCount: number
sizeRowCount: number sizeRowCount: number
packageRowCount: number packageRowCount: number
familyId: string | null
familyName: string | null
familyCode: string | null
familyStale: boolean | null
} }
export interface OriginGoodsTreeCategoryNode { export interface OriginGoodsTreeCategoryNode {
@@ -317,6 +401,107 @@ export interface OriginGoodsTreeResponse {
configuredCount: number configuredCount: number
} }
// Product Family (SPU layer) types
export interface OriginGoodTagInfo {
id: string
tagName: string
tagColor: string | null
tagFontColor: string | null
manual: boolean
}
/** 链接标签配置结果(origin-goods/:id/tags */
export interface OriginGoodTagsResult {
tagsManual: boolean
tags: OriginGoodTagInfo[]
}
export interface ProductFamilyMember {
id: string
sdsGoodId: string
goodName: string
goodImage: string | null
goodPrice: string | null
source: 'SDS' | 'CUSTOM'
delisted: boolean
skuCode: string | null
logisticsLabel: string | null
craftLabel: string | null
warehouseLabel: string | null
tagsManual: boolean
originGoodTags: Array<{ id: string; manual: boolean; tag: OriginGoodTagInfo }>
variantCount: number
}
export interface FamilyPriceOverrideRow {
id: string
familyId: string
sizeId: string
colorId: string
craft: string
logistics: string
price: string
note: string | null
derivedPrice: string | null
diff: string | null
}
export interface ProductFamilyPriceMatrix {
sizes: Array<{ key: string; name: string | null }>
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>
crafts: string[]
logistics: string[]
rows: Array<{
sizeId: string
sizeName: string | null
colorId: string
colorName: string | null
craft: string
logistics: string
price: string
manual: boolean
sources: Array<{ sdsGoodId: string; sdsVariantId: string; price: string }>
}>
}
export interface ProductFamily {
id: string
familyCode: string | null
familyName: string
familyImage: string | null
countryId: string | null
categoryId: string | null
primaryOriginGoodId: string | null
autoManaged: boolean
stale: boolean
detail: Record<string, unknown> | null
sizeChart: { columns?: unknown; rows?: Array<Record<string, unknown>> } | null
packageSpecs: { columns?: unknown; rows?: Array<Record<string, unknown>> } | null
priceMatrix: ProductFamilyPriceMatrix | null
originGoods: ProductFamilyMember[]
priceOverrides: FamilyPriceOverrideRow[]
_count: { originGoods: number; priceOverrides: number }
}
export interface AutoGroupPreviewGroup {
groupKey: string
familyName: string
familyCode: string | null
memberCount: number
sampleNames: string[]
}
export interface CustomMemberVariantInput {
sku: string
sizeId?: string | null
sizeName?: string | null
colorId?: string | null
colorName?: string | null
colorHex?: string | null
imageUrl?: string | null
price: number
}
// Sync types // Sync types
export interface SyncLog { export interface SyncLog {
id: string id: string
+28
View File
@@ -0,0 +1,28 @@
import type { CategoryTree } from '@/types'
/** 在分类树中找目标分类的祖先路径(含自身) */
export function findCategoryPath(nodes: CategoryTree[], targetId: string): string[] {
for (const n of nodes) {
if (n.id === targetId) return [n.id]
if (n.children?.length) {
const sub = findCategoryPath(n.children, targetId)
if (sub.length) return [n.id, ...sub]
}
}
return []
}
export interface CascadeNode {
value: string
label: string
children?: CascadeNode[]
}
/** 分类树 → el-cascader 选项 */
export function buildCascader(tree: CategoryTree[]): CascadeNode[] {
return tree.map((n) => ({
value: n.id,
label: n.categoryName,
children: n.children?.length ? buildCascader(n.children) : undefined,
}))
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { sameFamily } from './family-match'
const node = (familyId: string | null, goodName: string) => ({ familyId, goodName })
describe('sameFamily', () => {
it('同族(familyId 相等且非空)→ true,名称不同也不影响', () => {
expect(
sameFamily(node('12', '美国(包邮)T恤-DG001-单面印花'), node('12', '墨西哥(不包邮)卫衣-XX-双面印花')),
).toBe(true)
})
it('任一方 familyId 为空 → 回退名称 3 段规则', () => {
const a = node(null, '美国(包邮)T恤-DG001-单面印花')
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG001-单面印花-某仓'))).toBe(true)
expect(sameFamily(a, node('12', '美国(包邮)T恤-DG001-单面印花'))).toBe(true)
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-单面印花'))).toBe(false)
})
it('familyId 不同 → false(即使名称同段)', () => {
expect(
sameFamily(node('1', '美国(包邮)T恤-DG001-单面印花'), node('2', '美国(包邮)T恤-DG001-单面印花')),
).toBe(false)
})
it('双方均无族且名称不可比 → false', () => {
expect(sameFamily(node(null, ''), node(null, '随便'))).toBe(false)
expect(sameFamily(node(null, null as unknown as string), node(null, '随便'))).toBe(false)
})
})
+14
View File
@@ -0,0 +1,14 @@
import { sameOriginGroup } from './origin-name'
/**
* 两个原产品节点是否属于同一产品族:
* 优先用服务端返回的 familyId 精确匹配;无族归属时回退名称 3 段规则
* (老数据/未回填场景兜底)。
*/
export function sameFamily(
a: { familyId?: string | null; goodName?: string | null },
b: { familyId?: string | null; goodName?: string | null },
): boolean {
if (a.familyId && b.familyId) return a.familyId === b.familyId
return sameOriginGroup(a.goodName, b.goodName)
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import {
cleanLinkName,
deriveLinkTagNames,
linkDims,
parseLinkName,
sameOriginGroup,
truncateToProcess,
} from './origin-name';
describe('truncateToProcess', () => {
it('keeps first 3 dash segments (drops warehouse)', () => {
expect(truncateToProcess('美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓'))
.toBe('美国(包邮)180g纯棉T恤成人款-DG001-单面印花');
});
it('is identity for 3-segment names', () => {
expect(truncateToProcess('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面'))
.toBe('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面');
});
it('handles malformed names without country prefix', () => {
expect(truncateToProcess('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花'))
.toBe('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花');
});
it('returns empty for null/empty', () => {
expect(truncateToProcess(null)).toBe('');
expect(truncateToProcess('')).toBe('');
});
});
describe('sameOriginGroup', () => {
it('matches same product across warehouses', () => {
expect(sameOriginGroup(
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓',
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美中亚特兰大仓',
)).toBe(true);
});
it('does not match different SKU', () => {
expect(sameOriginGroup(
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花',
'美国(包邮)180g纯棉T恤成人款-DG002-单面印花',
)).toBe(false);
});
it('does not match different country', () => {
expect(sameOriginGroup(
'美国(包邮)T恤-DG001-单面印花',
'德国(不包邮)T恤-DG001-单面印花',
)).toBe(false);
});
it('does not match empty names', () => {
expect(sameOriginGroup(null, 'x-y-z')).toBe(false);
expect(sameOriginGroup('', '')).toBe(false);
});
});
describe('parseLinkName / cleanLinkName', () => {
it('parses 国家(物流)品名-SKU-工艺 → 品名 + 型号', () => {
expect(parseLinkName('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual({
productName: '180g纯棉T恤成人款',
skuCode: 'DG001',
});
expect(cleanLinkName('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toBe(
'180g纯棉T恤成人款 DG001',
);
});
it('handles half-width parens and warehouse suffix', () => {
expect(
cleanLinkName('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
).toBe('180GT恤成人款 JSA002');
});
it('returns null / raw fallback for manual names without the country(remark) structure', () => {
expect(parseLinkName('180g纯棉T恤成人款')).toBeNull();
expect(cleanLinkName('180g纯棉T恤成人款')).toBe('180g纯棉T恤成人款');
expect(cleanLinkName(null)).toBe('');
});
it('returns null when product name after parens is empty', () => {
expect(parseLinkName('美国(包邮)-DG001-单面印花')).toBeNull();
});
});
describe('deriveLinkTagNames', () => {
it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => {
expect(deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual([
'单面印花',
'烫画',
'包邮',
]);
});
it('不打印 + 光板 + 不包邮 → 归并为单个不打印(光板即不打印)', () => {
expect(
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
).toEqual(['不打印', '不包邮']);
});
it('双面印花 + 不包邮,且不误命中包邮', () => {
expect(deriveLinkTagNames('美国(不包邮)T恤-DG001-双面印花·美东新泽西仓')).toEqual([
'双面印花',
'烫画',
'不包邮',
]);
});
it('空名称返回空数组', () => {
expect(deriveLinkTagNames(null)).toEqual([]);
});
});
describe('linkDims', () => {
it('解析物流备注/工艺/印花数量', () => {
expect(linkDims('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual({
logistics: '包邮',
crafts: ['烫画'],
printCount: '单面印花',
});
expect(linkDims('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓')).toEqual({
logistics: '不包邮光板',
crafts: ['不打印'],
printCount: null,
});
});
it('无括号结构时物流为 null,手工名称工艺默认烫画', () => {
expect(linkDims('纯棉T恤')).toEqual({ logistics: null, crafts: ['烫画'], printCount: null });
expect(linkDims(null)).toEqual({ logistics: null, crafts: [], printCount: null });
});
});
+105
View File
@@ -0,0 +1,105 @@
/**
* 原产品名匹配规则(见 data-cleaning/README.md 命名调查):
* `国家(物流备注)品名-SKU-工艺位置[-仓库名]`
* 截断到「工艺位置」= 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。
* 国家前缀在第 1 段内,天然参与比较(不同国家不合并)。
*/
const KEEP_SEGMENTS = 3;
export function truncateToProcess(name: string | null | undefined): string {
if (!name) return '';
return name.split('-').slice(0, KEEP_SEGMENTS).join('-');
}
export function sameOriginGroup(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const ka = truncateToProcess(a);
return ka !== '' && ka === truncateToProcess(b);
}
/** 链接名结构化解析结果:品名 + 型号(SKU 代码) */
export interface ParsedLinkName {
productName: string;
skuCode: string | null;
}
/**
* 解析链接名称:`国家(物流备注)品名-SKU-工艺位置[-·仓库名]` → 品名 + 型号。
* 与 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();
if (!productName) return null;
const skuCode = segs[1] || null;
return { productName, skuCode };
}
/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名 */
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 CRAFT_KEYWORD_MAP: Record<string, string> = {
: '直喷',
: '不打印',
: '不打印', // 光板即为不打印
};
/** 链接的定价维度(SKU 表展示用):物流备注 / 工艺 / 印花数量 */
export interface LinkDims {
logistics: string | null;
crafts: string[];
printCount: string | null;
}
/** 从链接名称解析定价维度;工艺无命中时默认烫画(与派生标签规则一致) */
export function linkDims(name: string | null | undefined): LinkDims {
if (!name) return { logistics: null, crafts: [], printCount: null };
const head = name.split('-')[0] ?? '';
const openCandidates = [head.indexOf(''), head.indexOf('(')].filter((i) => i >= 0);
const openIdx = openCandidates.length ? Math.min(...openCandidates) : -1;
const closeIdx = Math.max(head.lastIndexOf(''), head.lastIndexOf(')'));
const logistics =
openIdx >= 0 && closeIdx > openIdx ? head.slice(openIdx + 1, closeIdx).trim() || null : null;
const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))];
// 与 api 端一致:裸「双面/单面」也归入印花数量(如「直喷双面」)
const printCount = name.includes('双面')
? '双面印花'
: name.includes('单面')
? '单面印花'
: null;
return {
logistics,
crafts: craftHits.length > 0 ? craftHits : ['烫画'],
printCount,
};
}
/**
* 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示):
* 印花数量:双面 优先于 单面(含「直喷双面」这类工艺段写法);工艺:直喷/不打印/光板→不打印,皆无则默认烫画;
* 物流:不包邮 优先于 包邮。
*/
export function deriveLinkTagNames(name: string | null | undefined): string[] {
if (!name) return [];
const names: string[] = [];
if (name.includes('双面')) names.push('双面印花');
else if (name.includes('单面')) names.push('单面印花');
const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))];
if (craftHits.length > 0) names.push(...craftHits);
else names.push('烫画');
if (name.includes('不包邮')) names.push('不包邮');
else if (name.includes('包邮')) names.push('包邮');
return names;
}
@@ -52,7 +52,6 @@ const dialogLoading = ref(false)
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({ const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
categoryName: '', categoryName: '',
categoryIcon: '', categoryIcon: '',
parentCategoryId: '',
}) })
const dialogRules = { const dialogRules = {
@@ -146,7 +145,7 @@ onMounted(fetchTree)
> >
<el-table-column prop="categoryName" label="名称" min-width="220" /> <el-table-column prop="categoryName" label="名称" min-width="220" />
<el-table-column label="图标" width="100"> <el-table-column label="图标" width="100">
<template #default="{ row }: { row: CategoryTree }"> <template #default="{ row }: any">
<el-image <el-image
v-if="row.categoryIcon" v-if="row.categoryIcon"
:src="row.categoryIcon" :src="row.categoryIcon"
@@ -158,17 +157,17 @@ onMounted(fetchTree)
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="父级"> <el-table-column label="父级">
<template #default="{ row }: { row: CategoryTree }"> <template #default="{ row }: any">
{{ row.parent?.categoryName || '-' }} {{ row.parent?.categoryName || '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="子级数" width="100"> <el-table-column label="子级数" width="100">
<template #default="{ row }: { row: CategoryTree }"> <template #default="{ row }: any">
{{ row._count?.children ?? row.children?.length ?? 0 }} {{ row._count?.children ?? row.children?.length ?? 0 }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: CategoryTree }"> <template #default="{ row }: any">
<div class="table-actions"> <div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)"> <el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon> <el-icon><Edit /></el-icon>
+131 -37
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue' import { Plus, Edit, Delete, Refresh, Search, Rank } from '@element-plus/icons-vue'
import type { import type {
Country, Country,
CreateCountryRequest, CreateCountryRequest,
@@ -23,7 +23,7 @@ const filter = reactive<Required<CountryFilter>>({
async function fetchList() { async function fetchList() {
loading.value = true loading.value = true
try { try {
const res = await countriesApi.getCountriesList(filter) await countriesApi.getCountriesList(filter)
const data = await countriesApi.getCountriesList(filter) as any const data = await countriesApi.getCountriesList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? []) const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr list.value = arr
@@ -44,6 +44,38 @@ function handleReset() {
fetchList() fetchList()
} }
// ─── 拖拽排序:松开即按新顺序全量保存(sortOrder = 下标) ───
const dragIndex = ref<number | null>(null)
const sorting = ref(false)
function onRowDragStart(index: number) {
dragIndex.value = index
}
async function onRowDrop(index: number) {
const from = dragIndex.value
dragIndex.value = null
if (from === null || from === index || sorting.value) return
const next = [...list.value]
const [moved] = next.splice(from, 1)
next.splice(index, 0, moved)
list.value = next
sorting.value = true
try {
const updated = await countriesApi.sortCountries(
next.map((c, i) => ({ id: String(c.id), sortOrder: i })),
)
const arr = Array.isArray(updated) ? updated : []
if (arr.length) list.value = arr
ElMessage.success('排序已保存')
} catch {
ElMessage.error('排序保存失败')
await fetchList()
} finally {
sorting.value = false
}
}
const dialogRef = ref() const dialogRef = ref()
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create') const dialogMode = ref<'create' | 'edit'>('create')
@@ -145,27 +177,30 @@ onMounted(fetchList)
</el-button> </el-button>
</div> </div>
<el-table v-loading="loading" :data="list" border stripe> <ul v-loading="loading" class="country-list">
<el-table-column label="图标" width="80"> <li
<template #default="{ row }: { row: Country }"> v-for="(row, index) in list"
:key="row.id"
class="country-row"
:class="{ 'is-dragging': dragIndex === index }"
draggable="true"
title="拖拽调整顺序,松开即保存"
@dragstart="onRowDragStart(index)"
@dragover.prevent
@drop="onRowDrop(index)"
@dragend="dragIndex = null"
>
<el-icon class="drag-handle"><Rank /></el-icon>
<el-image <el-image
v-if="row.countryIcon" v-if="row.countryIcon"
:src="row.countryIcon" :src="row.countryIcon"
:preview-src-list="[row.countryIcon]" :preview-src-list="[row.countryIcon]"
fit="cover" fit="cover"
style="width: 32px; height: 32px; border-radius: 4px;" class="row-icon"
/> />
<span v-else>-</span> <span v-else class="row-icon row-icon-placeholder">-</span>
</template> <span class="row-name">{{ row.countryName }}</span>
</el-table-column> <span class="row-time">{{ new Date(row.createdAt).toLocaleString() }}</span>
<el-table-column prop="countryName" label="名称" min-width="200" />
<el-table-column label="创建时间" width="180">
<template #default="{ row }: { row: Country }">
{{ new Date(row.createdAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Country }">
<div class="table-actions"> <div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)"> <el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon> <el-icon><Edit /></el-icon>
@@ -176,23 +211,9 @@ onMounted(fetchList)
<span>删除</span> <span>删除</span>
</el-button> </el-button>
</div> </div>
</template> </li>
</el-table-column> <li v-if="!loading && !list.length" class="country-empty">暂无国家</li>
<template #empty> </ul>
<el-empty description="暂无国家" />
</template>
</el-table>
<el-pagination
class="pagination"
v-model:current-page="filter.page"
v-model:page-size="filter.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(p: number) => { filter.page = p; fetchList() }"
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
/>
</div> </div>
<el-dialog <el-dialog
@@ -224,8 +245,81 @@ onMounted(fetchList)
flex: 1; flex: 1;
} }
.pagination { .country-list {
margin-top: 16px; list-style: none;
justify-content: flex-end; margin: 0;
padding: 0;
}
.country-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-top: none;
cursor: grab;
}
.country-row:first-child {
border-top: 1px solid var(--el-border-color-lighter);
border-radius: 4px 4px 0 0;
}
.country-row:last-child {
border-radius: 0 0 4px 4px;
}
.country-row:only-child {
border-radius: 4px;
}
.country-row.is-dragging {
opacity: 0.5;
}
.country-row:hover {
background: var(--el-fill-color-light);
}
.drag-handle {
color: var(--el-text-color-secondary);
font-size: 16px;
}
.row-icon {
width: 32px;
height: 32px;
border-radius: 4px;
flex: none;
}
.row-icon-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
border: 1px dashed var(--el-border-color);
}
.row-name {
font-weight: 500;
}
.row-time {
margin-left: auto;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.table-actions {
flex: none;
}
.country-empty {
padding: 32px 0;
text-align: center;
color: var(--el-text-color-secondary);
} }
</style> </style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { CategoryTree, Country, Tag } from '@/types'
import { goodsApi } from '@/api/goods'
import { countriesApi } from '@/api/countries'
import { buildCascader } from '@/utils/category-tree'
const props = defineProps<{
visible: boolean
countries: Country[]
categories: CategoryTree[]
/** 分组后的标签选项 {id,label,tags} */
tagOptionGroups: Array<{ id: string; label: string; tags: Tag[] }>
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'created', good: any): void
/** 快捷新建国家后由父级刷新字典 */
(e: 'dictionaries-changed'): void
}>()
const loading = ref(false)
const form = ref({
goodName: '', goodImage: '', goodPrice: '', countryId: '',
cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[],
goodPriority: 0,
})
watch(() => props.visible, (v) => {
if (v) {
form.value = {
goodName: '', goodImage: '', goodPrice: '', countryId: '',
cascaderCategory: [], categoryId: '', tagIds: [], goodPriority: 0,
}
}
})
const categoryCascader = computed(() => buildCascader(props.categories))
function onCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
form.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
async function quickCreateCountry() {
try {
const { value } = await ElMessageBox.prompt('请输入国家名称', '新增国家', {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
})
if (!value.trim()) return
const created = await countriesApi.createCountry({ countryName: value.trim() } as any)
emit('dictionaries-changed')
if (created?.id) form.value.countryId = created.id
ElMessage.success('已创建并选中')
} catch {}
}
async function handleSubmit() {
if (!form.value.goodName.trim()) { ElMessage.warning('请输入商品名称'); return }
if (!form.value.countryId) { ElMessage.warning('请选择国家'); return }
if (!form.value.categoryId) { ElMessage.warning('请选择分类'); return }
loading.value = true
try {
const created = await goodsApi.createCustomGood({
goodName: form.value.goodName.trim(),
goodImage: form.value.goodImage || undefined,
goodPrice: form.value.goodPrice || null,
countryId: Number(form.value.countryId),
categoryId: Number(form.value.categoryId),
tagIds: form.value.tagIds.map(Number),
goodPriority: form.value.goodPriority,
detail: {},
})
ElMessage.success('自定义商品已创建,可继续完善详情、尺码、包装和 SKU')
emit('update:visible', false)
emit('created', created)
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '自定义商品创建失败')
} finally { loading.value = false }
}
</script>
<template>
<el-dialog :model-value="visible" title="新增自定义商品" width="620px" destroy-on-close @update:model-value="emit('update:visible', $event)">
<el-alert
title="自定义商品不关联 SDS 原产品,名称、图片、价格、详情、尺码、包装和 SKU 均可维护。"
type="info"
:closable="false"
style="margin-bottom:16px"
/>
<el-form label-width="90px">
<el-form-item label="商品名称" required><el-input v-model="form.goodName" /></el-form-item>
<el-form-item label="商品图片"><ImageUpload v-model="form.goodImage" label="上传图片" /></el-form-item>
<el-form-item label="基础价格"><el-input v-model="form.goodPrice" placeholder="例如 28.00" /></el-form-item>
<el-form-item label="国家" required>
<div class="select-inline">
<el-select v-model="form.countryId" filterable placeholder="请选择国家" style="width:100%">
<el-option v-for="c in countries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
<el-button text :icon="Plus" @click="quickCreateCountry" />
</div>
</el-form-item>
<el-form-item label="分类" required>
<el-cascader v-model="form.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" style="width:100%" @change="onCascaderChange" />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="form.tagIds" multiple filterable placeholder="请选择标签" style="width:100%">
<el-option-group v-for="g in tagOptionGroups" :key="g.id" :label="g.label">
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="优先级"><el-input-number v-model="form.goodPriority" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">创建并完善详情</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { CategoryTree, Country } from '@/types'
import { goodsApi } from '@/api/goods'
import { productFamiliesApi } from '@/api/product-families'
import { findCategoryPath, buildCascader } from '@/utils/category-tree'
const props = defineProps<{
visible: boolean
/** 拖拽/配置的链接节点(含 rawId/goodName/goodImage/goodPrice/sdsGoodId/familyId */
og: any | null
/** 同分类下的可合并兄弟链接 */
siblings: any[]
/** 默认勾选的兄弟 rawId */
defaultCheckedIds: string[]
/** 左树拖放目标(国家/分类节点),为空时表单手动选 */
dropTarget: any | null
mode: 'category' | 'country' | 'global'
countries: Country[]
categories: CategoryTree[]
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'configured'): void
}>()
const loading = ref(false)
const form = ref({
countryId: '', cascaderCategory: [] as string[], categoryId: '', goodImage: '',
})
watch(() => props.visible, (v) => {
if (!v) return
const og = props.og
form.value = { countryId: '', cascaderCategory: [], categoryId: '', goodImage: og?.goodImage || '' }
if (props.dropTarget) {
if (props.mode === 'category') {
form.value.categoryId = props.dropTarget.id
form.value.cascaderCategory = findCategoryPath(props.categories, props.dropTarget.id)
} else {
form.value.countryId = props.dropTarget.id
}
}
})
const categoryCascader = computed(() => buildCascader(props.categories))
const checkedIds = ref<string[]>([])
watch(() => props.visible, (v) => {
if (v) checkedIds.value = [...props.defaultCheckedIds]
})
// 合并同名候选搜索(本地过滤同分类链接)
const mergeKeyword = ref('')
watch(() => props.visible, (v) => {
if (v) mergeKeyword.value = ''
})
const filteredSiblings = computed(() => {
const kw = mergeKeyword.value.trim()
if (!kw) return props.siblings
return props.siblings.filter((s) => (s.goodName ?? '').includes(kw))
})
function onCascaderChange(val: any) {
form.value.categoryId = val.length ? val[val.length - 1] : ''
}
/** 勾选的兄弟链接与当前链接静默归入同一族(无族则自动建族);失败不阻断原配置流程 */
async function ensureFamilyMembership() {
const og = props.og
const primaryId = String(og.rawId)
const checked = checkedIds.value.filter((id) => id !== primaryId)
if (!checked.length) return
try {
if (og.familyId) {
await productFamiliesApi.updateMembers(og.familyId, {
addOriginGoodIds: [...new Set([primaryId, ...checked])],
})
} else {
await productFamiliesApi.create({
familyName: og.goodName ?? '',
originGoodIds: [primaryId, ...checked],
primaryOriginGoodId: primaryId,
})
}
} catch (e: any) {
ElMessage.warning(e?.response?.data?.message || '挂族失败,商品仍按原方式配置')
}
}
async function handleSubmit() {
const og = props.og
if (!form.value.countryId) { ElMessage.warning('请选择国家'); return }
if (!form.value.categoryId) { ElMessage.warning('请选择分类'); return }
loading.value = true
try {
await ensureFamilyMembership()
await goodsApi.createGood({
goodName: og.goodName,
goodImage: form.value.goodImage || undefined,
originGoodId: Number(og.rawId),
countryId: Number(form.value.countryId),
categoryId: Number(form.value.categoryId),
positionId: undefined,
} as any)
ElMessage.success('配置成功')
emit('update:visible', false)
emit('configured')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '配置失败')
} finally { loading.value = false }
}
</script>
<template>
<el-dialog :model-value="visible" title="配置原产品" width="520px" destroy-on-close @update:model-value="emit('update:visible', $event)">
<div v-if="og" class="config-og-info">
<div>
<div class="config-og-name">{{ og.goodName }}</div>
<div class="config-og-meta">SDS ID: {{ og.sdsGoodId }}<template v-if="og.goodPrice"> · 价格: ¥{{ og.goodPrice }}</template></div>
</div>
</div>
<el-form label-width="80px" style="margin-top: 16px">
<el-form-item label="国家">
<div v-if="mode === 'category' || !dropTarget" class="select-inline">
<el-select v-model="form.countryId" placeholder="请选择国家" filterable>
<el-option v-for="c in countries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
</div>
<el-tag v-else>{{ countries.find(c => c.id === form.countryId)?.countryName }}</el-tag>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-if="mode === 'country' || !dropTarget" v-model="form.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onCascaderChange" style="width:100%" />
<el-tag v-else>{{ dropTarget?.label }}</el-tag>
</el-form-item>
<el-form-item label="合并同名">
<div class="config-merge-box">
<el-input
v-model="mergeKeyword"
size="small"
clearable
placeholder="搜索同款链接(全局,含已配置)…"
style="margin-bottom:8px"
/>
<el-checkbox-group v-model="checkedIds">
<el-checkbox v-for="s in filteredSiblings" :key="s.rawId" :value="String(s.rawId)">
{{ s.goodName }}<template v-if="s.goodPrice"> · ¥{{ s.goodPrice }}</template>
</el-checkbox>
</el-checkbox-group>
<div v-if="!filteredSiblings.length" class="config-merge-empty">
{{ siblings.length ? '没有匹配的链接' : '未找到同款链接同款号或同族' }}
</div>
</div>
</el-form-item>
<el-form-item label="图片">
<ImageUpload v-model="form.goodImage" label="上传图片" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">确认配置</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
.config-og-name { font-weight: 600; font-size: 14px; }
.config-og-meta { color: #909399; font-size: 12px; margin-top: 2px; }
.config-merge-box { width: 100%; }
.config-merge-empty { font-size: 12px; color: #909399; padding: 4px 0; }
.config-merge-box .el-checkbox-group { display: flex; flex-direction: column; align-items: flex-start; max-height: 200px; overflow-y: auto; }
</style>
@@ -0,0 +1,927 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, ArrowDown, QuestionFilled } from '@element-plus/icons-vue'
import type { CategoryTree, Country, Good, GoodDetail, Tag } from '@/types'
import { goodsApi } from '@/api/goods'
import { originGoodsApi } from '@/api/origin-goods'
import { productFamiliesApi } from '@/api/product-families'
import { countriesApi } from '@/api/countries'
import { syncApi } from '@/api/sync'
import { cleanLinkName, deriveLinkTagNames, linkDims } from '@/utils/origin-name'
import { findCategoryPath, buildCascader } from '@/utils/category-tree'
const props = defineProps<{
visible: boolean
goodId: string
countries: Country[]
categories: CategoryTree[]
/** 分组后的标签选项 {id,label,tags} */
tagOptionGroups: Array<{ id: string; label: string; tags: Tag[] }>
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
/** 商品数据变化(保存/同步/族变更),父级刷新左右树 */
(e: 'changed'): void
(e: 'deleted'): void
/** 快捷新建国家后由父级刷新字典 */
(e: 'dictionaries-changed'): void
}>()
const editLoading = ref(false)
const editDetailLoading = ref(false)
/** 正在同步详情的成员行 id */
const detailSyncing = ref<string>('')
const editGood = ref<Good | GoodDetail | null>(null)
const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', positionId: '',
})
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
const categoryCascader = computed(() => buildCascader(props.categories))
// ─── 自定义商品内容编辑 ───
const customContentForm = ref({
goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined,
minWeightG: '', productionProcess: '', materialDescription: '',
blankDesignUrl: '', detailsPageVideoUrl: '', textureName: '', reminder: '',
productPerformance: '', applicableScenarios: '', washingInstructions: '', specialDescription: '',
designExplanation: '', designArea: '', pictureRequest: '',
sizeChartJson: '{\n "columns": [],\n "rows": []\n}',
packageSpecsJson: '{\n "rows": []\n}',
optionsJson: '{}',
mediaJson: '{}',
variants: [] as Array<{
sku: string; sizeId: string; sizeName: string; colorId: string; colorName: string; colorHex: string; imageUrl: string;
price: string; originalPrice: string; weightG: string; boxLengthCm: string;
boxWidthCm: string; boxHeightCm: string; designDataJson: string; enabled: boolean
}>,
})
function fillCustomContent(g: GoodDetail) {
const detail = g.originDetail ?? {}
customContentForm.value = {
goodPrice: g.originGood?.goodPrice ?? '',
productCode: String(detail.productCode ?? ''),
englishName: String(detail.englishName ?? ''),
productionCycleHours: detail.productionCycleHours == null ? undefined : Number(detail.productionCycleHours),
minWeightG: String(detail.minWeightG ?? ''),
productionProcess: String(detail.productionProcess ?? ''),
materialDescription: String(detail.materialDescription ?? ''),
blankDesignUrl: String(detail.blankDesignUrl ?? ''),
detailsPageVideoUrl: String(detail.detailsPageVideoUrl ?? ''),
textureName: String(detail.textureName ?? ''),
reminder: String(detail.reminder ?? ''),
productPerformance: String(detail.productPerformance ?? ''),
applicableScenarios: String(detail.applicableScenarios ?? ''),
washingInstructions: String(detail.washingInstructions ?? ''),
specialDescription: String(detail.specialDescription ?? ''),
designExplanation: String(detail.designExplanation ?? ''),
designArea: String(detail.designArea ?? ''),
pictureRequest: String(detail.pictureRequest ?? ''),
sizeChartJson: JSON.stringify(detail.sizeChart ?? { columns: [], rows: [] }, null, 2),
packageSpecsJson: JSON.stringify(detail.packageSpecs ?? { rows: [] }, null, 2),
optionsJson: JSON.stringify(detail.options ?? {}, null, 2),
mediaJson: JSON.stringify(detail.media ?? {}, null, 2),
variants: g.variants.map((variant) => ({
sku: variant.sku,
sizeId: String(variant.sizeId ?? ''),
sizeName: String(variant.sizeName ?? ''),
colorId: String(variant.colorId ?? ''),
colorName: String(variant.colorName ?? ''),
colorHex: String(variant.colorHex ?? ''),
imageUrl: String(variant.imageUrl ?? ''),
price: String(variant.price ?? ''),
originalPrice: String(variant.originalPrice ?? ''),
weightG: String(variant.weightG ?? ''),
boxLengthCm: String(variant.boxLengthCm ?? ''),
boxWidthCm: String(variant.boxWidthCm ?? ''),
boxHeightCm: String(variant.boxHeightCm ?? ''),
designDataJson: JSON.stringify(variant.designData ?? {}, null, 2),
enabled: variant.enabled,
})),
}
}
function addCustomVariant() {
customContentForm.value.variants.push({
sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', colorHex: '', imageUrl: '', price: '',
originalPrice: '', weightG: '', boxLengthCm: '', boxWidthCm: '', boxHeightCm: '', designDataJson: '{}', enabled: true,
})
}
// ─── 关联原产品(族成员,可展开查看详情 / 配标签 / 改价格) ───
interface FamilyMemberRow {
id: string
goodName: string
goodImage: string | null
source: string
delisted: boolean
sdsGoodId: string
goodPrice: string | null
variantCount: number
logisticsLabel: string | null
craftLabel: string | null
warehouseLabel: string | null
tagsManual: boolean
tags: Array<{ id: string; tagName: string; tagColor: string | null; manual: boolean }>
}
interface MemberPriceCell {
sizeId: string
colorId: string
sizeName: string | null
colorName: string | null
printCount: string
craft: string
logistics: string
price: string
manual: boolean
editPrice: string
}
const editFamilyId = ref<string>('')
const editFamilyCode = ref<string | null>(null)
const editFamilyMembers = ref<FamilyMemberRow[]>([])
const familyMemberCount = ref<number | null>(null)
const editFamilyLoaded = ref(false)
const editFamilyAddKw = ref('')
const editFamilyCandidates = ref<Array<{ id: string; goodName: string }>>([])
const expandedMemberKeys = ref<Set<string>>(new Set())
const memberTagEdits = ref<Record<string, string[]>>({})
const memberTagSaving = ref<string>('')
/** 成员展开面板的 尺码×颜色→价格 编辑态(key = og id */
const memberPrices = ref<Record<string, MemberPriceCell[]>>({})
const memberPriceSaving = ref<string>('')
/** 族原始详情(含 priceMatrix */
const familyDetailRaw = ref<any>(null)
// ─── 族级并集视图:尺码表/包装规格(族物化并集)+ SKU(价格矩阵 = 各链接动态合并) ───
// 尺码行把 measurements 数组摊平成 { 列key: cm }(与成员级渲染一致,否则格子全空)
const familySizeRows = computed(() =>
((familyDetailRaw.value?.sizeChart?.rows ?? []) as any[]).map((row) => ({
...row,
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
out[item.key] = item.cm ?? '-'
return out
}, {}),
})),
)
const familySizeColumns = computed(() => familyDetailRaw.value?.sizeChart?.columns ?? [])
const familyPackageRows = computed(() => familyDetailRaw.value?.packageSpecs?.rows ?? [])
const familySkuRows = computed(() => {
const rows = (familyDetailRaw.value?.priceMatrix?.rows ?? []) as any[]
return rows.map((r) => ({ ...r, sourceCount: Array.isArray(r.sources) ? r.sources.length : 0 }))
})
function toggleMemberExpand(key: string) {
const next = new Set(expandedMemberKeys.value)
if (next.has(key)) next.delete(key)
else {
next.add(key)
loadMemberPrices(key)
ensureMemberDetail(key)
}
expandedMemberKeys.value = next
}
// ─── 成员详情(懒加载:GET /origin-goods/:id ───
const memberDetailCache = ref<Record<string, any>>({})
function memberDetail(id: string) {
return memberDetailCache.value[id]
}
async function ensureMemberDetail(ogId: string) {
const row = editFamilyMembers.value.find((m) => m.id === ogId)
if (!row || row.source !== 'SDS' || memberDetailCache.value[ogId] !== undefined) return
memberDetailCache.value[ogId] = { loading: true }
try {
memberDetailCache.value[ogId] = await originGoodsApi.getOriginGood(ogId)
} catch {
memberDetailCache.value[ogId] = null
}
}
function memberSizeColumns(id: string) {
return memberDetail(id)?.detail?.sizeChart?.columns ?? []
}
function memberSizeRows(id: string) {
const rows = memberDetail(id)?.detail?.sizeChart?.rows ?? []
return rows.map((row: any) => ({
...row,
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
out[item.key] = item.cm ?? '-'
return out
}, {}),
}))
}
function memberPackageRows(id: string) {
return memberDetail(id)?.detail?.packageSpecs?.rows ?? []
}
function memberVariants(id: string) {
return memberDetail(id)?.variants ?? []
}
// 矩阵三个归因维度的合法取值(与 api 端 auto-tag-rules 一致)
const PRINT_COUNT_VALUES = ['单面印花', '双面印花']
const CRAFT_VALUES = ['烫画', '直喷', '不打印']
const LOGISTICS_VALUES = ['包邮', '不包邮']
/** 成员的矩阵归因维度:优先取有效标签(人工接管后仍准确),回退按名称解析 */
function memberDims(id: string) {
const row = editFamilyMembers.value.find((m) => m.id === id)
const fallback = linkDims(row?.goodName ?? null)
const tagNames = ((row as any)?.originGoodTags ?? [])
.map((r: any) => r.tag?.tagName)
.filter(Boolean) as string[]
const crafts = CRAFT_VALUES.filter((v) => tagNames.includes(v))
return {
logistics: LOGISTICS_VALUES.find((v) => tagNames.includes(v)) ?? fallback.logistics,
crafts: crafts.length ? crafts : fallback.crafts,
printCount: PRINT_COUNT_VALUES.find((v) => tagNames.includes(v)) ?? fallback.printCount,
}
}
async function handleSyncMemberDetail(row: { id: string; sdsGoodId: string }) {
if (!row.sdsGoodId || detailSyncing.value) return
detailSyncing.value = row.id
try {
const result = await syncApi.syncOneProductDetail(row.sdsGoodId)
memberDetailCache.value[row.id] = await originGoodsApi.getOriginGood(row.id)
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
emit('changed')
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
} finally { detailSyncing.value = '' }
}
/** 该链接在族价格矩阵中的格子(印花数量/工艺/物流 三维即该链接的归因标签) */
function loadMemberPrices(ogId: string) {
const dims = memberDims(ogId)
if (!dims.printCount || !dims.crafts.length || !dims.logistics) {
memberPrices.value[ogId] = []
return
}
const matrixRows = (familyDetailRaw.value?.priceMatrix?.rows ?? []) as any[]
memberPrices.value[ogId] = matrixRows
.filter((r: any) =>
r.printCount === dims.printCount &&
dims.crafts.includes(r.craft) &&
r.logistics === dims.logistics)
.map((r: any) => ({
sizeId: String(r.sizeId),
colorId: String(r.colorId),
sizeName: r.sizeName ?? null,
colorName: r.colorName ?? null,
printCount: r.printCount,
craft: r.craft,
logistics: r.logistics,
price: String(r.price ?? ''),
manual: Boolean(r.manual),
editPrice: String(r.price ?? ''),
}))
}
async function saveMemberPrices(row: { id: string }) {
const cells = (memberPrices.value[row.id] ?? []).filter((c) => c.editPrice !== '' && c.editPrice !== c.price)
if (!cells.length) { ElMessage.info('价格没有修改'); return }
memberPriceSaving.value = row.id
try {
await productFamiliesApi.putOverrides(editFamilyId.value, cells.map((c) => ({
sizeId: c.sizeId, colorId: c.colorId, printCount: c.printCount, craft: c.craft, logistics: c.logistics, price: c.editPrice,
})))
ElMessage.success('价格已保存')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally { memberPriceSaving.value = '' }
}
function initEditFamily(family: { familyId?: string; familyCode?: string | null } | null) {
editFamilyId.value = family?.familyId ?? ''
editFamilyCode.value = family?.familyCode ?? null
editFamilyMembers.value = []
familyMemberCount.value = null
editFamilyLoaded.value = false
editFamilyAddKw.value = ''
editFamilyCandidates.value = []
expandedMemberKeys.value = new Set()
memberTagEdits.value = {}
memberPrices.value = {}
familyDetailRaw.value = null
if (editFamilyId.value) loadEditFamily()
}
async function loadEditFamily() {
if (!editFamilyId.value) return
try {
const f = await productFamiliesApi.detail(editFamilyId.value) as any
familyDetailRaw.value = f
editFamilyCode.value = f.familyCode
familyMemberCount.value = f._count.originGoods
const primaryOgId = String(editGood.value?.originGoodId ?? '')
const members: FamilyMemberRow[] = (f.originGoods ?? []).map((m: any) => ({
id: String(m.id),
goodName: m.goodName ?? '',
goodImage: m.goodImage ?? null,
source: m.source,
delisted: Boolean(m.delisted),
sdsGoodId: m.sdsGoodId ?? '',
goodPrice: m.goodPrice ?? null,
variantCount: m._count?.variants ?? 0,
logisticsLabel: m.logisticsLabel ?? null,
craftLabel: m.craftLabel ?? null,
warehouseLabel: m.warehouseLabel ?? null,
tagsManual: Boolean(m.tagsManual),
tags: (m.originGoodTags ?? []).map((t: any) => ({
id: String(t.tag.id),
tagName: t.tag.tagName,
tagColor: t.tag.tagColor ?? null,
manual: Boolean(t.manual),
})),
}))
// 当前商品的链接排最前,其余按 id 稳定排序(界面上不区分主次)
members.sort((a, b) => {
const aP = a.id === primaryOgId ? 0 : 1
const bP = b.id === primaryOgId ? 0 : 1
return aP - bP || a.id.localeCompare(b.id)
})
editFamilyMembers.value = members
const edits: Record<string, string[]> = {}
for (const m of members) edits[m.id] = m.tags.map((t) => t.id)
memberTagEdits.value = edits
for (const key of expandedMemberKeys.value) loadMemberPrices(key)
} finally {
editFamilyLoaded.value = true
}
}
async function saveMemberTags(row: { id: string }) {
memberTagSaving.value = row.id
try {
await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
ElMessage.success('标签已保存')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally { memberTagSaving.value = '' }
}
async function resetMemberTags(row: { id: string }) {
memberTagSaving.value = row.id
try {
await originGoodsApi.resetTags(row.id)
ElMessage.success('已恢复按链接名称自动解析')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '恢复失败')
} finally { memberTagSaving.value = '' }
}
async function searchFamilyCandidates(q: string) {
if (!q) { editFamilyCandidates.value = []; return }
try {
const res = await originGoodsApi.getOriginGoodsList({ keyword: q, page: 1, pageSize: 20 })
editFamilyCandidates.value = res.items.map((o: any) => ({ id: String(o.id), goodName: o.goodName ?? o.sdsGoodId }))
} catch { editFamilyCandidates.value = [] }
}
async function addFamilyMember(c: { id: string; goodName: string }) {
if (!editFamilyId.value) { ElMessage.warning('该商品尚未成族'); return }
try {
await productFamiliesApi.updateMembers(editFamilyId.value, { addOriginGoodIds: [c.id] })
ElMessage.success('已加入族')
editFamilyAddKw.value = ''
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '加入失败')
}
}
async function removeFamilyMember(m: { id: string }) {
if (!editFamilyId.value) return
try {
await ElMessageBox.confirm('确定把该链接移出族吗?', '确认', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
})
} catch { return }
try {
await productFamiliesApi.updateMembers(editFamilyId.value, { removeOriginGoodIds: [m.id] })
ElMessage.success('已移除出族')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '移除失败')
}
}
// ─── 打开 / 保存 / 删除 / 同步 ───
watch(() => props.visible, (v) => {
if (v && props.goodId) initLoad()
})
async function initLoad() {
editGood.value = null
editDetailLoading.value = true
try {
const detail = await goodsApi.getGoodById(props.goodId)
applyGood(detail)
} catch {
ElMessage.warning('商品详情加载失败')
} finally {
editDetailLoading.value = false
}
}
function applyGood(g: GoodDetail) {
editGood.value = g
// 名称展示解析为「品名 型号」,保存后即采用解析名
editForm.value = {
id: g.id,
goodName: cleanLinkName(g.goodName) || g.goodName,
goodImage: g.goodImage || g.originGood?.goodImage || '',
countryId: g.countryId,
cascaderCategory: findCategoryPath(props.categories, g.categoryId),
categoryId: g.categoryId,
positionId: g.positionId || '',
}
initEditFamily((g as any).originGood?.family ?? null)
if (g.originGood?.isCustom) fillCustomContent(g)
}
async function quickCreateCountry() {
try {
const { value } = await ElMessageBox.prompt('请输入国家名称', '新增国家', {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
})
if (!value.trim()) return
const created = await countriesApi.createCountry({ countryName: value.trim() } as any)
emit('dictionaries-changed')
if (created?.id) editForm.value.countryId = created.id
ElMessage.success('已创建并选中')
} catch {}
}
function onEditCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
async function handleEditSubmit() {
let customPayload: any = null
if (editIsCustom.value) {
let sizeChart: Record<string, unknown>
let packageSpecs: Record<string, unknown>
let options: Record<string, unknown>
let media: Record<string, unknown>
try {
sizeChart = JSON.parse(customContentForm.value.sizeChartJson)
packageSpecs = JSON.parse(customContentForm.value.packageSpecsJson)
options = JSON.parse(customContentForm.value.optionsJson)
media = JSON.parse(customContentForm.value.mediaJson)
for (const variant of customContentForm.value.variants) JSON.parse(variant.designDataJson)
} catch {
ElMessage.error('尺码表、包装规格、选项、媒体或 SKU 设计数据不是有效 JSON')
return
}
if (customContentForm.value.variants.some((variant) => !variant.sku.trim())) {
ElMessage.error('SKU 不能为空')
return
}
customPayload = {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
goodPrice: customContentForm.value.goodPrice || null,
detail: {
productCode: customContentForm.value.productCode || null,
englishName: customContentForm.value.englishName || null,
productionCycleHours: customContentForm.value.productionCycleHours ?? null,
minWeightG: customContentForm.value.minWeightG || null,
productionProcess: customContentForm.value.productionProcess || null,
materialDescription: customContentForm.value.materialDescription || null,
blankDesignUrl: customContentForm.value.blankDesignUrl || null,
detailsPageVideoUrl: customContentForm.value.detailsPageVideoUrl || null,
textureName: customContentForm.value.textureName || null,
reminder: customContentForm.value.reminder || null,
productPerformance: customContentForm.value.productPerformance || null,
applicableScenarios: customContentForm.value.applicableScenarios || null,
washingInstructions: customContentForm.value.washingInstructions || null,
specialDescription: customContentForm.value.specialDescription || null,
designExplanation: customContentForm.value.designExplanation || null,
designArea: customContentForm.value.designArea || null,
pictureRequest: customContentForm.value.pictureRequest || null,
sizeChart,
packageSpecs,
options,
media,
},
variants: customContentForm.value.variants.map((variant: any, index: number) => ({
sku: variant.sku.trim(),
sizeId: variant.sizeId || null,
sizeName: variant.sizeName || null,
colorId: variant.colorId || null,
colorName: variant.colorName || null,
colorHex: variant.colorHex || null,
imageUrl: variant.imageUrl || null,
price: variant.price || null,
originalPrice: variant.originalPrice || null,
weightG: variant.weightG || null,
boxLengthCm: variant.boxLengthCm || null,
boxWidthCm: variant.boxWidthCm || null,
boxHeightCm: variant.boxHeightCm || null,
designData: JSON.parse(variant.designDataJson),
enabled: variant.enabled,
sortOrder: index,
})),
}
}
editLoading.value = true
try {
await goodsApi.updateGood(editForm.value.id, {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
countryId: Number(editForm.value.countryId),
categoryId: Number(editForm.value.categoryId),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
} as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
ElMessage.success('更新成功')
emit('update:visible', false)
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '更新失败')
} finally { editLoading.value = false }
}
async function handleDeleteGood() {
const g = editGood.value
if (!g) return
try {
await ElMessageBox.confirm(`确定删除「${g.goodName}」吗?`, '确认', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
} catch { return }
await goodsApi.deleteGood(g.id)
ElMessage.success('删除成功')
emit('update:visible', false)
emit('deleted')
}
</script>
<template>
<el-dialog :model-value="visible" :title="cleanLinkName(editGood?.goodName) || '编辑商品'" width="900px" destroy-on-close @update:model-value="emit('update:visible', $event)">
<div v-if="!editIsCustom" class="edit-merged-box">
<div class="edit-merged-title">
关联原产品
<el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵。展开成员可查看详情、修正标签、调整价格。">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<span v-if="editFamilyCode" class="edit-family-code"> {{ editFamilyCode }} · {{ familyMemberCount ?? editFamilyMembers.length }} 条链接</span>
</div>
<div class="edit-merged-list">
<template v-for="row in editFamilyMembers" :key="row.id">
<div class="edit-merged-item">
<el-icon
class="member-expand-toggle"
:class="{ 'is-expanded': expandedMemberKeys.has(row.id) }"
@click="toggleMemberExpand(row.id)"
><ArrowDown /></el-icon>
<span class="edit-merged-name" :title="row.goodName">{{ row.goodName }}</span>
<span v-if="row.tags.length || deriveLinkTagNames(row.goodName).length" class="member-chips">
<template v-if="row.tags.length">
<span v-for="c in row.tags" :key="c.id" class="member-chip" :class="{ 'is-manual': c.manual }">{{ c.tagName }}</span>
</template>
<template v-else>
<span v-for="c in deriveLinkTagNames(row.goodName)" :key="c" class="member-chip is-preview">{{ c }}</span>
</template>
</span>
<span v-if="row.tagsManual" class="member-manual-flag" title="人工接管:自动同步不再覆盖该链接的标签">人工</span>
<div class="edit-merged-actions">
<el-button size="small" link type="danger" @click="removeFamilyMember(row)">移除出族</el-button>
</div>
</div>
<div v-if="expandedMemberKeys.has(row.id)" class="member-detail-panel">
<div class="member-detail-grid">
<span class="md-label">SDS ID</span><span class="md-val">{{ row.sdsGoodId || '-' }}</span>
<span class="md-label">链接价格</span><span class="md-val">{{ row.goodPrice ? `¥${row.goodPrice}` : '-' }}</span>
<span class="md-label">SKU </span><span class="md-val">{{ row.id === editGood?.originGoodId ? (editGood?.originGood?.variantCount ?? row.variantCount) : row.variantCount }}</span>
<span class="md-label">物流备注</span><span class="md-val">{{ row.logisticsLabel || '-' }}</span>
<span class="md-label">工艺位置</span><span class="md-val">{{ row.craftLabel || '-' }}</span>
<span class="md-label">仓库</span><span class="md-val">{{ row.warehouseLabel || '-' }}</span>
</div>
<div class="member-tags-edit">
<span class="md-label">标签</span>
<el-select
v-model="memberTagEdits[row.id]"
multiple filterable size="small"
placeholder="选择标签"
style="flex:1"
>
<el-option-group v-for="g in tagOptionGroups" :key="g.id" :label="g.label">
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
<el-button size="small" type="primary" :loading="memberTagSaving === row.id" @click="saveMemberTags(row)">保存标签</el-button>
<el-button v-if="row.tagsManual" size="small" :loading="memberTagSaving === row.id" @click="resetMemberTags(row)">恢复自动</el-button>
</div>
<div v-if="row.craftLabel && row.logisticsLabel" class="member-price-block">
<div class="member-price-head">
<el-button size="small" type="primary" :loading="memberPriceSaving === row.id" @click="saveMemberPrices(row)">保存价格</el-button>
</div>
<el-table :data="memberPrices[row.id] ?? []" border size="small" max-height="240">
<el-table-column prop="sizeName" label="尺码" width="100" />
<el-table-column prop="colorName" label="颜色" min-width="120" />
<el-table-column label="价格" width="130">
<template #default="{ row: cell }">
<el-input v-model="cell.editPrice" size="small" @input="cell.editPrice = String(cell.editPrice)" />
</template>
</el-table-column>
</el-table>
</div>
<div v-if="row.source === 'SDS'" class="member-extra">
<div class="member-detail-head">
<span v-if="!memberDetail(row.id) || memberDetail(row.id).loading" class="md-empty">详情加载中</span>
<span v-else-if="!memberDetail(row.id).hasDetail" class="md-empty">尚未同步详情</span>
<el-button
size="small"
:icon="Refresh"
:loading="detailSyncing === row.id"
@click="handleSyncMemberDetail(row)"
>{{ memberDetail(row.id)?.hasDetail ? '重新同步详情' : '同步详情' }}</el-button>
</div>
<el-tabs v-if="memberDetail(row.id) && memberDetail(row.id).hasDetail" class="member-detail-tabs">
<el-tab-pane label="商品详情">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品编码">{{ memberDetail(row.id).detail.productCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="英文名称">{{ memberDetail(row.id).detail.englishName || '-' }}</el-descriptions-item>
<el-descriptions-item label="生产周期">{{ memberDetail(row.id).detail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
<el-descriptions-item label="净重">{{ memberDetail(row.id).detail.minWeightG ?? '-' }} g</el-descriptions-item>
<el-descriptions-item label="生产工艺">{{ memberDetail(row.id).detail.productionProcess || '-' }}</el-descriptions-item>
<el-descriptions-item label="材质">{{ memberDetail(row.id).detail.materialDescription || '-' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane :label="`尺码表 (${memberSizeRows(row.id).length})`">
<el-table :data="memberSizeRows(row.id)" border size="small" max-height="240">
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
<el-table-column
v-for="column in memberSizeColumns(row.id)"
:key="column.key"
:prop="column.key"
:label="`${column.name} (cm)`"
min-width="110"
/>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`包装规格 (${memberPackageRows(row.id).length})`">
<el-table :data="memberPackageRows(row.id)" border size="small" max-height="240">
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
<el-table-column label="包装尺寸 (cm)" min-width="150">
<template #default="{ row: pkg }">
{{ pkg.dimensionsCm ? `${pkg.dimensionsCm.length}×${pkg.dimensionsCm.width}×${pkg.dimensionsCm.height}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="volumeCm3" label="体积 (cm³)" width="110" />
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="140" />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`SKU (${memberVariants(row.id).length})`">
<el-table :data="memberVariants(row.id)" border size="small" max-height="240">
<el-table-column prop="sku" label="SKU" min-width="160" fixed />
<el-table-column label="物流" width="100">
<template #default>{{ memberDims(row.id).logistics || '-' }}</template>
</el-table-column>
<el-table-column label="工艺" width="90">
<template #default>{{ memberDims(row.id).crafts.join(' / ') || '-' }}</template>
</el-table-column>
<el-table-column label="印花数量" width="95">
<template #default>{{ memberDims(row.id).printCount || '-' }}</template>
</el-table-column>
<el-table-column prop="sizeName" label="尺码" width="85" />
<el-table-column prop="colorName" label="颜色" width="100" />
<el-table-column prop="price" label="价格" width="90" />
<el-table-column label="状态" width="80">
<template #default="{ row: variant }">
<el-tag :type="variant.enabled ? 'success' : 'info'" size="small">{{ variant.enabled ? '可用' : '停用' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中</div>
<div v-else-if="!editFamilyId" class="edit-family-loading">暂未成族配置合并时自动成族</div>
</div>
<el-select v-model="editFamilyAddKw" filterable remote :remote-method="searchFamilyCandidates"
placeholder="搜索原产品名称添加到族" clearable style="width:100%">
<el-option v-for="c in editFamilyCandidates" :key="c.id" :label="c.goodName" :value="String(c.id)"
@click="addFamilyMember(c)" />
</el-select>
</div>
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
<el-form-item label="图片">
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
</el-form-item>
<el-form-item label="国家">
<div class="select-inline">
<el-select v-model="editForm.countryId" filterable placeholder="请选择国家">
<el-option v-for="c in countries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
<el-button text :icon="Plus" @click="quickCreateCountry" />
</div>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" />
</el-form-item>
<el-form-item v-if="editIsCustom" label="基础价格">
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
</el-form-item>
</el-form>
<!-- 自定义商品内容编辑SDS 商品的详情在关联原产品成员面板内查看 -->
<el-tabs v-if="editIsCustom" class="detail-tabs">
<el-tab-pane label="商品详情">
<el-form label-width="100px" class="custom-detail-form">
<el-form-item label="商品编码"><el-input v-model="customContentForm.productCode" /></el-form-item>
<el-form-item label="英文名称"><el-input v-model="customContentForm.englishName" /></el-form-item>
<el-form-item label="生产周期"><el-input-number v-model="customContentForm.productionCycleHours" :min="0" /><span style="margin-left:8px">小时</span></el-form-item>
<el-form-item label="净重"><el-input v-model="customContentForm.minWeightG"><template #append>g</template></el-input></el-form-item>
<el-form-item label="生产工艺"><el-input v-model="customContentForm.productionProcess" type="textarea" /></el-form-item>
<el-form-item label="材质"><el-input v-model="customContentForm.materialDescription" type="textarea" /></el-form-item>
<el-form-item label="空白设计图"><el-input v-model="customContentForm.blankDesignUrl" /></el-form-item>
<el-form-item label="详情视频"><el-input v-model="customContentForm.detailsPageVideoUrl" /></el-form-item>
<el-form-item label="面料名称"><el-input v-model="customContentForm.textureName" /></el-form-item>
<el-form-item label="温馨提示"><el-input v-model="customContentForm.reminder" type="textarea" /></el-form-item>
<el-form-item label="产品性能"><el-input v-model="customContentForm.productPerformance" type="textarea" /></el-form-item>
<el-form-item label="适用场景"><el-input v-model="customContentForm.applicableScenarios" type="textarea" /></el-form-item>
<el-form-item label="洗涤说明"><el-input v-model="customContentForm.washingInstructions" type="textarea" /></el-form-item>
<el-form-item label="特殊说明"><el-input v-model="customContentForm.specialDescription" type="textarea" /></el-form-item>
<el-form-item label="设计说明"><el-input v-model="customContentForm.designExplanation" type="textarea" /></el-form-item>
<el-form-item label="设计区域"><el-input v-model="customContentForm.designArea" /></el-form-item>
<el-form-item label="图片要求"><el-input v-model="customContentForm.pictureRequest" type="textarea" /></el-form-item>
<el-form-item label="商品选项 JSON"><el-input v-model="customContentForm.optionsJson" type="textarea" :rows="6" /></el-form-item>
<el-form-item label="媒体数据 JSON"><el-input v-model="customContentForm.mediaJson" type="textarea" :rows="6" /></el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="尺码表">
<el-input v-model="customContentForm.sizeChartJson" type="textarea" :rows="14" placeholder="尺码表 JSON" />
</el-tab-pane>
<el-tab-pane label="包装规格">
<el-input v-model="customContentForm.packageSpecsJson" type="textarea" :rows="14" placeholder="包装规格 JSON" />
</el-tab-pane>
<el-tab-pane :label="`SKU (${customContentForm.variants.length})`">
<div style="display:flex;justify-content:flex-end;margin-bottom:8px"><el-button size="small" :icon="Plus" @click="addCustomVariant">添加 SKU</el-button></div>
<el-table :data="customContentForm.variants" border max-height="320">
<el-table-column label="SKU" min-width="160"><template #default="{ row }"><el-input v-model="row.sku" /></template></el-table-column>
<el-table-column label="尺码 ID" width="120"><template #default="{ row }"><el-input v-model="row.sizeId" /></template></el-table-column>
<el-table-column label="尺码" width="120"><template #default="{ row }"><el-input v-model="row.sizeName" /></template></el-table-column>
<el-table-column label="颜色 ID" width="120"><template #default="{ row }"><el-input v-model="row.colorId" /></template></el-table-column>
<el-table-column label="颜色" width="120"><template #default="{ row }"><el-input v-model="row.colorName" /></template></el-table-column>
<el-table-column label="色值" width="120"><template #default="{ row }"><el-input v-model="row.colorHex" placeholder="#FFFFFF" /></template></el-table-column>
<el-table-column label="图片" min-width="180"><template #default="{ row }"><el-input v-model="row.imageUrl" /></template></el-table-column>
<el-table-column label="价格" width="120"><template #default="{ row }"><el-input v-model="row.price" /></template></el-table-column>
<el-table-column label="原价" width="120"><template #default="{ row }"><el-input v-model="row.originalPrice" /></template></el-table-column>
<el-table-column label="重量(g)" width="120"><template #default="{ row }"><el-input v-model="row.weightG" /></template></el-table-column>
<el-table-column label="包装长(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxLengthCm" /></template></el-table-column>
<el-table-column label="包装宽(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxWidthCm" /></template></el-table-column>
<el-table-column label="包装高(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxHeightCm" /></template></el-table-column>
<el-table-column label="设计数据 JSON" min-width="220"><template #default="{ row }"><el-input v-model="row.designDataJson" type="textarea" :rows="2" /></template></el-table-column>
<el-table-column label="启用" width="80"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
<el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- 族级并集视图弹窗最底部尺码表/包装规格为族并集SKU 为价格矩阵动态合并 -->
<el-tabs v-if="!editIsCustom && editFamilyId && editFamilyLoaded" class="detail-tabs">
<el-tab-pane :label="`尺码表 (${familySizeRows.length})`">
<el-table :data="familySizeRows" border size="small" max-height="260">
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
<el-table-column
v-for="column in familySizeColumns"
:key="column.key"
:prop="column.key"
:label="`${column.name} (cm)`"
min-width="110"
/>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`包装规格 (${familyPackageRows.length})`">
<el-table :data="familyPackageRows" border size="small" max-height="260">
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
<el-table-column label="包装尺寸 (cm)" min-width="150">
<template #default="{ row }">
{{ row.dimensionsCm ? `${row.dimensionsCm.length}×${row.dimensionsCm.width}×${row.dimensionsCm.height}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="volumeCm3" label="体积 (cm³)" width="110" />
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="140" />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`SKU (${familySkuRows.length})`">
<el-table :data="familySkuRows" border size="small" max-height="260">
<el-table-column label="物流" width="110">
<template #default="{ row }">{{ row.logistics || '-' }}</template>
</el-table-column>
<el-table-column label="工艺" width="100">
<template #default="{ row }">{{ row.craft || '-' }}</template>
</el-table-column>
<el-table-column label="印花数量" width="100">
<template #default="{ row }">{{ row.printCount || '-' }}</template>
</el-table-column>
<el-table-column prop="sizeName" label="尺码" width="90" />
<el-table-column prop="colorName" label="颜色" min-width="110" />
<el-table-column label="价格" width="100">
<template #default="{ row }">{{ row.price }}</template>
</el-table-column>
<el-table-column label="链接数" width="80">
<template #default="{ row }">{{ row.sourceCount || 1 }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.manual ? 'warning' : 'success'" size="small">{{ row.manual ? '人工价' : '推导价' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button type="danger" @click="handleDeleteGood">删除</el-button>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="handleEditSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.edit-merged-box { margin-bottom: 4px; }
.edit-merged-title {
display: flex; align-items: center; gap: 4px;
font-weight: 600; font-size: 13px; margin-bottom: 8px;
}
.edit-family-code { margin-left: auto; font-weight: 400; font-size: 12px; color: var(--el-color-primary); }
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.edit-merged-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: #f5f7fa; border-radius: 6px; font-size: 13px;
}
.edit-merged-name {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.edit-merged-actions { flex-shrink: 0; display: flex; align-items: center; }
.edit-merged-actions .el-button + .el-button { margin-left: 4px; }
.member-chips { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: nowrap; overflow: hidden; }
.member-chip {
font-size: 11px; line-height: 16px; padding: 1px 7px; border-radius: 8px;
background: #ecf5ff; color: var(--el-color-primary); white-space: nowrap;
}
.member-chip.is-manual { background: #f0f9eb; color: var(--el-color-success); }
.member-chip.is-preview { background: #f4f4f5; color: #909399; border: 1px dashed #dcdfe6; }
.member-manual-flag {
font-size: 11px; line-height: 16px; padding: 0 6px; border-radius: 4px;
background: #f0f9eb; color: var(--el-color-success); flex-shrink: 0;
}
.member-expand-toggle { cursor: pointer; flex-shrink: 0; color: #909399; transition: transform 0.18s; }
.member-expand-toggle.is-expanded { transform: rotate(180deg); }
.member-detail-panel {
background: #fbfcfe; border: 1px solid #ebeef5; border-radius: 6px;
padding: 10px 12px; margin: -2px 0 2px 30px;
}
.member-detail-grid {
display: grid; grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr);
gap: 4px 10px; font-size: 12px; margin-bottom: 8px;
}
.md-label { color: #909399; font-size: 12px; }
.md-val { color: #606266; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.member-tags-edit { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.member-tags-edit .md-label { flex-shrink: 0; }
.member-price-block { border-top: 1px dashed #e4e7ed; padding-top: 8px; }
.member-price-head { display: flex; align-items: center; justify-content: flex-end; margin-bottom: 6px; }
.member-extra { border-top: 1px dashed #e4e7ed; padding-top: 8px; }
.md-empty {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 12px; color: var(--el-text-color-placeholder); padding: 4px 0;
}
.member-detail-head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin-bottom: 4px;
}
.member-detail-head .md-empty { padding: 0; }
.member-detail-tabs :deep(.el-tabs__header) { margin-bottom: 8px; }
.edit-family-loading { color: var(--el-text-color-placeholder); font-size: 12px; padding: 4px 0; }
.family-detail-tabs { margin-top: 10px; }
.family-detail-tabs :deep(.el-tabs__header) { margin-bottom: 8px; }
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
.select-inline { display: flex; align-items: center; gap: 6px; width: 100%; }
</style>
@@ -0,0 +1,478 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, ArrowDown } from '@element-plus/icons-vue'
import type { Tag, TagGroup } from '@/types'
import { tagsApi } from '@/api/tags'
import { tagGroupsApi } from '@/api/tag-groups'
const props = defineProps<{
tags: Tag[]
tagGroups: TagGroup[]
selected: string[]
}>()
const emit = defineEmits<{
(e: 'update:selected', v: string[]): void
/** 标签/分组变化后由父级刷新字典 */
(e: 'dictionaries-changed'): void
}>()
const selectedTagIds = computed({
get: () => props.selected,
set: (v: string[]) => emit('update:selected', v),
})
interface TreeNode {
id: string
rawId: string | null
type: 'group' | 'tag'
label: string
disabled?: boolean
children?: TreeNode[]
}
const tagPopoverVisible = ref(false)
const hoveredNodeId = ref<string | null>(null)
const MAX_VISIBLE_TAGS = 1
const displayedSelectedTagIds = computed(() =>
selectedTagIds.value.slice(0, MAX_VISIBLE_TAGS),
)
const hiddenSelectedCount = computed(() =>
Math.max(0, selectedTagIds.value.length - MAX_VISIBLE_TAGS),
)
function getTagName(id: string): string {
return props.tags.find((t) => t.id === id)?.tagName ?? id
}
function removeSelectedTag(id: string): void {
selectedTagIds.value = selectedTagIds.value.filter((v) => v !== id)
}
function toggleTagInSelection(tagId: string): void {
if (selectedTagIds.value.includes(tagId)) {
selectedTagIds.value = selectedTagIds.value.filter((v) => v !== tagId)
} else {
selectedTagIds.value = [...selectedTagIds.value, tagId]
}
}
const tagTreeData = computed<TreeNode[]>(() => {
const groupNodes: TreeNode[] = props.tagGroups
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((g) => ({
id: `g-${g.id}`,
rawId: g.id,
type: 'group' as const,
label: g.groupName,
children: props.tags
.filter((t) => t.tagGroupId === g.id)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((t) => ({
id: `t-${t.id}`,
rawId: t.id,
type: 'tag' as const,
label: t.tagName,
})),
}))
const ungrouped: TreeNode[] = props.tags
.filter((t) => !t.tagGroupId)
.sort((a, b) => a.tagName.localeCompare(b.tagName))
.map((t) => ({
id: `t-${t.id}`,
rawId: t.id,
type: 'tag' as const,
label: t.tagName,
}))
if (ungrouped.length > 0) {
groupNodes.push({
id: 'g-ungrouped',
rawId: null,
type: 'group',
label: '未分组',
disabled: true,
children: ungrouped,
})
}
return groupNodes
})
function onTreeNodeClick(data: TreeNode): void {
if (data.type === 'group' && !data.disabled) {
// 点击分组 = 全选/取消该组全部标签
const tagIds = (data.children ?? []).map((c) => c.rawId!).filter(Boolean)
if (tagIds.length === 0) return
const allSelected = tagIds.every((id) => selectedTagIds.value.includes(id))
if (allSelected) {
selectedTagIds.value = selectedTagIds.value.filter((id) => !tagIds.includes(id))
} else {
selectedTagIds.value = [...new Set([...selectedTagIds.value, ...tagIds])]
}
}
}
// ─── 标签编辑小弹窗 ───
const tagEditVisible = ref(false)
const tagEditForm = ref({ id: '', tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff' })
const tagEditLoading = ref(false)
function openTagEdit(t: Tag) {
tagEditForm.value = { id: t.id, tagName: t.tagName, tagColor: t.tagColor || '#ff6800', tagFontColor: t.tagFontColor || '#ffffff' }
tagEditVisible.value = true
}
function openTagEditById(id: string): void {
const t = props.tags.find((tag) => tag.id === id)
if (t) openTagEdit(t)
}
async function handleTagEditSubmit() {
if (!tagEditForm.value.tagName.trim()) { ElMessage.warning('请输入标签名称'); return }
tagEditLoading.value = true
try {
await tagsApi.updateTag(tagEditForm.value.id, { tagName: tagEditForm.value.tagName.trim(), tagColor: tagEditForm.value.tagColor, tagFontColor: tagEditForm.value.tagFontColor } as any)
ElMessage.success('保存成功')
tagEditVisible.value = false
emit('dictionaries-changed')
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
finally { tagEditLoading.value = false }
}
async function handleTagEditDelete() {
try {
await ElMessageBox.confirm(`确定删除标签「${tagEditForm.value.tagName}」吗?`, '确认', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
} catch { return }
try {
await tagsApi.deleteTag(tagEditForm.value.id)
ElMessage.success('删除成功')
tagEditVisible.value = false
emit('dictionaries-changed')
} catch { ElMessage.error('删除失败') }
}
// ─── 分组编辑小弹窗 ───
const groupEditVisible = ref(false)
const groupEditLoading = ref(false)
const groupEditForm = ref<{ id: string; groupName: string }>({ id: '', groupName: '' })
function openGroupEdit(node: TreeNode): void {
// 虚拟「未分组」节点不可编辑
if (node.id === 'g-ungrouped') return
groupEditForm.value = { id: node.rawId!, groupName: node.label }
groupEditVisible.value = true
}
async function handleGroupSave(): Promise<void> {
const name = groupEditForm.value.groupName.trim()
if (!name) { ElMessage.warning('请输入分组名称'); return }
groupEditLoading.value = true
try {
await tagGroupsApi.updateTagGroup(groupEditForm.value.id, { groupName: name })
ElMessage.success('已保存')
groupEditVisible.value = false
emit('dictionaries-changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally {
groupEditLoading.value = false
}
}
async function handleGroupDelete(): Promise<void> {
try {
await ElMessageBox.confirm(
`删除分组「${groupEditForm.value.groupName}」后,组内标签将归为「未分组」。确认删除?`,
'确认删除',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch { return }
groupEditLoading.value = true
try {
await tagGroupsApi.deleteTagGroup(groupEditForm.value.id)
ElMessage.success('已删除')
groupEditVisible.value = false
emit('dictionaries-changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '删除失败')
} finally {
groupEditLoading.value = false
}
}
async function quickCreateTagGroup() {
try {
const { value } = await ElMessageBox.prompt('请输入分组名称', '新建分组', {
confirmButtonText: '新建', cancelButtonText: '取消', inputPlaceholder: '分组名称',
})
if (!value.trim()) return
const maxSort = Math.max(0, ...props.tagGroups.map((g) => g.sortOrder))
await tagGroupsApi.createTagGroup({ groupName: value.trim(), sortOrder: maxSort + 1 } as any)
ElMessage.success('已创建分组')
emit('dictionaries-changed')
} catch {}
}
</script>
<template>
<el-popover
v-model:visible="tagPopoverVisible"
placement="bottom-start"
:width="280"
trigger="click"
transition="el-zoom-in-top"
popper-class="tag-filter-popper"
>
<template #reference>
<div
class="tag-select-trigger"
:class="{ 'is-filled': selectedTagIds.length > 0, 'is-active': tagPopoverVisible }"
>
<template v-if="selectedTagIds.length === 0">
<span class="placeholder">标签筛选</span>
</template>
<template v-else>
<el-tag
v-for="id in displayedSelectedTagIds"
:key="id"
size="small"
closable
type="info"
@close.stop="removeSelectedTag(id)"
>
{{ getTagName(id) }}
</el-tag>
<span v-if="hiddenSelectedCount > 0" class="more-tag">+{{ hiddenSelectedCount }}</span>
</template>
<el-icon class="tag-arrow"><ArrowDown /></el-icon>
</div>
</template>
<div class="tag-tree-panel">
<div class="tag-tree-toolbar">
<el-button text size="small" :icon="Plus" @click="quickCreateTagGroup">新建分组</el-button>
</div>
<el-tree
:data="tagTreeData"
node-key="id"
default-expand-all
:props="{ label: 'label', children: 'children' }"
@node-click="onTreeNodeClick"
>
<template #default="{ data }">
<div
class="tree-row"
:class="{
'is-group': data.type === 'group',
'is-tag': data.type === 'tag',
'is-disabled': data.disabled,
'is-checked': data.type === 'tag' && selectedTagIds.includes(data.rawId),
}"
@mouseenter="hoveredNodeId = data.id"
@mouseleave="hoveredNodeId = null"
>
<template v-if="data.type === 'group'">
<i class="fa-solid fa-folder node-icon" />
<span class="node-label">
{{ data.label }}
<span v-if="data.children && data.children.length" class="node-count">{{ data.children.length }}</span>
</span>
<span v-show="hoveredNodeId === data.id" class="node-actions">
<el-button text size="small" :icon="Edit" title="编辑分组" @click.stop="openGroupEdit(data)" />
</span>
</template>
<template v-else>
<el-checkbox
:model-value="selectedTagIds.includes(data.rawId)"
@change="toggleTagInSelection(data.rawId)"
@click.stop
/>
<span class="node-label">{{ data.label }}</span>
<span v-show="hoveredNodeId === data.id" class="node-actions">
<el-button text size="small" :icon="Edit" title="编辑标签" @click.stop="openTagEditById(data.rawId)" />
</span>
</template>
</div>
</template>
</el-tree>
</div>
</el-popover>
<!-- Tag Edit Modal -->
<el-dialog v-model="tagEditVisible" title="编辑标签" width="420px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="tagEditForm.tagName" placeholder="标签名称" /></el-form-item>
<el-form-item label="背景色">
<div style="display: flex; align-items: center; gap: 8px;">
<el-color-picker v-model="tagEditForm.tagColor" />
<el-input v-model="tagEditForm.tagColor" placeholder="#ff6800" style="width: 120px" />
</div>
</el-form-item>
<el-form-item label="字体色">
<div style="display: flex; align-items: center; gap: 8px;">
<el-color-picker v-model="tagEditForm.tagFontColor" />
<el-input v-model="tagEditForm.tagFontColor" placeholder="#ffffff" style="width: 120px" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" :loading="tagEditLoading" @click="handleTagEditDelete">删除</el-button>
<el-button @click="tagEditVisible = false">取消</el-button>
<el-button type="primary" :loading="tagEditLoading" @click="handleTagEditSubmit">保存</el-button>
</template>
</el-dialog>
<!-- Group Edit Modal -->
<el-dialog
v-model="groupEditVisible"
:title="`编辑分组「${groupEditForm.groupName}」`"
width="420px"
destroy-on-close
>
<el-form label-width="80px">
<el-form-item label="分组名称">
<el-input v-model="groupEditForm.groupName" placeholder="请输入分组名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" :loading="groupEditLoading" @click="handleGroupDelete">删除</el-button>
<el-button @click="groupEditVisible = false">取消</el-button>
<el-button type="primary" :loading="groupEditLoading" @click="handleGroupSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
/* Custom trigger that looks like el-select */
.tag-select-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
width: 180px;
min-height: 24px;
padding: 0 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
font-size: 12px;
color: #606266;
cursor: pointer;
transition: border-color 0.2s;
box-sizing: border-box;
}
.tag-select-trigger:hover {
border-color: #c0c4cc;
}
.tag-select-trigger.is-active {
border-color: var(--brand-color);
}
.tag-select-trigger .placeholder {
color: #a8abb2;
}
.tag-select-trigger .tag-arrow {
margin-left: auto;
font-size: 12px;
color: #a8abb2;
transition: transform 0.3s ease, color 0.2s;
flex-shrink: 0;
}
.tag-select-trigger.is-active .tag-arrow {
transform: rotate(180deg);
color: var(--brand-color);
}
.tag-select-trigger.is-filled {
color: #111;
}
.tag-select-trigger .more-tag {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 6px;
background: #f4f4f5;
border-radius: 3px;
font-size: 11px;
color: #606266;
}
</style>
<style>
/* Popover 挂载在 body 下,需要全局样式 */
.tag-filter-popper {
padding: 0 !important;
}
.tag-filter-popper .tag-tree-panel {
max-height: 380px;
overflow-y: auto;
padding: 4px 0;
}
.tag-tree-toolbar {
padding: 4px 8px 6px;
border-bottom: 1px solid #f0f0f0;
}
.tag-filter-popper .el-tree {
padding: 0 4px;
}
/* Tree row layout */
.tag-filter-popper .tree-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 6px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
position: relative;
}
.tag-filter-popper .tree-row.is-group {
font-weight: 600;
color: #111;
background: #fafafa;
}
.tag-filter-popper .tree-row.is-group:hover {
background: #f0f0f0;
}
.tag-filter-popper .tree-row.is-tag:hover {
background: #f5f7fa;
}
.tag-filter-popper .tree-row.is-tag.is-checked {
background: #fff2e8;
color: #ff6a00;
}
.tag-filter-popper .node-icon {
color: #f59e0b;
font-size: 12px;
width: 14px;
text-align: center;
flex-shrink: 0;
}
.tag-filter-popper .node-label {
flex: 1;
user-select: none;
}
.tag-filter-popper .node-count {
display: inline-block;
margin-left: 4px;
padding: 0 5px;
font-size: 10px;
color: #909399;
background: #e9e9eb;
border-radius: 8px;
font-weight: 400;
}
.tag-filter-popper .node-actions {
display: inline-flex;
gap: 0;
margin-left: auto;
flex-shrink: 0;
}
.tag-filter-popper .node-actions .el-button {
padding: 2px 4px;
}
</style>
@@ -8,7 +8,6 @@ import type {
UpdatePositionRequest, UpdatePositionRequest,
PositionFilter, PositionFilter,
Country, Country,
Category,
CategoryTree, CategoryTree,
} from '@/types' } from '@/types'
import { positionsApi } from '@/api/positions' import { positionsApi } from '@/api/positions'
@@ -41,7 +40,7 @@ async function loadLookups() {
async function fetchList() { async function fetchList() {
loading.value = true loading.value = true
try { try {
const res = await positionsApi.getPositionsList(filter) await positionsApi.getPositionsList(filter)
const data = await positionsApi.getPositionsList(filter) as any const data = await positionsApi.getPositionsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? []) const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr list.value = arr
@@ -88,8 +87,6 @@ const dialogLoading = ref(false)
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({ const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
indexVal: 0, indexVal: 0,
countryId: '',
categoryId: '',
}) })
const dialogRules = { const dialogRules = {
@@ -218,17 +215,17 @@ onMounted(async () => {
<el-table v-loading="loading" :data="list" border stripe> <el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="排序值" prop="indexVal" width="100" sortable /> <el-table-column label="排序值" prop="indexVal" width="100" sortable />
<el-table-column label="国家" min-width="160"> <el-table-column label="国家" min-width="160">
<template #default="{ row }: { row: Position }"> <template #default="{ row }: any">
{{ row.country?.countryName || '-' }} {{ row.country?.countryName || '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="分类" min-width="200"> <el-table-column label="分类" min-width="200">
<template #default="{ row }: { row: Position }"> <template #default="{ row }: any">
{{ getCategoryName(row) }} {{ getCategoryName(row) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Position }"> <template #default="{ row }: any">
<div class="table-actions"> <div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)"> <el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon> <el-icon><Edit /></el-icon>
+26 -5
View File
@@ -28,7 +28,7 @@ async function refreshLogs() {
function syncTypeLabel(type: SyncType): string { function syncTypeLabel(type: SyncType): string {
if (type === 'CATEGORIES') return '分类' if (type === 'CATEGORIES') return '分类'
if (type === 'PRODUCT_DETAILS') return '品详情' if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
return '商品列表' return '商品列表'
} }
@@ -70,7 +70,7 @@ async function doSync(type: SyncType) {
const label = syncTypeLabel(type) const label = syncTypeLabel(type)
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确定立即执行${label}同步吗?${type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`, `确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
'确认', '确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' } { type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
) )
@@ -115,6 +115,27 @@ const stats = computed(() => {
return { total, success, failed, lastLog } return { total, success, failed, lastLog }
}) })
const SYNC_KEY_ZH: Record<string, string> = {
inserted: '新增',
updated: '更新',
total: '共计',
delisted: '下架',
reactivated: '恢复上架',
leafCategories: '叶子分类',
}
/** 后端报文(inserted=0 updated=536 ...)渲染为中文摘要;未知键原样保留 */
function humanizeSyncMessage(message?: string | null): string {
if (!message) return ''
const prefixMatch = message.match(/^([^:]+)[:]\s*/)
const prefix = prefixMatch ? prefixMatch[1] : ''
const body = prefixMatch ? message.slice(prefixMatch[0].length) : message
const pairs = [...body.matchAll(/(\w+)=([^\s=]+)/g)]
if (!pairs.length) return message
const text = pairs.map(([, k, v]) => `${SYNC_KEY_ZH[k] ?? k} ${v}`).join(' · ')
return prefix ? `${prefix}${text}` : text
}
onMounted(() => { onMounted(() => {
refreshLogs() refreshLogs()
timer = setInterval(refreshLogs, 60_000) timer = setInterval(refreshLogs, 60_000)
@@ -136,7 +157,7 @@ onUnmounted(() => {
</div> </div>
<div> <div>
<h2 class="sync-action-title">数据同步</h2> <h2 class="sync-action-title">数据同步</h2>
<p class="sync-action-desc">从上游 SDS 系统拉取最新分类和产品数据并同步到本地数据库</p> <p class="sync-action-desc">分类和产品每小时自动同步全部 SDS 原产品详情每天 03:30 自动同步也可手动执行</p>
</div> </div>
</div> </div>
<div class="sync-action-buttons"> <div class="sync-action-buttons">
@@ -166,7 +187,7 @@ onUnmounted(() => {
:icon="Refresh" :icon="Refresh"
@click="handleSyncProductDetails" @click="handleSyncProductDetails"
> >
同步品详情 同步全部原产品详情
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -229,7 +250,7 @@ onUnmounted(() => {
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }} {{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
</span> </span>
</div> </div>
<p v-if="log.message" class="timeline-message">{{ log.message }}</p> <p v-if="log.message" class="timeline-message" :title="log.message">{{ humanizeSyncMessage(log.message) }}</p>
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span> <span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
</div> </div>
</div> </div>
+6 -7
View File
@@ -26,7 +26,7 @@ const filter = reactive<Required<TagFilter>>({
async function fetchList() { async function fetchList() {
loading.value = true loading.value = true
try { try {
const res = await tagsApi.getTagsList(filter) await tagsApi.getTagsList(filter)
const data = await tagsApi.getTagsList(filter) as any const data = await tagsApi.getTagsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? []) const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr list.value = arr
@@ -62,7 +62,6 @@ const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: numbe
tagColor: '#ff6800', tagColor: '#ff6800',
tagFontColor: '#ffffff', tagFontColor: '#ffffff',
timing: '', timing: '',
tagGroupId: null,
}) })
const dialogRules = { const dialogRules = {
@@ -171,7 +170,7 @@ onMounted(() => {
<el-table v-loading="loading" :data="list" border stripe> <el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="预览" width="120"> <el-table-column label="预览" width="120">
<template #default="{ row }: { row: Tag }"> <template #default="{ row }: any">
<span <span
v-if="row.tagColor" v-if="row.tagColor"
class="tag-preview" class="tag-preview"
@@ -182,7 +181,7 @@ onMounted(() => {
</el-table-column> </el-table-column>
<el-table-column prop="tagName" label="名称" min-width="200" /> <el-table-column prop="tagName" label="名称" min-width="200" />
<el-table-column label="背景色" width="140"> <el-table-column label="背景色" width="140">
<template #default="{ row }: { row: Tag }"> <template #default="{ row }: any">
<div class="color-cell"> <div class="color-cell">
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" /> <span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagColor || '-' }}</span> <span class="color-hex">{{ row.tagColor || '-' }}</span>
@@ -190,7 +189,7 @@ onMounted(() => {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="字体色" width="140"> <el-table-column label="字体色" width="140">
<template #default="{ row }: { row: Tag }"> <template #default="{ row }: any">
<div class="color-cell"> <div class="color-cell">
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" /> <span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagFontColor || '-' }}</span> <span class="color-hex">{{ row.tagFontColor || '-' }}</span>
@@ -198,14 +197,14 @@ onMounted(() => {
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="所属分组" min-width="140"> <el-table-column label="所属分组" min-width="140">
<template #default="{ row }: { row: Tag }"> <template #default="{ row }: any">
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span> <span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
<span v-else style="color: #999;">未分组</span> <span v-else style="color: #999;">未分组</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="timing" label="定时" min-width="120" /> <el-table-column prop="timing" label="定时" min-width="120" />
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Tag }"> <template #default="{ row }: any">
<div class="table-actions"> <div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)"> <el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon> <el-icon><Edit /></el-icon>
+8
View File
@@ -7,6 +7,7 @@ import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: '/v2/admin/',
plugins: [ plugins: [
vue(), vue(),
AutoImport({ AutoImport({
@@ -27,7 +28,14 @@ export default defineConfig({
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: 5173,
// 端口被占时直接报错退出,而不是静默换端口导致浏览器看到旧实例
strictPort: true,
proxy: { proxy: {
// Swagger 挂载在后端 /api/docs(不走去掉 /api 前缀的改写),需放在 '/api' 之前
'/api/docs': {
target: 'http://127.0.0.1:3001',
changeOrigin: true,
},
'/api': { '/api': {
target: 'http://127.0.0.1:3001', target: 'http://127.0.0.1:3001',
changeOrigin: true, changeOrigin: true,
+24
View File
@@ -0,0 +1,24 @@
# Prisma connection string (PostgreSQL)
DATABASE_URL=postgresql://postgres:CHANGE_ME@localhost:5432/inkreach-official-website
# JWT signing secret: generate with `node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"`
# Must be at least 32 characters.
JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET
# Access token lifetime (e.g. 30m, 12h); short-lived, rotated via /auth/refresh
TOKEN_EXPIRES_IN=30m
# Refresh token lifetime (HttpOnly cookie)
REFRESH_TOKEN_EXPIRES_IN=7d
# Comma-separated list of allowed CORS origins (leave empty to disable CORS)
CORS_ORIGINS=http://localhost:5173
# Global rate limit per minute (per IP)
THROTTLE_LIMIT=120
PORT=3001
# Product-family public read path (family block + family variant union in
# GET /public/goods/:goodId). Set to 'false' for emergency rollback to legacy behavior.
PUBLIC_DETAIL_FROM_FAMILY=true
+8 -1
View File
@@ -22,7 +22,10 @@
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts", "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
"import:product-detail": "ts-node prisma/import-product-detail.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",
"recompute:families": "ts-node prisma/recompute-all-families.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^3.0.1", "@nestjs/axios": "^3.0.1",
@@ -34,13 +37,16 @@
"@nestjs/platform-express": "^10.3.0", "@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.0.0", "@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.17", "@nestjs/swagger": "^7.1.17",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.8.0", "@prisma/client": "^5.8.0",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"axios": "^1.6.5", "axios": "^1.6.5",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie-parser": "^1.4.7",
"express": "^4.21.0", "express": "^4.21.0",
"helmet": "^8.3.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -52,6 +58,7 @@
"@nestjs/schematics": "^10.0.3", "@nestjs/schematics": "^10.0.3",
"@nestjs/testing": "^10.3.0", "@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/node": "^20.10.6", "@types/node": "^20.10.6",
@@ -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<string, string> = {
'美国': '美国工厂直发',
'英国': '英国本地直发',
'日本': '日本本地工厂直发',
'墨西哥': '墨西哥工厂本地直发',
'巴西': '巴西本地工厂直发',
'中东': '中东本地工厂直发', // 同时更名沙特
'波兰': '欧洲波兰工厂直发',
'西班牙': '欧洲西班牙工厂直发',
'德国': '欧洲德国工厂本地直发',
'意大利': '欧洲意大利工厂直发',
'加拿大': '加拿大本地工厂直发',
'澳大利亚': '澳大利亚本地工厂直发',
'韩国': '韩国本地直发',
'中国(国内工厂)': '国内工厂',
};
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());
@@ -0,0 +1,35 @@
/**
* 整理脚本(幂等 / 显式人工触发)——解析去运行时化后的唯一解析入口:
* 1. 回填结构化解析列(只补 NULL,不覆盖存量);
* 2. 派生链接标签(未人工接管的 SDS 链接按名称刷新,人工接管不动)+ 商品镜像;
* 3. 自动建族(auto-group);
* 4. 全量族重算(并集尺码表/包装 + 五维价格矩阵)。
*
* 运行:pnpm --filter @inkreach/api organize
* 后台等价入口:POST /product-families/organize(「整理」按钮)。
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
import { ProductFamiliesService } from '../src/product-families/product-families.service';
import { OrganizeService } from '../src/product-families/organize.service';
async function main() {
const prisma = new PrismaService();
await prisma.onModuleInit();
const recompute = new FamilyRecomputeService(prisma);
const families = new ProductFamiliesService(prisma, recompute);
const organize = new OrganizeService(prisma, recompute, families);
const result = await organize.organize();
console.log(
`[organize] labels parsed=${result.labelsParsed} (unparsable=${result.labelsUnparsable}) | ` +
`tags links=${result.linksUpdated} goods=${result.goodsUpdated} | ` +
`families created=${result.familiesCreated} recomputed=${result.familiesRecomputed}`,
);
await prisma.onModuleDestroy();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "origin_goods" ADD COLUMN "delisted" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,22 @@
-- Sync database with schema.prisma (missing columns/table from earlier iterations)
-- AlterTable
ALTER TABLE "goods" ADD COLUMN "good_image" TEXT;
-- AlterTable
ALTER TABLE "tags" ADD COLUMN "tag_font_color" TEXT;
-- CreateTable
CREATE TABLE "good_tags" (
"good_id" BIGINT NOT NULL,
"tag_id" BIGINT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "good_tags_pkey" PRIMARY KEY ("good_id","tag_id")
);
-- CreateIndex
CREATE INDEX "good_tags_tag_id_idx" ON "good_tags"("tag_id");
-- AddForeignKey
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1,6 @@
CREATE TYPE "OriginGoodSource" AS ENUM ('SDS', 'CUSTOM');
ALTER TABLE "origin_goods"
ADD COLUMN "source" "OriginGoodSource" NOT NULL DEFAULT 'SDS';
CREATE INDEX "origin_goods_source_idx" ON "origin_goods"("source");
@@ -0,0 +1,8 @@
-- Create enum for user roles
CREATE TYPE "Role" AS ENUM ('ADMIN');
-- Add role column, existing users become ADMIN
ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'ADMIN';
-- Token version for JWT revocation (logout bumps it)
ALTER TABLE "users" ADD COLUMN "token_version" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "good_origin_goods" (
"good_id" BIGINT NOT NULL,
"origin_good_id" BIGINT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "good_origin_goods_pkey" PRIMARY KEY ("good_id","origin_good_id")
);
-- CreateIndex
CREATE INDEX "good_origin_goods_origin_good_id_idx" ON "good_origin_goods"("origin_good_id");
-- AddForeignKey
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1,79 @@
-- AlterTable
ALTER TABLE "origin_goods" ADD COLUMN "craft_label" TEXT,
ADD COLUMN "family_id" BIGINT,
ADD COLUMN "logistics_label" TEXT,
ADD COLUMN "sku_code" TEXT,
ADD COLUMN "warehouse_label" TEXT;
-- CreateTable
CREATE TABLE "product_families" (
"family_id" BIGSERIAL NOT NULL,
"family_code" TEXT,
"family_name" TEXT NOT NULL,
"family_image" TEXT,
"country_id" BIGINT,
"category_id" BIGINT,
"primary_origin_good_id" BIGINT,
"detail" JSONB,
"size_chart" JSONB,
"package_specs" JSONB,
"price_matrix" JSONB,
"auto_managed" BOOLEAN NOT NULL DEFAULT true,
"stale" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "product_families_pkey" PRIMARY KEY ("family_id")
);
-- CreateTable
CREATE TABLE "family_price_overrides" (
"family_price_override_id" BIGSERIAL NOT NULL,
"family_id" BIGINT NOT NULL,
"size_id" TEXT NOT NULL,
"color_id" TEXT NOT NULL,
"craft" TEXT NOT NULL,
"logistics" TEXT NOT NULL,
"price" DECIMAL(12,2) NOT NULL,
"note" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "family_price_overrides_pkey" PRIMARY KEY ("family_price_override_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "product_families_family_code_key" ON "product_families"("family_code");
-- CreateIndex
CREATE INDEX "product_families_country_id_idx" ON "product_families"("country_id");
-- CreateIndex
CREATE INDEX "product_families_category_id_idx" ON "product_families"("category_id");
-- CreateIndex
CREATE UNIQUE INDEX "family_price_overrides_family_id_size_id_color_id_craft_log_key" ON "family_price_overrides"("family_id", "size_id", "color_id", "craft", "logistics");
-- CreateIndex
CREATE INDEX "origin_goods_family_id_idx" ON "origin_goods"("family_id");
-- CreateIndex
CREATE INDEX "origin_goods_sku_code_idx" ON "origin_goods"("sku_code");
-- CreateIndex
CREATE INDEX "origin_goods_logistics_label_idx" ON "origin_goods"("logistics_label");
-- CreateIndex
CREATE INDEX "origin_goods_craft_label_idx" ON "origin_goods"("craft_label");
-- AddForeignKey
ALTER TABLE "origin_goods" ADD CONSTRAINT "origin_goods_family_id_fkey" FOREIGN KEY ("family_id") REFERENCES "product_families"("family_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "product_families" ADD CONSTRAINT "product_families_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "product_families" ADD CONSTRAINT "product_families_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "family_price_overrides" ADD CONSTRAINT "family_price_overrides_family_id_fkey" FOREIGN KEY ("family_id") REFERENCES "product_families"("family_id") ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "goods" ADD COLUMN "family_id" BIGINT;
-- CreateIndex
CREATE INDEX "goods_family_id_idx" ON "goods"("family_id");
-- AddForeignKey
ALTER TABLE "goods" ADD CONSTRAINT "goods_family_id_fkey" FOREIGN KEY ("family_id") REFERENCES "product_families"("family_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- Backfill: Good 的族 = 其主链接所属族(派生数据,后续由服务层联动维护)
UPDATE "goods" SET "family_id" = (
SELECT "o"."family_id" FROM "origin_goods" "o"
WHERE "o"."origin_good_id" = "goods"."origin_good_id"
);
@@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "origin_goods" ADD COLUMN "tags_manual" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "origin_good_tags" (
"id" BIGSERIAL NOT NULL,
"origin_good_id" BIGINT NOT NULL,
"tag_id" BIGINT NOT NULL,
"manual" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "origin_good_tags_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "origin_good_tags_tag_id_idx" ON "origin_good_tags"("tag_id");
-- CreateIndex
CREATE UNIQUE INDEX "origin_good_tags_origin_good_id_tag_id_key" ON "origin_good_tags"("origin_good_id", "tag_id");
-- AddForeignKey
ALTER TABLE "origin_good_tags" ADD CONSTRAINT "origin_good_tags_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "origin_good_tags" ADD CONSTRAINT "origin_good_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1,6 @@
-- 价格矩阵五维化:覆盖表补 印花数量 维度(表当前 0 行,无需数据回填)
ALTER TABLE "family_price_overrides" ADD COLUMN "print_count" TEXT NOT NULL DEFAULT '单面印花';
DROP INDEX "family_price_overrides_family_id_size_id_color_id_craft_log_key";
CREATE UNIQUE INDEX "family_price_overrides_family_id_size_id_color_id_print_count_craft_log_key"
ON "family_price_overrides"("family_id", "size_id", "color_id", "print_count", "craft", "logistics");
@@ -0,0 +1,2 @@
-- 国家自定义排序:默认 0 保持既有 id 序,后台可拖拽调整
ALTER TABLE "countries" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
+40
View File
@@ -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);
});
+122
View File
@@ -6,6 +6,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
} }
datasource db { datasource db {
@@ -14,6 +15,11 @@ datasource db {
} }
// ---------- Origin Goods ---------- // ---------- Origin Goods ----------
enum OriginGoodSource {
SDS
CUSTOM
}
model OriginGood { model OriginGood {
id BigInt @id @default(autoincrement()) @map("origin_good_id") id BigInt @id @default(autoincrement()) @map("origin_good_id")
sdsGoodId String @unique @map("sds_good_id") sdsGoodId String @unique @map("sds_good_id")
@@ -23,17 +29,50 @@ model OriginGood {
goodImage String? @map("good_image") goodImage String? @map("good_image")
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2) goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
delisted Boolean @default(false) delisted Boolean @default(false)
source OriginGoodSource @default(SDS)
familyId BigInt? @map("family_id")
skuCode String? @map("sku_code")
logisticsLabel String? @map("logistics_label")
craftLabel String? @map("craft_label")
warehouseLabel String? @map("warehouse_label")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
family ProductFamily? @relation(fields: [familyId], references: [id], onDelete: SetNull, onUpdate: NoAction)
goods Good[] goods Good[]
detail OriginGoodDetail? detail OriginGoodDetail?
variants OriginGoodVariant[] variants OriginGoodVariant[]
mergedIntoGoods GoodOriginGood[]
// 链接级标签:自动派生(manual=false)或人工接管(tagsManual=true 后全部 manual
tagsManual Boolean @default(false) @map("tags_manual")
originGoodTags OriginGoodTag[]
@@index([sdsCategoryId]) @@index([sdsCategoryId])
@@index([source])
@@index([familyId])
@@index([skuCode])
@@index([logisticsLabel])
@@index([craftLabel])
@@map("origin_goods") @@map("origin_goods")
} }
// ---------- OriginGood-Tag Junction(链接级标签:自动派生 + 人工修正) ----------
model OriginGoodTag {
id BigInt @id @default(autoincrement())
originGoodId BigInt @map("origin_good_id")
tagId BigInt @map("tag_id")
// true = 人工配置(自动同步永不覆盖);false = 按链接名称自动派生
manual Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([originGoodId, tagId])
@@index([tagId])
@@map("origin_good_tags")
}
// ---------- Origin Good Details (cached from SDS /products/{id}) ---------- // ---------- Origin Good Details (cached from SDS /products/{id}) ----------
model OriginGoodDetail { model OriginGoodDetail {
originGoodId BigInt @id @map("origin_good_id") originGoodId BigInt @id @map("origin_good_id")
@@ -104,11 +143,13 @@ model Country {
id BigInt @id @default(autoincrement()) @map("country_id") id BigInt @id @default(autoincrement()) @map("country_id")
countryName String @unique @map("country_name") countryName String @unique @map("country_name")
countryIcon String? @map("country_icon") countryIcon String? @map("country_icon")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
goods Good[] goods Good[]
positions Position[] positions Position[]
families ProductFamily[]
@@map("countries") @@map("countries")
} }
@@ -120,6 +161,7 @@ model Category {
categoryName String @map("category_name") categoryName String @map("category_name")
categoryIcon String? @map("category_icon") categoryIcon String? @map("category_icon")
sdsCategoryId String? @unique @map("sds_category_id") sdsCategoryId String? @unique @map("sds_category_id")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@ -127,6 +169,7 @@ model Category {
children Category[] @relation("CategoryToCategory") children Category[] @relation("CategoryToCategory")
goods Good[] goods Good[]
positions Position[] positions Position[]
families ProductFamily[]
@@index([parentCategoryId]) @@index([parentCategoryId])
@@map("categories") @@map("categories")
@@ -161,6 +204,7 @@ model Tag {
goods Good[] goods Good[]
goodTags GoodTag[] goodTags GoodTag[]
originGoodTags OriginGoodTag[]
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction) tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@index([tagGroupId]) @@index([tagGroupId])
@@ -191,6 +235,7 @@ model Position {
model Good { model Good {
id BigInt @id @default(autoincrement()) @map("good_id") id BigInt @id @default(autoincrement()) @map("good_id")
originGoodId BigInt @map("origin_good_id") originGoodId BigInt @map("origin_good_id")
familyId BigInt? @map("family_id")
countryId BigInt @map("country_id") countryId BigInt @map("country_id")
categoryId BigInt @map("category_id") categoryId BigInt @map("category_id")
tagId BigInt? @map("tag_id") tagId BigInt? @map("tag_id")
@@ -202,13 +247,16 @@ model Good {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction) originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction)
family ProductFamily? @relation(fields: [familyId], references: [id], onDelete: SetNull, onUpdate: NoAction)
country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction) country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction) category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction) tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction) position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
goodTags GoodTag[] goodTags GoodTag[]
mergedOriginGoods GoodOriginGood[]
@@index([originGoodId]) @@index([originGoodId])
@@index([familyId])
@@index([countryId]) @@index([countryId])
@@index([categoryId]) @@index([categoryId])
@@index([tagId]) @@index([tagId])
@@ -233,11 +281,85 @@ model GoodTag {
@@map("good_tags") @@map("good_tags")
} }
// ---------- Good-OriginGood Junction (merged secondary sources, M:N) ----------
// Primary source stays on goods.origin_good_id and is NOT stored here.
model GoodOriginGood {
goodId BigInt @map("good_id")
originGoodId BigInt @map("origin_good_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([goodId, originGoodId])
@@index([originGoodId])
@@map("good_origin_goods")
}
// ---------- Product Families (SPU layer over origin goods) ----------
// 设计文档:docs/superpowers/specs/2026-08-28-product-family-merge-design.md
// primaryOriginGoodId 刻意不建 FK:避免与 origin_goods 相互依赖,主链接被同步删除时由服务层清理。
model ProductFamily {
id BigInt @id @default(autoincrement()) @map("family_id")
familyCode String? @unique @map("family_code")
familyName String @map("family_name")
familyImage String? @map("family_image")
countryId BigInt? @map("country_id")
categoryId BigInt? @map("category_id")
primaryOriginGoodId BigInt? @map("primary_origin_good_id")
detail Json?
sizeChart Json? @map("size_chart")
packageSpecs Json? @map("package_specs")
priceMatrix Json? @map("price_matrix")
autoManaged Boolean @default(true) @map("auto_managed")
stale Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
originGoods OriginGood[]
priceOverrides FamilyPriceOverride[]
goods Good[]
country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction)
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@index([countryId])
@@index([categoryId])
@@map("product_families")
}
// ---------- Family Price Overrides (manual per-cell price) ----------
// 独立于推导矩阵:重算只重建推导部分,本表永不被动覆盖。
model FamilyPriceOverride {
id BigInt @id @default(autoincrement()) @map("family_price_override_id")
familyId BigInt @map("family_id")
sizeId String @map("size_id")
colorId String @map("color_id")
printCount String @default("单面印花") @map("print_count")
craft String
logistics String
price Decimal @db.Decimal(12, 2)
note String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
family ProductFamily @relation(fields: [familyId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([familyId, sizeId, colorId, printCount, craft, logistics])
@@map("family_price_overrides")
}
// ---------- Users (admin authentication) ---------- // ---------- Users (admin authentication) ----------
enum Role {
ADMIN
}
model User { model User {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
username String @unique username String @unique
passwordHash String @map("password_hash") passwordHash String @map("password_hash")
role Role @default(ADMIN)
// Bumped on logout / revocation; JWTs carrying an older version are rejected.
tokenVersion Int @default(0) @map("token_version")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+65
View File
@@ -0,0 +1,65 @@
/**
* Export all application tables from the local (source) database to a JSON
* file, preserving column types for the matching import-data.mjs script.
*
* Usage (from apps/api, against the local DB in .env):
* node scripts/export-data.mjs <output.json>
*/
import { PrismaClient } from '@prisma/client';
import { writeFileSync } from 'node:fs';
const TABLES = [
'users',
'countries',
'categories',
'tag_groups',
'tags',
'positions',
'origin_goods',
'origin_good_variants',
'origin_good_details',
'goods',
'good_tags',
'sync_logs',
];
const prisma = new PrismaClient();
// Serialize values losslessly; import side uses information_schema to restore types.
function serialize(value) {
if (value === null || value === undefined) return null;
if (typeof value === 'bigint') return value.toString();
if (value instanceof Date) return value.toISOString();
if (typeof value === 'object' && Buffer.isBuffer(value)) return value.toString('base64');
if (typeof value === 'object') return JSON.stringify(value); // jsonb
return value;
}
async function main() {
const out = process.argv[2];
if (!out) {
console.error('Usage: node scripts/export-data.mjs <output.json>');
process.exit(1);
}
const dump = { exportedAt: new Date().toISOString(), tables: {} };
for (const table of TABLES) {
const rows = await prisma.$queryRawUnsafe(`SELECT * FROM "${table}"`);
dump.tables[table] = rows.map((row) => {
const o = {};
for (const [k, v] of Object.entries(row)) o[k] = serialize(v);
return o;
});
console.log(`${table}: ${rows.length} rows`);
}
writeFileSync(out, JSON.stringify(dump));
console.log(`Wrote ${out}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+106
View File
@@ -0,0 +1,106 @@
/**
* Import a dump produced by export-data.mjs into the current database.
* Tables are truncated first (order-independent via session_replication_role)
* and columns are cast back to their real types using information_schema.
*
* Usage (inside the api container):
* node scripts/import-data.mjs <dump.json>
*/
import { PrismaClient, Prisma } from '@prisma/client';
import { readFileSync } from 'node:fs';
const prisma = new PrismaClient();
function toLiteral(value, udtName) {
if (value === null) return Prisma.sql`NULL`;
const target = Prisma.raw(`"${udtName}"`);
switch (udtName) {
case 'int2':
case 'int4':
case 'int8':
return Prisma.sql`${BigInt(value)}::${target}`;
case 'float4':
case 'float8':
case 'numeric':
return Prisma.sql`${Number(value)}::${target}`;
case 'bool':
return Prisma.sql`${!!value}::${target}`;
case 'timestamptz':
case 'timestamp':
return Prisma.sql`${new Date(value).toISOString()}::${target}`;
case 'date':
return Prisma.sql`${String(value)}::${target}`;
case 'jsonb':
case 'json':
return Prisma.sql`${typeof value === 'string' ? value : JSON.stringify(value)}::${target}`;
case 'bytea':
return Prisma.sql`${Buffer.from(value, 'base64')}::bytea`;
default:
// text, varchar, enums and anything else: pass as text and cast
return Prisma.sql`${String(value)}::${target}`;
}
}
async function main() {
const file = process.argv[2];
if (!file) {
console.error('Usage: node scripts/import-data.mjs <dump.json>');
process.exit(1);
}
const dump = JSON.parse(readFileSync(file, 'utf8'));
// Suspend FK checks during bulk load (postgres superuser not required for
// session_replication_role in the compose postgres where app user owns db).
await prisma.$executeRawUnsafe(`SET session_replication_role = replica`);
const summary = {};
for (const [table, rows] of Object.entries(dump.tables)) {
if (rows.length === 0) {
summary[table] = 0;
continue;
}
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
const colTypes = {};
const info = await prisma.$queryRawUnsafe(
`SELECT column_name, udt_name FROM information_schema.columns WHERE table_name = '${table}'`,
);
for (const c of info) colTypes[c.column_name] = c.udt_name;
const columns = Object.keys(rows[0]);
const colList = Prisma.raw(columns.map((c) => `"${c}"`).join(', '));
const CHUNK = 200;
for (let i = 0; i < rows.length; i += CHUNK) {
const tuples = rows.slice(i, i + CHUNK).map(
(r) =>
Prisma.sql`(${Prisma.join(
columns.map((c) => toLiteral(r[c], colTypes[c])),
)})`,
);
await prisma.$executeRaw(
Prisma.sql`INSERT INTO ${Prisma.raw(`"${table}"`)} (${colList}) VALUES ${Prisma.join(tuples)}`,
);
}
// Keep sequences ahead of imported serial ids
// Keep serial sequences ahead of imported ids
const idCol = columns.find(
(c) => c === 'id' || c === `${table.replace(/s$/, '')}_id`,
);
if (idCol) {
await prisma.$executeRawUnsafe(
`SELECT setval(pg_get_serial_sequence('"${table}"', '${idCol}'), COALESCE((SELECT MAX("${idCol}") FROM "${table}"), 1))`,
);
}
summary[table] = rows.length;
}
await prisma.$executeRawUnsafe(`SET session_replication_role = DEFAULT`);
console.log('Imported:', JSON.stringify(summary, null, 2));
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+42
View File
@@ -0,0 +1,42 @@
/**
* Set the admin credentials for the production deployment:
* rename/disable any existing admin and create/update user `inkreach`.
*
* Usage (inside the api container): node scripts/set-admin.mjs
* Reads NEW_ADMIN_USER / NEW_ADMIN_PASSWORD from env.
*/
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const username = process.env.NEW_ADMIN_USER;
const password = process.env.NEW_ADMIN_PASSWORD;
if (!username || !password) {
console.error('NEW_ADMIN_USER / NEW_ADMIN_PASSWORD must be set');
process.exit(1);
}
const passwordHash = await bcrypt.hash(password, 10);
await prisma.user.upsert({
where: { username },
create: { username, passwordHash },
update: { passwordHash, tokenVersion: { increment: 1 } },
});
// Remove every other admin so only `inkreach` can sign in.
const others = await prisma.user.deleteMany({
where: { username: { not: username } },
});
const total = await prisma.user.count();
console.log(`Admin '${username}' set. Demoted ${others.count} other user(s). Total users: ${total}.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+25
View File
@@ -1,13 +1,17 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { RolesGuard } from './auth/guards/roles.guard';
import { CountriesModule } from './countries/countries.module'; import { CountriesModule } from './countries/countries.module';
import { CategoriesModule } from './categories/categories.module'; import { CategoriesModule } from './categories/categories.module';
import { TagsModule } from './tags/tags.module'; import { TagsModule } from './tags/tags.module';
import { TagGroupsModule } from './tag-groups/tag-groups.module'; import { TagGroupsModule } from './tag-groups/tag-groups.module';
import { PositionsModule } from './positions/positions.module'; import { PositionsModule } from './positions/positions.module';
import { OriginGoodsModule } from './origin-goods/origin-goods.module'; import { OriginGoodsModule } from './origin-goods/origin-goods.module';
import { ProductFamiliesModule } from './product-families/product-families.module';
import { GoodsModule } from './goods/goods.module'; import { GoodsModule } from './goods/goods.module';
import { SyncModule } from './sync/sync.module'; import { SyncModule } from './sync/sync.module';
import { PublicModule } from './public/public.module'; import { PublicModule } from './public/public.module';
@@ -18,6 +22,14 @@ import { UploadModule } from './upload/upload.module';
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
}), }),
// Global rate limiting: 120 req/min per IP. Stricter limits are set
// per-endpoint with @Throttle (auth, upload).
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: Number(process.env.THROTTLE_LIMIT ?? 120),
},
]),
PrismaModule, PrismaModule,
AuthModule, AuthModule,
CountriesModule, CountriesModule,
@@ -26,10 +38,23 @@ import { UploadModule } from './upload/upload.module';
TagGroupsModule, TagGroupsModule,
PositionsModule, PositionsModule,
OriginGoodsModule, OriginGoodsModule,
ProductFamiliesModule,
GoodsModule, GoodsModule,
SyncModule, SyncModule,
PublicModule, PublicModule,
UploadModule, UploadModule,
], ],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
// Enforces the ADMIN role on every authenticated route unless the
// route widens access with @Roles(...).
provide: APP_GUARD,
useClass: RolesGuard,
},
],
}) })
export class AppModule {} export class AppModule {}
+108 -13
View File
@@ -1,16 +1,28 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { import {
ApiOperation, Body,
ApiResponse, Controller,
ApiTags, Get,
} from '@nestjs/swagger'; HttpCode,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto';
LoginResponseDto, import type { AuthenticatedUser } from './strategies/jwt.strategy';
UserPublicDto,
} from './dto/auth-response.dto'; const ACCESS_TOKEN_COOKIE = 'ir_at';
const REFRESH_TOKEN_COOKIE = 'ir_rt';
const isProd = process.env.NODE_ENV === 'production';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -19,19 +31,102 @@ export class AuthController {
@Post('register') @Post('register')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new admin user' }) @Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'Register the first admin user (bootstrap only)' })
@ApiResponse({ status: 201, type: UserPublicDto }) @ApiResponse({ status: 201, type: UserPublicDto })
@ApiResponse({ status: 409, description: 'Username already exists' }) @ApiResponse({ status: 409, description: 'Username already exists' })
@ApiResponse({ status: 403, description: 'Registration is disabled once a user exists' })
register(@Body() dto: RegisterDto): Promise<UserPublicDto> { register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
return this.authService.register(dto) as unknown as Promise<UserPublicDto>; return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
} }
@Post('login') @Post('login')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login and obtain a JWT' }) @Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'Login and obtain access + refresh tokens' })
@ApiResponse({ status: 200, type: LoginResponseDto }) @ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 401, description: 'Invalid credentials' }) @ApiResponse({ status: 401, description: 'Invalid credentials' })
login(@Body() dto: LoginDto): Promise<LoginResponseDto> { async login(
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>; @Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response,
): Promise<LoginResponseDto> {
const result = await this.authService.login(dto);
// HttpOnly cookies are the primary session channel for the admin SPA
// (XSS cannot read them). The access token is also returned in the
// body for non-browser API clients.
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
});
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
// Only ever sent to /auth/refresh and /auth/logout
path: '/auth',
});
// The refresh token deliberately stays HttpOnly-only.
return {
accessToken: result.accessToken,
user: result.user,
} as unknown as LoginResponseDto;
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@ApiOperation({ summary: 'Rotate the refresh token cookie' })
@ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<LoginResponseDto> {
const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
if (!token) {
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
throw new UnauthorizedException('Missing refresh token');
}
const result = await this.authService.refresh(token);
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/',
});
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
path: '/auth',
});
return {
accessToken: result.accessToken,
user: result.user,
} as unknown as LoginResponseDto;
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Current authenticated user' })
@ApiResponse({ status: 200, type: UserPublicDto })
me(@Req() req: Request & { user: AuthenticatedUser }): Promise<UserPublicDto> {
return this.authService.me(req.user.id) as unknown as Promise<UserPublicDto>;
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Revoke all tokens of the current user' })
async logout(
@Req() req: Request & { user: AuthenticatedUser },
@Res({ passthrough: true }) res: Response,
): Promise<{ success: true }> {
await this.authService.logout(req.user.id);
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
return { success: true };
} }
} }
+6 -1
View File
@@ -17,9 +17,14 @@ import { JwtStrategy } from './strategies/jwt.strategy';
if (!secret) { if (!secret) {
throw new Error('JWT_SECRET must be configured'); throw new Error('JWT_SECRET must be configured');
} }
if (secret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters (use a strong random value)');
}
return { return {
secret, secret,
signOptions: { expiresIn: '7d' }, signOptions: {
expiresIn: config.get<string>('TOKEN_EXPIRES_IN') ?? '7d',
},
}; };
}, },
}), }),
+119 -54
View File
@@ -1,102 +1,167 @@
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule, JwtService } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config'; import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
describe('AuthService', () => { describe('AuthService', () => {
let service: AuthService; let service: AuthService;
let prisma: PrismaService; let jwt: JwtService;
const createdUsernames: string[] = []; let prisma: {
user: {
count: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
const HASH = bcrypt.hashSync('plain-pwd', 10);
const dbUser = {
id: 1n,
username: 'alice',
passwordHash: HASH,
role: 'ADMIN' as const,
tokenVersion: 0,
createdAt: new Date('2026-01-01T00:00:00Z'),
};
beforeAll(async () => { beforeAll(async () => {
prisma = {
user: {
count: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }),
JwtModule.register({ JwtModule.register({
secret: 'test-secret', secret: 'a'.repeat(32),
signOptions: { expiresIn: '1h' }, signOptions: { expiresIn: '1h' },
}), }),
], ],
providers: [AuthService, PrismaService], providers: [AuthService, { provide: PrismaService, useValue: prisma }],
}).compile(); }).compile();
service = moduleRef.get(AuthService); service = moduleRef.get(AuthService);
prisma = moduleRef.get(PrismaService); jwt = moduleRef.get(JwtService);
await prisma.onModuleInit();
}); });
afterAll(async () => { beforeEach(() => {
// Cleanup created test users jest.clearAllMocks();
if (createdUsernames.length) {
await prisma.user.deleteMany({
where: { username: { in: createdUsernames } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
}); });
describe('register', () => { describe('register', () => {
it('creates a new user and stores a hashed password', async () => { it('creates the first user with a hashed password', async () => {
const username = `test_reg_${Date.now()}`; prisma.user.count.mockResolvedValueOnce(0);
createdUsernames.push(username); prisma.user.findUnique.mockResolvedValueOnce(null);
prisma.user.create.mockResolvedValueOnce(dbUser);
const user = await service.register({ username, password: 'plain-pwd' }); const user = await service.register({ username: 'alice', password: 'plain-pwd' });
expect(user.username).toBe(username); expect(user.username).toBe('alice');
expect(user.id).toBeTruthy(); const created = prisma.user.create.mock.calls[0][0].data;
expect(created.passwordHash).not.toBe('plain-pwd');
await expect(bcrypt.compare('plain-pwd', created.passwordHash)).resolves.toBe(true);
});
const stored = await prisma.user.findUnique({ where: { username } }); it('refuses registration once a user exists (bootstrap lock)', async () => {
expect(stored).not.toBeNull(); prisma.user.count.mockResolvedValueOnce(1);
expect(stored?.passwordHash).not.toBe('plain-pwd'); await expect(
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash); service.register({ username: 'mallory', password: 'evil-pwd' }),
expect(matches).toBe(true); ).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.create).not.toHaveBeenCalled();
}); });
it('throws ConflictException for duplicate usernames', async () => { it('throws ConflictException for duplicate usernames', async () => {
const username = `test_dup_${Date.now()}`; prisma.user.count.mockResolvedValueOnce(0);
createdUsernames.push(username); prisma.user.findUnique.mockResolvedValueOnce(dbUser);
await service.register({ username, password: 'pwd1234' });
await expect( await expect(
service.register({ username, password: 'pwd5678' }), service.register({ username: 'alice', password: 'pwd5678' }),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);
}); });
}); });
describe('login', () => { describe('login', () => {
it('returns an access token for valid credentials', async () => { it('returns access + refresh tokens with tokenVersion and type', async () => {
const username = `test_login_${Date.now()}`; prisma.user.findUnique.mockResolvedValueOnce(dbUser);
createdUsernames.push(username); const result = await service.login({ username: 'alice', password: 'plain-pwd' });
await service.register({ username, password: 'correct-pwd' });
const result = await service.login({ username, password: 'correct-pwd' }); expect(result.user.username).toBe('alice');
expect(result.accessToken).toEqual(expect.any(String)); const access = jwt.decode(result.accessToken) as Record<string, unknown>;
const parts = result.accessToken.split('.'); expect(access.typ).toBe('access');
expect(parts.length).toBe(3); expect(access.tv).toBe(0);
expect(result.user.username).toBe(username); const refresh = jwt.decode(result.refreshToken) as Record<string, unknown>;
expect(refresh.typ).toBe('refresh');
expect(refresh.tv).toBe(0);
}); });
it('throws UnauthorizedException for wrong password', async () => { it('throws UnauthorizedException for wrong password', async () => {
const username = `test_wrong_${Date.now()}`; prisma.user.findUnique.mockResolvedValueOnce(dbUser);
createdUsernames.push(username);
await service.register({ username, password: 'right-pwd' });
await expect( await expect(
service.login({ username, password: 'wrong-pwd' }), service.login({ username: 'alice', password: 'wrong-pwd' }),
).rejects.toBeInstanceOf(UnauthorizedException); ).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('throws UnauthorizedException for unknown user', async () => { it('throws UnauthorizedException for unknown user', async () => {
prisma.user.findUnique.mockResolvedValueOnce(null);
await expect( await expect(
service.login({ username: 'no-such-user-xyz', password: 'whatever' }), service.login({ username: 'no-such-user', password: 'whatever' }),
).rejects.toBeInstanceOf(UnauthorizedException); ).rejects.toBeInstanceOf(UnauthorizedException);
}); });
}); });
describe('refresh', () => {
it('rotates a valid refresh token', async () => {
const refreshToken = await jwt.signAsync({
sub: '1',
username: 'alice',
role: 'ADMIN',
tv: 0,
typ: 'refresh',
});
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
const result = await service.refresh(refreshToken);
expect(result.user.username).toBe('alice');
expect(result.accessToken).not.toBe(refreshToken);
});
it('rejects access tokens used as refresh tokens', async () => {
const accessToken = await jwt.signAsync({
sub: '1',
username: 'alice',
role: 'ADMIN',
tv: 0,
typ: 'access',
});
await expect(service.refresh(accessToken)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects refresh tokens with a stale tokenVersion (revoked)', async () => {
const refreshToken = await jwt.signAsync({
sub: '1',
username: 'alice',
role: 'ADMIN',
tv: 0,
typ: 'refresh',
});
// User logged out elsewhere: tokenVersion bumped to 1
prisma.user.findUnique.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException);
});
});
describe('logout', () => {
it('bumps tokenVersion to revoke all tokens', async () => {
prisma.user.update.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
await service.logout(1n);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 1n },
data: { tokenVersion: { increment: 1 } },
});
});
});
}); });
+107 -11
View File
@@ -1,5 +1,6 @@
import { import {
ConflictException, ConflictException,
ForbiddenException,
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -13,16 +14,25 @@ import type { JwtPayload } from './strategies/jwt.strategy';
export interface PublicUser { export interface PublicUser {
id: string; id: string;
username: string; username: string;
role: string;
createdAt: string; createdAt: string;
} }
export interface LoginResult { export interface LoginResult {
accessToken: string; accessToken: string;
refreshToken: string;
user: PublicUser; user: PublicUser;
} }
const BCRYPT_ROUNDS = 10; const BCRYPT_ROUNDS = 10;
const TOKEN_EXPIRES_IN = '7d'; const ACCESS_TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '30m';
const REFRESH_TOKEN_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN ?? '7d';
/**
* Compared against when the username does not exist so that login takes
* the same time either way (prevents user enumeration via timing).
*/
const DUMMY_HASH = '$2b$10$l232BFW3u63Mhfx0BatxUOLtw.qEofG9fNYjLsh2zce7MdIKDAIR6';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -32,10 +42,15 @@ export class AuthService {
) {} ) {}
/** /**
* Registers a brand-new admin user. Throws {@link ConflictException} * Bootstrap-only registration: allowed just while the instance has no
* if the username is already taken. * users. Once an admin exists the endpoint refuses to create accounts
* (use database seeding / an operator flow instead).
*/ */
async register(dto: RegisterDto): Promise<PublicUser> { async register(dto: RegisterDto): Promise<PublicUser> {
const userCount = await this.prisma.user.count();
if (userCount > 0) {
throw new ForbiddenException('Registration is disabled');
}
const existing = await this.prisma.user.findUnique({ const existing = await this.prisma.user.findUnique({
where: { username: dto.username }, where: { username: dto.username },
}); });
@@ -50,37 +65,118 @@ export class AuthService {
} }
/** /**
* Verifies credentials and returns a signed JWT. * Verifies credentials and returns signed access + refresh tokens.
* Both tokens embed the user's tokenVersion so bumping it on the user
* row (logout / revocation) invalidates them immediately.
*/ */
async login(dto: LoginDto): Promise<LoginResult> { async login(dto: LoginDto): Promise<LoginResult> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { username: dto.username }, where: { username: dto.username },
}); });
// Always run a bcrypt compare (against a dummy hash when the user is
// unknown) so response timing cannot be used to enumerate usernames.
const ok = await bcrypt.compare(dto.password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !ok) {
throw new UnauthorizedException('Invalid credentials');
}
return {
accessToken: await this.signAccessToken(user),
refreshToken: await this.signRefreshToken(user),
user: this.toPublic(user),
};
}
/**
* Rotates a refresh token: the old refresh token becomes invalid as
* soon as tokenVersion is bumped (logout, revocation).
*/
async refresh(refreshToken: string): Promise<LoginResult> {
let payload: JwtPayload;
try {
payload = await this.jwt.verifyAsync(refreshToken);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
if (payload.typ !== 'refresh') {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.prisma.user
.findUnique({ where: { id: BigInt(payload.sub) } })
.catch(() => null);
if (!user || user.tokenVersion !== payload.tv) {
throw new UnauthorizedException('Invalid refresh token');
}
return {
accessToken: await this.signAccessToken(user),
refreshToken: await this.signRefreshToken(user),
user: this.toPublic(user),
};
}
/**
* Revokes all tokens of a user by bumping tokenVersion.
*/
async logout(userId: bigint): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } },
});
}
async me(userId: bigint): Promise<PublicUser> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException();
} }
const ok = await bcrypt.compare(dto.password, user.passwordHash); return this.toPublic(user);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
} }
private async signAccessToken(user: {
id: bigint;
username: string;
role: string;
tokenVersion: number;
}): Promise<string> {
const payload: JwtPayload = { const payload: JwtPayload = {
sub: user.id.toString(), sub: user.id.toString(),
username: user.username, username: user.username,
role: user.role,
tv: user.tokenVersion,
typ: 'access',
}; };
const accessToken = await this.jwt.signAsync(payload, { return this.jwt.signAsync(payload, {
expiresIn: TOKEN_EXPIRES_IN, expiresIn: ACCESS_TOKEN_EXPIRES_IN,
});
}
private async signRefreshToken(user: {
id: bigint;
username: string;
role: string;
tokenVersion: number;
}): Promise<string> {
const payload: JwtPayload = {
sub: user.id.toString(),
username: user.username,
role: user.role,
tv: user.tokenVersion,
typ: 'refresh',
};
return this.jwt.signAsync(payload, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
}); });
return { accessToken, user: this.toPublic(user) };
} }
private toPublic(user: { private toPublic(user: {
id: bigint; id: bigint;
username: string; username: string;
role: string;
createdAt: Date; createdAt: Date;
}): PublicUser { }): PublicUser {
return { return {
id: user.id.toString(), id: user.id.toString(),
username: user.username, username: user.username,
role: user.role,
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
}; };
} }
@@ -0,0 +1,9 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
/**
* Restricts a route to the given roles. When omitted, any
* authenticated user with an ADMIN role passes the RolesGuard.
*/
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,32 @@
import { ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
describe('RolesGuard', () => {
let guard: RolesGuard;
beforeEach(() => {
guard = new RolesGuard(new Reflector());
});
const context = (user: unknown, roles?: string[]) =>
({
switchToHttp: () => ({ getRequest: () => ({ user }) }),
getHandler: () => (roles ? { __roles: roles } : {}),
getClass: () => ({}),
}) as never;
it('passes public routes (no authenticated user)', () => {
expect(guard.canActivate(context(undefined))).toBe(true);
});
it('passes ADMIN users by default', () => {
expect(guard.canActivate(context({ id: 1n, username: 'a', role: 'ADMIN' }))).toBe(true);
});
it('blocks users without the ADMIN role', () => {
expect(() => guard.canActivate(context({ id: 1n, username: 'a', role: 'VIEWER' }))).toThrow(
ForbiddenException,
);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import type { AuthenticatedUser } from '../strategies/jwt.strategy';
/**
* Role-based access control. Applied globally: any authenticated user
* reaching a protected route must hold the ADMIN role unless the route
* declares a wider set with @Roles(...). Routes without a JwtAuthGuard
* (public endpoints) have no `request.user` and are skipped here — their
* openness is decided by the controller's own guards.
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{
user?: AuthenticatedUser;
}>();
if (!request.user) {
return true; // public route — no JwtAuthGuard in front
}
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]) ?? ['ADMIN'];
if (!required.includes(request.user.role)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
}
+46 -5
View File
@@ -2,26 +2,51 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
export const ACCESS_TOKEN_COOKIE = 'ir_at';
export interface AuthenticatedUser {
id: bigint;
username: string;
role: string;
}
/** /**
* Shape of the JWT we issue. * Shape of the JWT we issue.
* *
* `sub` is the user ID as a string (bigints are serialized to strings in JSON). * `sub` is the user ID as a string (bigints are serialized to strings in JSON).
* `typ` distinguishes access tokens from refresh tokens; `tv` is the user's
* tokenVersion and `role` drives the RolesGuard.
*/ */
export interface JwtPayload { export interface JwtPayload {
sub: string; sub: string;
username: string; username: string;
role?: string;
tv?: number;
typ?: 'access' | 'refresh';
} }
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) { constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
const secret = config.get<string>('JWT_SECRET'); const secret = config.get<string>('JWT_SECRET');
if (!secret) { if (!secret) {
throw new Error('JWT_SECRET is not configured'); throw new Error('JWT_SECRET is not configured');
} }
if (secret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters');
}
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Access tokens are accepted from the HttpOnly cookie (browser) or
// the Authorization header (non-browser API clients).
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req) => req?.cookies?.[ACCESS_TOKEN_COOKIE] ?? null,
]),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: secret, secretOrKey: secret,
}); });
@@ -29,12 +54,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
/** /**
* Runs on every authenticated request. The returned object becomes * Runs on every authenticated request. The returned object becomes
* `request.user` for downstream controllers. * `request.user` for downstream controllers. The user and its
* tokenVersion are re-checked in the database so tokens of deleted
* users, logged-out users, or refresh tokens stop working immediately.
*/ */
validate(payload: JwtPayload): { id: bigint; username: string } { async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
if (!payload?.sub || !payload.username) { if (!payload?.sub || !payload.username) {
throw new UnauthorizedException('Invalid token payload'); throw new UnauthorizedException('Invalid token payload');
} }
return { id: BigInt(payload.sub), username: payload.username }; // Refresh tokens must never be accepted as API credentials.
if (payload.typ === 'refresh') {
throw new UnauthorizedException('Invalid token type');
}
const user = await this.prisma.user
.findUnique({ where: { id: BigInt(payload.sub) } })
.catch(() => null);
if (
!user ||
user.username !== payload.username ||
(payload.tv !== undefined && user.tokenVersion !== payload.tv)
) {
throw new UnauthorizedException('Invalid token');
}
return { id: user.id, username: user.username, role: user.role };
} }
} }
@@ -32,9 +32,24 @@ export class HttpExceptionFilter implements ExceptionFilter {
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
const status = const status =
exception instanceof HttpException exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR; // Malformed bigint/number inputs (e.g. `BigInt("abc")`) are client
// errors — map them to 400 instead of leaking a 500.
if (
status === HttpStatus.INTERNAL_SERVER_ERROR &&
exception instanceof Error &&
/Cannot convert .+ to (a BigInt|number)/i.test(exception.message)
) {
response.status(HttpStatus.BAD_REQUEST).json({
statusCode: HttpStatus.BAD_REQUEST,
message: 'Invalid numeric identifier',
error: 'BadRequestError',
timestamp: new Date().toISOString(),
path: request.url,
});
return;
}
let message: string | string[] = 'Internal server error'; let message: string | string[] = 'Internal server error';
let error = 'InternalServerError'; let error = 'InternalServerError';
@@ -51,11 +66,15 @@ export class HttpExceptionFilter implements ExceptionFilter {
message = exception.message; message = exception.message;
} }
} else if (exception instanceof Error) { } else if (exception instanceof Error) {
message = exception.message; // Unexpected errors (Prisma, driver, ...) may contain SQL or
error = exception.name; // connection details — never send them to the client.
this.logger.error(
`${request.method} ${request.url} -> ${status} ${exception.message}`,
exception.stack,
);
} }
if (status >= 500) { if (status >= 500 && exception instanceof HttpException) {
this.logger.error( this.logger.error(
`${request.method} ${request.url} -> ${status} ${message}`, `${request.method} ${request.url} -> ${status} ${message}`,
exception instanceof Error ? exception.stack : undefined, exception instanceof Error ? exception.stack : undefined,
@@ -18,6 +18,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CountriesService } from './countries.service'; import { CountriesService } from './countries.service';
import { CreateCountryDto } from './dto/create-country.dto'; import { CreateCountryDto } from './dto/create-country.dto';
import { UpdateCountryDto } from './dto/update-country.dto'; import { UpdateCountryDto } from './dto/update-country.dto';
import { ReorderCountriesDto } from './dto/reorder-countries.dto';
@ApiTags('countries') @ApiTags('countries')
@ApiBearerAuth() @ApiBearerAuth()
@@ -32,6 +33,13 @@ export class CountriesController {
return this.service.findAll(); return this.service.findAll();
} }
// 注意:'sort' 静态路由必须声明在 ':id' 之前,否则会被参数路由吞掉
@Patch('sort')
@ApiOperation({ summary: 'Batch update country sort order (drag reorder)' })
reorder(@Body() dto: ReorderCountriesDto) {
return this.service.reorder(dto);
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get one country' }) @ApiOperation({ summary: 'Get one country' })
findOne(@Param('id', ParseIntPipe) id: string) { findOne(@Param('id', ParseIntPipe) id: string) {
@@ -112,4 +112,30 @@ describe('CountriesService', () => {
const idx = created.indexOf(`${name}-v2`); const idx = created.indexOf(`${name}-v2`);
if (idx !== -1) created.splice(idx, 1); if (idx !== -1) created.splice(idx, 1);
}); });
it('reorder persists sortOrder and findAll honors it', async () => {
const stamp = Date.now();
const a = await service.create({ countryName: `Sort A ${stamp}` });
const b = await service.create({ countryName: `Sort B ${stamp}` });
const c = await service.create({ countryName: `Sort C ${stamp}` });
created.push(a.countryName, b.countryName, c.countryName);
// 新顺序 C, A, Bid 序与 sortOrder 无关时也能稳定验证)
const reordered = await service.reorder({
items: [
{ id: Number(c.id), sortOrder: 0 },
{ id: Number(a.id), sortOrder: 1 },
{ id: Number(b.id), sortOrder: 2 },
],
});
const ours = reordered.filter((r) =>
[a.id, b.id, c.id].some((id) => id.toString() === r.id.toString()),
);
expect(ours.map((r) => r.id.toString())).toEqual([c.id.toString(), a.id.toString(), b.id.toString()]);
const list = await service.findAll();
const pos = (id: bigint) => list.findIndex((r) => r.id.toString() === id.toString());
expect(pos(c.id)).toBeLessThan(pos(a.id));
expect(pos(a.id)).toBeLessThan(pos(b.id));
});
}); });
+17 -1
View File
@@ -8,13 +8,29 @@ import {
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateCountryDto } from './dto/create-country.dto'; import { CreateCountryDto } from './dto/create-country.dto';
import { UpdateCountryDto } from './dto/update-country.dto'; import { UpdateCountryDto } from './dto/update-country.dto';
import { ReorderCountriesDto } from './dto/reorder-countries.dto';
@Injectable() @Injectable()
export class CountriesService { export class CountriesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
findAll() { findAll() {
return this.prisma.country.findMany({ orderBy: { id: 'asc' } }); return this.prisma.country.findMany({
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
}
/** 批量保存拖拽后的顺序(全量提交,sortOrder = 目标下标) */
async reorder(dto: ReorderCountriesDto) {
await this.prisma.$transaction(
dto.items.map((item) =>
this.prisma.country.update({
where: { id: BigInt(item.id) },
data: { sortOrder: item.sortOrder },
}),
),
);
return this.findAll();
} }
async findOne(id: bigint) { async findOne(id: bigint) {
@@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsInt,
Min,
ValidateNested,
} from 'class-validator';
export class CountryOrderItem {
@ApiProperty()
@IsInt()
@Min(1)
id!: number;
@ApiProperty()
@IsInt()
@Min(0)
sortOrder!: number;
}
export class ReorderCountriesDto {
@ApiProperty({ type: [CountryOrderItem] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CountryOrderItem)
items!: CountryOrderItem[];
}
@@ -15,6 +15,13 @@ export class BatchCreateItemDto {
@Min(1) @Min(1)
originGoodId!: number; originGoodId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
mergedOriginGoodIds?: number[];
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@@ -20,6 +20,13 @@ export class CreateGoodDto {
@Min(1) @Min(1)
originGoodId!: number; originGoodId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
mergedOriginGoodIds?: number[];
@ApiProperty() @ApiProperty()
@IsInt() @IsInt()
@Min(1) @Min(1)
+261
View File
@@ -0,0 +1,261 @@
import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsInt,
IsNumberString,
IsObject,
IsOptional,
IsString,
Min,
ValidateNested,
} from 'class-validator';
import { CreateGoodDto } from './create-good.dto';
export class CustomGoodDetailDto {
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
productCode?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
englishName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
blankDesignUrl?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
detailsPageVideoUrl?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
textureName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(0)
productionCycleHours?: number | null;
@ApiProperty({ required: false, nullable: true, example: '208.000' })
@IsOptional()
@IsNumberString()
minWeightG?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
productionProcess?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
materialDescription?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
reminder?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
productPerformance?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
applicableScenarios?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
washingInstructions?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
specialDescription?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
designExplanation?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
designArea?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
pictureRequest?: string | null;
@ApiProperty({ required: false, nullable: true, type: Object })
@IsOptional()
@IsObject()
sizeChart?: Record<string, unknown> | null;
@ApiProperty({ required: false, nullable: true, type: Object })
@IsOptional()
@IsObject()
packageSpecs?: Record<string, unknown> | null;
@ApiProperty({ required: false, nullable: true, type: Object })
@IsOptional()
@IsObject()
options?: Record<string, unknown> | null;
@ApiProperty({ required: false, nullable: true, type: Object })
@IsOptional()
@IsObject()
media?: Record<string, unknown> | null;
}
export class CustomGoodVariantDto {
@ApiProperty()
@IsString()
sku!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
sizeName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
sizeId?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorHex?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorId?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
imageUrl?: string | null;
@ApiProperty({ required: false, nullable: true, example: '28.00' })
@IsOptional()
@IsNumberString()
price?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsNumberString()
originalPrice?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsNumberString()
weightG?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsNumberString()
boxLengthCm?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsNumberString()
boxWidthCm?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsNumberString()
boxHeightCm?: string | null;
@ApiProperty({ required: false, nullable: true, type: Object })
@IsOptional()
@IsObject()
designData?: Record<string, unknown> | null;
@ApiProperty({ required: false, default: true })
@IsOptional()
@IsBoolean()
enabled?: boolean;
@ApiProperty({ required: false, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
}
export class CreateCustomGoodDto extends OmitType(CreateGoodDto, [
'originGoodId',
] as const) {
@ApiProperty({ required: false, nullable: true, example: '28.00' })
@IsOptional()
@IsNumberString()
goodPrice?: string | null;
@ApiProperty({ required: false, description: '物流归因(入族后参与价格矩阵)', example: '包邮' })
@IsOptional()
@IsString()
logisticsLabel?: string;
@ApiProperty({ required: false, description: '工艺/印花数量归因', example: '双面印花' })
@IsOptional()
@IsString()
craftLabel?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
skuCode?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
warehouseLabel?: string;
@ApiProperty({ required: false, description: '挂入的产品族 id(缺省为独立商品)', example: '12' })
@IsOptional()
@IsNumberString()
familyId?: string;
@ApiProperty({ required: false, type: CustomGoodDetailDto })
@IsOptional()
@ValidateNested()
@Type(() => CustomGoodDetailDto)
detail?: CustomGoodDetailDto;
@ApiProperty({ required: false, type: [CustomGoodVariantDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CustomGoodVariantDto)
variants?: CustomGoodVariantDto[];
}
export class UpdateCustomGoodContentDto extends PartialType(
OmitType(CreateCustomGoodDto, [
'countryId',
'categoryId',
'tagIds',
'positionId',
'goodPriority',
] as const),
) {}
+89 -7
View File
@@ -9,9 +9,11 @@ export interface GoodRelations {
originGood?: { originGood?: {
id: bigint; id: bigint;
sdsGoodId: string; sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
goodName: string | null; goodName: string | null;
goodImage: string | null; goodImage: string | null;
goodPrice: unknown; goodPrice: unknown;
family?: { id: bigint; familyCode: string | null; familyName: string; _count?: { originGoods: number } } | null;
detail?: { detail?: {
productCode: string | null; productCode: string | null;
syncedAt: Date; syncedAt: Date;
@@ -31,7 +33,32 @@ export interface GoodRelations {
}>; }>;
_count?: { variants: number }; _count?: { variants: number };
} | null; } | null;
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[]; goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: bigint | null } }[];
mergedOriginGoods?: Array<{
originGood: {
id: bigint;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
goodName: string | null;
goodImage: string | null;
goodPrice: unknown;
detail?: { syncedAt: Date } | Record<string, unknown> | null;
variants?: Array<{ [key: string]: unknown }>;
_count?: { variants: number };
};
}>;
}
export interface MergedOriginGoodSummary {
id: string;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
isCustom: boolean;
goodName: string | null;
goodImage: string | null;
goodPrice: string | null;
hasDetail: boolean;
variantCount: number;
} }
export class GoodDto { export class GoodDto {
@@ -80,6 +107,9 @@ export class GoodDto {
@ApiProperty({ required: false, type: Array }) @ApiProperty({ required: false, type: Array })
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>; tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
@ApiProperty({ required: false, type: Array })
mergedOriginGoods!: MergedOriginGoodSummary[];
@ApiProperty({ required: false, nullable: true }) @ApiProperty({ required: false, nullable: true })
position?: { id: string; indexVal: number } | null; position?: { id: string; indexVal: number } | null;
@@ -87,6 +117,8 @@ export class GoodDto {
originGood?: { originGood?: {
id: string; id: string;
sdsGoodId: string; sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
isCustom: boolean;
goodName: string | null; goodName: string | null;
goodImage: string | null; goodImage: string | null;
goodPrice: string | null; goodPrice: string | null;
@@ -96,6 +128,7 @@ export class GoodDto {
sizeRowCount: number; sizeRowCount: number;
packageRowCount: number; packageRowCount: number;
productCode: string | null; productCode: string | null;
family?: { familyId: string; familyCode: string | null; familyName: string; memberCount?: number } | null;
} | null; } | null;
static from( static from(
@@ -142,8 +175,24 @@ export class GoodDto {
tagName: gt.tag.tagName, tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor, tagColor: gt.tag.tagColor,
tagFontColor: gt.tag.tagFontColor, tagFontColor: gt.tag.tagFontColor,
tagGroupId: gt.tag.tagGroupId?.toString() ?? null,
})) }))
: [], : [],
mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({
id: m.originGood.id.toString(),
sdsGoodId: m.originGood.sdsGoodId,
source: m.originGood.source,
isCustom: m.originGood.source === 'CUSTOM',
goodName: m.originGood.goodName,
goodImage: m.originGood.goodImage,
goodPrice:
m.originGood.goodPrice === null || m.originGood.goodPrice === undefined
? null
: (m.originGood.goodPrice as { toString(): string }).toString(),
hasDetail: Boolean(m.originGood.detail),
variantCount:
m.originGood._count?.variants ?? m.originGood.variants?.length ?? 0,
})),
position: rel.position position: rel.position
? { ? {
id: rel.position.id.toString(), id: rel.position.id.toString(),
@@ -154,6 +203,8 @@ export class GoodDto {
? { ? {
id: rel.originGood.id.toString(), id: rel.originGood.id.toString(),
sdsGoodId: rel.originGood.sdsGoodId, sdsGoodId: rel.originGood.sdsGoodId,
source: rel.originGood.source,
isCustom: rel.originGood.source === 'CUSTOM',
goodName: rel.originGood.goodName, goodName: rel.originGood.goodName,
goodImage: rel.originGood.goodImage, goodImage: rel.originGood.goodImage,
goodPrice: goodPrice:
@@ -167,6 +218,14 @@ export class GoodDto {
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart), sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs), packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
productCode: rel.originGood.detail?.productCode ?? null, productCode: rel.originGood.detail?.productCode ?? null,
family: rel.originGood.family
? {
familyId: rel.originGood.family.id.toString(),
familyCode: rel.originGood.family.familyCode,
familyName: rel.originGood.family.familyName,
memberCount: rel.originGood.family._count?.originGoods,
}
: null,
} }
: null, : null,
}; };
@@ -189,16 +248,39 @@ export class GoodDetailDto extends GoodDto {
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto { static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
const base = GoodDto.from(good, rel); const base = GoodDto.from(good, rel);
const detail = rel.originGood?.detail; const detail = rel.originGood?.detail;
return { const toAnnotated = (
...base, variant: Record<string, unknown>,
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null, originGoodId: string,
variants: (rel.originGood?.variants ?? []).map((variant) => ({ originGoodName: string | null,
) => ({
...variant, ...variant,
price: price:
variant.price === null || variant.price === undefined variant.price === null || variant.price === undefined
? null ? null
: (variant.price as { toString(): string }).toString(), : (variant.price as unknown as { toString(): string }).toString(),
})), originGoodId,
originGoodName,
});
const primaryId = good.originGoodId.toString();
const primaryName = rel.originGood?.goodName ?? null;
const mergedVariants = [
...(rel.originGood?.variants ?? []).map((variant) =>
toAnnotated(variant as Record<string, unknown>, primaryId, primaryName),
),
...(rel.mergedOriginGoods ?? []).flatMap((m) =>
(m.originGood.variants ?? []).map((variant) =>
toAnnotated(
variant,
m.originGood.id.toString(),
m.originGood.goodName,
),
),
),
];
return {
...base,
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
variants: mergedVariants,
}; };
} }
} }
+1 -1
View File
@@ -21,7 +21,7 @@ export class QueryGoodDto {
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
@Max(200) @Max(1000)
pageSize: number = 20; pageSize: number = 20;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
+18
View File
@@ -19,6 +19,13 @@ export class UpdateGoodDto {
@Min(1) @Min(1)
originGoodId?: number; originGoodId?: number;
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID 全量覆盖,不含主源' })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
mergedOriginGoodIds?: number[];
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@@ -54,4 +61,15 @@ export class UpdateGoodDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
goodImage?: string | null; goodImage?: string | null;
@ApiProperty({
required: false,
nullable: true,
type: Number,
description: '所属产品族 idnull = 脱离族)',
})
@IsOptional()
@IsInt()
@Min(1)
familyId?: number | null;
} }
+19
View File
@@ -22,6 +22,10 @@ import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto'; import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto'; import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto'; import { BatchPriorityDto } from './dto/batch-priority.dto';
import {
CreateCustomGoodDto,
UpdateCustomGoodContentDto,
} from './dto/custom-good.dto';
@ApiTags('goods') @ApiTags('goods')
@ApiBearerAuth() @ApiBearerAuth()
@@ -54,6 +58,21 @@ export class GoodsController {
return this.service.batchCreate(dto); return this.service.batchCreate(dto);
} }
@Post('custom')
@ApiOperation({ summary: 'Create a fully editable custom product' })
createCustom(@Body() dto: CreateCustomGoodDto) {
return this.service.createCustom(dto);
}
@Patch(':id/custom-content')
@ApiOperation({ summary: 'Update editable content for a custom product' })
updateCustomContent(
@Param('id', ParseIntPipe) id: string,
@Body() dto: UpdateCustomGoodContentDto,
) {
return this.service.updateCustomContent(BigInt(id), dto);
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get one good with relations' }) @ApiOperation({ summary: 'Get one good with relations' })
findOne(@Param('id', ParseIntPipe) id: string) { findOne(@Param('id', ParseIntPipe) id: string) {
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { GoodsController } from './goods.controller'; import { GoodsController } from './goods.controller';
import { GoodsService } from './goods.service'; import { GoodsService } from './goods.service';
import { SyncModule } from '../sync/sync.module'; import { SyncModule } from '../sync/sync.module';
import { ProductFamiliesModule } from '../product-families/product-families.module';
@Module({ @Module({
imports: [SyncModule], imports: [SyncModule, ProductFamiliesModule],
controllers: [GoodsController], controllers: [GoodsController],
providers: [GoodsService], providers: [GoodsService],
exports: [GoodsService], exports: [GoodsService],
+225
View File
@@ -6,6 +6,7 @@ import {
import { GoodsService } from './goods.service'; import { GoodsService } from './goods.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
describe('GoodsService', () => { describe('GoodsService', () => {
let service: GoodsService; let service: GoodsService;
@@ -30,6 +31,18 @@ describe('GoodsService', () => {
provide: SyncService, provide: SyncService,
useValue: { queueProductDetailSync: jest.fn() }, useValue: { queueProductDetailSync: jest.fn() },
}, },
{
provide: FamilyRecomputeService,
// 全量并行跑时其他套件的扫名归族可能把本套件夹具链接收进族,
// create/update 会调用 syncFamilyTags —— mock 必须覆盖全部被调方法
useValue: {
enqueue: jest.fn(),
recomputeFamily: jest.fn().mockResolvedValue(undefined),
syncFamilyTags: jest.fn().mockResolvedValue({ goodsUpdated: 0, linksUpdated: 0 }),
refreshLinkTags: jest.fn().mockResolvedValue(undefined),
mirrorLinkTagsToGoods: jest.fn().mockResolvedValue(undefined),
},
},
], ],
}).compile(); }).compile();
service = moduleRef.get(GoodsService); service = moduleRef.get(GoodsService);
@@ -119,6 +132,51 @@ describe('GoodsService', () => {
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`); expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
}); });
it('creates, edits, and removes a fully editable custom good', async () => {
const created = await service.createCustom({
goodName: `Goods Test ${stamp} custom`,
goodImage: 'https://example.com/custom.png',
goodPrice: '29.90',
countryId: Number(countryId),
categoryId: Number(categoryId),
tagIds: [Number(tagId)],
detail: {
productCode: `CUSTOM-${stamp}`,
materialDescription: 'Cotton',
sizeChart: { columns: [], rows: [] },
packageSpecs: { rows: [] },
},
variants: [
{ sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' },
],
});
expect(created.originGood?.source).toBe('CUSTOM');
expect(created.originGood?.isCustom).toBe(true);
expect(created.originGood?.goodPrice).toBe('29.9');
expect(created.variants).toHaveLength(1);
const updated = await service.updateCustomContent(BigInt(created.id), {
goodName: `Goods Test ${stamp} custom edited`,
goodPrice: '39.90',
detail: { materialDescription: 'Organic cotton' },
variants: [
{ sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' },
],
});
expect(updated.goodName).toContain('custom edited');
expect(updated.originGood?.goodPrice).toBe('39.9');
expect(updated.originDetail?.materialDescription).toBe('Organic cotton');
expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`);
expect(updated.variants[0]?.sizeName).toBe('M');
const customOriginId = BigInt(updated.originGoodId);
await service.remove(BigInt(updated.id));
await expect(
prisma.originGood.findUnique({ where: { id: customOriginId } }),
).resolves.toBeNull();
});
it('filters by countryId, tagId, positionId and keyword', async () => { it('filters by countryId, tagId, positionId and keyword', async () => {
const result = await service.findAll({ const result = await service.findAll({
page: 1, page: 1,
@@ -216,6 +274,173 @@ describe('GoodsService', () => {
expect(after.total).toBe(before.total); expect(after.total).toBe(before.total);
}); });
describe('good name normalization', () => {
it('create/update 时把结构完整的原始链接名规范化为 品名+型号', async () => {
const created = await service.create({
goodName: '德国(不包邮)230g水洗T恤-DETM002-双面印花',
originGoodId: Number(originGoodIds[0]),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
try {
expect(created.goodName).toBe('230g水洗T恤 DETM002');
const updated = await service.update(BigInt(created.id), {
goodName: '加拿大(不包邮)180g纯棉T恤-CATM001-单面印花',
});
expect(updated.goodName).toBe('180g纯棉T恤 CATM001');
} finally {
await prisma.good.delete({ where: { id: BigInt(created.id) } });
}
});
it('非链接结构的名称原样保留(已解析名/自定义名)', async () => {
const created = await service.create({
goodName: '230g水洗T恤 DETM002',
originGoodId: Number(originGoodIds[0]),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
try {
expect(created.goodName).toBe('230g水洗T恤 DETM002');
const updated = await service.update(BigInt(created.id), {
goodName: '自定义商品 ABC',
});
expect(updated.goodName).toBe('自定义商品 ABC');
} finally {
await prisma.good.delete({ where: { id: BigInt(created.id) } });
}
});
});
describe('merged origin goods', () => {
it('creates a good with merged origin goods and reads them back', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} merged`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[2]), Number(originGoodIds[3])],
countryId: Number(countryId),
categoryId: Number(categoryId),
});
expect(created.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
[originGoodIds[2].toString(), originGoodIds[3].toString()].sort(),
);
const fetched = await service.findOne(BigInt(created.id));
expect(fetched.mergedOriginGoods.length).toBe(2);
});
it('rejects mergedOriginGoodIds containing the primary', async () => {
await expect(
service.create({
goodName: `Goods Test ${stamp} bad-primary`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[1])],
countryId: Number(countryId),
categoryId: Number(categoryId),
}),
).rejects.toThrow(BadRequestException);
});
it('rejects mergedOriginGoodIds that do not exist', async () => {
await expect(
service.create({
goodName: `Goods Test ${stamp} bad-missing`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [999999999],
countryId: Number(countryId),
categoryId: Number(categoryId),
}),
).rejects.toThrow(BadRequestException);
});
it('replaces merged origin goods on update', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} replace`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[2])],
countryId: Number(countryId),
categoryId: Number(categoryId),
});
const updated = await service.update(BigInt(created.id), {
mergedOriginGoodIds: [Number(originGoodIds[3]), Number(originGoodIds[4])],
});
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
[originGoodIds[3].toString(), originGoodIds[4].toString()].sort(),
);
});
it('moves old primary into merged list when switching primary', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} switch`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[2])],
countryId: Number(countryId),
categoryId: Number(categoryId),
});
const updated = await service.update(BigInt(created.id), {
originGoodId: Number(originGoodIds[2]),
mergedOriginGoodIds: [Number(originGoodIds[1]), Number(originGoodIds[3])],
});
expect(updated.originGoodId).toBe(originGoodIds[2].toString());
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
[originGoodIds[1].toString(), originGoodIds[3].toString()].sort(),
);
});
it('cascades merged rows on good removal', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} cascade`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[2])],
countryId: Number(countryId),
categoryId: Number(categoryId),
});
await service.remove(BigInt(created.id));
const rows = await prisma.goodOriginGood.count({
where: { goodId: BigInt(created.id) },
});
expect(rows).toBe(0);
});
it('returns merged variants with source annotation in detail', async () => {
const v1 = await prisma.originGoodVariant.create({
data: {
originGoodId: originGoodIds[1],
sdsVariantId: `mv-pri-${stamp}`,
sku: `MV-PRI-${stamp}`,
colorName: '黑色',
},
});
const v2 = await prisma.originGoodVariant.create({
data: {
originGoodId: originGoodIds[2],
sdsVariantId: `mv-sec-${stamp}`,
sku: `MV-SEC-${stamp}`,
colorName: '白色',
},
});
try {
const created = await service.create({
goodName: `Goods Test ${stamp} variants`,
originGoodId: Number(originGoodIds[1]),
mergedOriginGoodIds: [Number(originGoodIds[2])],
countryId: Number(countryId),
categoryId: Number(categoryId),
});
const detail = await service.findOne(BigInt(created.id));
const sources = new Set(
detail.variants.map((v) => v['originGoodId'] as string),
);
expect(sources.has(originGoodIds[1].toString())).toBe(true);
expect(sources.has(originGoodIds[2].toString())).toBe(true);
expect(detail.variants).toHaveLength(2);
expect(detail.mergedOriginGoods.find((m) => m.id === originGoodIds[2].toString())?.variantCount).toBe(1);
} finally {
await prisma.originGoodVariant.delete({ where: { id: v1.id } });
await prisma.originGoodVariant.delete({ where: { id: v2.id } });
}
});
});
it('throws NotFoundException for unknown id', async () => { it('throws NotFoundException for unknown id', async () => {
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf( await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException, NotFoundException,
+452 -14
View File
@@ -5,6 +5,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { parseOriginName } from '../product-families/origin-name.parser';
import { CreateGoodDto } from './dto/create-good.dto'; import { CreateGoodDto } from './dto/create-good.dto';
import { UpdateGoodDto } from './dto/update-good.dto'; import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto'; import { QueryGoodDto } from './dto/query-good.dto';
@@ -12,6 +13,15 @@ import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto'; import { BatchPriorityDto } from './dto/batch-priority.dto';
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto'; import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { isAutoTagGroupName } from '../product-families/auto-tag-rules';
import { randomUUID } from 'crypto';
import {
CreateCustomGoodDto,
CustomGoodDetailDto,
CustomGoodVariantDto,
UpdateCustomGoodContentDto,
} from './dto/custom-good.dto';
const GOOD_INCLUDE = { const GOOD_INCLUDE = {
country: true, country: true,
@@ -23,16 +33,51 @@ const GOOD_INCLUDE = {
detail: true, detail: true,
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
_count: { select: { variants: true } }, _count: { select: { variants: true } },
family: {
select: {
id: true,
familyCode: true,
familyName: true,
stale: true,
_count: { select: { originGoods: true } },
},
},
}, },
}, },
goodTags: { include: { tag: true } }, goodTags: { include: { tag: true } },
mergedOriginGoods: {
orderBy: { createdAt: 'asc' },
include: {
originGood: {
include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
_count: { select: { variants: true } },
},
},
},
},
} satisfies Prisma.GoodInclude; } satisfies Prisma.GoodInclude;
/**
* 商品名(公开展示名)规范化:结构完整的原始链接名(`国家(物流)品名-型号-…`,
* 含半角/全角括号混用)一律存为「品名 型号」;已解析名与自由命名的自定义名原样保留。
* 防的是绕过前端弹窗(预填解析名)的写入路径——API 直调、批量工具、旧客户端。
*/
export function normalizeGoodName(name: string): string {
const parsed = parseOriginName(name);
if (parsed.productName && parsed.skuCode) {
return `${parsed.productName} ${parsed.skuCode}`;
}
return name;
}
@Injectable() @Injectable()
export class GoodsService { export class GoodsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly syncService: SyncService, private readonly syncService: SyncService,
private readonly familyRecompute: FamilyRecomputeService,
) {} ) {}
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> { async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
@@ -68,6 +113,7 @@ export class GoodsService {
position: g.position, position: g.position,
originGood: g.originGood, originGood: g.originGood,
goodTags: g.goodTags, goodTags: g.goodTags,
mergedOriginGoods: g.mergedOriginGoods,
})), })),
total, total,
page, page,
@@ -88,31 +134,52 @@ export class GoodsService {
position: good.position, position: good.position,
originGood: good.originGood, originGood: good.originGood,
goodTags: good.goodTags, goodTags: good.goodTags,
mergedOriginGoods: good.mergedOriginGoods,
}); });
} }
async create(dto: CreateGoodDto): Promise<GoodDto> { async create(dto: CreateGoodDto): Promise<GoodDto> {
await this.ensureReferences(dto); await this.ensureReferences(dto);
const mergedIds = this.dedupeMergedIds(
BigInt(dto.originGoodId),
dto.mergedOriginGoodIds,
);
await this.ensureMergedOriginGoods(mergedIds);
const result = await this.prisma.$transaction(async (tx) => { const result = await this.prisma.$transaction(async (tx) => {
// Good 的族是派生数据:主链接所属族
const primary = await tx.originGood.findUnique({
where: { id: BigInt(dto.originGoodId) },
select: { familyId: true },
});
const tagIds = await this.stripAutoGroupTags(dto.tagIds ?? [], primary?.familyId ?? null);
const created = await tx.good.create({ const created = await tx.good.create({
data: { data: {
goodName: dto.goodName, goodName: normalizeGoodName(dto.goodName),
goodImage: dto.goodImage, goodImage: dto.goodImage,
originGoodId: BigInt(dto.originGoodId), originGoodId: BigInt(dto.originGoodId),
familyId: primary?.familyId ?? null,
countryId: BigInt(dto.countryId), countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId), categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId), positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
goodPriority: dto.goodPriority ?? 0, goodPriority: dto.goodPriority ?? 0,
}, },
}); });
if (dto.tagIds && dto.tagIds.length > 0) { if (tagIds.length > 0) {
await tx.goodTag.createMany({ await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({ data: tagIds.map((tagId) => ({
goodId: created.id, goodId: created.id,
tagId: BigInt(tagId), tagId: BigInt(tagId),
})), })),
}); });
} }
if (mergedIds.length > 0) {
await tx.goodOriginGood.createMany({
data: mergedIds.map((originGoodId) => ({
goodId: created.id,
originGoodId,
})),
});
}
const result = await tx.good.findUniqueOrThrow({ const result = await tx.good.findUniqueOrThrow({
where: { id: created.id }, where: { id: created.id },
include: GOOD_INCLUDE, include: GOOD_INCLUDE,
@@ -124,18 +191,160 @@ export class GoodsService {
position: result.position, position: result.position,
originGood: result.originGood, originGood: result.originGood,
goodTags: result.goodTags, goodTags: result.goodTags,
mergedOriginGoods: result.mergedOriginGoods,
}); });
}); });
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) { if (result.originGood) {
// 建商品后把链接有效标签镜像到该商品(纯聚合,不派生——派生在整理流程)
await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId));
}
if (
result.originGood?.source === 'SDS' &&
result.originGood.sdsGoodId &&
!result.originGood.hasDetail
) {
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
} }
return result; return result;
} }
async createCustom(dto: CreateCustomGoodDto): Promise<GoodDetailDto> {
await this.ensureCountry(dto.countryId);
await this.ensureCategory(dto.categoryId);
if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId);
for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId);
let family: { id: bigint } | null = null;
if (dto.familyId !== undefined) {
family = await this.prisma.productFamily.findUnique({
where: { id: BigInt(dto.familyId) },
select: { id: true },
});
if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`);
}
const goodId = await this.prisma.$transaction(async (tx) => {
const originGood = await tx.originGood.create({
data: {
source: 'CUSTOM',
sdsGoodId: `custom-${randomUUID()}`,
goodName: dto.goodName,
goodImage: dto.goodImage ?? null,
goodPrice: this.decimal(dto.goodPrice),
...(family ? { familyId: family.id } : {}),
...(dto.logisticsLabel !== undefined || dto.craftLabel !== undefined
? {
logisticsLabel: dto.logisticsLabel ?? null,
craftLabel: dto.craftLabel ?? null,
skuCode: dto.skuCode ?? null,
warehouseLabel: dto.warehouseLabel ?? null,
}
: {}),
detail: {
create: this.customDetailData(
dto.detail ?? {},
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput,
},
},
});
if (dto.variants?.length) {
await this.replaceCustomVariants(tx, originGood.id, dto.variants);
}
const good = await tx.good.create({
data: {
originGoodId: originGood.id,
familyId: family?.id ?? null,
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId:
dto.positionId === undefined ? null : BigInt(dto.positionId),
goodName: normalizeGoodName(dto.goodName),
goodImage: dto.goodImage ?? null,
goodPriority: dto.goodPriority ?? 0,
},
});
if (dto.tagIds?.length) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: good.id,
tagId: BigInt(tagId),
})),
});
}
return good.id;
});
if (family) this.familyRecompute.enqueue(family.id);
return this.findOne(goodId);
}
async updateCustomContent(
id: bigint,
dto: UpdateCustomGoodContentDto,
): Promise<GoodDetailDto> {
const existing = await this.prisma.good.findUnique({
where: { id },
include: { originGood: true },
});
if (!existing) throw new NotFoundException(`Good ${id} not found`);
if (existing.originGood.source !== 'CUSTOM') {
throw new BadRequestException('SDS 映射商品的上游信息不可修改');
}
await this.prisma.$transaction(async (tx) => {
await tx.originGood.update({
where: { id: existing.originGoodId },
data: {
goodName: dto.goodName,
goodImage: dto.goodImage,
goodPrice:
dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice),
},
});
if (dto.detail !== undefined) {
await tx.originGoodDetail.upsert({
where: { originGoodId: existing.originGoodId },
create: {
originGoodId: existing.originGoodId,
...(this.customDetailData(
dto.detail,
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput),
},
update: this.customDetailData(dto.detail, true),
});
}
if (dto.variants !== undefined) {
await this.replaceCustomVariants(
tx,
existing.originGoodId,
dto.variants,
);
}
const goodData: Prisma.GoodUpdateInput = {};
if (dto.goodName !== undefined) {
goodData.goodName = normalizeGoodName(dto.goodName);
}
if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage;
if (Object.keys(goodData).length) {
await tx.good.update({ where: { id }, data: goodData });
}
});
return this.findOne(id);
}
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> { async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
await this.findOne(id); await this.findOne(id);
let mergedIds: bigint[] | undefined;
if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) {
const current = await this.prisma.good.findUniqueOrThrow({
where: { id },
select: { originGoodId: true },
});
const primaryId =
dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId;
mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds);
await this.ensureMergedOriginGoods(mergedIds);
}
const data: Prisma.GoodUpdateInput = {}; const data: Prisma.GoodUpdateInput = {};
if (dto.goodName !== undefined) data.goodName = dto.goodName; if (dto.goodName !== undefined) data.goodName = normalizeGoodName(dto.goodName);
if (dto.originGoodId !== undefined) { if (dto.originGoodId !== undefined) {
await this.ensureOriginGood(dto.originGoodId); await this.ensureOriginGood(dto.originGoodId);
data.originGood = { connect: { id: BigInt(dto.originGoodId) } }; data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
@@ -156,24 +365,65 @@ export class GoodsService {
} }
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority; if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage; if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
if (dto.familyId !== undefined) {
if (dto.familyId !== null) {
const family = await this.prisma.productFamily.findUnique({
where: { id: BigInt(dto.familyId) },
select: { id: true },
});
if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`);
data.family = { connect: { id: family.id } };
} else {
data.family = { disconnect: true };
}
}
if (dto.tagIds !== undefined) { if (dto.tagIds !== undefined) {
for (const tagId of dto.tagIds) { for (const tagId of dto.tagIds) {
await this.ensureTag(tagId); await this.ensureTag(tagId);
} }
} }
// 有族商品的自动组(物流/工艺/位置)标签为族派生数据,不接受手动写入
let familyIdForTags: bigint | null;
if (dto.familyId !== undefined) {
familyIdForTags = dto.familyId === null ? null : BigInt(dto.familyId);
} else {
familyIdForTags =
(
await this.prisma.good.findUnique({
where: { id },
select: { familyId: true },
})
)?.familyId ?? null;
}
const updateTagIds =
dto.tagIds !== undefined
? await this.stripAutoGroupTags(dto.tagIds, familyIdForTags)
: undefined;
const result = await this.prisma.$transaction(async (tx) => { const result = await this.prisma.$transaction(async (tx) => {
if (dto.tagIds !== undefined) { if (updateTagIds !== undefined) {
await tx.goodTag.deleteMany({ where: { goodId: id } }); await tx.goodTag.deleteMany({ where: { goodId: id } });
if (dto.tagIds.length > 0) { if (updateTagIds.length > 0) {
await tx.goodTag.createMany({ await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({ data: updateTagIds.map((tagId) => ({
goodId: id, goodId: id,
tagId: BigInt(tagId), tagId: BigInt(tagId),
})), })),
}); });
} }
} }
if (mergedIds !== undefined) {
await tx.goodOriginGood.deleteMany({ where: { goodId: id } });
if (mergedIds.length > 0) {
await tx.goodOriginGood.createMany({
data: mergedIds.map((originGoodId) => ({
goodId: id,
originGoodId,
})),
});
}
}
const updated = await tx.good.update({ const updated = await tx.good.update({
where: { id }, where: { id },
data, data,
@@ -186,17 +436,44 @@ export class GoodsService {
position: updated.position, position: updated.position,
originGood: updated.originGood, originGood: updated.originGood,
goodTags: updated.goodTags, goodTags: updated.goodTags,
mergedOriginGoods: updated.mergedOriginGoods,
}); });
}); });
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) { if (result.originGood) {
// 更新后把链接有效标签镜像到该商品(纯聚合,不派生)
await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId));
}
if (familyIdForTags) {
// 归族联动后重算矩阵(成员变化 → 自动重算,结构化聚合)
await this.familyRecompute.recomputeFamily(familyIdForTags);
}
if (
result.originGood?.source === 'SDS' &&
result.originGood.sdsGoodId &&
!result.originGood.hasDetail
) {
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId); this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
} }
return result; return result;
} }
async remove(id: bigint): Promise<{ id: string }> { async remove(id: bigint): Promise<{ id: string }> {
await this.findOne(id); const good = await this.prisma.good.findUnique({
await this.prisma.good.delete({ where: { id } }); where: { id },
include: { originGood: true },
});
if (!good) throw new NotFoundException(`Good ${id} not found`);
await this.prisma.$transaction(async (tx) => {
await tx.good.delete({ where: { id } });
if (good.originGood.source === 'CUSTOM') {
const remaining = await tx.good.count({
where: { originGoodId: good.originGoodId },
});
if (remaining === 0) {
await tx.originGood.delete({ where: { id: good.originGoodId } });
}
}
});
return { id: id.toString() }; return { id: id.toString() };
} }
@@ -244,6 +521,7 @@ export class GoodsService {
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`, goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
goodImage: og.goodImage, goodImage: og.goodImage,
originGoodId: og.id, originGoodId: og.id,
familyId: og.familyId,
countryId: BigInt(dto.countryId), countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId), categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId), positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
@@ -258,6 +536,24 @@ export class GoodsService {
})), })),
}); });
} }
const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds);
if (itemMerged.length > 0) {
const existRows = await tx.originGood.findMany({
where: { id: { in: itemMerged } },
select: { id: true },
});
if (existRows.length !== itemMerged.length) {
const found = new Set(existRows.map((r) => r.id.toString()));
const missing = itemMerged.find((mid) => !found.has(mid.toString()));
throw new BadRequestException(`Origin good ${missing} not found`);
}
await tx.goodOriginGood.createMany({
data: itemMerged.map((originGoodId) => ({
goodId: row.id,
originGoodId,
})),
});
}
const result = await tx.good.findUniqueOrThrow({ const result = await tx.good.findUniqueOrThrow({
where: { id: row.id }, where: { id: row.id },
include: GOOD_INCLUDE, include: GOOD_INCLUDE,
@@ -269,13 +565,18 @@ export class GoodsService {
position: result.position, position: result.position,
originGood: result.originGood, originGood: result.originGood,
goodTags: result.goodTags, goodTags: result.goodTags,
mergedOriginGoods: result.mergedOriginGoods,
})); }));
} }
return created; return created;
}); });
for (const goodId of new Set( for (const goodId of new Set(
result result
.filter((item) => !item.originGood?.hasDetail) .filter(
(item) =>
item.originGood?.source === 'SDS' &&
!item.originGood.hasDetail,
)
.map((item) => item.originGood?.sdsGoodId) .map((item) => item.originGood?.sdsGoodId)
.filter((id): id is string => Boolean(id)), .filter((id): id is string => Boolean(id)),
)) { )) {
@@ -312,6 +613,31 @@ export class GoodsService {
if (!og) throw new BadRequestException(`Origin good ${id} not found`); if (!og) throw new BadRequestException(`Origin good ${id} not found`);
} }
/** Dedupe merged ids and reject any that equals the primary source. */
private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] {
if (!ids || ids.length === 0) return [];
const unique = [...new Set(ids.map((id) => BigInt(id)))];
if (unique.includes(primaryId)) {
throw new BadRequestException(
'mergedOriginGoodIds 不能包含主源 originGoodId',
);
}
return unique;
}
private async ensureMergedOriginGoods(ids: bigint[]) {
if (ids.length === 0) return;
const rows = await this.prisma.originGood.findMany({
where: { id: { in: ids } },
select: { id: true },
});
if (rows.length !== ids.length) {
const found = new Set(rows.map((r) => r.id.toString()));
const missing = ids.find((id) => !found.has(id.toString()));
throw new BadRequestException(`Origin good ${missing} not found`);
}
}
private async ensureCountry(id: number) { private async ensureCountry(id: number) {
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } }); const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
if (!c) throw new BadRequestException(`Country ${id} not found`); if (!c) throw new BadRequestException(`Country ${id} not found`);
@@ -322,8 +648,31 @@ export class GoodsService {
if (!c) throw new BadRequestException(`Category ${id} not found`); if (!c) throw new BadRequestException(`Category ${id} not found`);
} }
private async ensureTag(id: number) { /** 有族商品:剔除自动组(物流/工艺/印花数量等)标签——它们由族按链接名称派生 */
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } }); private async stripAutoGroupTags(
tagIds: number[],
familyId: bigint | null,
): Promise<number[]> {
if (!familyId || !tagIds.length) return tagIds;
const groups = await this.prisma.tagGroup.findMany({
select: { id: true, groupName: true },
});
const autoIds = new Set(
groups.filter((g) => isAutoTagGroupName(g.groupName)).map((g) => g.id.toString()),
);
const tags = await this.prisma.tag.findMany({
where: { id: { in: tagIds.map((id) => BigInt(id)) } },
select: { id: true, tagGroupId: true },
});
const blocked = new Set(
tags
.filter((t) => t.tagGroupId && autoIds.has(t.tagGroupId.toString()))
.map((t) => t.id.toString()),
);
return blocked.size ? tagIds.filter((id) => !blocked.has(String(id))) : tagIds;
}
private async ensureTag(id: number) { const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
if (!t) throw new BadRequestException(`Tag ${id} not found`); if (!t) throw new BadRequestException(`Tag ${id} not found`);
} }
@@ -341,4 +690,93 @@ export class GoodsService {
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`); if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
} }
} }
private async ensurePosition(id: number) {
const position = await this.prisma.position.findUnique({
where: { id: BigInt(id) },
});
if (!position) throw new BadRequestException(`Position ${id} not found`);
}
private decimal(value: string | null | undefined): Prisma.Decimal | null {
return value === undefined || value === null || value === ''
? null
: new Prisma.Decimal(value);
}
private customDetailData(
detail: CustomGoodDetailDto,
preserveMissing = false,
): Prisma.OriginGoodDetailUncheckedUpdateInput {
const nullable = <T>(value: T | null | undefined): T | null | undefined =>
preserveMissing && value === undefined ? undefined : value ?? null;
const decimal = (value: string | null | undefined) =>
preserveMissing && value === undefined ? undefined : this.decimal(value);
const json = (
value: Record<string, unknown> | null | undefined,
): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined =>
preserveMissing && value === undefined
? undefined
: value === null || value === undefined
? Prisma.DbNull
: (value as Prisma.InputJsonValue);
return {
productCode: nullable(detail.productCode),
englishName: nullable(detail.englishName),
blankDesignUrl: nullable(detail.blankDesignUrl),
detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl),
textureName: nullable(detail.textureName),
productionCycleHours: nullable(detail.productionCycleHours),
minWeightG: decimal(detail.minWeightG),
reminder: nullable(detail.reminder),
productionProcess: nullable(detail.productionProcess),
materialDescription: nullable(detail.materialDescription),
productPerformance: nullable(detail.productPerformance),
applicableScenarios: nullable(detail.applicableScenarios),
washingInstructions: nullable(detail.washingInstructions),
specialDescription: nullable(detail.specialDescription),
designExplanation: nullable(detail.designExplanation),
designArea: nullable(detail.designArea),
pictureRequest: nullable(detail.pictureRequest),
sizeChart: json(detail.sizeChart),
packageSpecs: json(detail.packageSpecs),
options: json(detail.options),
media: json(detail.media),
};
}
private async replaceCustomVariants(
tx: Prisma.TransactionClient,
originGoodId: bigint,
variants: CustomGoodVariantDto[],
): Promise<void> {
await tx.originGoodVariant.deleteMany({ where: { originGoodId } });
for (const variant of variants) {
await tx.originGoodVariant.create({
data: {
originGoodId,
sdsVariantId: `custom-${randomUUID()}`,
sku: variant.sku,
sizeId: variant.sizeId ?? null,
sizeName: variant.sizeName ?? null,
colorId: variant.colorId ?? null,
colorName: variant.colorName ?? null,
colorHex: variant.colorHex ?? null,
imageUrl: variant.imageUrl ?? null,
price: this.decimal(variant.price),
originalPrice: this.decimal(variant.originalPrice),
weightG: this.decimal(variant.weightG),
boxLengthCm: this.decimal(variant.boxLengthCm),
boxWidthCm: this.decimal(variant.boxWidthCm),
boxHeightCm: this.decimal(variant.boxHeightCm),
enabled: variant.enabled ?? true,
sortOrder: variant.sortOrder ?? 0,
designData:
variant.designData === null || variant.designData === undefined
? Prisma.DbNull
: (variant.designData as Prisma.InputJsonValue),
},
});
}
}
} }
+48 -8
View File
@@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import * as cookieParserModule from 'cookie-parser';
import { json } from 'express'; import { json } from 'express';
import { join } from 'path'; import { join } from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
@@ -28,15 +30,51 @@ async function bootstrap() {
}), }),
); );
// CORS // Security headers (X-Content-Type-Options, X-Frame-Options, HSTS, ...)
app.enableCors({ // Static assets (/uploads, /assets) are embedded cross-origin by other
origin: true, // sites, so CORP must allow cross-origin reads. CSP 去掉默认的
credentials: true, // upgrade-insecure-requestsSwagger UI 经纯 http 访问时该指令会把
}); // 自身子资源升级为 https,导致 ERR_SSL_PROTOCOL_ERROR。
app.use(
helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
contentSecurityPolicy: {
useDefaults: true,
directives: {
'upgrade-insecure-requests': null,
},
},
}),
);
// Serve uploaded files // Parse auth cookies (HttpOnly access/refresh tokens). Resolve both the
// namespace and its `default` interop shape so it works regardless of
// the compiled module interop mode.
const cookieParser = (
cookieParserModule as unknown as {
default?: typeof cookieParserModule;
}
).default ?? cookieParserModule;
app.use(cookieParser());
// CORS: only origins listed in CORS_ORIGINS (comma-separated) are
// allowed. Credentials are enabled because the session lives in
// HttpOnly cookies. "*" disables the allowlist and reflects any origin
// (reflected origins are required when credentials are enabled).
const corsOrigins = (process.env.CORS_ORIGINS ?? '')
.split(',')
.map((o) => o.trim())
.filter(Boolean);
const origin = corsOrigins.includes('*') ? true : corsOrigins;
app.enableCors(corsOrigins.length > 0 ? { origin, credentials: true } : undefined);
// Serve uploaded files. nosniff prevents browsers from sniffing a
// non-image content type out of an uploaded file.
app.useStaticAssets(join(process.cwd(), 'uploads'), { app.useStaticAssets(join(process.cwd(), 'uploads'), {
prefix: '/uploads/', prefix: '/uploads/',
setHeaders: (res) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}); });
app.useStaticAssets(join(process.cwd(), 'public'), { app.useStaticAssets(join(process.cwd(), 'public'), {
prefix: '/assets/', prefix: '/assets/',
@@ -55,7 +93,9 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalInterceptors(new TransformInterceptor());
// Swagger // Swagger is only exposed outside production to avoid leaking the
// full admin API surface.
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('InkReach Product Center API') .setTitle('InkReach Product Center API')
.setDescription('Backend API for InkReach Product Center') .setDescription('Backend API for InkReach Product Center')
@@ -65,11 +105,11 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
}
const port = process.env.PORT ?? 3001; const port = process.env.PORT ?? 3001;
await app.listen(port, '0.0.0.0'); await app.listen(port, '0.0.0.0');
console.log(`🚀 Application is running on: http://0.0.0.0:${port}`); console.log(`🚀 Application is running on: http://0.0.0.0:${port}`);
console.log(`📚 Swagger documentation: http://0.0.0.0:${port}/api/docs`);
} }
// Make JSON.stringify aware of BigInt so outgoing responses containing // Make JSON.stringify aware of BigInt so outgoing responses containing
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsNumber,
} from 'class-validator';
export class UpdateOriginGoodTagsDto {
@ApiProperty({ description: '链接标签 id 全量集合(人工接管)', type: [Number] })
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(50)
@IsNumber({}, { each: true })
@Type(() => Number)
tagIds: number[];
}
@@ -1,8 +1,9 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OriginGoodsService } from './origin-goods.service'; import { OriginGoodsService } from './origin-goods.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto'; import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
import { UpdateOriginGoodTagsDto } from './dto/update-origin-good-tags.dto';
@ApiTags('origin-goods') @ApiTags('origin-goods')
@ApiBearerAuth() @ApiBearerAuth()
@@ -24,4 +25,28 @@ export class OriginGoodsController {
findAll(@Query() query: QueryOriginGoodDto) { findAll(@Query() query: QueryOriginGoodDto) {
return this.service.findAll(query); return this.service.findAll(query);
} }
@Get(':id')
@ApiOperation({ summary: '单链接详情(detail + variants,成员展开面板用)' })
findOne(@Param('id') id: string) {
return this.service.findOne(BigInt(id));
}
@Get(':id/tags')
@ApiOperation({ summary: '链接当前标签(含 manual 标记)' })
getTags(@Param('id') id: string) {
return this.service.getTags(BigInt(id));
}
@Put(':id/tags')
@ApiOperation({ summary: '人工接管链接标签(全量替换,自动同步不再覆盖)' })
updateTags(@Param('id') id: string, @Body() dto: UpdateOriginGoodTagsDto) {
return this.service.updateTags(BigInt(id), dto.tagIds);
}
@Delete(':id/tags')
@ApiOperation({ summary: '恢复自动派生(清掉人工标签)' })
resetTags(@Param('id') id: string) {
return this.service.resetTags(BigInt(id));
}
} }
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { OriginGoodsController } from './origin-goods.controller'; import { OriginGoodsController } from './origin-goods.controller';
import { OriginGoodsService } from './origin-goods.service'; import { OriginGoodsService } from './origin-goods.service';
import { ProductFamiliesModule } from '../product-families/product-families.module';
@Module({ @Module({
imports: [ProductFamiliesModule],
controllers: [OriginGoodsController], controllers: [OriginGoodsController],
providers: [OriginGoodsService], providers: [OriginGoodsService],
}) })

Some files were not shown because too many files have changed in this diff Show More