feat(标签): 题目列表的标签分成「知识点」「主题」两组,新建的标签在后台标出待归类
Deploy / deploy (push) Canceled after 0s

- 迁移 0022:problem_tag 加 category(knowledge / theme)和 category_confirmed;
  存量 25 个题材、题集类标签归主题,其余归知识点,全部记为已确认
- 前台题目列表按两组显示标签,组内按题目数从多到少
- 后台标签管理:每行切换分类;出题时新建的标签默认知识点、标「待归类」并排在最前,
  页头提示待归类数量,可点「确认」保留默认;加分类筛选
- 新接口 PUT /admin/problem-tags/:id/category,设置分类即视为确认

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 20:38:43 +08:00
1 parent e1b6a9e9fa
commit d07459928d
12 files changed
+4226 -30

No files matched your search

+25 -3
View File
@@ -2,6 +2,7 @@ import {
batchProblemTagRequestSchema,
generateFlowchartRequestSchema,
renameTagRequestSchema,
setTagCategoryRequestSchema,
type AcTrend,
type AdminTag,
type BatchProblemTagResponse,
@@ -54,15 +55,22 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
.select({
id: schema.problemTag.id,
name: schema.problemTag.name,
category: schema.problemTag.category,
categoryConfirmed: schema.problemTag.categoryConfirmed,
problemCount: countDistinct(schema.problemTags.problemId),
})
.from(schema.problemTag)
.leftJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name)
.groupBy(schema.problemTag.id)
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
.orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name))
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0。
// 待归类的新标签排最前面(false 在 true 之前),不然会沉在几十个老标签底下
.orderBy(
asc(schema.problemTag.categoryConfirmed),
desc(countDistinct(schema.problemTags.problemId)),
asc(schema.problemTag.name),
)
return success(c, rows satisfies AdminTag[])
})
@@ -134,6 +142,20 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
} satisfies RenameTagResponse)
})
adminTagRoutes.put("/problem-tags/:id/category", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = await parseBody(c, setTagCategoryRequestSchema, "分类只能是知识点或主题")
if (!parsed.success) return parsed.response
const [row] = await db
.update(schema.problemTag)
// 选了分类就算确认过,哪怕选的还是默认的「知识点」
.set({ category: parsed.data.category, categoryConfirmed: true })
.where(eq(schema.problemTag.id, id))
.returning({ id: schema.problemTag.id })
if (!row) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
return success(c, null)
})
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE(0010)