Files
OJ2/apps/web/src/admin/api.ts
T

717 lines
21 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 api from "utils/api"
import { toAdminProblemRow } from "admin/transforms"
import type {
BatchProblemTagResponse,
GenerateSqlTestCaseResponse,
RenameTagResponse,
SqlTestCaseScript,
AdminAiReport,
AdminAiReportList,
StuckProblem,
StuckProblemDetail,
GradeTrendProblem,
LearnBoard,
LearnExerciseProgressList,
LearnExerciseAttempt,
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,
} 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,
})
}
// 比赛「从题库中选择」用:列所有可见的公开题,不只是自己创建的
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),
)
}
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,
})
}
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 })
}
export function setTagCategory(id: number, category: TagCategory) {
return api.put<null>(`admin/problem-tags/${id}/category`, { category })
}
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,
})
}
// 用户排名(后台版,无 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 = "") {
return api.get<AdminUserList>("admin/users", {
// "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin";
// "-online" 是新增的,原样透传
params: {
offset,
limit,
keyword,
type,
orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy,
},
})
}
// 编辑用户
export function editUser(user: AdminUserForm) {
return api.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,
password: user.password ?? "",
})
}
// 重置用户密码,返回新密码
export async function resetPassword(userID: number) {
const res = await api.post<{ password: string }>(`admin/users/${userID}/reset-password`)
return res.password
}
// 导入用户
export function importUsers(users: string[][]) {
return api.post("admin/users", { users })
}
// 批量删除用户
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)
}
// 回显已上传的 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)
}
// 编程题出题页:读回已有测试点的原文(太大的只给数量和大小)
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)
}
// AI 按题面和标准答案想几组测试输入(输出由标准答案跑)
export function generateTestInputs(data: GenerateTestInputsRequest) {
return api.post<GenerateTestInputsResponse>("admin/test-inputs/generate", data)
}
/** 出站补默认值。字段名两边已经一致,不再做键名转换 */
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))
}
export function createContestProblem(problem: BlankProblem) {
// contestId 由 detail.vue 在提交前写进 problem 对象
return api.post<AdminProblem>(
`admin/contests/${problem.contestId}/problems`,
toProblemBody(problem),
)
}
/** 组件里的比赛对象是 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))
}
export function editContest(contest: AdminContest | BlankContest) {
return api.put<AdminContest>(
`admin/contests/${(contest as AdminContest).id}`,
toContestBody(contest),
)
}
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,
})
}
/** 比赛题按这个顺序重新编号 1、2、3…(要给全这场比赛的题) */
export function reorderContestProblems(contestID: string, problemIds: number[]) {
return api.put(`admin/contests/${contestID}/problems/order`, { problemIds })
}
export function getWebsite() {
return api.get<WebsiteConfig>("admin/website")
}
export function editWebsite(data: WebsiteConfig) {
return api.post<WebsiteConfig>("admin/website", data)
}
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)
}
export function createAnnouncement(announcement: AnnouncementEdit) {
const { id: _id, ...body } = announcement
return api.post<AdminAnnouncement>("admin/announcements", body)
}
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))
}
export function updateTutorial(data: TutorialEdit) {
return api.put<Tutorial>(`admin/tutorials/${data.id}`, toTutorialBody(data))
}
export function setTutorialProblems(id: number, problemIds: number[]) {
return api.put<Tutorial>(`admin/tutorials/${id}/problems`, { problemIds })
}
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 })
}
export function renameTutorialProject(type: "python" | "c", from: string, to: string) {
return api.put<null>("admin/tutorial-projects", { type, from, to })
}
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 })
}
export function getAdminExercises(tutorialId: number) {
return api.get<Exercise[]>(`admin/tutorials/${tutorialId}/exercises`)
}
export async function createExercise(data: {
tutorialId: number
type: ExerciseType
data: object
order: number
}) {
return api.post<Exercise>("admin/exercises", data)
}
export async function updateExercise(data: {
id: number
type: ExerciseType
data: object
order: number
}) {
return api.put<Exercise>(`admin/exercises/${data.id}`, {
type: data.type,
data: data.data,
order: data.order,
})
}
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,
})
}
// 比赛辅助检查
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,
})
}
// 题单管理 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)
}
export function editProblemSet(id: number, data: ProblemSetBody) {
return api.put<AdminProblemSet>(`admin/problem-sets/${id}`, data)
}
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,
})
}
export function setProblemRequired(problemSetId: number, itemId: number, isRequired: boolean) {
return api.put(`admin/problem-sets/${problemSetId}/problems/${itemId}`, { isRequired })
}
export function reorderProblemSetProblems(problemSetId: number, ids: number[]) {
return api.put(`admin/problem-sets/${problemSetId}/problems/order`, { ids })
}
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)
}
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) {
return api.put<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges/${badgeId}`, data)
}
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<LearnExerciseAttempt[]>(`admin/learn-analytics/exercises/${exerciseId}/attempts`, {
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))
}
export function updateAchievement(data: Partial<AdminAchievement>) {
return api.put<AdminAchievement>(`admin/achievements/${data.id}`, toAchievementBody(data))
}
export function deleteAchievement(id: number) {
return api.delete(`admin/achievements/${id}`)
}