Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f524298d95 | ||
|
|
e6ee5fd18d | ||
|
|
6f4a5677fd | ||
|
|
3a8e8e8948 | ||
|
|
2d2aeb655a | ||
|
|
027ef2b296 | ||
|
|
9f856858e2 | ||
|
|
ab79325ab0 | ||
|
|
ca38476047 | ||
|
|
44de8e670f | ||
|
|
dd6a540bf3 | ||
|
|
0d2a954153 | ||
|
|
a554b77379 | ||
|
|
5e69397adc | ||
|
|
37353470e7 | ||
|
|
9eafac7204 | ||
|
|
0ffab120e9 | ||
|
|
814f45c5a3 | ||
|
|
39eb06b6a2 | ||
|
|
c3be8ab979 | ||
|
|
ae9901e6cc | ||
|
|
f1e81872e7 | ||
|
|
b81d944c38 | ||
|
|
f09a629311 | ||
|
|
8903afdc82 | ||
|
|
fb85a56444 | ||
|
|
5f81b18388 | ||
|
|
f828cc0bd9 | ||
|
|
a84b3b5daa | ||
|
|
1ffd73a1af | ||
|
|
ae880ff346 | ||
|
|
26a5b08b56 | ||
|
|
63820a3e6f | ||
|
|
e34398a002 | ||
|
|
b32a5575df | ||
|
|
a0ad6da9c7 | ||
|
|
2bec7822f0 | ||
|
|
76eb849b49 | ||
|
|
bea0bf14d8 | ||
|
|
3ccddc5982 | ||
|
|
dc90712c4f | ||
|
|
d01c1e1b97 | ||
|
|
3d431f8375 | ||
|
|
6973f863b5 | ||
|
|
88f60ba2e2 | ||
|
|
01fe444f93 | ||
|
|
318f5c2a64 | ||
|
|
5c5fb88f58 | ||
|
|
007ad2c831 | ||
|
|
60992cb3d9 | ||
|
|
62f968c1c1 | ||
|
|
a5884c638c | ||
|
|
f55e7ba4bb | ||
|
|
674d38a9a7 | ||
|
|
102d61c181 | ||
|
|
12882e7d5a | ||
|
|
a756c8c3c8 | ||
|
|
ab42cb49a3 | ||
|
|
5d089bdb43 | ||
|
|
49d28c8d4f | ||
|
|
ab51052d26 | ||
|
|
7ff12fc69b | ||
|
|
fa4a95af98 | ||
|
|
ff8aad171c | ||
|
|
b6c19ae859 | ||
|
|
93ac525c55 | ||
|
|
976f308e3c | ||
|
|
64a6fa7004 | ||
|
|
b5875d33b2 | ||
|
|
ce283dff78 | ||
|
|
6498a198ef | ||
|
|
7c46f15967 | ||
|
|
d625b40873 | ||
|
|
6f2d836ef5 | ||
|
|
5e8183c220 | ||
|
|
4ddbf486bd | ||
|
|
0e451d07e6 | ||
|
|
aba90cb011 | ||
|
|
fdfe435ecd | ||
|
|
a14b8ecf4a | ||
|
|
1e07c62fc8 | ||
|
|
77f9c05346 | ||
|
|
fe6ab3825d | ||
|
|
8a6db1bdee | ||
|
|
d0102ec4e8 | ||
|
|
63b436fe41 | ||
|
|
5a7a2841de | ||
|
|
8a052773cd | ||
|
|
e5ec022834 | ||
|
|
e018f16d0b | ||
|
|
b055aedd3e | ||
|
|
1ef82c4c14 | ||
|
|
f07e843130 | ||
|
|
6054cb556a | ||
|
|
d4515631b1 | ||
|
|
c7e6c35fbf | ||
|
|
6d9305b878 | ||
|
|
7c85f7fe9f | ||
|
|
f280b1daf5 | ||
|
|
d67858c69b | ||
|
|
c2d50c9fa3 | ||
|
|
9863346c6d | ||
|
|
f28f51155f | ||
|
|
753b554413 | ||
|
|
10197c47a8 | ||
|
|
34d84a7a7c | ||
|
|
6f22a6fd1f | ||
|
|
08201f18a4 | ||
|
|
0a2302c4e2 | ||
|
|
3ddc4c87e8 | ||
|
|
37b52c5ebb | ||
|
|
1128167aef | ||
|
|
ee336968a0 | ||
|
|
bbd925070a | ||
|
|
f5a3b8c840 | ||
|
|
6395d9e6ab | ||
|
|
eedfbb344e | ||
|
|
a13931c84c | ||
|
|
353e51e871 | ||
|
|
7b17fff86f | ||
|
|
f919577ddd | ||
|
|
4eadb67c36 | ||
|
|
14c53504f0 | ||
|
|
6418aa7302 | ||
|
|
b4b9fbbe60 | ||
|
|
ba3c6d2d4f | ||
|
|
d70a03f6fa | ||
|
|
fd7ca5d53f | ||
|
|
07f3134f79 | ||
|
|
dcd2b235b9 | ||
|
|
c492469d70 | ||
|
|
8fad8de73e | ||
|
|
e49eebcedd | ||
|
|
ab450c350e | ||
|
|
f7c4252a91 | ||
|
|
502b7eba6e | ||
|
|
c8531bfe08 | ||
|
|
d63f1a6f35 | ||
|
|
d20a933e34 | ||
|
|
f68898dea4 | ||
|
|
de0f5509b1 | ||
|
|
c07b28def7 | ||
|
|
60946b5f64 | ||
|
|
37b45a4679 | ||
|
|
573549eb15 | ||
|
|
14d77fbab2 | ||
|
|
1186d615e2 | ||
|
|
270e516f94 | ||
|
|
d826c5bdd4 | ||
|
|
457792b729 | ||
|
|
8233dda07a | ||
|
|
cbdb507be9 | ||
|
|
b3bd10de06 | ||
|
|
e7582bbd6e | ||
|
|
4c50189538 | ||
|
|
4bebb9e4b2 | ||
|
|
ac29a0c7bf | ||
|
|
ac3a39f40d | ||
|
|
491e74b905 | ||
|
|
0aa6cbf1d9 | ||
|
|
697b82a64b | ||
|
|
f06dfffbda | ||
|
|
d9ecd04747 | ||
|
|
9c279ae393 | ||
|
|
848eed0b6f | ||
|
|
043d2463a6 | ||
|
|
005ab5b585 | ||
|
|
6c61a4e871 | ||
|
|
be0b90e68f | ||
|
|
755b40aded | ||
|
|
9c1106586a | ||
|
|
9ed569f5bc | ||
|
|
b04623ebdd | ||
|
|
a4151607c5 | ||
|
|
4cc99f3f23 | ||
|
|
8b38d6fef7 | ||
|
|
4963a5c463 | ||
|
|
a43353aa98 | ||
|
|
6e5f7edbb7 | ||
|
|
437d9ca93b | ||
|
|
d375df810d | ||
|
|
7d09077f1d |
@@ -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
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop, main, refactor/v2, feature/gitea-ci-verify-yeuimu]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: online # 使用你创建的 runner 标签
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: inkreach_test
|
||||||
|
# 去掉 ports 映射,避免与宿主机冲突
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://test:test@postgres:5432/inkreach_test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
|
# 测试 Docker 是否可用(验证挂载是否生效)
|
||||||
|
# - name: 测试 Docker 环境
|
||||||
|
# run: docker --version
|
||||||
|
|
||||||
|
- name: 安装依赖
|
||||||
|
run: npm install -g pnpm && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Prisma generate
|
||||||
|
run: pnpm --filter @inkreach/api prisma:generate
|
||||||
|
|
||||||
|
- name: 建表(migrate deploy)
|
||||||
|
run: pnpm --filter @inkreach/api exec prisma migrate deploy
|
||||||
|
|
||||||
|
- name: API 测试(真实数据库集成测试)
|
||||||
|
run: pnpm --filter @inkreach/api test
|
||||||
|
|
||||||
|
- name: Admin 前端测试
|
||||||
|
run: pnpm --filter @inkreach/admin test
|
||||||
@@ -1,45 +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.*.local
|
# Deployment runtime data (certs, ACME state, data dumps)
|
||||||
|
deploy/certbot/
|
||||||
# OS
|
deploy/data-dump.json
|
||||||
.DS_Store
|
uploads/
|
||||||
|
.pnpm-store/
|
||||||
# IDE
|
data-cleaning/
|
||||||
.idea
|
backups/
|
||||||
.vscode/*
|
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
|
|
||||||
# Coverage
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# Nuxt
|
|
||||||
.output
|
|
||||||
.nuxt
|
|
||||||
.nitro
|
|
||||||
.cache
|
|
||||||
|
|||||||
@@ -89,3 +89,31 @@ 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 加载失败;仓库 `.pnpm` store 里生成的 client 是 openssl-1.1.x target,**bookworm 系镜像(node:20-slim)同样跑不了**,用 `node:20-bullseye-slim` 挂仓库根 + `--network host`(连本机一次性测试库)跑 jest/prisma 即可,无需依赖项目运行镜像。
|
||||||
|
- **缓存值里绝不能带请求级切片(分页/字段裁剪)**:把 `items.slice(page…)` 的结果整个塞进缓存后,缓存键不含 page → 所有页码命中同一条目、页页返回第一页(total 对得上更具迷惑性);正确做法是缓存"全量物化结果",切片在缓存命中后按请求执行。TDD 用例必须包含"同键翻页 + 页内容随页码变化"的断言才能抓住这类错。
|
||||||
|
- **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`;schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名。
|
||||||
|
- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。
|
||||||
|
- **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。
|
||||||
|
- **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。
|
||||||
|
- **"物化数组的顺序必须有确定性来源"**:把聚合结果物化进 JSONB(如 price_matrix 的 printCounts/crafts/logistics)时,"Set 去重保首现序"不是顺序保证——源查询没有 orderBy 时 Postgres 不承诺返回序,同一数据两次重算顺序可能漂移。必须:① 聚合后按业务词表序稳定排序(词表外自由文本沉底);② 源查询显式 `orderBy` 固定遍历序。测试用"打乱输入顺序断言输出全等"覆盖顺序确定性。
|
||||||
|
- **"新增维度取值 = 标签行 + 两处词表常量,缺一不可"**:工艺/物流/印花数量这类封闭维度新增可选值时,只往 `tags` 表插行不够——矩阵维度词表 `DIM_VALUES` 和展示序 `OPTION_DISPLAY_ORDER` 是硬编码常量,必须同步追加,否则打了新标签的链接会被词表过滤导致价格格子静默消失(比报错更危险)。同时确认自动派生规则(auto-tag-rules / admin origin-name)是否要认识新值;存量数据零影响的前提是"只放开准入,不主动打标"。
|
||||||
|
- **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态。
|
||||||
|
- **严禁直接热修运行容器内的代码**:本次线上 v2 镜像内被塞过未提交的词表改动(热转印),仓库重建镜像即复发且难排查;所有修复必须落在仓库并重建部署。排查"线上行为与代码不符"时先 `md5sum` 比对容器内源文件与仓库。
|
||||||
|
- **两端"同构"解析器必须连测试用例也同构**:admin 与 api 各有一份链接名解析器,本次展示 bug(`品名(DTG180) SKU` 显示成光款号)正是两端取括号策略不一致(api 首括号对 vs admin 末括号)所致。改任何一端解析行为时,另一端同样输入的用例必须同步补上(origin-name.spec ↔ origin-name.parser.spec/goods.service.spec)。
|
||||||
|
- **共享开发库不可达时用一次性 docker postgres 跑集成测试**:`docker run -d --name inkreach-test-pg -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=inkreach_test -p 127.0.0.1:54329:5432 postgres:16-alpine` → `DATABASE_URL=… npx prisma migrate deploy` → jest/vitest 指向该库;用完 `docker rm -f`。夹具自包含的套件在新库上直接绿。
|
||||||
|
- **宿主机即部署机时,改生产数据前先 `docker inspect` 拿容器真实注入的环境变量**:deploy/.env 里的密码可能与运行容器不一致(v2 栈独立 env);对两个栈的库做数据修复时,dry-run 清单必须逐栈分别核对后再 apply。
|
||||||
|
- **改"镜像字段"的数据前先查覆盖路径**:分类名每小时被 `syncCategories` 用 SDS 原名无条件覆盖——直接改库/界面改名都会被下一轮同步冲回。此类字段的清洗必须写进同步入库路径(如 `cleanCategoryDisplayName`),存量靠回填脚本;数据先改而镜像未重部署的窗口期内会被冲回一次,重部署后自动恢复。
|
||||||
|
- **合并分叉血统时警惕"文本无冲突、行为有冲突"**:git 自动合并可能把两侧对同一机制的不同设计(如 热转印=烫画别名 vs 一等公民词表)拼进一个文件,编译通过但行为矛盾——合并后必须跑两侧血统各自的测试才能暴露;行级冲突反而不是最危险的。另:`git status | head` 截断会漏看冲突文件,连续 cherry-pick 后要全仓 `grep '<<<<<<<'` 扫一遍。
|
||||||
|
- **生产栈收敛三件套**:动栈结构前先 `pg_dump -Fc` 双库 + 数据卷归档 + 配置快照落 `deploy/backups/<时间戳>/` 并写 RESTORE.md;切换用"外部卷 external 引用 + down 不带 -v + 切换前后行数 diff"保证数据零丢失;先 `docker compose build` 预构建再 down/up,把停机窗口压到秒级。
|
||||||
|
- **矩阵维度占位不得借用业务词表既有值**:曾让「不打印」链接的印花数量占位为「单面印花」,导致顾客端出现「不打印+单面印花」的自相矛盾组合(不打印根本无印花面)。2026-09-03 数据收口:光板/不打印链接不入族(`family_id=NULL`),矩阵不计算不打印工艺。**回流风险**:`organize`/`autoGroup` 会把散链接按分类重新归族、人工改链接标签会触发 `attachToMatchingFamily` 回挂——跑这两类操作前必须先排查光板链接(清单与回滚见 `deploy/backups/20260903-noprint-removal/`)。
|
||||||
|
- **重部署不会自愈物化 JSON**:`price_matrix` 是物化列,代码修好/词表修正后已物化的矩阵**不会自动重算**(重算只在归族/成员变化/详情同步/标签调整等事件时触发)——族 148 曾因旧容器物化的坏矩阵跨镜像存活,"代码明明对了线上却没变"。此类修复收尾必须显式触发受影响族重算(可容器内 `node -e` 直调 `FamilyRecomputeService.recomputeFamily`,用已部署代码、免重建免鉴权)。
|
||||||
|
- **批量数据修复前"全量 dump + 受影响行快照 + RESTORE.md"三件是底线**:全量 `pg_dump -Fc` 兜灾难恢复,受影响行 CSV(含改前外键值)支持精准回滚,RESTORE.md 写清逐表恢复命令与重算步骤;事务内逐步核对影响行数与预期一致再 COMMIT。
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ PORT=3001
|
|||||||
cd apps/api
|
cd apps/api
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
pnpm prisma:migrate
|
pnpm prisma:migrate
|
||||||
pnpm start:dev
|
pnpm --filter @inkreach/api dev
|
||||||
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
||||||
|
|
||||||
# Terminal 2:官网
|
# Terminal 2:官网
|
||||||
@@ -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 # 开发
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type {
|
import type {
|
||||||
Good,
|
Good,
|
||||||
|
GoodDetail,
|
||||||
CreateGoodRequest,
|
CreateGoodRequest,
|
||||||
UpdateGoodRequest,
|
UpdateGoodRequest,
|
||||||
BatchCreateGoodsRequest,
|
BatchCreateGoodsRequest,
|
||||||
BatchPriorityRequest,
|
BatchPriorityRequest,
|
||||||
GoodsFilter,
|
GoodsFilter,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
|
CreateCustomGoodRequest,
|
||||||
|
UpdateCustomGoodContentRequest,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const goodsApi = {
|
export const goodsApi = {
|
||||||
@@ -17,7 +20,7 @@ export const goodsApi = {
|
|||||||
|
|
||||||
// Get good by id
|
// Get good by id
|
||||||
getGoodById: (id: string) => {
|
getGoodById: (id: string) => {
|
||||||
return request.get<any, Good>(`/goods/${id}`)
|
return request.get<any, GoodDetail>(`/goods/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create good
|
// Create good
|
||||||
@@ -25,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)
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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
|
||||||
|
familiesMerged: number
|
||||||
|
familiesSkipped: number
|
||||||
|
fragmentsConsolidated: number
|
||||||
|
fragmentsManualReview: 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 },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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,39 @@ 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 {
|
||||||
|
// 规范拼接:VITE_API_BASE 可能带尾斜杠(如 /v2-api/),直接相加会产生
|
||||||
|
// /v2-api//auth/refresh 双斜杠导致 404
|
||||||
|
const base = (import.meta.env.VITE_API_BASE || '/api').replace(/\/+$/, '')
|
||||||
|
await axios.post(
|
||||||
|
`${base}/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:
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ export const syncApi = {
|
|||||||
return request.post<any, { message: string }>('/sync/categories')
|
return request.post<any, { message: string }>('/sync/categories')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
syncProductDetails: () => {
|
||||||
|
return request.post<any, { message: string }>('/sync/product-details')
|
||||||
|
},
|
||||||
|
|
||||||
|
syncOneProductDetail: (goodId: string) => {
|
||||||
|
return request.post<any, { goodId: string; variants: number; detailSyncedAt: string }>(
|
||||||
|
`/sync/products/${goodId}/detail`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
getSyncStatus: (limit?: number) => {
|
getSyncStatus: (limit?: number) => {
|
||||||
return request.get<any, SyncLog[]>('/sync/status', {
|
return request.get<any, SyncLog[]>('/sync/status', {
|
||||||
params: limit ? { limit } : undefined,
|
params: limit ? { limit } : undefined,
|
||||||
|
|||||||
@@ -11,14 +11,18 @@ 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']
|
||||||
|
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
@@ -31,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']
|
||||||
@@ -40,6 +45,9 @@ 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']
|
||||||
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -42,6 +42,89 @@ 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 {
|
||||||
|
productCode?: string | null
|
||||||
|
englishName?: string | null
|
||||||
|
productionCycleHours?: number | null
|
||||||
|
minWeightG?: string | null
|
||||||
|
productionProcess?: string | null
|
||||||
|
materialDescription?: string | null
|
||||||
|
sizeChart?: { columns?: Array<{ key: string; name: string }>; rows?: any[] } | null
|
||||||
|
packageSpecs?: { rows?: any[] } | null
|
||||||
|
syncedAt?: string | null
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OriginGoodVariant {
|
||||||
|
sdsVariantId: string
|
||||||
|
sku: string
|
||||||
|
sizeName?: string | null
|
||||||
|
colorName?: string | null
|
||||||
|
colorHex?: string | null
|
||||||
|
price?: string | null
|
||||||
|
enabled: boolean
|
||||||
|
[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 {
|
||||||
|
originDetail: OriginGoodDetail | null
|
||||||
|
variants: OriginGoodVariant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateGoodRequest {
|
export interface CreateGoodRequest {
|
||||||
@@ -53,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 {
|
||||||
@@ -64,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 {
|
||||||
@@ -242,10 +348,19 @@ 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
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
hasDetail?: boolean
|
||||||
|
detailSyncedAt?: string | null
|
||||||
|
variantCount?: number
|
||||||
|
sizeRowCount?: number
|
||||||
|
packageRowCount?: number
|
||||||
|
productCode?: string | null
|
||||||
|
family?: { familyId: string; familyCode: string | null; familyName: string; memberCount?: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Origin Goods Tree types
|
// Origin Goods Tree types
|
||||||
@@ -259,6 +374,15 @@ export interface OriginGoodsTreeNode {
|
|||||||
configuredCount: number
|
configuredCount: number
|
||||||
configuredCountries: string[]
|
configuredCountries: string[]
|
||||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
||||||
|
hasDetail: boolean
|
||||||
|
detailSyncedAt: string | null
|
||||||
|
variantCount: number
|
||||||
|
sizeRowCount: number
|
||||||
|
packageRowCount: number
|
||||||
|
familyId: string | null
|
||||||
|
familyName: string | null
|
||||||
|
familyCode: string | null
|
||||||
|
familyStale: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OriginGoodsTreeCategoryNode {
|
export interface OriginGoodsTreeCategoryNode {
|
||||||
@@ -277,16 +401,119 @@ 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[]
|
||||||
|
/** 人工勾选缺定价维度时按链接名自动补齐的标签名(缺啥补啥) */
|
||||||
|
filledDimensionTags?: string[]
|
||||||
|
/** 保存时链接无族且自动并入成功的目标族 id */
|
||||||
|
attachedFamilyId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
type: 'CATEGORY' | 'PRODUCT'
|
type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS'
|
||||||
status: 'SUCCESS' | 'FAILED'
|
status: 'RUNNING' | 'SUCCESS' | 'FAILED'
|
||||||
message?: string
|
message: string | null
|
||||||
startTime: string
|
startedAt: string
|
||||||
endTime?: string
|
finishedAt: string | null
|
||||||
errorCount?: number
|
|
||||||
createdAt: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter types
|
// Filter types
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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 为空 → 回退族语义键(国家+品名+SKU,物流/工艺不分族)', () => {
|
||||||
|
const a = node(null, '美国(包邮)T恤-DG001-单面印花')
|
||||||
|
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG001-单面印花-某仓'))).toBe(true)
|
||||||
|
expect(sameFamily(a, node('12', '美国(包邮)T恤-DG001-单面印花'))).toBe(true)
|
||||||
|
// 物流/工艺是矩阵维度而非分族维度:包邮 vs 不包邮、单面 vs 双面 同款同族
|
||||||
|
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-单面印花'))).toBe(true)
|
||||||
|
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-双面印花-美西一仓'))).toBe(true)
|
||||||
|
// 不同款号不同族
|
||||||
|
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG002-单面印花'))).toBe(false)
|
||||||
|
// 不同国家不同族
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { familyNameKey } from './origin-name'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两个原产品节点是否属于同一产品族:
|
||||||
|
* 优先用服务端返回的 familyId 精确匹配;无族归属时回退族语义键
|
||||||
|
* (国家+品名+SKU —— 物流/工艺是矩阵维度不分族;老数据/未回填场景兜底)。
|
||||||
|
*/
|
||||||
|
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 familyNameKey(a.goodName) !== '' && familyNameKey(a.goodName) === familyNameKey(b.goodName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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('已规范化的「品名(型号限定词) SKU」防误解析 + 限定词剥离', () => {
|
||||||
|
it('品名内含(限定词)的已规范化名不再解析(否则展示会变成光款号)', () => {
|
||||||
|
expect(parseLinkName('童装纯色插肩短袖T恤(DTG180) PLTK016')).toBeNull();
|
||||||
|
expect(parseLinkName('牛奶丝T恤(女款) DG601')).toBeNull();
|
||||||
|
expect(parseLinkName('180G纯棉T恤 (JSA002) DG001')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanLinkName 对已规范化名剥离 ASCII 型号限定词', () => {
|
||||||
|
expect(cleanLinkName('童装纯色插肩短袖T恤(DTG180) PLTK016')).toBe(
|
||||||
|
'童装纯色插肩短袖T恤 PLTK016',
|
||||||
|
);
|
||||||
|
expect(cleanLinkName('180G纯棉T恤 (JSA002) DG001')).toBe('180G纯棉T恤 DG001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('原始链接名品名内含限定词时,解析后一并剥离', () => {
|
||||||
|
expect(
|
||||||
|
cleanLinkName('波兰(包邮)童装纯色插肩短袖T恤(DTG180)-PLTK016-双面印花'),
|
||||||
|
).toBe('童装纯色插肩短袖T恤 PLTK016');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('中文括号内容(如(女款))不剥离', () => {
|
||||||
|
expect(cleanLinkName('牛奶丝T恤(女款) DG601')).toBe('牛奶丝T恤(女款) DG601');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 SKU 段的单段链接名仍按首括号对解析(与 api 端一致)', () => {
|
||||||
|
expect(parseLinkName('波兰(包邮)童装T恤')).toEqual({
|
||||||
|
productName: '童装T恤',
|
||||||
|
skuCode: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveLinkTagNames', () => {
|
||||||
|
it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => {
|
||||||
|
expect(deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual([
|
||||||
|
'单面印花',
|
||||||
|
'烫画',
|
||||||
|
'包邮',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* 原产品名匹配规则(见 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 族语义分组键(与 api 端 familyNameKey 同构):第 1 段剥离(物流备注)括号,
|
||||||
|
* 保留 国家+品名+SKU,丢工艺/仓库段 —— 物流/工艺是价格矩阵维度而非分族维度。
|
||||||
|
*/
|
||||||
|
export function familyNameKey(name: string | null | undefined): string {
|
||||||
|
if (!name) return '';
|
||||||
|
const segs = name.split('-').map((s) => s.trim());
|
||||||
|
const head = segs[0] ?? '';
|
||||||
|
if (!head) return '';
|
||||||
|
const normalized = head.replace(/\(/g, '(').replace(/\)/g, ')');
|
||||||
|
const openAt = normalized.indexOf('(');
|
||||||
|
let stripped = normalized;
|
||||||
|
if (openAt >= 0) {
|
||||||
|
const closeAt = normalized.indexOf(')', openAt);
|
||||||
|
if (closeAt > openAt) {
|
||||||
|
stripped = normalized.slice(0, openAt) + normalized.slice(closeAt + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stripped = stripped.trim();
|
||||||
|
const sku = segs[1] ?? '';
|
||||||
|
if (!stripped) return sku || '';
|
||||||
|
return sku ? `${stripped}-${sku}` : stripped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 链接名结构化解析结果:品名 + 型号(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 normalized = head.replace(/\(/g, '(').replace(/\)/g, ')');
|
||||||
|
const openAt = normalized.indexOf('(');
|
||||||
|
const closeAt = openAt >= 0 ? normalized.indexOf(')', openAt) : -1;
|
||||||
|
if (closeAt <= openAt || closeAt === normalized.length - 1) return null;
|
||||||
|
const productName = normalized.slice(closeAt + 1).trim();
|
||||||
|
if (!productName) return null;
|
||||||
|
// 无 SKU 段时仅当括号后的品名含中文才视为链接名 —— 防把已规范化的
|
||||||
|
// 「品名(型号限定词) SKU」(如「童装…(DTG180) PLTK016」)的尾段款号误当品名
|
||||||
|
if (segs.length < 2 && !/[\u4e00-\u9fff]/.test(productName)) return null;
|
||||||
|
const skuCode = segs[1] || null;
|
||||||
|
return { productName, skuCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ASCII 型号限定词括号组(SDS 品名内的供应商/工艺型号,如(DTG180)(JSA002)) */
|
||||||
|
const ASCII_PAREN_GROUP = /(?:(|\()[A-Za-z0-9]+(?:)|\))/g;
|
||||||
|
|
||||||
|
/** 展示名剥离 ASCII 型号限定词;剥空(名称仅剩限定词)时原样返回 */
|
||||||
|
export function stripAsciiParenQualifiers(name: string): string {
|
||||||
|
const stripped = name.replace(ASCII_PAREN_GROUP, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return stripped || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名。品名内的 ASCII 型号限定词一并剥离 */
|
||||||
|
export function cleanLinkName(name: string | null | undefined): string {
|
||||||
|
if (!name) return '';
|
||||||
|
const parsed = parseLinkName(name);
|
||||||
|
const base = parsed
|
||||||
|
? parsed.skuCode
|
||||||
|
? `${parsed.productName} ${parsed.skuCode}`
|
||||||
|
: parsed.productName
|
||||||
|
: name;
|
||||||
|
return stripAsciiParenQualifiers(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,929 @@
|
|||||||
|
<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 type { OriginGoodTagsResult } from '@/types'
|
||||||
|
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 {
|
||||||
|
const res = await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
|
||||||
|
const filled = (res as OriginGoodTagsResult | undefined)?.filledDimensionTags ?? []
|
||||||
|
ElMessage.success(filled.length ? `标签已保存(缺维度已自动补齐:${filled.join('、')})` : '标签已保存')
|
||||||
|
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>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { syncApi } from '@/api/sync'
|
|||||||
const logs = ref<SyncLog[]>([])
|
const logs = ref<SyncLog[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
const currentType = ref<'PRODUCTS' | 'CATEGORIES'>('PRODUCTS')
|
type SyncType = 'PRODUCTS' | 'CATEGORIES' | 'PRODUCT_DETAILS'
|
||||||
|
const currentType = ref<SyncType>('PRODUCTS')
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
@@ -25,7 +26,13 @@ async function refreshLogs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollUntilDone(type: 'PRODUCTS' | 'CATEGORIES') {
|
function syncTypeLabel(type: SyncType): string {
|
||||||
|
if (type === 'CATEGORIES') return '分类'
|
||||||
|
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
|
||||||
|
return '商品列表'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUntilDone(type: SyncType) {
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -37,9 +44,9 @@ async function pollUntilDone(type: 'PRODUCTS' | 'CATEGORIES') {
|
|||||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
syncing.value = false
|
syncing.value = false
|
||||||
if (top.status === 'SUCCESS') {
|
if (top.status === 'SUCCESS') {
|
||||||
ElMessage.success(`${type === 'PRODUCTS' ? '产品' : '分类'}同步完成`)
|
ElMessage.success(`${syncTypeLabel(type)}同步完成`)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(`${type === 'PRODUCTS' ? '产品' : '分类'}同步失败`)
|
ElMessage.error(`${syncTypeLabel(type)}同步失败`)
|
||||||
}
|
}
|
||||||
await refreshLogs()
|
await refreshLogs()
|
||||||
}
|
}
|
||||||
@@ -55,11 +62,15 @@ async function handleSyncCategories() {
|
|||||||
await doSync('CATEGORIES')
|
await doSync('CATEGORIES')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
|
async function handleSyncProductDetails() {
|
||||||
const label = type === 'PRODUCTS' ? '产品' : '分类'
|
await doSync('PRODUCT_DETAILS')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSync(type: SyncType) {
|
||||||
|
const label = syncTypeLabel(type)
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定立即执行${label}同步吗?${type === 'PRODUCTS' ? '此操作可能需要几分钟。' : ''}`,
|
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
||||||
'确认',
|
'确认',
|
||||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
@@ -70,6 +81,8 @@ async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
|
|||||||
try {
|
try {
|
||||||
if (type === 'PRODUCTS') {
|
if (type === 'PRODUCTS') {
|
||||||
await syncApi.syncProducts()
|
await syncApi.syncProducts()
|
||||||
|
} else if (type === 'PRODUCT_DETAILS') {
|
||||||
|
await syncApi.syncProductDetails()
|
||||||
} else {
|
} else {
|
||||||
await syncApi.syncCategories()
|
await syncApi.syncCategories()
|
||||||
}
|
}
|
||||||
@@ -97,11 +110,32 @@ function formatDuration(start?: string, end?: string): string | null {
|
|||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
const total = logs.value.length
|
const total = logs.value.length
|
||||||
const success = logs.value.filter(l => l.status === 'SUCCESS').length
|
const success = logs.value.filter(l => l.status === 'SUCCESS').length
|
||||||
const failed = total - success
|
const failed = logs.value.filter(l => l.status === 'FAILED').length
|
||||||
const lastLog = logs.value[0]
|
const lastLog = logs.value[0]
|
||||||
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)
|
||||||
@@ -123,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">
|
||||||
@@ -145,6 +179,16 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
同步产品
|
同步产品
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
size="large"
|
||||||
|
:loading="syncing && currentType === 'PRODUCT_DETAILS'"
|
||||||
|
:disabled="syncing"
|
||||||
|
:icon="Refresh"
|
||||||
|
@click="handleSyncProductDetails"
|
||||||
|
>
|
||||||
|
同步全部原产品详情
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,7 +210,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-divider" />
|
<div class="stat-divider" />
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startTime) : '-' }}</span>
|
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startedAt) : '-' }}</span>
|
||||||
<span class="stat-label">最近同步</span>
|
<span class="stat-label">最近同步</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,17 +241,17 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="timeline-content">
|
<div class="timeline-content">
|
||||||
<div class="timeline-header">
|
<div class="timeline-header">
|
||||||
<span class="timeline-type">{{ log.type === 'PRODUCTS' ? '产品' : '分类' }}</span>
|
<span class="timeline-type">{{ syncTypeLabel(log.type) }}</span>
|
||||||
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||||
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="formatDuration(log.startTime, log.endTime)" class="timeline-duration">
|
<span v-if="formatDuration(log.startedAt, log.finishedAt || undefined)" class="timeline-duration">
|
||||||
<el-icon :size="11"><Clock /></el-icon>
|
<el-icon :size="11"><Clock /></el-icon>
|
||||||
{{ formatDuration(log.startTime, log.endTime) }}
|
{{ 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.startTime) }}</span>
|
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,18 +28,25 @@ 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://localhost:3001',
|
target: 'http://127.0.0.1:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
},
|
},
|
||||||
'/uploads': {
|
'/uploads': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://127.0.0.1:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/assets': {
|
'/assets': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://127.0.0.1:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,7 +21,14 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"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",
|
||||||
|
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
|
||||||
|
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
|
||||||
|
"fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts",
|
||||||
|
"fix:category-names": "ts-node prisma/fix-category-parens.ts",
|
||||||
|
"organize": "ts-node prisma/backfill-product-families.ts",
|
||||||
|
"recompute:families": "ts-node prisma/recompute-all-families.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^3.0.1",
|
"@nestjs/axios": "^3.0.1",
|
||||||
@@ -33,13 +40,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",
|
||||||
@@ -51,6 +61,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,36 @@
|
|||||||
|
/**
|
||||||
|
* 整理脚本(幂等 / 显式人工触发)——解析去运行时化后的唯一解析入口:
|
||||||
|
* 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 { PublicCacheService } from '../src/public/public-cache.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, new PublicCacheService());
|
||||||
|
const families = new ProductFamiliesService(prisma, recompute);
|
||||||
|
const organize = new OrganizeService(prisma, recompute, families, new PublicCacheService());
|
||||||
|
|
||||||
|
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,51 @@
|
|||||||
|
/**
|
||||||
|
* 存量修复脚本(幂等):分类名去括号 —— 删除所有括号段(型号限定词/内容性括号,
|
||||||
|
* 整段删除)。分类名每小时被 SDS 同步覆盖,清洗已落在 syncCategories 入库路径;
|
||||||
|
* 本脚本用于部署后立即生效(不等下一轮同步)或修人工建的历史分类。
|
||||||
|
*
|
||||||
|
* 运行:
|
||||||
|
* pnpm --filter @inkreach/api fix:category-names # dry-run,只打印清单
|
||||||
|
* pnpm --filter @inkreach/api fix:category-names -- --apply # 实际写库
|
||||||
|
*/
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { cleanCategoryDisplayName } from '../src/sync/sync.service';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const prisma = new PrismaService();
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
|
||||||
|
const rows = await prisma.category.findMany({
|
||||||
|
select: { id: true, categoryName: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
const renames = rows
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
from: row.categoryName,
|
||||||
|
to: cleanCategoryDisplayName(row.categoryName) ?? row.categoryName,
|
||||||
|
}))
|
||||||
|
.filter((r) => r.from !== r.to);
|
||||||
|
|
||||||
|
if (apply && renames.length > 0) {
|
||||||
|
await prisma.$transaction(
|
||||||
|
renames.map((r) =>
|
||||||
|
prisma.category.update({
|
||||||
|
where: { id: r.id },
|
||||||
|
data: { categoryName: r.to },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mode = apply ? 'APPLIED' : 'DRY-RUN(未写库,加 --apply 执行)';
|
||||||
|
console.log(`[fix:category-names] ${mode} renamed=${renames.length}`);
|
||||||
|
for (const r of renames) {
|
||||||
|
console.log(` #${r.id} ${r.from} -> ${r.to}`);
|
||||||
|
}
|
||||||
|
await prisma.onModuleDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 存量修复脚本(幂等):把纯款号商品名(如 `PLTK016`)按主链接 SDS 分类名
|
||||||
|
* 补成「描述名 款号」(如 `童装纯色插肩短袖T恤 PLTK016`)。
|
||||||
|
*
|
||||||
|
* 运行:
|
||||||
|
* pnpm --filter @inkreach/api fix:good-names # dry-run,只打印清单
|
||||||
|
* pnpm --filter @inkreach/api fix:good-names -- --apply # 实际写库
|
||||||
|
* origin_goods.good_name 为 SDS 纯镜像(每小时同步覆盖),本脚本只改 goods.good_name。
|
||||||
|
*/
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../src/public/public-cache.service';
|
||||||
|
import { SyncService } from '../src/sync/sync.service';
|
||||||
|
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
||||||
|
import { GoodsService } from '../src/goods/goods.service';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dryRun = !process.argv.includes('--apply');
|
||||||
|
const prisma = new PrismaService();
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
const recompute = new FamilyRecomputeService(prisma, new PublicCacheService());
|
||||||
|
// 回填路径不触发详情同步,SyncService 仅作占位依赖
|
||||||
|
const goods = new GoodsService(
|
||||||
|
prisma,
|
||||||
|
{ queueProductDetailSync: async () => undefined } as unknown as SyncService,
|
||||||
|
recompute,
|
||||||
|
new PublicCacheService(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await goods.backfillPureSkuGoodNames({ dryRun });
|
||||||
|
const mode = dryRun ? 'DRY-RUN(未写库,加 --apply 执行)' : 'APPLIED';
|
||||||
|
console.log(`[fix:good-names] ${mode} renamed=${result.renamed} unmapped=${result.unmapped.length}`);
|
||||||
|
for (const r of result.renames) {
|
||||||
|
console.log(` #${r.id} ${r.from} -> ${r.to}`);
|
||||||
|
}
|
||||||
|
for (const u of result.unmapped) {
|
||||||
|
console.log(` #${u.id} ${u.from} -> (无分类映射,保留原名)`);
|
||||||
|
}
|
||||||
|
await prisma.onModuleDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { SyncService } from '../src/sync/sync.service';
|
||||||
|
import { SdsProductDetail } from '../src/sync/sds-client.service';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const inputPath = process.argv[2];
|
||||||
|
if (!inputPath) {
|
||||||
|
throw new Error('Usage: pnpm --filter @inkreach/api import:product-detail -- <product_detail.txt>');
|
||||||
|
}
|
||||||
|
const absolutePath = resolve(inputPath);
|
||||||
|
const raw = await readFile(absolutePath, 'utf8');
|
||||||
|
const jsonStart = raw.indexOf('{');
|
||||||
|
if (jsonStart < 0) throw new Error('No JSON object found in product detail file');
|
||||||
|
const detail = JSON.parse(raw.slice(jsonStart)) as SdsProductDetail;
|
||||||
|
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
|
||||||
|
try {
|
||||||
|
const result = await app.get(SyncService).importProductDetail(detail);
|
||||||
|
process.stdout.write(
|
||||||
|
`Imported SDS product ${result.goodId}: ${result.variants} variants, ` +
|
||||||
|
`${result.sizeRows} size rows, ${result.packageRows} package rows, ` +
|
||||||
|
`${result.configuredGoods} configured goods\n`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- Cache SDS product details separately from website merchandising configuration.
|
||||||
|
CREATE TABLE "origin_good_details" (
|
||||||
|
"origin_good_id" BIGINT NOT NULL,
|
||||||
|
"product_code" TEXT,
|
||||||
|
"english_name" TEXT,
|
||||||
|
"blank_design_url" TEXT,
|
||||||
|
"details_page_video_url" TEXT,
|
||||||
|
"texture_name" TEXT,
|
||||||
|
"production_cycle_hours" INTEGER,
|
||||||
|
"min_weight_g" DECIMAL(12,3),
|
||||||
|
"reminder" TEXT,
|
||||||
|
"production_process" TEXT,
|
||||||
|
"material_description" TEXT,
|
||||||
|
"product_performance" TEXT,
|
||||||
|
"applicable_scenarios" TEXT,
|
||||||
|
"washing_instructions" TEXT,
|
||||||
|
"special_description" TEXT,
|
||||||
|
"design_explanation" TEXT,
|
||||||
|
"design_area" TEXT,
|
||||||
|
"picture_request" TEXT,
|
||||||
|
"size_chart" JSONB,
|
||||||
|
"package_specs" JSONB,
|
||||||
|
"options" JSONB,
|
||||||
|
"media" JSONB,
|
||||||
|
"upstream_updated_at" TIMESTAMPTZ(6),
|
||||||
|
"synced_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "origin_good_details_pkey" PRIMARY KEY ("origin_good_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "origin_good_variants" (
|
||||||
|
"origin_good_variant_id" BIGSERIAL NOT NULL,
|
||||||
|
"origin_good_id" BIGINT NOT NULL,
|
||||||
|
"sds_variant_id" TEXT NOT NULL,
|
||||||
|
"sku" TEXT NOT NULL,
|
||||||
|
"size_id" TEXT,
|
||||||
|
"size_name" TEXT,
|
||||||
|
"color_id" TEXT,
|
||||||
|
"color_name" TEXT,
|
||||||
|
"color_hex" TEXT,
|
||||||
|
"image_url" TEXT,
|
||||||
|
"price" DECIMAL(12,2),
|
||||||
|
"original_price" DECIMAL(12,2),
|
||||||
|
"weight_g" DECIMAL(12,3),
|
||||||
|
"box_length_cm" DECIMAL(12,3),
|
||||||
|
"box_width_cm" DECIMAL(12,3),
|
||||||
|
"box_height_cm" DECIMAL(12,3),
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"design_data" JSONB,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "origin_good_variants_pkey" PRIMARY KEY ("origin_good_variant_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "origin_good_variants_origin_good_id_sds_variant_id_key"
|
||||||
|
ON "origin_good_variants"("origin_good_id", "sds_variant_id");
|
||||||
|
CREATE INDEX "origin_good_variants_origin_good_id_sort_order_idx"
|
||||||
|
ON "origin_good_variants"("origin_good_id", "sort_order");
|
||||||
|
CREATE INDEX "origin_good_variants_sku_idx" ON "origin_good_variants"("sku");
|
||||||
|
|
||||||
|
ALTER TABLE "origin_good_details"
|
||||||
|
ADD CONSTRAINT "origin_good_details_origin_good_id_fkey"
|
||||||
|
FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id")
|
||||||
|
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
ALTER TABLE "origin_good_variants"
|
||||||
|
ADD CONSTRAINT "origin_good_variants_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 @@
|
|||||||
|
ALTER TYPE "SyncType" ADD VALUE IF NOT EXISTS 'PRODUCT_DETAILS';
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 全量族重算(一次性维护脚本):
|
||||||
|
* 逐族调用 FamilyRecomputeService.recomputeFamily,刷新物化字段
|
||||||
|
* (并集尺码表/包装规则 + 五维价格矩阵,含选项组词表序排序)。
|
||||||
|
* autoManaged=false 的族内部只置 stale,不会覆盖人工物化字段。
|
||||||
|
*
|
||||||
|
* 运行:pnpm --filter @inkreach/api recompute:families
|
||||||
|
*/
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../src/public/public-cache.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, new PublicCacheService());
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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,25 +29,127 @@ 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?
|
||||||
|
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}) ----------
|
||||||
|
model OriginGoodDetail {
|
||||||
|
originGoodId BigInt @id @map("origin_good_id")
|
||||||
|
productCode String? @map("product_code")
|
||||||
|
englishName String? @map("english_name")
|
||||||
|
blankDesignUrl String? @map("blank_design_url")
|
||||||
|
detailsPageVideoUrl String? @map("details_page_video_url")
|
||||||
|
textureName String? @map("texture_name")
|
||||||
|
productionCycleHours Int? @map("production_cycle_hours")
|
||||||
|
minWeightG Decimal? @map("min_weight_g") @db.Decimal(12, 3)
|
||||||
|
reminder String?
|
||||||
|
productionProcess String? @map("production_process")
|
||||||
|
materialDescription String? @map("material_description")
|
||||||
|
productPerformance String? @map("product_performance")
|
||||||
|
applicableScenarios String? @map("applicable_scenarios")
|
||||||
|
washingInstructions String? @map("washing_instructions")
|
||||||
|
specialDescription String? @map("special_description")
|
||||||
|
designExplanation String? @map("design_explanation")
|
||||||
|
designArea String? @map("design_area")
|
||||||
|
pictureRequest String? @map("picture_request")
|
||||||
|
sizeChart Json? @map("size_chart")
|
||||||
|
packageSpecs Json? @map("package_specs")
|
||||||
|
options Json?
|
||||||
|
media Json?
|
||||||
|
upstreamUpdatedAt DateTime? @map("upstream_updated_at") @db.Timestamptz(6)
|
||||||
|
syncedAt DateTime @default(now()) @map("synced_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@map("origin_good_details")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Origin Good Variants (cached SDS child products / SKUs) ----------
|
||||||
|
model OriginGoodVariant {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("origin_good_variant_id")
|
||||||
|
originGoodId BigInt @map("origin_good_id")
|
||||||
|
sdsVariantId String @map("sds_variant_id")
|
||||||
|
sku String
|
||||||
|
sizeId String? @map("size_id")
|
||||||
|
sizeName String? @map("size_name")
|
||||||
|
colorId String? @map("color_id")
|
||||||
|
colorName String? @map("color_name")
|
||||||
|
colorHex String? @map("color_hex")
|
||||||
|
imageUrl String? @map("image_url")
|
||||||
|
price Decimal? @db.Decimal(12, 2)
|
||||||
|
originalPrice Decimal? @map("original_price") @db.Decimal(12, 2)
|
||||||
|
weightG Decimal? @map("weight_g") @db.Decimal(12, 3)
|
||||||
|
boxLengthCm Decimal? @map("box_length_cm") @db.Decimal(12, 3)
|
||||||
|
boxWidthCm Decimal? @map("box_width_cm") @db.Decimal(12, 3)
|
||||||
|
boxHeightCm Decimal? @map("box_height_cm") @db.Decimal(12, 3)
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
designData Json? @map("design_data")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@unique([originGoodId, sdsVariantId])
|
||||||
|
@@index([originGoodId, sortOrder])
|
||||||
|
@@index([sku])
|
||||||
|
@@map("origin_good_variants")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Countries ----------
|
// ---------- Countries ----------
|
||||||
model Country {
|
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")
|
||||||
}
|
}
|
||||||
@@ -53,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)
|
||||||
|
|
||||||
@@ -60,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")
|
||||||
@@ -94,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])
|
||||||
@@ -124,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")
|
||||||
@@ -135,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])
|
||||||
@@ -166,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)
|
||||||
|
|
||||||
@@ -181,6 +370,7 @@ model User {
|
|||||||
enum SyncType {
|
enum SyncType {
|
||||||
CATEGORIES
|
CATEGORIES
|
||||||
PRODUCTS
|
PRODUCTS
|
||||||
|
PRODUCT_DETAILS
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SyncStatus {
|
enum SyncStatus {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.9 KiB |
@@ -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());
|
||||||
@@ -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());
|
||||||
@@ -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());
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
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';
|
||||||
|
import { PublicCacheModule } from './public/public-cache.module';
|
||||||
import { UploadModule } from './upload/upload.module';
|
import { UploadModule } from './upload/upload.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -18,7 +23,17 @@ 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,
|
||||||
|
// public 读路径缓存(分域版本失效,@Global 供各写路径注入 bump 入口)
|
||||||
|
PublicCacheModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CountriesModule,
|
CountriesModule,
|
||||||
CategoriesModule,
|
CategoriesModule,
|
||||||
@@ -26,10 +41,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 {}
|
||||||
@@ -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,103 @@ 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,
|
||||||
|
// 浏览器实际请求路径带代理前缀(/api/auth/*、/v2-api/auth/*),cookie path
|
||||||
|
// 必须用 '/' 才能命中;否则 refresh cookie 永远带不上 → 访问令牌一过期就掉线
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
// 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: '/' });
|
||||||
|
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: '/',
|
||||||
|
});
|
||||||
|
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: '/' });
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -13,7 +14,7 @@ describe('CategoriesService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [CategoriesService, PrismaService],
|
providers: [CategoriesService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(CategoriesService);
|
service = moduleRef.get(CategoriesService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
import { CategoryNodeDto } from './dto/category-node.dto';
|
import { CategoryNodeDto } from './dto/category-node.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoriesService {
|
export class CategoriesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly publicCache: PublicCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<CategoryNodeDto[]> {
|
async findAll(): Promise<CategoryNodeDto[]> {
|
||||||
const all = await this.prisma.category.findMany({
|
const all = await this.prisma.category.findMany({
|
||||||
@@ -37,7 +41,7 @@ export class CategoriesService {
|
|||||||
// Validate the parent exists to produce a clean 404 instead of FK error.
|
// Validate the parent exists to produce a clean 404 instead of FK error.
|
||||||
await this.findOne(BigInt(dto.parentCategoryId));
|
await this.findOne(BigInt(dto.parentCategoryId));
|
||||||
}
|
}
|
||||||
return this.prisma.category.create({
|
const created = await this.prisma.category.create({
|
||||||
data: {
|
data: {
|
||||||
categoryName: dto.categoryName,
|
categoryName: dto.categoryName,
|
||||||
categoryIcon: dto.categoryIcon ?? null,
|
categoryIcon: dto.categoryIcon ?? null,
|
||||||
@@ -47,6 +51,8 @@ export class CategoriesService {
|
|||||||
: BigInt(dto.parentCategoryId),
|
: BigInt(dto.parentCategoryId),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateCategoryDto) {
|
async update(id: bigint, dto: UpdateCategoryDto) {
|
||||||
@@ -66,7 +72,9 @@ export class CategoriesService {
|
|||||||
? { disconnect: true }
|
? { disconnect: true }
|
||||||
: { connect: { id: BigInt(dto.parentCategoryId) } };
|
: { connect: { id: BigInt(dto.parentCategoryId) } };
|
||||||
}
|
}
|
||||||
return this.prisma.category.update({ where: { id }, data });
|
const updated = await this.prisma.category.update({ where: { id }, data });
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: bigint) {
|
async remove(id: bigint) {
|
||||||
@@ -80,7 +88,9 @@ export class CategoriesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await this.prisma.category.delete({ where: { id } });
|
const removed = await this.prisma.category.delete({ where: { id } });
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return removed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.isForeignKeyViolation(err)) {
|
if (this.isForeignKeyViolation(err)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -14,7 +15,7 @@ describe('CountriesService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [CountriesService, PrismaService],
|
providers: [CountriesService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(CountriesService);
|
service = moduleRef.get(CountriesService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
@@ -112,4 +113,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, B(id 序与 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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,15 +6,36 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.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,
|
||||||
|
private readonly publicCache: PublicCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return this.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: bigint) {
|
async findOne(id: bigint) {
|
||||||
@@ -27,12 +48,14 @@ export class CountriesService {
|
|||||||
|
|
||||||
async create(dto: CreateCountryDto) {
|
async create(dto: CreateCountryDto) {
|
||||||
try {
|
try {
|
||||||
return await this.prisma.country.create({
|
const created = await this.prisma.country.create({
|
||||||
data: {
|
data: {
|
||||||
countryName: dto.countryName,
|
countryName: dto.countryName,
|
||||||
countryIcon: dto.countryIcon ?? null,
|
countryIcon: dto.countryIcon ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return created;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -47,13 +70,15 @@ export class CountriesService {
|
|||||||
async update(id: bigint, dto: UpdateCountryDto) {
|
async update(id: bigint, dto: UpdateCountryDto) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
try {
|
try {
|
||||||
return await this.prisma.country.update({
|
const updated = await this.prisma.country.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
countryName: dto.countryName,
|
countryName: dto.countryName,
|
||||||
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return updated;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -68,7 +93,9 @@ export class CountriesService {
|
|||||||
async remove(id: bigint) {
|
async remove(id: bigint) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
try {
|
try {
|
||||||
return await this.prisma.country.delete({ where: { id } });
|
const removed = await this.prisma.country.delete({ where: { id } });
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return removed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.isForeignKeyViolation(err)) {
|
if (this.isForeignKeyViolation(err)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
) {}
|
||||||
@@ -9,11 +9,56 @@ 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?: {
|
||||||
|
productCode: string | null;
|
||||||
|
syncedAt: Date;
|
||||||
|
sizeChart: unknown;
|
||||||
|
packageSpecs: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
variants?: Array<{
|
||||||
|
sdsVariantId: string;
|
||||||
|
sku: string;
|
||||||
|
sizeName: string | null;
|
||||||
|
colorName: string | null;
|
||||||
|
colorHex: string | null;
|
||||||
|
price: unknown;
|
||||||
|
enabled: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
_count?: { variants: number };
|
||||||
|
} | 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 {
|
||||||
@@ -62,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;
|
||||||
|
|
||||||
@@ -69,9 +117,18 @@ 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;
|
||||||
|
hasDetail: boolean;
|
||||||
|
detailSyncedAt: string | null;
|
||||||
|
variantCount: number;
|
||||||
|
sizeRowCount: number;
|
||||||
|
packageRowCount: number;
|
||||||
|
productCode: string | null;
|
||||||
|
family?: { familyId: string; familyCode: string | null; familyName: string; memberCount?: number } | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
static from(
|
static from(
|
||||||
@@ -118,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(),
|
||||||
@@ -130,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:
|
||||||
@@ -137,8 +212,75 @@ export class GoodDto {
|
|||||||
rel.originGood.goodPrice === undefined
|
rel.originGood.goodPrice === undefined
|
||||||
? null
|
? null
|
||||||
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
||||||
|
hasDetail: Boolean(rel.originGood.detail),
|
||||||
|
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
|
||||||
|
variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0,
|
||||||
|
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
|
||||||
|
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static jsonRows(value: unknown): number {
|
||||||
|
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
|
||||||
|
const rows = (value as { rows?: unknown }).rows;
|
||||||
|
return Array.isArray(rows) ? rows.length : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GoodDetailDto extends GoodDto {
|
||||||
|
@ApiProperty({ nullable: true, type: Object })
|
||||||
|
originDetail!: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Array })
|
||||||
|
variants!: Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
||||||
|
const base = GoodDto.from(good, rel);
|
||||||
|
const detail = rel.originGood?.detail;
|
||||||
|
const toAnnotated = (
|
||||||
|
variant: Record<string, unknown>,
|
||||||
|
originGoodId: string,
|
||||||
|
originGoodName: string | null,
|
||||||
|
) => ({
|
||||||
|
...variant,
|
||||||
|
price:
|
||||||
|
variant.price === null || variant.price === undefined
|
||||||
|
? null
|
||||||
|
: (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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||