feat(阶段4): 标签管理 / 批量打标签 / 题目可见性 / 卡点与 AC 趋势 / 流程图 AI
GET/PUT/DELETE admin/problem-tags[/:id] POST admin/problems/batch-tag PUT admin/problems/:id/visibility GET admin/problems/stuck GET admin/problems/ac-trend POST admin/problems/flowchart 要点: - 后台标签列表用 leftJoin 且不加 having,能看到 problemCount=0 的标签 —— 那正是要清理的那些。oj 侧的 /problem-tags 才过滤 >0。 - 标签改名撞上已有标签视为合并:只给「还没挂目标标签」的题目补关系, 否则会撞 (problem_id, problemtag_id) 唯一约束。 - 批量打标签:add 时按需新建标签、remove 时只认已有标签 —— 否则「移除」会顺手造出一堆空标签。名字去重且大小写不敏感。 - **旧 ProblemVisibleAPI 的 `self.error(...)` 少写了 return**,题目不存在时会继续 往下跑并抛 AttributeError(500)。这里正常返回 404。 顺带记下一条阶段 5 的必做项(见 phase3-coverage.md 文末):本地库按显式 id 从生产 导入,序列没跟着走,第一次新建标签就撞 problem_tag_pkey。只要切换流程里有 「导出→导入到新库」这一步,就必须重置全部序列,否则读全正常、第一次写才炸。 实测:学生 403;大小写重复与空白名去重后 tagCount=1、重复 add 幂等、 remove 不存在的标签 404 且不新建;纯改名 merged=false、合并 merged=true affectedCount=1 且只剩一个标签;可见性取反两次复原、不存在的题 404。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { adminAiRoutes } from "./ai"
|
|||||||
import { adminConfRoutes } from "./conf"
|
import { adminConfRoutes } from "./conf"
|
||||||
import { adminContestRoutes } from "./contest"
|
import { adminContestRoutes } from "./contest"
|
||||||
import { adminProblemSetRoutes } from "./problemset"
|
import { adminProblemSetRoutes } from "./problemset"
|
||||||
|
import { adminTagRoutes } from "./tag"
|
||||||
import { adminAnnouncementRoutes } from "./announcement"
|
import { adminAnnouncementRoutes } from "./announcement"
|
||||||
import { adminTutorialRoutes } from "./tutorial"
|
import { adminTutorialRoutes } from "./tutorial"
|
||||||
|
|
||||||
@@ -26,5 +27,6 @@ adminRoutes.route("/", adminAiRoutes)
|
|||||||
adminRoutes.route("/", adminConfRoutes)
|
adminRoutes.route("/", adminConfRoutes)
|
||||||
adminRoutes.route("/", adminContestRoutes)
|
adminRoutes.route("/", adminContestRoutes)
|
||||||
adminRoutes.route("/", adminProblemSetRoutes)
|
adminRoutes.route("/", adminProblemSetRoutes)
|
||||||
|
adminRoutes.route("/", adminTagRoutes)
|
||||||
adminRoutes.route("/", adminAnnouncementRoutes)
|
adminRoutes.route("/", adminAnnouncementRoutes)
|
||||||
adminRoutes.route("/", adminTutorialRoutes)
|
adminRoutes.route("/", adminTutorialRoutes)
|
||||||
|
|||||||
293
apps/api/src/routes/admin/tag.ts
Normal file
293
apps/api/src/routes/admin/tag.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import {
|
||||||
|
acTrendSchema,
|
||||||
|
adminTagSchema,
|
||||||
|
batchProblemTagRequestSchema,
|
||||||
|
batchProblemTagResponseSchema,
|
||||||
|
generateFlowchartRequestSchema,
|
||||||
|
generateFlowchartResponseSchema,
|
||||||
|
renameTagRequestSchema,
|
||||||
|
renameTagResponseSchema,
|
||||||
|
stuckProblemSchema,
|
||||||
|
} from "@oj2/contract"
|
||||||
|
import { and, asc, countDistinct, count, desc, eq, gte, ilike, inArray, isNull, lte, ne, sql } from "drizzle-orm"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
|
||||||
|
import { requireProblemPermission, requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||||
|
import type { AuthUser } from "../../auth/session"
|
||||||
|
import { db, schema } from "../../db"
|
||||||
|
import { failure, success } from "../../http"
|
||||||
|
import { JudgeStatus } from "../../judge/status"
|
||||||
|
import { completeChat } from "../../services/ai"
|
||||||
|
import { queryInteger, rounded } from "../helpers"
|
||||||
|
|
||||||
|
export const adminTagRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||||
|
const FAILED = [JudgeStatus.WRONG_ANSWER, JudgeStatus.COMPILE_ERROR, JudgeStatus.RUNTIME_ERROR]
|
||||||
|
|
||||||
|
/** 能管所有题目:超管,或 problemPermission 为 All */
|
||||||
|
function canManageAllProblems(user: AuthUser) {
|
||||||
|
return user.adminType === "Super Admin" || user.problemPermission === "All"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 标签
|
||||||
|
|
||||||
|
adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
|
||||||
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
|
const rows = await db.select({
|
||||||
|
id: schema.problemTag.id,
|
||||||
|
name: schema.problemTag.name,
|
||||||
|
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)
|
||||||
|
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
|
||||||
|
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
|
||||||
|
.orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name))
|
||||||
|
return success(c, rows.map((row) => adminTagSchema.parse(row)))
|
||||||
|
})
|
||||||
|
|
||||||
|
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||||
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
|
const parsed = renameTagRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||||
|
if (!parsed.success) return failure(c, 400, "invalid-request", "标签名不能为空")
|
||||||
|
const name = parsed.data.name
|
||||||
|
|
||||||
|
const [tag] = await db.select().from(schema.problemTag).where(eq(schema.problemTag.id, id)).limit(1)
|
||||||
|
if (!tag) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
|
||||||
|
|
||||||
|
const [target] = await db.select().from(schema.problemTag)
|
||||||
|
.where(and(sql`lower(${schema.problemTag.name}) = lower(${name})`, ne(schema.problemTag.id, id))).limit(1)
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id))
|
||||||
|
return success(c, renameTagResponseSchema.parse({ merged: false, id, name, affectedCount: 0 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除
|
||||||
|
const affected = await db.transaction(async (tx) => {
|
||||||
|
const links = await tx.select({ problemId: schema.problemTags.problemId })
|
||||||
|
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
|
||||||
|
const already = new Set((await tx.select({ problemId: schema.problemTags.problemId })
|
||||||
|
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, target.id)))
|
||||||
|
.map((row) => row.problemId))
|
||||||
|
// 只给还没挂目标标签的题目补关系,否则会撞 (problem_id, problemtag_id) 唯一约束
|
||||||
|
const missing = links.filter((link) => !already.has(link.problemId))
|
||||||
|
if (missing.length) {
|
||||||
|
await tx.insert(schema.problemTags).values(missing.map((link) => ({
|
||||||
|
problemId: link.problemId,
|
||||||
|
problemtagId: target.id,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
|
||||||
|
await tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
|
||||||
|
return links.length
|
||||||
|
})
|
||||||
|
return success(c, renameTagResponseSchema.parse({
|
||||||
|
merged: true, id: target.id, name: target.name, affectedCount: affected,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||||
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
|
// 中间表是 NO ACTION 外键,得先清关系再删标签
|
||||||
|
const deleted = await db.transaction(async (tx) => {
|
||||||
|
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
|
||||||
|
return tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
|
||||||
|
.returning({ id: schema.problemTag.id })
|
||||||
|
})
|
||||||
|
if (deleted.length === 0) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
|
||||||
|
return success(c, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) => {
|
||||||
|
const parsed = batchProblemTagRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||||
|
if (!parsed.success) {
|
||||||
|
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
||||||
|
}
|
||||||
|
const user = c.get("user")!
|
||||||
|
const filters = [inArray(schema.problem.id, parsed.data.problemIds), isNull(schema.problem.contestId)]
|
||||||
|
if (!canManageAllProblems(user)) filters.push(eq(schema.problem.createdById, user.id))
|
||||||
|
const problems = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(...filters))
|
||||||
|
if (problems.length === 0) return failure(c, 404, "no-problems", "没有可操作的题目")
|
||||||
|
|
||||||
|
// 去重且大小写不敏感,与旧 resolve_tags / find_tags 一致
|
||||||
|
const wanted: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const raw of parsed.data.tagNames) {
|
||||||
|
const name = raw.trim()
|
||||||
|
if (!name || seen.has(name.toLowerCase())) continue
|
||||||
|
seen.add(name.toLowerCase())
|
||||||
|
wanted.push(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagIds = await db.transaction(async (tx) => {
|
||||||
|
const ids: number[] = []
|
||||||
|
for (const name of wanted) {
|
||||||
|
const [existing] = await tx.select({ id: schema.problemTag.id }).from(schema.problemTag)
|
||||||
|
.where(sql`lower(${schema.problemTag.name}) = lower(${name})`).limit(1)
|
||||||
|
if (existing) { ids.push(existing.id); continue }
|
||||||
|
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
|
||||||
|
if (parsed.data.action === "add") {
|
||||||
|
const [created] = await tx.insert(schema.problemTag).values({ name })
|
||||||
|
.returning({ id: schema.problemTag.id })
|
||||||
|
ids.push(created!.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签")
|
||||||
|
|
||||||
|
const problemIds = problems.map((problem) => problem.id)
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
if (parsed.data.action === "remove") {
|
||||||
|
await tx.delete(schema.problemTags).where(and(
|
||||||
|
inArray(schema.problemTags.problemId, problemIds),
|
||||||
|
inArray(schema.problemTags.problemtagId, tagIds),
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existing = await tx.select().from(schema.problemTags).where(and(
|
||||||
|
inArray(schema.problemTags.problemId, problemIds),
|
||||||
|
inArray(schema.problemTags.problemtagId, tagIds),
|
||||||
|
))
|
||||||
|
const have = new Set(existing.map((row) => `${row.problemId}:${row.problemtagId}`))
|
||||||
|
const rows = []
|
||||||
|
for (const problemId of problemIds) {
|
||||||
|
for (const tagId of tagIds) {
|
||||||
|
if (!have.has(`${problemId}:${tagId}`)) rows.push({ problemId, problemtagId: tagId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rows.length) await tx.insert(schema.problemTags).values(rows)
|
||||||
|
})
|
||||||
|
|
||||||
|
return success(c, batchProblemTagResponseSchema.parse({
|
||||||
|
problemCount: problems.length,
|
||||||
|
tagCount: tagIds.length,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 题目可见性
|
||||||
|
|
||||||
|
adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (c) => {
|
||||||
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
|
const [problem] = await db.select({ id: schema.problem.id, visible: schema.problem.visible, createdById: schema.problem.createdById })
|
||||||
|
.from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
|
||||||
|
// 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛
|
||||||
|
// AttributeError(500)。这里正常返回 404。
|
||||||
|
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
|
||||||
|
const user = c.get("user")!
|
||||||
|
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
|
||||||
|
return failure(c, 404, "problem-not-found", "题目不存在")
|
||||||
|
}
|
||||||
|
await db.update(schema.problem).set({ visible: !problem.visible }).where(eq(schema.problem.id, id))
|
||||||
|
return success(c, { visible: !problem.visible })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
|
||||||
|
|
||||||
|
adminTagRoutes.get("/problems/stuck", requireTeacher, async (c) => {
|
||||||
|
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
|
||||||
|
const rows = await db.select({
|
||||||
|
displayId: schema.problem.displayId,
|
||||||
|
title: schema.problem.title,
|
||||||
|
total: count(),
|
||||||
|
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
|
||||||
|
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
|
||||||
|
failedUsers: sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(Number),
|
||||||
|
}).from(schema.submission)
|
||||||
|
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||||
|
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
|
||||||
|
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`)
|
||||||
|
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
|
||||||
|
.limit(40)
|
||||||
|
return success(c, rows.map((row) => stuckProblemSchema.parse({
|
||||||
|
problemId: row.displayId,
|
||||||
|
problemTitle: row.title,
|
||||||
|
total: row.total,
|
||||||
|
failed: row.failed,
|
||||||
|
failedUsers: row.failedUsers,
|
||||||
|
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
|
||||||
|
adminTagRoutes.get("/problems/ac-trend", requireTeacher, async (c) => {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
// 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错
|
||||||
|
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)
|
||||||
|
if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023
|
||||||
|
let untilYear = queryInteger(c.req.query("untilYear"), currentYear)
|
||||||
|
if (untilYear < sinceYear || untilYear > currentYear) untilYear = currentYear - 1
|
||||||
|
let minPerYear = queryInteger(c.req.query("minPerYear"), 100)
|
||||||
|
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
|
||||||
|
|
||||||
|
const year = sql<number>`extract(year from ${schema.submission.createTime})`.mapWith(Number)
|
||||||
|
const rows = await db.select({
|
||||||
|
problemId: schema.problem.id,
|
||||||
|
displayId: schema.problem.displayId,
|
||||||
|
title: schema.problem.title,
|
||||||
|
year,
|
||||||
|
total: count(),
|
||||||
|
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
|
||||||
|
}).from(schema.submission)
|
||||||
|
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||||
|
.where(and(
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
gte(sql`extract(year from ${schema.submission.createTime})`, sinceYear),
|
||||||
|
lte(sql`extract(year from ${schema.submission.createTime})`, untilYear),
|
||||||
|
))
|
||||||
|
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year)
|
||||||
|
.orderBy(asc(schema.problem.id), asc(year))
|
||||||
|
|
||||||
|
const required = new Set<number>()
|
||||||
|
for (let y = sinceYear; y <= untilYear; y += 1) required.add(y)
|
||||||
|
|
||||||
|
const grouped = new Map<number, { displayId: string; title: string; yearly: typeof rows }>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const bucket = grouped.get(row.problemId)
|
||||||
|
if (bucket) bucket.yearly.push(row)
|
||||||
|
else grouped.set(row.problemId, { displayId: row.displayId, title: row.title, yearly: [row] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = []
|
||||||
|
for (const entry of grouped.values()) {
|
||||||
|
const years = new Set(entry.yearly.map((row) => row.year))
|
||||||
|
// 每一年都得有数据,且每年提交量都超过门槛 —— 否则趋势没有可比性
|
||||||
|
if (![...required].every((y) => years.has(y))) continue
|
||||||
|
if (!entry.yearly.every((row) => row.total > minPerYear)) continue
|
||||||
|
result.push(acTrendSchema.parse({
|
||||||
|
problemId: entry.displayId,
|
||||||
|
problemTitle: entry.title,
|
||||||
|
yearly: entry.yearly
|
||||||
|
.map((row) => ({
|
||||||
|
year: row.year,
|
||||||
|
total: row.total,
|
||||||
|
accepted: row.accepted,
|
||||||
|
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.year - right.year),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return success(c, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Python → 流程图
|
||||||
|
|
||||||
|
adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => {
|
||||||
|
const parsed = generateFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||||
|
if (!parsed.success) return failure(c, 400, "invalid-request", "python 代码不能为空")
|
||||||
|
try {
|
||||||
|
const flowchart = await completeChat(
|
||||||
|
`你是一个可以将Python代码转换为mermaid的助手。
|
||||||
|
请将用户提供的Python代码转换为 Mermaid 纯文本。
|
||||||
|
注意括号内的内容用引号包裹,如果本身就有引号,请注意双引号和单引号的问题。
|
||||||
|
请只返回 mermaid 代码,连 \`\`\` 都不需要。`,
|
||||||
|
parsed.data.python,
|
||||||
|
)
|
||||||
|
return success(c, generateFlowchartResponseSchema.parse({ flowchart }))
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Flowchart generation failed", error)
|
||||||
|
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -72,11 +72,11 @@ export function editProblem(problem: AdminProblem | BlankProblem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toggleProblemVisible(problemID: number) {
|
export function toggleProblemVisible(problemID: number) {
|
||||||
return http.put("admin/problem/visible", { id: problemID })
|
return legacyResponse(api2.put(`admin/problems/${problemID}/visibility`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateFlowchartFromPythonCode(python: string) {
|
export function generateFlowchartFromPythonCode(python: string) {
|
||||||
return http.post("admin/problem/flowchart", { python })
|
return legacyResponse(api2.post("admin/problems/flowchart", { python }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editContestProblem(problem: AdminProblem | BlankProblem) {
|
export function editContestProblem(problem: AdminProblem | BlankProblem) {
|
||||||
@@ -93,20 +93,22 @@ export function getContestProblem(id: number) {
|
|||||||
|
|
||||||
// 标签管理
|
// 标签管理
|
||||||
export function getTagAdminList(keyword = "") {
|
export function getTagAdminList(keyword = "") {
|
||||||
return http.get<AdminTag[]>("admin/problem/tag", { params: { keyword } })
|
return legacyResponse<AdminTag[]>(
|
||||||
|
api2.get("admin/problem-tags", { params: { keyword } }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renameTag(id: number, name: string) {
|
export function renameTag(id: number, name: string) {
|
||||||
return http.put<{
|
return legacyResponse<{
|
||||||
merged: boolean
|
merged: boolean
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
affected_count: number
|
affected_count: number
|
||||||
}>("admin/problem/tag", { id, name })
|
}>(api2.put(`admin/problem-tags/${id}`, { name }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteTag(id: number) {
|
export function deleteTag(id: number) {
|
||||||
return http.delete("admin/problem/tag", { params: { id } })
|
return api2.delete(`admin/problem-tags/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function batchTagProblems(
|
export function batchTagProblems(
|
||||||
@@ -114,9 +116,12 @@ export function batchTagProblems(
|
|||||||
tagNames: string[],
|
tagNames: string[],
|
||||||
action: "add" | "remove",
|
action: "add" | "remove",
|
||||||
) {
|
) {
|
||||||
return http.post<{ problem_count: number; tag_count: number }>(
|
return legacyResponse<{ problem_count: number; tag_count: number }>(
|
||||||
"admin/problem/batch_tag",
|
api2.post("admin/problems/batch-tag", {
|
||||||
{ problem_ids: problemIds, tag_names: tagNames, action },
|
problemIds,
|
||||||
|
tagNames,
|
||||||
|
action,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,7 +675,7 @@ export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
|||||||
|
|
||||||
// 学生卡点分析
|
// 学生卡点分析
|
||||||
export function getStuckProblems() {
|
export function getStuckProblems() {
|
||||||
return http.get("admin/problem/stuck")
|
return legacyResponse(api2.get("admin/problems/stuck"))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopACTrend(params: {
|
export function getTopACTrend(params: {
|
||||||
@@ -678,7 +683,15 @@ export function getTopACTrend(params: {
|
|||||||
until_year: number
|
until_year: number
|
||||||
min_per_year: number
|
min_per_year: number
|
||||||
}) {
|
}) {
|
||||||
return http.get("admin/problem/top_ac_trend", { params })
|
return legacyResponse(
|
||||||
|
api2.get("admin/problems/ac-trend", {
|
||||||
|
params: {
|
||||||
|
sinceYear: params.since_year,
|
||||||
|
untilYear: params.until_year,
|
||||||
|
minPerYear: params.min_per_year,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI 学习分析报告
|
// AI 学习分析报告
|
||||||
|
|||||||
@@ -147,3 +147,34 @@
|
|||||||
|
|
||||||
1. 本报告的对照关系带人工判断成分,若某条对应有异议,以实际业务行为为准。
|
1. 本报告的对照关系带人工判断成分,若某条对应有异议,以实际业务行为为准。
|
||||||
2. `utils/download.ts` 仍指向旧后端的 `/api/admin`(blob 下载),只被 admin 侧两个页面用,随阶段 4 一起切。
|
2. `utils/download.ts` 仍指向旧后端的 `/api/admin`(blob 下载),只被 admin 侧两个页面用,随阶段 4 一起切。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 5 切换必做项(阶段 4 施工时发现,记在这里以免忘)
|
||||||
|
|
||||||
|
**导入数据后必须重置全部序列。** 本地库是按显式 id 从生产导入的,
|
||||||
|
`problem_tag_id_seq` 停在 6 而表里 max(id)=87,于是第一次新建标签就撞
|
||||||
|
`duplicate key value violates unique constraint "problem_tag_pkey"`(500)。
|
||||||
|
|
||||||
|
生产切换若沿用同一个库则不受影响(序列本来就是对的);但只要有任何一步是
|
||||||
|
「导出 → 导入到新库」,就必须补这一句:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
do $$
|
||||||
|
declare r record; mx bigint;
|
||||||
|
begin
|
||||||
|
for r in
|
||||||
|
select split_part(pg_get_serial_sequence(quote_ident(t.table_name), c.column_name), '.', 2) as seqname,
|
||||||
|
t.table_name, c.column_name
|
||||||
|
from information_schema.tables t
|
||||||
|
join information_schema.columns c on c.table_name = t.table_name
|
||||||
|
join pg_sequences s on s.sequencename = split_part(pg_get_serial_sequence(quote_ident(t.table_name), c.column_name), '.', 2)
|
||||||
|
where t.table_schema = 'public'
|
||||||
|
loop
|
||||||
|
execute format('select coalesce(max(%I),0) from %I', r.column_name, r.table_name) into mx;
|
||||||
|
execute format('select setval(%L, greatest(%s, 1))', r.seqname, mx);
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
症状很隐蔽:读全部正常,只有**写**才炸,而且是导入后第一次写才炸。
|
||||||
|
|||||||
@@ -433,3 +433,57 @@ export const adminProblemSetProgressSchema = z.object({
|
|||||||
totalProblemsCount: z.number().int(),
|
totalProblemsCount: z.number().int(),
|
||||||
totalScore: z.number().int(),
|
totalScore: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 标签与题目分析
|
||||||
|
|
||||||
|
export const adminTagSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
name: z.string(),
|
||||||
|
problemCount: z.number().int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const renameTagRequestSchema = z.object({ name: z.string().trim().min(1).max(64) })
|
||||||
|
|
||||||
|
export const renameTagResponseSchema = z.object({
|
||||||
|
/** 改名撞上已有标签时视为合并:题目关系转移过去、原标签删除 */
|
||||||
|
merged: z.boolean(),
|
||||||
|
id: z.number().int(),
|
||||||
|
name: z.string(),
|
||||||
|
affectedCount: z.number().int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const batchProblemTagRequestSchema = z.object({
|
||||||
|
problemIds: z.array(z.number().int().positive()).min(1),
|
||||||
|
tagNames: z.array(z.string()).min(1),
|
||||||
|
action: z.enum(["add", "remove"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const batchProblemTagResponseSchema = z.object({
|
||||||
|
problemCount: z.number().int(),
|
||||||
|
tagCount: z.number().int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const stuckProblemSchema = z.object({
|
||||||
|
problemId: z.string(),
|
||||||
|
problemTitle: z.string(),
|
||||||
|
total: z.number().int(),
|
||||||
|
failed: z.number().int(),
|
||||||
|
failedUsers: z.number().int(),
|
||||||
|
acRate: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const acTrendYearSchema = z.object({
|
||||||
|
year: z.number().int(),
|
||||||
|
total: z.number().int(),
|
||||||
|
accepted: z.number().int(),
|
||||||
|
acRate: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const acTrendSchema = z.object({
|
||||||
|
problemId: z.string(),
|
||||||
|
problemTitle: z.string(),
|
||||||
|
yearly: z.array(acTrendYearSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const generateFlowchartRequestSchema = z.object({ python: z.string().min(1).max(64 * 1024) })
|
||||||
|
export const generateFlowchartResponseSchema = z.object({ flowchart: z.string() })
|
||||||
|
|||||||
Reference in New Issue
Block a user