- 迁移 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:
1 parent
e1b6a9e9fa
commit
d07459928d
12 files changed
+4226
-30
No files matched your search
@@ -0,0 +1,16 @@
|
||||
-- 标签分「知识点 / 主题」两类,前台题目列表按两组显示,含义见契约 tagCategorySchema。
|
||||
-- 带常量默认值的加列只改目录不重写表;新标签默认算知识点、且分类「未确认」,
|
||||
-- 后台标签管理把未确认的排在最前面,等老师点一下归类。
|
||||
ALTER TABLE "problem_tag" ADD COLUMN "category" text DEFAULT 'knowledge' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "problem_tag" ADD COLUMN "category_confirmed" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
-- 存量标签的归类:按名字挑出主题类标签,其余留在知识点。按 lower(name) 匹配
|
||||
-- (和唯一索引 problem_tag_name_ci_unique 同一口径),库里没有的名字空转即可。
|
||||
-- 必会题、技能队、力扣是题集 / 来源而不是知识点,也归主题。
|
||||
UPDATE "problem_tag" SET "category" = 'theme' WHERE lower("name") IN (
|
||||
'弱智吧', '二次元', '狐妖小红娘', '第五人格', '农药', '哈利波特', '九大学科', '原',
|
||||
'职一第一深情', '异兽', '反诈', '三角洲', '火影', '神秘复苏', 'blue archive',
|
||||
'空洞骑士', '周杰伦', 'lol', '奥特曼', '无畏契约', '图书馆', '物联网',
|
||||
'必会题', '技能队', '力扣'
|
||||
);--> statement-breakpoint
|
||||
-- 上面这版归类已经人工过目,存量标签一律算确认过;之后新建的才进「待归类」
|
||||
UPDATE "problem_tag" SET "category_confirmed" = true;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -155,6 +155,13 @@
|
||||
"when": 1789990062378,
|
||||
"tag": "0021_ai_hint_level",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1790512513639,
|
||||
"tag": "0022_add_tag_category",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
ReactionKey,
|
||||
SqlConfig,
|
||||
SqlDisplay,
|
||||
TagCategory,
|
||||
TutorialType,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
@@ -738,6 +739,10 @@ export const problemTag = pgTable(
|
||||
{
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
category: text().notNull().default("knowledge").$type<TagCategory>(),
|
||||
// 分类有没有人确认过。出题时顺手新建的标签一律先落在「知识点」,这一列为 false,
|
||||
// 后台标签管理把它们排在最前面等老师归类;存量标签由 0022 统一标成 true
|
||||
categoryConfirmed: boolean("category_confirmed").notNull().default(false),
|
||||
},
|
||||
() => [uniqueIndex("problem_tag_name_ci_unique").using("btree", sql`lower(name)`)],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -193,6 +193,7 @@ problemRoutes.get("/problem-tags", async (c) => {
|
||||
.select({
|
||||
id: schema.problemTag.id,
|
||||
name: schema.problemTag.name,
|
||||
category: schema.problemTag.category,
|
||||
problemCount: countDistinct(schema.problemTags.problemId),
|
||||
})
|
||||
.from(schema.problemTag)
|
||||
@@ -206,7 +207,7 @@ problemRoutes.get("/problem-tags", async (c) => {
|
||||
),
|
||||
)
|
||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name)
|
||||
.groupBy(schema.problemTag.id, schema.problemTag.name, schema.problemTag.category)
|
||||
.having(sql`count(${schema.problemTags.problemId}) > 0`)
|
||||
.orderBy(asc(schema.problemTag.name))
|
||||
return success(c, rows satisfies Tag[])
|
||||
|
||||
Reference in new issue
Block a user