Files
OJ2/apps/web/src/admin/api.ts
T
xuyueandClaude Opus 5.5 4dfd14f95b
Deploy / deploy (push) Canceled after 0s
refactor(契约): 前端 60 个写请求的请求体接上契约类型,前后端对不上编译期就报
- 前端 oj/api.ts、admin/api.ts、shared/api.ts 的 post / put 请求体一律 `satisfies XxxRequest`,
  只在编译期检查,不加运行时校验(和「只在三处挂运行时闸门」的规矩一致)
- 契约里的 XxxRequest 从 z.infer 改成 z.input:请求类型描述的是「客户端可以发什么」,
  带默认值的字段可以不传。后端从不用这些类型(它用 parse 出来的 data),不受影响
- 补了四个漏导出的请求类型(比赛题目排序、题单题目排序、标签分类、检查用户名)
- 接上之后对不上的几处:编辑用户的角色和题目权限经 toAdminType / toProblemPermission
  收窄,导入用户的行改成四元组,练习题数据改成 Record,格式化的语言只收后端认的四种
  (isFormatLanguage)。出题页和成就的请求体是「表单校验兜底、出站补默认值」,
  用 Partial<…> 只核键名和值的类型

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:03:58 +08:00

839 lines
25 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 { toAdminType, toProblemPermission } from "@oj2/contract"
import type {
AddContestProblemRequest,
AddProblemsToSetRequest,
BatchProblemTagRequest,
BatchProblemVisibleRequest,
CheckUsernamesRequest,
CreateAchievementRequest,
CreateAnnouncementRequest,
CreateContestRequest,
CreateExerciseRequest,
CreateProblemRequest,
CreateProblemSetBadgeRequest,
CreateProblemSetRequest,
CreateTutorialRequest,
GenerateFlowchartRequest,
GenerateSqlTestCaseRequest,
ImportUsersRequest,
MakeProblemPublicRequest,
RenameTagRequest,
RenameTutorialProjectRequest,
ReorderContestProblemsRequest,
ReorderProblemSetProblemsRequest,
ReorderTutorialsRequest,
SetTagCategoryRequest,
SetTutorialProblemsRequest,
SetTutorialVisibilityRequest,
SqlPreviewRequest,
UpdateAchievementRequest,
UpdateAcmHelperRequest,
UpdateAnnouncementRequest,
UpdateContestRequest,
UpdateExerciseRequest,
UpdateProblemInSetRequest,
UpdateProblemRequest,
UpdateProblemSetBadgeRequest,
UpdateProblemSetRequest,
UpdateTutorialRequest,
UpdateUserRequest,
UpdateWebsiteConfigRequest,
} from "@oj2/contract"
import api from "utils/api"
import { toAdminProblemRow } from "admin/transforms"
import type {
BatchProblemTagResponse,
GenerateSqlTestCaseResponse,
RenameTagResponse,
SqlTestCaseScript,
AdminAiReport,
AdminAiReportList,
StuckProblem,
StuckProblemDetail,
GradeTrendProblem,
LearnBoard,
LearnExerciseProgressList,
LearnExerciseAttemptList,
LearnStudentDetail,
AdminContestList,
AdminUser,
AdminUserList,
DashboardInfo,
JudgeServerList,
OrphanTestCase,
AdminProblem,
AdminProblemList,
AdminTag,
AdminAnnouncement,
AnnouncementEdit,
AdminAnnouncementListItem,
BlankContest,
BlankProblem,
AdminContest,
Exercise,
ExerciseType,
SqlDisplay,
UploadTestCaseResponse,
Tutorial,
AdminUserForm,
WebsiteConfig,
AdminProblemSetBadge,
AdminProblemSetList,
AdminProblemSet,
AdminProblemSetProblem,
AddProblemsToSetResult,
TutorialListItem,
TutorialEdit,
TagCategory,
} from "utils/types"
import type {
TutorialProblemCandidate,
AstCheckRequest,
AstCheckResponse,
GenerateTestInputsRequest,
GenerateTestInputsResponse,
TestCaseFiles,
CheckUsernamesResponse,
} from "@oj2/contract"
export function getBaseInfo() {
return api.get<DashboardInfo>("admin/dashboard")
}
export function randomUser10(classroom: string) {
return api.get<string[]>("admin/random-usernames", {
params: { classroom },
})
}
export async function getProblemList(
offset = 0,
limit = 10,
keyword: string,
author?: string,
contestID?: string,
tagId?: number,
/** 题型:sql = SQL 题,code = 编程题;不传是全部(比赛题列表不认) */
kind?: "sql" | "code" | "",
/** 1 = 学生看得到的,0 = 藏着的;不传是全部(比赛题列表不认) */
visible?: "1" | "0" | "",
) {
const endpoint = contestID ? `admin/contests/${contestID}/problems` : "admin/problems"
const res = await api.get<AdminProblemList>(endpoint, {
params: {
offset,
limit,
keyword,
author,
tagId,
kind: kind || undefined,
visible: visible || undefined,
},
})
return {
results: res.results.map(toAdminProblemRow),
total: res.total,
counts: res.counts ?? null,
}
}
/** 勾了几道,一次让学生看得到 / 藏起来 */
export function batchSetProblemVisible(problemIds: number[], visible: boolean) {
return api.post<{ problemCount: number }>("admin/problems/batch-visible", {
problemIds,
visible,
} satisfies BatchProblemVisibleRequest)
}
// 比赛「从题库中选择」用:列所有可见的公开题,不只是自己创建的
export async function getLibraryProblemList(offset = 0, limit = 10, keyword: string) {
const res = await api.get<AdminProblemList>("admin/problems", {
params: { offset, limit, keyword, library: 1 },
})
return {
results: res.results.map(toAdminProblemRow),
total: res.total,
}
}
export function deleteProblem(id: number) {
return api.delete(`admin/problems/${id}`)
}
// 比赛题与公开题共用一条删除路由,比赛由后端从题目推导
export function deleteContestProblem(id: number) {
return api.delete(`admin/problems/${id}`)
}
// 比赛题也走这条:后端是同一条 PUT admin/problems/:id,比赛由题目自己推导,
// 所以不像新建那样需要分两个函数
export function editProblem(problem: AdminProblem | BlankProblem) {
return api.put<AdminProblem>(
`admin/problems/${(problem as AdminProblem).id}`,
toProblemBody(problem) satisfies Partial<UpdateProblemRequest>,
)
}
export function toggleProblemVisible(problemID: number) {
return api.put<{ visible: boolean }>(`admin/problems/${problemID}/visibility`)
}
export function generateFlowchartFromPythonCode(python: string) {
return api.post<{ doc: Record<string, unknown> }>("admin/problems/flowchart", {
python,
} satisfies GenerateFlowchartRequest)
}
export function getProblem(id: string | number) {
return api.get<AdminProblem>(`admin/problems/${id}`)
}
// 标签管理
export function getTagAdminList(keyword = "") {
return api.get<AdminTag[]>("admin/problem-tags", { params: { keyword } })
}
export function renameTag(id: number, name: string) {
return api.put<RenameTagResponse>(`admin/problem-tags/${id}`, { name } satisfies RenameTagRequest)
}
export function setTagCategory(id: number, category: TagCategory) {
return api.put<null>(`admin/problem-tags/${id}/category`, {
category,
} satisfies SetTagCategoryRequest)
}
export function deleteTag(id: number) {
return api.delete(`admin/problem-tags/${id}`)
}
export function batchTagProblems(
problemIds: number[],
tagNames: string[],
action: "add" | "remove",
) {
return api.post<BatchProblemTagResponse>("admin/problems/batch-tag", {
problemIds,
tagNames,
action,
} satisfies BatchProblemTagRequest)
}
// 用户排名(后台版,无 100 名上限;公开榜单是 oj/api.ts 的 getRank)
export function getAdminUserRank(offset: number, limit: number, keyword: string) {
return api.get<AdminUserRank>("admin/rankings/users", {
params: { offset, limit, keyword },
})
}
// 用户列表
export function getUserList(
offset = 0,
limit = 10,
type = "",
keyword: string,
orderBy = "",
/** 班级,NO_CLASS 是「没填班级」 */
className = "",
) {
return api.get<AdminUserList>("admin/users", {
// "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin";
// "-online" 是新增的,原样透传
params: {
offset,
limit,
keyword,
type,
orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy,
className: className || undefined,
},
})
}
/** 批量建号之前查重:这些用户名哪些已经有了 */
export function checkUsernames(usernames: string[]) {
return api.post<CheckUsernamesResponse>("admin/users/check", {
usernames,
} satisfies CheckUsernamesRequest)
}
// 编辑用户
export function editUser(user: AdminUserForm) {
return api.put<AdminUser>(`admin/users/${user.id}`, {
username: user.username,
// 库里 email 没有空的(2026-10 核过),响应类型是 nullable 只因为列可空
email: user.email ?? "",
adminType: toAdminType(user.adminType),
problemPermission: toProblemPermission(user.problemPermission),
realName: user.realName ?? null,
isDisabled: user.isDisabled,
password: user.password ?? "",
} satisfies UpdateUserRequest)
}
// 重置用户密码,返回新密码
export async function resetPassword(userID: number) {
const res = await api.post<{ password: string }>(`admin/users/${userID}/reset-password`)
return res.password
}
// 导入用户
/** 批量建号。addedClasses:这批里的新班级,后端已经顺手加进了班级列表 */
export function importUsers(users: ImportUsersRequest["users"]) {
return api.post<{ imported: number; addedClasses: string[] }>("admin/users", {
users,
} satisfies ImportUsersRequest)
}
// 批量删除用户
export function deleteUsers(userIDs: number[]) {
return api.delete("admin/users", { data: { ids: userIDs } })
}
export function getContestList(offset = 0, limit = 10, keyword: string) {
return api.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 api.post<{
success: boolean
filePath: string
msg: string
}>("admin/upload-image", form, {
headers: { "content-type": "multipart/form-data" },
})
return res.success ? res.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 api.post<UploadTestCaseResponse>("admin/test-cases", form, {
headers: { "content-type": "multipart/form-data" },
})
}
// SQL 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据
export function previewSQLTestcase(data: {
initSql: string
refSql: string
mode: "query" | "modify"
/** 是不是题面上展示的那组(第 1 组):增删改题只有它必须改到行 */
shown: boolean
}) {
return api.post<SqlDisplay>("admin/sql-test-cases/preview", data satisfies SqlPreviewRequest)
}
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
export function getSQLTestcaseScripts(problemId: number) {
return api.get<SqlTestCaseScript[]>(`admin/problems/${problemId}/sql-scripts`)
}
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
export function generateSQLTestcase(data: { refSql: string; mode: "query" | "modify" }) {
return api.post<GenerateSqlTestCaseResponse>(
"admin/sql-test-cases/generate",
data satisfies GenerateSqlTestCaseRequest,
)
}
// 编程题出题页:读回已有测试点的原文(太大的只给数量和大小)
export function getTestCaseFiles(problemId: number) {
return api.get<TestCaseFiles>(`admin/problems/${problemId}/test-case-files`)
}
// 拿标准答案自测语法要求
export function checkAstRules(data: AstCheckRequest) {
return api.post<AstCheckResponse>("admin/ast-check", data satisfies AstCheckRequest)
}
// AI 按题面和标准答案想几组测试输入(输出由标准答案跑)
export function generateTestInputs(data: GenerateTestInputsRequest) {
return api.post<GenerateTestInputsResponse>(
"admin/test-inputs/generate",
data satisfies GenerateTestInputsRequest,
)
}
/** 出站补默认值。字段名两边已经一致,不再做键名转换 */
function toProblemBody(problem: AdminProblem | BlankProblem) {
const p = problem as Partial<Omit<AdminProblem, "flowchartData">> & {
flowchartData?: Record<string, unknown> | null
}
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 ?? [],
allowFlowchart: p.allowFlowchart ?? false,
showFlowchart: p.showFlowchart ?? false,
mermaidCode: p.mermaidCode ?? null,
flowchartData: p.flowchartData ?? null,
flowchartHint: p.flowchartHint ?? null,
astRules: p.astRules ?? null,
sqlConfig: p.sqlConfig ?? null,
}
}
export function createProblem(problem: BlankProblem) {
return api.post<AdminProblem>(
"admin/problems",
toProblemBody(problem) satisfies Partial<CreateProblemRequest>,
)
}
export function createContestProblem(problem: BlankProblem) {
// contestId 由 detail.vue 在提交前写进 problem 对象
return api.post<AdminProblem>(
`admin/contests/${problem.contestId}/problems`,
toProblemBody(problem) satisfies Partial<CreateProblemRequest>,
)
}
/** 组件里的比赛对象是 snake_case,出站转成新后端要的 camelCase */
function toContestBody(contest: AdminContest | BlankContest) {
return {
title: contest.title,
description: contest.description,
tag: contest.tag,
startTime: contest.startTime,
endTime: contest.endTime,
password: contest.password || null,
visible: contest.visible,
}
}
export function createContest(contest: BlankContest) {
return api.post<AdminContest>(
"admin/contests",
toContestBody(contest) satisfies CreateContestRequest,
)
}
export function editContest(contest: AdminContest | BlankContest) {
return api.put<AdminContest>(
`admin/contests/${(contest as AdminContest).id}`,
toContestBody(contest) satisfies UpdateContestRequest,
)
}
export function cloneContest(contestId: number) {
return api.post<AdminContest>(`admin/contests/${contestId}/clone`)
}
export function getContest(id: string) {
return api.get<AdminContest>(`admin/contests/${id}`)
}
export function addProblemForContest(contestID: string, problemID: number, displayID: string) {
return api.post<AdminProblem>(`admin/contests/${contestID}/problems/from-public`, {
problemId: problemID,
displayId: displayID,
} satisfies AddContestProblemRequest)
}
/** 比赛题按这个顺序重新编号 1、2、3…(要给全这场比赛的题) */
export function reorderContestProblems(contestID: string, problemIds: number[]) {
return api.put(`admin/contests/${contestID}/problems/order`, {
problemIds,
} satisfies ReorderContestProblemsRequest)
}
export function getWebsite() {
return api.get<WebsiteConfig>("admin/website")
}
export function editWebsite(data: WebsiteConfig) {
return api.post<WebsiteConfig>("admin/website", data satisfies UpdateWebsiteConfigRequest)
}
export function listInvalidTestcases() {
return api.get<OrphanTestCase[]>("admin/orphan-test-cases")
}
export function pruneInvalidTestcases(id?: string) {
return api.delete("admin/orphan-test-cases", { params: { id } })
}
export function getJudgeServer() {
return api.get<JudgeServerList>("admin/judge-servers")
}
export function deleteJudgeServer(hostname: string) {
return api.delete(`admin/judge-servers/${encodeURIComponent(hostname)}`)
}
export function getAnnouncementList(offset = 0, limit = 10) {
return api.get<{ results: AdminAnnouncementListItem[]; total: number }>("admin/announcements", {
params: { offset, limit },
})
}
export function getAnnouncement(id: number) {
return api.get<AdminAnnouncement>(`admin/announcements/${id}`)
}
export function deleteAnnouncement(id: number) {
return api.delete(`admin/announcements/${id}`)
}
export function editAnnouncement(announcement: AnnouncementEdit) {
const { id, ...body } = announcement
return api.put<AdminAnnouncement>(
`admin/announcements/${id}`,
body satisfies UpdateAnnouncementRequest,
)
}
export function createAnnouncement(announcement: AnnouncementEdit) {
const { id: _id, ...body } = announcement
return api.post<AdminAnnouncement>(
"admin/announcements",
body satisfies CreateAnnouncementRequest,
)
}
export function getTutorialList() {
return api.get<{ [key: string]: TutorialListItem[] }>("admin/tutorials")
}
export function getTutorial(id: number) {
return api.get<Tutorial>(`admin/tutorials/${id}`)
}
/** 「示例代码」界面上去掉了,`code` 不带:后端照原样留着发布脚本写进去的那份 */
function toTutorialBody(data: TutorialEdit) {
return {
title: data.title,
content: data.content,
project: data.project,
isPublic: data.isPublic,
order: data.order,
type: data.type,
}
}
export function createTutorial(data: TutorialEdit) {
return api.post<Tutorial>("admin/tutorials", toTutorialBody(data) satisfies CreateTutorialRequest)
}
export function updateTutorial(data: TutorialEdit) {
return api.put<Tutorial>(
`admin/tutorials/${data.id}`,
toTutorialBody(data) satisfies UpdateTutorialRequest,
)
}
export function setTutorialProblems(id: number, problemIds: number[]) {
return api.put<Tutorial>(`admin/tutorials/${id}/problems`, {
problemIds,
} satisfies SetTutorialProblemsRequest)
}
export function getTutorialProblemCandidates(keyword: string) {
return api.get<TutorialProblemCandidate[]>("admin/tutorial-problem-candidates", {
params: keyword ? { keyword } : {},
})
}
export function reorderTutorials(ids: number[]) {
return api.put<null>("admin/tutorial-order", { ids } satisfies ReorderTutorialsRequest)
}
export function renameTutorialProject(type: "python" | "c", from: string, to: string) {
return api.put<null>("admin/tutorial-projects", {
type,
from,
to,
} satisfies RenameTutorialProjectRequest)
}
export function deleteTutorial(id: number) {
return api.delete(`admin/tutorials/${id}`)
}
export function setTutorialVisibility(id: number, isPublic: boolean) {
return api.put<Tutorial>(`admin/tutorials/${id}/visibility`, {
isPublic,
} satisfies SetTutorialVisibilityRequest)
}
export function getAdminExercises(tutorialId: number) {
return api.get<Exercise[]>(`admin/tutorials/${tutorialId}/exercises`)
}
export async function createExercise(data: {
tutorialId: number
type: ExerciseType
data: Record<string, unknown>
order: number
}) {
return api.post<Exercise>("admin/exercises", data satisfies CreateExerciseRequest)
}
export async function updateExercise(data: {
id: number
type: ExerciseType
data: Record<string, unknown>
order: number
resetAttempts: boolean
}) {
return api.put<Exercise>(`admin/exercises/${data.id}`, {
type: data.type,
data: data.data,
order: data.order,
resetAttempts: data.resetAttempts,
} satisfies UpdateExerciseRequest)
}
export function deleteExercise(id: number) {
return api.delete(`admin/exercises/${id}`)
}
// 将竞赛题目转为公开题目
export function makeProblemPublic(id: number, displayId: string) {
return api.post<AdminProblem>(`admin/problems/${id}/make-public`, {
displayId,
} satisfies MakeProblemPublicRequest)
}
// 比赛辅助检查
export function updateACMHelperChecked(
contest_id: number,
rank_id: number,
problem_id: string,
checked: boolean,
) {
return api.put(`admin/contests/${contest_id}/acm-helper`, {
rankId: rank_id,
problemId: problem_id,
checked,
} satisfies UpdateAcmHelperRequest)
}
// 题单管理 API
export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
return api.get<AdminProblemSetList>("admin/problem-sets", {
params: { offset, limit, keyword },
})
}
export function getProblemSetDetail(id: number) {
return api.get<AdminProblemSet>(`admin/problem-sets/${id}`)
}
export interface ProblemSetBody {
title: string
description: string
/** 布置到哪天(东八区当天结束的 ISO 时刻);null = 不布置 */
assignedUntil: string | null
visible: boolean
}
export function createProblemSet(data: ProblemSetBody) {
return api.post<AdminProblemSet>("admin/problem-sets", data satisfies CreateProblemSetRequest)
}
export function editProblemSet(id: number, data: ProblemSetBody) {
return api.put<AdminProblemSet>(
`admin/problem-sets/${id}`,
data satisfies UpdateProblemSetRequest,
)
}
export function deleteProblemSet(id: number) {
return api.delete(`admin/problem-sets/${id}`)
}
export function toggleProblemSetVisible(id: number) {
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`)
}
// 题单题目管理 API
export function getProblemSetProblems(problemSetId: number) {
return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`)
}
/** 一次加好几道(题号),按给的顺序接在最后;找不到的、已经在里面的分开报回来 */
export function addProblemsToSet(problemSetId: number, problemIds: string[]) {
return api.post<AddProblemsToSetResult>(`admin/problem-sets/${problemSetId}/problems`, {
problemIds,
} satisfies AddProblemsToSetRequest)
}
export function setProblemRequired(problemSetId: number, itemId: number, isRequired: boolean) {
return api.put(`admin/problem-sets/${problemSetId}/problems/${itemId}`, {
isRequired,
} satisfies UpdateProblemInSetRequest)
}
export function reorderProblemSetProblems(problemSetId: number, ids: number[]) {
return api.put(`admin/problem-sets/${problemSetId}/problems/order`, {
ids,
} satisfies ReorderProblemSetProblemsRequest)
}
export function removeProblemFromSet(problemSetId: number, itemId: number) {
return api.delete(`admin/problem-sets/${problemSetId}/problems/${itemId}`)
}
// 题单奖章管理 API
export function getProblemSetBadges(problemSetId: number) {
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
}
export interface BadgeBody {
name: string
description: string
icon: string
conditionType: AdminProblemSetBadge["conditionType"]
conditionValue: number
}
export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
return api.post<AdminProblemSetBadge>(
`admin/problem-sets/${problemSetId}/badges`,
data satisfies CreateProblemSetBadgeRequest,
)
}
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) {
return api.put<AdminProblemSetBadge>(
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
data satisfies UpdateProblemSetBadgeRequest,
)
}
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
return api.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`)
}
// 卡点分析:start 是时间段的起点(ISO),不给就是全部时间
export function getStuckProblems(params: { className?: string; start?: string }) {
return api.get<StuckProblem[]>("admin/problem-analytics/stuck", { params })
}
export function getStuckProblemDetail(id: number, params: { className?: string; start?: string }) {
return api.get<StuckProblemDetail>(`admin/problem-analytics/stuck/${id}`, { params })
}
export function getLearnBoard(params: { type: "python" | "c"; className?: string }) {
return api.get<LearnBoard>("admin/learn-analytics/board", { params })
}
export function getLearnExercises(params: { type: "python" | "c"; className?: string }) {
return api.get<LearnExerciseProgressList>("admin/learn-analytics/exercises", {
params,
})
}
export function getLearnExerciseAttempts(exerciseId: number, params: { className?: string }) {
return api.get<LearnExerciseAttemptList>(
`admin/learn-analytics/exercises/${exerciseId}/attempts`,
{ params },
)
}
export function getLearnStudent(userId: number, params: { type: "python" | "c" }) {
return api.get<LearnStudentDetail>(`admin/learn-analytics/students/${userId}`, { params })
}
export function getGradeTrend() {
return api.get<GradeTrendProblem[]>("admin/problem-analytics/grades")
}
// AI 学习分析报告
export function getAIReportList(offset = 0, limit = 10, username = "") {
return api.get<AdminAiReportList>("admin/ai/reports", {
params: { offset, limit, username: username || undefined },
})
}
export function getAIReportDetail(id: number) {
return api.get<AdminAiReport>(`admin/ai/reports/${id}`)
}
export function pinAIReport(id: number) {
return api.post<{ isPinned: boolean }>(`admin/ai/reports/${id}/pin`)
}
export function getPinnedAIReports() {
return api.get<AdminAiReportList>("admin/ai/reports", {
params: { pinnedOnly: "true" },
})
}
// ==================== 成就 ====================
import type {
AdminAchievement,
AchievementMetric as MetricOption,
AdminUserRank,
} 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 api.get<AdminAchievement[]>("admin/achievements")
}
export function getMetricOptions() {
return api.get<MetricOption[]>("admin/achievement-metrics")
}
export function createAchievement(data: Partial<AdminAchievement>) {
return api.post<AdminAchievement>(
"admin/achievements",
toAchievementBody(data) satisfies Partial<CreateAchievementRequest>,
)
}
export function updateAchievement(data: Partial<AdminAchievement>) {
return api.put<AdminAchievement>(
`admin/achievements/${data.id}`,
toAchievementBody(data) satisfies Partial<UpdateAchievementRequest>,
)
}
export function deleteAchievement(id: number) {
return api.delete(`admin/achievements/${id}`)
}