- 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
15 KiB
15 KiB
标签组 (Tag Group) 功能
目标
为 Tag 引入分组(TagGroup)概念,覆盖三个分组:物流渠道 / 印刷位置 / 印刷工艺。
分组可在后台通过标签筛选下拉的树形结构直接管理(增/删/重命名/拖动排序),同时在官网商品卡片上按组排序展示 tag chip,国家 chip 放在最后。
用户故事
- 运营在「官网后台 → 商品配置 → 标签筛选」打开下拉,看到一个两层树:分组 → 标签,自动展开
- 运营可以拖动分组调整顺序,也可以拖动组内 tag 调整顺序(支持跨组拖动)
- 运营双击分组名直接 inline 编辑,按 Enter 提交
- 运营点击分组底部「+ 在此组下新建标签」在该组下创建新 tag
- 运营点击分组右上角删除按钮删除分组(组内 tag 自动归入「未分组」)
- 顾客在前端产品中心看到商品卡片的 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/sortOrderPATCH /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>
关键逻辑:
tagTreeDatacomputed:从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-groupsquickAddTag(group):弹小输入框 → 调POST /tags,传入tagGroupIdremoveGroup(data):ElMessageBox.confirm→ 调DELETE /tag-groups/:idremoveTag(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 之后:
<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/updateDTO 加tagGroupId/sortOrdertags.service.ts处理新字段- 加
PATCH /tags/sort端点
- T4:后端 public 接口
- 新建
public-tag-group.dto.ts PublicTagDto加group字段public.service.tsgetTags用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>- 实现
tagTreeDatacomputed - 实现拖动(
@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.vuechip 顺序:tag 们 → 国家- 用
sortedTagscomputed
- 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
- 更新
风险与权衡
- 拖动跨组 vs 同组:用户允许跨组,但 UI 上要明确提示(鼠标 cursor + 节点高亮)
- 删除分组的级联:用户确认 SetNull,但要在 UI 上二次确认(避免误删)
- "+ 在此组下新建标签"位置:用户说"底部",意味着每个组节点下方有固定行(hover 时显示)
- 未分组虚拟节点:不可拖动、不可编辑/删除,仅作展示
- 数据库 seed 在 production 的处理:seed 数据只在 migration 中执行一次,production 已有数据时迁移需谨慎