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:
2026-08-25 13:44:24 -06:00
parent b5edd6faf5
commit c2a81209d7
125 changed files with 1651 additions and 2253 deletions

View File

@@ -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(

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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),
)
}

View File

@@ -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>

View File

@@ -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)"

View File

@@ -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: "可见",

View File

@@ -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}%`,
},
]

View File

@@ -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)"

View File

@@ -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 || "未知错误"))

View File

@@ -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)

View File

@@ -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" />

View File

@@ -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

View File

@@ -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>

View File

@@ -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: "可见",

View File

@@ -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 () => {

View File

@@ -54,7 +54,7 @@ function handleConfirm() {
// 只有非"完成所有题目"时才添加条件值
if (newBadgeConditionType.value !== "all_problems") {
data.condition_value = newBadgeConditionValue.value
data.conditionValue = newBadgeConditionValue.value
}
emit("confirm", data)

View File

@@ -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' },

View File

@@ -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 },

View File

@@ -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 || ""
}

View File

@@ -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 },

View File

@@ -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>

View File

@@ -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: "操作",

View File

@@ -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 || "未知错误"))

View File

@@ -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: "可见",

View File

@@ -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 }}
位数字 2512510要和用户名里 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>

View File

@@ -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() {

View File

@@ -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"],
}
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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
? "全部"
: "仅自己"
}}

View File

@@ -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">