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
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
.output
|
||||
.nuxt
|
||||
.nitro
|
||||
.data
|
||||
.cache
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
logs
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
|
||||
# Nuxt
|
||||
.output
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.env.*
|
||||
# Deployment runtime data (certs, ACME state, data dumps)
|
||||
deploy/certbot/
|
||||
deploy/data-dump.json
|
||||
uploads/
|
||||
.pnpm-store/
|
||||
data-cleaning/
|
||||
backups/
|
||||
|
||||
@@ -89,3 +89,31 @@ Prisma Postgres 最佳实践: prisma-postgres 技能
|
||||
参考 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。
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
brand
|
||||
|
||||
## Users
|
||||
|
||||
跨境电商、POD 卖家和需要小批量定制履约能力的商家。他们通过官网了解 InkReach 的商品、生产、设计与全球配送能力,并进入 InkPOD 完成实际业务操作。
|
||||
|
||||
## Product Purpose
|
||||
|
||||
官网用于清晰展示 InkReach 的一站式 POD 能力,让访客快速浏览货盘和案例,并以最短路径进入 InkPOD 登录及商品详情页面。
|
||||
|
||||
## Brand Personality
|
||||
|
||||
直接、可靠、务实。页面应保留鲜明的橙色品牌识别,同时让商品和真实业务数据成为视觉重点。
|
||||
|
||||
## Anti-references
|
||||
|
||||
避免与 Figma 设计稿无关的通用 SaaS 模板、纯装饰性渐变、过度卡片化、被拉伸模糊的位图,以及拥挤到妨碍浏览的桌面布局。
|
||||
|
||||
## Design Principles
|
||||
|
||||
- 商品与业务信息优先于装饰。
|
||||
- 关键操作即时、明确,并保持当前页面跳转语义一致。
|
||||
- 宽屏增加呼吸空间,不通过无限放大素材填满画面。
|
||||
- 动效用于展示连续内容,不影响用户主动操作。
|
||||
- 后台配置是商品、国家、品类和标签的唯一数据来源。
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
以 WCAG 2.1 AA 为基准,维持键盘可操作性、足够对比度,并为持续动画提供 `prefers-reduced-motion` 降级。
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
brand
|
||||
|
||||
## Users
|
||||
|
||||
跨境电商、POD 卖家和需要小批量定制履约能力的商家。他们通过官网了解 InkReach 的商品、生产、设计与全球配送能力,并进入 InkPOD 完成实际业务操作。
|
||||
|
||||
## Product Purpose
|
||||
|
||||
官网用于清晰展示 InkReach 的一站式 POD 能力,让访客快速浏览货盘和案例,并以最短路径进入 InkPOD 登录及商品详情页面。
|
||||
|
||||
## Brand Personality
|
||||
|
||||
直接、可靠、务实。页面应保留鲜明的橙色品牌识别,同时让商品和真实业务数据成为视觉重点。
|
||||
|
||||
## Anti-references
|
||||
|
||||
避免与 Figma 设计稿无关的通用 SaaS 模板、纯装饰性渐变、过度卡片化、被拉伸模糊的位图,以及拥挤到妨碍浏览的桌面布局。
|
||||
|
||||
## Design Principles
|
||||
|
||||
- 商品与业务信息优先于装饰。
|
||||
- 关键操作即时、明确,并保持当前页面跳转语义一致。
|
||||
- 宽屏增加呼吸空间,不通过无限放大素材填满画面。
|
||||
- 动效用于展示连续内容,不影响用户主动操作。
|
||||
- 后台配置是商品、国家、品类和标签的唯一数据来源。
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
以 WCAG 2.1 AA 为基准,维持键盘可操作性、足够对比度,并为持续动画提供 `prefers-reduced-motion` 降级。
|
||||
|
||||
@@ -1,126 +1,127 @@
|
||||
# Inkreach
|
||||
|
||||
pnpm workspaces + Turborepo monorepo,包含三个协同工作的子项目和共享包:
|
||||
|
||||
| 子项目 | 包名 | 技术栈 | 端口 | 角色 |
|
||||
|--------|------|--------|------|------|
|
||||
| `apps/api` | `@inkreach/api` | NestJS 10 + Prisma 5 + PostgreSQL | 3001 | 后端 API(鉴权 / CRUD / SDS 同步 / 公开 API / Swagger) |
|
||||
| `apps/admin` | `@inkreach/admin` | Vue 3 + Vite + Element Plus + Pinia | 5173 | 后台管理(登录 / 商品 / 品类 / 国家 / 标签 / 坑位 / 同步) |
|
||||
| `apps/website` | `@inkreach/website` | Nuxt 4 + Vue 3 + Tailwind v4 | 3000 | 官方展示站 + 产品中心页 |
|
||||
|
||||
| 共享包 | 说明 |
|
||||
|--------|------|
|
||||
| `packages/tsconfig` | 共享 TypeScript 配置(base / api / vue) |
|
||||
| `packages/shared-types` | 共享类型定义(PaginatedResult, BackendEnvelope, 实体接口) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 环境变量
|
||||
|
||||
在仓库根目录的 `.env` 中配置数据库:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://postgres:<password>@localhost:5432/inkreach-official
|
||||
```
|
||||
|
||||
后端额外需要的变量(在 `apps/api/.env` 中):
|
||||
|
||||
```env
|
||||
JWT_SECRET=<your-secret>
|
||||
SDS_API_BASE=https://api.sds.example.com
|
||||
SDS_API_KEY=<your-key>
|
||||
PORT=3001
|
||||
```
|
||||
|
||||
官网通过 `apps/website/.env` 的 `NUXT_PUBLIC_BACKEND_URL=http://localhost:3001` 指向后端。
|
||||
|
||||
后台无需额外环境变量;Vite 代理已把 `/api/*` 转给 `http://localhost:3001`。
|
||||
|
||||
### 3. 部署
|
||||
|
||||
启动顺序:先启动后端,再启动另两个。
|
||||
|
||||
```bash
|
||||
# Terminal 1:后端(需要 PostgreSQL)
|
||||
cd apps/api
|
||||
pnpm prisma:generate
|
||||
pnpm prisma:migrate
|
||||
pnpm start:dev
|
||||
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
||||
|
||||
# Terminal 2:官网
|
||||
pnpm --filter @inkreach/website dev
|
||||
# → http://localhost:3000
|
||||
|
||||
# Terminal 3:后台
|
||||
pnpm --filter @inkreach/admin dev
|
||||
# → http://localhost:5173
|
||||
```
|
||||
|
||||
或者使用 Turborepo 同时启动所有开发服务器:
|
||||
|
||||
```bash
|
||||
pnpm dev # turbo run dev
|
||||
```
|
||||
|
||||
## 端口分配
|
||||
|
||||
| 端口 | 项目 | 入口 |
|
||||
|------|------|------|
|
||||
| 3001 | NestJS 后端 | `apps/api/src/main.ts` |
|
||||
| 5173 | Vue Admin | `apps/admin/vite.config.ts` |
|
||||
| 3000 | Nuxt 官网 | `apps/website/nuxt.config.ts` |
|
||||
|
||||
## 数据流概览
|
||||
|
||||
```
|
||||
[SDS 外部 API]
|
||||
│ (同步)
|
||||
▼
|
||||
[NestJS 后端 :3001] ── public/goods ──► [Nitro 代理 /api/backend/*] ──► [Nuxt 官网 :3000]
|
||||
│ ▲
|
||||
├── /auth /goods /categories ... ──► │ Vite proxy /api/*
|
||||
│ │
|
||||
[PostgreSQL] [Vue Admin :5173]
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 根目录(使用 pnpm workspace filter)
|
||||
pnpm install # 安装所有依赖
|
||||
pnpm dev # 启动所有 dev 服务器 (turbo)
|
||||
pnpm build # 构建所有项目 (turbo)
|
||||
pnpm format # 格式化所有文件 (prettier)
|
||||
|
||||
# 后端
|
||||
pnpm --filter @inkreach/api start:dev # 开发
|
||||
pnpm --filter @inkreach/api build # 编译
|
||||
pnpm --filter @inkreach/api test # 单元测试
|
||||
pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client
|
||||
pnpm --filter @inkreach/api prisma:migrate # 运行迁移
|
||||
pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio
|
||||
|
||||
# 官网
|
||||
pnpm --filter @inkreach/website dev # 开发
|
||||
pnpm --filter @inkreach/website build # 构建
|
||||
pnpm --filter @inkreach/website generate # 静态站点生成
|
||||
|
||||
# 后台
|
||||
pnpm --filter @inkreach/admin dev # 开发
|
||||
pnpm --filter @inkreach/admin build # 构建
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- 整体目录结构:[`docs/references/structs.md`](docs/references/structs.md)
|
||||
- 数据库表设计:[`docs/dev/database-table-design.md`](docs/dev/database-table-design.md)
|
||||
- 产品中心 PRD:[`docs/dev/product-center-prd.md`](docs/dev/product-center-prd.md)
|
||||
- 官网子项目结构:[`apps/website/docs/references/structs.md`](apps/website/docs/references/structs.md)
|
||||
- 开发规范:[`AGENTS.md`](AGENTS.md)
|
||||
# Inkreach
|
||||
|
||||
pnpm workspaces + Turborepo monorepo,包含三个协同工作的子项目和共享包:
|
||||
|
||||
| 子项目 | 包名 | 技术栈 | 端口 | 角色 |
|
||||
|--------|------|--------|------|------|
|
||||
| `apps/api` | `@inkreach/api` | NestJS 10 + Prisma 5 + PostgreSQL | 3001 | 后端 API(鉴权 / CRUD / SDS 同步 / 公开 API / Swagger) |
|
||||
| `apps/admin` | `@inkreach/admin` | Vue 3 + Vite + Element Plus + Pinia | 5173 | 后台管理(登录 / 商品 / 品类 / 国家 / 标签 / 坑位 / 同步) |
|
||||
| `apps/website` | `@inkreach/website` | Nuxt 4 + Vue 3 + Tailwind v4 | 3000 | 官方展示站 + 产品中心页 |
|
||||
|
||||
| 共享包 | 说明 |
|
||||
|--------|------|
|
||||
| `packages/tsconfig` | 共享 TypeScript 配置(base / api / vue) |
|
||||
| `packages/shared-types` | 共享类型定义(PaginatedResult, BackendEnvelope, 实体接口) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 环境变量
|
||||
|
||||
在仓库根目录的 `.env` 中配置数据库:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://postgres:<password>@localhost:5432/inkreach-official
|
||||
```
|
||||
|
||||
后端额外需要的变量(在 `apps/api/.env` 中):
|
||||
|
||||
```env
|
||||
JWT_SECRET=<your-secret>
|
||||
SDS_API_BASE=https://api.sds.example.com
|
||||
SDS_API_KEY=<your-key>
|
||||
PORT=3001
|
||||
```
|
||||
|
||||
官网通过 `apps/website/.env` 的 `NUXT_PUBLIC_BACKEND_URL=http://localhost:3001` 指向后端。
|
||||
|
||||
后台无需额外环境变量;Vite 代理已把 `/api/*` 转给 `http://localhost:3001`。
|
||||
|
||||
### 3. 部署
|
||||
|
||||
启动顺序:先启动后端,再启动另两个。
|
||||
|
||||
```bash
|
||||
# Terminal 1:后端(需要 PostgreSQL)
|
||||
cd apps/api
|
||||
pnpm prisma:generate
|
||||
pnpm prisma:migrate
|
||||
pnpm --filter @inkreach/api dev
|
||||
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
||||
|
||||
# Terminal 2:官网
|
||||
pnpm --filter @inkreach/website dev
|
||||
# → http://localhost:3000
|
||||
|
||||
# Terminal 3:后台
|
||||
pnpm --filter @inkreach/admin dev
|
||||
# → http://localhost:5173
|
||||
```
|
||||
|
||||
或者使用 Turborepo 同时启动所有开发服务器:
|
||||
|
||||
```bash
|
||||
pnpm dev # turbo run dev
|
||||
```
|
||||
|
||||
## 端口分配
|
||||
|
||||
| 端口 | 项目 | 入口 |
|
||||
|------|------|------|
|
||||
| 3001 | NestJS 后端 | `apps/api/src/main.ts` |
|
||||
| 5173 | Vue Admin | `apps/admin/vite.config.ts` |
|
||||
| 3000 | Nuxt 官网 | `apps/website/nuxt.config.ts` |
|
||||
|
||||
## 数据流概览
|
||||
|
||||
```
|
||||
[SDS 外部 API]
|
||||
│ (同步)
|
||||
▼
|
||||
[NestJS 后端 :3001] ── public/goods ──► [Nitro 代理 /api/backend/*] ──► [Nuxt 官网 :3000]
|
||||
│ ▲
|
||||
├── /auth /goods /categories ... ──► │ Vite proxy /api/*
|
||||
│ │
|
||||
[PostgreSQL] [Vue Admin :5173]
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 根目录(使用 pnpm workspace filter)
|
||||
pnpm install # 安装所有依赖
|
||||
pnpm dev # 启动所有 dev 服务器 (turbo)
|
||||
pnpm build # 构建所有项目 (turbo)
|
||||
pnpm format # 格式化所有文件 (prettier)
|
||||
|
||||
# 后端
|
||||
pnpm --filter @inkreach/api start:dev # 开发
|
||||
pnpm --filter @inkreach/api build # 编译
|
||||
pnpm --filter @inkreach/api test # 单元测试
|
||||
pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client
|
||||
pnpm --filter @inkreach/api prisma:migrate # 运行迁移
|
||||
pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio
|
||||
pnpm --filter @inkreach/api backfill:product-families # 产品族回填(解析链接名→自动建族→全量重算,幂等)
|
||||
|
||||
# 官网
|
||||
pnpm --filter @inkreach/website dev # 开发
|
||||
pnpm --filter @inkreach/website build # 构建
|
||||
pnpm --filter @inkreach/website generate # 静态站点生成
|
||||
|
||||
# 后台
|
||||
pnpm --filter @inkreach/admin dev # 开发
|
||||
pnpm --filter @inkreach/admin build # 构建
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- 整体目录结构:[`docs/references/structs.md`](docs/references/structs.md)
|
||||
- 数据库表设计:[`docs/dev/database-table-design.md`](docs/dev/database-table-design.md)
|
||||
- 产品中心 PRD:[`docs/dev/product-center-prd.md`](docs/dev/product-center-prd.md)
|
||||
- 官网子项目结构:[`apps/website/docs/references/structs.md`](apps/website/docs/references/structs.md)
|
||||
- 开发规范:[`AGENTS.md`](AGENTS.md)
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>inkreach-official-admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>inkreach-official-admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
@@ -27,6 +28,7 @@
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -1,32 +1,32 @@
|
||||
<svg preserveAspectRatio="xMidYMid meet" width="97" height="56" viewBox="0 0 97 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame" clip-path="url(#clip0_0_30)">
|
||||
<g id="Group">
|
||||
<path id="Vector" d="M16.318 51.2332V53.8323C17.2443 53.6294 18.0909 53.4188 18.8525 53.1979L19.0969 54.0481C17.9416 54.3665 16.7117 54.6542 15.4072 54.9085L15.0341 54.1354C15.2683 54.0044 15.3866 53.8169 15.3866 53.5678V47.6478C16.632 47.4526 17.7486 47.1957 18.7341 46.8747L19.3105 47.617C18.4614 47.8918 17.463 48.1383 16.3155 48.3592V50.342H19.0171V51.2306H16.318V51.2332ZM21.4666 54.6131L21.2119 53.7142L22.0842 53.7347C22.3389 53.7347 22.465 53.5935 22.465 53.3135V48.2513H20.5378V55.9307H19.5986V47.373H23.4067V53.5087C23.4067 54.2458 23.0671 54.6131 22.3878 54.6131H21.4666Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_2" d="M34.4941 52.1013C34.4606 52.2965 34.4221 52.4788 34.3757 52.6483H38.0474V53.5267H34.8183C35.4847 54.2895 36.7275 54.8057 38.5466 55.0805L38.0577 55.9589C35.9633 55.548 34.5893 54.8032 33.9358 53.7219C33.7325 54.0737 33.4855 54.3794 33.1922 54.6414C32.5463 55.209 31.4142 55.6584 29.7958 55.9897L29.415 55.0805C30.8173 54.834 31.8233 54.477 32.4305 54.0147C32.6209 53.858 32.783 53.6962 32.9194 53.5267H29.8755V52.6483H33.398C33.4572 52.4583 33.5086 52.2451 33.5549 52.0037L34.4941 52.1013ZM29.9733 47.8224H32.078C31.9468 47.5219 31.8079 47.2548 31.6561 47.0211L32.6338 46.8644C32.7522 47.111 32.8808 47.432 33.0249 47.8224H34.9135C35.0627 47.4962 35.1785 47.1675 35.2557 46.8362L36.254 46.962C36.1691 47.2548 36.0508 47.5425 35.9015 47.8224H37.9368V48.6623H34.4272V49.4045H37.4222V50.2058H34.4272V50.9584H38.2636V51.8188H29.6491V50.9584H33.4855V50.2058H30.5188V49.4045H33.4855V48.6623H29.9707V47.8224H29.9733Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_3" d="M50.8993 55.756C50.5597 55.756 49.9525 55.7483 49.0776 55.7355C48.419 55.7278 47.876 55.6738 47.4541 55.5685C47.0372 55.4504 46.677 55.2244 46.3786 54.8853C46.2422 54.7235 46.111 54.6414 45.9875 54.6414C45.779 54.6414 45.4188 55.0934 44.9119 56L44.2275 55.3656C44.7241 54.5592 45.1692 54.0506 45.5681 53.8426V51.097H44.2378V50.1981H46.4609V53.9094C46.5123 53.9479 46.5612 53.9967 46.6076 54.0558C46.8623 54.3152 47.1093 54.5078 47.3512 54.6311C47.6522 54.7672 48.1076 54.8468 48.7226 54.8648C49.4919 54.8776 50.1763 54.8853 50.7784 54.8853L52.1395 54.8751C52.5898 54.8545 52.9449 54.8365 53.2073 54.816L52.9835 55.7534H50.8993V55.756ZM45.0174 47.0006C45.6838 47.5091 46.2473 48.0125 46.7105 48.5159L46.0261 49.199C45.635 48.7111 45.0869 48.182 44.3819 47.617L45.0174 47.0006ZM47.2096 49.0039H49.2938C49.3452 48.5338 49.3761 48.0561 49.3813 47.5682V46.9132H50.3101V47.3627C50.3101 47.9354 50.2818 48.4825 50.2226 49.0039H52.5718V49.9028H50.3899C50.7167 51.1073 51.5426 52.3221 52.8651 53.5472L52.2193 54.2304C51.0511 53.0644 50.2689 51.9189 49.8701 50.7914C49.4199 52.3221 48.6274 53.4676 47.4927 54.2304L46.924 53.4676C48.0664 52.7382 48.8101 51.5491 49.1548 49.9028H47.2071V49.0039H47.2096Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_4" d="M63.6535 46.8953C64.8808 48.1589 66.2908 49.1528 67.8809 49.8745L67.4203 50.7041C66.9186 50.4627 66.4606 50.2135 66.0489 49.9516V50.8299H63.7873V52.2965H66.5378V53.1954H63.7873V54.739H67.4281V55.6481H59.215V54.739H62.8455V53.1954H60.0847V52.2965H62.8455V50.8299H60.5839V49.9516C60.167 50.2058 59.6962 50.4627 59.1738 50.7246L58.7236 49.913C60.4269 49.0989 61.8446 48.0921 62.9819 46.8953H63.6535ZM66.0309 49.9413C65.012 49.3172 64.1089 48.5801 63.319 47.7325C62.5882 48.5339 61.6851 49.271 60.607 49.9413H66.0309Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_5" d="M73.6859 50.4319H74.7229V48.3618H73.5779V47.4911H76.7401V48.3618H75.6646V50.4319H76.5368V51.3102H75.6646V53.031C76.0505 52.8615 76.4082 52.6946 76.7401 52.5328V53.4317C75.8061 53.894 74.7769 54.2972 73.6473 54.6439L73.4132 53.7245C73.9355 53.6063 74.3729 53.4933 74.7254 53.3829V51.3128H73.6885V50.4319H73.6859ZM76.8276 48.5159H78.9812V46.9235H79.8715V48.5159H81.2017C80.9341 48.2102 80.5893 47.8815 80.1648 47.5296L80.6922 47.0211C81.2017 47.4064 81.616 47.7762 81.935 48.1358L81.5645 48.5159H82.1126V49.4045H79.8715V50.2752C80.0027 50.6861 80.1519 51.0688 80.3217 51.4284C80.7077 50.9789 81.0628 50.4832 81.3895 49.9439L82.0842 50.4627C81.634 51.1664 81.1811 51.7494 80.7231 52.2117C81.2069 53.0464 81.7986 53.7758 82.5036 54.3999L81.8861 55.1319C81.037 54.2972 80.3655 53.3212 79.8689 52.2014V54.9085C79.8689 55.5274 79.5498 55.8356 78.9092 55.8356H77.8851L77.6895 54.9573C78.0549 55.0035 78.3585 55.0266 78.6004 55.0266C78.8551 55.0266 78.9812 54.9136 78.9812 54.685V52.6714C78.3817 53.5113 77.687 54.2381 76.8971 54.8494L76.4082 54.0481C77.3679 53.4163 78.2247 52.543 78.9812 51.4309V49.4097H76.8276V48.5159ZM77.512 50.0209C77.9546 50.6733 78.2942 51.228 78.5309 51.6929L77.8851 52.1424C77.6098 51.6338 77.2496 51.0765 76.7993 50.4729L77.512 50.0209Z" fill="var(--fill-0, #FF6902)"/>
|
||||
</g>
|
||||
<path id="Vector_6" d="M11.1801 50.889H0.165039V51.4386H11.1801V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_7" d="M97 50.889H85.7508V51.5671H97V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<g id="Group_2">
|
||||
<path id="Vector_8" d="M64.2864 27.2064C55.1033 35.3686 44.6466 38.6072 40.9337 34.4414C39.4748 32.8054 39.2741 30.273 40.1207 27.304C37.5091 32.1016 36.9481 36.4293 39.0863 38.8281C42.8017 42.994 53.2585 39.7527 62.439 31.5931C68.0173 26.6362 71.8408 21.0039 73.1504 16.4117C71.2232 19.9508 68.1845 23.7443 64.2864 27.2064Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_9" d="M3.07218 12.536H0.810499C0.362795 12.536 0 12.8982 0 13.3451V15.6026C0 16.0495 0.362795 16.4117 0.810499 16.4117H3.07218C3.51988 16.4117 3.88268 16.0495 3.88268 15.6026V13.3451C3.88268 12.8982 3.51988 12.536 3.07218 12.536Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_10" d="M3.07218 17.6804H0.810499C0.362795 17.6804 0 18.0451 0 18.492V30.1702C0 30.6171 0.362795 30.9793 0.810499 30.9793H3.07218C3.51988 30.9793 3.88268 30.6171 3.88268 30.1702V18.492C3.88268 18.0451 3.51988 17.6804 3.07218 17.6804Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_11" d="M11.6889 17.6907C10.9711 17.701 10.2841 17.8397 9.64853 18.0811C9.50701 17.8422 9.24714 17.6804 8.94867 17.6804H6.5146C6.0669 17.6804 5.7041 18.0426 5.7041 18.4895V23.847V23.9806V30.2113C5.7041 30.6582 6.0669 31.0204 6.5146 31.0204H8.94867C9.39637 31.0204 9.75917 30.6582 9.75917 30.2113V23.9831V23.7571C9.75917 22.6193 10.7086 21.6973 11.8587 21.741C12.9523 21.7821 13.8039 22.7144 13.8039 23.8085V30.2447C13.8039 30.6916 14.1667 31.0538 14.6144 31.0538H17.0485C17.4962 31.0538 17.859 30.6916 17.859 30.2447V23.7571C17.8616 20.3797 15.0853 17.6393 11.6889 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_12" d="M90.8296 17.6907C90.1606 17.701 89.5199 17.8217 88.9204 18.0349V10.6303C88.9204 10.1835 88.5576 9.82132 88.1099 9.82132H85.6758C85.2281 9.82132 84.8653 10.1835 84.8653 10.6303V22.907C84.8653 22.9969 84.873 23.0842 84.8833 23.169C84.8576 23.3924 84.8447 23.6184 84.8447 23.847V30.2113C84.8447 30.6582 85.2075 31.0204 85.6552 31.0204H88.0893C88.537 31.0204 88.8998 30.6582 88.8998 30.2113V23.7571C88.8998 22.6193 89.8492 21.6973 90.9994 21.741C92.0929 21.7821 92.9446 22.7144 92.9446 23.8085V30.2447C92.9446 30.6916 93.3074 31.0538 93.7551 31.0538H96.1891C96.6368 31.0538 96.9996 30.6916 96.9996 30.2447V23.7571C96.9996 20.3797 94.2234 17.6393 90.8296 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_13" d="M31.0412 17.7909H28.6072C28.1595 17.7909 27.7967 18.153 27.7967 18.5999V19.4089C27.7967 20.4748 26.9656 21.3506 25.9184 21.4276C25.8386 21.4251 25.7563 21.4251 25.6765 21.4251C25.6559 21.4251 25.6379 21.4276 25.6173 21.4276C25.0898 21.3891 24.5238 21.1477 23.9886 20.6314C23.8316 20.4799 23.7416 20.2693 23.7416 20.051V10.6303C23.7416 10.1835 23.3788 9.82132 22.9311 9.82132H20.497C20.0493 9.82132 19.6865 10.1835 19.6865 10.6303V18.5999V21.985V27.5865V30.3269C19.6865 30.7738 20.0493 31.1359 20.497 31.1359H22.9311C23.3788 31.1359 23.7416 30.7738 23.7416 30.3269V30.2653V27.494C23.7416 26.4307 24.5701 25.5549 25.6147 25.4779C25.7125 25.4805 25.8103 25.4805 25.9081 25.4779C26.9759 25.5524 27.7967 26.4693 27.7967 27.5454V30.3295C27.7967 30.7764 28.1595 31.1385 28.6072 31.1385H31.0412C31.4889 31.1385 31.8517 30.7764 31.8517 30.3295V27.4966C31.8517 25.9453 31.2651 24.5302 30.3028 23.454C31.2651 22.3805 31.8517 20.9628 31.8517 19.4089V18.5999C31.8517 18.153 31.4889 17.7909 31.0412 17.7909Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_14" d="M39.8324 21.7744C39.8761 21.7769 39.9173 21.7821 39.9585 21.7846C40.7355 20.6546 41.6052 19.5271 42.5598 18.4124C41.6927 17.9604 40.7046 17.7087 39.66 17.7267C38.9807 17.7369 38.3323 17.8628 37.7277 18.0785C37.6633 17.8628 37.4678 17.7061 37.2311 17.7061H34.5062C34.0585 17.7061 33.6957 18.0682 33.6957 18.5151V23.4181C33.6855 23.5722 33.6777 23.7263 33.6777 23.883V30.2473C33.6777 30.4759 33.7729 30.6839 33.9273 30.8303C34.074 30.9818 34.2798 31.0769 34.5088 31.0769H36.9429C37.3906 31.0769 37.7534 30.7147 37.7534 30.2678V23.5285C37.8846 22.514 38.7723 21.7358 39.8324 21.7744Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_15" d="M41.5516 25.4805C41.7291 26.5746 42.1948 27.6199 42.8844 28.4854H42.8818C45.3493 31.6239 50.2304 31.891 53.4003 29.1943C53.5701 29.0504 53.5881 28.7936 53.444 28.6241L51.5709 26.4256C51.432 26.2612 51.1875 26.2381 51.0151 26.3665C49.6257 27.4016 48.1925 27.643 46.7362 26.9008L51.3316 25.0079L54.6611 23.6338C54.8695 23.5491 54.9673 23.3102 54.8798 23.1048C54.5299 22.2675 54.0847 21.1246 53.6087 20.5056C52.3685 18.7617 50.3461 17.6907 48.1488 17.6933H48.1514C44.0628 17.6624 40.8466 21.4662 41.5516 25.4805ZM45.3262 23.9215C45.4909 21.7384 48.262 20.5698 49.9164 22.0312C48.738 22.5166 46.5021 23.4386 45.3262 23.9241V23.9215Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_16" d="M77.6686 21.6125C78.5975 21.2299 79.6396 21.3352 80.4604 21.8668C80.7768 22.0723 81.1988 22.0209 81.4664 21.7538L82.9433 20.2796C83.2932 19.9303 83.2495 19.355 82.8533 19.0571C81.7006 18.1864 80.2854 17.7087 78.8213 17.7112C75.7929 17.7112 73.1401 19.7428 72.3579 22.663C71.5732 25.5832 72.852 28.6652 75.4764 30.1779C77.8256 31.5315 80.7305 31.3234 82.8507 29.7285C83.2469 29.4306 83.2932 28.8553 82.9433 28.5034L81.4664 27.0292C81.1988 26.7621 80.7794 26.7107 80.4604 26.9162C79.6396 27.4478 78.5975 27.5531 77.6686 27.1704C76.5417 26.7056 75.8084 25.6089 75.8084 24.3915C75.8084 23.1767 76.5417 22.0774 77.6686 21.6125Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_17" d="M69.361 19.617V18.1299C69.361 17.9064 69.1783 17.7241 68.9545 17.7241H65.9132C65.6893 17.7241 65.5067 17.9064 65.5067 18.1299V18.3225C64.5907 17.8936 63.564 17.665 62.4808 17.6933C58.9481 17.7909 56.0663 20.67 55.9788 24.1989C55.9145 26.7107 57.2421 28.9169 59.2465 30.1086C60.2757 29.3278 61.2921 28.488 62.2904 27.5942C62.4345 27.4632 62.5786 27.3322 62.7227 27.2012C62.7047 27.2012 62.6892 27.2038 62.6712 27.2038C60.9987 27.2038 59.6582 25.7527 59.8512 24.0448C59.9953 22.7452 61.0451 21.6973 62.347 21.5535C64.0581 21.3634 65.5118 22.699 65.5118 24.3684C65.5118 24.3889 65.5118 24.4095 65.5092 24.4326C66.9681 22.8556 68.2649 21.235 69.361 19.617Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_18" d="M65.5089 30.3166V30.9536H68.9567C69.1806 30.9536 69.3633 30.7712 69.3633 30.5478V26.102C68.0356 27.6173 67.0836 28.9066 65.5089 30.3166Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_19" d="M48.9692 12.2458C57.8229 3.65217 68.4906 0.272244 72.7979 4.69492C74.6762 6.62374 75.0338 9.71602 74.1101 13.304C76.2791 8.46524 76.467 4.15814 74.159 1.78756C70.037 -2.44249 59.5185 1.09411 50.6674 9.6852C47.765 12.5027 45.4004 15.5153 43.6816 18.4381C45.1431 16.3372 46.9185 14.2389 48.9692 12.2458Z" fill="var(--fill-0, #FF6800)"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_30">
|
||||
<rect width="97" height="56" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg preserveAspectRatio="xMidYMid meet" width="97" height="56" viewBox="0 0 97 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame" clip-path="url(#clip0_0_30)">
|
||||
<g id="Group">
|
||||
<path id="Vector" d="M16.318 51.2332V53.8323C17.2443 53.6294 18.0909 53.4188 18.8525 53.1979L19.0969 54.0481C17.9416 54.3665 16.7117 54.6542 15.4072 54.9085L15.0341 54.1354C15.2683 54.0044 15.3866 53.8169 15.3866 53.5678V47.6478C16.632 47.4526 17.7486 47.1957 18.7341 46.8747L19.3105 47.617C18.4614 47.8918 17.463 48.1383 16.3155 48.3592V50.342H19.0171V51.2306H16.318V51.2332ZM21.4666 54.6131L21.2119 53.7142L22.0842 53.7347C22.3389 53.7347 22.465 53.5935 22.465 53.3135V48.2513H20.5378V55.9307H19.5986V47.373H23.4067V53.5087C23.4067 54.2458 23.0671 54.6131 22.3878 54.6131H21.4666Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_2" d="M34.4941 52.1013C34.4606 52.2965 34.4221 52.4788 34.3757 52.6483H38.0474V53.5267H34.8183C35.4847 54.2895 36.7275 54.8057 38.5466 55.0805L38.0577 55.9589C35.9633 55.548 34.5893 54.8032 33.9358 53.7219C33.7325 54.0737 33.4855 54.3794 33.1922 54.6414C32.5463 55.209 31.4142 55.6584 29.7958 55.9897L29.415 55.0805C30.8173 54.834 31.8233 54.477 32.4305 54.0147C32.6209 53.858 32.783 53.6962 32.9194 53.5267H29.8755V52.6483H33.398C33.4572 52.4583 33.5086 52.2451 33.5549 52.0037L34.4941 52.1013ZM29.9733 47.8224H32.078C31.9468 47.5219 31.8079 47.2548 31.6561 47.0211L32.6338 46.8644C32.7522 47.111 32.8808 47.432 33.0249 47.8224H34.9135C35.0627 47.4962 35.1785 47.1675 35.2557 46.8362L36.254 46.962C36.1691 47.2548 36.0508 47.5425 35.9015 47.8224H37.9368V48.6623H34.4272V49.4045H37.4222V50.2058H34.4272V50.9584H38.2636V51.8188H29.6491V50.9584H33.4855V50.2058H30.5188V49.4045H33.4855V48.6623H29.9707V47.8224H29.9733Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_3" d="M50.8993 55.756C50.5597 55.756 49.9525 55.7483 49.0776 55.7355C48.419 55.7278 47.876 55.6738 47.4541 55.5685C47.0372 55.4504 46.677 55.2244 46.3786 54.8853C46.2422 54.7235 46.111 54.6414 45.9875 54.6414C45.779 54.6414 45.4188 55.0934 44.9119 56L44.2275 55.3656C44.7241 54.5592 45.1692 54.0506 45.5681 53.8426V51.097H44.2378V50.1981H46.4609V53.9094C46.5123 53.9479 46.5612 53.9967 46.6076 54.0558C46.8623 54.3152 47.1093 54.5078 47.3512 54.6311C47.6522 54.7672 48.1076 54.8468 48.7226 54.8648C49.4919 54.8776 50.1763 54.8853 50.7784 54.8853L52.1395 54.8751C52.5898 54.8545 52.9449 54.8365 53.2073 54.816L52.9835 55.7534H50.8993V55.756ZM45.0174 47.0006C45.6838 47.5091 46.2473 48.0125 46.7105 48.5159L46.0261 49.199C45.635 48.7111 45.0869 48.182 44.3819 47.617L45.0174 47.0006ZM47.2096 49.0039H49.2938C49.3452 48.5338 49.3761 48.0561 49.3813 47.5682V46.9132H50.3101V47.3627C50.3101 47.9354 50.2818 48.4825 50.2226 49.0039H52.5718V49.9028H50.3899C50.7167 51.1073 51.5426 52.3221 52.8651 53.5472L52.2193 54.2304C51.0511 53.0644 50.2689 51.9189 49.8701 50.7914C49.4199 52.3221 48.6274 53.4676 47.4927 54.2304L46.924 53.4676C48.0664 52.7382 48.8101 51.5491 49.1548 49.9028H47.2071V49.0039H47.2096Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_4" d="M63.6535 46.8953C64.8808 48.1589 66.2908 49.1528 67.8809 49.8745L67.4203 50.7041C66.9186 50.4627 66.4606 50.2135 66.0489 49.9516V50.8299H63.7873V52.2965H66.5378V53.1954H63.7873V54.739H67.4281V55.6481H59.215V54.739H62.8455V53.1954H60.0847V52.2965H62.8455V50.8299H60.5839V49.9516C60.167 50.2058 59.6962 50.4627 59.1738 50.7246L58.7236 49.913C60.4269 49.0989 61.8446 48.0921 62.9819 46.8953H63.6535ZM66.0309 49.9413C65.012 49.3172 64.1089 48.5801 63.319 47.7325C62.5882 48.5339 61.6851 49.271 60.607 49.9413H66.0309Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_5" d="M73.6859 50.4319H74.7229V48.3618H73.5779V47.4911H76.7401V48.3618H75.6646V50.4319H76.5368V51.3102H75.6646V53.031C76.0505 52.8615 76.4082 52.6946 76.7401 52.5328V53.4317C75.8061 53.894 74.7769 54.2972 73.6473 54.6439L73.4132 53.7245C73.9355 53.6063 74.3729 53.4933 74.7254 53.3829V51.3128H73.6885V50.4319H73.6859ZM76.8276 48.5159H78.9812V46.9235H79.8715V48.5159H81.2017C80.9341 48.2102 80.5893 47.8815 80.1648 47.5296L80.6922 47.0211C81.2017 47.4064 81.616 47.7762 81.935 48.1358L81.5645 48.5159H82.1126V49.4045H79.8715V50.2752C80.0027 50.6861 80.1519 51.0688 80.3217 51.4284C80.7077 50.9789 81.0628 50.4832 81.3895 49.9439L82.0842 50.4627C81.634 51.1664 81.1811 51.7494 80.7231 52.2117C81.2069 53.0464 81.7986 53.7758 82.5036 54.3999L81.8861 55.1319C81.037 54.2972 80.3655 53.3212 79.8689 52.2014V54.9085C79.8689 55.5274 79.5498 55.8356 78.9092 55.8356H77.8851L77.6895 54.9573C78.0549 55.0035 78.3585 55.0266 78.6004 55.0266C78.8551 55.0266 78.9812 54.9136 78.9812 54.685V52.6714C78.3817 53.5113 77.687 54.2381 76.8971 54.8494L76.4082 54.0481C77.3679 53.4163 78.2247 52.543 78.9812 51.4309V49.4097H76.8276V48.5159ZM77.512 50.0209C77.9546 50.6733 78.2942 51.228 78.5309 51.6929L77.8851 52.1424C77.6098 51.6338 77.2496 51.0765 76.7993 50.4729L77.512 50.0209Z" fill="var(--fill-0, #FF6902)"/>
|
||||
</g>
|
||||
<path id="Vector_6" d="M11.1801 50.889H0.165039V51.4386H11.1801V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<path id="Vector_7" d="M97 50.889H85.7508V51.5671H97V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||
<g id="Group_2">
|
||||
<path id="Vector_8" d="M64.2864 27.2064C55.1033 35.3686 44.6466 38.6072 40.9337 34.4414C39.4748 32.8054 39.2741 30.273 40.1207 27.304C37.5091 32.1016 36.9481 36.4293 39.0863 38.8281C42.8017 42.994 53.2585 39.7527 62.439 31.5931C68.0173 26.6362 71.8408 21.0039 73.1504 16.4117C71.2232 19.9508 68.1845 23.7443 64.2864 27.2064Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_9" d="M3.07218 12.536H0.810499C0.362795 12.536 0 12.8982 0 13.3451V15.6026C0 16.0495 0.362795 16.4117 0.810499 16.4117H3.07218C3.51988 16.4117 3.88268 16.0495 3.88268 15.6026V13.3451C3.88268 12.8982 3.51988 12.536 3.07218 12.536Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_10" d="M3.07218 17.6804H0.810499C0.362795 17.6804 0 18.0451 0 18.492V30.1702C0 30.6171 0.362795 30.9793 0.810499 30.9793H3.07218C3.51988 30.9793 3.88268 30.6171 3.88268 30.1702V18.492C3.88268 18.0451 3.51988 17.6804 3.07218 17.6804Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_11" d="M11.6889 17.6907C10.9711 17.701 10.2841 17.8397 9.64853 18.0811C9.50701 17.8422 9.24714 17.6804 8.94867 17.6804H6.5146C6.0669 17.6804 5.7041 18.0426 5.7041 18.4895V23.847V23.9806V30.2113C5.7041 30.6582 6.0669 31.0204 6.5146 31.0204H8.94867C9.39637 31.0204 9.75917 30.6582 9.75917 30.2113V23.9831V23.7571C9.75917 22.6193 10.7086 21.6973 11.8587 21.741C12.9523 21.7821 13.8039 22.7144 13.8039 23.8085V30.2447C13.8039 30.6916 14.1667 31.0538 14.6144 31.0538H17.0485C17.4962 31.0538 17.859 30.6916 17.859 30.2447V23.7571C17.8616 20.3797 15.0853 17.6393 11.6889 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_12" d="M90.8296 17.6907C90.1606 17.701 89.5199 17.8217 88.9204 18.0349V10.6303C88.9204 10.1835 88.5576 9.82132 88.1099 9.82132H85.6758C85.2281 9.82132 84.8653 10.1835 84.8653 10.6303V22.907C84.8653 22.9969 84.873 23.0842 84.8833 23.169C84.8576 23.3924 84.8447 23.6184 84.8447 23.847V30.2113C84.8447 30.6582 85.2075 31.0204 85.6552 31.0204H88.0893C88.537 31.0204 88.8998 30.6582 88.8998 30.2113V23.7571C88.8998 22.6193 89.8492 21.6973 90.9994 21.741C92.0929 21.7821 92.9446 22.7144 92.9446 23.8085V30.2447C92.9446 30.6916 93.3074 31.0538 93.7551 31.0538H96.1891C96.6368 31.0538 96.9996 30.6916 96.9996 30.2447V23.7571C96.9996 20.3797 94.2234 17.6393 90.8296 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_13" d="M31.0412 17.7909H28.6072C28.1595 17.7909 27.7967 18.153 27.7967 18.5999V19.4089C27.7967 20.4748 26.9656 21.3506 25.9184 21.4276C25.8386 21.4251 25.7563 21.4251 25.6765 21.4251C25.6559 21.4251 25.6379 21.4276 25.6173 21.4276C25.0898 21.3891 24.5238 21.1477 23.9886 20.6314C23.8316 20.4799 23.7416 20.2693 23.7416 20.051V10.6303C23.7416 10.1835 23.3788 9.82132 22.9311 9.82132H20.497C20.0493 9.82132 19.6865 10.1835 19.6865 10.6303V18.5999V21.985V27.5865V30.3269C19.6865 30.7738 20.0493 31.1359 20.497 31.1359H22.9311C23.3788 31.1359 23.7416 30.7738 23.7416 30.3269V30.2653V27.494C23.7416 26.4307 24.5701 25.5549 25.6147 25.4779C25.7125 25.4805 25.8103 25.4805 25.9081 25.4779C26.9759 25.5524 27.7967 26.4693 27.7967 27.5454V30.3295C27.7967 30.7764 28.1595 31.1385 28.6072 31.1385H31.0412C31.4889 31.1385 31.8517 30.7764 31.8517 30.3295V27.4966C31.8517 25.9453 31.2651 24.5302 30.3028 23.454C31.2651 22.3805 31.8517 20.9628 31.8517 19.4089V18.5999C31.8517 18.153 31.4889 17.7909 31.0412 17.7909Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_14" d="M39.8324 21.7744C39.8761 21.7769 39.9173 21.7821 39.9585 21.7846C40.7355 20.6546 41.6052 19.5271 42.5598 18.4124C41.6927 17.9604 40.7046 17.7087 39.66 17.7267C38.9807 17.7369 38.3323 17.8628 37.7277 18.0785C37.6633 17.8628 37.4678 17.7061 37.2311 17.7061H34.5062C34.0585 17.7061 33.6957 18.0682 33.6957 18.5151V23.4181C33.6855 23.5722 33.6777 23.7263 33.6777 23.883V30.2473C33.6777 30.4759 33.7729 30.6839 33.9273 30.8303C34.074 30.9818 34.2798 31.0769 34.5088 31.0769H36.9429C37.3906 31.0769 37.7534 30.7147 37.7534 30.2678V23.5285C37.8846 22.514 38.7723 21.7358 39.8324 21.7744Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_15" d="M41.5516 25.4805C41.7291 26.5746 42.1948 27.6199 42.8844 28.4854H42.8818C45.3493 31.6239 50.2304 31.891 53.4003 29.1943C53.5701 29.0504 53.5881 28.7936 53.444 28.6241L51.5709 26.4256C51.432 26.2612 51.1875 26.2381 51.0151 26.3665C49.6257 27.4016 48.1925 27.643 46.7362 26.9008L51.3316 25.0079L54.6611 23.6338C54.8695 23.5491 54.9673 23.3102 54.8798 23.1048C54.5299 22.2675 54.0847 21.1246 53.6087 20.5056C52.3685 18.7617 50.3461 17.6907 48.1488 17.6933H48.1514C44.0628 17.6624 40.8466 21.4662 41.5516 25.4805ZM45.3262 23.9215C45.4909 21.7384 48.262 20.5698 49.9164 22.0312C48.738 22.5166 46.5021 23.4386 45.3262 23.9241V23.9215Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_16" d="M77.6686 21.6125C78.5975 21.2299 79.6396 21.3352 80.4604 21.8668C80.7768 22.0723 81.1988 22.0209 81.4664 21.7538L82.9433 20.2796C83.2932 19.9303 83.2495 19.355 82.8533 19.0571C81.7006 18.1864 80.2854 17.7087 78.8213 17.7112C75.7929 17.7112 73.1401 19.7428 72.3579 22.663C71.5732 25.5832 72.852 28.6652 75.4764 30.1779C77.8256 31.5315 80.7305 31.3234 82.8507 29.7285C83.2469 29.4306 83.2932 28.8553 82.9433 28.5034L81.4664 27.0292C81.1988 26.7621 80.7794 26.7107 80.4604 26.9162C79.6396 27.4478 78.5975 27.5531 77.6686 27.1704C76.5417 26.7056 75.8084 25.6089 75.8084 24.3915C75.8084 23.1767 76.5417 22.0774 77.6686 21.6125Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_17" d="M69.361 19.617V18.1299C69.361 17.9064 69.1783 17.7241 68.9545 17.7241H65.9132C65.6893 17.7241 65.5067 17.9064 65.5067 18.1299V18.3225C64.5907 17.8936 63.564 17.665 62.4808 17.6933C58.9481 17.7909 56.0663 20.67 55.9788 24.1989C55.9145 26.7107 57.2421 28.9169 59.2465 30.1086C60.2757 29.3278 61.2921 28.488 62.2904 27.5942C62.4345 27.4632 62.5786 27.3322 62.7227 27.2012C62.7047 27.2012 62.6892 27.2038 62.6712 27.2038C60.9987 27.2038 59.6582 25.7527 59.8512 24.0448C59.9953 22.7452 61.0451 21.6973 62.347 21.5535C64.0581 21.3634 65.5118 22.699 65.5118 24.3684C65.5118 24.3889 65.5118 24.4095 65.5092 24.4326C66.9681 22.8556 68.2649 21.235 69.361 19.617Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_18" d="M65.5089 30.3166V30.9536H68.9567C69.1806 30.9536 69.3633 30.7712 69.3633 30.5478V26.102C68.0356 27.6173 67.0836 28.9066 65.5089 30.3166Z" fill="var(--fill-0, #FF6800)"/>
|
||||
<path id="Vector_19" d="M48.9692 12.2458C57.8229 3.65217 68.4906 0.272244 72.7979 4.69492C74.6762 6.62374 75.0338 9.71602 74.1101 13.304C76.2791 8.46524 76.467 4.15814 74.159 1.78756C70.037 -2.44249 59.5185 1.09411 50.6674 9.6852C47.765 12.5027 45.4004 15.5153 43.6816 18.4381C45.1431 16.3372 46.9185 14.2389 48.9692 12.2458Z" fill="var(--fill-0, #FF6800)"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_30">
|
||||
<rect width="97" height="56" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// Root component renders router-managed views.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
// Root component renders router-managed views.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import request from './request'
|
||||
import type { LoginRequest, LoginResponse, User } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
// Login
|
||||
login: (data: LoginRequest) => {
|
||||
return request.post<any, LoginResponse>('/auth/login', data)
|
||||
},
|
||||
|
||||
// Get current user
|
||||
getCurrentUser: () => {
|
||||
return request.get<any, User>('/auth/me')
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
return request.post('/auth/logout')
|
||||
},
|
||||
import request from './request'
|
||||
import type { LoginRequest, LoginResponse, User } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
// Login
|
||||
login: (data: LoginRequest) => {
|
||||
return request.post<any, LoginResponse>('/auth/login', data)
|
||||
},
|
||||
|
||||
// Get current user
|
||||
getCurrentUser: () => {
|
||||
return request.get<any, User>('/auth/me')
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
return request.post('/auth/logout')
|
||||
},
|
||||
}
|
||||
@@ -1,41 +1,41 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
CategoryFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const categoriesApi = {
|
||||
// Get categories list
|
||||
getCategoriesList: (params: CategoryFilter) => {
|
||||
return request.get<any, PaginatedResult<Category>>('/categories', { params })
|
||||
},
|
||||
|
||||
// Get category tree
|
||||
getCategoryTree: () => {
|
||||
return request.get<any, CategoryTree[]>('/categories')
|
||||
},
|
||||
|
||||
// Get category by id
|
||||
getCategoryById: (id: string) => {
|
||||
return request.get<any, Category>(`/categories/${id}`)
|
||||
},
|
||||
|
||||
// Create category
|
||||
createCategory: (data: CreateCategoryRequest) => {
|
||||
return request.post<any, Category>('/categories', data)
|
||||
},
|
||||
|
||||
// Update category
|
||||
updateCategory: (id: string, data: UpdateCategoryRequest) => {
|
||||
return request.patch<any, Category>(`/categories/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete category
|
||||
deleteCategory: (id: string) => {
|
||||
return request.delete(`/categories/${id}`)
|
||||
},
|
||||
import request from './request'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
CategoryFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const categoriesApi = {
|
||||
// Get categories list
|
||||
getCategoriesList: (params: CategoryFilter) => {
|
||||
return request.get<any, PaginatedResult<Category>>('/categories', { params })
|
||||
},
|
||||
|
||||
// Get category tree
|
||||
getCategoryTree: () => {
|
||||
return request.get<any, CategoryTree[]>('/categories')
|
||||
},
|
||||
|
||||
// Get category by id
|
||||
getCategoryById: (id: string) => {
|
||||
return request.get<any, Category>(`/categories/${id}`)
|
||||
},
|
||||
|
||||
// Create category
|
||||
createCategory: (data: CreateCategoryRequest) => {
|
||||
return request.post<any, Category>('/categories', data)
|
||||
},
|
||||
|
||||
// Update category
|
||||
updateCategory: (id: string, data: UpdateCategoryRequest) => {
|
||||
return request.patch<any, Category>(`/categories/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete category
|
||||
deleteCategory: (id: string) => {
|
||||
return request.delete(`/categories/${id}`)
|
||||
},
|
||||
}
|
||||
@@ -1,35 +1,40 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const countriesApi = {
|
||||
// Get countries list
|
||||
getCountriesList: (params: CountryFilter) => {
|
||||
return request.get<any, PaginatedResult<Country>>('/countries', { params })
|
||||
},
|
||||
|
||||
// Get country by id
|
||||
getCountryById: (id: string) => {
|
||||
return request.get<any, Country>(`/countries/${id}`)
|
||||
},
|
||||
|
||||
// Create country
|
||||
createCountry: (data: CreateCountryRequest) => {
|
||||
return request.post<any, Country>('/countries', data)
|
||||
},
|
||||
|
||||
// Update country
|
||||
updateCountry: (id: string, data: UpdateCountryRequest) => {
|
||||
return request.patch<any, Country>(`/countries/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete country
|
||||
deleteCountry: (id: string) => {
|
||||
return request.delete(`/countries/${id}`)
|
||||
},
|
||||
import request from './request'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const countriesApi = {
|
||||
// Get countries list
|
||||
getCountriesList: (params: CountryFilter) => {
|
||||
return request.get<any, PaginatedResult<Country>>('/countries', { params })
|
||||
},
|
||||
|
||||
// Get country by id
|
||||
getCountryById: (id: string) => {
|
||||
return request.get<any, Country>(`/countries/${id}`)
|
||||
},
|
||||
|
||||
// Create country
|
||||
createCountry: (data: CreateCountryRequest) => {
|
||||
return request.post<any, Country>('/countries', data)
|
||||
},
|
||||
|
||||
// Update country
|
||||
updateCountry: (id: string, data: UpdateCountryRequest) => {
|
||||
return request.patch<any, Country>(`/countries/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete country
|
||||
deleteCountry: (id: string) => {
|
||||
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,47 +1,58 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
import request from './request'
|
||||
import type {
|
||||
Good,
|
||||
CreateGoodRequest,
|
||||
UpdateGoodRequest,
|
||||
BatchCreateGoodsRequest,
|
||||
BatchPriorityRequest,
|
||||
GoodDetail,
|
||||
CreateGoodRequest,
|
||||
UpdateGoodRequest,
|
||||
BatchCreateGoodsRequest,
|
||||
BatchPriorityRequest,
|
||||
GoodsFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const goodsApi = {
|
||||
// Get goods list
|
||||
getGoodsList: (params: GoodsFilter) => {
|
||||
return request.get<any, PaginatedResult<Good>>('/goods', { params })
|
||||
},
|
||||
|
||||
// Get good by id
|
||||
getGoodById: (id: string) => {
|
||||
return request.get<any, Good>(`/goods/${id}`)
|
||||
},
|
||||
|
||||
// Create good
|
||||
CreateCustomGoodRequest,
|
||||
UpdateCustomGoodContentRequest,
|
||||
} from '@/types'
|
||||
|
||||
export const goodsApi = {
|
||||
// Get goods list
|
||||
getGoodsList: (params: GoodsFilter) => {
|
||||
return request.get<any, PaginatedResult<Good>>('/goods', { params })
|
||||
},
|
||||
|
||||
// Get good by id
|
||||
getGoodById: (id: string) => {
|
||||
return request.get<any, GoodDetail>(`/goods/${id}`)
|
||||
},
|
||||
|
||||
// Create good
|
||||
createGood: (data: CreateGoodRequest) => {
|
||||
return request.post<any, Good>('/goods', data)
|
||||
},
|
||||
|
||||
// Update good
|
||||
updateGood: (id: string, data: UpdateGoodRequest) => {
|
||||
return request.patch<any, Good>(`/goods/${id}`, data)
|
||||
createCustomGood: (data: CreateCustomGoodRequest) => {
|
||||
return request.post<any, GoodDetail>('/goods/custom', data)
|
||||
},
|
||||
|
||||
// Delete good
|
||||
deleteGood: (id: string) => {
|
||||
return request.delete(`/goods/${id}`)
|
||||
updateCustomGoodContent: (id: string, data: UpdateCustomGoodContentRequest) => {
|
||||
return request.patch<any, GoodDetail>(`/goods/${id}/custom-content`, data)
|
||||
},
|
||||
|
||||
// Batch create goods
|
||||
batchCreateGoods: (data: BatchCreateGoodsRequest) => {
|
||||
return request.post<any, Good[]>('/goods/batch', data)
|
||||
},
|
||||
|
||||
// Batch update priority
|
||||
batchUpdatePriority: (data: BatchPriorityRequest) => {
|
||||
return request.patch<any, { count: number }>('/goods/batch-priority', data)
|
||||
},
|
||||
}
|
||||
|
||||
// Update good
|
||||
updateGood: (id: string, data: UpdateGoodRequest) => {
|
||||
return request.patch<any, Good>(`/goods/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete good
|
||||
deleteGood: (id: string) => {
|
||||
return request.delete(`/goods/${id}`)
|
||||
},
|
||||
|
||||
// Batch create goods
|
||||
batchCreateGoods: (data: BatchCreateGoodsRequest) => {
|
||||
return request.post<any, Good[]>('/goods/batch', data)
|
||||
},
|
||||
|
||||
// Batch update priority
|
||||
batchUpdatePriority: (data: BatchPriorityRequest) => {
|
||||
return request.patch<any, { count: number }>('/goods/batch-priority', data)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import request from './request'
|
||||
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types'
|
||||
|
||||
export const originGoodsApi = {
|
||||
getTree: () => {
|
||||
return request.get<any, OriginGoodsTreeResponse>('/origin-goods/tree')
|
||||
},
|
||||
|
||||
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
|
||||
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
|
||||
},
|
||||
}
|
||||
import request from './request'
|
||||
import type { OriginGood, OriginGoodsTreeResponse, OriginGoodTagsResult, PaginatedResult } from '@/types'
|
||||
|
||||
export const originGoodsApi = {
|
||||
getTree: () => {
|
||||
return request.get<any, OriginGoodsTreeResponse>('/origin-goods/tree')
|
||||
},
|
||||
|
||||
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
|
||||
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`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const positionsApi = {
|
||||
// Get positions list
|
||||
getPositionsList: (params: PositionFilter) => {
|
||||
return request.get<any, PaginatedResult<Position>>('/positions', { params })
|
||||
},
|
||||
|
||||
// Get position by id
|
||||
getPositionById: (id: string) => {
|
||||
return request.get<any, Position>(`/positions/${id}`)
|
||||
},
|
||||
|
||||
// Create position
|
||||
createPosition: (data: CreatePositionRequest) => {
|
||||
return request.post<any, Position>('/positions', data)
|
||||
},
|
||||
|
||||
// Update position
|
||||
updatePosition: (id: string, data: UpdatePositionRequest) => {
|
||||
return request.patch<any, Position>(`/positions/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete position
|
||||
deletePosition: (id: string) => {
|
||||
return request.delete(`/positions/${id}`)
|
||||
},
|
||||
import request from './request'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const positionsApi = {
|
||||
// Get positions list
|
||||
getPositionsList: (params: PositionFilter) => {
|
||||
return request.get<any, PaginatedResult<Position>>('/positions', { params })
|
||||
},
|
||||
|
||||
// Get position by id
|
||||
getPositionById: (id: string) => {
|
||||
return request.get<any, Position>(`/positions/${id}`)
|
||||
},
|
||||
|
||||
// Create position
|
||||
createPosition: (data: CreatePositionRequest) => {
|
||||
return request.post<any, Position>('/positions', data)
|
||||
},
|
||||
|
||||
// Update position
|
||||
updatePosition: (id: string, data: UpdatePositionRequest) => {
|
||||
return request.patch<any, Position>(`/positions/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete position
|
||||
deletePosition: (id: string) => {
|
||||
return request.delete(`/positions/${id}`)
|
||||
},
|
||||
}
|
||||
@@ -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,72 +1,84 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 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
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// Backend wraps everything in { data, success } — unwrap to data
|
||||
const body = response.data
|
||||
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
|
||||
return body.data
|
||||
}
|
||||
return body
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
ElMessage.error('Unauthorized, please login')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
router.push('/login')
|
||||
break
|
||||
case 403:
|
||||
ElMessage.error('Forbidden')
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('Resource not found')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('Server error')
|
||||
break
|
||||
default:
|
||||
const errorMessage = (data as any)?.message || 'Request failed'
|
||||
ElMessage.error(errorMessage)
|
||||
}
|
||||
} else if (error.request) {
|
||||
ElMessage.error('Network error')
|
||||
} else {
|
||||
ElMessage.error('Request failed')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Session tokens live in HttpOnly cookies — send them along.
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// Response interceptor
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// Backend wraps everything in { data, success } — unwrap to data
|
||||
const body = response.data
|
||||
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
|
||||
return body.data
|
||||
}
|
||||
return body
|
||||
},
|
||||
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) {
|
||||
const { status, data } = error.response
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
ElMessage.error('Unauthorized, please login')
|
||||
router.push('/login')
|
||||
break
|
||||
case 403:
|
||||
ElMessage.error('Forbidden')
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('Resource not found')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('Server error')
|
||||
break
|
||||
default:
|
||||
const errorMessage = (data as any)?.message || 'Request failed'
|
||||
ElMessage.error(errorMessage)
|
||||
}
|
||||
} else if (error.request) {
|
||||
ElMessage.error('Network error')
|
||||
} else {
|
||||
ElMessage.error('Request failed')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import request from './request'
|
||||
import type { SyncLog } from '@/types'
|
||||
|
||||
export const syncApi = {
|
||||
syncProducts: () => {
|
||||
return request.post<any, { message: string }>('/sync/products')
|
||||
},
|
||||
|
||||
import request from './request'
|
||||
import type { SyncLog } from '@/types'
|
||||
|
||||
export const syncApi = {
|
||||
syncProducts: () => {
|
||||
return request.post<any, { message: string }>('/sync/products')
|
||||
},
|
||||
|
||||
syncCategories: () => {
|
||||
return request.post<any, { message: string }>('/sync/categories')
|
||||
return request.post<any, { message: string }>('/sync/categories')
|
||||
},
|
||||
|
||||
getSyncStatus: (limit?: number) => {
|
||||
return request.get<any, SyncLog[]>('/sync/status', {
|
||||
params: limit ? { limit } : undefined,
|
||||
})
|
||||
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) => {
|
||||
return request.get<any, SyncLog[]>('/sync/status', {
|
||||
params: limit ? { limit } : undefined,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
TagGroup,
|
||||
CreateTagGroupRequest,
|
||||
UpdateTagGroupRequest,
|
||||
ReorderTagGroupsRequest,
|
||||
} from '@/types'
|
||||
|
||||
export const tagGroupsApi = {
|
||||
getTagGroupsList: () => {
|
||||
return request.get<any, TagGroup[]>('/tag-groups')
|
||||
},
|
||||
|
||||
getTagGroupById: (id: string) => {
|
||||
return request.get<any, TagGroup>(`/tag-groups/${id}`)
|
||||
},
|
||||
|
||||
createTagGroup: (data: CreateTagGroupRequest) => {
|
||||
return request.post<any, TagGroup>('/tag-groups', data)
|
||||
},
|
||||
|
||||
updateTagGroup: (id: string, data: UpdateTagGroupRequest) => {
|
||||
return request.patch<any, TagGroup>(`/tag-groups/${id}`, data)
|
||||
},
|
||||
|
||||
deleteTagGroup: (id: string) => {
|
||||
return request.delete(`/tag-groups/${id}`)
|
||||
},
|
||||
|
||||
reorderTagGroups: (data: ReorderTagGroupsRequest) => {
|
||||
return request.patch('/tag-groups/sort', data)
|
||||
},
|
||||
}
|
||||
import request from './request'
|
||||
import type {
|
||||
TagGroup,
|
||||
CreateTagGroupRequest,
|
||||
UpdateTagGroupRequest,
|
||||
ReorderTagGroupsRequest,
|
||||
} from '@/types'
|
||||
|
||||
export const tagGroupsApi = {
|
||||
getTagGroupsList: () => {
|
||||
return request.get<any, TagGroup[]>('/tag-groups')
|
||||
},
|
||||
|
||||
getTagGroupById: (id: string) => {
|
||||
return request.get<any, TagGroup>(`/tag-groups/${id}`)
|
||||
},
|
||||
|
||||
createTagGroup: (data: CreateTagGroupRequest) => {
|
||||
return request.post<any, TagGroup>('/tag-groups', data)
|
||||
},
|
||||
|
||||
updateTagGroup: (id: string, data: UpdateTagGroupRequest) => {
|
||||
return request.patch<any, TagGroup>(`/tag-groups/${id}`, data)
|
||||
},
|
||||
|
||||
deleteTagGroup: (id: string) => {
|
||||
return request.delete(`/tag-groups/${id}`)
|
||||
},
|
||||
|
||||
reorderTagGroups: (data: ReorderTagGroupsRequest) => {
|
||||
return request.patch('/tag-groups/sort', data)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Tag,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
ReorderTagsRequest,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const tagsApi = {
|
||||
// Get tags list
|
||||
getTagsList: (params: TagFilter) => {
|
||||
return request.get<any, PaginatedResult<Tag>>('/tags', { params })
|
||||
},
|
||||
|
||||
// Get tag by id
|
||||
getTagById: (id: string) => {
|
||||
return request.get<any, Tag>(`/tags/${id}`)
|
||||
},
|
||||
|
||||
// Create tag
|
||||
createTag: (data: CreateTagRequest) => {
|
||||
return request.post<any, Tag>('/tags', data)
|
||||
},
|
||||
|
||||
// Update tag
|
||||
updateTag: (id: string, data: UpdateTagRequest) => {
|
||||
return request.patch<any, Tag>(`/tags/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete tag
|
||||
deleteTag: (id: string) => {
|
||||
return request.delete(`/tags/${id}`)
|
||||
},
|
||||
|
||||
// Reorder tags (and/or move across groups)
|
||||
reorderTags: (data: ReorderTagsRequest) => {
|
||||
return request.patch('/tags/sort', data)
|
||||
},
|
||||
import request from './request'
|
||||
import type {
|
||||
Tag,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
ReorderTagsRequest,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const tagsApi = {
|
||||
// Get tags list
|
||||
getTagsList: (params: TagFilter) => {
|
||||
return request.get<any, PaginatedResult<Tag>>('/tags', { params })
|
||||
},
|
||||
|
||||
// Get tag by id
|
||||
getTagById: (id: string) => {
|
||||
return request.get<any, Tag>(`/tags/${id}`)
|
||||
},
|
||||
|
||||
// Create tag
|
||||
createTag: (data: CreateTagRequest) => {
|
||||
return request.post<any, Tag>('/tags', data)
|
||||
},
|
||||
|
||||
// Update tag
|
||||
updateTag: (id: string, data: UpdateTagRequest) => {
|
||||
return request.patch<any, Tag>(`/tags/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete tag
|
||||
deleteTag: (id: string) => {
|
||||
return request.delete(`/tags/${id}`)
|
||||
},
|
||||
|
||||
// Reorder tags (and/or move across groups)
|
||||
reorderTags: (data: ReorderTagsRequest) => {
|
||||
return request.patch('/tags/sort', data)
|
||||
},
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import request from './request'
|
||||
|
||||
export interface UploadResult {
|
||||
url: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export const uploadApi = {
|
||||
uploadImage: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post<any, UploadResult>('/upload/image', formData)
|
||||
},
|
||||
}
|
||||
import request from './request'
|
||||
|
||||
export interface UploadResult {
|
||||
url: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export const uploadApi = {
|
||||
uploadImage: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post<any, UploadResult>('/upload/image', formData)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,14 +11,18 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
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']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
@@ -31,6 +35,7 @@ declare module 'vue' {
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
@@ -40,6 +45,9 @@ declare module 'vue' {
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
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']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
|
||||
@@ -1,154 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Loading } from '@element-plus/icons-vue'
|
||||
import type { UploadProps } from 'element-plus'
|
||||
import { uploadApi } from '@/api/upload'
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
width?: number
|
||||
height?: number
|
||||
shape?: 'square' | 'circle'
|
||||
label?: string
|
||||
}>(), {
|
||||
width: 80,
|
||||
height: 80,
|
||||
shape: 'square',
|
||||
label: '点击上传',
|
||||
})
|
||||
|
||||
const uploading = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const customUpload: UploadProps['httpRequest'] = async (options) => {
|
||||
const file = options.file as File
|
||||
uploading.value = true
|
||||
try {
|
||||
const result = await uploadApi.uploadImage(file)
|
||||
model.value = result.url
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
model.value = ''
|
||||
}
|
||||
|
||||
function onImgError() {
|
||||
console.warn('Image failed to load:', model.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="img-upload">
|
||||
<el-upload
|
||||
class="img-uploader"
|
||||
:show-file-list="false"
|
||||
:http-request="customUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div v-if="model" class="img-preview-wrap" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||
<img :src="model" class="img-preview-img" @click.stop="previewVisible = true" @error="onImgError" />
|
||||
<div class="img-overlay">
|
||||
<span @click.stop="previewVisible = true">预览</span>
|
||||
<span @click.stop="removeImage">删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="img-placeholder" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||
<el-icon v-if="!uploading" :size="20"><Plus /></el-icon>
|
||||
<el-icon v-else :size="20" class="is-loading"><Loading /></el-icon>
|
||||
<span class="img-placeholder-text">{{ uploading ? '上传中' : label }}</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<el-image-viewer v-if="previewVisible" :url-list="[model]" @close="previewVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.img-upload {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.img-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.img-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
background: #fafafa;
|
||||
color: #a8abb2;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.img-placeholder:hover {
|
||||
border-color: var(--brand-color, #ff6800);
|
||||
color: var(--brand-color, #ff6800);
|
||||
}
|
||||
|
||||
.img-placeholder-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.img-preview-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.img-preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.img-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.img-preview-wrap:hover .img-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.img-overlay span {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.img-overlay span:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.is-loading {
|
||||
animation: rotating 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Loading } from '@element-plus/icons-vue'
|
||||
import type { UploadProps } from 'element-plus'
|
||||
import { uploadApi } from '@/api/upload'
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
width?: number
|
||||
height?: number
|
||||
shape?: 'square' | 'circle'
|
||||
label?: string
|
||||
}>(), {
|
||||
width: 80,
|
||||
height: 80,
|
||||
shape: 'square',
|
||||
label: '点击上传',
|
||||
})
|
||||
|
||||
const uploading = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const customUpload: UploadProps['httpRequest'] = async (options) => {
|
||||
const file = options.file as File
|
||||
uploading.value = true
|
||||
try {
|
||||
const result = await uploadApi.uploadImage(file)
|
||||
model.value = result.url
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
model.value = ''
|
||||
}
|
||||
|
||||
function onImgError() {
|
||||
console.warn('Image failed to load:', model.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="img-upload">
|
||||
<el-upload
|
||||
class="img-uploader"
|
||||
:show-file-list="false"
|
||||
:http-request="customUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div v-if="model" class="img-preview-wrap" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||
<img :src="model" class="img-preview-img" @click.stop="previewVisible = true" @error="onImgError" />
|
||||
<div class="img-overlay">
|
||||
<span @click.stop="previewVisible = true">预览</span>
|
||||
<span @click.stop="removeImage">删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="img-placeholder" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||
<el-icon v-if="!uploading" :size="20"><Plus /></el-icon>
|
||||
<el-icon v-else :size="20" class="is-loading"><Loading /></el-icon>
|
||||
<span class="img-placeholder-text">{{ uploading ? '上传中' : label }}</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<el-image-viewer v-if="previewVisible" :url-list="[model]" @close="previewVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.img-upload {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.img-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.img-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
background: #fafafa;
|
||||
color: #a8abb2;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.img-placeholder:hover {
|
||||
border-color: var(--brand-color, #ff6800);
|
||||
color: var(--brand-color, #ff6800);
|
||||
}
|
||||
|
||||
.img-placeholder-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.img-preview-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.img-preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.img-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.img-preview-wrap:hover .img-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.img-overlay span {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.img-overlay span:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.is-loading {
|
||||
animation: rotating 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,274 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Goods,
|
||||
Expand,
|
||||
Fold,
|
||||
ArrowDown,
|
||||
User,
|
||||
SwitchButton,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
interface MenuItem {
|
||||
index: string
|
||||
title: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const menuItems = ref<MenuItem[]>([
|
||||
{ index: '/goods', title: '商品管理', icon: 'Goods' },
|
||||
])
|
||||
|
||||
const iconMap: Record<string, unknown> = {
|
||||
Goods,
|
||||
}
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const username = computed(() => authStore.user?.username || 'Admin')
|
||||
|
||||
const breadcrumb = computed(() => {
|
||||
const meta = route.meta as { title?: string } | undefined
|
||||
return meta?.title || '商品管理'
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
|
||||
confirmButtonText: '退出登录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function handleCommand(command: string) {
|
||||
if (command === 'logout') {
|
||||
handleLogout()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout-root">
|
||||
<!-- Sidebar -->
|
||||
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
||||
<div class="brand" :class="{ collapsed }">
|
||||
<img src="/logo-unified.svg" class="brand-logo" alt="InkReach" />
|
||||
<span v-if="!collapsed" class="brand-text">印美达官网后台</span>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
background-color="#1f1f24"
|
||||
text-color="#d4d4d8"
|
||||
active-text-color="#ff6800"
|
||||
router
|
||||
>
|
||||
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
||||
<el-icon>
|
||||
<component :is="iconMap[item.icon]" />
|
||||
</el-icon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<!-- Header -->
|
||||
<el-header class="layout-header" height="56px">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
text
|
||||
class="collapse-btn"
|
||||
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<el-icon :size="20">
|
||||
<component :is="collapsed ? Expand : Fold" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<span class="user-trigger">
|
||||
<el-icon><User /></el-icon>
|
||||
<span class="user-name">{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- Main content -->
|
||||
<el-main class="layout-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.layout-aside {
|
||||
background: #1f1f24;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #2b2b33;
|
||||
background: #16161a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand.collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
color: #e4e4e7;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item.is-active) {
|
||||
background: rgba(255, 104, 0, 0.12) !important;
|
||||
border-right: 3px solid var(--brand-color);
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item:hover) {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.user-trigger:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* View transition */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Goods,
|
||||
Expand,
|
||||
Fold,
|
||||
ArrowDown,
|
||||
User,
|
||||
SwitchButton,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
interface MenuItem {
|
||||
index: string
|
||||
title: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const menuItems = ref<MenuItem[]>([
|
||||
{ index: '/goods', title: '商品管理', icon: 'Goods' },
|
||||
])
|
||||
|
||||
const iconMap: Record<string, unknown> = {
|
||||
Goods,
|
||||
}
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const username = computed(() => authStore.user?.username || 'Admin')
|
||||
|
||||
const breadcrumb = computed(() => {
|
||||
const meta = route.meta as { title?: string } | undefined
|
||||
return meta?.title || '商品管理'
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
|
||||
confirmButtonText: '退出登录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function handleCommand(command: string) {
|
||||
if (command === 'logout') {
|
||||
handleLogout()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout-root">
|
||||
<!-- Sidebar -->
|
||||
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
||||
<div class="brand" :class="{ collapsed }">
|
||||
<img src="/logo-unified.svg" class="brand-logo" alt="InkReach" />
|
||||
<span v-if="!collapsed" class="brand-text">印美达官网后台</span>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
background-color="#1f1f24"
|
||||
text-color="#d4d4d8"
|
||||
active-text-color="#ff6800"
|
||||
router
|
||||
>
|
||||
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
||||
<el-icon>
|
||||
<component :is="iconMap[item.icon]" />
|
||||
</el-icon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<!-- Header -->
|
||||
<el-header class="layout-header" height="56px">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
text
|
||||
class="collapse-btn"
|
||||
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<el-icon :size="20">
|
||||
<component :is="collapsed ? Expand : Fold" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<span class="user-trigger">
|
||||
<el-icon><User /></el-icon>
|
||||
<span class="user-name">{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- Main content -->
|
||||
<el-main class="layout-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.layout-aside {
|
||||
background: #1f1f24;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #2b2b33;
|
||||
background: #16161a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand.collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
color: #e4e4e7;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item.is-active) {
|
||||
background: rgba(255, 104, 0, 0.12) !important;
|
||||
border-right: 3px solid var(--brand-color);
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item:hover) {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.user-trigger:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* View transition */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,55 +1,60 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/LoginView.vue'),
|
||||
meta: { title: '登录', public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||
redirect: '/goods',
|
||||
children: [
|
||||
{
|
||||
path: 'goods',
|
||||
name: 'Goods',
|
||||
component: () => import('@/views/product-management/ProductManagementView.vue'),
|
||||
meta: { title: '商品管理' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/goods',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore()
|
||||
const isPublic = to.meta?.public === true
|
||||
|
||||
if (!authStore.isLoggedIn && !isPublic) {
|
||||
return { path: '/login', replace: true }
|
||||
}
|
||||
|
||||
if (authStore.isLoggedIn && to.path === '/login') {
|
||||
return { path: '/', replace: true }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta?.title as string) || 'Inkreach 官网后台'
|
||||
document.title = `${title} | Inkreach 官网后台`
|
||||
})
|
||||
|
||||
export default router
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/LoginView.vue'),
|
||||
meta: { title: '登录', public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||
redirect: '/goods',
|
||||
children: [
|
||||
{
|
||||
path: 'goods',
|
||||
name: 'Goods',
|
||||
component: () => import('@/views/product-management/ProductManagementView.vue'),
|
||||
meta: { title: '商品管理' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/goods',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/v2/admin/'),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore()
|
||||
const isPublic = to.meta?.public === true
|
||||
|
||||
// 会话在 HttpOnly cookie 里:刷新后先经 /auth/me 恢复登录态,再判定是否放行
|
||||
if (!authStore.isLoggedIn && !isPublic) {
|
||||
await authStore.ensureSessionChecked()
|
||||
}
|
||||
|
||||
if (!authStore.isLoggedIn && !isPublic) {
|
||||
return { path: '/login', replace: true }
|
||||
}
|
||||
|
||||
if (authStore.isLoggedIn && to.path === '/login') {
|
||||
return { path: '/', replace: true }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta?.title as string) || 'Inkreach 官网后台'
|
||||
document.title = `${title} | Inkreach 官网后台`
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const SIDEBAR_KEY = 'app:sidebarCollapsed'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const sidebarCollapsed = ref<boolean>(
|
||||
localStorage.getItem(SIDEBAR_KEY) === 'true'
|
||||
)
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
localStorage.setItem(SIDEBAR_KEY, String(sidebarCollapsed.value))
|
||||
}
|
||||
|
||||
function setSidebarCollapsed(value: boolean) {
|
||||
sidebarCollapsed.value = value
|
||||
localStorage.setItem(SIDEBAR_KEY, String(value))
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
setSidebarCollapsed,
|
||||
}
|
||||
})
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const SIDEBAR_KEY = 'app:sidebarCollapsed'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const sidebarCollapsed = ref<boolean>(
|
||||
localStorage.getItem(SIDEBAR_KEY) === 'true'
|
||||
)
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
localStorage.setItem(SIDEBAR_KEY, String(sidebarCollapsed.value))
|
||||
}
|
||||
|
||||
function setSidebarCollapsed(value: boolean) {
|
||||
sidebarCollapsed.value = value
|
||||
localStorage.setItem(SIDEBAR_KEY, String(value))
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
setSidebarCollapsed,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,76 +1,67 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { LoginRequest, User } from '@/types'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// Token persisted to localStorage
|
||||
const token = ref<string>(localStorage.getItem('token') || '')
|
||||
|
||||
// User persisted to localStorage (parsed if available)
|
||||
const user = ref<User | null>(loadUser())
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
function setUser(newUser: User | null) {
|
||||
user.value = newUser
|
||||
if (newUser) {
|
||||
localStorage.setItem('user', JSON.stringify(newUser))
|
||||
} else {
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
}
|
||||
|
||||
async function login(payload: LoginRequest) {
|
||||
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;
|
||||
if (accessToken) setToken(accessToken);
|
||||
if (userData) setUser(userData);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
const current = await authApi.getCurrentUser()
|
||||
setUser(current)
|
||||
return current
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// Ignore network errors during logout
|
||||
}
|
||||
setToken('')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isLoggedIn,
|
||||
login,
|
||||
fetchCurrentUser,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { LoginRequest, User } from '@/types'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// The session lives in HttpOnly cookies set by the API; nothing
|
||||
// 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)
|
||||
|
||||
// Tokens moved to HttpOnly cookies; clean up any stale values from the
|
||||
// previous localStorage-based session.
|
||||
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) {
|
||||
user.value = newUser
|
||||
}
|
||||
|
||||
async function login(payload: LoginRequest) {
|
||||
const res = await authApi.login(payload) as any
|
||||
const userData: User | null = res.user ?? res.data?.user ?? null
|
||||
sessionChecked.value = true
|
||||
setUser(userData)
|
||||
return res
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
const current = await authApi.getCurrentUser()
|
||||
setUser(current)
|
||||
return current
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// Ignore network errors during logout
|
||||
}
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoggedIn,
|
||||
ensureSessionChecked,
|
||||
login,
|
||||
fetchCurrentUser,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
:root {
|
||||
--brand-color: #ff6800;
|
||||
--brand-color-hover: #ff7d1f;
|
||||
--brand-color-active: #e65a00;
|
||||
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-width-collapsed: 64px;
|
||||
--header-height: 56px;
|
||||
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'PingFang SC',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
color: #1f2937;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Element Plus brand color overrides */
|
||||
:root {
|
||||
--el-color-primary: var(--brand-color);
|
||||
--el-color-primary-light-3: #ff8f3d;
|
||||
--el-color-primary-light-5: #ffb073;
|
||||
--el-color-primary-light-7: #ffd1a8;
|
||||
--el-color-primary-light-8: #ffe0c2;
|
||||
--el-color-primary-light-9: #fff0e0;
|
||||
--el-color-primary-dark-2: #e65a00;
|
||||
}
|
||||
|
||||
/* Brand-colored buttons */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--brand-color);
|
||||
--el-button-border-color: var(--brand-color);
|
||||
--el-button-hover-bg-color: var(--brand-color-hover);
|
||||
--el-button-hover-border-color: var(--brand-color-hover);
|
||||
--el-button-active-bg-color: var(--brand-color-active);
|
||||
--el-button-active-border-color: var(--brand-color-active);
|
||||
}
|
||||
|
||||
/* Page wrapper helper */
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Compact filter bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-bar .el-input,
|
||||
.filter-bar .el-select,
|
||||
.filter-bar .el-cascader {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
/* Action cell padding */
|
||||
.table-actions .el-button + .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Brand-colored dialog titles */
|
||||
.brand-title {
|
||||
color: var(--brand-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
:root {
|
||||
--brand-color: #ff6800;
|
||||
--brand-color-hover: #ff7d1f;
|
||||
--brand-color-active: #e65a00;
|
||||
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-width-collapsed: 64px;
|
||||
--header-height: 56px;
|
||||
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'PingFang SC',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
color: #1f2937;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Element Plus brand color overrides */
|
||||
:root {
|
||||
--el-color-primary: var(--brand-color);
|
||||
--el-color-primary-light-3: #ff8f3d;
|
||||
--el-color-primary-light-5: #ffb073;
|
||||
--el-color-primary-light-7: #ffd1a8;
|
||||
--el-color-primary-light-8: #ffe0c2;
|
||||
--el-color-primary-light-9: #fff0e0;
|
||||
--el-color-primary-dark-2: #e65a00;
|
||||
}
|
||||
|
||||
/* Brand-colored buttons */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--brand-color);
|
||||
--el-button-border-color: var(--brand-color);
|
||||
--el-button-hover-bg-color: var(--brand-color-hover);
|
||||
--el-button-hover-border-color: var(--brand-color-hover);
|
||||
--el-button-active-bg-color: var(--brand-color-active);
|
||||
--el-button-active-border-color: var(--brand-color-active);
|
||||
}
|
||||
|
||||
/* Page wrapper helper */
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Compact filter bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-bar .el-input,
|
||||
.filter-bar .el-select,
|
||||
.filter-bar .el-cascader {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
/* Action cell padding */
|
||||
.table-actions .el-button + .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Brand-colored dialog titles */
|
||||
.brand-title {
|
||||
color: var(--brand-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,326 +1,553 @@
|
||||
// Common types
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
// Good types
|
||||
export interface Good {
|
||||
id: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPriority: number
|
||||
originGoodId: string
|
||||
originGood?: OriginGood
|
||||
countryId: string
|
||||
country?: Country
|
||||
categoryId: string
|
||||
category?: Category
|
||||
tagId?: string
|
||||
tag?: Tag
|
||||
tags: Tag[]
|
||||
positionId?: string
|
||||
position?: Position
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateGoodRequest {
|
||||
goodName: string
|
||||
goodImage?: string
|
||||
originGoodId: number
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: number
|
||||
goodPriority?: number
|
||||
}
|
||||
|
||||
export interface UpdateGoodRequest {
|
||||
goodName?: string
|
||||
goodImage?: string | null
|
||||
originGoodId?: number
|
||||
countryId?: number
|
||||
categoryId?: number
|
||||
tagIds?: number[]
|
||||
positionId?: number | null
|
||||
goodPriority?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateItem {
|
||||
originGoodId: number
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateGoodsRequest {
|
||||
items: BatchCreateItem[]
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: number
|
||||
defaultPriority?: number
|
||||
}
|
||||
|
||||
export interface UpdatePriorityRequest {
|
||||
goodIds: string[]
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface BatchPriorityItem {
|
||||
id: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface BatchPriorityRequest {
|
||||
items: BatchPriorityItem[]
|
||||
}
|
||||
|
||||
// Country types
|
||||
export interface Country {
|
||||
id: string
|
||||
countryName: string
|
||||
countryIcon?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCountryRequest {
|
||||
countryName: string
|
||||
countryIcon?: string
|
||||
}
|
||||
|
||||
export interface UpdateCountryRequest {
|
||||
countryName?: string
|
||||
countryIcon?: string | null
|
||||
}
|
||||
|
||||
// Category types
|
||||
export interface Category {
|
||||
id: string
|
||||
categoryName: string
|
||||
categoryIcon?: string | null
|
||||
sdsCategoryId?: string | null
|
||||
parentCategoryId?: string | null
|
||||
parent?: Category
|
||||
children?: Category[]
|
||||
_count?: {
|
||||
children: number
|
||||
}
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
categoryName: string
|
||||
categoryIcon?: string
|
||||
parentCategoryId?: number
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
categoryName?: string
|
||||
categoryIcon?: string | null
|
||||
parentCategoryId?: number | null
|
||||
}
|
||||
|
||||
export interface CategoryTree extends Category {
|
||||
children?: CategoryTree[]
|
||||
}
|
||||
|
||||
// Tag types
|
||||
export interface Tag {
|
||||
id: string
|
||||
tagName: string
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
tagGroupId?: string | null
|
||||
sortOrder?: number
|
||||
tagGroup?: TagGroup | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateTagRequest {
|
||||
tagName: string
|
||||
tagColor?: string
|
||||
tagFontColor?: string
|
||||
timing?: string
|
||||
tagGroupId?: number
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface UpdateTagRequest {
|
||||
tagName?: string
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
tagGroupId?: number | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
// Tag Group types
|
||||
export interface TagGroup {
|
||||
id: string
|
||||
groupName: string
|
||||
groupIcon?: string | null
|
||||
groupColor?: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
_count?: { tags: number }
|
||||
}
|
||||
|
||||
export interface CreateTagGroupRequest {
|
||||
groupName: string
|
||||
groupIcon?: string
|
||||
groupColor?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface UpdateTagGroupRequest {
|
||||
groupName?: string
|
||||
groupIcon?: string | null
|
||||
groupColor?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface ReorderTagGroupsRequest {
|
||||
items: Array<{ id: number; sortOrder: number }>
|
||||
}
|
||||
|
||||
export interface ReorderTagsRequest {
|
||||
items: Array<{ id: number; tagGroupId?: number | null; sortOrder: number }>
|
||||
}
|
||||
|
||||
// Position types
|
||||
export interface Position {
|
||||
id: string
|
||||
indexVal: number
|
||||
countryId?: string
|
||||
country?: Country
|
||||
categoryId?: string
|
||||
category?: Category
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
indexVal: number
|
||||
countryId?: number
|
||||
categoryId?: number
|
||||
}
|
||||
|
||||
export interface UpdatePositionRequest {
|
||||
indexVal?: number
|
||||
countryId?: number | null
|
||||
categoryId?: number | null
|
||||
}
|
||||
|
||||
// Origin Good types
|
||||
export interface OriginGood {
|
||||
id: string
|
||||
goodName: string | null
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
sdsGoodId: string
|
||||
sdsCategoryId: string | null
|
||||
delisted?: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Origin Goods Tree types
|
||||
export interface OriginGoodsTreeNode {
|
||||
id: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
sdsGoodId: string
|
||||
delisted: boolean
|
||||
configuredCount: number
|
||||
configuredCountries: string[]
|
||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
sdsCategoryId: string | null
|
||||
configuredCount: number
|
||||
totalCount: number
|
||||
children: OriginGoodsTreeCategoryNode[]
|
||||
originGoods: OriginGoodsTreeNode[]
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeResponse {
|
||||
tree: OriginGoodsTreeCategoryNode[]
|
||||
totalOriginGoods: number
|
||||
configuredCount: number
|
||||
}
|
||||
|
||||
// Sync types
|
||||
export interface SyncLog {
|
||||
id: string
|
||||
type: 'CATEGORY' | 'PRODUCT'
|
||||
status: 'SUCCESS' | 'FAILED'
|
||||
message?: string
|
||||
startTime: string
|
||||
endTime?: string
|
||||
errorCount?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// Filter types
|
||||
export interface GoodsFilter {
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
tagId?: string
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CategoryFilter {
|
||||
parentCategoryId?: string
|
||||
categoryName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CountryFilter {
|
||||
countryName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface TagFilter {
|
||||
tagName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface PositionFilter {
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
// Common types
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
// Good types
|
||||
export interface Good {
|
||||
id: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPriority: number
|
||||
originGoodId: string
|
||||
originGood?: OriginGood
|
||||
countryId: string
|
||||
country?: Country
|
||||
categoryId: string
|
||||
category?: Category
|
||||
tagId?: string
|
||||
tag?: Tag
|
||||
tags: Tag[]
|
||||
positionId?: string
|
||||
position?: Position
|
||||
createdAt: 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 {
|
||||
goodName: string
|
||||
goodImage?: string
|
||||
originGoodId: number
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: 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 {
|
||||
goodName?: string
|
||||
goodImage?: string | null
|
||||
originGoodId?: number
|
||||
countryId?: number
|
||||
categoryId?: number
|
||||
tagIds?: number[]
|
||||
positionId?: number | null
|
||||
goodPriority?: number
|
||||
mergedOriginGoodIds?: number[]
|
||||
}
|
||||
|
||||
export interface BatchCreateItem {
|
||||
originGoodId: number
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateGoodsRequest {
|
||||
items: BatchCreateItem[]
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: number
|
||||
defaultPriority?: number
|
||||
}
|
||||
|
||||
export interface UpdatePriorityRequest {
|
||||
goodIds: string[]
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface BatchPriorityItem {
|
||||
id: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface BatchPriorityRequest {
|
||||
items: BatchPriorityItem[]
|
||||
}
|
||||
|
||||
// Country types
|
||||
export interface Country {
|
||||
id: string
|
||||
countryName: string
|
||||
countryIcon?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCountryRequest {
|
||||
countryName: string
|
||||
countryIcon?: string
|
||||
}
|
||||
|
||||
export interface UpdateCountryRequest {
|
||||
countryName?: string
|
||||
countryIcon?: string | null
|
||||
}
|
||||
|
||||
// Category types
|
||||
export interface Category {
|
||||
id: string
|
||||
categoryName: string
|
||||
categoryIcon?: string | null
|
||||
sdsCategoryId?: string | null
|
||||
parentCategoryId?: string | null
|
||||
parent?: Category
|
||||
children?: Category[]
|
||||
_count?: {
|
||||
children: number
|
||||
}
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
categoryName: string
|
||||
categoryIcon?: string
|
||||
parentCategoryId?: number
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
categoryName?: string
|
||||
categoryIcon?: string | null
|
||||
parentCategoryId?: number | null
|
||||
}
|
||||
|
||||
export interface CategoryTree extends Category {
|
||||
children?: CategoryTree[]
|
||||
}
|
||||
|
||||
// Tag types
|
||||
export interface Tag {
|
||||
id: string
|
||||
tagName: string
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
tagGroupId?: string | null
|
||||
sortOrder?: number
|
||||
tagGroup?: TagGroup | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateTagRequest {
|
||||
tagName: string
|
||||
tagColor?: string
|
||||
tagFontColor?: string
|
||||
timing?: string
|
||||
tagGroupId?: number
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface UpdateTagRequest {
|
||||
tagName?: string
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
tagGroupId?: number | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
// Tag Group types
|
||||
export interface TagGroup {
|
||||
id: string
|
||||
groupName: string
|
||||
groupIcon?: string | null
|
||||
groupColor?: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
_count?: { tags: number }
|
||||
}
|
||||
|
||||
export interface CreateTagGroupRequest {
|
||||
groupName: string
|
||||
groupIcon?: string
|
||||
groupColor?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface UpdateTagGroupRequest {
|
||||
groupName?: string
|
||||
groupIcon?: string | null
|
||||
groupColor?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface ReorderTagGroupsRequest {
|
||||
items: Array<{ id: number; sortOrder: number }>
|
||||
}
|
||||
|
||||
export interface ReorderTagsRequest {
|
||||
items: Array<{ id: number; tagGroupId?: number | null; sortOrder: number }>
|
||||
}
|
||||
|
||||
// Position types
|
||||
export interface Position {
|
||||
id: string
|
||||
indexVal: number
|
||||
countryId?: string
|
||||
country?: Country
|
||||
categoryId?: string
|
||||
category?: Category
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
indexVal: number
|
||||
countryId?: number
|
||||
categoryId?: number
|
||||
}
|
||||
|
||||
export interface UpdatePositionRequest {
|
||||
indexVal?: number
|
||||
countryId?: number | null
|
||||
categoryId?: number | null
|
||||
}
|
||||
|
||||
// Origin Good types
|
||||
export interface OriginGood {
|
||||
id: string
|
||||
goodName: string | null
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
sdsGoodId: string
|
||||
source: 'SDS' | 'CUSTOM'
|
||||
isCustom: boolean
|
||||
sdsCategoryId: string | null
|
||||
delisted?: boolean
|
||||
createdAt: 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
|
||||
export interface OriginGoodsTreeNode {
|
||||
id: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
sdsGoodId: string
|
||||
delisted: boolean
|
||||
configuredCount: number
|
||||
configuredCountries: string[]
|
||||
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 {
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
sdsCategoryId: string | null
|
||||
configuredCount: number
|
||||
totalCount: number
|
||||
children: OriginGoodsTreeCategoryNode[]
|
||||
originGoods: OriginGoodsTreeNode[]
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeResponse {
|
||||
tree: OriginGoodsTreeCategoryNode[]
|
||||
totalOriginGoods: 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
|
||||
export interface SyncLog {
|
||||
id: string
|
||||
type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS'
|
||||
status: 'RUNNING' | 'SUCCESS' | 'FAILED'
|
||||
message: string | null
|
||||
startedAt: string
|
||||
finishedAt: string | null
|
||||
}
|
||||
|
||||
// Filter types
|
||||
export interface GoodsFilter {
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
tagId?: string
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CategoryFilter {
|
||||
parentCategoryId?: string
|
||||
categoryName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CountryFilter {
|
||||
countryName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface TagFilter {
|
||||
tagName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface PositionFilter {
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,246 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
} from '@/types'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref<CategoryTree[]>([])
|
||||
|
||||
async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
tree.value = await categoriesApi.getCategoryTree()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(tree.value)
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||
categoryName: '',
|
||||
categoryIcon: '',
|
||||
parentCategoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function openEditDialog(c: Category) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: c.id,
|
||||
categoryName: c.categoryName,
|
||||
categoryIcon: c.categoryIcon || '',
|
||||
parentCategoryId: c.parentCategoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCategoryRequest = {
|
||||
categoryName: dialogForm.categoryName,
|
||||
categoryIcon: dialogForm.categoryIcon || undefined,
|
||||
parentCategoryId: dialogForm.parentCategoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await categoriesApi.createCategory(payload)
|
||||
ElMessage.success('分类创建成功')
|
||||
} else {
|
||||
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||
ElMessage.success('分类更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Category) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await categoriesApi.deleteCategory(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchTree()
|
||||
}
|
||||
|
||||
onMounted(fetchTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增分类</span>
|
||||
</el-button>
|
||||
<el-button @click="fetchTree">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tree"
|
||||
border
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
>
|
||||
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||
<el-table-column label="图标" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<el-image
|
||||
v-if="row.categoryIcon"
|
||||
:src="row.categoryIcon"
|
||||
:preview-src-list="[row.categoryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="父级">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row.parent?.categoryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="子级数" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无分类" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="dialogRef"
|
||||
:model="dialogForm"
|
||||
:rules="dialogRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="名称" prop="categoryName">
|
||||
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.parentCategoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="顶级(可选)"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
} from '@/types'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref<CategoryTree[]>([])
|
||||
|
||||
async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
tree.value = await categoriesApi.getCategoryTree()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(tree.value)
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||
categoryName: '',
|
||||
categoryIcon: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function openEditDialog(c: Category) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: c.id,
|
||||
categoryName: c.categoryName,
|
||||
categoryIcon: c.categoryIcon || '',
|
||||
parentCategoryId: c.parentCategoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCategoryRequest = {
|
||||
categoryName: dialogForm.categoryName,
|
||||
categoryIcon: dialogForm.categoryIcon || undefined,
|
||||
parentCategoryId: dialogForm.parentCategoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await categoriesApi.createCategory(payload)
|
||||
ElMessage.success('分类创建成功')
|
||||
} else {
|
||||
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||
ElMessage.success('分类更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Category) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await categoriesApi.deleteCategory(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchTree()
|
||||
}
|
||||
|
||||
onMounted(fetchTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增分类</span>
|
||||
</el-button>
|
||||
<el-button @click="fetchTree">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tree"
|
||||
border
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
>
|
||||
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||
<el-table-column label="图标" width="100">
|
||||
<template #default="{ row }: any">
|
||||
<el-image
|
||||
v-if="row.categoryIcon"
|
||||
:src="row.categoryIcon"
|
||||
:preview-src-list="[row.categoryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="父级">
|
||||
<template #default="{ row }: any">
|
||||
{{ row.parent?.categoryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="子级数" width="100">
|
||||
<template #default="{ row }: any">
|
||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: any">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无分类" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="dialogRef"
|
||||
:model="dialogForm"
|
||||
:rules="dialogRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="名称" prop="categoryName">
|
||||
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.parentCategoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="顶级(可选)"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,231 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
} from '@/types'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Country[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<CountryFilter>>({
|
||||
countryName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await countriesApi.getCountriesList(filter)
|
||||
const data = await countriesApi.getCountriesList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||
countryName: '',
|
||||
countryIcon: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(c: Country) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCountryRequest = {
|
||||
countryName: dialogForm.countryName,
|
||||
countryIcon: dialogForm.countryIcon || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await countriesApi.createCountry(payload)
|
||||
ElMessage.success('国家创建成功')
|
||||
} else {
|
||||
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||
ElMessage.success('国家更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Country) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await countriesApi.deleteCountry(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.countryName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增国家</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="图标" width="80">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<el-image
|
||||
v-if="row.countryIcon"
|
||||
:src="row.countryIcon"
|
||||
:preview-src-list="[row.countryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<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>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="countryName">
|
||||
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search, Rank } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
} from '@/types'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Country[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<CountryFilter>>({
|
||||
countryName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
await countriesApi.getCountriesList(filter)
|
||||
const data = await countriesApi.getCountriesList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryName = ''
|
||||
filter.page = 1
|
||||
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 dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||
countryName: '',
|
||||
countryIcon: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(c: Country) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCountryRequest = {
|
||||
countryName: dialogForm.countryName,
|
||||
countryIcon: dialogForm.countryIcon || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await countriesApi.createCountry(payload)
|
||||
ElMessage.success('国家创建成功')
|
||||
} else {
|
||||
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||
ElMessage.success('国家更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Country) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await countriesApi.deleteCountry(c.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.countryName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增国家</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<ul v-loading="loading" class="country-list">
|
||||
<li
|
||||
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
|
||||
v-if="row.countryIcon"
|
||||
:src="row.countryIcon"
|
||||
:preview-src-list="[row.countryIcon]"
|
||||
fit="cover"
|
||||
class="row-icon"
|
||||
/>
|
||||
<span v-else class="row-icon row-icon-placeholder">-</span>
|
||||
<span class="row-name">{{ row.countryName }}</span>
|
||||
<span class="row-time">{{ new Date(row.createdAt).toLocaleString() }}</span>
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!loading && !list.length" class="country-empty">暂无国家</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="countryName">
|
||||
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.country-list {
|
||||
list-style: none;
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,181 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login({ username: form.username, password: form.password })
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
// Error toast is shown by axios response interceptor
|
||||
console.error('Login failed', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg" />
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="brand-logo">Inkreach</div>
|
||||
<div class="brand-subtitle">官网后台</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
label-position="top"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
autocomplete="username"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
native-type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>© {{ new Date().getFullYear() }} Inkreach</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
|
||||
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 40px 36px 28px;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--brand-color);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login({ username: form.username, password: form.password })
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
// Error toast is shown by axios response interceptor
|
||||
console.error('Login failed', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg" />
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="brand-logo">Inkreach</div>
|
||||
<div class="brand-subtitle">官网后台</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
label-position="top"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
autocomplete="username"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
native-type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>© {{ new Date().getFullYear() }} Inkreach</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
|
||||
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 40px 36px 28px;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--brand-color);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,317 +1,314 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
Country,
|
||||
Category,
|
||||
CategoryTree,
|
||||
} from '@/types'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Position[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const countries = ref<Country[]>([])
|
||||
const categoriesTree = ref<CategoryTree[]>([])
|
||||
|
||||
const filter = reactive<Required<PositionFilter>>({
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function loadLookups() {
|
||||
const [c, ct] = await Promise.all([
|
||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||
categoriesApi.getCategoryTree(),
|
||||
])
|
||||
countries.value = c.items
|
||||
categoriesTree.value = ct
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await positionsApi.getPositionsList(filter)
|
||||
const data = await positionsApi.getPositionsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryId = ''
|
||||
filter.categoryId = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||
indexVal: 0,
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(categoriesTree.value)
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(p: Position) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: p.id,
|
||||
indexVal: p.indexVal,
|
||||
countryId: p.countryId || '',
|
||||
categoryId: p.categoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreatePositionRequest = {
|
||||
indexVal: Number(dialogForm.indexVal),
|
||||
countryId: dialogForm.countryId || undefined,
|
||||
categoryId: dialogForm.categoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await positionsApi.createPosition(payload)
|
||||
ElMessage.success('位置创建成功')
|
||||
} else {
|
||||
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||
ElMessage.success('位置更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(p: Position) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await positionsApi.deletePosition(p.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function getCategoryName(p: Position): string {
|
||||
if (!p.category) return p.categoryId || '-'
|
||||
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLookups()
|
||||
await fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="filter.countryId"
|
||||
placeholder="请选择国家"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-cascader
|
||||
v-model="filter.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||
placeholder="请选择分类"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增位置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||
<el-table-column label="国家" min-width="160">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ row.country?.countryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" min-width="200">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ getCategoryName(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<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>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="排序值" prop="indexVal">
|
||||
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="可选"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
Country,
|
||||
CategoryTree,
|
||||
} from '@/types'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Position[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const countries = ref<Country[]>([])
|
||||
const categoriesTree = ref<CategoryTree[]>([])
|
||||
|
||||
const filter = reactive<Required<PositionFilter>>({
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function loadLookups() {
|
||||
const [c, ct] = await Promise.all([
|
||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||
categoriesApi.getCategoryTree(),
|
||||
])
|
||||
countries.value = c.items
|
||||
categoriesTree.value = ct
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
await positionsApi.getPositionsList(filter)
|
||||
const data = await positionsApi.getPositionsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryId = ''
|
||||
filter.categoryId = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||
indexVal: 0,
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(categoriesTree.value)
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(p: Position) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: p.id,
|
||||
indexVal: p.indexVal,
|
||||
countryId: p.countryId || '',
|
||||
categoryId: p.categoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreatePositionRequest = {
|
||||
indexVal: Number(dialogForm.indexVal),
|
||||
countryId: dialogForm.countryId || undefined,
|
||||
categoryId: dialogForm.categoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await positionsApi.createPosition(payload)
|
||||
ElMessage.success('位置创建成功')
|
||||
} else {
|
||||
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||
ElMessage.success('位置更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(p: Position) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await positionsApi.deletePosition(p.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function getCategoryName(p: Position): string {
|
||||
if (!p.category) return p.categoryId || '-'
|
||||
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLookups()
|
||||
await fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="filter.countryId"
|
||||
placeholder="请选择国家"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-cascader
|
||||
v-model="filter.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||
placeholder="请选择分类"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增位置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||
<el-table-column label="国家" min-width="160">
|
||||
<template #default="{ row }: any">
|
||||
{{ row.country?.countryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" min-width="200">
|
||||
<template #default="{ row }: any">
|
||||
{{ getCategoryName(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: any">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<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>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="排序值" prop="indexVal">
|
||||
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="可选"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { markRaw, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Goods, Refresh } from '@element-plus/icons-vue'
|
||||
import GoodsView from '@/views/goods/GoodsView.vue'
|
||||
import SyncView from '@/views/sync/SyncView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = [
|
||||
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
|
||||
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
|
||||
]
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'goods')
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
router.replace({ query: { ...route.query, tab: val } })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pm-wrapper">
|
||||
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
|
||||
<el-tab-pane
|
||||
v-for="t in tabs"
|
||||
:key="t.name"
|
||||
:name="t.name"
|
||||
>
|
||||
<template #label>
|
||||
<span class="pm-tab-label">
|
||||
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
|
||||
{{ t.label }}
|
||||
</span>
|
||||
</template>
|
||||
<component :is="t.comp" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
|
||||
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
|
||||
.pm-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pm-tab-icon { font-size: 14px; }
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { markRaw, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Goods, Refresh } from '@element-plus/icons-vue'
|
||||
import GoodsView from '@/views/goods/GoodsView.vue'
|
||||
import SyncView from '@/views/sync/SyncView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = [
|
||||
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
|
||||
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
|
||||
]
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'goods')
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
router.replace({ query: { ...route.query, tab: val } })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pm-wrapper">
|
||||
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
|
||||
<el-tab-pane
|
||||
v-for="t in tabs"
|
||||
:key="t.name"
|
||||
:name="t.name"
|
||||
>
|
||||
<template #label>
|
||||
<span class="pm-tab-label">
|
||||
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
|
||||
{{ t.label }}
|
||||
</span>
|
||||
</template>
|
||||
<component :is="t.comp" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
|
||||
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
|
||||
.pm-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pm-tab-icon { font-size: 14px; }
|
||||
</style>
|
||||
|
||||
@@ -1,451 +1,495 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Box, Clock, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
|
||||
import type { SyncLog } from '@/types'
|
||||
import { syncApi } from '@/api/sync'
|
||||
|
||||
const logs = ref<SyncLog[]>([])
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const currentType = ref<'PRODUCTS' | 'CATEGORIES'>('PRODUCTS')
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(50) as any
|
||||
logs.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch sync status', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pollUntilDone(type: 'PRODUCTS' | 'CATEGORIES') {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(5) as any
|
||||
const latest = Array.isArray(data) ? data : []
|
||||
if (latest.length > 0) logs.value = [...latest, ...logs.value.slice(latest.length)]
|
||||
const top = latest.find((l: SyncLog) => l.type === type)
|
||||
if (top && top.status !== 'RUNNING') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
syncing.value = false
|
||||
if (top.status === 'SUCCESS') {
|
||||
ElMessage.success(`${type === 'PRODUCTS' ? '产品' : '分类'}同步完成`)
|
||||
} else {
|
||||
ElMessage.error(`${type === 'PRODUCTS' ? '产品' : '分类'}同步失败`)
|
||||
}
|
||||
await refreshLogs()
|
||||
}
|
||||
} catch { /* ignore poll errors */ }
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function handleSyncProducts() {
|
||||
await doSync('PRODUCTS')
|
||||
}
|
||||
|
||||
async function handleSyncCategories() {
|
||||
await doSync('CATEGORIES')
|
||||
}
|
||||
|
||||
async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
|
||||
const label = type === 'PRODUCTS' ? '产品' : '分类'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定立即执行${label}同步吗?${type === 'PRODUCTS' ? '此操作可能需要几分钟。' : ''}`,
|
||||
'确认',
|
||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
syncing.value = true
|
||||
currentType.value = type
|
||||
try {
|
||||
if (type === 'PRODUCTS') {
|
||||
await syncApi.syncProducts()
|
||||
} else {
|
||||
await syncApi.syncCategories()
|
||||
}
|
||||
ElMessage.info(`${label}同步已开始`)
|
||||
pollUntilDone(type)
|
||||
} catch {
|
||||
ElMessage.error(`${label}同步启动失败`)
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(s?: string): string {
|
||||
if (!s) return '-'
|
||||
const d = new Date(s)
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDuration(start?: string, end?: string): string | null {
|
||||
if (!start || !end) return null
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = logs.value.length
|
||||
const success = logs.value.filter(l => l.status === 'SUCCESS').length
|
||||
const failed = total - success
|
||||
const lastLog = logs.value[0]
|
||||
return { total, success, failed, lastLog }
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refreshLogs()
|
||||
timer = setInterval(refreshLogs, 60_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sync-page" v-loading="loading">
|
||||
<!-- Action Card -->
|
||||
<div class="sync-action-card">
|
||||
<div class="sync-action-info">
|
||||
<div class="sync-action-icon">
|
||||
<el-icon :size="28"><Box /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="sync-action-title">数据同步</h2>
|
||||
<p class="sync-action-desc">从上游 SDS 系统拉取最新分类和产品数据并同步到本地数据库</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sync-action-buttons">
|
||||
<el-button
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'CATEGORIES'"
|
||||
:disabled="syncing"
|
||||
@click="handleSyncCategories"
|
||||
>
|
||||
同步分类
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'PRODUCTS'"
|
||||
:disabled="syncing"
|
||||
:icon="Refresh"
|
||||
@click="handleSyncProducts"
|
||||
>
|
||||
同步产品
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="sync-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ stats.total }}</span>
|
||||
<span class="stat-label">总同步次数</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-success">{{ stats.success }}</span>
|
||||
<span class="stat-label">成功</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-failed">{{ stats.failed }}</span>
|
||||
<span class="stat-label">失败</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startTime) : '-' }}</span>
|
||||
<span class="stat-label">最近同步</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Timeline -->
|
||||
<div class="sync-logs">
|
||||
<div class="sync-logs-head">
|
||||
<h3 class="sync-logs-title">同步日志</h3>
|
||||
<el-button text :icon="Refresh" @click="refreshLogs">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0 && !loading" class="sync-empty">
|
||||
<el-empty description="暂无同步记录" :image-size="80" />
|
||||
</div>
|
||||
|
||||
<div v-else class="sync-timeline">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="timeline-item"
|
||||
>
|
||||
<div class="timeline-dot" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
<el-icon :size="12">
|
||||
<Loading v-if="log.status === 'RUNNING'" />
|
||||
<CircleCheck v-else-if="log.status === 'SUCCESS'" />
|
||||
<CircleClose v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="timeline-type">{{ log.type === 'PRODUCTS' ? '产品' : '分类' }}</span>
|
||||
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||
</span>
|
||||
<span v-if="formatDuration(log.startTime, log.endTime)" class="timeline-duration">
|
||||
<el-icon :size="11"><Clock /></el-icon>
|
||||
{{ formatDuration(log.startTime, log.endTime) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
|
||||
<span class="timeline-time">{{ formatTime(log.startTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-page {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Action Card */
|
||||
.sync-action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sync-action-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sync-action-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #fff2e8, #ffe0c2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--brand-color, #ff6800);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-action-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-action-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.sync-action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.sync-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 16px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-value.stat-success { color: #67c23a; }
|
||||
.stat-value.stat-failed { color: #f56c6c; }
|
||||
.stat-value.stat-time { font-size: 14px; font-weight: 600; color: #606266; }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: #ebeef5;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.sync-logs {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sync-logs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.sync-logs-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-empty {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.sync-timeline {
|
||||
padding: 16px 20px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-item:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 22px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.timeline-dot.is-success {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.timeline-dot.is-failed {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.timeline-dot.is-running {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-status.is-success { color: #67c23a; }
|
||||
.timeline-status.is-failed { color: #f56c6c; }
|
||||
.timeline-status.is-running { color: #409eff; }
|
||||
|
||||
.timeline-type {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-duration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.timeline-message {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Box, Clock, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
|
||||
import type { SyncLog } from '@/types'
|
||||
import { syncApi } from '@/api/sync'
|
||||
|
||||
const logs = ref<SyncLog[]>([])
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
type SyncType = 'PRODUCTS' | 'CATEGORIES' | 'PRODUCT_DETAILS'
|
||||
const currentType = ref<SyncType>('PRODUCTS')
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(50) as any
|
||||
logs.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch sync status', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncTypeLabel(type: SyncType): string {
|
||||
if (type === 'CATEGORIES') return '分类'
|
||||
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
|
||||
return '商品列表'
|
||||
}
|
||||
|
||||
async function pollUntilDone(type: SyncType) {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const data = await syncApi.getSyncStatus(5) as any
|
||||
const latest = Array.isArray(data) ? data : []
|
||||
if (latest.length > 0) logs.value = [...latest, ...logs.value.slice(latest.length)]
|
||||
const top = latest.find((l: SyncLog) => l.type === type)
|
||||
if (top && top.status !== 'RUNNING') {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
syncing.value = false
|
||||
if (top.status === 'SUCCESS') {
|
||||
ElMessage.success(`${syncTypeLabel(type)}同步完成`)
|
||||
} else {
|
||||
ElMessage.error(`${syncTypeLabel(type)}同步失败`)
|
||||
}
|
||||
await refreshLogs()
|
||||
}
|
||||
} catch { /* ignore poll errors */ }
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function handleSyncProducts() {
|
||||
await doSync('PRODUCTS')
|
||||
}
|
||||
|
||||
async function handleSyncCategories() {
|
||||
await doSync('CATEGORIES')
|
||||
}
|
||||
|
||||
async function handleSyncProductDetails() {
|
||||
await doSync('PRODUCT_DETAILS')
|
||||
}
|
||||
|
||||
async function doSync(type: SyncType) {
|
||||
const label = syncTypeLabel(type)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
||||
'确认',
|
||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
syncing.value = true
|
||||
currentType.value = type
|
||||
try {
|
||||
if (type === 'PRODUCTS') {
|
||||
await syncApi.syncProducts()
|
||||
} else if (type === 'PRODUCT_DETAILS') {
|
||||
await syncApi.syncProductDetails()
|
||||
} else {
|
||||
await syncApi.syncCategories()
|
||||
}
|
||||
ElMessage.info(`${label}同步已开始`)
|
||||
pollUntilDone(type)
|
||||
} catch {
|
||||
ElMessage.error(`${label}同步启动失败`)
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(s?: string): string {
|
||||
if (!s) return '-'
|
||||
const d = new Date(s)
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDuration(start?: string, end?: string): string | null {
|
||||
if (!start || !end) return null
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = logs.value.length
|
||||
const success = logs.value.filter(l => l.status === 'SUCCESS').length
|
||||
const failed = logs.value.filter(l => l.status === 'FAILED').length
|
||||
const lastLog = logs.value[0]
|
||||
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(() => {
|
||||
refreshLogs()
|
||||
timer = setInterval(refreshLogs, 60_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sync-page" v-loading="loading">
|
||||
<!-- Action Card -->
|
||||
<div class="sync-action-card">
|
||||
<div class="sync-action-info">
|
||||
<div class="sync-action-icon">
|
||||
<el-icon :size="28"><Box /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="sync-action-title">数据同步</h2>
|
||||
<p class="sync-action-desc">分类和产品每小时自动同步;全部 SDS 原产品详情每天 03:30 自动同步,也可手动执行</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sync-action-buttons">
|
||||
<el-button
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'CATEGORIES'"
|
||||
:disabled="syncing"
|
||||
@click="handleSyncCategories"
|
||||
>
|
||||
同步分类
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'PRODUCTS'"
|
||||
:disabled="syncing"
|
||||
:icon="Refresh"
|
||||
@click="handleSyncProducts"
|
||||
>
|
||||
同步产品
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
:loading="syncing && currentType === 'PRODUCT_DETAILS'"
|
||||
:disabled="syncing"
|
||||
:icon="Refresh"
|
||||
@click="handleSyncProductDetails"
|
||||
>
|
||||
同步全部原产品详情
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="sync-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ stats.total }}</span>
|
||||
<span class="stat-label">总同步次数</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-success">{{ stats.success }}</span>
|
||||
<span class="stat-label">成功</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-failed">{{ stats.failed }}</span>
|
||||
<span class="stat-label">失败</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startedAt) : '-' }}</span>
|
||||
<span class="stat-label">最近同步</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Timeline -->
|
||||
<div class="sync-logs">
|
||||
<div class="sync-logs-head">
|
||||
<h3 class="sync-logs-title">同步日志</h3>
|
||||
<el-button text :icon="Refresh" @click="refreshLogs">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="logs.length === 0 && !loading" class="sync-empty">
|
||||
<el-empty description="暂无同步记录" :image-size="80" />
|
||||
</div>
|
||||
|
||||
<div v-else class="sync-timeline">
|
||||
<div
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="timeline-item"
|
||||
>
|
||||
<div class="timeline-dot" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||
<el-icon :size="12">
|
||||
<Loading v-if="log.status === 'RUNNING'" />
|
||||
<CircleCheck v-else-if="log.status === 'SUCCESS'" />
|
||||
<CircleClose v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<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')">
|
||||
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||
</span>
|
||||
<span v-if="formatDuration(log.startedAt, log.finishedAt || undefined)" class="timeline-duration">
|
||||
<el-icon :size="11"><Clock /></el-icon>
|
||||
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="log.message" class="timeline-message" :title="log.message">{{ humanizeSyncMessage(log.message) }}</p>
|
||||
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-page {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Action Card */
|
||||
.sync-action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sync-action-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sync-action-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #fff2e8, #ffe0c2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--brand-color, #ff6800);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-action-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-action-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.sync-action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.sync-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 16px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-value.stat-success { color: #67c23a; }
|
||||
.stat-value.stat-failed { color: #f56c6c; }
|
||||
.stat-value.stat-time { font-size: 14px; font-weight: 600; color: #606266; }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: #ebeef5;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.sync-logs {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sync-logs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.sync-logs-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sync-empty {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.sync-timeline {
|
||||
padding: 16px 20px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-item:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 22px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.timeline-dot.is-success {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.timeline-dot.is-failed {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.timeline-dot.is-running {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-status.is-success { color: #67c23a; }
|
||||
.timeline-status.is-failed { color: #f56c6c; }
|
||||
.timeline-status.is-running { color: #409eff; }
|
||||
|
||||
.timeline-type {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-duration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.timeline-message {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,324 +1,323 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Tag,
|
||||
TagGroup,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
} from '@/types'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
import { tagGroupsApi } from '@/api/tag-groups'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
const allGroups = ref<TagGroup[]>([])
|
||||
|
||||
const filter = reactive<Required<TagFilter>>({
|
||||
tagName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await tagsApi.getTagsList(filter)
|
||||
const data = await tagsApi.getTagsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
allGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.tagName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
|
||||
id: undefined,
|
||||
tagName: '',
|
||||
tagColor: '#ff6800',
|
||||
tagFontColor: '#ffffff',
|
||||
timing: '',
|
||||
tagGroupId: null,
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(t: Tag) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: t.id,
|
||||
tagName: t.tagName,
|
||||
tagColor: t.tagColor || '#ff6800',
|
||||
tagFontColor: t.tagFontColor || '#ffffff',
|
||||
timing: t.timing || '',
|
||||
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateTagRequest = {
|
||||
tagName: dialogForm.tagName,
|
||||
tagColor: dialogForm.tagColor || undefined,
|
||||
tagFontColor: dialogForm.tagFontColor || undefined,
|
||||
timing: dialogForm.timing || undefined,
|
||||
tagGroupId: dialogForm.tagGroupId ?? undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await tagsApi.createTag(payload)
|
||||
ElMessage.success('标签创建成功')
|
||||
} else {
|
||||
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||
ElMessage.success('标签更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(t: Tag) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await tagsApi.deleteTag(t.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.tagName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增标签</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="预览" width="120">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<span
|
||||
v-if="row.tagColor"
|
||||
class="tag-preview"
|
||||
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
|
||||
>{{ row.tagName }}</span>
|
||||
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||
<el-table-column label="背景色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字体色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属分组" min-width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
||||
<span v-else style="color: #999;">未分组</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<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>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="tagName">
|
||||
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="背景色">
|
||||
<el-color-picker v-model="dialogForm.tagColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="字体色">
|
||||
<el-color-picker v-model="dialogForm.tagFontColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属分组">
|
||||
<el-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
|
||||
<el-option
|
||||
v-for="g in allGroups"
|
||||
:key="g.id"
|
||||
:label="g.groupName"
|
||||
:value="Number(g.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="定时">
|
||||
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.color-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.color-readout {
|
||||
margin-left: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tag-preview {
|
||||
display: inline-block;
|
||||
padding: 2px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Tag,
|
||||
TagGroup,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
} from '@/types'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
import { tagGroupsApi } from '@/api/tag-groups'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
const allGroups = ref<TagGroup[]>([])
|
||||
|
||||
const filter = reactive<Required<TagFilter>>({
|
||||
tagName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
await tagsApi.getTagsList(filter)
|
||||
const data = await tagsApi.getTagsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
allGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.tagName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
|
||||
id: undefined,
|
||||
tagName: '',
|
||||
tagColor: '#ff6800',
|
||||
tagFontColor: '#ffffff',
|
||||
timing: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(t: Tag) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: t.id,
|
||||
tagName: t.tagName,
|
||||
tagColor: t.tagColor || '#ff6800',
|
||||
tagFontColor: t.tagFontColor || '#ffffff',
|
||||
timing: t.timing || '',
|
||||
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateTagRequest = {
|
||||
tagName: dialogForm.tagName,
|
||||
tagColor: dialogForm.tagColor || undefined,
|
||||
tagFontColor: dialogForm.tagFontColor || undefined,
|
||||
timing: dialogForm.timing || undefined,
|
||||
tagGroupId: dialogForm.tagGroupId ?? undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await tagsApi.createTag(payload)
|
||||
ElMessage.success('标签创建成功')
|
||||
} else {
|
||||
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||
ElMessage.success('标签更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(t: Tag) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await tagsApi.deleteTag(t.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.tagName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新增标签</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="预览" width="120">
|
||||
<template #default="{ row }: any">
|
||||
<span
|
||||
v-if="row.tagColor"
|
||||
class="tag-preview"
|
||||
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
|
||||
>{{ row.tagName }}</span>
|
||||
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||
<el-table-column label="背景色" width="140">
|
||||
<template #default="{ row }: any">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="字体色" width="140">
|
||||
<template #default="{ row }: any">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属分组" min-width="140">
|
||||
<template #default="{ row }: any">
|
||||
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
||||
<span v-else style="color: #999;">未分组</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: any">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<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>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="tagName">
|
||||
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="背景色">
|
||||
<el-color-picker v-model="dialogForm.tagColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="字体色">
|
||||
<el-color-picker v-model="dialogForm.tagFontColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属分组">
|
||||
<el-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
|
||||
<el-option
|
||||
v-for="g in allGroups"
|
||||
:key="g.id"
|
||||
:label="g.groupName"
|
||||
:value="Number(g.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="定时">
|
||||
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.color-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.color-readout {
|
||||
margin-left: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tag-preview {
|
||||
display: inline-block;
|
||||
padding: 2px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client", "node"],
|
||||
|
||||
/* Path aliases */
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"src/auto-imports.d.ts",
|
||||
"src/components.d.ts"
|
||||
]
|
||||
}
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client", "node"],
|
||||
|
||||
/* Path aliases */
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"src/auto-imports.d.ts",
|
||||
"src/components.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,46 +1,54 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/components.d.ts',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/assets': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/v2/admin/',
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/components.d.ts',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
// 端口被占时直接报错退出,而不是静默换端口导致浏览器看到旧实例
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
// Swagger 挂载在后端 /api/docs(不走去掉 /api 前缀的改写),需放在 '/api' 之前
|
||||
'/api/docs': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/assets': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
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
|
||||
@@ -1,25 +1,25 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
@@ -1,48 +1,48 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Prisma
|
||||
# prisma/migrations (keep migrations in VCS)
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Uploads
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Prisma
|
||||
# prisma/migrations (keep migrations in VCS)
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Uploads
|
||||
/uploads
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
module.exports = {
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
coveragePathIgnorePatterns: ['/node_modules/', '/test/'],
|
||||
module.exports = {
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
coveragePathIgnorePatterns: ['/node_modules/', '/test/'],
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": false
|
||||
}
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": false
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,14 @@
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"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": {
|
||||
"@nestjs/axios": "^3.0.1",
|
||||
@@ -33,13 +40,16 @@
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.1.17",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^5.8.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"axios": "^1.6.5",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^4.21.0",
|
||||
"helmet": "^8.3.0",
|
||||
"multer": "^2.2.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
@@ -51,6 +61,7 @@
|
||||
"@nestjs/schematics": "^10.0.3",
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@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);
|
||||
});
|
||||
@@ -1,54 +1,54 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const countryIcons: Record<string, string> = {
|
||||
美国: '/assets/product-center/countries/us.png',
|
||||
日本: '/assets/product-center/countries/jp.png',
|
||||
墨西哥: '/assets/product-center/countries/mx.png',
|
||||
巴西: '/assets/product-center/countries/br.png',
|
||||
中东: '/assets/product-center/countries/middle-east.png',
|
||||
波兰: '/assets/product-center/countries/pl.png',
|
||||
西班牙: '/assets/product-center/countries/es.png',
|
||||
德国: '/assets/product-center/countries/de.png',
|
||||
意大利: '/assets/product-center/countries/it.png',
|
||||
英国: '/assets/product-center/countries/gb.png',
|
||||
加拿大: '/assets/product-center/countries/ca.png',
|
||||
澳大利亚: '/assets/product-center/countries/au.png',
|
||||
韩国: '/assets/product-center/countries/kr.png',
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, string> = {
|
||||
男士服装: '/assets/product-center/categories/men.svg',
|
||||
女士服装: '/assets/product-center/categories/women.svg',
|
||||
儿童服装: '/assets/product-center/categories/children.svg',
|
||||
家居配饰: '/assets/product-center/categories/home.svg',
|
||||
};
|
||||
|
||||
async function updateExistingIcons(
|
||||
entries: Record<string, string>,
|
||||
update: (name: string, icon: string) => Promise<{ count: number }>,
|
||||
): Promise<number> {
|
||||
let updated = 0;
|
||||
for (const [name, icon] of Object.entries(entries)) {
|
||||
const result = await update(name, icon);
|
||||
updated += result.count;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const countries = await updateExistingIcons(countryIcons, (countryName, countryIcon) =>
|
||||
prisma.country.updateMany({ where: { countryName }, data: { countryIcon } }),
|
||||
);
|
||||
const categories = await updateExistingIcons(categoryIcons, (categoryName, categoryIcon) =>
|
||||
prisma.category.updateMany({
|
||||
where: { categoryName, parentCategoryId: null },
|
||||
data: { categoryIcon },
|
||||
}),
|
||||
);
|
||||
console.log(`Configured ${countries} countries and ${categories} root categories.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.finally(async () => prisma.$disconnect());
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const countryIcons: Record<string, string> = {
|
||||
美国: '/assets/product-center/countries/us.png',
|
||||
日本: '/assets/product-center/countries/jp.png',
|
||||
墨西哥: '/assets/product-center/countries/mx.png',
|
||||
巴西: '/assets/product-center/countries/br.png',
|
||||
中东: '/assets/product-center/countries/middle-east.png',
|
||||
波兰: '/assets/product-center/countries/pl.png',
|
||||
西班牙: '/assets/product-center/countries/es.png',
|
||||
德国: '/assets/product-center/countries/de.png',
|
||||
意大利: '/assets/product-center/countries/it.png',
|
||||
英国: '/assets/product-center/countries/gb.png',
|
||||
加拿大: '/assets/product-center/countries/ca.png',
|
||||
澳大利亚: '/assets/product-center/countries/au.png',
|
||||
韩国: '/assets/product-center/countries/kr.png',
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, string> = {
|
||||
男士服装: '/assets/product-center/categories/men.svg',
|
||||
女士服装: '/assets/product-center/categories/women.svg',
|
||||
儿童服装: '/assets/product-center/categories/children.svg',
|
||||
家居配饰: '/assets/product-center/categories/home.svg',
|
||||
};
|
||||
|
||||
async function updateExistingIcons(
|
||||
entries: Record<string, string>,
|
||||
update: (name: string, icon: string) => Promise<{ count: number }>,
|
||||
): Promise<number> {
|
||||
let updated = 0;
|
||||
for (const [name, icon] of Object.entries(entries)) {
|
||||
const result = await update(name, icon);
|
||||
updated += result.count;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const countries = await updateExistingIcons(countryIcons, (countryName, countryIcon) =>
|
||||
prisma.country.updateMany({ where: { countryName }, data: { countryIcon } }),
|
||||
);
|
||||
const categories = await updateExistingIcons(categoryIcons, (categoryName, categoryIcon) =>
|
||||
prisma.category.updateMany({
|
||||
where: { categoryName, parentCategoryId: null },
|
||||
data: { categoryIcon },
|
||||
}),
|
||||
);
|
||||
console.log(`Configured ${countries} countries and ${categories} root categories.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.finally(async () => prisma.$disconnect());
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -1,187 +1,187 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SyncType" AS ENUM ('CATEGORIES', 'PRODUCTS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SyncStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "origin_goods" (
|
||||
"origin_good_id" BIGSERIAL NOT NULL,
|
||||
"sds_good_id" TEXT NOT NULL,
|
||||
"sds_category_id" TEXT,
|
||||
"good_name" TEXT,
|
||||
"good_image" TEXT,
|
||||
"good_price" DECIMAL(12,2),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "origin_goods_pkey" PRIMARY KEY ("origin_good_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "countries" (
|
||||
"country_id" BIGSERIAL NOT NULL,
|
||||
"country_name" TEXT NOT NULL,
|
||||
"country_icon" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "countries_pkey" PRIMARY KEY ("country_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "categories" (
|
||||
"category_id" BIGSERIAL NOT NULL,
|
||||
"parent_category_id" BIGINT,
|
||||
"category_name" TEXT NOT NULL,
|
||||
"category_icon" TEXT,
|
||||
"sds_category_id" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "categories_pkey" PRIMARY KEY ("category_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tags" (
|
||||
"tag_id" BIGSERIAL NOT NULL,
|
||||
"tag_name" TEXT NOT NULL,
|
||||
"tag_color" TEXT,
|
||||
"timing" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tags_pkey" PRIMARY KEY ("tag_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "positions" (
|
||||
"position_id" BIGSERIAL NOT NULL,
|
||||
"index_val" INTEGER NOT NULL,
|
||||
"country_id" BIGINT,
|
||||
"category_id" BIGINT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "positions_pkey" PRIMARY KEY ("position_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "goods" (
|
||||
"good_id" BIGSERIAL NOT NULL,
|
||||
"origin_good_id" BIGINT NOT NULL,
|
||||
"country_id" BIGINT NOT NULL,
|
||||
"category_id" BIGINT NOT NULL,
|
||||
"tag_id" BIGINT,
|
||||
"position_id" BIGINT,
|
||||
"good_name" TEXT NOT NULL,
|
||||
"good_priority" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "goods_pkey" PRIMARY KEY ("good_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sync_logs" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"type" "SyncType" NOT NULL,
|
||||
"status" "SyncStatus" NOT NULL,
|
||||
"message" TEXT,
|
||||
"started_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "sync_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "origin_goods_sds_good_id_key" ON "origin_goods"("sds_good_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "origin_goods_sds_category_id_idx" ON "origin_goods"("sds_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "countries_country_name_key" ON "countries"("country_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "categories_sds_category_id_key" ON "categories"("sds_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "categories_parent_category_id_idx" ON "categories"("parent_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tags_tag_name_key" ON "tags"("tag_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_country_id_idx" ON "positions"("country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_category_id_idx" ON "positions"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_country_id_category_id_idx" ON "positions"("country_id", "category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_origin_good_id_idx" ON "goods"("origin_good_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_country_id_idx" ON "goods"("country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_category_id_idx" ON "goods"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_tag_id_idx" ON "goods"("tag_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_position_id_idx" ON "goods"("position_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_good_priority_idx" ON "goods"("good_priority" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_country_id_good_priority_idx" ON "goods"("country_id", "good_priority" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_category_id_country_id_idx" ON "goods"("category_id", "country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sync_logs_type_started_at_idx" ON "sync_logs"("type", "started_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_category_id_fkey" FOREIGN KEY ("parent_category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "positions" ADD CONSTRAINT "positions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "positions" ADD CONSTRAINT "positions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_position_id_fkey" FOREIGN KEY ("position_id") REFERENCES "positions"("position_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SyncType" AS ENUM ('CATEGORIES', 'PRODUCTS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SyncStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "origin_goods" (
|
||||
"origin_good_id" BIGSERIAL NOT NULL,
|
||||
"sds_good_id" TEXT NOT NULL,
|
||||
"sds_category_id" TEXT,
|
||||
"good_name" TEXT,
|
||||
"good_image" TEXT,
|
||||
"good_price" DECIMAL(12,2),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "origin_goods_pkey" PRIMARY KEY ("origin_good_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "countries" (
|
||||
"country_id" BIGSERIAL NOT NULL,
|
||||
"country_name" TEXT NOT NULL,
|
||||
"country_icon" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "countries_pkey" PRIMARY KEY ("country_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "categories" (
|
||||
"category_id" BIGSERIAL NOT NULL,
|
||||
"parent_category_id" BIGINT,
|
||||
"category_name" TEXT NOT NULL,
|
||||
"category_icon" TEXT,
|
||||
"sds_category_id" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "categories_pkey" PRIMARY KEY ("category_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tags" (
|
||||
"tag_id" BIGSERIAL NOT NULL,
|
||||
"tag_name" TEXT NOT NULL,
|
||||
"tag_color" TEXT,
|
||||
"timing" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tags_pkey" PRIMARY KEY ("tag_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "positions" (
|
||||
"position_id" BIGSERIAL NOT NULL,
|
||||
"index_val" INTEGER NOT NULL,
|
||||
"country_id" BIGINT,
|
||||
"category_id" BIGINT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "positions_pkey" PRIMARY KEY ("position_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "goods" (
|
||||
"good_id" BIGSERIAL NOT NULL,
|
||||
"origin_good_id" BIGINT NOT NULL,
|
||||
"country_id" BIGINT NOT NULL,
|
||||
"category_id" BIGINT NOT NULL,
|
||||
"tag_id" BIGINT,
|
||||
"position_id" BIGINT,
|
||||
"good_name" TEXT NOT NULL,
|
||||
"good_priority" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "goods_pkey" PRIMARY KEY ("good_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sync_logs" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"type" "SyncType" NOT NULL,
|
||||
"status" "SyncStatus" NOT NULL,
|
||||
"message" TEXT,
|
||||
"started_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "sync_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "origin_goods_sds_good_id_key" ON "origin_goods"("sds_good_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "origin_goods_sds_category_id_idx" ON "origin_goods"("sds_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "countries_country_name_key" ON "countries"("country_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "categories_sds_category_id_key" ON "categories"("sds_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "categories_parent_category_id_idx" ON "categories"("parent_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tags_tag_name_key" ON "tags"("tag_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_country_id_idx" ON "positions"("country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_category_id_idx" ON "positions"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_country_id_category_id_idx" ON "positions"("country_id", "category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_origin_good_id_idx" ON "goods"("origin_good_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_country_id_idx" ON "goods"("country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_category_id_idx" ON "goods"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_tag_id_idx" ON "goods"("tag_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_position_id_idx" ON "goods"("position_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_good_priority_idx" ON "goods"("good_priority" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_country_id_good_priority_idx" ON "goods"("country_id", "good_priority" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_category_id_country_id_idx" ON "goods"("category_id", "country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sync_logs_type_started_at_idx" ON "sync_logs"("type", "started_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_category_id_fkey" FOREIGN KEY ("parent_category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "positions" ADD CONSTRAINT "positions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "positions" ADD CONSTRAINT "positions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_position_id_fkey" FOREIGN KEY ("position_id") REFERENCES "positions"("position_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "tags" ADD COLUMN "tag_group_id" BIGINT,
|
||||
ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tag_groups" (
|
||||
"tag_group_id" BIGSERIAL NOT NULL,
|
||||
"group_name" TEXT NOT NULL,
|
||||
"group_icon" TEXT,
|
||||
"group_color" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tag_groups_pkey" PRIMARY KEY ("tag_group_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tag_groups_group_name_key" ON "tag_groups"("group_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tags_tag_group_id_idx" ON "tags"("tag_group_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tags_tag_group_id_sort_order_idx" ON "tags"("tag_group_id", "sort_order");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tags" ADD CONSTRAINT "tags_tag_group_id_fkey" FOREIGN KEY ("tag_group_id") REFERENCES "tag_groups"("tag_group_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- Seed: insert 3 default tag groups
|
||||
INSERT INTO "tag_groups" ("group_name", "sort_order", "created_at", "updated_at") VALUES
|
||||
('物流渠道', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('印刷位置', 2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('印刷工艺', 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
|
||||
-- Seed: assign existing tags to groups
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '物流渠道')
|
||||
WHERE "tag_name" IN ('包邮', '不包邮');
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷位置')
|
||||
WHERE "tag_name" IN ('单面印', '双面印');
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷工艺')
|
||||
WHERE "tag_name" IN ('烫画', '直喷', '不打印');
|
||||
|
||||
-- Seed: assign sortOrder within each group by tag_id
|
||||
WITH ordered AS (
|
||||
SELECT "tag_id",
|
||||
ROW_NUMBER() OVER (PARTITION BY "tag_group_id" ORDER BY "tag_id") AS rn
|
||||
FROM "tags"
|
||||
WHERE "tag_group_id" IS NOT NULL
|
||||
)
|
||||
UPDATE "tags" SET "sort_order" = ordered.rn
|
||||
FROM ordered
|
||||
WHERE "tags"."tag_id" = ordered."tag_id";
|
||||
-- AlterTable
|
||||
ALTER TABLE "tags" ADD COLUMN "tag_group_id" BIGINT,
|
||||
ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tag_groups" (
|
||||
"tag_group_id" BIGSERIAL NOT NULL,
|
||||
"group_name" TEXT NOT NULL,
|
||||
"group_icon" TEXT,
|
||||
"group_color" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tag_groups_pkey" PRIMARY KEY ("tag_group_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tag_groups_group_name_key" ON "tag_groups"("group_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tags_tag_group_id_idx" ON "tags"("tag_group_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tags_tag_group_id_sort_order_idx" ON "tags"("tag_group_id", "sort_order");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tags" ADD CONSTRAINT "tags_tag_group_id_fkey" FOREIGN KEY ("tag_group_id") REFERENCES "tag_groups"("tag_group_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- Seed: insert 3 default tag groups
|
||||
INSERT INTO "tag_groups" ("group_name", "sort_order", "created_at", "updated_at") VALUES
|
||||
('物流渠道', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('印刷位置', 2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('印刷工艺', 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
|
||||
-- Seed: assign existing tags to groups
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '物流渠道')
|
||||
WHERE "tag_name" IN ('包邮', '不包邮');
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷位置')
|
||||
WHERE "tag_name" IN ('单面印', '双面印');
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷工艺')
|
||||
WHERE "tag_name" IN ('烫画', '直喷', '不打印');
|
||||
|
||||
-- Seed: assign sortOrder within each group by tag_id
|
||||
WITH ordered AS (
|
||||
SELECT "tag_id",
|
||||
ROW_NUMBER() OVER (PARTITION BY "tag_group_id" ORDER BY "tag_id") AS rn
|
||||
FROM "tags"
|
||||
WHERE "tag_group_id" IS NOT NULL
|
||||
)
|
||||
UPDATE "tags" SET "sort_order" = ordered.rn
|
||||
FROM ordered
|
||||
WHERE "tags"."tag_id" = ordered."tag_id";
|
||||
|
||||
@@ -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 {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -14,52 +15,161 @@ datasource db {
|
||||
}
|
||||
|
||||
// ---------- Origin Goods ----------
|
||||
model OriginGood {
|
||||
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
||||
sdsGoodId String @unique @map("sds_good_id")
|
||||
// Cached SDS product metadata (filled during sync)
|
||||
sdsCategoryId String? @map("sds_category_id")
|
||||
goodName String? @map("good_name")
|
||||
goodImage String? @map("good_image")
|
||||
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
||||
delisted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
enum OriginGoodSource {
|
||||
SDS
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
goods Good[]
|
||||
model OriginGood {
|
||||
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
||||
sdsGoodId String @unique @map("sds_good_id")
|
||||
// Cached SDS product metadata (filled during sync)
|
||||
sdsCategoryId String? @map("sds_category_id")
|
||||
goodName String? @map("good_name")
|
||||
goodImage String? @map("good_image")
|
||||
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
||||
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)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
family ProductFamily? @relation(fields: [familyId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
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([source])
|
||||
@@index([familyId])
|
||||
@@index([skuCode])
|
||||
@@index([logisticsLabel])
|
||||
@@index([craftLabel])
|
||||
@@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 ----------
|
||||
model Country {
|
||||
id BigInt @id @default(autoincrement()) @map("country_id")
|
||||
countryName String @unique @map("country_name")
|
||||
countryIcon String? @map("country_icon")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
goods Good[]
|
||||
positions Position[]
|
||||
families ProductFamily[]
|
||||
|
||||
@@map("countries")
|
||||
}
|
||||
|
||||
// ---------- Categories (self-referential tree) ----------
|
||||
model Category {
|
||||
id BigInt @id @default(autoincrement()) @map("category_id")
|
||||
parentCategoryId BigInt? @map("parent_category_id")
|
||||
categoryName String @map("category_name")
|
||||
categoryIcon String? @map("category_icon")
|
||||
sdsCategoryId String? @unique @map("sds_category_id")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
id BigInt @id @default(autoincrement()) @map("category_id")
|
||||
parentCategoryId BigInt? @map("parent_category_id")
|
||||
categoryName String @map("category_name")
|
||||
categoryIcon String? @map("category_icon")
|
||||
sdsCategoryId String? @unique @map("sds_category_id")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
children Category[] @relation("CategoryToCategory")
|
||||
goods Good[]
|
||||
positions Position[]
|
||||
parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
children Category[] @relation("CategoryToCategory")
|
||||
goods Good[]
|
||||
positions Position[]
|
||||
families ProductFamily[]
|
||||
|
||||
@@index([parentCategoryId])
|
||||
@@map("categories")
|
||||
@@ -94,7 +204,8 @@ model Tag {
|
||||
|
||||
goods Good[]
|
||||
goodTags GoodTag[]
|
||||
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
originGoodTags OriginGoodTag[]
|
||||
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
|
||||
@@index([tagGroupId])
|
||||
@@index([tagGroupId, sortOrder])
|
||||
@@ -122,26 +233,30 @@ model Position {
|
||||
|
||||
// ---------- Goods ----------
|
||||
model Good {
|
||||
id BigInt @id @default(autoincrement()) @map("good_id")
|
||||
originGoodId BigInt @map("origin_good_id")
|
||||
countryId BigInt @map("country_id")
|
||||
categoryId BigInt @map("category_id")
|
||||
tagId BigInt? @map("tag_id")
|
||||
positionId BigInt? @map("position_id")
|
||||
goodName String @map("good_name")
|
||||
goodImage String? @map("good_image")
|
||||
goodPriority Int @default(0) @map("good_priority")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
id BigInt @id @default(autoincrement()) @map("good_id")
|
||||
originGoodId BigInt @map("origin_good_id")
|
||||
familyId BigInt? @map("family_id")
|
||||
countryId BigInt @map("country_id")
|
||||
categoryId BigInt @map("category_id")
|
||||
tagId BigInt? @map("tag_id")
|
||||
positionId BigInt? @map("position_id")
|
||||
goodName String @map("good_name")
|
||||
goodImage String? @map("good_image")
|
||||
goodPriority Int @default(0) @map("good_priority")
|
||||
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: 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)
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
goodTags GoodTag[]
|
||||
mergedOriginGoods GoodOriginGood[]
|
||||
|
||||
@@index([originGoodId])
|
||||
@@index([familyId])
|
||||
@@index([countryId])
|
||||
@@index([categoryId])
|
||||
@@index([tagId])
|
||||
@@ -166,11 +281,85 @@ model GoodTag {
|
||||
@@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) ----------
|
||||
enum Role {
|
||||
ADMIN
|
||||
}
|
||||
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement())
|
||||
username String @unique
|
||||
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)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@ -181,6 +370,7 @@ model User {
|
||||
enum SyncType {
|
||||
CATEGORIES
|
||||
PRODUCTS
|
||||
PRODUCT_DETAILS
|
||||
}
|
||||
|
||||
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 |