feat(阶段4): 题单管理(题目 / 奖章 / 进度三层)

GET/POST                admin/problem-sets
  GET/PUT/DELETE          admin/problem-sets/:id
  PUT                     admin/problem-sets/:id/visibility   (取反语义,与旧一致)
  PUT                     admin/problem-sets/:id/status
  GET/POST                admin/problem-sets/:id/problems
  PUT/DELETE              admin/problem-sets/:id/problems/:itemId
  GET/POST                admin/problem-sets/:id/badges
  PUT/DELETE              admin/problem-sets/:id/badges/:badgeId
  GET                     admin/problem-sets/:id/progress
  DELETE                  admin/problem-sets/:id/progress/:userId

修掉旧后端三个问题:

1. **后台列表写死了 visible=True,可它同时又提供「切换可见性」的接口** ——
   一旦把题单设成不可见,它就从后台列表消失,再也没法在界面上改回来。
   新实现不按 visible 过滤,后台能看见自己管的全部题单。实测取反两次仍在列表里。
2. **加题/删题/改分值后不重算学生进度**。往题单里加一道题,学生那边的
   totalProblemsCount 还是老数字,进度百分比因此偏高,甚至已「完成」的人分母变了
   却还标着完成。新实现加了 resyncProgress,实测 2/2 加一道题后变成 2/3 = 66.67%。
   只重算分母与百分比,不碰 completeTime —— 已完成过的事实不因加题而撤销。
3. 把人踢出题单时一并收回他基于这份题单拿到的奖章,否则奖章悬空。

奖章重算对齐旧 recalculate_user_badges(那边靠 post_save 信号,这里显式调):
只增删差集、保留已有记录的 earnedTime,否则每改一次条件所有人的获得时间都会
刷新成今天。实测 all_problems 建成时补发 1 人 → 加题后收回 → 改成
problem_count>=2 后重新发出 → 踢人后收回。

删题单要按序清五张子表(全是 NO ACTION 外键):user_badge 挂在 problemset_badge
上,得先于 badge 删。

实测:学生 403;重复加题 409、加不存在的题 404、野状态/野奖章条件 400;
级联删除后五张表全为 0。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:25:14 -06:00
parent 553c084579
commit 5e0229db12
4 changed files with 643 additions and 28 deletions

View File

@@ -478,19 +478,34 @@ export function getProblemSetList(
difficulty = "",
status = "",
) {
return http.get("admin/problemset", {
params: {
offset,
limit,
keyword,
difficulty,
status,
},
})
return legacyResponse(
api2.get("admin/problem-sets", {
params: { offset, limit, keyword, difficulty, status },
}),
)
}
export function getProblemSetDetail(id: number) {
return http.get(`admin/problemset/${id}`)
return legacyResponse(api2.get(`admin/problem-sets/${id}`))
}
/** 组件传的是 snake_case出站转成新后端要的 camelCase */
function toProblemSetBody(data: {
title?: string
description?: string
difficulty?: string
status?: string
end_time?: Date | null
visible?: boolean
}) {
return {
title: data.title,
description: data.description ?? "",
difficulty: data.difficulty ?? "Easy",
status: data.status ?? "active",
endTime: data.end_time ? new Date(data.end_time).toISOString() : null,
visible: data.visible ?? true,
}
}
export function createProblemSet(data: {
@@ -500,7 +515,7 @@ export function createProblemSet(data: {
status: string
end_time?: Date | null
}) {
return http.post("admin/problemset", data)
return legacyResponse(api2.post("admin/problem-sets", toProblemSetBody(data)))
}
export function editProblemSet(data: {
@@ -512,24 +527,26 @@ export function editProblemSet(data: {
end_time?: Date | null
visible?: boolean
}) {
return http.put("admin/problemset", data)
return legacyResponse(
api2.put(`admin/problem-sets/${data.id}`, toProblemSetBody(data)),
)
}
export function deleteProblemSet(id: number) {
return http.delete("admin/problemset", { params: { id } })
return api2.delete(`admin/problem-sets/${id}`)
}
export function toggleProblemSetVisible(id: number) {
return http.put("admin/problemset/visible", { id })
return legacyResponse(api2.put(`admin/problem-sets/${id}/visibility`))
}
export function updateProblemSetStatus(id: number, status: string) {
return http.put("admin/problemset/status", { id, status })
return legacyResponse(api2.put(`admin/problem-sets/${id}/status`, { status }))
}
// 题单题目管理 API
export function getProblemSetProblems(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/problems`)
return legacyResponse(api2.get(`admin/problem-sets/${problemSetId}/problems`))
}
export function addProblemToSet(
@@ -542,7 +559,13 @@ export function addProblemToSet(
hint?: string
},
) {
return http.post(`admin/problemset/${problemSetId}/problems`, data)
return api2.post(`admin/problem-sets/${problemSetId}/problems`, {
problemId: data.problem_id,
order: data.order ?? 0,
isRequired: data.is_required ?? true,
score: data.score ?? 0,
hint: data.hint ?? "",
})
}
export function editProblemInSet(
@@ -555,9 +578,14 @@ export function editProblemInSet(
hint?: string
},
) {
return http.put(
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
data,
return api2.put(
`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`,
{
order: data.order,
isRequired: data.is_required,
score: data.score,
hint: data.hint,
},
)
}
@@ -565,14 +593,30 @@ export function removeProblemFromSet(
problemSetId: number,
problemSetProblemId: number,
) {
return http.delete(
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
return api2.delete(
`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`,
)
}
// 题单奖章管理 API
export function getProblemSetBadges(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/badges`)
return legacyResponse(api2.get(`admin/problem-sets/${problemSetId}/badges`))
}
function toBadgeBody(data: {
name?: string
description?: string
icon?: string
condition_type?: string
condition_value?: number
}) {
return {
name: data.name,
description: data.description ?? "",
icon: data.icon ?? "",
conditionType: data.condition_type,
conditionValue: data.condition_value ?? 0,
}
}
export function createProblemSetBadge(
@@ -586,7 +630,9 @@ export function createProblemSetBadge(
level?: number
},
) {
return http.post(`admin/problemset/${problemSetId}/badges`, data)
return legacyResponse(
api2.post(`admin/problem-sets/${problemSetId}/badges`, toBadgeBody(data)),
)
}
export function editProblemSetBadge(
@@ -601,20 +647,25 @@ export function editProblemSetBadge(
level?: number
},
) {
return http.put(`admin/problemset/${problemSetId}/badges/${badgeId}`, data)
return legacyResponse(
api2.put(
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
toBadgeBody(data),
),
)
}
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
return http.delete(`admin/problemset/${problemSetId}/badges/${badgeId}`)
return api2.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`)
}
// 题单进度管理 API
export function getProblemSetProgress(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/progress`)
return legacyResponse(api2.get(`admin/problem-sets/${problemSetId}/progress`))
}
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
return http.delete(`admin/problemset/${problemSetId}/progress/${userId}`)
return api2.delete(`admin/problem-sets/${problemSetId}/progress/${userId}`)
}
// 学生卡点分析