name: tag-system description: 标签系统的设计、存储、导入导出和打标模式。适用于需要实现标签/分类功能的 Spring Boot + Vue 项目。 version: 1.0.0
本项目的标签系统提供标签组管理、树形标签、导入导出、通用打标四大能力。
tag_group 标签组(分类维度)
├── id, name, description, sortOrder, createdAt, updatedAt
tag 标签节点(自引用树,每个组有一个同名根节点 parentId=null)
├── id, groupId, parentId, name, sortOrder, createdAt, updatedAt
tag_assignment 打标关联(通用多对多)
├── id, entityType, entityId, tagId, createdAt
└── 唯一约束: (entity_type, entity_id, tag_id)
parentId=null 的根标签,名称与组名相同。用户操作的标签都是根节点的子孙。listTags 返回扁平列表,前端 buildTree() 根据 parentId 构建树结构。buildTagTree 只输出根节点的子节点,不包含根节点本身。@OneToMany/@ManyToOne。model/entity/
TagGroup.java 标签组实体 @Table(name = "tag_group")
Tag.java 标签节点实体 @Table(name = "tag")
TagAssignment.java 打标关联实体 @Table(name = "tag_assignment")
model/dto/
TagExportDTO.java 导出格式(嵌套树结构)
└── TagGroupExport { name, description, sortOrder, tags: List<TagNodeExport> }
└── TagNodeExport { name, sortOrder, children: List<TagNodeExport> } 递归嵌套
TagImportDTO.java 导入请求 { data: TagExportDTO, strategies: Map<name, strategy> }
TagImportResultDTO.java 导入结果 { created, overwritten, merged, skipped, details }
model/vo/
TagBriefVO.java 标签简要信息 { tagId, tagName, groupId, groupName }
repository/
TagGroupRepository findAllByOrderBySortOrderAsc
TagRepository findByGroupIdOrderBySortOrderAsc, findByParentIdOrderBySortOrderAsc, deleteAllByGroupId
TagAssignmentRepository findByEntityTypeAndEntityId, deleteAllByEntityTypeAndEntityId
service/TagService 接口
service/impl/TagServiceImpl 实现(包含导入导出、合并算法、打标)
controller/TagController REST API(/api/tags/*)
└── 请求体 DTO 均定义为 static inner class(CreateGroupReq, SetTagsReq 等)
GET /api/tags/groups 列出所有标签组
POST /api/tags/groups 创建标签组
POST /api/tags/groups/{id}/edit 更新标签组
POST /api/tags/groups/{id}/delete 删除标签组
GET /api/tags/groups/{groupId}/tags 获取该组全部标签(扁平列表)
POST /api/tags/groups/{groupId}/tags 创建标签节点
POST /api/tags/{id}/edit 重命名标签
POST /api/tags/{id}/delete 删除标签(递归删除子孙)
POST /api/tags/{id}/move 移动标签(含防循环检测)
GET /api/tags/export 导出所有标签组(嵌套树 JSON)
POST /api/tags/import 导入标签(支持 overwrite/merge/skip 策略)
POST /api/tags/assignments 设置实体标签(全量替换)
GET /api/tags/assignments?entityType=&entityId= 获取实体标签
GET /api/tags/all-with-tags 所有标签组+标签(标签选择器用)
@Data(Lombok)。{
"version": "1.0",
"exportedAt": "2026-06-11T14:30:00",
"groups": [
{
"name": "标签组名",
"description": "可选",
"sortOrder": 0,
"tags": [
{
"name": "标签名",
"sortOrder": 0,
"children": [ { "name": "子标签", "children": [] } ]
}
]
}
]
}
name 作为匹配键。tags 是根节点的子节点,不包含标签组同名根节点。| 策略 | 值 | 行为 |
|---|---|---|
| 新建 | create |
无冲突时自动新建 |
| 覆盖 | overwrite |
删除旧组+旧标签,用导入数据重建 |
| 合并 | merge |
递归按名称匹配:同名保留+递归子节点,新名追加 |
| 跳过 | skip |
不做任何修改 |
对于导入标签树的每一层节点:
1. 在目标标签组的同级子节点中查找同名标签
2. 找到 → 保留该标签,递归处理其子节点
3. 未找到 → 创建新标签节点
幂等安全:同名标签不会重复创建。
// TagServiceImpl.setTags()
assignmentRepo.deleteAllByEntityTypeAndEntityId(entityType, entityId);
for (Long tagId : tagIds) {
// 逐条插入
}
| entityType | entityId 来源 | 说明 |
|---|---|---|
skill |
folderName | 技能(文件系统存储,无数据库 ID) |
新增实体类型只需在调用方传入不同的 entityType 和 entityId,无需后端改动。
api/tag.js 所有标签 API 封装
stores/tag.js Pinia store(groups, activeGroupId, currentTags, buildTree)
components/tag/
TreeNodeItem.vue 递归树节点(编辑模式)
TagSelector.vue 标签选择器弹窗(打标模式)
views/TagManagement.vue 标签管理页面(左右分栏)
<TagSelector
v-model:show="showTagSelector"
entity-type="skill"
:entity-id="skill.folderName"
:selected-tag-ids="skill.tags?.map(t => t.tagId) || []"
@saved="onTagsSaved"
/>
getAllTagsForSelector() 加载所有标签setEntityTags() 全量替换,触发 @saved 回调// 将扁平 parentId 列表构建为嵌套树
function buildTree() {
const map = {}
const roots = []
currentTags.value.forEach(t => { map[t.id] = { ...t, children: [] } })
currentTags.value.forEach(t => {
if (t.parentId == null) roots.push(map[t.id])
else if (map[t.parentId]) map[t.parentId].children.push(map[t.id])
})
return roots
}
标签组的根节点是隐式的(parentId=null,名称等于组名):
buildTagTree 跳过根节点,直接输出其子节点importCreate 找到自动创建的根节点,将导入的标签作为其子节点插入// TagServiceImpl.isDescendant()
// 检查 targetId 是否是 ancestorId 的子孙,防止 moveTag 形成环
while (current != null) {
if (current.equals(ancestorId)) return true;
// 向上遍历 parentId 链
}
// SkillController.toVOWithCache()
vo.setTags(tagService.getEntityTags("skill", dto.getFolderName()));
Skill 无数据库实体,使用 folderName 作为 entityId。
TagSelector 组件entityType="your-type" 和 entityId="your-id"TagGroup 实体添加字段CreateGroupReq / UpdateGroupReqTagGroupExport 和 importCreate / importOverwriteTagManagement.vue 表单docs/tag-system-spec.md 格式规范