feat(阶段4): 题目管理 / 比赛题目 / 比赛题⇄公开题互转

GET/POST          admin/problems
  GET/PUT/DELETE    admin/problems/:id
  POST              admin/problems/:id/make-public
  GET/POST          admin/contests/:contestId/problems
  POST              admin/contests/:contestId/problems/from-public

合并了旧后端拆开的两套路由:旧 admin/problem 与 admin/contest/problem 的
GET 详情、PUT、DELETE 都只按题目 id 取、比赛是从题目推导出来的,分开没有意义,
还逼前端多传一个它未必知道的 contestId。现在共用 /admin/problems/:id。

归属判断按旧后端的口径分开:**比赛题看比赛的创建者,公开题看题目自己的创建者**
(旧 ensure_created_by(problem.contest, user) vs ensure_created_by(problem, user))。
一道比赛题的 created_by 可能是克隆时的操作人,跟谁有权改它没关系。
题号唯一性的作用域也跟着走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一。

删题不删测试用例目录,与旧后端一致(它把 rmtree 注释掉了):删错了还能从磁盘捞回来,
而误删的测试数据没有别处备份;孤儿目录另有清理入口。有提交记录的题目拒绝删除。

## SQL 题目前只能编辑、不能新建

新后端**没有 SQL 判题链路** —— 旧 judge/sql_runner.py(378 行)+ sql_dispatcher.py
(113 行)无对应实现,languages.ts 里也只有 C/C++/Java/Golang/JavaScript/Python3 六种。
题目页给学生看的表结构与期望结果(sql_display)要跑 SQLite 现算,算不出来就没法建题。

因此:新建 SQL 题返回 501 并说明原因;**编辑已有 SQL 题时原样保留 sqlDisplay 不动** ——
不动它比生成一个错的安全,它直接决定学生看到的期望结果。SQL 的前置校验
(必须是唯一语言、要有 sql_config、要有 SQL 标准答案)照旧执行。

实测:学生 403;缺样例/缺输入描述/SQL 混语言三条校验文案与旧后端一致;
题号重复 409、跨比赛题号互不影响;公开题加入比赛后计数归零且标签带过来;
比赛题转公开后 visible=false、contestId=null、重复转 409;删除级联正常。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:37:20 -06:00
parent 764e0d28cc
commit e396d78a07
5 changed files with 702 additions and 33 deletions

View File

@@ -38,21 +38,13 @@ export async function getProblemList(
contestID?: string,
tagId?: number,
) {
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
const res = await http.get<{ results: AdminProblem[]; total: number }>(
endpoint,
{
params: {
paging: true,
offset,
limit,
keyword,
author,
contest_id: contestID,
tag_id: tagId,
},
},
)
const endpoint = contestID
? `admin/contests/${contestID}/problems`
: "admin/problems"
const res = await legacyResponse<{
results: AdminProblem[]
total: number
}>(api2.get(endpoint, { params: { offset, limit, keyword, author, tagId } }))
return {
results: res.data.results.map(toProblemListItem),
total: res.data.total,
@@ -60,15 +52,21 @@ export async function getProblemList(
}
export function deleteProblem(id: number) {
return http.delete("admin/problem", { params: { id } })
return api2.delete(`admin/problems/${id}`)
}
// 比赛题与公开题共用一条删除路由,比赛由后端从题目推导
export function deleteContestProblem(id: number) {
return http.delete("admin/contest/problem", { params: { id } })
return api2.delete(`admin/problems/${id}`)
}
export function editProblem(problem: AdminProblem | BlankProblem) {
return http.put("admin/problem", problem)
return legacyResponse(
api2.put(
`admin/problems/${(problem as AdminProblem).id}`,
toProblemBody(problem),
),
)
}
export function toggleProblemVisible(problemID: number) {
@@ -80,15 +78,20 @@ export function generateFlowchartFromPythonCode(python: string) {
}
export function editContestProblem(problem: AdminProblem | BlankProblem) {
return http.put("admin/contest/problem", problem)
return legacyResponse(
api2.put(
`admin/problems/${(problem as AdminProblem).id}`,
toProblemBody(problem),
),
)
}
export function getProblem(id: string | number) {
return http.get<AdminProblem>("admin/problem", { params: { id } })
return legacyResponse<AdminProblem>(api2.get(`admin/problems/${id}`))
}
export function getContestProblem(id: number) {
return http.get("admin/contest/problem", { params: { id } })
return legacyResponse<AdminProblem>(api2.get(`admin/problems/${id}`))
}
// 标签管理
@@ -239,12 +242,49 @@ export function generateSQLTestcase(data: {
return http.post<{ sql: string }>("admin/sql_test_case_ai_gen", data)
}
/** 组件里的题目对象是 snake_case出站转成新后端要的 camelCase */
function toProblemBody(problem: AdminProblem | BlankProblem) {
const p = problem as Record<string, any>
return {
_id: p._id,
title: p.title,
description: p.description,
inputDescription: p.input_description ?? "",
outputDescription: p.output_description ?? "",
samples: p.samples ?? [],
testCaseId: p.test_case_id,
testCaseScore: p.test_case_score ?? [],
timeLimit: p.time_limit,
memoryLimit: p.memory_limit,
languages: p.languages ?? [],
template: p.template ?? {},
visible: p.visible,
difficulty: p.difficulty,
tags: p.tags ?? [],
hint: p.hint ?? null,
source: p.source ?? null,
prompt: p.prompt ?? null,
answers: p.answers ?? [],
shareSubmission: p.share_submission ?? false,
allowFlowchart: p.allow_flowchart ?? false,
showFlowchart: p.show_flowchart ?? false,
mermaidCode: p.mermaid_code ?? null,
flowchartHint: p.flowchart_hint ?? null,
astRules: p.ast_rules ?? null,
sqlConfig: p.sql_config ?? null,
}
}
export function createProblem(problem: BlankProblem) {
return http.post("admin/problem", problem)
return legacyResponse(api2.post("admin/problems", toProblemBody(problem)))
}
export function createContestProblem(problem: BlankProblem) {
return http.post("admin/contest/problem", problem)
// contest_id 由 detail.vue 在提交前写进 problem 对象
const contestID = (problem as Record<string, any>).contest_id
return legacyResponse(
api2.post(`admin/contests/${contestID}/problems`, toProblemBody(problem)),
)
}
/** 组件里的比赛对象是 snake_case出站转成新后端要的 camelCase */
@@ -289,11 +329,12 @@ export function addProblemForContest(
problemID: number,
displayID: string,
) {
return http.post("admin/contest/add_problem_from_public", {
contest_id: contestID,
problem_id: problemID,
display_id: displayID,
})
return legacyResponse(
api2.post(`admin/contests/${contestID}/problems/from-public`, {
problemId: problemID,
displayId: displayID,
}),
)
}
export function getWebsite() {
@@ -451,10 +492,9 @@ export function deleteExercise(id: number) {
// 将竞赛题目转为公开题目
export function makeProblemPublic(id: number, display_id: string) {
return http.post("admin/contest_problem/make_public", {
id,
display_id,
})
return legacyResponse(
api2.post(`admin/problems/${id}/make-public`, { displayId: display_id }),
)
}
// 比赛辅助检查