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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { AchievementRarity } from "utils/types"
|
||||
import {
|
||||
createAchievement,
|
||||
getMetricOptions,
|
||||
@@ -29,7 +30,7 @@ function emptyForm() {
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "noto:trophy",
|
||||
rarity: "bronze",
|
||||
rarity: "bronze" as AchievementRarity,
|
||||
hidden: false,
|
||||
metric: "",
|
||||
operator: "gte" as "gte" | "lte",
|
||||
@@ -46,7 +47,7 @@ const metricOptions = computed(() =>
|
||||
)
|
||||
|
||||
const metricHelp = computed(
|
||||
() => metrics.value.find((m) => m.key === form.value.metric)?.help_text ?? "",
|
||||
() => metrics.value.find((m) => m.key === form.value.metric)?.helpText ?? "",
|
||||
)
|
||||
|
||||
watch(
|
||||
|
||||
@@ -74,7 +74,7 @@ const columns: DataTableColumn<AdminAchievement>[] = [
|
||||
width: 90,
|
||||
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
|
||||
},
|
||||
{ title: "指标", key: "metric_name" },
|
||||
{ title: "指标", key: "metricName" },
|
||||
{
|
||||
title: "条件",
|
||||
key: "threshold",
|
||||
@@ -93,7 +93,7 @@ const columns: DataTableColumn<AdminAchievement>[] = [
|
||||
width: 70,
|
||||
render: (row) => (row.visible ? "是" : "否"),
|
||||
},
|
||||
{ title: "已解锁人数", key: "unlock_count", width: 110 },
|
||||
{ title: "已解锁人数", key: "unlockCount", width: 110 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
|
||||
@@ -49,10 +49,10 @@
|
||||
detail.username
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="班级">{{
|
||||
detail.class_name || "-"
|
||||
detail.className || "-"
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="时间" :span="2">{{
|
||||
parseTime(detail.create_time, "YYYY-MM-DD HH:mm:ss")
|
||||
parseTime(detail.createTime, "YYYY-MM-DD HH:mm:ss")
|
||||
}}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-scrollbar style="max-height: 60vh; margin-top: 12px">
|
||||
@@ -75,19 +75,10 @@ import {
|
||||
getPinnedAIReports,
|
||||
} from "../api"
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import type { AdminAiReport, AdminAiReportListItem } from "utils/types"
|
||||
|
||||
interface ReportItem {
|
||||
id: number
|
||||
create_time: string
|
||||
username: string
|
||||
analysis_excerpt: string
|
||||
is_pinned: boolean
|
||||
}
|
||||
|
||||
interface ReportDetail extends ReportItem {
|
||||
analysis: string
|
||||
class_name: string | null
|
||||
}
|
||||
type ReportItem = AdminAiReportListItem
|
||||
type ReportDetail = AdminAiReport
|
||||
|
||||
const reports = ref<ReportItem[]>([])
|
||||
const total = ref(0)
|
||||
@@ -105,29 +96,25 @@ const columns: DataTableColumn<ReportItem>[] = [
|
||||
key: "username",
|
||||
width: 150,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: row.is_pinned ? "font-weight:600" : "" },
|
||||
row.username,
|
||||
),
|
||||
h("span", { style: row.isPinned ? "font-weight:600" : "" }, row.username),
|
||||
},
|
||||
{
|
||||
title: "AI 分析内容",
|
||||
key: "analysis_excerpt",
|
||||
render: (row) => row.analysis_excerpt || "-",
|
||||
render: (row) => row.analysisExcerpt || "-",
|
||||
},
|
||||
{
|
||||
title: "生成时间",
|
||||
key: "create_time",
|
||||
width: 200,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "PIN 状态",
|
||||
key: "is_pinned",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
row.is_pinned
|
||||
row.isPinned
|
||||
? h(NTag, { type: "warning", size: "small" }, () => "已锁定")
|
||||
: null,
|
||||
},
|
||||
@@ -146,10 +133,10 @@ const columns: DataTableColumn<ReportItem>[] = [
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: row.is_pinned ? "error" : "default",
|
||||
type: row.isPinned ? "error" : "default",
|
||||
onClick: () => togglePin(row),
|
||||
},
|
||||
() => (row.is_pinned ? "取消 PIN" : "PIN"),
|
||||
() => (row.isPinned ? "取消 PIN" : "PIN"),
|
||||
),
|
||||
]),
|
||||
},
|
||||
@@ -157,7 +144,7 @@ const columns: DataTableColumn<ReportItem>[] = [
|
||||
|
||||
async function loadPinnedReports() {
|
||||
const res = await getPinnedAIReports()
|
||||
pinnedReports.value = res.data
|
||||
pinnedReports.value = res.data.results
|
||||
}
|
||||
|
||||
async function togglePin(row: ReportItem) {
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
import { NSwitch } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Announcement } from "utils/types"
|
||||
import { editAnnouncement, getAnnouncementList } from "../api"
|
||||
import type { AnnouncementListItem } from "utils/types"
|
||||
import { editAnnouncement, getAnnouncement, getAnnouncementList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
const message = useMessage()
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
})
|
||||
const announcements = ref<Announcement[]>([])
|
||||
const announcements = ref<AnnouncementListItem[]>([])
|
||||
|
||||
const columns: DataTableColumn<Announcement>[] = [
|
||||
const columns: DataTableColumn<AnnouncementListItem>[] = [
|
||||
{ title: "ID", key: "id", width: 60 },
|
||||
{ title: "标题", key: "title", minWidth: 300 },
|
||||
{ title: "标签", key: "tag", width: 80 },
|
||||
@@ -25,20 +26,20 @@ const columns: DataTableColumn<Announcement>[] = [
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
key: "createTime",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "上次更新时间",
|
||||
key: "last_update_time",
|
||||
key: "lastUpdateTime",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.last_update_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row) => parseTime(row.lastUpdateTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "作者",
|
||||
key: "created_by",
|
||||
render: (row) => row.created_by.username,
|
||||
key: "createdBy",
|
||||
render: (row) => row.createdBy.username,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
@@ -61,16 +62,24 @@ const columns: DataTableColumn<Announcement>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
async function toggleVisible(announcement: Announcement) {
|
||||
announcement.visible = !announcement.visible
|
||||
editAnnouncement({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
tag: announcement.tag,
|
||||
content: announcement.content,
|
||||
visible: announcement.visible,
|
||||
top: announcement.top,
|
||||
})
|
||||
// 列表响应不含 content(正文是 8MB 上限的富文本),而更新接口要求 content 必填 ——
|
||||
// 拿列表里那行直接回传会 400。所以先取回整条再改。
|
||||
async function toggleVisible(announcement: AnnouncementListItem) {
|
||||
const next = !announcement.visible
|
||||
try {
|
||||
const { data: full } = await getAnnouncement(announcement.id)
|
||||
await editAnnouncement({
|
||||
id: full.id,
|
||||
title: full.title,
|
||||
tag: full.tag,
|
||||
content: full.content,
|
||||
visible: next,
|
||||
top: full.top,
|
||||
})
|
||||
announcement.visible = next
|
||||
} catch {
|
||||
message.error("修改可见性失败")
|
||||
}
|
||||
}
|
||||
|
||||
async function listAnnouncements() {
|
||||
|
||||
@@ -1,32 +1,53 @@
|
||||
import api2 from "utils/api2"
|
||||
import { legacyResponse } from "utils/legacy"
|
||||
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,
|
||||
Server,
|
||||
SQLDisplay,
|
||||
TestcaseUploadedReturns,
|
||||
Tutorial,
|
||||
User,
|
||||
WebsiteConfig,
|
||||
ProblemSet,
|
||||
ProblemSetBadge,
|
||||
ProblemSetList,
|
||||
ProblemSetProblem,
|
||||
ProblemSetProgressList,
|
||||
TutorialListItem,
|
||||
} from "utils/types"
|
||||
|
||||
export function getBaseInfo() {
|
||||
return legacyResponse(api2.get("admin/dashboard"))
|
||||
return api2.get<DashboardInfo>("admin/dashboard")
|
||||
}
|
||||
|
||||
export function randomUser10(classroom: string) {
|
||||
return legacyResponse(
|
||||
api2.get("admin/random-usernames", { params: { classroom } }),
|
||||
)
|
||||
return api2.get<string[]>("admin/random-usernames", {
|
||||
params: { classroom },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getProblemList(
|
||||
@@ -40,10 +61,9 @@ export async function getProblemList(
|
||||
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 } }))
|
||||
const res = await api2.get<AdminProblemList>(endpoint, {
|
||||
params: { offset, limit, keyword, author, tagId },
|
||||
})
|
||||
return {
|
||||
results: res.data.results.map(toProblemListItem),
|
||||
total: res.data.total,
|
||||
@@ -60,53 +80,46 @@ export function deleteContestProblem(id: number) {
|
||||
}
|
||||
|
||||
export function editProblem(problem: AdminProblem | BlankProblem) {
|
||||
return legacyResponse(
|
||||
api2.put(
|
||||
`admin/problems/${(problem as AdminProblem).id}`,
|
||||
toProblemBody(problem),
|
||||
),
|
||||
return api2.put<AdminProblem>(
|
||||
`admin/problems/${(problem as AdminProblem).id}`,
|
||||
toProblemBody(problem),
|
||||
)
|
||||
}
|
||||
|
||||
export function toggleProblemVisible(problemID: number) {
|
||||
return legacyResponse(api2.put(`admin/problems/${problemID}/visibility`))
|
||||
return api2.put<{ visible: boolean }>(
|
||||
`admin/problems/${problemID}/visibility`,
|
||||
)
|
||||
}
|
||||
|
||||
export function generateFlowchartFromPythonCode(python: string) {
|
||||
return legacyResponse(api2.post("admin/problems/flowchart", { python }))
|
||||
return api2.post<{ flowchart: string }>("admin/problems/flowchart", {
|
||||
python,
|
||||
})
|
||||
}
|
||||
|
||||
export function editContestProblem(problem: AdminProblem | BlankProblem) {
|
||||
return legacyResponse(
|
||||
api2.put(
|
||||
`admin/problems/${(problem as AdminProblem).id}`,
|
||||
toProblemBody(problem),
|
||||
),
|
||||
return api2.put<AdminProblem>(
|
||||
`admin/problems/${(problem as AdminProblem).id}`,
|
||||
toProblemBody(problem),
|
||||
)
|
||||
}
|
||||
|
||||
export function getProblem(id: string | number) {
|
||||
return legacyResponse<AdminProblem>(api2.get(`admin/problems/${id}`))
|
||||
return api2.get<AdminProblem>(`admin/problems/${id}`)
|
||||
}
|
||||
|
||||
export function getContestProblem(id: number) {
|
||||
return legacyResponse<AdminProblem>(api2.get(`admin/problems/${id}`))
|
||||
return api2.get<AdminProblem>(`admin/problems/${id}`)
|
||||
}
|
||||
|
||||
// 标签管理
|
||||
export function getTagAdminList(keyword = "") {
|
||||
return legacyResponse<AdminTag[]>(
|
||||
api2.get("admin/problem-tags", { params: { keyword } }),
|
||||
)
|
||||
return api2.get<AdminTag[]>("admin/problem-tags", { params: { keyword } })
|
||||
}
|
||||
|
||||
export function renameTag(id: number, name: string) {
|
||||
return legacyResponse<{
|
||||
merged: boolean
|
||||
id: number
|
||||
name: string
|
||||
affected_count: number
|
||||
}>(api2.put(`admin/problem-tags/${id}`, { name }))
|
||||
return api2.put<RenameTagResponse>(`admin/problem-tags/${id}`, { name })
|
||||
}
|
||||
|
||||
export function deleteTag(id: number) {
|
||||
@@ -118,13 +131,11 @@ export function batchTagProblems(
|
||||
tagNames: string[],
|
||||
action: "add" | "remove",
|
||||
) {
|
||||
return legacyResponse<{ problem_count: number; tag_count: number }>(
|
||||
api2.post("admin/problems/batch-tag", {
|
||||
problemIds,
|
||||
tagNames,
|
||||
action,
|
||||
}),
|
||||
)
|
||||
return api2.post<BatchProblemTagResponse>("admin/problems/batch-tag", {
|
||||
problemIds,
|
||||
tagNames,
|
||||
action,
|
||||
})
|
||||
}
|
||||
|
||||
// 用户列表
|
||||
@@ -135,34 +146,30 @@ export function getUserList(
|
||||
keyword: string,
|
||||
orderBy = "",
|
||||
) {
|
||||
return legacyResponse(
|
||||
api2.get("admin/users", {
|
||||
// 旧接口的 order_by 只有 "-last_login" 一个取值
|
||||
params: {
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
type,
|
||||
orderBy: orderBy === "-last_login" ? "-lastLogin" : 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 legacyResponse(
|
||||
api2.put(`admin/users/${user.id}`, {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
adminType: user.admin_type,
|
||||
problemPermission: user.problem_permission,
|
||||
realName: user.real_name ?? null,
|
||||
isDisabled: user.is_disabled,
|
||||
openApi: user.open_api,
|
||||
password: user.password ?? "",
|
||||
}),
|
||||
)
|
||||
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 当密码字符串(旧后端返回的就是裸字符串),
|
||||
@@ -185,9 +192,9 @@ export function deleteUsers(userIDs: number[]) {
|
||||
}
|
||||
|
||||
export function getContestList(offset = 0, limit = 10, keyword: string) {
|
||||
return legacyResponse(
|
||||
api2.get("admin/contests", { params: { offset, limit, keyword } }),
|
||||
)
|
||||
return api2.get<AdminContestList>("admin/contests", {
|
||||
params: { offset, limit, keyword },
|
||||
})
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
@@ -218,53 +225,45 @@ export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
|
||||
|
||||
// SQL 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据
|
||||
export function previewSQLTestcase(data: {
|
||||
init_sql: string
|
||||
ref_sql: string
|
||||
initSql: string
|
||||
refSql: string
|
||||
mode: "query" | "modify"
|
||||
}) {
|
||||
return legacyResponse<SQLDisplay>(
|
||||
api2.post("admin/sql-test-cases/preview", {
|
||||
initSql: data.init_sql,
|
||||
refSql: data.ref_sql,
|
||||
mode: data.mode,
|
||||
}),
|
||||
)
|
||||
return api2.post<SQLDisplay>("admin/sql-test-cases/preview", data)
|
||||
}
|
||||
|
||||
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
|
||||
export function getSQLTestcaseScripts(problemId: number) {
|
||||
return legacyResponse<{ name: string; content: string }[]>(
|
||||
api2.get(`admin/problems/${problemId}/sql-scripts`),
|
||||
return api2.get<SqlTestCaseScript[]>(
|
||||
`admin/problems/${problemId}/sql-scripts`,
|
||||
)
|
||||
}
|
||||
|
||||
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
|
||||
export function generateSQLTestcase(data: {
|
||||
ref_sql: string
|
||||
refSql: string
|
||||
mode: "query" | "modify"
|
||||
}) {
|
||||
return legacyResponse<{ sql: string }>(
|
||||
api2.post("admin/sql-test-cases/generate", {
|
||||
refSql: data.ref_sql,
|
||||
mode: data.mode,
|
||||
}),
|
||||
return api2.post<GenerateSqlTestCaseResponse>(
|
||||
"admin/sql-test-cases/generate",
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
/** 组件里的题目对象是 snake_case,出站转成新后端要的 camelCase */
|
||||
/** 出站补默认值。字段名两边已经一致,不再做键名转换 */
|
||||
function toProblemBody(problem: AdminProblem | BlankProblem) {
|
||||
const p = problem as Record<string, any>
|
||||
const p = problem as Partial<AdminProblem>
|
||||
return {
|
||||
_id: p._id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
inputDescription: p.input_description ?? "",
|
||||
outputDescription: p.output_description ?? "",
|
||||
inputDescription: p.inputDescription ?? "",
|
||||
outputDescription: p.outputDescription ?? "",
|
||||
samples: p.samples ?? [],
|
||||
testCaseId: p.test_case_id,
|
||||
testCaseScore: p.test_case_score ?? [],
|
||||
timeLimit: p.time_limit,
|
||||
memoryLimit: p.memory_limit,
|
||||
testCaseId: p.testCaseId,
|
||||
testCaseScore: p.testCaseScore ?? [],
|
||||
timeLimit: p.timeLimit,
|
||||
memoryLimit: p.memoryLimit,
|
||||
languages: p.languages ?? [],
|
||||
template: p.template ?? {},
|
||||
visible: p.visible,
|
||||
@@ -274,25 +273,25 @@ function toProblemBody(problem: AdminProblem | BlankProblem) {
|
||||
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,
|
||||
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 legacyResponse(api2.post("admin/problems", toProblemBody(problem)))
|
||||
return api2.post<AdminProblem>("admin/problems", toProblemBody(problem))
|
||||
}
|
||||
|
||||
export function createContestProblem(problem: BlankProblem) {
|
||||
// contest_id 由 detail.vue 在提交前写进 problem 对象
|
||||
const contestID = (problem as Record<string, any>).contest_id
|
||||
return legacyResponse(
|
||||
api2.post(`admin/contests/${contestID}/problems`, toProblemBody(problem)),
|
||||
// contestId 由 detail.vue 在提交前写进 problem 对象
|
||||
return api2.post<AdminProblem>(
|
||||
`admin/contests/${problem.contestId}/problems`,
|
||||
toProblemBody(problem),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -302,35 +301,31 @@ function toContestBody(contest: Contest | BlankContest) {
|
||||
title: contest.title,
|
||||
description: contest.description,
|
||||
tag: contest.tag,
|
||||
startTime: contest.start_time,
|
||||
endTime: contest.end_time,
|
||||
startTime: contest.startTime,
|
||||
endTime: contest.endTime,
|
||||
password: contest.password || null,
|
||||
visible: contest.visible,
|
||||
allowedIpRanges: contest.allowed_ip_ranges ?? [],
|
||||
allowedIpRanges: contest.allowedIpRanges ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function createContest(contest: BlankContest) {
|
||||
return legacyResponse(api2.post("admin/contests", toContestBody(contest)))
|
||||
return api2.post<Contest>("admin/contests", toContestBody(contest))
|
||||
}
|
||||
|
||||
export function editContest(contest: Contest | BlankContest) {
|
||||
return legacyResponse(
|
||||
api2.put(
|
||||
`admin/contests/${(contest as Contest).id}`,
|
||||
toContestBody(contest),
|
||||
),
|
||||
return api2.put<Contest>(
|
||||
`admin/contests/${(contest as Contest).id}`,
|
||||
toContestBody(contest),
|
||||
)
|
||||
}
|
||||
|
||||
export function cloneContest(contest_id: number) {
|
||||
return legacyResponse(api2.post(`admin/contests/${contest_id}/clone`))
|
||||
export function cloneContest(contestId: number) {
|
||||
return api2.post<Contest>(`admin/contests/${contestId}/clone`)
|
||||
}
|
||||
|
||||
export function getContest(id: string) {
|
||||
return legacyResponse<Contest & { password: string }>(
|
||||
api2.get(`admin/contests/${id}`),
|
||||
)
|
||||
return api2.get<Contest>(`admin/contests/${id}`)
|
||||
}
|
||||
|
||||
export function addProblemForContest(
|
||||
@@ -338,33 +333,22 @@ export function addProblemForContest(
|
||||
problemID: number,
|
||||
displayID: string,
|
||||
) {
|
||||
return legacyResponse(
|
||||
api2.post(`admin/contests/${contestID}/problems/from-public`, {
|
||||
problemId: problemID,
|
||||
displayId: displayID,
|
||||
}),
|
||||
return api2.post<AdminProblem>(
|
||||
`admin/contests/${contestID}/problems/from-public`,
|
||||
{ problemId: problemID, displayId: displayID },
|
||||
)
|
||||
}
|
||||
|
||||
export function getWebsite() {
|
||||
return legacyResponse<WebsiteConfig>(api2.get("admin/website"))
|
||||
return api2.get<WebsiteConfig>("admin/website")
|
||||
}
|
||||
|
||||
export function editWebsite(data: WebsiteConfig) {
|
||||
return api2.post("admin/website", {
|
||||
websiteBaseUrl: data.website_base_url,
|
||||
websiteName: data.website_name,
|
||||
websiteNameShortcut: data.website_name_shortcut,
|
||||
websiteFooter: data.website_footer,
|
||||
allowRegister: data.allow_register,
|
||||
submissionListShowAll: data.submission_list_show_all,
|
||||
classList: data.class_list,
|
||||
enableMaxkb: data.enable_maxkb,
|
||||
})
|
||||
return api2.post<WebsiteConfig>("admin/website", data)
|
||||
}
|
||||
|
||||
export function listInvalidTestcases() {
|
||||
return legacyResponse(api2.get("admin/orphan-test-cases"))
|
||||
return api2.get<OrphanTestCase[]>("admin/orphan-test-cases")
|
||||
}
|
||||
|
||||
export function pruneInvalidTestcases(id?: string) {
|
||||
@@ -372,9 +356,7 @@ export function pruneInvalidTestcases(id?: string) {
|
||||
}
|
||||
|
||||
export function getJudgeServer() {
|
||||
return legacyResponse<{ token: string; servers: Server[] }>(
|
||||
api2.get("admin/judge-servers"),
|
||||
)
|
||||
return api2.get<JudgeServerList>("admin/judge-servers")
|
||||
}
|
||||
|
||||
export function deleteJudgeServer(hostname: string) {
|
||||
@@ -382,15 +364,14 @@ export function deleteJudgeServer(hostname: string) {
|
||||
}
|
||||
|
||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||
return legacyResponse(
|
||||
api2.get("admin/announcements", {
|
||||
params: { offset, limit },
|
||||
}),
|
||||
return api2.get<{ results: AnnouncementListItem[]; total: number }>(
|
||||
"admin/announcements",
|
||||
{ params: { offset, limit } },
|
||||
)
|
||||
}
|
||||
|
||||
export function getAnnouncement(id: number) {
|
||||
return legacyResponse<Announcement>(api2.get(`admin/announcements/${id}`))
|
||||
return api2.get<Announcement>(`admin/announcements/${id}`)
|
||||
}
|
||||
|
||||
export function deleteAnnouncement(id: number) {
|
||||
@@ -399,48 +380,46 @@ export function deleteAnnouncement(id: number) {
|
||||
|
||||
export function editAnnouncement(announcement: AnnouncementEdit) {
|
||||
const { id, ...body } = announcement
|
||||
return legacyResponse(api2.put(`admin/announcements/${id}`, body))
|
||||
return api2.put<Announcement>(`admin/announcements/${id}`, body)
|
||||
}
|
||||
|
||||
export function createAnnouncement(announcement: AnnouncementEdit) {
|
||||
const { id: _id, ...body } = announcement
|
||||
return legacyResponse(api2.post("admin/announcements", body))
|
||||
}
|
||||
|
||||
/** 组件里的 Tutorial 仍是 snake_case,出站时转成新后端要的 camelCase */
|
||||
function toTutorialBody(data: Partial<Tutorial>) {
|
||||
return {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
code: data.code ?? null,
|
||||
isPublic: data.is_public ?? false,
|
||||
order: data.order ?? 0,
|
||||
type: data.type,
|
||||
}
|
||||
return api2.post<Announcement>("admin/announcements", body)
|
||||
}
|
||||
|
||||
export async function getTutorialList() {
|
||||
const res = await legacyResponse<{ [key: string]: Tutorial[] }>(
|
||||
api2.get("admin/tutorials"),
|
||||
const res = await api2.get<{ [key: string]: TutorialListItem[] }>(
|
||||
"admin/tutorials",
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getTutorial(id: number) {
|
||||
const res = await legacyResponse<Tutorial>(api2.get(`admin/tutorials/${id}`))
|
||||
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 legacyResponse<Tutorial>(
|
||||
api2.post("admin/tutorials", toTutorialBody(data)),
|
||||
)
|
||||
const res = await api2.post<Tutorial>("admin/tutorials", toTutorialBody(data))
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateTutorial(data: Partial<Tutorial>) {
|
||||
const res = await legacyResponse<Tutorial>(
|
||||
api2.put(`admin/tutorials/${data.id}`, toTutorialBody(data)),
|
||||
const res = await api2.put<Tutorial>(
|
||||
`admin/tutorials/${data.id}`,
|
||||
toTutorialBody(data),
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
@@ -449,33 +428,24 @@ export function deleteTutorial(id: number) {
|
||||
return api2.delete(`admin/tutorials/${id}`)
|
||||
}
|
||||
|
||||
export function setTutorialVisibility(id: number, is_public: boolean) {
|
||||
return legacyResponse(
|
||||
api2.put(`admin/tutorials/${id}/visibility`, { isPublic: is_public }),
|
||||
)
|
||||
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 legacyResponse<Exercise[]>(
|
||||
api2.get(`admin/tutorials/${tutorialId}/exercises`),
|
||||
const res = await api2.get<Exercise[]>(
|
||||
`admin/tutorials/${tutorialId}/exercises`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createExercise(data: {
|
||||
tutorial_id: number
|
||||
tutorialId: number
|
||||
type: ExerciseType
|
||||
data: object
|
||||
order: number
|
||||
}) {
|
||||
const res = await legacyResponse<Exercise>(
|
||||
api2.post("admin/exercises", {
|
||||
tutorialId: data.tutorial_id,
|
||||
type: data.type,
|
||||
data: data.data,
|
||||
order: data.order,
|
||||
}),
|
||||
)
|
||||
const res = await api2.post<Exercise>("admin/exercises", data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -485,13 +455,11 @@ export async function updateExercise(data: {
|
||||
data: object
|
||||
order: number
|
||||
}) {
|
||||
const res = await legacyResponse<Exercise>(
|
||||
api2.put(`admin/exercises/${data.id}`, {
|
||||
type: data.type,
|
||||
data: data.data,
|
||||
order: data.order,
|
||||
}),
|
||||
)
|
||||
const res = await api2.put<Exercise>(`admin/exercises/${data.id}`, {
|
||||
type: data.type,
|
||||
data: data.data,
|
||||
order: data.order,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -500,15 +468,15 @@ export function deleteExercise(id: number) {
|
||||
}
|
||||
|
||||
// 将竞赛题目转为公开题目
|
||||
export function makeProblemPublic(id: number, display_id: string) {
|
||||
return legacyResponse(
|
||||
api2.post(`admin/problems/${id}/make-public`, { displayId: display_id }),
|
||||
)
|
||||
export function makeProblemPublic(id: number, displayId: string) {
|
||||
return api2.post<AdminProblem>(`admin/problems/${id}/make-public`, {
|
||||
displayId,
|
||||
})
|
||||
}
|
||||
|
||||
// 比赛辅助检查
|
||||
export function getACMHelperList(contest_id: number) {
|
||||
return legacyResponse(api2.get(`admin/contests/${contest_id}/acm-helper`))
|
||||
export function getACMHelperList(contestId: number) {
|
||||
return api2.get<AcmHelperItem[]>(`admin/contests/${contestId}/acm-helper`)
|
||||
}
|
||||
|
||||
export function updateACMHelperChecked(
|
||||
@@ -532,57 +500,44 @@ export function getProblemSetList(
|
||||
difficulty = "",
|
||||
status = "",
|
||||
) {
|
||||
return legacyResponse(
|
||||
api2.get("admin/problem-sets", {
|
||||
params: { offset, limit, keyword, difficulty, status },
|
||||
}),
|
||||
)
|
||||
return api2.get<ProblemSetList>("admin/problem-sets", {
|
||||
params: { offset, limit, keyword, difficulty, status },
|
||||
})
|
||||
}
|
||||
|
||||
export function getProblemSetDetail(id: number) {
|
||||
return legacyResponse(api2.get(`admin/problem-sets/${id}`))
|
||||
return api2.get<ProblemSet>(`admin/problem-sets/${id}`)
|
||||
}
|
||||
|
||||
/** 组件传的是 snake_case,出站转成新后端要的 camelCase */
|
||||
function toProblemSetBody(data: {
|
||||
interface ProblemSetBody {
|
||||
title?: string
|
||||
description?: string
|
||||
difficulty?: string
|
||||
status?: string
|
||||
end_time?: Date | null
|
||||
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.end_time ? new Date(data.end_time).toISOString() : null,
|
||||
endTime: data.endTime ? new Date(data.endTime).toISOString() : null,
|
||||
visible: data.visible ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
export function createProblemSet(data: {
|
||||
title: string
|
||||
description: string
|
||||
difficulty: string
|
||||
status: string
|
||||
end_time?: Date | null
|
||||
}) {
|
||||
return legacyResponse(api2.post("admin/problem-sets", toProblemSetBody(data)))
|
||||
export function createProblemSet(data: ProblemSetBody) {
|
||||
return api2.post<ProblemSet>("admin/problem-sets", toProblemSetBody(data))
|
||||
}
|
||||
|
||||
export function editProblemSet(data: {
|
||||
id: number
|
||||
title?: string
|
||||
description?: string
|
||||
difficulty?: string
|
||||
status?: string
|
||||
end_time?: Date | null
|
||||
visible?: boolean
|
||||
}) {
|
||||
return legacyResponse(
|
||||
api2.put(`admin/problem-sets/${data.id}`, toProblemSetBody(data)),
|
||||
export function editProblemSet(data: ProblemSetBody & { id: number }) {
|
||||
return api2.put<ProblemSet>(
|
||||
`admin/problem-sets/${data.id}`,
|
||||
toProblemSetBody(data),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -591,32 +546,34 @@ export function deleteProblemSet(id: number) {
|
||||
}
|
||||
|
||||
export function toggleProblemSetVisible(id: number) {
|
||||
return legacyResponse(api2.put(`admin/problem-sets/${id}/visibility`))
|
||||
return api2.put<ProblemSet>(`admin/problem-sets/${id}/visibility`)
|
||||
}
|
||||
|
||||
export function updateProblemSetStatus(id: number, status: string) {
|
||||
return legacyResponse(api2.put(`admin/problem-sets/${id}/status`, { status }))
|
||||
return api2.put<ProblemSet>(`admin/problem-sets/${id}/status`, { status })
|
||||
}
|
||||
|
||||
// 题单题目管理 API
|
||||
export function getProblemSetProblems(problemSetId: number) {
|
||||
return legacyResponse(api2.get(`admin/problem-sets/${problemSetId}/problems`))
|
||||
return api2.get<ProblemSetProblem[]>(
|
||||
`admin/problem-sets/${problemSetId}/problems`,
|
||||
)
|
||||
}
|
||||
|
||||
export function addProblemToSet(
|
||||
problemSetId: number,
|
||||
data: {
|
||||
problem_id: string
|
||||
problemId: string
|
||||
order?: number
|
||||
is_required?: boolean
|
||||
isRequired?: boolean
|
||||
score?: number
|
||||
hint?: string
|
||||
},
|
||||
) {
|
||||
return api2.post(`admin/problem-sets/${problemSetId}/problems`, {
|
||||
problemId: data.problem_id,
|
||||
problemId: data.problemId,
|
||||
order: data.order ?? 0,
|
||||
isRequired: data.is_required ?? true,
|
||||
isRequired: data.isRequired ?? true,
|
||||
score: data.score ?? 0,
|
||||
hint: data.hint ?? "",
|
||||
})
|
||||
@@ -627,19 +584,14 @@ export function editProblemInSet(
|
||||
problemSetProblemId: number,
|
||||
data: {
|
||||
order?: number
|
||||
is_required?: boolean
|
||||
isRequired?: boolean
|
||||
score?: number
|
||||
hint?: string
|
||||
},
|
||||
) {
|
||||
return api2.put(
|
||||
`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`,
|
||||
{
|
||||
order: data.order,
|
||||
isRequired: data.is_required,
|
||||
score: data.score,
|
||||
hint: data.hint,
|
||||
},
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -654,58 +606,44 @@ export function removeProblemFromSet(
|
||||
|
||||
// 题单奖章管理 API
|
||||
export function getProblemSetBadges(problemSetId: number) {
|
||||
return legacyResponse(api2.get(`admin/problem-sets/${problemSetId}/badges`))
|
||||
return api2.get<ProblemSetBadge[]>(
|
||||
`admin/problem-sets/${problemSetId}/badges`,
|
||||
)
|
||||
}
|
||||
|
||||
function toBadgeBody(data: {
|
||||
interface BadgeBody {
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
condition_type?: string
|
||||
condition_value?: number
|
||||
}) {
|
||||
conditionType?: ProblemSetBadge["conditionType"]
|
||||
conditionValue?: number
|
||||
}
|
||||
|
||||
function toBadgeBody(data: BadgeBody) {
|
||||
return {
|
||||
name: data.name,
|
||||
description: data.description ?? "",
|
||||
icon: data.icon ?? "",
|
||||
conditionType: data.condition_type,
|
||||
conditionValue: data.condition_value ?? 0,
|
||||
conditionType: data.conditionType,
|
||||
conditionValue: data.conditionValue ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function createProblemSetBadge(
|
||||
problemSetId: number,
|
||||
data: {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
condition_type: string
|
||||
condition_value: number
|
||||
level?: number
|
||||
},
|
||||
) {
|
||||
return legacyResponse(
|
||||
api2.post(`admin/problem-sets/${problemSetId}/badges`, toBadgeBody(data)),
|
||||
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: {
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
condition_type?: string
|
||||
condition_value?: number
|
||||
level?: number
|
||||
},
|
||||
data: BadgeBody,
|
||||
) {
|
||||
return legacyResponse(
|
||||
api2.put(
|
||||
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
|
||||
toBadgeBody(data),
|
||||
),
|
||||
return api2.put<ProblemSetBadge>(
|
||||
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
|
||||
toBadgeBody(data),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -715,7 +653,9 @@ export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
|
||||
|
||||
// 题单进度管理 API
|
||||
export function getProblemSetProgress(problemSetId: number) {
|
||||
return legacyResponse(api2.get(`admin/problem-sets/${problemSetId}/progress`))
|
||||
return api2.get<ProblemSetProgressList>(
|
||||
`admin/problem-sets/${problemSetId}/progress`,
|
||||
)
|
||||
}
|
||||
|
||||
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
||||
@@ -724,74 +664,46 @@ export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
||||
|
||||
// 学生卡点分析
|
||||
export function getStuckProblems() {
|
||||
return legacyResponse(api2.get("admin/problem-analytics/stuck"))
|
||||
return api2.get<StuckProblem[]>("admin/problem-analytics/stuck")
|
||||
}
|
||||
|
||||
export function getTopACTrend(params: {
|
||||
since_year: number
|
||||
until_year: number
|
||||
min_per_year: number
|
||||
sinceYear: number
|
||||
untilYear: number
|
||||
minPerYear: number
|
||||
}) {
|
||||
return legacyResponse(
|
||||
api2.get("admin/problem-analytics/ac-trend", {
|
||||
params: {
|
||||
sinceYear: params.since_year,
|
||||
untilYear: params.until_year,
|
||||
minPerYear: params.min_per_year,
|
||||
},
|
||||
}),
|
||||
)
|
||||
return api2.get<AcTrend[]>("admin/problem-analytics/ac-trend", { params })
|
||||
}
|
||||
|
||||
// AI 学习分析报告
|
||||
export function getAIReportList(offset = 0, limit = 10, username = "") {
|
||||
return legacyResponse(
|
||||
api2.get("admin/ai/reports", {
|
||||
params: { offset, limit, username: username || undefined },
|
||||
}),
|
||||
)
|
||||
return api2.get<AdminAiReportList>("admin/ai/reports", {
|
||||
params: { offset, limit, username: username || undefined },
|
||||
})
|
||||
}
|
||||
|
||||
export function getAIReportDetail(id: number) {
|
||||
return legacyResponse(api2.get(`admin/ai/reports/${id}`))
|
||||
return api2.get<AdminAiReport>(`admin/ai/reports/${id}`)
|
||||
}
|
||||
|
||||
export function pinAIReport(id: number) {
|
||||
return legacyResponse(api2.post(`admin/ai/reports/${id}/pin`))
|
||||
return api2.post<{ isPinned: boolean }>(`admin/ai/reports/${id}/pin`)
|
||||
}
|
||||
|
||||
export function getPinnedAIReports() {
|
||||
return legacyResponse(
|
||||
api2.get("admin/ai/reports", { params: { pinnedOnly: "true" } }),
|
||||
)
|
||||
return api2.get<AdminAiReportList>("admin/ai/reports", {
|
||||
params: { pinnedOnly: "true" },
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 成就 ====================
|
||||
|
||||
export interface AdminAchievement {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
rarity: string
|
||||
hidden: boolean
|
||||
metric: string
|
||||
metric_name: string
|
||||
operator: "gte" | "lte"
|
||||
threshold: number
|
||||
visible: boolean
|
||||
unlock_count: number
|
||||
order: number
|
||||
create_time: string
|
||||
}
|
||||
import type {
|
||||
AdminAchievement,
|
||||
AchievementMetric as MetricOption,
|
||||
} from "@oj2/contract"
|
||||
export type { AdminAchievement, MetricOption }
|
||||
|
||||
export interface MetricOption {
|
||||
key: string
|
||||
name: string
|
||||
help_text: string
|
||||
}
|
||||
|
||||
/** 组件里的成就对象是 snake_case,出站转成新后端要的 camelCase */
|
||||
function toAchievementBody(data: Partial<AdminAchievement>) {
|
||||
return {
|
||||
name: data.name,
|
||||
@@ -808,22 +720,24 @@ function toAchievementBody(data: Partial<AdminAchievement>) {
|
||||
}
|
||||
|
||||
export function getAdminAchievements() {
|
||||
return legacyResponse<AdminAchievement[]>(api2.get("admin/achievements"))
|
||||
return api2.get<AdminAchievement[]>("admin/achievements")
|
||||
}
|
||||
|
||||
export function getMetricOptions() {
|
||||
return legacyResponse<MetricOption[]>(api2.get("admin/achievement-metrics"))
|
||||
return api2.get<MetricOption[]>("admin/achievement-metrics")
|
||||
}
|
||||
|
||||
export function createAchievement(data: Partial<AdminAchievement>) {
|
||||
return legacyResponse<AdminAchievement>(
|
||||
api2.post("admin/achievements", toAchievementBody(data)),
|
||||
return api2.post<AdminAchievement>(
|
||||
"admin/achievements",
|
||||
toAchievementBody(data),
|
||||
)
|
||||
}
|
||||
|
||||
export function updateAchievement(data: Partial<AdminAchievement>) {
|
||||
return legacyResponse<AdminAchievement>(
|
||||
api2.put(`admin/achievements/${data.id}`, toAchievementBody(data)),
|
||||
return api2.put<AdminAchievement>(
|
||||
`admin/achievements/${data.id}`,
|
||||
toAchievementBody(data),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ const durationMins = ref(10) // 比赛默认时长10分钟
|
||||
|
||||
watch([waitMins, durationMins], () => {
|
||||
const times = getTimes()
|
||||
contest.start_time = formatISO(times[0])
|
||||
contest.end_time = formatISO(times[1])
|
||||
contest.startTime = formatISO(times[0])
|
||||
contest.endTime = formatISO(times[1])
|
||||
})
|
||||
|
||||
// 编辑的时候
|
||||
@@ -32,8 +32,8 @@ const startTime = ref(0)
|
||||
const endTime = ref(0)
|
||||
|
||||
watch([startTime, endTime], (values) => {
|
||||
contest.start_time = formatISO(values[0])
|
||||
contest.end_time = formatISO(values[1])
|
||||
contest.startTime = formatISO(values[0])
|
||||
contest.endTime = formatISO(values[1])
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
@@ -54,18 +54,18 @@ const contest = reactive<BlankContest & { id: number }>({
|
||||
title: "",
|
||||
description: "",
|
||||
tag: "练习",
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
password: "",
|
||||
visible: false,
|
||||
allowed_ip_ranges: [],
|
||||
allowedIpRanges: [],
|
||||
})
|
||||
|
||||
async function getContestDetail() {
|
||||
if (!props.contestID) {
|
||||
const times = getTimes()
|
||||
contest.start_time = formatISO(times[0])
|
||||
contest.end_time = formatISO(times[1])
|
||||
contest.startTime = formatISO(times[0])
|
||||
contest.endTime = formatISO(times[1])
|
||||
toggleReady(true)
|
||||
return
|
||||
}
|
||||
@@ -75,15 +75,15 @@ async function getContestDetail() {
|
||||
contest.title = data.title
|
||||
contest.description = data.description
|
||||
contest.tag = data.tag
|
||||
contest.start_time = data.start_time
|
||||
contest.end_time = data.end_time
|
||||
contest.startTime = data.startTime
|
||||
contest.endTime = data.endTime
|
||||
contest.password = data.password
|
||||
contest.visible = data.visible
|
||||
contest.allowed_ip_ranges = []
|
||||
contest.allowedIpRanges = []
|
||||
|
||||
// 显示
|
||||
startTime.value = Date.parse(data.start_time)
|
||||
endTime.value = Date.parse(data.end_time)
|
||||
startTime.value = Date.parse(data.startTime)
|
||||
endTime.value = Date.parse(data.endTime)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -118,12 +118,12 @@ onMounted(getContestDetail)
|
||||
<template v-if="!props.contestID">
|
||||
<n-alert type="success">
|
||||
<template #header>
|
||||
开始时间 {{ parseTime(contest.start_time, "YYYY年M月D日 HH:mm:ss") }}
|
||||
开始时间 {{ parseTime(contest.startTime, "YYYY年M月D日 HH:mm:ss") }}
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-alert type="warning">
|
||||
<template #header>
|
||||
结束时间 {{ parseTime(contest.end_time, "YYYY年M月D日 HH:mm:ss") }}
|
||||
结束时间 {{ parseTime(contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
|
||||
</template>
|
||||
</n-alert>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { AcmHelperItem, SubmissionInfo } from "utils/types"
|
||||
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
|
||||
@@ -10,19 +11,12 @@ interface Props {
|
||||
contestID: string
|
||||
}
|
||||
|
||||
interface HelperItem {
|
||||
id: number
|
||||
username: string
|
||||
real_name: string
|
||||
problem_id: string
|
||||
problem_display_id: string
|
||||
ac_info: {
|
||||
is_ac: boolean
|
||||
ac_time: number
|
||||
error_number: number
|
||||
checked?: boolean
|
||||
}
|
||||
checked: boolean
|
||||
/**
|
||||
* ACM 助手行。`acInfo` 的**内容**是 acm_contest_rank.submission_info 的 JSONB 原文,
|
||||
* 键名保持 snake_case —— 回滚时旧后端还要读。
|
||||
*/
|
||||
type HelperItem = Omit<AcmHelperItem, "acInfo"> & {
|
||||
acInfo: SubmissionInfo
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -65,12 +59,12 @@ async function toggleChecked(item: HelperItem) {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
item.id,
|
||||
item.problem_id,
|
||||
item.problemId,
|
||||
newChecked,
|
||||
)
|
||||
// 更新本地状态
|
||||
item.checked = newChecked
|
||||
item.ac_info.checked = newChecked
|
||||
item.acInfo.checked = newChecked
|
||||
|
||||
// 强制触发响应式更新
|
||||
submissions.value = [...submissions.value]
|
||||
@@ -95,11 +89,11 @@ async function markAllAsChecked() {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
item.id,
|
||||
item.problem_id,
|
||||
item.problemId,
|
||||
true,
|
||||
)
|
||||
item.checked = true
|
||||
item.ac_info.checked = true
|
||||
item.acInfo.checked = true
|
||||
}
|
||||
|
||||
// 强制触发响应式更新
|
||||
@@ -117,7 +111,7 @@ async function markAllAsChecked() {
|
||||
const filteredSubmissions = computed(() => {
|
||||
return submissions.value.filter((item) => {
|
||||
if (query.username && !item.username.includes(query.username)) return false
|
||||
if (query.problemId && !item.problem_display_id.includes(query.problemId))
|
||||
if (query.problemId && !item.problemDisplayId.includes(query.problemId))
|
||||
return false
|
||||
if (query.checked === "checked" && !item.checked) return false
|
||||
if (query.checked === "unchecked" && item.checked) return false
|
||||
@@ -139,8 +133,8 @@ async function viewSubmission(item: HelperItem) {
|
||||
// 查询该用户在该竞赛该题目的 AC 提交
|
||||
const res = await getSubmissions({
|
||||
username: item.username,
|
||||
problem_id: item.problem_display_id,
|
||||
contest_id: props.contestID,
|
||||
problemId: item.problemDisplayId,
|
||||
contestId: props.contestID,
|
||||
result: "0", // ACCEPTED
|
||||
language: "",
|
||||
page: 1,
|
||||
@@ -161,7 +155,7 @@ async function viewSubmission(item: HelperItem) {
|
||||
currentSubmission.value = {
|
||||
...detailRes.data,
|
||||
contest: Number(props.contestID),
|
||||
problem_display_id: item.problem_display_id,
|
||||
problem_display_id: item.problemDisplayId,
|
||||
}
|
||||
|
||||
toggleCodePanel(true)
|
||||
@@ -175,7 +169,7 @@ async function loadData() {
|
||||
try {
|
||||
// 先获取比赛信息,获取开始时间
|
||||
const contestRes = await getContest(props.contestID)
|
||||
contestStartTime.value = new Date(contestRes.data.start_time)
|
||||
contestStartTime.value = new Date(contestRes.data.startTime)
|
||||
|
||||
// 再获取 AC 提交列表
|
||||
const { data } = await getACMHelperList(Number(props.contestID))
|
||||
@@ -195,13 +189,13 @@ const columns: DataTableColumn<HelperItem>[] = [
|
||||
title: "题目",
|
||||
key: "problem_display_id",
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { type: "info" }, () => row.problem_display_id),
|
||||
render: (row) => h(NTag, { type: "info" }, () => row.problemDisplayId),
|
||||
},
|
||||
{
|
||||
title: "AC时间",
|
||||
key: "ac_time",
|
||||
width: 180,
|
||||
render: (row) => formatACTime(row.ac_info.ac_time),
|
||||
render: (row) => formatACTime(row.acInfo.ac_time),
|
||||
},
|
||||
{
|
||||
title: "错误次数",
|
||||
@@ -211,10 +205,10 @@ const columns: DataTableColumn<HelperItem>[] = [
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
type: row.ac_info.error_number > 0 ? "warning" : "success",
|
||||
type: row.acInfo.error_number > 0 ? "warning" : "success",
|
||||
size: "small",
|
||||
},
|
||||
() => row.ac_info.error_number,
|
||||
() => row.acInfo.error_number,
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -310,7 +304,7 @@ onMounted(loadData)
|
||||
<SubmissionDetail
|
||||
v-if="currentSubmission"
|
||||
:submission="currentSubmission"
|
||||
:problemID="currentSubmission.problem_display_id"
|
||||
:problemID="currentSubmission.problemDisplayId"
|
||||
:submissionID="currentSubmission.id"
|
||||
hideList
|
||||
@copied="toggleCodePanel(false)"
|
||||
|
||||
@@ -56,13 +56,13 @@ const columns: DataTableColumn<Contest>[] = [
|
||||
title: "创建者",
|
||||
key: "created_by",
|
||||
width: 120,
|
||||
render: (row) => row.created_by.username,
|
||||
render: (row) => row.createdBy.username,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 160,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm"),
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
|
||||
@@ -1,36 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { getStuckProblems } from "admin/api"
|
||||
|
||||
interface StuckProblem {
|
||||
problem_id: string
|
||||
problem_title: string
|
||||
total: number
|
||||
failed: number
|
||||
failed_users: number
|
||||
ac_rate: number
|
||||
}
|
||||
import type { StuckProblem } from "utils/types"
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref<StuckProblem[]>([])
|
||||
|
||||
const columns: DataTableColumn<StuckProblem>[] = [
|
||||
{ title: "题目 ID", key: "problem_id", width: 100 },
|
||||
{ title: "题目名称", key: "problem_title", minWidth: 200 },
|
||||
{ title: "题目 ID", key: "problemId", width: 100 },
|
||||
{ title: "题目名称", key: "problemTitle", minWidth: 200 },
|
||||
{ title: "总提交", key: "total", width: 100, sorter: "default" },
|
||||
{ title: "失败次数", key: "failed", width: 100, sorter: "default" },
|
||||
{
|
||||
title: "卡住学生数",
|
||||
key: "failed_users",
|
||||
key: "failedUsers",
|
||||
width: 120,
|
||||
sorter: "default",
|
||||
defaultSortOrder: "descend",
|
||||
},
|
||||
{
|
||||
title: "AC 率",
|
||||
key: "ac_rate",
|
||||
key: "acRate",
|
||||
width: 100,
|
||||
sorter: "default",
|
||||
render: (row) => `${row.ac_rate}%`,
|
||||
render: (row) => `${row.acRate}%`,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { AcTrend } from "utils/types"
|
||||
import { Line } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
@@ -22,18 +23,7 @@ ChartJS.register(
|
||||
Tooltip,
|
||||
)
|
||||
|
||||
interface YearlyEntry {
|
||||
year: number
|
||||
total: number
|
||||
accepted: number
|
||||
ac_rate: number
|
||||
}
|
||||
|
||||
interface ProblemTrend {
|
||||
problem_id: string
|
||||
problem_title: string
|
||||
yearly: YearlyEntry[]
|
||||
}
|
||||
type ProblemTrend = AcTrend
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const yearOptions = Array.from({ length: currentYear - 2022 + 1 }, (_, i) => ({
|
||||
@@ -79,7 +69,7 @@ function getChartData(problem: ProblemTrend) {
|
||||
datasets: [
|
||||
{
|
||||
label: "AC 率",
|
||||
data: problem.yearly.map((y) => y.ac_rate),
|
||||
data: problem.yearly.map((y) => y.acRate),
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
backgroundColor: "rgba(99, 179, 237, 0.2)",
|
||||
@@ -98,14 +88,14 @@ function getChartOptions(problem: ProblemTrend) {
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: `${problem.problem_id} · ${problem.problem_title}`,
|
||||
text: `${problem.problemId} · ${problem.problemTitle}`,
|
||||
font: { size: 14 },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: any) => {
|
||||
const entry = problem.yearly[ctx.dataIndex]
|
||||
return `AC 率: ${entry.ac_rate}% (${entry.accepted}/${entry.total})`
|
||||
return `AC 率: ${entry.acRate}% (${entry.accepted}/${entry.total})`
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -127,9 +117,9 @@ async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTopACTrend({
|
||||
since_year: sinceYear.value,
|
||||
until_year: untilYear.value,
|
||||
min_per_year: minPerYear.value,
|
||||
sinceYear: sinceYear.value,
|
||||
untilYear: untilYear.value,
|
||||
minPerYear: minPerYear.value,
|
||||
})
|
||||
data.value = res.data
|
||||
} finally {
|
||||
@@ -174,7 +164,7 @@ onMounted(fetchData)
|
||||
暂无数据
|
||||
</div>
|
||||
<div v-else class="grid">
|
||||
<div v-for="problem in data" :key="problem.problem_id" class="chart-card">
|
||||
<div v-for="problem in data" :key="problem.problemId" class="chart-card">
|
||||
<Line
|
||||
:data="getChartData(problem)"
|
||||
:options="getChartOptions(problem)"
|
||||
|
||||
@@ -34,7 +34,7 @@ async function handleDeleteProblem() {
|
||||
message.success("删除成功")
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
if (err.data === "Can't delete the problem as it has submissions") {
|
||||
if (err.error === "problem-has-submissions") {
|
||||
message.error("这道题有提交之后,就不能被删除")
|
||||
} else {
|
||||
message.error("删除失败")
|
||||
@@ -82,9 +82,9 @@ async function handleMakePublic() {
|
||||
showMakePublicModal.value = false
|
||||
emit("updated") // 刷新列表
|
||||
} catch (err: any) {
|
||||
if (err.data === "Duplicate display ID") {
|
||||
if (err.error === "display-id-exists") {
|
||||
message.error("该题目编号已存在,请使用其他编号")
|
||||
} else if (err.data === "Already be a public problem") {
|
||||
} else if (err.error === "already-public") {
|
||||
message.error("该题目已经是公开题目")
|
||||
} else {
|
||||
message.error("转换失败:" + (err.data || "未知错误"))
|
||||
|
||||
@@ -23,9 +23,9 @@ async function addProblem() {
|
||||
)
|
||||
emit("added")
|
||||
} catch (err: any) {
|
||||
if (err.data === "Duplicate display id in this contest") {
|
||||
if (err.error === "display-id-exists") {
|
||||
message.error("显示编号重复了,请重新写一个")
|
||||
} else if (err.data === "Contest has ended") {
|
||||
} else if (err.error === "contest-ended") {
|
||||
message.error("这场比赛已经结束了,不能添加题目")
|
||||
} else {
|
||||
message.error(err.data)
|
||||
|
||||
@@ -60,7 +60,7 @@ async function submit() {
|
||||
)
|
||||
const verb = props.action === "add" ? "添加" : "移除"
|
||||
message.success(
|
||||
`已为 ${res.data.problem_count} 道题${verb} ${res.data.tag_count} 个标签`,
|
||||
`已为 ${res.data.problemCount} 道题${verb} ${res.data.tagCount} 个标签`,
|
||||
)
|
||||
close()
|
||||
emit("done")
|
||||
@@ -96,7 +96,7 @@ watch(
|
||||
:checked="selectedSet.has(tag.name)"
|
||||
@update:checked="toggleTag(tag.name)"
|
||||
>
|
||||
{{ tag.name }}({{ tag.problem_count }})
|
||||
{{ tag.name }}({{ tag.problemCount }})
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-dynamic-tags v-if="action === 'add'" v-model:value="newTags" />
|
||||
|
||||
@@ -125,7 +125,7 @@ async function generate() {
|
||||
blanks.map(async (s) => {
|
||||
try {
|
||||
const res = await generateSQLTestcase({
|
||||
ref_sql: refSQL.value,
|
||||
refSql: refSQL.value,
|
||||
mode: props.mode,
|
||||
})
|
||||
s.sql = res.data.sql
|
||||
@@ -154,8 +154,8 @@ async function preview() {
|
||||
s.stale = false
|
||||
try {
|
||||
const res = await previewSQLTestcase({
|
||||
init_sql: s.sql,
|
||||
ref_sql: refSQL.value,
|
||||
initSql: s.sql,
|
||||
refSql: refSQL.value,
|
||||
mode: props.mode,
|
||||
})
|
||||
s.display = res.data
|
||||
|
||||
@@ -11,13 +11,7 @@ import {
|
||||
} from "utils/constants"
|
||||
import download from "utils/download"
|
||||
import { unique } from "utils/functions"
|
||||
import type {
|
||||
BlankProblem,
|
||||
LANGUAGE,
|
||||
SQLConfig,
|
||||
Tag,
|
||||
Testcase,
|
||||
} from "utils/types"
|
||||
import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
|
||||
import {
|
||||
createContestProblem,
|
||||
createProblem,
|
||||
@@ -62,13 +56,13 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
||||
_id: "",
|
||||
title: "",
|
||||
description: "",
|
||||
input_description: "",
|
||||
output_description: "",
|
||||
time_limit: 1000,
|
||||
memory_limit: 64,
|
||||
difficulty: "Low" as "Low" | "Mid" | "High",
|
||||
inputDescription: "",
|
||||
outputDescription: "",
|
||||
timeLimit: 1000,
|
||||
memoryLimit: 64,
|
||||
difficulty: "Low",
|
||||
visible: false,
|
||||
share_submission: false,
|
||||
shareSubmission: false,
|
||||
tags: [],
|
||||
languages: ["Python3", "C"] as LANGUAGE[],
|
||||
template: {} as { [key in LANGUAGE]?: string },
|
||||
@@ -77,20 +71,20 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
||||
{ input: "", output: "" },
|
||||
{ input: "", output: "" },
|
||||
],
|
||||
test_case_id: "",
|
||||
test_case_score: [] as Testcase[],
|
||||
testCaseId: "",
|
||||
testCaseScore: [] as Testcase[],
|
||||
hint: "",
|
||||
source: "",
|
||||
prompt: "",
|
||||
answers: [] as { language: LANGUAGE; code: string }[],
|
||||
contest_id: "",
|
||||
allow_flowchart: false,
|
||||
mermaid_code: "",
|
||||
flowchart_data: {},
|
||||
flowchart_hint: "",
|
||||
show_flowchart: false,
|
||||
ast_rules: null as { [key: string]: any[] } | null,
|
||||
sql_config: null as SQLConfig | null,
|
||||
contestId: null,
|
||||
allowFlowchart: false,
|
||||
showFlowchart: false,
|
||||
mermaidCode: "",
|
||||
flowchartHint: "",
|
||||
astRules: null,
|
||||
sqlConfig: null,
|
||||
sqlDisplay: null,
|
||||
})
|
||||
|
||||
// 从服务器来的tag列表
|
||||
@@ -189,19 +183,19 @@ watch(
|
||||
return
|
||||
}
|
||||
needTemplate.value = false
|
||||
if (!problem.value.sql_config) {
|
||||
problem.value.sql_config = { mode: "query", order_sensitive: false }
|
||||
if (!problem.value.sqlConfig) {
|
||||
problem.value.sqlConfig = { mode: "query", order_sensitive: false }
|
||||
}
|
||||
currentActiveAnswer.value = "SQL"
|
||||
// 代码规则检查基于 Python/C 的 AST 解析,对 SQL 没有意义,清空避免脏数据
|
||||
if (problem.value.ast_rules) {
|
||||
problem.value.ast_rules = null
|
||||
if (problem.value.astRules) {
|
||||
problem.value.astRules = null
|
||||
}
|
||||
// 流程图依赖 Python 答案生成,对 SQL 没有意义
|
||||
problem.value.allow_flowchart = false
|
||||
problem.value.show_flowchart = false
|
||||
} else if (problem.value.sql_config) {
|
||||
problem.value.sql_config = null
|
||||
problem.value.allowFlowchart = false
|
||||
problem.value.showFlowchart = false
|
||||
} else if (problem.value.sqlConfig) {
|
||||
problem.value.sqlConfig = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -219,32 +213,31 @@ async function getProblemDetail() {
|
||||
problem.value._id = data._id
|
||||
problem.value.title = data.title
|
||||
problem.value.description = data.description
|
||||
problem.value.input_description = data.input_description
|
||||
problem.value.output_description = data.output_description
|
||||
problem.value.time_limit = data.time_limit
|
||||
problem.value.memory_limit = data.memory_limit
|
||||
problem.value.memory_limit = data.memory_limit
|
||||
problem.value.inputDescription = data.inputDescription
|
||||
problem.value.outputDescription = data.outputDescription
|
||||
problem.value.timeLimit = data.timeLimit
|
||||
problem.value.memoryLimit = data.memoryLimit
|
||||
problem.value.memoryLimit = data.memoryLimit
|
||||
problem.value.difficulty = data.difficulty
|
||||
problem.value.visible = data.visible
|
||||
problem.value.share_submission = data.share_submission
|
||||
problem.value.shareSubmission = data.shareSubmission
|
||||
problem.value.tags = normalizeTagNames(data.tags)
|
||||
problem.value.languages = data.languages
|
||||
problem.value.template = data.template
|
||||
problem.value.samples = data.samples
|
||||
problem.value.samples = data.samples
|
||||
problem.value.test_case_id = data.test_case_id
|
||||
problem.value.test_case_score = data.test_case_score
|
||||
problem.value.hint = data.hint
|
||||
problem.value.testCaseId = data.testCaseId
|
||||
problem.value.testCaseScore = data.testCaseScore
|
||||
problem.value.hint = data.hint ?? ""
|
||||
problem.value.source = data.source
|
||||
problem.value.prompt = data.prompt
|
||||
// 流程图相关字段
|
||||
problem.value.allow_flowchart = data.allow_flowchart
|
||||
problem.value.show_flowchart = data.show_flowchart
|
||||
problem.value.mermaid_code = data.mermaid_code ?? ""
|
||||
problem.value.flowchart_hint = data.flowchart_hint ?? ""
|
||||
problem.value.flowchart_data = data.flowchart_data
|
||||
problem.value.ast_rules = data.ast_rules ?? null
|
||||
problem.value.sql_config = data.sql_config ?? null
|
||||
problem.value.allowFlowchart = data.allowFlowchart
|
||||
problem.value.showFlowchart = data.showFlowchart
|
||||
problem.value.mermaidCode = data.mermaidCode ?? ""
|
||||
problem.value.flowchartHint = data.flowchartHint ?? ""
|
||||
problem.value.astRules = data.astRules ?? null
|
||||
problem.value.sqlConfig = data.sqlConfig ?? null
|
||||
if (data.answers && data.answers.length) {
|
||||
problem.value.answers = data.answers
|
||||
} else {
|
||||
@@ -253,8 +246,8 @@ async function getProblemDetail() {
|
||||
code: "",
|
||||
}))
|
||||
}
|
||||
if (problem.value.contest_id) {
|
||||
problem.value.contest_id = problem.value.contest_id
|
||||
if (problem.value.contestId) {
|
||||
problem.value.contestId = problem.value.contestId
|
||||
}
|
||||
|
||||
// 下面是用来显示的:
|
||||
@@ -305,8 +298,8 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
|
||||
for (let file of testcases) {
|
||||
file.score = (100 / testcases.length).toFixed(0)
|
||||
}
|
||||
problem.value.test_case_score = testcases
|
||||
problem.value.test_case_id = res.data.id
|
||||
problem.value.testCaseScore = testcases
|
||||
problem.value.testCaseId = res.data.id
|
||||
} catch (err) {
|
||||
message.error("上传测试用例失败")
|
||||
}
|
||||
@@ -338,7 +331,7 @@ async function validateProblem() {
|
||||
else if (
|
||||
!problem.value.description ||
|
||||
(!isSQLProblem.value &&
|
||||
(!problem.value.input_description || !problem.value.output_description))
|
||||
(!problem.value.inputDescription || !problem.value.outputDescription))
|
||||
) {
|
||||
message.error("题目或输入或输出没有填写")
|
||||
hasErrors = true
|
||||
@@ -359,7 +352,7 @@ async function validateProblem() {
|
||||
hasErrors = true
|
||||
}
|
||||
// 测试用例
|
||||
else if (problem.value.test_case_score.length === 0) {
|
||||
else if (problem.value.testCaseScore.length === 0) {
|
||||
message.error("测试用例没有上传")
|
||||
hasErrors = true
|
||||
} else if (problem.value.languages.length === 0) {
|
||||
@@ -367,7 +360,7 @@ async function validateProblem() {
|
||||
hasErrors = true
|
||||
}
|
||||
// SQL 题验证
|
||||
else if (isSQLProblem.value && !problem.value.sql_config?.mode) {
|
||||
else if (isSQLProblem.value && !problem.value.sqlConfig?.mode) {
|
||||
message.error("SQL 题需要选择题型(查询题/增删改题)")
|
||||
hasErrors = true
|
||||
} else if (
|
||||
@@ -380,11 +373,8 @@ async function validateProblem() {
|
||||
hasErrors = true
|
||||
}
|
||||
// 流程图验证
|
||||
else if (problem.value.show_flowchart || problem.value.allow_flowchart) {
|
||||
if (
|
||||
!problem.value.mermaid_code ||
|
||||
problem.value.mermaid_code.trim() === ""
|
||||
) {
|
||||
else if (problem.value.showFlowchart || problem.value.allowFlowchart) {
|
||||
if (!problem.value.mermaidCode || problem.value.mermaidCode.trim() === "") {
|
||||
message.error("启用了流程图功能,但流程图代码为空")
|
||||
hasErrors = true
|
||||
} else if (!mermaidRenderSuccess.value) {
|
||||
@@ -449,7 +439,7 @@ async function submit() {
|
||||
route.name === "admin contest problem create" ||
|
||||
route.name === "admin contest problem edit"
|
||||
) {
|
||||
problem.value.contest_id = props.contestID
|
||||
problem.value.contestId = Number(props.contestID)
|
||||
}
|
||||
try {
|
||||
await api!(problem.value)
|
||||
@@ -474,7 +464,7 @@ async function submit() {
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.data === "Display ID already exists") {
|
||||
if (err.error === "display-id-exists") {
|
||||
message.error("显示编号重复了,请换一个显示编号")
|
||||
} else {
|
||||
message.error(err.data)
|
||||
@@ -503,7 +493,7 @@ async function generateMermaid() {
|
||||
)
|
||||
isAIGenerating.value = false
|
||||
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
|
||||
problem.value.mermaid_code = res.data.flowchart
|
||||
problem.value.mermaidCode = res.data.flowchart
|
||||
}
|
||||
|
||||
const showGeneratorModal = ref(false)
|
||||
@@ -512,8 +502,8 @@ function handleTestcasesGenerated(
|
||||
testCaseId: string,
|
||||
testCaseScore: Testcase[],
|
||||
) {
|
||||
problem.value.test_case_id = testCaseId
|
||||
problem.value.test_case_score = testCaseScore
|
||||
problem.value.testCaseId = testCaseId
|
||||
problem.value.testCaseScore = testCaseScore
|
||||
showGeneratorModal.value = false
|
||||
}
|
||||
|
||||
@@ -593,12 +583,12 @@ watch(
|
||||
/>
|
||||
<TextEditor
|
||||
v-if="ready && !isSQLProblem"
|
||||
v-model:value="problem.input_description"
|
||||
v-model:value="problem.inputDescription"
|
||||
title="输入的描述"
|
||||
/>
|
||||
<TextEditor
|
||||
v-if="ready && !isSQLProblem"
|
||||
v-model:value="problem.output_description"
|
||||
v-model:value="problem.outputDescription"
|
||||
title="输出的描述"
|
||||
/>
|
||||
<template v-if="!isSQLProblem">
|
||||
@@ -686,12 +676,12 @@ watch(
|
||||
</n-form>
|
||||
|
||||
<n-form
|
||||
v-if="isSQLProblem && problem.sql_config"
|
||||
v-if="isSQLProblem && problem.sqlConfig"
|
||||
inline
|
||||
label-placement="left"
|
||||
>
|
||||
<n-form-item label="SQL 题型">
|
||||
<n-radio-group v-model:value="problem.sql_config.mode">
|
||||
<n-radio-group v-model:value="problem.sqlConfig.mode">
|
||||
<n-radio-button value="query">查询题(比对查询结果)</n-radio-button>
|
||||
<n-radio-button value="modify">
|
||||
增删改题(比对执行后的表数据)
|
||||
@@ -699,7 +689,7 @@ watch(
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="严格比对行顺序">
|
||||
<n-switch v-model:value="problem.sql_config.order_sensitive" />
|
||||
<n-switch v-model:value="problem.sqlConfig.order_sensitive" />
|
||||
<n-text depth="3" style="margin-left: 12px">
|
||||
题目要求 ORDER BY 时开启;关闭则按无序集合比对
|
||||
</n-text>
|
||||
@@ -766,7 +756,7 @@ watch(
|
||||
<n-grid v-if="!isSQLProblem" :cols="2">
|
||||
<n-gi :span="1">
|
||||
<AstRulesEditor
|
||||
v-model="problem.ast_rules!"
|
||||
v-model="problem.astRules!"
|
||||
:languages="problem.languages"
|
||||
/>
|
||||
</n-gi>
|
||||
@@ -802,22 +792,22 @@ watch(
|
||||
<SQLTestcaseEditor
|
||||
v-if="isSQLProblem"
|
||||
:answers="problem.answers"
|
||||
:mode="problem.sql_config?.mode ?? 'query'"
|
||||
:mode="problem.sqlConfig?.mode ?? 'query'"
|
||||
:problem-id="problem.id"
|
||||
@uploaded="handleTestcasesGenerated"
|
||||
/>
|
||||
|
||||
<n-alert
|
||||
class="box"
|
||||
v-if="problem.test_case_score.length"
|
||||
v-if="problem.testCaseScore.length"
|
||||
:show-icon="false"
|
||||
type="info"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
|
||||
{{ problem.test_case_score.length }}
|
||||
测试组编号 {{ problem.testCaseId.slice(0, 12) }} 共有
|
||||
{{ problem.testCaseScore.length }}
|
||||
条测试用例
|
||||
</div>
|
||||
<n-button
|
||||
@@ -870,23 +860,23 @@ watch(
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="允许提交流程图">
|
||||
<n-switch v-model:value="problem.allow_flowchart" />
|
||||
<n-switch v-model:value="problem.allowFlowchart" />
|
||||
</n-form-item>
|
||||
<n-form-item label="显示标准流程图">
|
||||
<n-switch v-model:value="problem.show_flowchart" />
|
||||
<n-switch v-model:value="problem.showFlowchart" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-form>
|
||||
<n-form-item>
|
||||
<MermaidEditor
|
||||
v-model="problem.mermaid_code"
|
||||
v-model="problem.mermaidCode"
|
||||
@render-success="onMermaidRenderSuccess"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="流程图提示信息(选填)">
|
||||
<n-input
|
||||
v-model:value="problem.flowchart_hint"
|
||||
v-model:value="problem.flowchartHint"
|
||||
placeholder="请输入流程图相关的提示信息,帮助学生理解题目要求"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
@@ -113,20 +113,20 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 4, align: "center" }, () => [
|
||||
row.allow_flowchart
|
||||
row.allowFlowchart
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-drawio",
|
||||
title: "绘图",
|
||||
})
|
||||
: row.show_flowchart
|
||||
: row.showFlowchart
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-graphql",
|
||||
title: "流程图",
|
||||
})
|
||||
: null,
|
||||
row.has_ast_rules
|
||||
row.hasAstRules
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-light-todo",
|
||||
@@ -140,7 +140,7 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
key: "top_reaction",
|
||||
width: 60,
|
||||
render: (row) => {
|
||||
const top = row.top_reaction
|
||||
const top = row.topReaction
|
||||
if (!top) return null
|
||||
const reaction = REACTIONS.find((it) => it.key === top.type)
|
||||
if (!reaction) return null
|
||||
@@ -155,7 +155,7 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 200,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
|
||||
@@ -57,7 +57,7 @@ const columns: DataTableColumn<AdminTag>[] = [
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => openTagProblems(row) },
|
||||
() => String(row.problem_count),
|
||||
() => String(row.problemCount),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -123,7 +123,7 @@ async function saveTag(tag: AdminTag) {
|
||||
const res = await renameTag(tag.id, name)
|
||||
if (res.data.merged) {
|
||||
message.success(
|
||||
`已合并到「${res.data.name}」,影响 ${res.data.affected_count} 道题`,
|
||||
`已合并到「${res.data.name}」,影响 ${res.data.affectedCount} 道题`,
|
||||
)
|
||||
} else {
|
||||
message.success("已重命名")
|
||||
@@ -135,7 +135,7 @@ async function saveTag(tag: AdminTag) {
|
||||
function confirmDelete(tag: AdminTag) {
|
||||
dialog.warning({
|
||||
title: "删除标签",
|
||||
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problem_count} 道题在使用它,删除后这些题目会失去该标签。`,
|
||||
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problemCount} 道题在使用它,删除后这些题目会失去该标签。`,
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ function handleConfirm() {
|
||||
|
||||
// 只有非"完成所有题目"时才添加条件值
|
||||
if (newBadgeConditionType.value !== "all_problems") {
|
||||
data.condition_value = newBadgeConditionValue.value
|
||||
data.conditionValue = newBadgeConditionValue.value
|
||||
}
|
||||
|
||||
emit("confirm", data)
|
||||
|
||||
@@ -48,16 +48,16 @@ defineEmits<Emits>()
|
||||
problem_count: '完成指定数量题目',
|
||||
score: '达到指定分数',
|
||||
}
|
||||
return typeMap[row.condition_type] || row.condition_type
|
||||
return typeMap[row.conditionType] || row.conditionType
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '条件值',
|
||||
key: 'condition_value',
|
||||
render: (row) => {
|
||||
return row.condition_type === 'all_problems'
|
||||
return row.conditionType === 'all_problems'
|
||||
? '-'
|
||||
: row.condition_value
|
||||
: row.conditionValue
|
||||
},
|
||||
},
|
||||
{ title: '描述', key: 'description' },
|
||||
|
||||
@@ -58,7 +58,7 @@ function handleConfirm() {
|
||||
|
||||
// 只有非"完成所有题目"时才添加条件值
|
||||
if (editBadgeConditionType.value !== "all_problems") {
|
||||
data.condition_value = editBadgeConditionValue.value
|
||||
data.conditionValue = editBadgeConditionValue.value
|
||||
}
|
||||
|
||||
emit("confirm", data)
|
||||
@@ -76,8 +76,8 @@ watch(
|
||||
editBadgeName.value = newBadge.name
|
||||
editBadgeDescription.value = newBadge.description
|
||||
editBadgeIcon.value = newBadge.icon
|
||||
editBadgeConditionType.value = newBadge.condition_type
|
||||
editBadgeConditionValue.value = newBadge.condition_value
|
||||
editBadgeConditionType.value = newBadge.conditionType
|
||||
editBadgeConditionValue.value = newBadge.conditionValue
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -46,7 +46,7 @@ watch(
|
||||
(newProblem) => {
|
||||
if (newProblem) {
|
||||
editProblemOrder.value = newProblem.order
|
||||
editProblemRequired.value = newProblem.is_required
|
||||
editProblemRequired.value = newProblem.isRequired
|
||||
editProblemScore.value = newProblem.score
|
||||
editProblemHint.value = newProblem.hint || ""
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ defineEmits<Emits>()
|
||||
title: '必做',
|
||||
key: 'is_required',
|
||||
width: 80,
|
||||
render: (row) => (row.is_required ? '是' : '否'),
|
||||
render: (row) => (row.isRequired ? '是' : '否'),
|
||||
},
|
||||
{ title: '分数', key: 'score', width: 80 },
|
||||
{ title: '提示', key: 'hint', minWidth: 200 },
|
||||
|
||||
@@ -16,7 +16,7 @@ defineProps<Props>()
|
||||
{{ problemSet.description }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建者">
|
||||
{{ problemSet.created_by.username }}
|
||||
{{ problemSet.createdBy.username }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="难度">
|
||||
<n-tag
|
||||
@@ -60,10 +60,10 @@ defineProps<Props>()
|
||||
{{ problemSet.visible ? "是" : "否" }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="题目数量">
|
||||
{{ problemSet.problems_count }}
|
||||
{{ problemSet.problemsCount }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">
|
||||
{{ parseTime(problemSet.create_time, "YYYY-MM-DD HH:mm:ss") }}
|
||||
{{ parseTime(problemSet.createTime, "YYYY-MM-DD HH:mm:ss") }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
|
||||
@@ -23,7 +23,7 @@ const progressColumns = [
|
||||
key: "join_time",
|
||||
width: 180,
|
||||
render: (row: ProblemSetProgress) =>
|
||||
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{ title: "已完成", key: "completed_problems_count", width: 100 },
|
||||
{ title: "总题目", key: "total_problems_count", width: 100 },
|
||||
@@ -32,13 +32,13 @@ const progressColumns = [
|
||||
key: "progress_percentage",
|
||||
width: 100,
|
||||
render: (row: ProblemSetProgress) =>
|
||||
`${row.progress_percentage.toFixed(0)}%`,
|
||||
`${row.progressPercentage.toFixed(0)}%`,
|
||||
},
|
||||
{
|
||||
title: "是否完成",
|
||||
key: "is_completed",
|
||||
width: 100,
|
||||
render: (row: ProblemSetProgress) => (row.is_completed ? "是" : "否"),
|
||||
render: (row: ProblemSetProgress) => (row.isCompleted ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
|
||||
@@ -15,16 +15,14 @@ const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
|
||||
difficulty: "Easy",
|
||||
status: "draft",
|
||||
visible: false,
|
||||
end_time: null,
|
||||
endTime: null,
|
||||
})
|
||||
|
||||
const endTimeTimestamp = computed({
|
||||
get: () =>
|
||||
formData.value.end_time
|
||||
? new Date(formData.value.end_time).getTime()
|
||||
: null,
|
||||
formData.value.endTime ? new Date(formData.value.endTime).getTime() : null,
|
||||
set: (val: number | null) => {
|
||||
formData.value.end_time = val ? new Date(val) : null
|
||||
formData.value.endTime = val ? new Date(val) : null
|
||||
},
|
||||
})
|
||||
|
||||
@@ -55,7 +53,7 @@ async function loadProblemSetDetail() {
|
||||
difficulty: data.difficulty,
|
||||
status: data.status,
|
||||
visible: data.visible,
|
||||
end_time: data.end_time ? new Date(data.end_time) : null,
|
||||
endTime: data.endTime ? new Date(data.endTime) : null,
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error("加载题单详情失败:" + (err.data || "未知错误"))
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { ProblemSetList } from "utils/types"
|
||||
import type { ProblemSet } from "utils/types"
|
||||
import { getProblemSetList, toggleProblemSetVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import { NTag, NSwitch } from "naive-ui"
|
||||
|
||||
const total = ref(0)
|
||||
const problemSets = ref<ProblemSetList[]>([])
|
||||
const problemSets = ref<ProblemSet[]>([])
|
||||
|
||||
interface ProblemSetQuery {
|
||||
keyword: string
|
||||
@@ -37,7 +37,7 @@ const statusOptions = [
|
||||
{ label: "草稿", value: "draft" },
|
||||
]
|
||||
|
||||
const columns: DataTableColumn<ProblemSetList>[] = [
|
||||
const columns: DataTableColumn<ProblemSet>[] = [
|
||||
{ title: "ID", key: "id", width: 80 },
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
{ title: "描述", key: "description", minWidth: 300, ellipsis: true },
|
||||
@@ -45,7 +45,7 @@ const columns: DataTableColumn<ProblemSetList>[] = [
|
||||
title: "创建者",
|
||||
key: "created_by",
|
||||
width: 120,
|
||||
render: (row) => row.created_by.username,
|
||||
render: (row) => row.createdBy.username,
|
||||
},
|
||||
{
|
||||
title: "难度",
|
||||
@@ -87,7 +87,7 @@ const columns: DataTableColumn<ProblemSetList>[] = [
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
|
||||
@@ -94,7 +94,7 @@ const serverColumns: DataTableColumn<Server>[] = [
|
||||
{
|
||||
title: "内存占用",
|
||||
key: "memory_usage",
|
||||
render: (row) => row.memory_usage + "%",
|
||||
render: (row) => row.memoryUsage + "%",
|
||||
width: 100,
|
||||
},
|
||||
{ title: "IP", key: "ip", width: 140 },
|
||||
@@ -103,13 +103,13 @@ const serverColumns: DataTableColumn<Server>[] = [
|
||||
{
|
||||
title: "上一次心跳",
|
||||
key: "last_heartbeat",
|
||||
render: (row) => parseTime(row.last_heartbeat, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row) => parseTime(row.lastHeartbeat, "YYYY-MM-DD HH:mm:ss"),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
width: 120,
|
||||
},
|
||||
]
|
||||
@@ -122,32 +122,32 @@ const abnormalServers = computed(() =>
|
||||
)
|
||||
|
||||
const websiteConfig = reactive({
|
||||
website_base_url: import.meta.env.PUBLIC_OJ_URL,
|
||||
website_name: "判题狗",
|
||||
website_name_shortcut: "判题狗",
|
||||
website_footer: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
|
||||
allow_register: true,
|
||||
submission_list_show_all: true,
|
||||
class_list: [],
|
||||
enable_maxkb: true,
|
||||
websiteBaseUrl: import.meta.env.PUBLIC_OJ_URL,
|
||||
websiteName: "判题狗",
|
||||
websiteNameShortcut: "判题狗",
|
||||
websiteFooter: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
|
||||
allowRegister: true,
|
||||
submissionListShowAll: true,
|
||||
classList: [],
|
||||
enableMaxkb: true,
|
||||
})
|
||||
|
||||
async function getWebsiteConfig() {
|
||||
const res = await getWebsite()
|
||||
websiteConfig.website_base_url = res.data.website_base_url
|
||||
websiteConfig.website_name = res.data.website_name
|
||||
websiteConfig.website_name_shortcut = res.data.website_name_shortcut
|
||||
websiteConfig.website_footer = res.data.website_footer
|
||||
websiteConfig.allow_register = res.data.allow_register
|
||||
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
|
||||
websiteConfig.class_list = res.data.class_list
|
||||
websiteConfig.enable_maxkb = res.data.enable_maxkb
|
||||
websiteConfig.websiteBaseUrl = res.data.websiteBaseUrl
|
||||
websiteConfig.websiteName = res.data.websiteName
|
||||
websiteConfig.websiteNameShortcut = res.data.websiteNameShortcut
|
||||
websiteConfig.websiteFooter = res.data.websiteFooter
|
||||
websiteConfig.allowRegister = res.data.allowRegister
|
||||
websiteConfig.submissionListShowAll = res.data.submissionListShowAll
|
||||
websiteConfig.classList = res.data.classList
|
||||
websiteConfig.enableMaxkb = res.data.enableMaxkb
|
||||
}
|
||||
|
||||
async function saveWebsiteConfig() {
|
||||
// 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。
|
||||
// 后端 CreateEditWebsiteConfigSerializer 也会拦,这里先报更明确的错
|
||||
const invalid = websiteConfig.class_list.filter((c) => !CLASS_NAME_RE.test(c))
|
||||
const invalid = websiteConfig.classList.filter((c) => !CLASS_NAME_RE.test(c))
|
||||
if (invalid.length) {
|
||||
message.error(
|
||||
`班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
|
||||
@@ -165,11 +165,8 @@ async function saveWebsiteConfig() {
|
||||
configStore.getConfig()
|
||||
|
||||
// 通过 WebSocket 广播配置变化,实现实时切换
|
||||
updateConfig("enable_maxkb", websiteConfig.enable_maxkb)
|
||||
updateConfig(
|
||||
"submission_list_show_all",
|
||||
websiteConfig.submission_list_show_all,
|
||||
)
|
||||
updateConfig("enable_maxkb", websiteConfig.enableMaxkb)
|
||||
updateConfig("submission_list_show_all", websiteConfig.submissionListShowAll)
|
||||
}
|
||||
|
||||
async function deleteTestcase(id?: string) {
|
||||
@@ -222,19 +219,19 @@ onMounted(() => {
|
||||
</template>
|
||||
<n-form inline label-placement="left">
|
||||
<n-form-item label="网站 URL">
|
||||
<n-input class="url" v-model:value="websiteConfig.website_base_url" />
|
||||
<n-input class="url" v-model:value="websiteConfig.websiteBaseUrl" />
|
||||
</n-form-item>
|
||||
<n-form-item label="网站名">
|
||||
<n-input v-model:value="websiteConfig.website_name" />
|
||||
<n-input v-model:value="websiteConfig.websiteName" />
|
||||
</n-form-item>
|
||||
<n-form-item label="网站简称">
|
||||
<n-input v-model:value="websiteConfig.website_name_shortcut" />
|
||||
<n-input v-model:value="websiteConfig.websiteNameShortcut" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form label-placement="left">
|
||||
<n-form-item label="班级列表">
|
||||
<n-flex vertical size="small">
|
||||
<n-dynamic-tags v-model:value="websiteConfig.class_list" />
|
||||
<n-dynamic-tags v-model:value="websiteConfig.classList" />
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
填 {{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
|
||||
位数字,如 251、2510,要和用户名里 ks 后面那段一致
|
||||
@@ -245,15 +242,15 @@ onMounted(() => {
|
||||
<n-flex align="center">
|
||||
<n-flex align="center">
|
||||
<span>是否允许注册</span>
|
||||
<n-switch v-model:value="websiteConfig.allow_register" />
|
||||
<n-switch v-model:value="websiteConfig.allowRegister" />
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<span>显示所有提交</span>
|
||||
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
|
||||
<n-switch v-model:value="websiteConfig.submissionListShowAll" />
|
||||
</n-flex>
|
||||
<n-flex align="center">
|
||||
<span>启用AI小助手</span>
|
||||
<n-switch v-model:value="websiteConfig.enable_maxkb" />
|
||||
<n-switch v-model:value="websiteConfig.enableMaxkb" />
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
@@ -59,15 +59,15 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
title: "正确率",
|
||||
key: "rate",
|
||||
width: 100,
|
||||
render: (row) => getACRate(row.accepted_number, row.submission_number),
|
||||
render: (row) => getACRate(row.acceptedNumber, row.submissionNumber),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getBaseInfo()
|
||||
userCount.value = res.data.user_count
|
||||
submissionCount.value = res.data.today_submission_count
|
||||
contestCount.value = res.data.recent_contest_count
|
||||
userCount.value = res.data.userCount
|
||||
submissionCount.value = res.data.todaySubmissionCount
|
||||
contestCount.value = res.data.recentContestCount
|
||||
})
|
||||
|
||||
async function listRanks() {
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import type { AdminProblem } from "utils/types"
|
||||
import type { AdminProblemFiltered, AdminProblemListItem } from "utils/types"
|
||||
|
||||
// 把后端的 AdminProblem 塑形成管理端列表项,与请求逻辑解耦。
|
||||
export function toProblemListItem(result: AdminProblem) {
|
||||
// 把后端的列表项塑形成管理端列表行,与请求逻辑解耦。
|
||||
export function toProblemListItem(
|
||||
result: AdminProblemListItem,
|
||||
): AdminProblemFiltered {
|
||||
return {
|
||||
id: result.id,
|
||||
_id: result._id,
|
||||
title: result.title,
|
||||
username: result.created_by.username,
|
||||
create_time: result.create_time,
|
||||
username: result.createdBy.username,
|
||||
createTime: result.createTime,
|
||||
visible: result.visible,
|
||||
difficulty: result.difficulty,
|
||||
tags: result.tags,
|
||||
has_ast_rules: result.has_ast_rules,
|
||||
allow_flowchart: result.allow_flowchart,
|
||||
show_flowchart: result.show_flowchart,
|
||||
hasAstRules: result.hasAstRules,
|
||||
allowFlowchart: result.allowFlowchart,
|
||||
showFlowchart: result.showFlowchart,
|
||||
// 比赛题目列表接口不返回这个字段
|
||||
top_reaction: result.top_reaction ?? null,
|
||||
topReaction: (result.topReaction ??
|
||||
null) as AdminProblemFiltered["topReaction"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ async function save() {
|
||||
message.success("练习题已更新")
|
||||
} else {
|
||||
await createExercise({
|
||||
tutorial_id: props.tutorialId,
|
||||
tutorialId: props.tutorialId,
|
||||
type: formType.value,
|
||||
data,
|
||||
order: formOrder.value,
|
||||
|
||||
@@ -19,7 +19,7 @@ const tutorial = reactive<Tutorial>({
|
||||
title: "",
|
||||
content: "",
|
||||
code: "",
|
||||
is_public: false,
|
||||
isPublic: false,
|
||||
order: 0,
|
||||
type: "python", // 默认选择 Python
|
||||
})
|
||||
@@ -39,7 +39,7 @@ async function init() {
|
||||
tutorial.title = data.title
|
||||
tutorial.content = data.content
|
||||
tutorial.code = data.code || ""
|
||||
tutorial.is_public = data.is_public
|
||||
tutorial.isPublic = data.isPublic
|
||||
tutorial.order = data.order
|
||||
tutorial.type = data.type || "python"
|
||||
}
|
||||
@@ -55,7 +55,7 @@ async function submit() {
|
||||
title: tutorial.title,
|
||||
content: tutorial.content,
|
||||
code: tutorial.code,
|
||||
is_public: tutorial.is_public,
|
||||
isPublic: tutorial.isPublic,
|
||||
order: tutorial.order,
|
||||
type: tutorial.type,
|
||||
})
|
||||
@@ -94,7 +94,7 @@ onMounted(init)
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可见">
|
||||
<n-switch v-model:value="tutorial.is_public" />
|
||||
<n-switch v-model:value="tutorial.isPublic" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button type="primary" @click="submit">保存</n-button>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Tutorial } from "utils/types"
|
||||
import type { TutorialListItem } from "utils/types"
|
||||
import { getTutorialList, setTutorialVisibility } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
const tutorials = ref<{ [key: string]: Tutorial[] }>({
|
||||
const tutorials = ref<{ [key: string]: TutorialListItem[] }>({
|
||||
python: [],
|
||||
c: [],
|
||||
})
|
||||
const message = useMessage()
|
||||
const activeTab = ref("python")
|
||||
|
||||
const columns: DataTableColumn<Tutorial>[] = [
|
||||
const columns: DataTableColumn<TutorialListItem>[] = [
|
||||
{
|
||||
title: "顺序",
|
||||
key: "order",
|
||||
@@ -21,29 +21,29 @@ const columns: DataTableColumn<Tutorial>[] = [
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
{
|
||||
title: "作者",
|
||||
key: "created_by",
|
||||
render: (row) => row.created_by?.username,
|
||||
key: "createdBy",
|
||||
render: (row) => row.createdBy?.username,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "created_at",
|
||||
key: "createdAt",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.created_at!, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row) => parseTime(row.createdAt!, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
key: "updated_at",
|
||||
key: "updatedAt",
|
||||
width: 180,
|
||||
render: (row) => parseTime(row.updated_at!, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row) => parseTime(row.updatedAt!, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "is_public",
|
||||
key: "isPublic",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.is_public,
|
||||
value: row.isPublic,
|
||||
size: "small",
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row),
|
||||
@@ -58,14 +58,14 @@ const columns: DataTableColumn<Tutorial>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
async function toggleVisible(tutorial: Tutorial) {
|
||||
tutorial.is_public = !tutorial.is_public
|
||||
async function toggleVisible(tutorial: TutorialListItem) {
|
||||
tutorial.isPublic = !tutorial.isPublic
|
||||
try {
|
||||
await setTutorialVisibility(tutorial.id, tutorial.is_public)
|
||||
await setTutorialVisibility(tutorial.id, tutorial.isPublic)
|
||||
message.success("更新成功")
|
||||
} catch (err: any) {
|
||||
message.error(err.data)
|
||||
tutorial.is_public = !tutorial.is_public
|
||||
tutorial.isPublic = !tutorial.isPublic
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
async function banUser() {
|
||||
props.user.is_disabled = !props.user.is_disabled
|
||||
props.user.isDisabled = !props.user.isDisabled
|
||||
await editUser(props.user)
|
||||
emit("userBanned", props.user)
|
||||
}
|
||||
@@ -40,10 +40,10 @@ async function banUser() {
|
||||
<n-button
|
||||
size="small"
|
||||
secondary
|
||||
:type="props.user.is_disabled ? 'info' : 'error'"
|
||||
:type="props.user.isDisabled ? 'info' : 'error'"
|
||||
@click="banUser"
|
||||
>
|
||||
{{ props.user.is_disabled ? "解封" : "封号" }}
|
||||
{{ props.user.isDisabled ? "解封" : "封号" }}
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="$emit('deleteUser', [props.user.id])">
|
||||
<template #trigger>
|
||||
|
||||
@@ -9,30 +9,30 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const isNotRegularUser = computed(
|
||||
() => props.user.admin_type !== USER_TYPE.REGULAR_USER,
|
||||
() => props.user.adminType !== USER_TYPE.REGULAR_USER,
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-tag v-if="props.user.is_disabled" type="error" size="small">
|
||||
<n-tag v-if="props.user.isDisabled" type="error" size="small">
|
||||
封号中
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-if="isNotRegularUser"
|
||||
:type="getUserRole(props.user.admin_type).type"
|
||||
:type="getUserRole(props.user.adminType).type"
|
||||
size="small"
|
||||
>
|
||||
{{ getUserRole(props.user.admin_type).label }}
|
||||
{{ getUserRole(props.user.adminType).label }}
|
||||
</n-tag>
|
||||
<n-tag
|
||||
size="small"
|
||||
v-if="
|
||||
props.user.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||
props.user.admin_type === USER_TYPE.TEACHER_ADMIN
|
||||
props.user.adminType === USER_TYPE.STUDENT_ADMIN ||
|
||||
props.user.adminType === USER_TYPE.TEACHER_ADMIN
|
||||
"
|
||||
>
|
||||
{{
|
||||
props.user.problem_permission === PROBLEM_PERMISSION.ALL
|
||||
props.user.problemPermission === PROBLEM_PERMISSION.ALL
|
||||
? "全部"
|
||||
: "仅自己"
|
||||
}}
|
||||
|
||||
@@ -66,28 +66,29 @@ const columns: DataTableColumn<User>[] = [
|
||||
title: "密码",
|
||||
key: "raw_password",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.raw_password),
|
||||
render: (row) => h(TextCopy, () => row.rawPassword),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 200,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row) =>
|
||||
row.createTime ? parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss") : "",
|
||||
},
|
||||
{
|
||||
title: "上次登录",
|
||||
key: "last_login",
|
||||
width: 200,
|
||||
render: (row) =>
|
||||
row.last_login
|
||||
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
||||
row.lastLogin
|
||||
? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss")
|
||||
: "从未登录",
|
||||
},
|
||||
{
|
||||
title: "真名",
|
||||
key: "real_name",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.real_name),
|
||||
render: (row) => h(TextCopy, () => row.realName),
|
||||
},
|
||||
{ title: "邮箱", key: "email", width: 200 },
|
||||
{
|
||||
@@ -145,8 +146,8 @@ async function onResetPassword(user: User) {
|
||||
const res = await resetPassword(user.id)
|
||||
message.success(`【${user.username}】的密码已重置成【${res.data}】`)
|
||||
users.value = users.value.map((it) => {
|
||||
if (it.id === user.id && user.admin_type === USER_TYPE.REGULAR_USER) {
|
||||
it.raw_password = res.data
|
||||
if (it.id === user.id && user.adminType === USER_TYPE.REGULAR_USER) {
|
||||
it.rawPassword = res.data
|
||||
}
|
||||
return it
|
||||
})
|
||||
@@ -155,7 +156,7 @@ async function onResetPassword(user: User) {
|
||||
async function onUserBanned(user: User) {
|
||||
users.value = users.value.map((it) => {
|
||||
if (it.id === user.id) {
|
||||
it.is_disabled = user.is_disabled
|
||||
it.isDisabled = user.isDisabled
|
||||
}
|
||||
return it
|
||||
})
|
||||
@@ -166,14 +167,16 @@ function createNewUser() {
|
||||
userEditing.value = {
|
||||
id: 0,
|
||||
username: "",
|
||||
real_name: "",
|
||||
realName: "",
|
||||
email: "",
|
||||
admin_type: "Student Admin",
|
||||
problem_permission: "None",
|
||||
create_time: new Date(),
|
||||
last_login: new Date(),
|
||||
open_api: false,
|
||||
is_disabled: false,
|
||||
adminType: "Student Admin",
|
||||
problemPermission: "None",
|
||||
createTime: null,
|
||||
lastLogin: null,
|
||||
openApi: false,
|
||||
isDisabled: false,
|
||||
rawPassword: null,
|
||||
className: null,
|
||||
password: "",
|
||||
}
|
||||
password.value = ""
|
||||
@@ -204,8 +207,8 @@ async function handleEditUser() {
|
||||
[
|
||||
userEditing.value.username,
|
||||
password.value,
|
||||
userEditing.value.email,
|
||||
userEditing.value.real_name,
|
||||
userEditing.value.email ?? "",
|
||||
userEditing.value.realName ?? "",
|
||||
],
|
||||
]
|
||||
await importUsers(newUser)
|
||||
@@ -303,16 +306,16 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
<n-input v-model:value="userEditing.username" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="1" label="真名">
|
||||
<n-input v-model:value="userEditing.real_name" />
|
||||
<n-input v-model:value="userEditing.realName" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi v-if="!create" :span="1" label="班级">
|
||||
<n-input v-model:value="userEditing.class_name" />
|
||||
<n-input v-model:value="userEditing.className" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="1" label="邮箱">
|
||||
<n-input v-model:value="userEditing.email" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi v-if="!create" :span="1" label="类型">
|
||||
<n-select v-model:value="userEditing.admin_type" :options="options" />
|
||||
<n-select v-model:value="userEditing.adminType" :options="options" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
:span="1"
|
||||
@@ -324,20 +327,20 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
<n-form-item-gi
|
||||
v-if="
|
||||
!create &&
|
||||
(userEditing.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||
userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)
|
||||
(userEditing.adminType === USER_TYPE.STUDENT_ADMIN ||
|
||||
userEditing.adminType === USER_TYPE.TEACHER_ADMIN)
|
||||
"
|
||||
:span="1"
|
||||
label="出题权限"
|
||||
>
|
||||
<n-select
|
||||
v-model:value="userEditing.problem_permission"
|
||||
v-model:value="userEditing.problemPermission"
|
||||
:options="problemPermissionOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
|
||||
<n-form-item-gi v-if="!create" :span="1" label="是否封禁">
|
||||
<n-switch v-model:value="userEditing.is_disabled">封号</n-switch>
|
||||
<n-switch v-model:value="userEditing.isDisabled">封号</n-switch>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
<n-flex justify="end">
|
||||
|
||||
Reference in New Issue
Block a user