Files
OJ2/apps/web/src/admin/api.ts
yuetsh c2a81209d7 refactor(前端): 拆掉 camelCase→snake_case 转换层,契约成为唯一真相
utils/legacy.ts 是迁移期的临时层:新后端一律 camelCase,而组件读的还是
旧 Django 的 snake_case,于是在 api 层做一次递归键名重写。它自己的注释就
写了「迁移完成后这一层应当整体拆掉」。现在拆了。

代价不只是那 96 处包装:每个响应都要递归遍历整个对象重写一遍键名,而且
utils/types.ts 和 packages/contract 是两份真相 —— 手抄的那份还抄歪了好几处。

做法是按域推进,每域都用 vue-tsc 相对基线做差,确认零新增错误后再往下走。
前端的类型现在一律以契约为准,只在必要处窄化(比如 languages/template 的键
窄化成 LANGUAGE),删掉的重复定义包括 WebsiteConfig、LoginSummary、
AchievementSummary、ProblemSet、Contest、User、Profile、AdminTag、
StuckProblem 等等,其中 ClassComparison 有两个组件各手抄了一份。

## 顺带修掉的真 bug

- 管理端公告列表的「可见」开关每次都 400:列表响应被契约 omit 掉了 content,
  而更新接口要求 content 必填,toggleVisible 把列表行原样回传。而且是乐观
  翻转、不 await 不 catch,管理员看到开关动了、实际没存也没有提示。
  改成先 GET 整条再 PUT,加失败提示。

- 删有提交的题时只显示笼统的「删除失败」:前端还在 match 旧 Django 的英文
  文案,而后端返回的是 problem-has-submissions + 中文。连同另外 8 处同类
  匹配一起改成判错误码 —— 文案是后端随时能改的,match 文案改一个字就静默失效。

- SubmissionStatus.time_limit_exceeded 写成 `1 | 2`,TS 按位或算成 3,和
  memory_limit_exceeded 撞了同一个值。后端 judge/status.ts 里这是分开的
  两个码,按后端拆成 cpu_/real_ 两项。当前没有代码读这两个成员,但
  CLAUDE.md 明确要求判题状态码三处同步。

- 流程图历史翻到没有提交的那一页会直接抛:契约里 submission 是 nullable,
  被 any 掩盖成看起来非空。补了 null 分支。

## 契约里被逼出来的三处不诚实

- grade 写成 z.string(),但 averageGrade() 在没有可用数据时返回空串,
  前端三张图表拿它查 Record<Grade,...> 会查出 undefined。按实际收紧成
  z.enum([...,""]),四个查表点都补了「无评级」分支。

- difficulty 写成 z.string()。核对过生产库 dump:956 道题只有
  Low/Mid/High 三个值(761/149/46)。收紧成枚举。

- topReaction 写成 z.string(),既对不上前端渲染的 {type,count},也对不上
  旧后端 get_top_reactions 下发的形状。改成正确形状并注明当前恒传 null。

## 明确保留 snake_case 的 54 处

判题沙箱原始输出(cpu_time/exit_code/output_md5/compile_output)、
statistic_info 内容(err_info/time_cost/ast_results)、submission_info
JSONB(is_ac/ac_time/error_number,回滚时旧后端还要读)、SQL 判题引擎的
total_rows/order_sensitive/changed_tables、WebSocket 的 submission_id、
以及数据库选项键 enable_maxkb。每一处都在类型定义旁写了为什么不能改。

language 没有跟着收紧契约 —— 它是配置项、随时可能加语言,收紧会让新语言
在后端 parse 时直接抛。改在 api 边界一处窄化。

## 另外

- utils/http.ts 整个模块已是死代码(四处引用全是 import type),删除。
- profile 的 blog/github/school/major/language 五个字段全链路空转,没有
  任何组件读,从契约到类型一并摘除(数据库列不动)。
- admin/account.ts 往 user_profile 塞的 totalScore 是 OI 模式遗留,表里
  没这一列。Drizzle 按表定义拼列名会把它静默丢弃,所以没出过错,是死代码。

验证:vue-tsc 143 → 54 条且无新增,apps/api tsc、check:routes、web build
全通过;各域响应形状逐条打接口核对过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:44:24 -06:00

