Files
yeuimu 6c61a4e871 feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
2026-08-26 14:23:09 +08:00

15 KiB
Raw Permalink Blame History

标签组 (Tag Group) 功能

目标

Tag 引入分组(TagGroup)概念,覆盖三个分组:物流渠道 / 印刷位置 / 印刷工艺。 分组可在后台通过标签筛选下拉的树形结构直接管理(增/删/重命名/拖动排序),同时在官网商品卡片上按组排序展示 tag chip,国家 chip 放在最后。

用户故事

  1. 运营在「官网后台 → 商品配置 → 标签筛选」打开下拉,看到一个两层树:分组 → 标签,自动展开
  2. 运营可以拖动分组调整顺序,也可以拖动组内 tag 调整顺序(支持跨组拖动)
  3. 运营双击分组名直接 inline 编辑,按 Enter 提交
  4. 运营点击分组底部「+ 在此组下新建标签」在该组下创建新 tag
  5. 运营点击分组右上角删除按钮删除分组(组内 tag 自动归入「未分组」)
  6. 顾客在前端产品中心看到商品卡片的 tag chip 按 物流渠道 → 印刷位置 → 印刷工艺 顺序排列,国家 chip 排在最后

数据模型

新表 tag_groups

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 表加字段

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])
}

种子数据

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 增加:
    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> 标签筛选。

新结构

<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):双击触发,记录 editingIdnextTick 聚焦输入框
  • commitEdit(data):调 PATCH /tag-groups/:idPATCH /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 TagGroupCreateTagGroupRequestUpdateTagGroupRequest
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 BackendTagtagGroupIdtagGroup: { id, name } | nullTag 接口加 group;新增 loadTagGroups() + useState('pc-tag-groups');新增 sortTagsByGroup() 工具函数

ProductCard.vue chip 排序

国家 chip 移到 tag 之后

<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

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
    • PublicTagDtogroup 字段
    • public.service.ts getTagsinclude: { tagGroup: true },按 tagGroup.sortOrder, sortOrder 排序
    • public.service.ts 新增 getTagGroups()
    • public.controller.tsGET /public/tag-groups
  • T5:后端测试更新
    • 更新 tags.service.spec.ts 现有测试,加 tagGroupId 关联
    • 更新 public.service.spec.ts 加 tag group 关联 + 排序测试
  • T6Admin 类型 + API
    • types/index.tsTagGroup 类型
    • 新建 api/tag-groups.ts
    • api/tags.ts 扩展支持新字段
  • T7Admin 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
  • T8Admin 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 已有数据时迁移需谨慎