# 标签组 (Tag Group) 功能 ## 目标 为 `Tag` 引入分组(TagGroup)概念,覆盖三个分组:**物流渠道 / 印刷位置 / 印刷工艺**。 分组可在后台通过标签筛选下拉的树形结构直接管理(增/删/重命名/拖动排序),同时在官网商品卡片上按组排序展示 tag chip,国家 chip 放在最后。 ## 用户故事 1. 运营在「官网后台 → 商品配置 → 标签筛选」打开下拉,看到一个两层树:分组 → 标签,自动展开 2. 运营可以拖动分组调整顺序,也可以拖动组内 tag 调整顺序(支持跨组拖动) 3. 运营双击分组名直接 inline 编辑,按 Enter 提交 4. 运营点击分组底部「+ 在此组下新建标签」在该组下创建新 tag 5. 运营点击分组右上角删除按钮删除分组(组内 tag 自动归入「未分组」) 6. 顾客在前端产品中心看到商品卡片的 tag chip 按 `物流渠道 → 印刷位置 → 印刷工艺` 顺序排列,国家 chip 排在最后 ## 数据模型 ### 新表 `tag_groups` ```prisma model TagGroup { id BigInt @id @default(autoincrement()) @map("tag_group_id") groupName String @unique @map("group_name") groupIcon String? @map("group_icon") groupColor String? @map("group_color") 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) tags Tag[] @@map("tag_groups") } ``` ### `Tag` 表加字段 ```prisma model Tag { // ... 现有字段 tagGroupId BigInt? @map("tag_group_id") sortOrder Int @default(0) @map("sort_order") tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction) @@index([tagGroupId]) @@index([tagGroupId, sortOrder]) } ``` ### 种子数据 ```sql INSERT INTO tag_groups (group_name, sort_order) VALUES ('物流渠道', 1), ('印刷位置', 2), ('印刷工艺', 3); 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 tag_group_name = '印刷工艺') WHERE tag_name IN ('烫画', '直喷', '不打印'); -- 同组内的 tag 按现有 id 升序分配 sortOrder WITH ordered AS ( SELECT t.tag_id, ROW_NUMBER() OVER (PARTITION BY t.tag_group_id ORDER BY t.tag_id) AS rn FROM tags t WHERE t.tag_group_id IS NOT NULL ) UPDATE tags SET sort_order = ordered.rn FROM ordered WHERE tags.tag_id = ordered.tag_id; ``` ## 后端 API ### TagGroup CRUD - `GET /tag-groups` — 列表(含 tags) - `POST /tag-groups` — 创建 - `PATCH /tag-groups/:id` — 更新(重命名、颜色、图标) - `DELETE /tag-groups/:id` — 删除(级联 SetNull) - `PATCH /tag-groups/sort` — 批量更新 `sortOrder` ### Tag 扩展 - `POST /tags` / `PATCH /tags/:id` — 支持 `tagGroupId` / `sortOrder` - `PATCH /tags/sort` — 批量更新 `sortOrder`(跨组时同时更新 `tagGroupId`) ### Public 公开接口 - `GET /public/tag-groups` — 公开的分组列表 - `GET /public/tags` 返回的 `PublicTagDto` 增加: ```ts group: { id: string; name: string } | null ``` ## 后端文件改动 | 文件 | 操作 | |---|---| | `prisma/schema.prisma` | 新表 + Tag 字段 | | `prisma/migrations/_tag_groups/migration.sql` | 自动生成 + 种子 | | `src/tag-groups/tag-groups.module.ts` | 新建 | | `src/tag-groups/tag-groups.service.ts` | 新建 | | `src/tag-groups/tag-groups.controller.ts` | 新建 | | `src/tag-groups/dto/create-tag-group.dto.ts` | 新建 | | `src/tag-groups/dto/update-tag-group.dto.ts` | 新建 | | `src/tag-groups/dto/reorder-tag-groups.dto.ts` | 新建(批量排序) | | `src/tags/tags.service.ts` | 改:create/update 支持 `tagGroupId`/`sortOrder`;新增 `reorder` | | `src/tags/tags.controller.ts` | 改:加 `PATCH /tags/sort` | | `src/tags/dto/create-tag.dto.ts` | 改 | | `src/tags/dto/update-tag.dto.ts` | 改 | | `src/tags/dto/reorder-tags.dto.ts` | 新建 | | `src/public/dto/public-tag-group.dto.ts` | 新建 | | `src/public/dto/public-tag.dto.ts` | 改:加 `group` | | `src/public/dto/public-good.dto.ts` | 改:tag 类型加 group | | `src/public/public.service.ts` | 改:getTags include group、按 sortOrder 排序;新增 getTagGroups | | `src/public/public.controller.ts` | 改:加 `GET /public/tag-groups` | | `src/app.module.ts` | 改:注册 TagGroupsModule | | `src/tags/tags.service.spec.ts` | 改:加 tagGroupId 关联测试 | | `src/public/public.service.spec.ts` | 改:加 tag 排序测试 | ## Admin 前端 ### 核心:`GoodsView.vue` 标签筛选改 X2 位置:第 661-668 行的 `` 标签筛选。 **新结构**: ```vue
标签分组 新建分组
``` **关键逻辑**: - `tagTreeData` computed:从 `tagGroups` + `tags` 构造两层树,组内按 `sortOrder`,未分组追加末尾(`disabled: true`) - `allowDrop(draggingNode, dropNode, type)`: - 拖动到分组节点上 → `type === 'inner'`(加入该组) - 拖动到 tag 节点上 → `type === 'before'/'after'`(同组排序) - 未分组节点不允许 drop - `onNodeDrop`:根据拖动结果调 `PATCH /tags/sort`(组内排序)或 `PATCH /tag-groups/sort`(分组排序),或更新 `tagGroupId`(跨组移动) - `startEdit(data)`:双击触发,记录 `editingId`,nextTick 聚焦输入框 - `commitEdit(data)`:调 `PATCH /tag-groups/:id` 或 `PATCH /tags/:id` 保存新名称 - `quickAddGroup`:弹小输入框 → 调 `POST /tag-groups` - `quickAddTag(group)`:弹小输入框 → 调 `POST /tags`,传入 `tagGroupId` - `removeGroup(data)`:`ElMessageBox.confirm` → 调 `DELETE /tag-groups/:id` - `removeTag(data)`:调 `DELETE /tags/:id` **`selectedTagIds` 与树的勾选**: - 树上的勾选用 `show-checkbox` 即可 - 但用户更倾向"单选即勾选 / 取消勾选"——可以省略勾选框,用 `@node-click` 处理 - 简化方案:分组节点不可点击勾选(disabled),tag 节点单击切换 `selectedTagIds` 中是否包含该 id ### `TagsView.vue` 同步 - 表格加「所属分组」列 - 创建/编辑弹窗加「所属分组」`` 字段(数据来自 `/tag-groups`) ### 类型与 API | 文件 | 改动 | |---|---| | `src/types/index.ts` | 加 `TagGroup`、`CreateTagGroupRequest`、`UpdateTagGroupRequest` | | `src/api/tag-groups.ts` | 新建(含 `getList/create/update/remove/reorder`) | | `src/api/tags.ts` | 扩展支持 `tagGroupId`/`sortOrder` | ## 官网前端 ### 数据加载 | 文件 | 改动 | |---|---| | `server/api/backend/tag-groups.get.ts` | 新建代理 | | `composables/useProductCenter.ts` | `BackendTag` 加 `tagGroupId`、`tagGroup: { id, name } \| null`;`Tag` 接口加 `group`;新增 `loadTagGroups()` + `useState('pc-tag-groups')`;新增 `sortTagsByGroup()` 工具函数 | ### `ProductCard.vue` chip 排序 **国家 chip 移到 tag 之后**: ```vue
{{ tag.name }} {{ product.country.name }}
``` `sortedTags` computed: ```ts const sortedTags = computed(() => { const groupOrder = new Map(tagGroups.value.map((g, i) => [g.id, i])); return [...props.product.tags] .sort((a, b) => { const ao = a.group ? (groupOrder.get(a.group.id) ?? 999) : 999; const bo = b.group ? (groupOrder.get(b.group.id) ?? 999) : 999; return ao - bo; }) .slice(0, 4); }); ``` ### `product-center.vue` 已选 chip 排序 - `.selected-tags` 区域按相同规则排序 - country chip 排在已选 tag 之后 ## 测试与文档 | 文件 | 改动 | |---|---| | `src/tags/tags.service.spec.ts` | 加 tagGroupId 关联测试 | | `src/tags/tags.service.ts` create/update 单测 | 加 `tagGroupId` 字段 | | `src/public/public.service.spec.ts` | 加 `/public/tag-groups` + tag 排序测试 | | `docs/references/structs.md` | 更新项目结构 | | `docs/references/admin/tags.md`(如有) | 新建或更新标签管理文档 | | `skills/inkreach-official-admin/SKILL.md` | 加标签分组管理说明 | | `skills/inkreach-official-website/SKILL.md` | 加 tag 排序说明 | ## 任务列表 - [ ] **T1**:后端 schema + migration - 改 `prisma/schema.prisma` 新增 TagGroup + Tag 字段 - 跑 `npx prisma migrate dev --name add_tag_groups` 生成 migration - 在 migration.sql 末尾追加种子数据(3 个分组 + 关联现有 tag + 分配 sortOrder) - 跑 `npx prisma generate` 重新生成 client - [ ] **T2**:后端 tag-groups module - 新建 `tag-groups.module.ts` / `service.ts` / `controller.ts` / dto - 注册到 `app.module.ts` - 实现 CRUD + 批量排序 - [ ] **T3**:后端 tags 扩展 - `create/update` DTO 加 `tagGroupId` / `sortOrder` - `tags.service.ts` 处理新字段 - 加 `PATCH /tags/sort` 端点 - [ ] **T4**:后端 public 接口 - 新建 `public-tag-group.dto.ts` - `PublicTagDto` 加 `group` 字段 - `public.service.ts` `getTags` 用 `include: { tagGroup: true }`,按 `tagGroup.sortOrder, sortOrder` 排序 - `public.service.ts` 新增 `getTagGroups()` - `public.controller.ts` 加 `GET /public/tag-groups` - [ ] **T5**:后端测试更新 - 更新 `tags.service.spec.ts` 现有测试,加 `tagGroupId` 关联 - 更新 `public.service.spec.ts` 加 tag group 关联 + 排序测试 - [ ] **T6**:Admin 类型 + API - `types/index.ts` 加 `TagGroup` 类型 - 新建 `api/tag-groups.ts` - `api/tags.ts` 扩展支持新字段 - [ ] **T7**:Admin GoodsView 标签筛选(X2) - `GoodsView.vue:661-668` 改为 `` + `` - 实现 `tagTreeData` computed - 实现拖动(`@node-drop` + `allow-drop`) - 实现 inline 编辑(双击 + 失焦/Enter 提交) - 实现 quick add group / quick add tag - 实现 remove group / remove tag - `selectedTagIds` 与树节点单击同步 - "未分组" 虚拟节点追加在最后(`disabled: true`) - [ ] **T8**:Admin TagsView 同步 - 表格加「所属分组」列 - 创建/编辑弹窗加「所属分组」下拉 - [ ] **T9**:官网数据加载 - 新建 `server/api/backend/tag-groups.get.ts` 代理 - `useProductCenter.ts` 加载 tagGroups - 给 `Tag` 接口加 `group` 字段 - 新增 `sortTagsByGroup()` 工具函数 - [ ] **T10**:官网 ProductCard 排序 - `ProductCard.vue` chip 顺序:tag 们 → 国家 - 用 `sortedTags` computed - [ ] **T11**:官网 product-center 已选 chip 排序 - `.selected-tags` 区域按组排序 - country chip 排在最后 - [ ] **T12**:文档 - 更新 `docs/references/structs.md` - 更新 `skills/inkreach-official-admin/SKILL.md` - 更新 `skills/inkreach-official-website/SKILL.md` ## 风险与权衡 1. **拖动跨组 vs 同组**:用户允许跨组,但 UI 上要明确提示(鼠标 cursor + 节点高亮) 2. **删除分组的级联**:用户确认 SetNull,但要在 UI 上二次确认(避免误删) 3. **"+ 在此组下新建标签"位置**:用户说"底部",意味着每个组节点下方有固定行(hover 时显示) 4. **未分组虚拟节点**:不可拖动、不可编辑/删除,仅作展示 5. **数据库 seed 在 production 的处理**:seed 数据只在 migration 中执行一次,production 已有数据时迁移需谨慎