747 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import api2 from "utils/api2"
import { toProblemListItem } from "admin/transforms"
import type {
AcTrend,
BatchProblemTagResponse,
GenerateSqlTestCaseResponse,
RenameTagResponse,
SqlTestCaseScript,
AcmHelperItem,
AdminAiReport,
AdminAiReportList,
StuckProblem,
AdminContestList,
AdminUser,
AdminUserList,
DashboardInfo,
JudgeServerList,
OrphanTestCase,
AdminProblem,
AdminProblemList,
AdminTag,
Announcement,
AnnouncementEdit,
AnnouncementListItem,
BlankContest,
BlankProblem,
Contest,
Exercise,
ExerciseType,
SQLDisplay,
TestcaseUploadedReturns,
Tutorial,
User,
WebsiteConfig,
ProblemSet,
ProblemSetBadge,
ProblemSetList,
ProblemSetProblem,
ProblemSetProgressList,
TutorialListItem,
} from "utils/types"
export function getBaseInfo() {
return api2.get<DashboardInfo>("admin/dashboard")
}
export function randomUser10(classroom: string) {
return api2.get<string[]>("admin/random-usernames", {
params: { classroom },
})
}
export async function getProblemList(
offset = 0,
limit = 10,
keyword: string,
author?: string,
contestID?: string,
tagId?: number,
) {
const endpoint = contestID
? `admin/contests/${contestID}/problems`
: "admin/problems"
const res = await api2.get<AdminProblemList>(endpoint, {
params: { offset, limit, keyword, author, tagId },
})
return {
results: res.data.results.map(toProblemListItem),
total: res.data.total,
}
}
export function deleteProblem(id: number) {
return api2.delete(`admin/problems/${id}`)
}
// 比赛题与公开题共用一条删除路由,比赛由后端从题目推导
export function deleteContestProblem(id: number) {
return api2.delete(`admin/problems/${id}`)
}
export function editProblem(problem: AdminProblem | BlankProblem) {
return api2.put<AdminProblem>(
`admin/problems/${(problem as AdminProblem).id}`,
toProblemBody(problem),
)
}
export function toggleProblemVisible(problemID: number) {
return api2.put<{ visible: boolean }>(
`admin/problems/${problemID}/visibility`,
)
}
export function generateFlowchartFromPythonCode(python: string) {
return api2.post<{ flowchart: string }>("admin/problems/flowchart", {
python,
})
}
export function editContestProblem(problem: AdminProblem | BlankProblem) {
return api2.put<AdminProblem>(
`admin/problems/${(problem as AdminProblem).id}`,
toProblemBody(problem),
)
}
export function getProblem(id: string | number) {
return api2.get<AdminProblem>(`admin/problems/${id}`)
}
export function getContestProblem(id: number) {
return api2.get<AdminProblem>(`admin/problems/${id}`)
}
// 标签管理
export function getTagAdminList(keyword = "") {
return api2.get<AdminTag[]>("admin/problem-tags", { params: { keyword } })
}
export function renameTag(id: number, name: string) {
return api2.put<RenameTagResponse>(`admin/problem-tags/${id}`, { name })
}
export function deleteTag(id: number) {
return api2.delete(`admin/problem-tags/${id}`)
}
export function batchTagProblems(
problemIds: number[],
tagNames: string[],
action: "add" | "remove",
) {
return api2.post<BatchProblemTagResponse>("admin/problems/batch-tag", {
problemIds,
tagNames,
action,
})
}
// 用户列表
export function getUserList(
offset = 0,
limit = 10,
type = "",
keyword: string,
orderBy = "",
) {
return api2.get<AdminUserList>("admin/users", {
// 旧接口的 order_by 只有 "-last_login" 一个取值
params: {
offset,
limit,
keyword,
type,
orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy,
},
})
}
// 编辑用户
export function editUser(user: User) {
return api2.put<AdminUser>(`admin/users/${user.id}`, {
username: user.username,
email: user.email,
adminType: user.adminType,
problemPermission: user.problemPermission,
realName: user.realName ?? null,
isDisabled: user.isDisabled,
openApi: user.openApi,
password: user.password ?? "",
})
}
// 重置用户密码。调用方直接用 res.data 当密码字符串(旧后端返回的就是裸字符串),
// 新后端返回 { password },在这里解包,组件不动
export async function resetPassword(userID: number) {
const res = await api2.post<{ password: string }>(
`admin/users/${userID}/reset-password`,
)
return { error: res.error, data: res.data.password }
}
// 导入用户
export function importUsers(users: string[][]) {
return api2.post("admin/users", { users })
}
// 批量删除用户
export function deleteUsers(userIDs: number[]) {
return api2.delete("admin/users", { data: { ids: userIDs } })
}
export function getContestList(offset = 0, limit = 10, keyword: string) {
return api2.get<AdminContestList>("admin/contests", {
params: { offset, limit, keyword },
})
}
// 上传图片
export async function uploadImage(file: File): Promise<string> {
const form = new window.FormData()
form.append("image", file)
const res = await api2.post<{
success: boolean
filePath: string
msg: string
}>("admin/upload-image", form, {
headers: { "content-type": "multipart/form-data" },
})
return res.data.success ? res.data.filePath : ""
}
// 上传测试用例SQL 题的压缩包是 1.sql..N.sql每个文件一个测试点的建表+数据脚本)
export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
const form = new window.FormData()
form.append("file", file)
if (options.sql) {
form.append("sql", "1")
}
return api2.post<TestcaseUploadedReturns>("admin/test-cases", form, {
headers: { "content-type": "multipart/form-data" },
})
}
// SQL 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据
export function previewSQLTestcase(data: {
initSql: string
refSql: string
mode: "query" | "modify"
}) {
return api2.post<SQLDisplay>("admin/sql-test-cases/preview", data)
}
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
export function getSQLTestcaseScripts(problemId: number) {
return api2.get<SqlTestCaseScript[]>(
`admin/problems/${problemId}/sql-scripts`,
)
}
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
export function generateSQLTestcase(data: {
refSql: string
mode: "query" | "modify"
}) {
return api2.post<GenerateSqlTestCaseResponse>(
"admin/sql-test-cases/generate",
data,
)
}
/** 出站补默认值。字段名两边已经一致,不再做键名转换 */
function toProblemBody(problem: AdminProblem | BlankProblem) {
const p = problem as Partial<AdminProblem>
return {
_id: p._id,
title: p.title,
description: p.description,
inputDescription: p.inputDescription ?? "",
outputDescription: p.outputDescription ?? "",
samples: p.samples ?? [],
testCaseId: p.testCaseId,
testCaseScore: p.testCaseScore ?? [],
timeLimit: p.timeLimit,
memoryLimit: p.memoryLimit,
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.shareSubmission ?? false,
allowFlowchart: p.allowFlowchart ?? false,
showFlowchart: p.showFlowchart ?? false,
mermaidCode: p.mermaidCode ?? null,
flowchartHint: p.flowchartHint ?? null,
astRules: p.astRules ?? null,
sqlConfig: p.sqlConfig ?? null,
}
}
export function createProblem(problem: BlankProblem) {
return api2.post<AdminProblem>("admin/problems", toProblemBody(problem))
}
export function createContestProblem(problem: BlankProblem) {
// contestId 由 detail.vue 在提交前写进 problem 对象
return api2.post<AdminProblem>(
`admin/contests/${problem.contestId}/problems`,
toProblemBody(problem),
)
}
/** 组件里的比赛对象是 snake_case出站转成新后端要的 camelCase */
function toContestBody(contest: Contest | BlankContest) {
return {
title: contest.title,
description: contest.description,
tag: contest.tag,
startTime: contest.startTime,
endTime: contest.endTime,
password: contest.password || null,
visible: contest.visible,
allowedIpRanges: contest.allowedIpRanges ?? [],
}
}
export function createContest(contest: BlankContest) {
return api2.post<Contest>("admin/contests", toContestBody(contest))
}
export function editContest(contest: Contest | BlankContest) {
return api2.put<Contest>(
`admin/contests/${(contest as Contest).id}`,
toContestBody(contest),
)
}
export function cloneContest(contestId: number) {
return api2.post<Contest>(`admin/contests/${contestId}/clone`)
}
export function getContest(id: string) {
return api2.get<Contest>(`admin/contests/${id}`)
}
export function addProblemForContest(
contestID: string,
problemID: number,
displayID: string,
) {
return api2.post<AdminProblem>(
`admin/contests/${contestID}/problems/from-public`,
{ problemId: problemID, displayId: displayID },
)
}
export function getWebsite() {
return api2.get<WebsiteConfig>("admin/website")
}
export function editWebsite(data: WebsiteConfig) {
return api2.post<WebsiteConfig>("admin/website", data)
}
export function listInvalidTestcases() {
return api2.get<OrphanTestCase[]>("admin/orphan-test-cases")
}
export function pruneInvalidTestcases(id?: string) {
return api2.delete("admin/orphan-test-cases", { params: { id } })
}
export function getJudgeServer() {
return api2.get<JudgeServerList>("admin/judge-servers")
}
export function deleteJudgeServer(hostname: string) {
return api2.delete(`admin/judge-servers/${encodeURIComponent(hostname)}`)
}
export function getAnnouncementList(offset = 0, limit = 10) {
return api2.get<{ results: AnnouncementListItem[]; total: number }>(
"admin/announcements",
{ params: { offset, limit } },
)
}
export function getAnnouncement(id: number) {
return api2.get<Announcement>(`admin/announcements/${id}`)
}
export function deleteAnnouncement(id: number) {
return api2.delete(`admin/announcements/${id}`)
}
export function editAnnouncement(announcement: AnnouncementEdit) {
const { id, ...body } = announcement
return api2.put<Announcement>(`admin/announcements/${id}`, body)
}
export function createAnnouncement(announcement: AnnouncementEdit) {
const { id: _id, ...body } = announcement
return api2.post<Announcement>("admin/announcements", body)
}
export async function getTutorialList() {
const res = await api2.get<{ [key: string]: TutorialListItem[] }>(
"admin/tutorials",
)
return res.data
}
export async function getTutorial(id: number) {
const res = await api2.get<Tutorial>(`admin/tutorials/${id}`)
return res.data
}
function toTutorialBody(data: Partial<Tutorial>) {
return {
title: data.title,
content: data.content,
code: data.code ?? null,
isPublic: data.isPublic ?? false,
order: data.order ?? 0,
type: data.type,
}
}
export async function createTutorial(data: Partial<Tutorial>) {
const res = await api2.post<Tutorial>("admin/tutorials", toTutorialBody(data))
return res.data
}
export async function updateTutorial(data: Partial<Tutorial>) {
const res = await api2.put<Tutorial>(
`admin/tutorials/${data.id}`,
toTutorialBody(data),
)
return res.data
}
export function deleteTutorial(id: number) {
return api2.delete(`admin/tutorials/${id}`)
}
export function setTutorialVisibility(id: number, isPublic: boolean) {
return api2.put<Tutorial>(`admin/tutorials/${id}/visibility`, { isPublic })
}
export async function getAdminExercises(tutorialId: number) {
const res = await api2.get<Exercise[]>(
`admin/tutorials/${tutorialId}/exercises`,
)
return res.data
}
export async function createExercise(data: {
tutorialId: number
type: ExerciseType
data: object
order: number
}) {
const res = await api2.post<Exercise>("admin/exercises", data)
return res.data
}
export async function updateExercise(data: {
id: number
type: ExerciseType
data: object
order: number
}) {
const res = await api2.put<Exercise>(`admin/exercises/${data.id}`, {
type: data.type,
data: data.data,
order: data.order,
})
return res.data
}
export function deleteExercise(id: number) {
return api2.delete(`admin/exercises/${id}`)
}
// 将竞赛题目转为公开题目
export function makeProblemPublic(id: number, displayId: string) {
return api2.post<AdminProblem>(`admin/problems/${id}/make-public`, {
displayId,
})
}
// 比赛辅助检查
export function getACMHelperList(contestId: number) {
return api2.get<AcmHelperItem[]>(`admin/contests/${contestId}/acm-helper`)
}
export function updateACMHelperChecked(
contest_id: number,
rank_id: number,
problem_id: string,
checked: boolean,
) {
return api2.put(`admin/contests/${contest_id}/acm-helper`, {
rankId: rank_id,
problemId: problem_id,
checked,
})
}
// 题单管理 API
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return api2.get<ProblemSetList>("admin/problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
}
export function getProblemSetDetail(id: number) {
return api2.get<ProblemSet>(`admin/problem-sets/${id}`)
}
interface ProblemSetBody {
title?: string
description?: string
difficulty?: ProblemSet["difficulty"]
status?: ProblemSet["status"]
// 表单里是 Date出站要 ISO 串
endTime?: Date | null
visible?: boolean
}
function toProblemSetBody(data: ProblemSetBody) {
return {
title: data.title,
description: data.description ?? "",
difficulty: data.difficulty ?? "Easy",
status: data.status ?? "active",
endTime: data.endTime ? new Date(data.endTime).toISOString() : null,
visible: data.visible ?? true,
}
}
export function createProblemSet(data: ProblemSetBody) {
return api2.post<ProblemSet>("admin/problem-sets", toProblemSetBody(data))
}
export function editProblemSet(data: ProblemSetBody & { id: number }) {
return api2.put<ProblemSet>(
`admin/problem-sets/${data.id}`,
toProblemSetBody(data),
)
}
export function deleteProblemSet(id: number) {
return api2.delete(`admin/problem-sets/${id}`)
}
export function toggleProblemSetVisible(id: number) {
return api2.put<ProblemSet>(`admin/problem-sets/${id}/visibility`)
}
export function updateProblemSetStatus(id: number, status: string) {
return api2.put<ProblemSet>(`admin/problem-sets/${id}/status`, { status })
}
// 题单题目管理 API
export function getProblemSetProblems(problemSetId: number) {
return api2.get<ProblemSetProblem[]>(
`admin/problem-sets/${problemSetId}/problems`,
)
}
export function addProblemToSet(
problemSetId: number,
data: {
problemId: string
order?: number
isRequired?: boolean
score?: number
hint?: string
},
) {
return api2.post(`admin/problem-sets/${problemSetId}/problems`, {
problemId: data.problemId,
order: data.order ?? 0,
isRequired: data.isRequired ?? true,
score: data.score ?? 0,
hint: data.hint ?? "",
})
}
export function editProblemInSet(
problemSetId: number,
problemSetProblemId: number,
data: {
order?: number
isRequired?: boolean
score?: number
hint?: string
},
) {
return api2.put(
`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`,
data,
)
}
export function removeProblemFromSet(
problemSetId: number,
problemSetProblemId: number,
) {
return api2.delete(
`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`,
)
}
// 题单奖章管理 API
export function getProblemSetBadges(problemSetId: number) {
return api2.get<ProblemSetBadge[]>(
`admin/problem-sets/${problemSetId}/badges`,
)
}
interface BadgeBody {
name?: string
description?: string
icon?: string
conditionType?: ProblemSetBadge["conditionType"]
conditionValue?: number
}
function toBadgeBody(data: BadgeBody) {
return {
name: data.name,
description: data.description ?? "",
icon: data.icon ?? "",
conditionType: data.conditionType,
conditionValue: data.conditionValue ?? 0,
}
}
export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
return api2.post<ProblemSetBadge>(
`admin/problem-sets/${problemSetId}/badges`,
toBadgeBody(data),
)
}
export function editProblemSetBadge(
problemSetId: number,
badgeId: number,
data: BadgeBody,
) {
return api2.put<ProblemSetBadge>(
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
toBadgeBody(data),
)
}
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
return api2.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`)
}
// 题单进度管理 API
export function getProblemSetProgress(problemSetId: number) {
return api2.get<ProblemSetProgressList>(
`admin/problem-sets/${problemSetId}/progress`,
)
}
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
return api2.delete(`admin/problem-sets/${problemSetId}/progress/${userId}`)
}
// 学生卡点分析
export function getStuckProblems() {
return api2.get<StuckProblem[]>("admin/problem-analytics/stuck")
}
export function getTopACTrend(params: {
sinceYear: number
untilYear: number
minPerYear: number
}) {
return api2.get<AcTrend[]>("admin/problem-analytics/ac-trend", { params })
}
// AI 学习分析报告
export function getAIReportList(offset = 0, limit = 10, username = "") {
return api2.get<AdminAiReportList>("admin/ai/reports", {
params: { offset, limit, username: username || undefined },
})
}
export function getAIReportDetail(id: number) {
return api2.get<AdminAiReport>(`admin/ai/reports/${id}`)
}
export function pinAIReport(id: number) {
return api2.post<{ isPinned: boolean }>(`admin/ai/reports/${id}/pin`)
}
export function getPinnedAIReports() {
return api2.get<AdminAiReportList>("admin/ai/reports", {
params: { pinnedOnly: "true" },
})
}
// ==================== 成就 ====================
import type {
AdminAchievement,
AchievementMetric as MetricOption,
} from "@oj2/contract"
export type { AdminAchievement, MetricOption }
function toAchievementBody(data: Partial<AdminAchievement>) {
return {
name: data.name,
description: data.description,
icon: data.icon,
rarity: data.rarity,
hidden: data.hidden ?? false,
metric: data.metric,
operator: data.operator,
threshold: data.threshold,
visible: data.visible ?? true,
order: data.order ?? 0,
}
}
export function getAdminAchievements() {
return api2.get<AdminAchievement[]>("admin/achievements")
}
export function getMetricOptions() {
return api2.get<MetricOption[]>("admin/achievement-metrics")
}
export function createAchievement(data: Partial<AdminAchievement>) {
return api2.post<AdminAchievement>(
"admin/achievements",
toAchievementBody(data),
)
}
export function updateAchievement(data: Partial<AdminAchievement>) {
return api2.put<AdminAchievement>(
`admin/achievements/${data.id}`,
toAchievementBody(data),
)
}
export function deleteAchievement(id: number) {
return api2.delete(`admin/achievements/${id}`)
}