# 标签组 (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/<timestamp>_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 行的 `<el-select multiple>` 标签筛选。

**新结构**：
```vue
<el-popover
  v-model:visible="tagTreeVisible"
  placement="bottom-start"
  :width="320"
  trigger="click"
  popper-class="tag-tree-popper"
>
  <template #reference>
    <el-button size="small" plain>
      <i class="fa-solid fa-tags" />
      标签筛选 ({{ selectedTagIds.length }})
      <i class="fa-solid fa-chevron-down" />
    </el-button>
  </template>

  <div class="tag-tree-panel">
    <div class="tag-tree-header">
      <span>标签分组</span>
      <el-button text size="small" :icon="Plus" @click="quickAddGroup">新建分组</el-button>
    </div>

    <el-tree
      ref="tagTreeRef"
      :data="tagTreeData"
      node-key="id"
      default-expand-all
      draggable
      :allow-drop="allowDrop"
      :props="{ label: 'label', children: 'children' }"
      @node-drop="onNodeDrop"
    >
      <template #default="{ data }">
        <div
          class="tree-node"
          @mouseenter="hoveredId = data.id"
          @mouseleave="hoveredId = null"
        >
          <!-- 分组节点 -->
          <template v-if="data.type === 'group'">
            <i class="fa-solid fa-folder node-icon" />
            <input
              v-if="editingId === data.id"
              ref="editInputRef"
              v-model="editValue"
              class="node-edit-input"
              @blur="commitEdit(data)"
              @keydown.enter="commitEdit(data)"
              @keydown.esc="cancelEdit"
            />
            <span v-else class="node-label" @dblclick="startEdit(data)">
              {{ data.label }}
            </span>
            <span v-show="hoveredId === data.id && data.id !== 'g-ungrouped'" class="node-actions">
              <el-button text size="small" :icon="Delete" @click.stop="removeGroup(data)" />
            </span>
            <!-- 分组底部：+ 新建标签 -->
            <div v-if="data.id !== 'g-ungrouped' && hoveredId === data.id" class="group-add-tag">
              <el-button text size="small" :icon="Plus" @click.stop="quickAddTag(data)">在此组下新建标签</el-button>
            </div>
          </template>

          <!-- 标签节点 -->
          <template v-else>
            <span
              class="node-chip"
              :style="{ background: data.tagColor + '4d', borderColor: data.tagColor + '99', color: data.fontColor || data.tagColor }"
            >
              {{ data.label }}
            </span>
            <span v-show="hoveredId === data.id" class="node-actions">
              <el-button text size="small" :icon="Edit" @click.stop="openTagEdit(data)" />
              <el-button text size="small" :icon="Delete" @click.stop="removeTag(data)" />
            </span>
          </template>
        </div>
      </template>
    </el-tree>
  </div>
</el-popover>
```

**关键逻辑**：
- `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` 同步
- 表格加「所属分组」列
- 创建/编辑弹窗加「所属分组」`<el-select>` 字段（数据来自 `/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
<div v-if="product.tags.length > 0 || showCountry" class="chips">
  <span
    v-for="tag in sortedTags"
    :key="tag.id"
    class="chip"
    :style="{ color: tag.fontColor || tag.color || '#ff6a00', background: (tag.color || '#ff6a00') + '4d', borderColor: (tag.color || '#ff6a00') + '99' }"
  >
    {{ tag.name }}
  </span>
  <span v-if="showCountry" class="chip country-chip">
    <img v-if="product.country.icon" :src="product.country.icon" :alt="product.country.name" class="country-flag" />
    {{ product.country.name }}
  </span>
</div>
```

`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` 改为 `<el-popover>` + `<el-tree>`
  - 实现 `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 已有数据时迁移需谨慎
