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

@@ -84,15 +84,10 @@ accountRoutes.post("/users", async (c) => {
userId: created.id, userId: created.id,
acmProblemsStatus: {}, acmProblemsStatus: {},
avatar: `${config.avatarUriPrefix}/default.png`, avatar: `${config.avatarUriPrefix}/default.png`,
blog: null,
mood: null, mood: null,
acceptedNumber: 0, acceptedNumber: 0,
submissionNumber: 0, submissionNumber: 0,
github: null,
school: null,
major: null,
realName: null, realName: null,
language: null,
}) })
}) })
return success(c, { ok: true }, 201) return success(c, { ok: true }, 201)

View File

@@ -227,7 +227,6 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
acmProblemsStatus: {}, acmProblemsStatus: {},
submissionNumber: 0, submissionNumber: 0,
acceptedNumber: 0, acceptedNumber: 0,
totalScore: 0,
}))) })))
return users.length return users.length
}) })

View File

@@ -1,9 +1,5 @@
import { import { loginRequestSchema } from "@oj2/contract"
loginRequestSchema, import { eq, sql } from "drizzle-orm"
sessionUserSchema,
userProfileSchema,
} from "@oj2/contract"
import { and, eq, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware" import { optionalAuth, type AppEnv } from "../auth/middleware"
@@ -17,21 +13,31 @@ import { getUserProfileById } from "../services/profile"
export const authRoutes = new Hono<AppEnv>() export const authRoutes = new Hono<AppEnv>()
authRoutes.post("/auth/login", async (c) => { authRoutes.post("/auth/login", async (c) => {
const parsed = loginRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = loginRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) { if (!parsed.success) {
return failure(c, 400, "invalid-request", "Username and password are required") return failure(
c,
400,
"invalid-request",
"Username and password are required",
)
} }
const [user] = await db const [user] = await db
.select() .select()
.from(schema.user) .from(schema.user)
.where( .where(sql`lower(${schema.user.username}) = lower(${parsed.data.username})`)
sql`lower(${schema.user.username}) = lower(${parsed.data.username})`,
)
.limit(1) .limit(1)
if (!user) { if (!user) {
return failure(c, 401, "invalid-credentials", "Invalid username or password") return failure(
c,
401,
"invalid-credentials",
"Invalid username or password",
)
} }
if (user.isDisabled) { if (user.isDisabled) {
return failure(c, 403, "account-disabled", "Your account has been disabled") return failure(c, 403, "account-disabled", "Your account has been disabled")
@@ -39,7 +45,12 @@ authRoutes.post("/auth/login", async (c) => {
const password = await verifyPassword(parsed.data.password, user.password) const password = await verifyPassword(parsed.data.password, user.password)
if (!password.valid) { if (!password.valid) {
return failure(c, 401, "invalid-credentials", "Invalid username or password") return failure(
c,
401,
"invalid-credentials",
"Invalid username or password",
)
} }
const now = new Date().toISOString() const now = new Date().toISOString()
@@ -67,6 +78,7 @@ authRoutes.get("/me", optionalAuth, async (c) => {
if (!authUser) return success(c, null) if (!authUser) return success(c, null)
const data = await getUserProfileById(authUser.id, true) const data = await getUserProfileById(authUser.id, true)
if (!data) return failure(c, 404, "profile-not-found", "User profile does not exist") if (!data)
return failure(c, 404, "profile-not-found", "User profile does not exist")
return success(c, data) return success(c, data)
}) })

View File

@@ -29,12 +29,7 @@ export async function getUserProfileById(userId: number, showRealName: boolean)
realName: showRealName ? row.profile.realName : null, realName: showRealName ? row.profile.realName : null,
acmProblemsStatus: row.profile.acmProblemsStatus, acmProblemsStatus: row.profile.acmProblemsStatus,
avatar: row.profile.avatar, avatar: row.profile.avatar,
blog: row.profile.blog,
mood: row.profile.mood, mood: row.profile.mood,
github: row.profile.github,
school: row.profile.school,
major: row.profile.major,
language: row.profile.language,
acceptedNumber: row.profile.acceptedNumber, acceptedNumber: row.profile.acceptedNumber,
submissionNumber: row.profile.submissionNumber, submissionNumber: row.profile.submissionNumber,
}) })

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AchievementRarity } from "utils/types"
import { import {
createAchievement, createAchievement,
getMetricOptions, getMetricOptions,
@@ -29,7 +30,7 @@ function emptyForm() {
name: "", name: "",
description: "", description: "",
icon: "noto:trophy", icon: "noto:trophy",
rarity: "bronze", rarity: "bronze" as AchievementRarity,
hidden: false, hidden: false,
metric: "", metric: "",
operator: "gte" as "gte" | "lte", operator: "gte" as "gte" | "lte",
@@ -46,7 +47,7 @@ const metricOptions = computed(() =>
) )
const metricHelp = 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( watch(

View File

@@ -74,7 +74,7 @@ const columns: DataTableColumn<AdminAchievement>[] = [
width: 90, width: 90,
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity, render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
}, },
{ title: "指标", key: "metric_name" }, { title: "指标", key: "metricName" },
{ {
title: "条件", title: "条件",
key: "threshold", key: "threshold",
@@ -93,7 +93,7 @@ const columns: DataTableColumn<AdminAchievement>[] = [
width: 70, width: 70,
render: (row) => (row.visible ? "是" : "否"), render: (row) => (row.visible ? "是" : "否"),
}, },
{ title: "已解锁人数", key: "unlock_count", width: 110 }, { title: "已解锁人数", key: "unlockCount", width: 110 },
{ {
title: "操作", title: "操作",
key: "actions", key: "actions",

View File

@@ -49,10 +49,10 @@
detail.username detail.username
}}</n-descriptions-item> }}</n-descriptions-item>
<n-descriptions-item label="班级">{{ <n-descriptions-item label="班级">{{
detail.class_name || "-" detail.className || "-"
}}</n-descriptions-item> }}</n-descriptions-item>
<n-descriptions-item label="时间" :span="2">{{ <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-item>
</n-descriptions> </n-descriptions>
<n-scrollbar style="max-height: 60vh; margin-top: 12px"> <n-scrollbar style="max-height: 60vh; margin-top: 12px">
@@ -75,19 +75,10 @@ import {
getPinnedAIReports, getPinnedAIReports,
} from "../api" } from "../api"
import { NButton, NTag } from "naive-ui" import { NButton, NTag } from "naive-ui"
import type { AdminAiReport, AdminAiReportListItem } from "utils/types"
interface ReportItem { type ReportItem = AdminAiReportListItem
id: number type ReportDetail = AdminAiReport
create_time: string
username: string
analysis_excerpt: string
is_pinned: boolean
}
interface ReportDetail extends ReportItem {
analysis: string
class_name: string | null
}
const reports = ref<ReportItem[]>([]) const reports = ref<ReportItem[]>([])
const total = ref(0) const total = ref(0)
@@ -105,29 +96,25 @@ const columns: DataTableColumn<ReportItem>[] = [
key: "username", key: "username",
width: 150, width: 150,
render: (row) => render: (row) =>
h( h("span", { style: row.isPinned ? "font-weight:600" : "" }, row.username),
"span",
{ style: row.is_pinned ? "font-weight:600" : "" },
row.username,
),
}, },
{ {
title: "AI 分析内容", title: "AI 分析内容",
key: "analysis_excerpt", key: "analysis_excerpt",
render: (row) => row.analysis_excerpt || "-", render: (row) => row.analysisExcerpt || "-",
}, },
{ {
title: "生成时间", title: "生成时间",
key: "create_time", key: "create_time",
width: 200, 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 状态", title: "PIN 状态",
key: "is_pinned", key: "is_pinned",
width: 100, width: 100,
render: (row) => render: (row) =>
row.is_pinned row.isPinned
? h(NTag, { type: "warning", size: "small" }, () => "已锁定") ? h(NTag, { type: "warning", size: "small" }, () => "已锁定")
: null, : null,
}, },
@@ -146,10 +133,10 @@ const columns: DataTableColumn<ReportItem>[] = [
NButton, NButton,
{ {
size: "small", size: "small",
type: row.is_pinned ? "error" : "default", type: row.isPinned ? "error" : "default",
onClick: () => togglePin(row), onClick: () => togglePin(row),
}, },
() => (row.is_pinned ? "取消 PIN" : "PIN"), () => (row.isPinned ? "取消 PIN" : "PIN"),
), ),
]), ]),
}, },
@@ -157,7 +144,7 @@ const columns: DataTableColumn<ReportItem>[] = [
async function loadPinnedReports() { async function loadPinnedReports() {
const res = await getPinnedAIReports() const res = await getPinnedAIReports()
pinnedReports.value = res.data pinnedReports.value = res.data.results
} }
async function togglePin(row: ReportItem) { async function togglePin(row: ReportItem) {

View File

@@ -2,18 +2,19 @@
import { NSwitch } from "naive-ui" import { NSwitch } from "naive-ui"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { Announcement } from "utils/types" import type { AnnouncementListItem } from "utils/types"
import { editAnnouncement, getAnnouncementList } from "../api" import { editAnnouncement, getAnnouncement, getAnnouncementList } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
const message = useMessage()
const total = ref(0) const total = ref(0)
const query = reactive({ const query = reactive({
limit: 10, limit: 10,
page: 1, 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: "ID", key: "id", width: 60 },
{ title: "标题", key: "title", minWidth: 300 }, { title: "标题", key: "title", minWidth: 300 },
{ title: "标签", key: "tag", width: 80 }, { title: "标签", key: "tag", width: 80 },
@@ -25,20 +26,20 @@ const columns: DataTableColumn<Announcement>[] = [
}, },
{ {
title: "创建时间", title: "创建时间",
key: "create_time", key: "createTime",
width: 180, 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: "上次更新时间", title: "上次更新时间",
key: "last_update_time", key: "lastUpdateTime",
width: 180, 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: "作者", title: "作者",
key: "created_by", key: "createdBy",
render: (row) => row.created_by.username, render: (row) => row.createdBy.username,
width: 80, width: 80,
}, },
{ {
@@ -61,16 +62,24 @@ const columns: DataTableColumn<Announcement>[] = [
}, },
] ]
async function toggleVisible(announcement: Announcement) { // 列表响应不含 content正文是 8MB 上限的富文本),而更新接口要求 content 必填 ——
announcement.visible = !announcement.visible // 拿列表里那行直接回传会 400。所以先取回整条再改。
editAnnouncement({ async function toggleVisible(announcement: AnnouncementListItem) {
id: announcement.id, const next = !announcement.visible
title: announcement.title, try {
tag: announcement.tag, const { data: full } = await getAnnouncement(announcement.id)
content: announcement.content, await editAnnouncement({
visible: announcement.visible, id: full.id,
top: announcement.top, title: full.title,
}) tag: full.tag,
content: full.content,
visible: next,
top: full.top,
})
announcement.visible = next
} catch {
message.error("修改可见性失败")
}
} }
async function listAnnouncements() { async function listAnnouncements() {

View File

@@ -1,32 +1,53 @@
import api2 from "utils/api2" import api2 from "utils/api2"
import { legacyResponse } from "utils/legacy"
import { toProblemListItem } from "admin/transforms" import { toProblemListItem } from "admin/transforms"
import type { import type {
AcTrend,
BatchProblemTagResponse,
GenerateSqlTestCaseResponse,
RenameTagResponse,
SqlTestCaseScript,
AcmHelperItem,
AdminAiReport,
AdminAiReportList,
StuckProblem,
AdminContestList,
AdminUser,
AdminUserList,
DashboardInfo,
JudgeServerList,
OrphanTestCase,
AdminProblem, AdminProblem,
AdminProblemList,
AdminTag, AdminTag,
Announcement, Announcement,
AnnouncementEdit, AnnouncementEdit,
AnnouncementListItem,
BlankContest, BlankContest,
BlankProblem, BlankProblem,
Contest, Contest,
Exercise, Exercise,
ExerciseType, ExerciseType,
Server,
SQLDisplay, SQLDisplay,
TestcaseUploadedReturns, TestcaseUploadedReturns,
Tutorial, Tutorial,
User, User,
WebsiteConfig, WebsiteConfig,
ProblemSet,
ProblemSetBadge,
ProblemSetList,
ProblemSetProblem,
ProblemSetProgressList,
TutorialListItem,
} from "utils/types" } from "utils/types"
export function getBaseInfo() { export function getBaseInfo() {
return legacyResponse(api2.get("admin/dashboard")) return api2.get<DashboardInfo>("admin/dashboard")
} }
export function randomUser10(classroom: string) { export function randomUser10(classroom: string) {
return legacyResponse( return api2.get<string[]>("admin/random-usernames", {
api2.get("admin/random-usernames", { params: { classroom } }), params: { classroom },
) })
} }
export async function getProblemList( export async function getProblemList(
@@ -40,10 +61,9 @@ export async function getProblemList(
const endpoint = contestID const endpoint = contestID
? `admin/contests/${contestID}/problems` ? `admin/contests/${contestID}/problems`
: "admin/problems" : "admin/problems"
const res = await legacyResponse<{ const res = await api2.get<AdminProblemList>(endpoint, {
results: AdminProblem[] params: { offset, limit, keyword, author, tagId },
total: number })
}>(api2.get(endpoint, { params: { offset, limit, keyword, author, tagId } }))
return { return {
results: res.data.results.map(toProblemListItem), results: res.data.results.map(toProblemListItem),
total: res.data.total, total: res.data.total,
@@ -60,53 +80,46 @@ export function deleteContestProblem(id: number) {
} }
export function editProblem(problem: AdminProblem | BlankProblem) { export function editProblem(problem: AdminProblem | BlankProblem) {
return legacyResponse( return api2.put<AdminProblem>(
api2.put( `admin/problems/${(problem as AdminProblem).id}`,
`admin/problems/${(problem as AdminProblem).id}`, toProblemBody(problem),
toProblemBody(problem),
),
) )
} }
export function toggleProblemVisible(problemID: number) { 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) { 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) { export function editContestProblem(problem: AdminProblem | BlankProblem) {
return legacyResponse( return api2.put<AdminProblem>(
api2.put( `admin/problems/${(problem as AdminProblem).id}`,
`admin/problems/${(problem as AdminProblem).id}`, toProblemBody(problem),
toProblemBody(problem),
),
) )
} }
export function getProblem(id: string | number) { 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) { export function getContestProblem(id: number) {
return legacyResponse<AdminProblem>(api2.get(`admin/problems/${id}`)) return api2.get<AdminProblem>(`admin/problems/${id}`)
} }
// 标签管理 // 标签管理
export function getTagAdminList(keyword = "") { export function getTagAdminList(keyword = "") {
return legacyResponse<AdminTag[]>( return api2.get<AdminTag[]>("admin/problem-tags", { params: { keyword } })
api2.get("admin/problem-tags", { params: { keyword } }),
)
} }
export function renameTag(id: number, name: string) { export function renameTag(id: number, name: string) {
return legacyResponse<{ return api2.put<RenameTagResponse>(`admin/problem-tags/${id}`, { name })
merged: boolean
id: number
name: string
affected_count: number
}>(api2.put(`admin/problem-tags/${id}`, { name }))
} }
export function deleteTag(id: number) { export function deleteTag(id: number) {
@@ -118,13 +131,11 @@ export function batchTagProblems(
tagNames: string[], tagNames: string[],
action: "add" | "remove", action: "add" | "remove",
) { ) {
return legacyResponse<{ problem_count: number; tag_count: number }>( return api2.post<BatchProblemTagResponse>("admin/problems/batch-tag", {
api2.post("admin/problems/batch-tag", { problemIds,
problemIds, tagNames,
tagNames, action,
action, })
}),
)
} }
// 用户列表 // 用户列表
@@ -135,34 +146,30 @@ export function getUserList(
keyword: string, keyword: string,
orderBy = "", orderBy = "",
) { ) {
return legacyResponse( return api2.get<AdminUserList>("admin/users", {
api2.get("admin/users", { // 旧接口的 order_by 只有 "-last_login" 一个取值
// 旧接口的 order_by 只有 "-last_login" 一个取值 params: {
params: { offset,
offset, limit,
limit, keyword,
keyword, type,
type, orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy,
orderBy: orderBy === "-last_login" ? "-lastLogin" : orderBy, },
}, })
}),
)
} }
// 编辑用户 // 编辑用户
export function editUser(user: User) { export function editUser(user: User) {
return legacyResponse( return api2.put<AdminUser>(`admin/users/${user.id}`, {
api2.put(`admin/users/${user.id}`, { username: user.username,
username: user.username, email: user.email,
email: user.email, adminType: user.adminType,
adminType: user.admin_type, problemPermission: user.problemPermission,
problemPermission: user.problem_permission, realName: user.realName ?? null,
realName: user.real_name ?? null, isDisabled: user.isDisabled,
isDisabled: user.is_disabled, openApi: user.openApi,
openApi: user.open_api, password: user.password ?? "",
password: user.password ?? "", })
}),
)
} }
// 重置用户密码。调用方直接用 res.data 当密码字符串(旧后端返回的就是裸字符串), // 重置用户密码。调用方直接用 res.data 当密码字符串(旧后端返回的就是裸字符串),
@@ -185,9 +192,9 @@ export function deleteUsers(userIDs: number[]) {
} }
export function getContestList(offset = 0, limit = 10, keyword: string) { export function getContestList(offset = 0, limit = 10, keyword: string) {
return legacyResponse( return api2.get<AdminContestList>("admin/contests", {
api2.get("admin/contests", { params: { offset, limit, keyword } }), params: { offset, limit, keyword },
) })
} }
// 上传图片 // 上传图片
@@ -218,53 +225,45 @@ export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
// SQL 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据 // SQL 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据
export function previewSQLTestcase(data: { export function previewSQLTestcase(data: {
init_sql: string initSql: string
ref_sql: string refSql: string
mode: "query" | "modify" mode: "query" | "modify"
}) { }) {
return legacyResponse<SQLDisplay>( return api2.post<SQLDisplay>("admin/sql-test-cases/preview", data)
api2.post("admin/sql-test-cases/preview", {
initSql: data.init_sql,
refSql: data.ref_sql,
mode: data.mode,
}),
)
} }
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序) // 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
export function getSQLTestcaseScripts(problemId: number) { export function getSQLTestcaseScripts(problemId: number) {
return legacyResponse<{ name: string; content: string }[]>( return api2.get<SqlTestCaseScript[]>(
api2.get(`admin/problems/${problemId}/sql-scripts`), `admin/problems/${problemId}/sql-scripts`,
) )
} }
// AI 根据标准答案生成一个 SQL 测试点初始化脚本 // AI 根据标准答案生成一个 SQL 测试点初始化脚本
export function generateSQLTestcase(data: { export function generateSQLTestcase(data: {
ref_sql: string refSql: string
mode: "query" | "modify" mode: "query" | "modify"
}) { }) {
return legacyResponse<{ sql: string }>( return api2.post<GenerateSqlTestCaseResponse>(
api2.post("admin/sql-test-cases/generate", { "admin/sql-test-cases/generate",
refSql: data.ref_sql, data,
mode: data.mode,
}),
) )
} }
/** 组件里的题目对象是 snake_case出站转成新后端要的 camelCase */ /** 出站补默认值。字段名两边已经一致,不再做键名转换 */
function toProblemBody(problem: AdminProblem | BlankProblem) { function toProblemBody(problem: AdminProblem | BlankProblem) {
const p = problem as Record<string, any> const p = problem as Partial<AdminProblem>
return { return {
_id: p._id, _id: p._id,
title: p.title, title: p.title,
description: p.description, description: p.description,
inputDescription: p.input_description ?? "", inputDescription: p.inputDescription ?? "",
outputDescription: p.output_description ?? "", outputDescription: p.outputDescription ?? "",
samples: p.samples ?? [], samples: p.samples ?? [],
testCaseId: p.test_case_id, testCaseId: p.testCaseId,
testCaseScore: p.test_case_score ?? [], testCaseScore: p.testCaseScore ?? [],
timeLimit: p.time_limit, timeLimit: p.timeLimit,
memoryLimit: p.memory_limit, memoryLimit: p.memoryLimit,
languages: p.languages ?? [], languages: p.languages ?? [],
template: p.template ?? {}, template: p.template ?? {},
visible: p.visible, visible: p.visible,
@@ -274,25 +273,25 @@ function toProblemBody(problem: AdminProblem | BlankProblem) {
source: p.source ?? null, source: p.source ?? null,
prompt: p.prompt ?? null, prompt: p.prompt ?? null,
answers: p.answers ?? [], answers: p.answers ?? [],
shareSubmission: p.share_submission ?? false, shareSubmission: p.shareSubmission ?? false,
allowFlowchart: p.allow_flowchart ?? false, allowFlowchart: p.allowFlowchart ?? false,
showFlowchart: p.show_flowchart ?? false, showFlowchart: p.showFlowchart ?? false,
mermaidCode: p.mermaid_code ?? null, mermaidCode: p.mermaidCode ?? null,
flowchartHint: p.flowchart_hint ?? null, flowchartHint: p.flowchartHint ?? null,
astRules: p.ast_rules ?? null, astRules: p.astRules ?? null,
sqlConfig: p.sql_config ?? null, sqlConfig: p.sqlConfig ?? null,
} }
} }
export function createProblem(problem: BlankProblem) { 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) { export function createContestProblem(problem: BlankProblem) {
// contest_id 由 detail.vue 在提交前写进 problem 对象 // contestId 由 detail.vue 在提交前写进 problem 对象
const contestID = (problem as Record<string, any>).contest_id return api2.post<AdminProblem>(
return legacyResponse( `admin/contests/${problem.contestId}/problems`,
api2.post(`admin/contests/${contestID}/problems`, toProblemBody(problem)), toProblemBody(problem),
) )
} }
@@ -302,35 +301,31 @@ function toContestBody(contest: Contest | BlankContest) {
title: contest.title, title: contest.title,
description: contest.description, description: contest.description,
tag: contest.tag, tag: contest.tag,
startTime: contest.start_time, startTime: contest.startTime,
endTime: contest.end_time, endTime: contest.endTime,
password: contest.password || null, password: contest.password || null,
visible: contest.visible, visible: contest.visible,
allowedIpRanges: contest.allowed_ip_ranges ?? [], allowedIpRanges: contest.allowedIpRanges ?? [],
} }
} }
export function createContest(contest: BlankContest) { 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) { export function editContest(contest: Contest | BlankContest) {
return legacyResponse( return api2.put<Contest>(
api2.put( `admin/contests/${(contest as Contest).id}`,
`admin/contests/${(contest as Contest).id}`, toContestBody(contest),
toContestBody(contest),
),
) )
} }
export function cloneContest(contest_id: number) { export function cloneContest(contestId: number) {
return legacyResponse(api2.post(`admin/contests/${contest_id}/clone`)) return api2.post<Contest>(`admin/contests/${contestId}/clone`)
} }
export function getContest(id: string) { export function getContest(id: string) {
return legacyResponse<Contest & { password: string }>( return api2.get<Contest>(`admin/contests/${id}`)
api2.get(`admin/contests/${id}`),
)
} }
export function addProblemForContest( export function addProblemForContest(
@@ -338,33 +333,22 @@ export function addProblemForContest(
problemID: number, problemID: number,
displayID: string, displayID: string,
) { ) {
return legacyResponse( return api2.post<AdminProblem>(
api2.post(`admin/contests/${contestID}/problems/from-public`, { `admin/contests/${contestID}/problems/from-public`,
problemId: problemID, { problemId: problemID, displayId: displayID },
displayId: displayID,
}),
) )
} }
export function getWebsite() { export function getWebsite() {
return legacyResponse<WebsiteConfig>(api2.get("admin/website")) return api2.get<WebsiteConfig>("admin/website")
} }
export function editWebsite(data: WebsiteConfig) { export function editWebsite(data: WebsiteConfig) {
return api2.post("admin/website", { return api2.post<WebsiteConfig>("admin/website", data)
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,
})
} }
export function listInvalidTestcases() { export function listInvalidTestcases() {
return legacyResponse(api2.get("admin/orphan-test-cases")) return api2.get<OrphanTestCase[]>("admin/orphan-test-cases")
} }
export function pruneInvalidTestcases(id?: string) { export function pruneInvalidTestcases(id?: string) {
@@ -372,9 +356,7 @@ export function pruneInvalidTestcases(id?: string) {
} }
export function getJudgeServer() { export function getJudgeServer() {
return legacyResponse<{ token: string; servers: Server[] }>( return api2.get<JudgeServerList>("admin/judge-servers")
api2.get("admin/judge-servers"),
)
} }
export function deleteJudgeServer(hostname: string) { export function deleteJudgeServer(hostname: string) {
@@ -382,15 +364,14 @@ export function deleteJudgeServer(hostname: string) {
} }
export function getAnnouncementList(offset = 0, limit = 10) { export function getAnnouncementList(offset = 0, limit = 10) {
return legacyResponse( return api2.get<{ results: AnnouncementListItem[]; total: number }>(
api2.get("admin/announcements", { "admin/announcements",
params: { offset, limit }, { params: { offset, limit } },
}),
) )
} }
export function getAnnouncement(id: number) { 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) { export function deleteAnnouncement(id: number) {
@@ -399,48 +380,46 @@ export function deleteAnnouncement(id: number) {
export function editAnnouncement(announcement: AnnouncementEdit) { export function editAnnouncement(announcement: AnnouncementEdit) {
const { id, ...body } = announcement 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) { export function createAnnouncement(announcement: AnnouncementEdit) {
const { id: _id, ...body } = announcement const { id: _id, ...body } = announcement
return legacyResponse(api2.post("admin/announcements", body)) return api2.post<Announcement>("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,
}
} }
export async function getTutorialList() { export async function getTutorialList() {
const res = await legacyResponse<{ [key: string]: Tutorial[] }>( const res = await api2.get<{ [key: string]: TutorialListItem[] }>(
api2.get("admin/tutorials"), "admin/tutorials",
) )
return res.data return res.data
} }
export async function getTutorial(id: number) { 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 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>) { export async function createTutorial(data: Partial<Tutorial>) {
const res = await legacyResponse<Tutorial>( const res = await api2.post<Tutorial>("admin/tutorials", toTutorialBody(data))
api2.post("admin/tutorials", toTutorialBody(data)),
)
return res.data return res.data
} }
export async function updateTutorial(data: Partial<Tutorial>) { export async function updateTutorial(data: Partial<Tutorial>) {
const res = await legacyResponse<Tutorial>( const res = await api2.put<Tutorial>(
api2.put(`admin/tutorials/${data.id}`, toTutorialBody(data)), `admin/tutorials/${data.id}`,
toTutorialBody(data),
) )
return res.data return res.data
} }
@@ -449,33 +428,24 @@ export function deleteTutorial(id: number) {
return api2.delete(`admin/tutorials/${id}`) return api2.delete(`admin/tutorials/${id}`)
} }
export function setTutorialVisibility(id: number, is_public: boolean) { export function setTutorialVisibility(id: number, isPublic: boolean) {
return legacyResponse( return api2.put<Tutorial>(`admin/tutorials/${id}/visibility`, { isPublic })
api2.put(`admin/tutorials/${id}/visibility`, { isPublic: is_public }),
)
} }
export async function getAdminExercises(tutorialId: number) { export async function getAdminExercises(tutorialId: number) {
const res = await legacyResponse<Exercise[]>( const res = await api2.get<Exercise[]>(
api2.get(`admin/tutorials/${tutorialId}/exercises`), `admin/tutorials/${tutorialId}/exercises`,
) )
return res.data return res.data
} }
export async function createExercise(data: { export async function createExercise(data: {
tutorial_id: number tutorialId: number
type: ExerciseType type: ExerciseType
data: object data: object
order: number order: number
}) { }) {
const res = await legacyResponse<Exercise>( const res = await api2.post<Exercise>("admin/exercises", data)
api2.post("admin/exercises", {
tutorialId: data.tutorial_id,
type: data.type,
data: data.data,
order: data.order,
}),
)
return res.data return res.data
} }
@@ -485,13 +455,11 @@ export async function updateExercise(data: {
data: object data: object
order: number order: number
}) { }) {
const res = await legacyResponse<Exercise>( const res = await api2.put<Exercise>(`admin/exercises/${data.id}`, {
api2.put(`admin/exercises/${data.id}`, { type: data.type,
type: data.type, data: data.data,
data: data.data, order: data.order,
order: data.order, })
}),
)
return res.data return res.data
} }
@@ -500,15 +468,15 @@ export function deleteExercise(id: number) {
} }
// 将竞赛题目转为公开题目 // 将竞赛题目转为公开题目
export function makeProblemPublic(id: number, display_id: string) { export function makeProblemPublic(id: number, displayId: string) {
return legacyResponse( return api2.post<AdminProblem>(`admin/problems/${id}/make-public`, {
api2.post(`admin/problems/${id}/make-public`, { displayId: display_id }), displayId,
) })
} }
// 比赛辅助检查 // 比赛辅助检查
export function getACMHelperList(contest_id: number) { export function getACMHelperList(contestId: number) {
return legacyResponse(api2.get(`admin/contests/${contest_id}/acm-helper`)) return api2.get<AcmHelperItem[]>(`admin/contests/${contestId}/acm-helper`)
} }
export function updateACMHelperChecked( export function updateACMHelperChecked(
@@ -532,57 +500,44 @@ export function getProblemSetList(
difficulty = "", difficulty = "",
status = "", status = "",
) { ) {
return legacyResponse( return api2.get<ProblemSetList>("admin/problem-sets", {
api2.get("admin/problem-sets", { params: { offset, limit, keyword, difficulty, status },
params: { offset, limit, keyword, difficulty, status }, })
}),
)
} }
export function getProblemSetDetail(id: number) { export function getProblemSetDetail(id: number) {
return legacyResponse(api2.get(`admin/problem-sets/${id}`)) return api2.get<ProblemSet>(`admin/problem-sets/${id}`)
} }
/** 组件传的是 snake_case出站转成新后端要的 camelCase */ interface ProblemSetBody {
function toProblemSetBody(data: {
title?: string title?: string
description?: string description?: string
difficulty?: string difficulty?: ProblemSet["difficulty"]
status?: string status?: ProblemSet["status"]
end_time?: Date | null // 表单里是 Date出站要 ISO 串
endTime?: Date | null
visible?: boolean visible?: boolean
}) { }
function toProblemSetBody(data: ProblemSetBody) {
return { return {
title: data.title, title: data.title,
description: data.description ?? "", description: data.description ?? "",
difficulty: data.difficulty ?? "Easy", difficulty: data.difficulty ?? "Easy",
status: data.status ?? "active", 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, visible: data.visible ?? true,
} }
} }
export function createProblemSet(data: { export function createProblemSet(data: ProblemSetBody) {
title: string return api2.post<ProblemSet>("admin/problem-sets", toProblemSetBody(data))
description: string
difficulty: string
status: string
end_time?: Date | null
}) {
return legacyResponse(api2.post("admin/problem-sets", toProblemSetBody(data)))
} }
export function editProblemSet(data: { export function editProblemSet(data: ProblemSetBody & { id: number }) {
id: number return api2.put<ProblemSet>(
title?: string `admin/problem-sets/${data.id}`,
description?: string toProblemSetBody(data),
difficulty?: string
status?: string
end_time?: Date | null
visible?: boolean
}) {
return legacyResponse(
api2.put(`admin/problem-sets/${data.id}`, toProblemSetBody(data)),
) )
} }
@@ -591,32 +546,34 @@ export function deleteProblemSet(id: number) {
} }
export function toggleProblemSetVisible(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) { 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 // 题单题目管理 API
export function getProblemSetProblems(problemSetId: number) { 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( export function addProblemToSet(
problemSetId: number, problemSetId: number,
data: { data: {
problem_id: string problemId: string
order?: number order?: number
is_required?: boolean isRequired?: boolean
score?: number score?: number
hint?: string hint?: string
}, },
) { ) {
return api2.post(`admin/problem-sets/${problemSetId}/problems`, { return api2.post(`admin/problem-sets/${problemSetId}/problems`, {
problemId: data.problem_id, problemId: data.problemId,
order: data.order ?? 0, order: data.order ?? 0,
isRequired: data.is_required ?? true, isRequired: data.isRequired ?? true,
score: data.score ?? 0, score: data.score ?? 0,
hint: data.hint ?? "", hint: data.hint ?? "",
}) })
@@ -627,19 +584,14 @@ export function editProblemInSet(
problemSetProblemId: number, problemSetProblemId: number,
data: { data: {
order?: number order?: number
is_required?: boolean isRequired?: boolean
score?: number score?: number
hint?: string hint?: string
}, },
) { ) {
return api2.put( return api2.put(
`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`, `admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`,
{ data,
order: data.order,
isRequired: data.is_required,
score: data.score,
hint: data.hint,
},
) )
} }
@@ -654,58 +606,44 @@ export function removeProblemFromSet(
// 题单奖章管理 API // 题单奖章管理 API
export function getProblemSetBadges(problemSetId: number) { 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 name?: string
description?: string description?: string
icon?: string icon?: string
condition_type?: string conditionType?: ProblemSetBadge["conditionType"]
condition_value?: number conditionValue?: number
}) { }
function toBadgeBody(data: BadgeBody) {
return { return {
name: data.name, name: data.name,
description: data.description ?? "", description: data.description ?? "",
icon: data.icon ?? "", icon: data.icon ?? "",
conditionType: data.condition_type, conditionType: data.conditionType,
conditionValue: data.condition_value ?? 0, conditionValue: data.conditionValue ?? 0,
} }
} }
export function createProblemSetBadge( export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
problemSetId: number, return api2.post<ProblemSetBadge>(
data: { `admin/problem-sets/${problemSetId}/badges`,
name: string toBadgeBody(data),
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 editProblemSetBadge( export function editProblemSetBadge(
problemSetId: number, problemSetId: number,
badgeId: number, badgeId: number,
data: { data: BadgeBody,
name?: string
description?: string
icon?: string
condition_type?: string
condition_value?: number
level?: number
},
) { ) {
return legacyResponse( return api2.put<ProblemSetBadge>(
api2.put( `admin/problem-sets/${problemSetId}/badges/${badgeId}`,
`admin/problem-sets/${problemSetId}/badges/${badgeId}`, toBadgeBody(data),
toBadgeBody(data),
),
) )
} }
@@ -715,7 +653,9 @@ export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
// 题单进度管理 API // 题单进度管理 API
export function getProblemSetProgress(problemSetId: number) { 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) { export function removeUserFromProblemSet(problemSetId: number, userId: number) {
@@ -724,74 +664,46 @@ export function removeUserFromProblemSet(problemSetId: number, userId: number) {
// 学生卡点分析 // 学生卡点分析
export function getStuckProblems() { export function getStuckProblems() {
return legacyResponse(api2.get("admin/problem-analytics/stuck")) return api2.get<StuckProblem[]>("admin/problem-analytics/stuck")
} }
export function getTopACTrend(params: { export function getTopACTrend(params: {
since_year: number sinceYear: number
until_year: number untilYear: number
min_per_year: number minPerYear: number
}) { }) {
return legacyResponse( return api2.get<AcTrend[]>("admin/problem-analytics/ac-trend", { params })
api2.get("admin/problem-analytics/ac-trend", {
params: {
sinceYear: params.since_year,
untilYear: params.until_year,
minPerYear: params.min_per_year,
},
}),
)
} }
// AI 学习分析报告 // AI 学习分析报告
export function getAIReportList(offset = 0, limit = 10, username = "") { export function getAIReportList(offset = 0, limit = 10, username = "") {
return legacyResponse( return api2.get<AdminAiReportList>("admin/ai/reports", {
api2.get("admin/ai/reports", { params: { offset, limit, username: username || undefined },
params: { offset, limit, username: username || undefined }, })
}),
)
} }
export function getAIReportDetail(id: number) { 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) { 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() { export function getPinnedAIReports() {
return legacyResponse( return api2.get<AdminAiReportList>("admin/ai/reports", {
api2.get("admin/ai/reports", { params: { pinnedOnly: "true" } }), params: { pinnedOnly: "true" },
) })
} }
// ==================== 成就 ==================== // ==================== 成就 ====================
export interface AdminAchievement { import type {
id: number AdminAchievement,
name: string AchievementMetric as MetricOption,
description: string } from "@oj2/contract"
icon: string export type { AdminAchievement, MetricOption }
rarity: string
hidden: boolean
metric: string
metric_name: string
operator: "gte" | "lte"
threshold: number
visible: boolean
unlock_count: number
order: number
create_time: string
}
export interface MetricOption {
key: string
name: string
help_text: string
}
/** 组件里的成就对象是 snake_case出站转成新后端要的 camelCase */
function toAchievementBody(data: Partial<AdminAchievement>) { function toAchievementBody(data: Partial<AdminAchievement>) {
return { return {
name: data.name, name: data.name,
@@ -808,22 +720,24 @@ function toAchievementBody(data: Partial<AdminAchievement>) {
} }
export function getAdminAchievements() { export function getAdminAchievements() {
return legacyResponse<AdminAchievement[]>(api2.get("admin/achievements")) return api2.get<AdminAchievement[]>("admin/achievements")
} }
export function getMetricOptions() { export function getMetricOptions() {
return legacyResponse<MetricOption[]>(api2.get("admin/achievement-metrics")) return api2.get<MetricOption[]>("admin/achievement-metrics")
} }
export function createAchievement(data: Partial<AdminAchievement>) { export function createAchievement(data: Partial<AdminAchievement>) {
return legacyResponse<AdminAchievement>( return api2.post<AdminAchievement>(
api2.post("admin/achievements", toAchievementBody(data)), "admin/achievements",
toAchievementBody(data),
) )
} }
export function updateAchievement(data: Partial<AdminAchievement>) { export function updateAchievement(data: Partial<AdminAchievement>) {
return legacyResponse<AdminAchievement>( return api2.put<AdminAchievement>(
api2.put(`admin/achievements/${data.id}`, toAchievementBody(data)), `admin/achievements/${data.id}`,
toAchievementBody(data),
) )
} }

View File

@@ -23,8 +23,8 @@ const durationMins = ref(10) // 比赛默认时长10分钟
watch([waitMins, durationMins], () => { watch([waitMins, durationMins], () => {
const times = getTimes() const times = getTimes()
contest.start_time = formatISO(times[0]) contest.startTime = formatISO(times[0])
contest.end_time = formatISO(times[1]) contest.endTime = formatISO(times[1])
}) })
// 编辑的时候 // 编辑的时候
@@ -32,8 +32,8 @@ const startTime = ref(0)
const endTime = ref(0) const endTime = ref(0)
watch([startTime, endTime], (values) => { watch([startTime, endTime], (values) => {
contest.start_time = formatISO(values[0]) contest.startTime = formatISO(values[0])
contest.end_time = formatISO(values[1]) contest.endTime = formatISO(values[1])
}) })
const route = useRoute() const route = useRoute()
@@ -54,18 +54,18 @@ const contest = reactive<BlankContest & { id: number }>({
title: "", title: "",
description: "", description: "",
tag: "练习", tag: "练习",
start_time: "", startTime: "",
end_time: "", endTime: "",
password: "", password: "",
visible: false, visible: false,
allowed_ip_ranges: [], allowedIpRanges: [],
}) })
async function getContestDetail() { async function getContestDetail() {
if (!props.contestID) { if (!props.contestID) {
const times = getTimes() const times = getTimes()
contest.start_time = formatISO(times[0]) contest.startTime = formatISO(times[0])
contest.end_time = formatISO(times[1]) contest.endTime = formatISO(times[1])
toggleReady(true) toggleReady(true)
return return
} }
@@ -75,15 +75,15 @@ async function getContestDetail() {
contest.title = data.title contest.title = data.title
contest.description = data.description contest.description = data.description
contest.tag = data.tag contest.tag = data.tag
contest.start_time = data.start_time contest.startTime = data.startTime
contest.end_time = data.end_time contest.endTime = data.endTime
contest.password = data.password contest.password = data.password
contest.visible = data.visible contest.visible = data.visible
contest.allowed_ip_ranges = [] contest.allowedIpRanges = []
// 显示 // 显示
startTime.value = Date.parse(data.start_time) startTime.value = Date.parse(data.startTime)
endTime.value = Date.parse(data.end_time) endTime.value = Date.parse(data.endTime)
} }
async function submit() { async function submit() {
@@ -118,12 +118,12 @@ onMounted(getContestDetail)
<template v-if="!props.contestID"> <template v-if="!props.contestID">
<n-alert type="success"> <n-alert type="success">
<template #header> <template #header>
开始时间 {{ parseTime(contest.start_time, "YYYY年M月D日 HH:mm:ss") }} 开始时间 {{ parseTime(contest.startTime, "YYYY年M月D日 HH:mm:ss") }}
</template> </template>
</n-alert> </n-alert>
<n-alert type="warning"> <n-alert type="warning">
<template #header> <template #header>
结束时间 {{ parseTime(contest.end_time, "YYYY年M月D日 HH:mm:ss") }} 结束时间 {{ parseTime(contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
</template> </template>
</n-alert> </n-alert>
</template> </template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AcmHelperItem, SubmissionInfo } from "utils/types"
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui" import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api" import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
@@ -10,19 +11,12 @@ interface Props {
contestID: string contestID: string
} }
interface HelperItem { /**
id: number * ACM 助手行。`acInfo` 的**内容**是 acm_contest_rank.submission_info 的 JSONB 原文,
username: string * 键名保持 snake_case —— 回滚时旧后端还要读。
real_name: string */
problem_id: string type HelperItem = Omit<AcmHelperItem, "acInfo"> & {
problem_display_id: string acInfo: SubmissionInfo
ac_info: {
is_ac: boolean
ac_time: number
error_number: number
checked?: boolean
}
checked: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -65,12 +59,12 @@ async function toggleChecked(item: HelperItem) {
await updateACMHelperChecked( await updateACMHelperChecked(
Number(props.contestID), Number(props.contestID),
item.id, item.id,
item.problem_id, item.problemId,
newChecked, newChecked,
) )
// 更新本地状态 // 更新本地状态
item.checked = newChecked item.checked = newChecked
item.ac_info.checked = newChecked item.acInfo.checked = newChecked
// 强制触发响应式更新 // 强制触发响应式更新
submissions.value = [...submissions.value] submissions.value = [...submissions.value]
@@ -95,11 +89,11 @@ async function markAllAsChecked() {
await updateACMHelperChecked( await updateACMHelperChecked(
Number(props.contestID), Number(props.contestID),
item.id, item.id,
item.problem_id, item.problemId,
true, true,
) )
item.checked = true item.checked = true
item.ac_info.checked = true item.acInfo.checked = true
} }
// 强制触发响应式更新 // 强制触发响应式更新
@@ -117,7 +111,7 @@ async function markAllAsChecked() {
const filteredSubmissions = computed(() => { const filteredSubmissions = computed(() => {
return submissions.value.filter((item) => { return submissions.value.filter((item) => {
if (query.username && !item.username.includes(query.username)) return false 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 return false
if (query.checked === "checked" && !item.checked) return false if (query.checked === "checked" && !item.checked) return false
if (query.checked === "unchecked" && item.checked) return false if (query.checked === "unchecked" && item.checked) return false
@@ -139,8 +133,8 @@ async function viewSubmission(item: HelperItem) {
// 查询该用户在该竞赛该题目的 AC 提交 // 查询该用户在该竞赛该题目的 AC 提交
const res = await getSubmissions({ const res = await getSubmissions({
username: item.username, username: item.username,
problem_id: item.problem_display_id, problemId: item.problemDisplayId,
contest_id: props.contestID, contestId: props.contestID,
result: "0", // ACCEPTED result: "0", // ACCEPTED
language: "", language: "",
page: 1, page: 1,
@@ -161,7 +155,7 @@ async function viewSubmission(item: HelperItem) {
currentSubmission.value = { currentSubmission.value = {
...detailRes.data, ...detailRes.data,
contest: Number(props.contestID), contest: Number(props.contestID),
problem_display_id: item.problem_display_id, problem_display_id: item.problemDisplayId,
} }
toggleCodePanel(true) toggleCodePanel(true)
@@ -175,7 +169,7 @@ async function loadData() {
try { try {
// 先获取比赛信息,获取开始时间 // 先获取比赛信息,获取开始时间
const contestRes = await getContest(props.contestID) const contestRes = await getContest(props.contestID)
contestStartTime.value = new Date(contestRes.data.start_time) contestStartTime.value = new Date(contestRes.data.startTime)
// 再获取 AC 提交列表 // 再获取 AC 提交列表
const { data } = await getACMHelperList(Number(props.contestID)) const { data } = await getACMHelperList(Number(props.contestID))
@@ -195,13 +189,13 @@ const columns: DataTableColumn<HelperItem>[] = [
title: "题目", title: "题目",
key: "problem_display_id", key: "problem_display_id",
width: 100, width: 100,
render: (row) => h(NTag, { type: "info" }, () => row.problem_display_id), render: (row) => h(NTag, { type: "info" }, () => row.problemDisplayId),
}, },
{ {
title: "AC时间", title: "AC时间",
key: "ac_time", key: "ac_time",
width: 180, width: 180,
render: (row) => formatACTime(row.ac_info.ac_time), render: (row) => formatACTime(row.acInfo.ac_time),
}, },
{ {
title: "错误次数", title: "错误次数",
@@ -211,10 +205,10 @@ const columns: DataTableColumn<HelperItem>[] = [
h( h(
NTag, NTag,
{ {
type: row.ac_info.error_number > 0 ? "warning" : "success", type: row.acInfo.error_number > 0 ? "warning" : "success",
size: "small", size: "small",
}, },
() => row.ac_info.error_number, () => row.acInfo.error_number,
), ),
}, },
{ {
@@ -310,7 +304,7 @@ onMounted(loadData)
<SubmissionDetail <SubmissionDetail
v-if="currentSubmission" v-if="currentSubmission"
:submission="currentSubmission" :submission="currentSubmission"
:problemID="currentSubmission.problem_display_id" :problemID="currentSubmission.problemDisplayId"
:submissionID="currentSubmission.id" :submissionID="currentSubmission.id"
hideList hideList
@copied="toggleCodePanel(false)" @copied="toggleCodePanel(false)"

View File

@@ -56,13 +56,13 @@ const columns: DataTableColumn<Contest>[] = [
title: "创建者", title: "创建者",
key: "created_by", key: "created_by",
width: 120, width: 120,
render: (row) => row.created_by.username, render: (row) => row.createdBy.username,
}, },
{ {
title: "创建时间", title: "创建时间",
key: "create_time", key: "create_time",
width: 160, width: 160,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm"), render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm"),
}, },
{ {
title: "可见", title: "可见",

View File

@@ -1,36 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { getStuckProblems } from "admin/api" import { getStuckProblems } from "admin/api"
import type { StuckProblem } from "utils/types"
interface StuckProblem {
problem_id: string
problem_title: string
total: number
failed: number
failed_users: number
ac_rate: number
}
const loading = ref(true) const loading = ref(true)
const data = ref<StuckProblem[]>([]) const data = ref<StuckProblem[]>([])
const columns: DataTableColumn<StuckProblem>[] = [ const columns: DataTableColumn<StuckProblem>[] = [
{ title: "题目 ID", key: "problem_id", width: 100 }, { title: "题目 ID", key: "problemId", width: 100 },
{ title: "题目名称", key: "problem_title", minWidth: 200 }, { title: "题目名称", key: "problemTitle", minWidth: 200 },
{ title: "总提交", key: "total", width: 100, sorter: "default" }, { title: "总提交", key: "total", width: 100, sorter: "default" },
{ title: "失败次数", key: "failed", width: 100, sorter: "default" }, { title: "失败次数", key: "failed", width: 100, sorter: "default" },
{ {
title: "卡住学生数", title: "卡住学生数",
key: "failed_users", key: "failedUsers",
width: 120, width: 120,
sorter: "default", sorter: "default",
defaultSortOrder: "descend", defaultSortOrder: "descend",
}, },
{ {
title: "AC 率", title: "AC 率",
key: "ac_rate", key: "acRate",
width: 100, width: 100,
sorter: "default", sorter: "default",
render: (row) => `${row.ac_rate}%`, render: (row) => `${row.acRate}%`,
}, },
] ]

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AcTrend } from "utils/types"
import { Line } from "vue-chartjs" import { Line } from "vue-chartjs"
import { import {
Chart as ChartJS, Chart as ChartJS,
@@ -22,18 +23,7 @@ ChartJS.register(
Tooltip, Tooltip,
) )
interface YearlyEntry { type ProblemTrend = AcTrend
year: number
total: number
accepted: number
ac_rate: number
}
interface ProblemTrend {
problem_id: string
problem_title: string
yearly: YearlyEntry[]
}
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const yearOptions = Array.from({ length: currentYear - 2022 + 1 }, (_, i) => ({ const yearOptions = Array.from({ length: currentYear - 2022 + 1 }, (_, i) => ({
@@ -79,7 +69,7 @@ function getChartData(problem: ProblemTrend) {
datasets: [ datasets: [
{ {
label: "AC 率", label: "AC 率",
data: problem.yearly.map((y) => y.ac_rate), data: problem.yearly.map((y) => y.acRate),
fill: true, fill: true,
tension: 0.3, tension: 0.3,
backgroundColor: "rgba(99, 179, 237, 0.2)", backgroundColor: "rgba(99, 179, 237, 0.2)",
@@ -98,14 +88,14 @@ function getChartOptions(problem: ProblemTrend) {
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: `${problem.problem_id} · ${problem.problem_title}`, text: `${problem.problemId} · ${problem.problemTitle}`,
font: { size: 14 }, font: { size: 14 },
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: (ctx: any) => { label: (ctx: any) => {
const entry = problem.yearly[ctx.dataIndex] 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 loading.value = true
try { try {
const res = await getTopACTrend({ const res = await getTopACTrend({
since_year: sinceYear.value, sinceYear: sinceYear.value,
until_year: untilYear.value, untilYear: untilYear.value,
min_per_year: minPerYear.value, minPerYear: minPerYear.value,
}) })
data.value = res.data data.value = res.data
} finally { } finally {
@@ -174,7 +164,7 @@ onMounted(fetchData)
暂无数据 暂无数据
</div> </div>
<div v-else class="grid"> <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 <Line
:data="getChartData(problem)" :data="getChartData(problem)"
:options="getChartOptions(problem)" :options="getChartOptions(problem)"

View File

@@ -34,7 +34,7 @@ async function handleDeleteProblem() {
message.success("删除成功") message.success("删除成功")
emit("updated") emit("updated")
} catch (err: any) { } catch (err: any) {
if (err.data === "Can't delete the problem as it has submissions") { if (err.error === "problem-has-submissions") {
message.error("这道题有提交之后,就不能被删除") message.error("这道题有提交之后,就不能被删除")
} else { } else {
message.error("删除失败") message.error("删除失败")
@@ -82,9 +82,9 @@ async function handleMakePublic() {
showMakePublicModal.value = false showMakePublicModal.value = false
emit("updated") // 刷新列表 emit("updated") // 刷新列表
} catch (err: any) { } catch (err: any) {
if (err.data === "Duplicate display ID") { if (err.error === "display-id-exists") {
message.error("该题目编号已存在,请使用其他编号") message.error("该题目编号已存在,请使用其他编号")
} else if (err.data === "Already be a public problem") { } else if (err.error === "already-public") {
message.error("该题目已经是公开题目") message.error("该题目已经是公开题目")
} else { } else {
message.error("转换失败:" + (err.data || "未知错误")) message.error("转换失败:" + (err.data || "未知错误"))

View File

@@ -23,9 +23,9 @@ async function addProblem() {
) )
emit("added") emit("added")
} catch (err: any) { } catch (err: any) {
if (err.data === "Duplicate display id in this contest") { if (err.error === "display-id-exists") {
message.error("显示编号重复了,请重新写一个") message.error("显示编号重复了,请重新写一个")
} else if (err.data === "Contest has ended") { } else if (err.error === "contest-ended") {
message.error("这场比赛已经结束了,不能添加题目") message.error("这场比赛已经结束了,不能添加题目")
} else { } else {
message.error(err.data) message.error(err.data)

View File

@@ -60,7 +60,7 @@ async function submit() {
) )
const verb = props.action === "add" ? "添加" : "移除" const verb = props.action === "add" ? "添加" : "移除"
message.success( message.success(
`已为 ${res.data.problem_count} 道题${verb} ${res.data.tag_count} 个标签`, `已为 ${res.data.problemCount} 道题${verb} ${res.data.tagCount} 个标签`,
) )
close() close()
emit("done") emit("done")
@@ -96,7 +96,7 @@ watch(
:checked="selectedSet.has(tag.name)" :checked="selectedSet.has(tag.name)"
@update:checked="toggleTag(tag.name)" @update:checked="toggleTag(tag.name)"
> >
{{ tag.name }}{{ tag.problem_count }} {{ tag.name }}{{ tag.problemCount }}
</n-tag> </n-tag>
</n-flex> </n-flex>
<n-dynamic-tags v-if="action === 'add'" v-model:value="newTags" /> <n-dynamic-tags v-if="action === 'add'" v-model:value="newTags" />

View File

@@ -125,7 +125,7 @@ async function generate() {
blanks.map(async (s) => { blanks.map(async (s) => {
try { try {
const res = await generateSQLTestcase({ const res = await generateSQLTestcase({
ref_sql: refSQL.value, refSql: refSQL.value,
mode: props.mode, mode: props.mode,
}) })
s.sql = res.data.sql s.sql = res.data.sql
@@ -154,8 +154,8 @@ async function preview() {
s.stale = false s.stale = false
try { try {
const res = await previewSQLTestcase({ const res = await previewSQLTestcase({
init_sql: s.sql, initSql: s.sql,
ref_sql: refSQL.value, refSql: refSQL.value,
mode: props.mode, mode: props.mode,
}) })
s.display = res.data s.display = res.data

View File

@@ -11,13 +11,7 @@ import {
} from "utils/constants" } from "utils/constants"
import download from "utils/download" import download from "utils/download"
import { unique } from "utils/functions" import { unique } from "utils/functions"
import type { import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
BlankProblem,
LANGUAGE,
SQLConfig,
Tag,
Testcase,
} from "utils/types"
import { import {
createContestProblem, createContestProblem,
createProblem, createProblem,
@@ -62,13 +56,13 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
_id: "", _id: "",
title: "", title: "",
description: "", description: "",
input_description: "", inputDescription: "",
output_description: "", outputDescription: "",
time_limit: 1000, timeLimit: 1000,
memory_limit: 64, memoryLimit: 64,
difficulty: "Low" as "Low" | "Mid" | "High", difficulty: "Low",
visible: false, visible: false,
share_submission: false, shareSubmission: false,
tags: [], tags: [],
languages: ["Python3", "C"] as LANGUAGE[], languages: ["Python3", "C"] as LANGUAGE[],
template: {} as { [key in LANGUAGE]?: string }, template: {} as { [key in LANGUAGE]?: string },
@@ -77,20 +71,20 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
{ input: "", output: "" }, { input: "", output: "" },
{ input: "", output: "" }, { input: "", output: "" },
], ],
test_case_id: "", testCaseId: "",
test_case_score: [] as Testcase[], testCaseScore: [] as Testcase[],
hint: "", hint: "",
source: "", source: "",
prompt: "", prompt: "",
answers: [] as { language: LANGUAGE; code: string }[], answers: [] as { language: LANGUAGE; code: string }[],
contest_id: "", contestId: null,
allow_flowchart: false, allowFlowchart: false,
mermaid_code: "", showFlowchart: false,
flowchart_data: {}, mermaidCode: "",
flowchart_hint: "", flowchartHint: "",
show_flowchart: false, astRules: null,
ast_rules: null as { [key: string]: any[] } | null, sqlConfig: null,
sql_config: null as SQLConfig | null, sqlDisplay: null,
}) })
// 从服务器来的tag列表 // 从服务器来的tag列表
@@ -189,19 +183,19 @@ watch(
return return
} }
needTemplate.value = false needTemplate.value = false
if (!problem.value.sql_config) { if (!problem.value.sqlConfig) {
problem.value.sql_config = { mode: "query", order_sensitive: false } problem.value.sqlConfig = { mode: "query", order_sensitive: false }
} }
currentActiveAnswer.value = "SQL" currentActiveAnswer.value = "SQL"
// 代码规则检查基于 Python/C 的 AST 解析,对 SQL 没有意义,清空避免脏数据 // 代码规则检查基于 Python/C 的 AST 解析,对 SQL 没有意义,清空避免脏数据
if (problem.value.ast_rules) { if (problem.value.astRules) {
problem.value.ast_rules = null problem.value.astRules = null
} }
// 流程图依赖 Python 答案生成,对 SQL 没有意义 // 流程图依赖 Python 答案生成,对 SQL 没有意义
problem.value.allow_flowchart = false problem.value.allowFlowchart = false
problem.value.show_flowchart = false problem.value.showFlowchart = false
} else if (problem.value.sql_config) { } else if (problem.value.sqlConfig) {
problem.value.sql_config = null problem.value.sqlConfig = null
} }
}, },
{ immediate: true }, { immediate: true },
@@ -219,32 +213,31 @@ async function getProblemDetail() {
problem.value._id = data._id problem.value._id = data._id
problem.value.title = data.title problem.value.title = data.title
problem.value.description = data.description problem.value.description = data.description
problem.value.input_description = data.input_description problem.value.inputDescription = data.inputDescription
problem.value.output_description = data.output_description problem.value.outputDescription = data.outputDescription
problem.value.time_limit = data.time_limit problem.value.timeLimit = data.timeLimit
problem.value.memory_limit = data.memory_limit problem.value.memoryLimit = data.memoryLimit
problem.value.memory_limit = data.memory_limit problem.value.memoryLimit = data.memoryLimit
problem.value.difficulty = data.difficulty problem.value.difficulty = data.difficulty
problem.value.visible = data.visible 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.tags = normalizeTagNames(data.tags)
problem.value.languages = data.languages problem.value.languages = data.languages
problem.value.template = data.template problem.value.template = data.template
problem.value.samples = data.samples problem.value.samples = data.samples
problem.value.samples = data.samples problem.value.samples = data.samples
problem.value.test_case_id = data.test_case_id problem.value.testCaseId = data.testCaseId
problem.value.test_case_score = data.test_case_score problem.value.testCaseScore = data.testCaseScore
problem.value.hint = data.hint problem.value.hint = data.hint ?? ""
problem.value.source = data.source problem.value.source = data.source
problem.value.prompt = data.prompt problem.value.prompt = data.prompt
// 流程图相关字段 // 流程图相关字段
problem.value.allow_flowchart = data.allow_flowchart problem.value.allowFlowchart = data.allowFlowchart
problem.value.show_flowchart = data.show_flowchart problem.value.showFlowchart = data.showFlowchart
problem.value.mermaid_code = data.mermaid_code ?? "" problem.value.mermaidCode = data.mermaidCode ?? ""
problem.value.flowchart_hint = data.flowchart_hint ?? "" problem.value.flowchartHint = data.flowchartHint ?? ""
problem.value.flowchart_data = data.flowchart_data problem.value.astRules = data.astRules ?? null
problem.value.ast_rules = data.ast_rules ?? null problem.value.sqlConfig = data.sqlConfig ?? null
problem.value.sql_config = data.sql_config ?? null
if (data.answers && data.answers.length) { if (data.answers && data.answers.length) {
problem.value.answers = data.answers problem.value.answers = data.answers
} else { } else {
@@ -253,8 +246,8 @@ async function getProblemDetail() {
code: "", code: "",
})) }))
} }
if (problem.value.contest_id) { if (problem.value.contestId) {
problem.value.contest_id = problem.value.contest_id problem.value.contestId = problem.value.contestId
} }
// 下面是用来显示的: // 下面是用来显示的:
@@ -305,8 +298,8 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
for (let file of testcases) { for (let file of testcases) {
file.score = (100 / testcases.length).toFixed(0) file.score = (100 / testcases.length).toFixed(0)
} }
problem.value.test_case_score = testcases problem.value.testCaseScore = testcases
problem.value.test_case_id = res.data.id problem.value.testCaseId = res.data.id
} catch (err) { } catch (err) {
message.error("上传测试用例失败") message.error("上传测试用例失败")
} }
@@ -338,7 +331,7 @@ async function validateProblem() {
else if ( else if (
!problem.value.description || !problem.value.description ||
(!isSQLProblem.value && (!isSQLProblem.value &&
(!problem.value.input_description || !problem.value.output_description)) (!problem.value.inputDescription || !problem.value.outputDescription))
) { ) {
message.error("题目或输入或输出没有填写") message.error("题目或输入或输出没有填写")
hasErrors = true hasErrors = true
@@ -359,7 +352,7 @@ async function validateProblem() {
hasErrors = true hasErrors = true
} }
// 测试用例 // 测试用例
else if (problem.value.test_case_score.length === 0) { else if (problem.value.testCaseScore.length === 0) {
message.error("测试用例没有上传") message.error("测试用例没有上传")
hasErrors = true hasErrors = true
} else if (problem.value.languages.length === 0) { } else if (problem.value.languages.length === 0) {
@@ -367,7 +360,7 @@ async function validateProblem() {
hasErrors = true hasErrors = true
} }
// SQL 题验证 // SQL 题验证
else if (isSQLProblem.value && !problem.value.sql_config?.mode) { else if (isSQLProblem.value && !problem.value.sqlConfig?.mode) {
message.error("SQL 题需要选择题型(查询题/增删改题)") message.error("SQL 题需要选择题型(查询题/增删改题)")
hasErrors = true hasErrors = true
} else if ( } else if (
@@ -380,11 +373,8 @@ async function validateProblem() {
hasErrors = true hasErrors = true
} }
// 流程图验证 // 流程图验证
else if (problem.value.show_flowchart || problem.value.allow_flowchart) { else if (problem.value.showFlowchart || problem.value.allowFlowchart) {
if ( if (!problem.value.mermaidCode || problem.value.mermaidCode.trim() === "") {
!problem.value.mermaid_code ||
problem.value.mermaid_code.trim() === ""
) {
message.error("启用了流程图功能,但流程图代码为空") message.error("启用了流程图功能,但流程图代码为空")
hasErrors = true hasErrors = true
} else if (!mermaidRenderSuccess.value) { } else if (!mermaidRenderSuccess.value) {
@@ -449,7 +439,7 @@ async function submit() {
route.name === "admin contest problem create" || route.name === "admin contest problem create" ||
route.name === "admin contest problem edit" route.name === "admin contest problem edit"
) { ) {
problem.value.contest_id = props.contestID problem.value.contestId = Number(props.contestID)
} }
try { try {
await api!(problem.value) await api!(problem.value)
@@ -474,7 +464,7 @@ async function submit() {
}) })
} }
} catch (err: any) { } catch (err: any) {
if (err.data === "Display ID already exists") { if (err.error === "display-id-exists") {
message.error("显示编号重复了,请换一个显示编号") message.error("显示编号重复了,请换一个显示编号")
} else { } else {
message.error(err.data) message.error(err.data)
@@ -503,7 +493,7 @@ async function generateMermaid() {
) )
isAIGenerating.value = false isAIGenerating.value = false
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法") message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
problem.value.mermaid_code = res.data.flowchart problem.value.mermaidCode = res.data.flowchart
} }
const showGeneratorModal = ref(false) const showGeneratorModal = ref(false)
@@ -512,8 +502,8 @@ function handleTestcasesGenerated(
testCaseId: string, testCaseId: string,
testCaseScore: Testcase[], testCaseScore: Testcase[],
) { ) {
problem.value.test_case_id = testCaseId problem.value.testCaseId = testCaseId
problem.value.test_case_score = testCaseScore problem.value.testCaseScore = testCaseScore
showGeneratorModal.value = false showGeneratorModal.value = false
} }
@@ -593,12 +583,12 @@ watch(
/> />
<TextEditor <TextEditor
v-if="ready && !isSQLProblem" v-if="ready && !isSQLProblem"
v-model:value="problem.input_description" v-model:value="problem.inputDescription"
title="输入的描述" title="输入的描述"
/> />
<TextEditor <TextEditor
v-if="ready && !isSQLProblem" v-if="ready && !isSQLProblem"
v-model:value="problem.output_description" v-model:value="problem.outputDescription"
title="输出的描述" title="输出的描述"
/> />
<template v-if="!isSQLProblem"> <template v-if="!isSQLProblem">
@@ -686,12 +676,12 @@ watch(
</n-form> </n-form>
<n-form <n-form
v-if="isSQLProblem && problem.sql_config" v-if="isSQLProblem && problem.sqlConfig"
inline inline
label-placement="left" label-placement="left"
> >
<n-form-item label="SQL 题型"> <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="query">查询题比对查询结果</n-radio-button>
<n-radio-button value="modify"> <n-radio-button value="modify">
增删改题比对执行后的表数据 增删改题比对执行后的表数据
@@ -699,7 +689,7 @@ watch(
</n-radio-group> </n-radio-group>
</n-form-item> </n-form-item>
<n-form-item label="严格比对行顺序"> <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"> <n-text depth="3" style="margin-left: 12px">
题目要求 ORDER BY 时开启关闭则按无序集合比对 题目要求 ORDER BY 时开启关闭则按无序集合比对
</n-text> </n-text>
@@ -766,7 +756,7 @@ watch(
<n-grid v-if="!isSQLProblem" :cols="2"> <n-grid v-if="!isSQLProblem" :cols="2">
<n-gi :span="1"> <n-gi :span="1">
<AstRulesEditor <AstRulesEditor
v-model="problem.ast_rules!" v-model="problem.astRules!"
:languages="problem.languages" :languages="problem.languages"
/> />
</n-gi> </n-gi>
@@ -802,22 +792,22 @@ watch(
<SQLTestcaseEditor <SQLTestcaseEditor
v-if="isSQLProblem" v-if="isSQLProblem"
:answers="problem.answers" :answers="problem.answers"
:mode="problem.sql_config?.mode ?? 'query'" :mode="problem.sqlConfig?.mode ?? 'query'"
:problem-id="problem.id" :problem-id="problem.id"
@uploaded="handleTestcasesGenerated" @uploaded="handleTestcasesGenerated"
/> />
<n-alert <n-alert
class="box" class="box"
v-if="problem.test_case_score.length" v-if="problem.testCaseScore.length"
:show-icon="false" :show-icon="false"
type="info" type="info"
> >
<template #header> <template #header>
<n-flex align="center"> <n-flex align="center">
<div> <div>
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有 测试组编号 {{ problem.testCaseId.slice(0, 12) }} 共有
{{ problem.test_case_score.length }} {{ problem.testCaseScore.length }}
条测试用例 条测试用例
</div> </div>
<n-button <n-button
@@ -870,23 +860,23 @@ watch(
</n-button> </n-button>
</n-form-item> </n-form-item>
<n-form-item label="允许提交流程图"> <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>
<n-form-item label="显示标准流程图"> <n-form-item label="显示标准流程图">
<n-switch v-model:value="problem.show_flowchart" /> <n-switch v-model:value="problem.showFlowchart" />
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-form> <n-form>
<n-form-item> <n-form-item>
<MermaidEditor <MermaidEditor
v-model="problem.mermaid_code" v-model="problem.mermaidCode"
@render-success="onMermaidRenderSuccess" @render-success="onMermaidRenderSuccess"
/> />
</n-form-item> </n-form-item>
<n-form-item label="流程图提示信息(选填)"> <n-form-item label="流程图提示信息(选填)">
<n-input <n-input
v-model:value="problem.flowchart_hint" v-model:value="problem.flowchartHint"
placeholder="请输入流程图相关的提示信息,帮助学生理解题目要求" placeholder="请输入流程图相关的提示信息,帮助学生理解题目要求"
/> />
</n-form-item> </n-form-item>

View File

@@ -113,20 +113,20 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
width: 80, width: 80,
render: (row) => render: (row) =>
h(NFlex, { size: 4, align: "center" }, () => [ h(NFlex, { size: 4, align: "center" }, () => [
row.allow_flowchart row.allowFlowchart
? h(Icon, { ? h(Icon, {
width: 18, width: 18,
icon: "vscode-icons:file-type-drawio", icon: "vscode-icons:file-type-drawio",
title: "绘图", title: "绘图",
}) })
: row.show_flowchart : row.showFlowchart
? h(Icon, { ? h(Icon, {
width: 18, width: 18,
icon: "vscode-icons:file-type-graphql", icon: "vscode-icons:file-type-graphql",
title: "流程图", title: "流程图",
}) })
: null, : null,
row.has_ast_rules row.hasAstRules
? h(Icon, { ? h(Icon, {
width: 18, width: 18,
icon: "vscode-icons:file-type-light-todo", icon: "vscode-icons:file-type-light-todo",
@@ -140,7 +140,7 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
key: "top_reaction", key: "top_reaction",
width: 60, width: 60,
render: (row) => { render: (row) => {
const top = row.top_reaction const top = row.topReaction
if (!top) return null if (!top) return null
const reaction = REACTIONS.find((it) => it.key === top.type) const reaction = REACTIONS.find((it) => it.key === top.type)
if (!reaction) return null if (!reaction) return null
@@ -155,7 +155,7 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
title: "创建时间", title: "创建时间",
key: "create_time", key: "create_time",
width: 200, 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: "可见", title: "可见",

View File

@@ -57,7 +57,7 @@ const columns: DataTableColumn<AdminTag>[] = [
h( h(
NButton, NButton,
{ text: true, type: "primary", onClick: () => openTagProblems(row) }, { 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) const res = await renameTag(tag.id, name)
if (res.data.merged) { if (res.data.merged) {
message.success( message.success(
`已合并到「${res.data.name}」,影响 ${res.data.affected_count} 道题`, `已合并到「${res.data.name}」,影响 ${res.data.affectedCount} 道题`,
) )
} else { } else {
message.success("已重命名") message.success("已重命名")
@@ -135,7 +135,7 @@ async function saveTag(tag: AdminTag) {
function confirmDelete(tag: AdminTag) { function confirmDelete(tag: AdminTag) {
dialog.warning({ dialog.warning({
title: "删除标签", title: "删除标签",
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problem_count} 道题在使用它,删除后这些题目会失去该标签。`, content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problemCount} 道题在使用它,删除后这些题目会失去该标签。`,
positiveText: "删除", positiveText: "删除",
negativeText: "取消", negativeText: "取消",
onPositiveClick: async () => { onPositiveClick: async () => {

View File

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

View File

@@ -48,16 +48,16 @@ defineEmits<Emits>()
problem_count: '完成指定数量题目', problem_count: '完成指定数量题目',
score: '达到指定分数', score: '达到指定分数',
} }
return typeMap[row.condition_type] || row.condition_type return typeMap[row.conditionType] || row.conditionType
}, },
}, },
{ {
title: '条件值', title: '条件值',
key: 'condition_value', key: 'condition_value',
render: (row) => { render: (row) => {
return row.condition_type === 'all_problems' return row.conditionType === 'all_problems'
? '-' ? '-'
: row.condition_value : row.conditionValue
}, },
}, },
{ title: '描述', key: 'description' }, { title: '描述', key: 'description' },

View File

@@ -58,7 +58,7 @@ function handleConfirm() {
// 只有非"完成所有题目"时才添加条件值 // 只有非"完成所有题目"时才添加条件值
if (editBadgeConditionType.value !== "all_problems") { if (editBadgeConditionType.value !== "all_problems") {
data.condition_value = editBadgeConditionValue.value data.conditionValue = editBadgeConditionValue.value
} }
emit("confirm", data) emit("confirm", data)
@@ -76,8 +76,8 @@ watch(
editBadgeName.value = newBadge.name editBadgeName.value = newBadge.name
editBadgeDescription.value = newBadge.description editBadgeDescription.value = newBadge.description
editBadgeIcon.value = newBadge.icon editBadgeIcon.value = newBadge.icon
editBadgeConditionType.value = newBadge.condition_type editBadgeConditionType.value = newBadge.conditionType
editBadgeConditionValue.value = newBadge.condition_value editBadgeConditionValue.value = newBadge.conditionValue
} }
}, },
{ immediate: true }, { immediate: true },

View File

@@ -46,7 +46,7 @@ watch(
(newProblem) => { (newProblem) => {
if (newProblem) { if (newProblem) {
editProblemOrder.value = newProblem.order editProblemOrder.value = newProblem.order
editProblemRequired.value = newProblem.is_required editProblemRequired.value = newProblem.isRequired
editProblemScore.value = newProblem.score editProblemScore.value = newProblem.score
editProblemHint.value = newProblem.hint || "" editProblemHint.value = newProblem.hint || ""
} }

View File

@@ -34,7 +34,7 @@ defineEmits<Emits>()
title: '必做', title: '必做',
key: 'is_required', key: 'is_required',
width: 80, width: 80,
render: (row) => (row.is_required ? '是' : '否'), render: (row) => (row.isRequired ? '是' : '否'),
}, },
{ title: '分数', key: 'score', width: 80 }, { title: '分数', key: 'score', width: 80 },
{ title: '提示', key: 'hint', minWidth: 200 }, { title: '提示', key: 'hint', minWidth: 200 },

View File

@@ -16,7 +16,7 @@ defineProps<Props>()
{{ problemSet.description }} {{ problemSet.description }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="创建者"> <n-descriptions-item label="创建者">
{{ problemSet.created_by.username }} {{ problemSet.createdBy.username }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="难度"> <n-descriptions-item label="难度">
<n-tag <n-tag
@@ -60,10 +60,10 @@ defineProps<Props>()
{{ problemSet.visible ? "是" : "否" }} {{ problemSet.visible ? "是" : "否" }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="题目数量"> <n-descriptions-item label="题目数量">
{{ problemSet.problems_count }} {{ problemSet.problemsCount }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="创建时间"> <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-item>
</n-descriptions> </n-descriptions>
</n-card> </n-card>

View File

@@ -23,7 +23,7 @@ const progressColumns = [
key: "join_time", key: "join_time",
width: 180, width: 180,
render: (row: ProblemSetProgress) => 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: "completed_problems_count", width: 100 },
{ title: "总题目", key: "total_problems_count", width: 100 }, { title: "总题目", key: "total_problems_count", width: 100 },
@@ -32,13 +32,13 @@ const progressColumns = [
key: "progress_percentage", key: "progress_percentage",
width: 100, width: 100,
render: (row: ProblemSetProgress) => render: (row: ProblemSetProgress) =>
`${row.progress_percentage.toFixed(0)}%`, `${row.progressPercentage.toFixed(0)}%`,
}, },
{ {
title: "是否完成", title: "是否完成",
key: "is_completed", key: "is_completed",
width: 100, width: 100,
render: (row: ProblemSetProgress) => (row.is_completed ? "是" : "否"), render: (row: ProblemSetProgress) => (row.isCompleted ? "是" : "否"),
}, },
{ {
title: "操作", title: "操作",

View File

@@ -15,16 +15,14 @@ const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
difficulty: "Easy", difficulty: "Easy",
status: "draft", status: "draft",
visible: false, visible: false,
end_time: null, endTime: null,
}) })
const endTimeTimestamp = computed({ const endTimeTimestamp = computed({
get: () => get: () =>
formData.value.end_time formData.value.endTime ? new Date(formData.value.endTime).getTime() : null,
? new Date(formData.value.end_time).getTime()
: null,
set: (val: number | 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, difficulty: data.difficulty,
status: data.status, status: data.status,
visible: data.visible, 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) { } catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误")) message.error("加载题单详情失败:" + (err.data || "未知错误"))

View File

@@ -2,13 +2,13 @@
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { ProblemSetList } from "utils/types" import type { ProblemSet } from "utils/types"
import { getProblemSetList, toggleProblemSetVisible } from "../api" import { getProblemSetList, toggleProblemSetVisible } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
import { NTag, NSwitch } from "naive-ui" import { NTag, NSwitch } from "naive-ui"
const total = ref(0) const total = ref(0)
const problemSets = ref<ProblemSetList[]>([]) const problemSets = ref<ProblemSet[]>([])
interface ProblemSetQuery { interface ProblemSetQuery {
keyword: string keyword: string
@@ -37,7 +37,7 @@ const statusOptions = [
{ label: "草稿", value: "draft" }, { label: "草稿", value: "draft" },
] ]
const columns: DataTableColumn<ProblemSetList>[] = [ const columns: DataTableColumn<ProblemSet>[] = [
{ title: "ID", key: "id", width: 80 }, { title: "ID", key: "id", width: 80 },
{ title: "标题", key: "title", minWidth: 200 }, { title: "标题", key: "title", minWidth: 200 },
{ title: "描述", key: "description", minWidth: 300, ellipsis: true }, { title: "描述", key: "description", minWidth: 300, ellipsis: true },
@@ -45,7 +45,7 @@ const columns: DataTableColumn<ProblemSetList>[] = [
title: "创建者", title: "创建者",
key: "created_by", key: "created_by",
width: 120, width: 120,
render: (row) => row.created_by.username, render: (row) => row.createdBy.username,
}, },
{ {
title: "难度", title: "难度",
@@ -87,7 +87,7 @@ const columns: DataTableColumn<ProblemSetList>[] = [
title: "创建时间", title: "创建时间",
key: "create_time", key: "create_time",
width: 180, 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: "可见", title: "可见",

View File

@@ -94,7 +94,7 @@ const serverColumns: DataTableColumn<Server>[] = [
{ {
title: "内存占用", title: "内存占用",
key: "memory_usage", key: "memory_usage",
render: (row) => row.memory_usage + "%", render: (row) => row.memoryUsage + "%",
width: 100, width: 100,
}, },
{ title: "IP", key: "ip", width: 140 }, { title: "IP", key: "ip", width: 140 },
@@ -103,13 +103,13 @@ const serverColumns: DataTableColumn<Server>[] = [
{ {
title: "上一次心跳", title: "上一次心跳",
key: "last_heartbeat", 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, width: 120,
}, },
{ {
title: "创建时间", title: "创建时间",
key: "create_time", 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, width: 120,
}, },
] ]
@@ -122,32 +122,32 @@ const abnormalServers = computed(() =>
) )
const websiteConfig = reactive({ const websiteConfig = reactive({
website_base_url: import.meta.env.PUBLIC_OJ_URL, websiteBaseUrl: import.meta.env.PUBLIC_OJ_URL,
website_name: "判题狗", websiteName: "判题狗",
website_name_shortcut: "判题狗", websiteNameShortcut: "判题狗",
website_footer: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区", websiteFooter: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
allow_register: true, allowRegister: true,
submission_list_show_all: true, submissionListShowAll: true,
class_list: [], classList: [],
enable_maxkb: true, enableMaxkb: true,
}) })
async function getWebsiteConfig() { async function getWebsiteConfig() {
const res = await getWebsite() const res = await getWebsite()
websiteConfig.website_base_url = res.data.website_base_url websiteConfig.websiteBaseUrl = res.data.websiteBaseUrl
websiteConfig.website_name = res.data.website_name websiteConfig.websiteName = res.data.websiteName
websiteConfig.website_name_shortcut = res.data.website_name_shortcut websiteConfig.websiteNameShortcut = res.data.websiteNameShortcut
websiteConfig.website_footer = res.data.website_footer websiteConfig.websiteFooter = res.data.websiteFooter
websiteConfig.allow_register = res.data.allow_register websiteConfig.allowRegister = res.data.allowRegister
websiteConfig.submission_list_show_all = res.data.submission_list_show_all websiteConfig.submissionListShowAll = res.data.submissionListShowAll
websiteConfig.class_list = res.data.class_list websiteConfig.classList = res.data.classList
websiteConfig.enable_maxkb = res.data.enable_maxkb websiteConfig.enableMaxkb = res.data.enableMaxkb
} }
async function saveWebsiteConfig() { async function saveWebsiteConfig() {
// 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。 // 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。
// 后端 CreateEditWebsiteConfigSerializer 也会拦,这里先报更明确的错 // 后端 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) { if (invalid.length) {
message.error( message.error(
`班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`, `班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
@@ -165,11 +165,8 @@ async function saveWebsiteConfig() {
configStore.getConfig() configStore.getConfig()
// 通过 WebSocket 广播配置变化,实现实时切换 // 通过 WebSocket 广播配置变化,实现实时切换
updateConfig("enable_maxkb", websiteConfig.enable_maxkb) updateConfig("enable_maxkb", websiteConfig.enableMaxkb)
updateConfig( updateConfig("submission_list_show_all", websiteConfig.submissionListShowAll)
"submission_list_show_all",
websiteConfig.submission_list_show_all,
)
} }
async function deleteTestcase(id?: string) { async function deleteTestcase(id?: string) {
@@ -222,19 +219,19 @@ onMounted(() => {
</template> </template>
<n-form inline label-placement="left"> <n-form inline label-placement="left">
<n-form-item label="网站 URL"> <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>
<n-form-item label="网站名"> <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>
<n-form-item label="网站简称"> <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-item>
</n-form> </n-form>
<n-form label-placement="left"> <n-form label-placement="left">
<n-form-item label="班级列表"> <n-form-item label="班级列表">
<n-flex vertical size="small"> <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"> <n-text depth="3" style="font-size: 12px">
{{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }} {{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
位数字 2512510要和用户名里 ks 后面那段一致 位数字 2512510要和用户名里 ks 后面那段一致
@@ -245,15 +242,15 @@ onMounted(() => {
<n-flex align="center"> <n-flex align="center">
<n-flex align="center"> <n-flex align="center">
<span>是否允许注册</span> <span>是否允许注册</span>
<n-switch v-model:value="websiteConfig.allow_register" /> <n-switch v-model:value="websiteConfig.allowRegister" />
</n-flex> </n-flex>
<n-flex align="center"> <n-flex align="center">
<span>显示所有提交</span> <span>显示所有提交</span>
<n-switch v-model:value="websiteConfig.submission_list_show_all" /> <n-switch v-model:value="websiteConfig.submissionListShowAll" />
</n-flex> </n-flex>
<n-flex align="center"> <n-flex align="center">
<span>启用AI小助手</span> <span>启用AI小助手</span>
<n-switch v-model:value="websiteConfig.enable_maxkb" /> <n-switch v-model:value="websiteConfig.enableMaxkb" />
</n-flex> </n-flex>
</n-flex> </n-flex>
</n-card> </n-card>

View File

@@ -59,15 +59,15 @@ const columns: DataTableColumn<Rank>[] = [
title: "正确率", title: "正确率",
key: "rate", key: "rate",
width: 100, width: 100,
render: (row) => getACRate(row.accepted_number, row.submission_number), render: (row) => getACRate(row.acceptedNumber, row.submissionNumber),
}, },
] ]
onMounted(async () => { onMounted(async () => {
const res = await getBaseInfo() const res = await getBaseInfo()
userCount.value = res.data.user_count userCount.value = res.data.userCount
submissionCount.value = res.data.today_submission_count submissionCount.value = res.data.todaySubmissionCount
contestCount.value = res.data.recent_contest_count contestCount.value = res.data.recentContestCount
}) })
async function listRanks() { 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 { return {
id: result.id, id: result.id,
_id: result._id, _id: result._id,
title: result.title, title: result.title,
username: result.created_by.username, username: result.createdBy.username,
create_time: result.create_time, createTime: result.createTime,
visible: result.visible, visible: result.visible,
difficulty: result.difficulty, difficulty: result.difficulty,
tags: result.tags, tags: result.tags,
has_ast_rules: result.has_ast_rules, hasAstRules: result.hasAstRules,
allow_flowchart: result.allow_flowchart, allowFlowchart: result.allowFlowchart,
show_flowchart: result.show_flowchart, 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("练习题已更新") message.success("练习题已更新")
} else { } else {
await createExercise({ await createExercise({
tutorial_id: props.tutorialId, tutorialId: props.tutorialId,
type: formType.value, type: formType.value,
data, data,
order: formOrder.value, order: formOrder.value,

View File

@@ -19,7 +19,7 @@ const tutorial = reactive<Tutorial>({
title: "", title: "",
content: "", content: "",
code: "", code: "",
is_public: false, isPublic: false,
order: 0, order: 0,
type: "python", // 默认选择 Python type: "python", // 默认选择 Python
}) })
@@ -39,7 +39,7 @@ async function init() {
tutorial.title = data.title tutorial.title = data.title
tutorial.content = data.content tutorial.content = data.content
tutorial.code = data.code || "" tutorial.code = data.code || ""
tutorial.is_public = data.is_public tutorial.isPublic = data.isPublic
tutorial.order = data.order tutorial.order = data.order
tutorial.type = data.type || "python" tutorial.type = data.type || "python"
} }
@@ -55,7 +55,7 @@ async function submit() {
title: tutorial.title, title: tutorial.title,
content: tutorial.content, content: tutorial.content,
code: tutorial.code, code: tutorial.code,
is_public: tutorial.is_public, isPublic: tutorial.isPublic,
order: tutorial.order, order: tutorial.order,
type: tutorial.type, type: tutorial.type,
}) })
@@ -94,7 +94,7 @@ onMounted(init)
/> />
</n-form-item> </n-form-item>
<n-form-item label="可见"> <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-form-item> <n-form-item>
<n-button type="primary" @click="submit">保存</n-button> <n-button type="primary" @click="submit">保存</n-button>

View File

@@ -1,18 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { NSwitch } from "naive-ui" import { NSwitch } from "naive-ui"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { Tutorial } from "utils/types" import type { TutorialListItem } from "utils/types"
import { getTutorialList, setTutorialVisibility } from "../api" import { getTutorialList, setTutorialVisibility } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
const tutorials = ref<{ [key: string]: Tutorial[] }>({ const tutorials = ref<{ [key: string]: TutorialListItem[] }>({
python: [], python: [],
c: [], c: [],
}) })
const message = useMessage() const message = useMessage()
const activeTab = ref("python") const activeTab = ref("python")
const columns: DataTableColumn<Tutorial>[] = [ const columns: DataTableColumn<TutorialListItem>[] = [
{ {
title: "顺序", title: "顺序",
key: "order", key: "order",
@@ -21,29 +21,29 @@ const columns: DataTableColumn<Tutorial>[] = [
{ title: "标题", key: "title", minWidth: 200 }, { title: "标题", key: "title", minWidth: 200 },
{ {
title: "作者", title: "作者",
key: "created_by", key: "createdBy",
render: (row) => row.created_by?.username, render: (row) => row.createdBy?.username,
width: 80, width: 80,
}, },
{ {
title: "创建时间", title: "创建时间",
key: "created_at", key: "createdAt",
width: 180, 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: "更新时间", title: "更新时间",
key: "updated_at", key: "updatedAt",
width: 180, 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: "可见", title: "可见",
key: "is_public", key: "isPublic",
width: 100, width: 100,
render: (row) => render: (row) =>
h(NSwitch, { h(NSwitch, {
value: row.is_public, value: row.isPublic,
size: "small", size: "small",
rubberBand: false, rubberBand: false,
onUpdateValue: () => toggleVisible(row), onUpdateValue: () => toggleVisible(row),
@@ -58,14 +58,14 @@ const columns: DataTableColumn<Tutorial>[] = [
}, },
] ]
async function toggleVisible(tutorial: Tutorial) { async function toggleVisible(tutorial: TutorialListItem) {
tutorial.is_public = !tutorial.is_public tutorial.isPublic = !tutorial.isPublic
try { try {
await setTutorialVisibility(tutorial.id, tutorial.is_public) await setTutorialVisibility(tutorial.id, tutorial.isPublic)
message.success("更新成功") message.success("更新成功")
} catch (err: any) { } catch (err: any) {
message.error(err.data) 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() { async function banUser() {
props.user.is_disabled = !props.user.is_disabled props.user.isDisabled = !props.user.isDisabled
await editUser(props.user) await editUser(props.user)
emit("userBanned", props.user) emit("userBanned", props.user)
} }
@@ -40,10 +40,10 @@ async function banUser() {
<n-button <n-button
size="small" size="small"
secondary secondary
:type="props.user.is_disabled ? 'info' : 'error'" :type="props.user.isDisabled ? 'info' : 'error'"
@click="banUser" @click="banUser"
> >
{{ props.user.is_disabled ? "解封" : "封号" }} {{ props.user.isDisabled ? "解封" : "封号" }}
</n-button> </n-button>
<n-popconfirm @positive-click="$emit('deleteUser', [props.user.id])"> <n-popconfirm @positive-click="$emit('deleteUser', [props.user.id])">
<template #trigger> <template #trigger>

View File

@@ -9,30 +9,30 @@ interface Props {
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const isNotRegularUser = computed( const isNotRegularUser = computed(
() => props.user.admin_type !== USER_TYPE.REGULAR_USER, () => props.user.adminType !== USER_TYPE.REGULAR_USER,
) )
</script> </script>
<template> <template>
<n-flex align="center"> <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>
<n-tag <n-tag
v-if="isNotRegularUser" v-if="isNotRegularUser"
:type="getUserRole(props.user.admin_type).type" :type="getUserRole(props.user.adminType).type"
size="small" size="small"
> >
{{ getUserRole(props.user.admin_type).label }} {{ getUserRole(props.user.adminType).label }}
</n-tag> </n-tag>
<n-tag <n-tag
size="small" size="small"
v-if=" v-if="
props.user.admin_type === USER_TYPE.STUDENT_ADMIN || props.user.adminType === USER_TYPE.STUDENT_ADMIN ||
props.user.admin_type === USER_TYPE.TEACHER_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: "密码", title: "密码",
key: "raw_password", key: "raw_password",
width: 100, width: 100,
render: (row) => h(TextCopy, () => row.raw_password), render: (row) => h(TextCopy, () => row.rawPassword),
}, },
{ {
title: "创建时间", title: "创建时间",
key: "create_time", key: "create_time",
width: 200, 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: "上次登录", title: "上次登录",
key: "last_login", key: "last_login",
width: 200, width: 200,
render: (row) => render: (row) =>
row.last_login row.lastLogin
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss") ? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss")
: "从未登录", : "从未登录",
}, },
{ {
title: "真名", title: "真名",
key: "real_name", key: "real_name",
width: 100, width: 100,
render: (row) => h(TextCopy, () => row.real_name), render: (row) => h(TextCopy, () => row.realName),
}, },
{ title: "邮箱", key: "email", width: 200 }, { title: "邮箱", key: "email", width: 200 },
{ {
@@ -145,8 +146,8 @@ async function onResetPassword(user: User) {
const res = await resetPassword(user.id) const res = await resetPassword(user.id)
message.success(`${user.username}】的密码已重置成【${res.data}`) message.success(`${user.username}】的密码已重置成【${res.data}`)
users.value = users.value.map((it) => { users.value = users.value.map((it) => {
if (it.id === user.id && user.admin_type === USER_TYPE.REGULAR_USER) { if (it.id === user.id && user.adminType === USER_TYPE.REGULAR_USER) {
it.raw_password = res.data it.rawPassword = res.data
} }
return it return it
}) })
@@ -155,7 +156,7 @@ async function onResetPassword(user: User) {
async function onUserBanned(user: User) { async function onUserBanned(user: User) {
users.value = users.value.map((it) => { users.value = users.value.map((it) => {
if (it.id === user.id) { if (it.id === user.id) {
it.is_disabled = user.is_disabled it.isDisabled = user.isDisabled
} }
return it return it
}) })
@@ -166,14 +167,16 @@ function createNewUser() {
userEditing.value = { userEditing.value = {
id: 0, id: 0,
username: "", username: "",
real_name: "", realName: "",
email: "", email: "",
admin_type: "Student Admin", adminType: "Student Admin",
problem_permission: "None", problemPermission: "None",
create_time: new Date(), createTime: null,
last_login: new Date(), lastLogin: null,
open_api: false, openApi: false,
is_disabled: false, isDisabled: false,
rawPassword: null,
className: null,
password: "", password: "",
} }
password.value = "" password.value = ""
@@ -204,8 +207,8 @@ async function handleEditUser() {
[ [
userEditing.value.username, userEditing.value.username,
password.value, password.value,
userEditing.value.email, userEditing.value.email ?? "",
userEditing.value.real_name, userEditing.value.realName ?? "",
], ],
] ]
await importUsers(newUser) 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-input v-model:value="userEditing.username" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :span="1" label="真名"> <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>
<n-form-item-gi v-if="!create" :span="1" label="班级"> <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>
<n-form-item-gi :span="1" label="邮箱"> <n-form-item-gi :span="1" label="邮箱">
<n-input v-model:value="userEditing.email" /> <n-input v-model:value="userEditing.email" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi v-if="!create" :span="1" label="类型"> <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>
<n-form-item-gi <n-form-item-gi
:span="1" :span="1"
@@ -324,20 +327,20 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<n-form-item-gi <n-form-item-gi
v-if=" v-if="
!create && !create &&
(userEditing.admin_type === USER_TYPE.STUDENT_ADMIN || (userEditing.adminType === USER_TYPE.STUDENT_ADMIN ||
userEditing.admin_type === USER_TYPE.TEACHER_ADMIN) userEditing.adminType === USER_TYPE.TEACHER_ADMIN)
" "
:span="1" :span="1"
label="出题权限" label="出题权限"
> >
<n-select <n-select
v-model:value="userEditing.problem_permission" v-model:value="userEditing.problemPermission"
:options="problemPermissionOptions" :options="problemPermissionOptions"
/> />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi v-if="!create" :span="1" label="是否封禁"> <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-form-item-gi>
</n-grid> </n-grid>
<n-flex justify="end"> <n-flex justify="end">

View File

@@ -1,26 +1,14 @@
import api2 from "utils/api2" import api2 from "utils/api2"
import type { import type {
Achievement, AchievementList,
AchievementSummary, AchievementSummary,
PendingAchievement, PendingAchievement,
} from "utils/types" } from "utils/types"
export function getAchievements(name?: string) { export function getAchievements(name?: string) {
return api2 return api2.get<AchievementList>("achievements", {
.get<any>("achievements", { params: name ? { username: name } : {} }) params: name ? { username: name } : {},
.then((response) => ({ })
...response,
data: {
username: response.data.username,
achievements: response.data.achievements.map(
(item: any): Achievement => ({
...item,
unlock_time: item.unlockTime,
unlock_rate: item.unlockRate,
}),
),
},
}))
} }
export function getAchievementSummary(name?: string) { export function getAchievementSummary(name?: string) {

View File

@@ -17,7 +17,7 @@ const masked = computed(
// 获得率低于 5% 的加稀有闪光边框 // 获得率低于 5% 的加稀有闪光边框
const isRare = computed( const isRare = computed(
() => props.achievement.unlock_rate > 0 && props.achievement.unlock_rate < 5, () => props.achievement.unlockRate > 0 && props.achievement.unlockRate < 5,
) )
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符) // 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
@@ -45,10 +45,10 @@ const percent = computed(() => {
}) })
const unlockDate = computed(() => { const unlockDate = computed(() => {
const { unlock_time, backfilled } = props.achievement const { unlockTime, backfilled } = props.achievement
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳 // 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
if (backfilled || !unlock_time) return "已获得" if (backfilled || !unlockTime) return "已获得"
return `${new Date(unlock_time).toLocaleDateString()} 获得` return `${new Date(unlockTime).toLocaleDateString()} 获得`
}) })
</script> </script>
@@ -86,7 +86,7 @@ const unlockDate = computed(() => {
<template v-if="achievement.unlocked"> <template v-if="achievement.unlocked">
<n-text depth="3" class="nowrap">{{ unlockDate }}</n-text> <n-text depth="3" class="nowrap">{{ unlockDate }}</n-text>
<n-text depth="3" class="nowrap"> <n-text depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得 {{ achievement.unlockRate }}% 的人获得
</n-text> </n-text>
</template> </template>
@@ -113,7 +113,7 @@ const unlockDate = computed(() => {
</template> </template>
<n-text v-else depth="3" class="nowrap"> <n-text v-else depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得 {{ achievement.unlockRate }}% 的人获得
</n-text> </n-text>
</n-flex> </n-flex>
</n-thing> </n-thing>

View File

@@ -7,25 +7,10 @@ import type {
Achievement, Achievement,
AchievementRarity, AchievementRarity,
AchievementSummary, AchievementSummary,
UserBadge,
} from "utils/types" } from "utils/types"
import AchievementCard from "./components/AchievementCard.vue" import AchievementCard from "./components/AchievementCard.vue"
interface UserBadge {
id: number
earned_time: string
badge: {
id: number
name: string
description: string
icon: string
}
// 奖章来自哪个题单,接口在 UserBadgeSerializer 里带出来
problemset: {
id: number
title: string
} | null
}
const route = useRoute() const route = useRoute()
const name = computed(() => (route.query.name as string) || undefined) const name = computed(() => (route.query.name as string) || undefined)
@@ -72,7 +57,7 @@ async function load() {
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里 // http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
achievements.value = list.data.achievements achievements.value = list.data.achievements
summary.value = sum.data summary.value = sum.data
badges.value = (badgeRes.data ?? []) as UserBadge[] badges.value = badgeRes.data ?? []
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -83,6 +83,7 @@ const data = computed(() => {
// 根据等级返回对应的颜色 // 根据等级返回对应的颜色
function getGradeColor(grade: Grade): string { function getGradeColor(grade: Grade): string {
const colors: { [key in Grade]: string } = { const colors: { [key in Grade]: string } = {
"": "#C9CDD4", // 无评级:后端在没有可用数据时下发空串
S: "#FF6384", S: "#FF6384",
A: "#FFCE56", A: "#FFCE56",
B: "#36A2EB", B: "#36A2EB",

View File

@@ -73,14 +73,14 @@ const data = computed<ChartData<"bar" | "line">>(() => {
{ {
type: "bar", type: "bar",
label: "完成题目数", label: "完成题目数",
data: aiStore.durationData.map((duration) => duration.problem_count), data: aiStore.durationData.map((duration) => duration.problemCount),
yAxisID: "y", yAxisID: "y",
order: 2, order: 2,
}, },
{ {
type: "bar", type: "bar",
label: "总提交次数", label: "总提交次数",
data: aiStore.durationData.map((duration) => duration.submission_count), data: aiStore.durationData.map((duration) => duration.submissionCount),
yAxisID: "y", yAxisID: "y",
order: 2, order: 2,
}, },

View File

@@ -59,8 +59,8 @@ const show = computed(() => {
// 计算提交效率数据 // 计算提交效率数据
const efficiencyData = computed(() => { const efficiencyData = computed(() => {
return aiStore.durationData.map((duration) => { return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0 const problemCount = duration.problemCount || 0
const submissionCount = duration.submission_count || 0 const submissionCount = duration.submissionCount || 0
// 计算效率:提交次数/完成题目数 // 计算效率:提交次数/完成题目数
// 值越接近1说明一次AC率越高 // 值越接近1说明一次AC率越高

View File

@@ -28,8 +28,10 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Grade } from "utils/types"
const props = defineProps<{ const props = defineProps<{
grade: "S" | "A" | "B" | "C" // 空串是「无评级」,四张图都不渲染 —— 后端没有可用数据时会下发它
grade: Grade
}>() }>()
</script> </script>
<style scoped> <style scoped>

View File

@@ -8,9 +8,9 @@
<span>你一共解决 </span> <span>你一共解决 </span>
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b> <b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
<span> 道题</span> <span> 道题</span>
<span v-if="aiStore.detailsData.contest_count > 0"> <span v-if="aiStore.detailsData.contestCount > 0">
并且参加 并且参加
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b> <b class="charming"> {{ aiStore.detailsData.contestCount }} </b>
次比赛 次比赛
</span> </span>
<span>综合评价给到</span> <span>综合评价给到</span>
@@ -49,6 +49,7 @@ const durationLabel = computed(() => {
const greeting = computed(() => { const greeting = computed(() => {
return { return {
"": "还没有足够的数据来评级",
S: "要不试试高难度题目?", S: "要不试试高难度题目?",
A: "你很棒,继续保持!", A: "你很棒,继续保持!",
B: "请再接再厉!", B: "请再接再厉!",

View File

@@ -44,6 +44,7 @@ const aiStore = useAIStore()
const gradeOrder = ["C", "B", "A", "S"] as const const gradeOrder = ["C", "B", "A", "S"] as const
const gradeColors: Record<Grade, string> = { const gradeColors: Record<Grade, string> = {
"": "#C9CDD4", // 无评级:后端在没有可用数据时下发空串
C: "#95F204", C: "#95F204",
B: "#36A2EB", B: "#36A2EB",
A: "#FFCE56", A: "#FFCE56",
@@ -74,7 +75,7 @@ const progressData = computed(() => {
let totalProblems = 0 // 累计题目总数 let totalProblems = 0 // 累计题目总数
return aiStore.durationData.map((duration) => { return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0 const problemCount = duration.problemCount || 0
cumulativeCount += problemCount cumulativeCount += problemCount
// 计算本期等级的权重值 // 计算本期等级的权重值

View File

@@ -38,8 +38,8 @@ const rankDistribution = computed(() => {
})) }))
aiStore.detailsData.solved.forEach((item) => { aiStore.detailsData.solved.forEach((item) => {
const rank = item.period_rank const rank = item.periodRank
const acCount = item.period_ac_count const acCount = item.periodAcCount
if (rank && acCount && acCount > 0) { if (rank && acCount && acCount > 0) {
const percentile = (rank / acCount) * 100 const percentile = (rank / acCount) * 100
@@ -52,7 +52,7 @@ const rankDistribution = computed(() => {
if (rangeIndex !== -1) { if (rangeIndex !== -1) {
distribution[rangeIndex].count++ distribution[rangeIndex].count++
distribution[rangeIndex].problems.push( distribution[rangeIndex].problems.push(
`${item.problem.display_id}: ${item.problem.title}`, `${item.problem.displayId}: ${item.problem.title}`,
) )
} }
} }

View File

@@ -58,40 +58,40 @@ const columns: DataTableColumn<SolvedProblem>[] = [
{ {
text: true, text: true,
onClick: () => { onClick: () => {
if (row.problem.contest_id) { if (row.problem.contestId) {
router.push( router.push(
"/contest/" + "/contest/" +
row.problem.contest_id + row.problem.contestId +
"/problem/" + "/problem/" +
row.problem.display_id, row.problem.displayId,
) )
} else { } else {
router.push("/problem/" + row.problem.display_id) router.push("/problem/" + row.problem.displayId)
} }
}, },
}, },
() => { () => {
if (row.problem.contest_id) { if (row.problem.contestId) {
return h(TagTitle, { problem: row.problem }) return h(TagTitle, { problem: row.problem })
} else { } else {
return row.problem.display_id + " " + row.problem.title return row.problem.displayId + " " + row.problem.title
} }
}, },
), ),
}, },
{ {
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"), title: () => (aiStore.detailsData.className ? "班级排名" : "全服排名"),
key: "rank", key: "rank",
width: 100, width: 100,
align: "center", align: "center",
render: (row) => row.rank + " / " + row.ac_count, render: (row) => row.rank + " / " + row.acCount,
}, },
{ {
title: "同期排名", title: "同期排名",
key: "period_rank", key: "period_rank",
width: 100, width: 100,
align: "center", align: "center",
render: (row) => row.period_rank + " / " + row.period_ac_count, render: (row) => row.periodRank + " / " + row.periodAcCount,
}, },
{ {
title: () => title: () =>
@@ -128,10 +128,10 @@ const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
{ {
text: true, text: true,
onClick: () => { onClick: () => {
router.push("/problem/" + row.problem__id) router.push("/problem/" + row.problemId)
}, },
}, },
() => `${row.problem__id} ${row.problem_title}`, () => `${row.problemId} ${row.problemTitle}`,
), ),
}, },
{ title: "提交次数", key: "submission_count", width: 100, align: "center" }, { title: "提交次数", key: "submission_count", width: 100, align: "center" },
@@ -140,14 +140,14 @@ const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
key: "best", key: "best",
width: 100, width: 100,
align: "center", align: "center",
render: (row) => `${row.best_score} (${row.best_grade})`, render: (row) => `${row.bestScore} (${row.bestGrade})`,
}, },
{ {
title: "最新提交时间", title: "最新提交时间",
key: "latest_submission_time", key: "latest_submission_time",
width: 200, width: 200,
align: "center", align: "center",
render: (row) => parseTime(row.latest_submission_time), render: (row) => parseTime(row.latestSubmissionTime),
}, },
{ title: "平均分", key: "avg_score", width: 100, align: "center" }, { title: "平均分", key: "avg_score", width: 100, align: "center" },
] ]

View File

@@ -2,19 +2,15 @@
<n-flex vertical align="start"> <n-flex vertical align="start">
<n-flex align="center"> <n-flex align="center">
<n-tag type="info" size="small" :bordered="false">比赛</n-tag> <n-tag type="info" size="small" :bordered="false">比赛</n-tag>
<span>{{ problem.contest_title }}</span> <span>{{ problem.contestTitle }}</span>
</n-flex> </n-flex>
<span>{{ problem.display_id }} {{ problem.title }}</span> <span>{{ problem.displayId }} {{ problem.title }}</span>
</n-flex> </n-flex>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { SolvedProblem } from "utils/types"
interface Props { interface Props {
problem: { problem: SolvedProblem["problem"]
title: string
display_id: string
contest_title: string
contest_id: number
}
} }
const props = defineProps<Props>() const props = defineProps<Props>()

View File

@@ -50,7 +50,7 @@ const activityMatrix = computed(() => {
// 统计数据 // 统计数据
aiStore.detailsData.solved.forEach((item) => { aiStore.detailsData.solved.forEach((item) => {
const date = new Date(item.ac_time) const date = new Date(item.acTime)
const weekday = date.getDay() // 0-60是周日 const weekday = date.getDay() // 0-60是周日
const hour = date.getHours() // 0-23 const hour = date.getHours() // 0-23

View File

@@ -33,15 +33,15 @@ const columns: DataTableColumn<Announcement>[] = [
render: (row) => h(NTag, () => row.tag || "公告"), render: (row) => h(NTag, () => row.tag || "公告"),
}, },
{ {
key: "create_time", key: "createTime",
title: renderTableTitle("发布时间", "fluent-emoji-flat:eight-oclock"), title: renderTableTitle("发布时间", "fluent-emoji-flat:eight-oclock"),
render: (row) => parseTime(row.create_time), render: (row) => parseTime(row.createTime),
width: 180, width: 180,
}, },
{ {
key: "username", key: "username",
title: renderTableTitle("发布人", "streamline-emojis:ghost"), title: renderTableTitle("发布人", "streamline-emojis:ghost"),
render: (row) => row.created_by.username, render: (row) => row.createdBy.username,
width: 120, width: 120,
}, },
] ]

View File

@@ -1,15 +1,46 @@
import { import {
createSubmissionResponseSchema, type AiAnalysisRecord,
type Contest as OjContest,
type ContestList,
type ActivityRankItem,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type ContestRank,
type UserRank,
type ProblemRank,
type CreateSubmissionResponse,
type ProblemAuthor,
type ProblemListItem,
type YearlyAc,
type ProblemList,
type CreateFlowchartResponse,
type FlowchartCurrent,
type FlowchartDetail,
type FlowchartList,
type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type LoginSummary,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetList,
type ProblemSetProblem,
type ProblemSetProgressList,
type UserBadge,
problemDetailSchema, problemDetailSchema,
submissionDetailSchema, submissionDetailSchema,
type FlowchartStatistics, type FlowchartStatistics,
type SubmissionStatistics, type SubmissionStatistics,
} from "@oj2/contract" } from "@oj2/contract"
import api2 from "utils/api2" import api2 from "utils/api2"
import { legacyResponse, toLegacy } from "utils/legacy"
import type { ApiResponse } from "utils/http"
import { filterResult } from "oj/transforms" import { filterResult } from "oj/transforms"
import type { import type {
Announcement,
Profile,
Message,
SubmissionListItem,
Exercise, Exercise,
Problem, Problem,
ReactionKey, ReactionKey,
@@ -18,97 +49,39 @@ import type {
SubmissionListPayload, SubmissionListPayload,
SubmitCodePayload, SubmitCodePayload,
WebsiteConfig, WebsiteConfig,
Tutorial,
} from "utils/types" } from "utils/types"
function listProblem(value: any): Problem { /**
return { * 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
id: value.id, * 键名映射,抄漏一个字段就是静默 undefined。
_id: value._id, */
title: value.title,
difficulty: value.difficulty,
submission_number: value.submissionNumber,
accepted_number: value.acceptedNumber,
created_by: toLegacy(value.createdBy),
tags: value.tags,
contest: value.contestId,
allow_flowchart: value.allowFlowchart,
show_flowchart: value.showFlowchart,
has_ast_rules: value.hasAstRules,
my_status: value.myStatus,
} as Problem
}
function detailProblem(value: unknown): Problem { function detailProblem(value: unknown): Problem {
const problem = problemDetailSchema.parse(value) return problemDetailSchema.parse(value) as Problem
return {
id: problem.id,
_id: problem._id,
title: problem.title,
description: problem.description,
input_description: problem.inputDescription,
output_description: problem.outputDescription,
samples: problem.samples,
hint: problem.hint ?? "",
languages: problem.languages,
template: problem.template,
create_time: problem.createTime,
last_update_time: problem.lastUpdateTime,
time_limit: problem.timeLimit,
memory_limit: problem.memoryLimit,
difficulty: problem.difficulty,
source: problem.source ?? "",
prompt: problem.prompt ?? "",
answers: [],
submission_number: problem.submissionNumber,
accepted_number: problem.acceptedNumber,
statistic_info: problem.statisticInfo,
share_submission: problem.shareSubmission,
contest: problem.contestId,
tags: problem.tags,
created_by: {
id: problem.createdBy.id,
username: problem.createdBy.username,
real_name: problem.createdBy.realName,
},
my_status: problem.myStatus,
my_failed_count: problem.myFailedCount,
visible: true,
allow_flowchart: problem.allowFlowchart,
show_flowchart: problem.showFlowchart,
mermaid_code: problem.mermaidCode ?? undefined,
flowchart_data: problem.flowchartData ?? undefined,
flowchart_hint: problem.flowchartHint ?? undefined,
sql_config: problem.sqlConfig as Problem["sql_config"],
sql_display: problem.sqlDisplay as Problem["sql_display"],
} as Problem
} }
export function getWebsiteConfig() { export function getWebsiteConfig() {
return legacyResponse<WebsiteConfig>(api2.get("site")) return api2.get<WebsiteConfig>("site")
} }
export async function getProblemList( export async function getProblemList(
offset = 0, offset = 0,
limit = 10, limit = 10,
searchParams: any = {}, searchParams: Record<string, unknown> = {},
) { ) {
const res = await api2.get<{ results: any[]; total: number }>("problems", { const res = await api2.get<ProblemList>("problems", {
params: { paging: true, offset, limit, ...searchParams }, params: { paging: true, offset, limit, ...searchParams },
}) })
return { return {
results: res.data.results.map(listProblem).map(filterResult), results: res.data.results.map(filterResult),
total: res.data.total, total: res.data.total,
} }
} }
export function getAuthors(all = false) { export function getAuthors(all = false) {
return legacyResponse( return api2.get<ProblemAuthor[]>("problem-authors", {
api2.get("problem-authors", { params: { all: all ? "1" : "0" },
params: { })
all: all ? "1" : "0",
},
}),
)
} }
export function getRandomProblemID() { export function getRandomProblemID() {
@@ -131,41 +104,14 @@ export async function getSubmission(id: string) {
const response = await api2.get<unknown>( const response = await api2.get<unknown>(
`submissions/${encodeURIComponent(id)}`, `submissions/${encodeURIComponent(id)}`,
) )
const submission = submissionDetailSchema.parse(response.data)
return { return {
error: null, error: null,
data: { data: submissionDetailSchema.parse(response.data) as Submission,
id: submission.id,
create_time: submission.createTime,
user_id: submission.userId,
username: submission.username,
code: submission.code,
result: submission.result,
info: submission.info,
language: submission.language,
shared: submission.shared,
show_link: submission.showLink,
statistic_info: submission.statisticInfo,
ip: submission.ip,
contest: submission.contestId,
problem: submission.problemId,
can_unshare: submission.canUnshare,
} as Submission,
} }
} }
export async function submitCode(data: SubmitCodePayload) { export function submitCode(data: SubmitCodePayload) {
const response = await api2.post<unknown>("submissions", { return api2.post<CreateSubmissionResponse>("submissions", data)
problemId: data.problem_id,
language: data.language,
code: data.code,
contestId: data.contest_id,
})
const created = createSubmissionResponseSchema.parse(response.data)
return {
error: null,
data: { submission_id: created.submissionId },
}
} }
export function formatCode(data: { code: string; language: string }) { export function formatCode(data: { code: string; language: string }) {
@@ -182,30 +128,23 @@ export function formatCode(data: { code: string; language: string }) {
} }
export function getSubmissions(params: Partial<SubmissionListPayload>) { export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = params.contest_id const endpoint = params.contestId
? `contests/${encodeURIComponent(params.contest_id)}/submissions` ? `contests/${encodeURIComponent(params.contestId)}/submissions`
: "submissions" : "submissions"
return legacyResponse( // 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
api2.get(endpoint, { // 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE
params: { return api2.get<{ results: SubmissionListItem[]; total: number }>(endpoint, {
...params, // contestId 走的是路径page 只有前端分页器用
problemId: params.problem_id, params: { ...params, contestId: undefined, page: undefined },
contest_id: undefined, })
problem_id: undefined,
page: undefined,
},
}),
)
} }
export function getRankOfProblem(problem_id: string) { export function getRankOfProblem(problemId: string) {
return legacyResponse( return api2.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
api2.get(`problems/${encodeURIComponent(problem_id)}/rank`),
)
} }
export function getTodaySubmissionCount(language?: string) { export function getTodaySubmissionCount(language?: string) {
return api2.get("submissions/today-count", { params: { language } }) return api2.get<number>("submissions/today-count", { params: { language } })
} }
export function adminRejudge(id: string) { export function adminRejudge(id: string) {
@@ -228,25 +167,19 @@ export function getRank(
n: number, n: number,
username?: string, username?: string,
) { ) {
return legacyResponse( return api2.get<UserRank>("rankings/users", {
api2.get("rankings/users", { params: { offset, limit, username, top: n },
params: { offset, limit, username, top: n }, })
}),
)
} }
export function getActivityRank(start: string) { export function getActivityRank(start: string) {
return api2.get("rankings/activity", { return api2.get<ActivityRankItem[]>("rankings/activity", {
params: { start }, params: { start },
}) })
} }
export function getClassRank(grade?: number | null) { export function getClassRank(grade?: number | null) {
return legacyResponse( return api2.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
api2.get("rankings/classes", {
params: { grade },
}),
)
} }
export function getUserClassRank( export function getUserClassRank(
@@ -254,9 +187,9 @@ export function getUserClassRank(
offset?: number, offset?: number,
limit?: number, limit?: number,
) { ) {
return legacyResponse( return api2.get<ClassUserRank>("me/class-rank", {
api2.get("me/class-rank", { params: { scope, offset, limit } }), params: { scope, offset, limit },
) })
} }
export function getClassPK( export function getClassPK(
@@ -264,16 +197,11 @@ export function getClassPK(
startTime?: string, startTime?: string,
endTime?: string, endTime?: string,
) { ) {
const payload: any = { return api2.post<ClassComparisonResponse>("classes/comparison", {
classNames, classNames,
} ...(startTime ? { startTime } : {}),
if (startTime) { ...(endTime ? { endTime } : {}),
payload.startTime = startTime })
}
if (endTime) {
payload.endTime = endTime
}
return legacyResponse(api2.post("classes/comparison", payload))
} }
export function getContestList(query: { export function getContestList(query: {
@@ -283,11 +211,11 @@ export function getContestList(query: {
status: string status: string
tag: string tag: string
}) { }) {
return legacyResponse(api2.get("contests", { params: query })) return api2.get<ContestList>("contests", { params: query })
} }
export function getContest(id: string) { export function getContest(id: string) {
return legacyResponse(api2.get(`contests/${encodeURIComponent(id)}`)) return api2.get<OjContest>(`contests/${encodeURIComponent(id)}`)
} }
export function getContestAccess(id: string) { export function getContestAccess(id: string) {
@@ -301,30 +229,20 @@ export function checkContestPassword(contestID: string, password: string) {
} }
export async function getContestProblems(contestID: string) { export async function getContestProblems(contestID: string) {
const res = await api2.get<any[]>( const res = await api2.get<ProblemListItem[]>(
`contests/${encodeURIComponent(contestID)}/problems`, `contests/${encodeURIComponent(contestID)}/problems`,
) )
return res.data.map(listProblem).map(filterResult) return res.data.map(filterResult)
} }
export function getContestRank( export function getContestRank(
contestID: string, contestID: string,
query: { limit: number; offset: number }, query: { limit: number; offset: number },
) { ) {
return legacyResponse<any>( return api2.get<ContestRank>(
api2.get(`contests/${encodeURIComponent(contestID)}/rank`, { `contests/${encodeURIComponent(contestID)}/rank`,
params: query, { params: query },
}), )
).then((response) => ({
...response,
data: {
...response.data,
results: response.data.results.map((item: any) => ({
...item,
contest: item.contest_id,
})),
},
}))
} }
export function uploadAvatar(file: File) { export function uploadAvatar(file: File) {
@@ -335,23 +253,18 @@ export function uploadAvatar(file: File) {
}) })
} }
export function updateProfile(data: { real_name: string; mood: string }) { export function updateProfile(data: { realName: string; mood: string }) {
return legacyResponse( return api2.put<Profile>("me/profile", data)
api2.put("me/profile", {
realName: data.real_name,
mood: data.mood,
}),
)
} }
export function getAnnouncementList(offset = 0, limit = 10) { export function getAnnouncementList(offset = 0, limit = 10) {
return legacyResponse( return api2.get<{ results: Announcement[]; total: number }>("announcements", {
api2.get("announcements", { params: { limit, offset } }), params: { limit, offset },
) })
} }
export function getAnnouncement(id: number) { export function getAnnouncement(id: number) {
return legacyResponse(api2.get(`announcements/${id}`)) return api2.get<Announcement>(`announcements/${id}`)
} }
export function createMessage(data: { export function createMessage(data: {
@@ -367,7 +280,10 @@ export function createMessage(data: {
} }
export function getMessageList(offset = 0, limit = 10) { export function getMessageList(offset = 0, limit = 10) {
return legacyResponse(api2.get("messages", { params: { limit, offset } })) // language 的收窄同 getSubmissions见那里的说明
return api2.get<{ results: Message[]; total: number }>("messages", {
params: { limit, offset },
})
} }
export function getReaction(problemID: number) { export function getReaction(problemID: number) {
@@ -388,7 +304,7 @@ export function getMetrics(userid: number) {
} }
export function getTutorial(id: number) { export function getTutorial(id: number) {
return legacyResponse(api2.get(`tutorials/${id}`)) return api2.get<Tutorial>(`tutorials/${id}`)
} }
export function getTutorials(type: "python" | "c") { export function getTutorials(type: "python" | "c") {
@@ -396,19 +312,7 @@ export function getTutorials(type: "python" | "c") {
} }
export function getAIDetailData(start: string, end: string, username?: string) { export function getAIDetailData(start: string, end: string, username?: string) {
return legacyResponse<any>( return api2.get<AiDetail>("ai/detail", { params: { start, end, username } })
api2.get("ai/detail", { params: { start, end, username } }),
).then((response) => ({
...response,
data: {
...response.data,
flowcharts:
response.data.flowcharts?.map((item: any) => ({
...item,
problem__id: item.problem_id,
})) ?? [],
},
}))
} }
export function getAIDurationData( export function getAIDurationData(
@@ -416,95 +320,68 @@ export function getAIDurationData(
duration: string, duration: string,
username?: string, username?: string,
) { ) {
return legacyResponse( return api2.get<DurationData[]>("ai/duration", {
api2.get("ai/duration", { params: { end, duration, username } }), params: { end, duration, username },
) })
} }
export function getAIHeatmapData(username?: string) { export function getAIHeatmapData(username?: string) {
return api2.get("ai/heatmap", { params: username ? { username } : {} }) return api2.get<HeatmapItem[]>("ai/heatmap", {
params: username ? { username } : {},
})
} }
export function getAILoginSummary() { export function getAILoginSummary() {
return legacyResponse(api2.get("ai/login-summary")) return api2.get<LoginSummary>("ai/login-summary")
} }
export function getAIPinnedReport() { export function getAIPinnedReport() {
return legacyResponse(api2.get("ai/pinned")) return api2.get<AiAnalysisRecord | null>("ai/pinned")
} }
// ==================== 相似题目推荐 ==================== // ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) { export function getSimilarProblems(problemId: string) {
return api2 return api2
.get<any[]>(`problems/${encodeURIComponent(problemId)}/similar`) .get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => ({ .then((response) => ({
...response, ...response,
data: response.data.map(listProblem).map(filterResult), data: response.data.map(filterResult),
})) }))
} }
export interface YearlyACData { export type { YearlyAc as YearlyACData } from "@oj2/contract"
year: number
total: number
accepted: number
ac_rate: number
}
export function getProblemYearlyAC(problemId: string) { export function getProblemYearlyAC(problemId: string) {
return legacyResponse<YearlyACData[]>( return api2.get<YearlyAc[]>(
api2.get(`problems/${encodeURIComponent(problemId)}/yearly-ac`), `problems/${encodeURIComponent(problemId)}/yearly-ac`,
) )
} }
// ==================== 流程图相关API ==================== // ==================== 流程图相关API ====================
export function submitFlowchart(data: { export function submitFlowchart(data: {
problem_id: number problemId: number
mermaid_code: string mermaidCode: string
flowchart_data: any // 这个是压缩之后的,元数据太长了 flowchartData: Record<string, unknown> // 压缩之后的,元数据太长了
}) { }) {
return legacyResponse( return api2.post<CreateFlowchartResponse>("flowcharts", data)
api2.post("flowcharts", {
problemId: data.problem_id,
mermaidCode: data.mermaid_code,
flowchartData: data.flowchart_data,
}),
)
} }
function legacyFlowchart(value: unknown) { export function getFlowchartSubmission(id: string) {
const item = toLegacy<any>(value) return api2.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
return {
...item,
user: item.user_id ?? 0,
problem: item.problem_id,
}
}
export async function getFlowchartSubmission(id: string) {
const response = await api2.get(`flowcharts/${encodeURIComponent(id)}`)
return { ...response, data: legacyFlowchart(response.data) }
} }
export function getFlowchartSubmissions(params: { export function getFlowchartSubmissions(params: {
username?: string username?: string
problem_id?: string problemId?: string
myself?: string myself?: string
offset?: number offset?: number
limit?: number limit?: number
today?: string today?: string
grade?: string grade?: string
}) { }) {
return legacyResponse<any>( return api2.get<FlowchartList>("flowcharts", { params })
api2.get("flowcharts", {
params: {
...params,
problemId: params.problem_id,
problem_id: undefined,
},
}),
)
} }
export function getFlowchartStatistics( export function getFlowchartStatistics(
@@ -518,32 +395,19 @@ export function getFlowchartStatistics(
} }
export function retryFlowchartSubmission(submissionId: string) { export function retryFlowchartSubmission(submissionId: string) {
return legacyResponse( return api2.post<{ status: string }>(
api2.post(`flowcharts/${encodeURIComponent(submissionId)}/retry`), `flowcharts/${encodeURIComponent(submissionId)}/retry`,
) )
} }
export function getCurrentProblemFlowchartSubmission(problemId: number) { export function getCurrentProblemFlowchartSubmission(problemId: number) {
return api2.get(`problems/${problemId}/flowchart/current`) return api2.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
} }
export async function getFlowchartSubmissionDetail( export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
problemId: number, return api2.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
page = 0, params: { page },
) { })
const response = await api2.get<any>(
`problems/${problemId}/flowchart/history`,
{ params: { page } },
)
return {
...response,
data: {
...response.data,
submission: response.data.submission
? legacyFlowchart(response.data.submission)
: null,
},
}
} }
// ==================== 题单相关API ==================== // ==================== 题单相关API ====================
@@ -555,64 +419,17 @@ export function getProblemSetList(
difficulty = "", difficulty = "",
status = "", status = "",
) { ) {
return legacyResponse<any>( return api2.get<ProblemSetList>("problem-sets", {
api2.get("problem-sets", { params: { offset, limit, keyword, difficulty, status },
params: { })
offset,
limit,
keyword,
difficulty,
status,
},
}),
).then(mapProblemSetResponse)
} }
export function getProblemSetDetail(id: number) { export function getProblemSetDetail(id: number) {
return legacyResponse<any>(api2.get(`problem-sets/${id}`)).then( return api2.get<ProblemSet>(`problem-sets/${id}`)
(response) => ({
...response,
data: legacyProblemSet(response.data),
}),
)
} }
function legacyBadge(value: any) { export function getProblemSetProblems(problemSetId: number) {
return { ...value, problemset: value.problemset_id } return api2.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
}
function legacyProblemSet(value: any) {
return {
...value,
badges: value.badges?.map(legacyBadge),
}
}
function mapProblemSetResponse(response: ApiResponse<any>) {
return {
...response,
data: {
...response.data,
results: response.data.results.map(legacyProblemSet),
},
}
}
export async function getProblemSetProblems(problemSetId: number) {
const response = await legacyResponse<any[]>(
api2.get(`problem-sets/${problemSetId}/problems`),
)
return {
...response,
data: response.data.map((item) => ({
...item,
problemset: item.problemset_id,
problem: {
...item.problem,
contest: item.problem.contest_id,
},
})),
}
} }
export function joinProblemSet(problemSetId: number) { export function joinProblemSet(problemSetId: number) {
@@ -624,57 +441,35 @@ export function updateProblemSetProgress(
problemId: number, problemId: number,
submissionId: string, submissionId: string,
) { ) {
return legacyResponse( return api2.put("problem-set-progress", {
api2.put("problem-set-progress", { problemSetId,
problemSetId, problemId,
problemId, submissionId,
submissionId, })
}), }
export function getUserBadges(username?: string) {
return api2.get<UserBadge[]>(
`users/${encodeURIComponent(username ?? "me")}/badges`,
) )
} }
// 获取用户徽章列表 export function getProblemSetBadges(problemSetId: number) {
export async function getUserBadges(username?: string) { return api2.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
const response = await legacyResponse<any[]>(
api2.get(`users/${encodeURIComponent(username ?? "me")}/badges`),
)
return {
...response,
data: response.data.map((item) => ({
...item,
user: item.user_id,
badge: legacyBadge(item.badge),
})),
}
} }
// 获取题单徽章列表
export async function getProblemSetBadges(problemSetId: number) {
const response = await legacyResponse<any[]>(
api2.get(`problem-sets/${problemSetId}/badges`),
)
return { ...response, data: response.data.map(legacyBadge) }
}
// 获取题单用户进度列表
export function getProblemSetUserProgress( export function getProblemSetUserProgress(
problemSetId: number, problemSetId: number,
params?: { params?: {
limit?: number limit?: number
offset?: number offset?: number
class_name?: string className?: string
completion_status?: "" | "completed" | "in_progress" | "not_started" completionStatus?: "" | "completed" | "in_progress" | "not_started"
}, },
) { ) {
return legacyResponse( return api2.get<ProblemSetProgressList>(
api2.get(`problem-sets/${problemSetId}/user-progress`, { `problem-sets/${problemSetId}/user-progress`,
params: { { params },
limit: params?.limit,
offset: params?.offset,
className: params?.class_name,
completionStatus: params?.completion_status,
},
}),
) )
} }

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ClassComparison } from "utils/types"
import { h } from "vue" import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns" import { formatISO, sub, type Duration } from "date-fns"
import { getClassPK } from "oj/api" import { getClassPK } from "oj/api"
@@ -46,32 +47,6 @@ const { isTeacherOrAbove } = useUserStore()
const message = useMessage() const message = useMessage()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
interface ClassComparison {
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
median_ac: number
q1_ac: number
q3_ac: number
iqr: number
std_dev: number
top10_avg: number
middle80_avg: number
bottom10_avg: number
excellent_rate: number
pass_rate: number
active_rate: number
ac_rate: number
composite_score: number
recent_total_ac?: number
recent_avg_ac?: number
recent_median_ac?: number
recent_top10_avg?: number
recent_active_count?: number
}
const selectedClasses = ref<string[]>([]) const selectedClasses = ref<string[]>([])
const comparisons = ref<ClassComparison[]>([]) const comparisons = ref<ClassComparison[]>([])
const duration = ref<string>("") const duration = ref<string>("")
@@ -129,7 +104,7 @@ function getTimeRange(): {
const classOptions = computed(() => { const classOptions = computed(() => {
return ( return (
configStore.config?.class_list.map((item) => ({ configStore.config?.classList.map((item) => ({
label: `${item.slice(0, 2)}计算机${item.slice(2)}`, label: `${item.slice(0, 2)}计算机${item.slice(2)}`,
value: item, value: item,
})) ?? [] })) ?? []
@@ -148,7 +123,7 @@ async function compare() {
const res = await getClassPK(selectedClasses.value, startTime, endTime) const res = await getClassPK(selectedClasses.value, startTime, endTime)
comparisons.value = res.data.comparisons comparisons.value = res.data.comparisons
hasTimeRange.value = res.data.has_time_range || false hasTimeRange.value = res.data.hasTimeRange || false
} catch (error) { } catch (error) {
message.error("获取数据失败") message.error("获取数据失败")
} finally { } finally {
@@ -252,11 +227,11 @@ function getClassColor(index: number) {
const compositeScoreChartData = computed(() => { const compositeScoreChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.className)
const datasets = [ const datasets = [
{ {
label: "综合分", label: "综合分",
data: comparisons.value.map((c) => c.composite_score), data: comparisons.value.map((c) => c.compositeScore),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -270,11 +245,11 @@ const compositeScoreChartData = computed(() => {
const totalAcChartData = computed(() => { const totalAcChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.className)
const datasets = [ const datasets = [
{ {
label: "总AC数", label: "总AC数",
data: comparisons.value.map((c) => c.total_ac), data: comparisons.value.map((c) => c.totalAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -288,11 +263,11 @@ const totalAcChartData = computed(() => {
const avgAcChartData = computed(() => { const avgAcChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.className)
const datasets = [ const datasets = [
{ {
label: "平均AC数", label: "平均AC数",
data: comparisons.value.map((c) => c.avg_ac), data: comparisons.value.map((c) => c.avgAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -306,11 +281,11 @@ const avgAcChartData = computed(() => {
const medianAcChartData = computed(() => { const medianAcChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.className)
const datasets = [ const datasets = [
{ {
label: "中位数AC数", label: "中位数AC数",
data: comparisons.value.map((c) => c.median_ac), data: comparisons.value.map((c) => c.medianAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -324,11 +299,11 @@ const medianAcChartData = computed(() => {
const excellentRateChartData = computed(() => { const excellentRateChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.className)
const datasets = [ const datasets = [
{ {
label: "优秀率", label: "优秀率",
data: comparisons.value.map((c) => c.excellent_rate), data: comparisons.value.map((c) => c.excellentRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -342,11 +317,11 @@ const excellentRateChartData = computed(() => {
const passRateChartData = computed(() => { const passRateChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.className)
const datasets = [ const datasets = [
{ {
label: "及格率", label: "及格率",
data: comparisons.value.map((c) => c.pass_rate), data: comparisons.value.map((c) => c.passRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -360,11 +335,11 @@ const passRateChartData = computed(() => {
const activeRateChartData = computed(() => { const activeRateChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.className)
const datasets = [ const datasets = [
{ {
label: "参与度", label: "参与度",
data: comparisons.value.map((c) => c.active_rate), data: comparisons.value.map((c) => c.activeRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -378,11 +353,11 @@ const activeRateChartData = computed(() => {
const top10AvgChartData = computed(() => { const top10AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.className)
const datasets = [ const datasets = [
{ {
label: "前10%平均", label: "前10%平均",
data: comparisons.value.map((c) => c.top10_avg), data: comparisons.value.map((c) => c.top10Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -396,11 +371,11 @@ const top10AvgChartData = computed(() => {
const bottom10AvgChartData = computed(() => { const bottom10AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.className)
const datasets = [ const datasets = [
{ {
label: "后10%平均", label: "后10%平均",
data: comparisons.value.map((c) => c.bottom10_avg), data: comparisons.value.map((c) => c.bottom10Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -414,11 +389,11 @@ const bottom10AvgChartData = computed(() => {
const middle80AvgChartData = computed(() => { const middle80AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.className)
const datasets = [ const datasets = [
{ {
label: "中间80%均值", label: "中间80%均值",
data: comparisons.value.map((c) => c.middle80_avg), data: comparisons.value.map((c) => c.middle80Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -449,18 +424,18 @@ const radarChartData = computed(() => {
// 计算每个指标的最大最小值 // 计算每个指标的最大最小值
const maxValues = [ const maxValues = [
Math.max(...comparisons.value.map((c) => c.total_ac)), Math.max(...comparisons.value.map((c) => c.totalAc)),
Math.max(...comparisons.value.map((c) => c.avg_ac)), Math.max(...comparisons.value.map((c) => c.avgAc)),
Math.max(...comparisons.value.map((c) => c.median_ac)), Math.max(...comparisons.value.map((c) => c.medianAc)),
100, // 优秀率最大值 100, // 优秀率最大值
100, // 及格率最大值 100, // 及格率最大值
100, // 参与度最大值 100, // 参与度最大值
] ]
const minValues = [ const minValues = [
Math.min(...comparisons.value.map((c) => c.total_ac)), Math.min(...comparisons.value.map((c) => c.totalAc)),
Math.min(...comparisons.value.map((c) => c.avg_ac)), Math.min(...comparisons.value.map((c) => c.avgAc)),
Math.min(...comparisons.value.map((c) => c.median_ac)), Math.min(...comparisons.value.map((c) => c.medianAc)),
0, 0,
0, 0,
0, 0,
@@ -469,22 +444,22 @@ const radarChartData = computed(() => {
const datasets = comparisons.value.map((c, index) => { const datasets = comparisons.value.map((c, index) => {
const color = getClassColor(index) const color = getClassColor(index)
const rawData = [ const rawData = [
c.total_ac, c.totalAc,
c.avg_ac, c.avgAc,
c.median_ac, c.medianAc,
c.excellent_rate, c.excellentRate,
c.pass_rate, c.passRate,
c.active_rate, c.activeRate,
] ]
return { return {
label: c.class_name, label: c.className,
data: [ data: [
normalize(c.total_ac, maxValues[0], minValues[0]), normalize(c.totalAc, maxValues[0], minValues[0]),
normalize(c.avg_ac, maxValues[1], minValues[1]), normalize(c.avgAc, maxValues[1], minValues[1]),
normalize(c.median_ac, maxValues[2], minValues[2]), normalize(c.medianAc, maxValues[2], minValues[2]),
c.excellent_rate, c.excellentRate,
c.pass_rate, c.passRate,
c.active_rate, c.activeRate,
], ],
rawData, rawData,
backgroundColor: color.bg, backgroundColor: color.bg,
@@ -584,14 +559,14 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
fontSize: "15px", fontSize: "15px",
}, },
}, },
row.composite_score.toFixed(1), row.compositeScore.toFixed(1),
), ),
}, },
{ {
title: "班级", title: "班级",
key: "class_name", key: "class_name",
render: (row) => render: (row) =>
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}`, `${row.className.slice(0, 2)}计算机${row.className.slice(2)}`,
width: 160, width: 160,
}, },
{ {
@@ -602,7 +577,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h( h(
"span", "span",
{ style: { color: "#1890ff", fontWeight: "600" } }, { style: { color: "#1890ff", fontWeight: "600" } },
row.user_count, row.userCount,
), ),
}, },
{ {
@@ -613,7 +588,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h( h(
"span", "span",
{ style: { color: "#ff4d4f", fontWeight: "600" } }, { style: { color: "#ff4d4f", fontWeight: "600" } },
row.total_ac, row.totalAc,
), ),
}, },
{ {
@@ -624,7 +599,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h( h(
"span", "span",
{ style: { color: "#52c41a", fontWeight: "600" } }, { style: { color: "#52c41a", fontWeight: "600" } },
row.avg_ac.toFixed(2), row.avgAc.toFixed(2),
), ),
}, },
{ {
@@ -635,7 +610,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h( h(
"span", "span",
{ style: { color: "#fa8c16", fontWeight: "600" } }, { style: { color: "#fa8c16", fontWeight: "600" } },
row.median_ac.toFixed(2), row.medianAc.toFixed(2),
), ),
}, },
{ {
@@ -646,7 +621,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h( h(
"span", "span",
{ style: { color: "#cf1322", fontWeight: "600" } }, { style: { color: "#cf1322", fontWeight: "600" } },
row.top10_avg.toFixed(2), row.top10Avg.toFixed(2),
), ),
}, },
{ {
@@ -657,7 +632,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h( h(
"span", "span",
{ style: { color: "#389e0d", fontWeight: "600" } }, { style: { color: "#389e0d", fontWeight: "600" } },
row.middle80_avg.toFixed(2), row.middle80Avg.toFixed(2),
), ),
}, },
{ {
@@ -668,7 +643,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h( h(
"span", "span",
{ style: { color: "#096dd9", fontWeight: "500" } }, { style: { color: "#096dd9", fontWeight: "500" } },
row.bottom10_avg.toFixed(2), row.bottom10Avg.toFixed(2),
), ),
}, },
{ {
@@ -679,7 +654,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h( h(
"span", "span",
{ style: { color: "#faad14", fontWeight: "600" } }, { style: { color: "#faad14", fontWeight: "600" } },
row.excellent_rate.toFixed(1) + "%", row.excellentRate.toFixed(1) + "%",
), ),
}, },
{ {
@@ -690,7 +665,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h( h(
"span", "span",
{ style: { color: "#52c41a", fontWeight: "600" } }, { style: { color: "#52c41a", fontWeight: "600" } },
row.pass_rate.toFixed(1) + "%", row.passRate.toFixed(1) + "%",
), ),
}, },
{ {
@@ -701,7 +676,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h( h(
"span", "span",
{ style: { color: "#1890ff", fontWeight: "600" } }, { style: { color: "#1890ff", fontWeight: "600" } },
row.active_rate.toFixed(1) + "%", row.activeRate.toFixed(1) + "%",
), ),
}, },
] ]
@@ -827,11 +802,11 @@ const radarChartOptions = {
<n-grid v-if="comparisons.length > 0" :cols="2" :x-gap="16" :y-gap="16"> <n-grid v-if="comparisons.length > 0" :cols="2" :x-gap="16" :y-gap="16">
<n-gi <n-gi
v-for="(classData, index) in comparisons" v-for="(classData, index) in comparisons"
:key="classData.class_name" :key="classData.className"
:span="isDesktop ? 1 : 2" :span="isDesktop ? 1 : 2"
> >
<n-card <n-card
:title="`${classData.class_name.slice(0, 2)}计算机${classData.class_name.slice(2)}班`" :title="`${classData.className.slice(0, 2)}计算机${classData.className.slice(2)}班`"
:bordered="true" :bordered="true"
hoverable hoverable
:style="{ :style="{
@@ -842,7 +817,7 @@ const radarChartOptions = {
<n-tag :type="getRankColor(index).type" size="large"> <n-tag :type="getRankColor(index).type" size="large">
#{{ getRankColor(index).text }} #{{ getRankColor(index).text }}
<span style="margin-left: 6px; font-size: 12px; opacity: 0.85"> <span style="margin-left: 6px; font-size: 12px; opacity: 0.85">
{{ classData.composite_score }} {{ classData.compositeScore }}
</span> </span>
</n-tag> </n-tag>
</template> </template>
@@ -854,7 +829,7 @@ const radarChartOptions = {
<n-gi> <n-gi>
<n-statistic <n-statistic
label="总AC数" label="总AC数"
:value="classData.total_ac" :value="classData.totalAc"
size="large" size="large"
class="stat-total-ac" class="stat-total-ac"
> >
@@ -866,7 +841,7 @@ const radarChartOptions = {
<n-gi> <n-gi>
<n-statistic <n-statistic
label="平均AC数" label="平均AC数"
:value="classData.avg_ac.toFixed(2)" :value="classData.avgAc.toFixed(2)"
size="large" size="large"
class="stat-avg-ac" class="stat-avg-ac"
> >
@@ -881,7 +856,7 @@ const radarChartOptions = {
<n-gi> <n-gi>
<n-statistic <n-statistic
label="中位数AC数" label="中位数AC数"
:value="classData.median_ac.toFixed(2)" :value="classData.medianAc.toFixed(2)"
size="large" size="large"
class="stat-median-ac" class="stat-median-ac"
> >
@@ -896,7 +871,7 @@ const radarChartOptions = {
<n-gi> <n-gi>
<n-statistic <n-statistic
label="总提交数" label="总提交数"
:value="classData.total_submission" :value="classData.totalSubmission"
size="large" size="large"
class="stat-total-submission" class="stat-total-submission"
> >
@@ -911,7 +886,7 @@ const radarChartOptions = {
<n-gi> <n-gi>
<n-statistic <n-statistic
label="AC率" label="AC率"
:value="classData.ac_rate.toFixed(1) + '%'" :value="classData.acRate.toFixed(1) + '%'"
size="large" size="large"
class="stat-ac-rate" class="stat-ac-rate"
> >
@@ -934,12 +909,12 @@ const radarChartOptions = {
<!-- 分位数统计 --> <!-- 分位数统计 -->
<n-descriptions-item label="第一四分位数(Q1)"> <n-descriptions-item label="第一四分位数(Q1)">
<span style="color: #9254de; font-weight: 500">{{ <span style="color: #9254de; font-weight: 500">{{
classData.q1_ac.toFixed(2) classData.q1Ac.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="第三四分位数(Q3)"> <n-descriptions-item label="第三四分位数(Q3)">
<span style="color: #f759ab; font-weight: 500">{{ <span style="color: #f759ab; font-weight: 500">{{
classData.q3_ac.toFixed(2) classData.q3Ac.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="四分位距(IQR)"> <n-descriptions-item label="四分位距(IQR)">
@@ -949,31 +924,31 @@ const radarChartOptions = {
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="标准差"> <n-descriptions-item label="标准差">
<span style="color: #fa8c16; font-weight: 500">{{ <span style="color: #fa8c16; font-weight: 500">{{
classData.std_dev.toFixed(2) classData.stdDev.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<!-- 分层统计 --> <!-- 分层统计 -->
<n-descriptions-item label="前10%均值"> <n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{ <span style="color: #cf1322; font-weight: 600">{{
classData.top10_avg.toFixed(2) classData.top10Avg.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="中间80%均值"> <n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{ <span style="color: #389e0d; font-weight: 600">{{
classData.middle80_avg.toFixed(2) classData.middle80Avg.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="后10%均值"> <n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{ <span style="color: #096dd9; font-weight: 500">{{
classData.bottom10_avg.toFixed(2) classData.bottom10Avg.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<!-- 人数 --> <!-- 人数 -->
<n-descriptions-item label="人数"> <n-descriptions-item label="人数">
<span style="color: #1890ff; font-weight: 600">{{ <span style="color: #1890ff; font-weight: 600">{{
classData.user_count classData.userCount
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
</n-descriptions> </n-descriptions>
@@ -988,34 +963,34 @@ const radarChartOptions = {
<n-space vertical :size="10"> <n-space vertical :size="10">
<n-progress <n-progress
type="line" type="line"
:percentage="classData.excellent_rate" :percentage="classData.excellentRate"
:show-indicator="true" :show-indicator="true"
:border-radius="4" :border-radius="4"
> >
<template #default> <template #default>
优秀率: {{ classData.excellent_rate.toFixed(1) }}% 优秀率: {{ classData.excellentRate.toFixed(1) }}%
</template> </template>
</n-progress> </n-progress>
<n-progress <n-progress
type="line" type="line"
:percentage="classData.pass_rate" :percentage="classData.passRate"
:show-indicator="true" :show-indicator="true"
:border-radius="4" :border-radius="4"
status="success" status="success"
> >
<template #default> <template #default>
及格率: {{ classData.pass_rate.toFixed(1) }}% 及格率: {{ classData.passRate.toFixed(1) }}%
</template> </template>
</n-progress> </n-progress>
<n-progress <n-progress
type="line" type="line"
:percentage="classData.active_rate" :percentage="classData.activeRate"
:show-indicator="true" :show-indicator="true"
:border-radius="4" :border-radius="4"
status="info" status="info"
> >
<template #default> <template #default>
参与度: {{ classData.active_rate.toFixed(1) }}% 参与度: {{ classData.activeRate.toFixed(1) }}%
</template> </template>
</n-progress> </n-progress>
</n-space> </n-space>
@@ -1023,7 +998,7 @@ const radarChartOptions = {
<!-- 时间段统计如果有 --> <!-- 时间段统计如果有 -->
<template <template
v-if="hasTimeRange && classData.recent_total_ac !== undefined" v-if="hasTimeRange && classData.recentTotalAc !== undefined"
> >
<n-descriptions <n-descriptions
bordered bordered
@@ -1034,27 +1009,27 @@ const radarChartOptions = {
> >
<n-descriptions-item label="时间段总AC"> <n-descriptions-item label="时间段总AC">
<span style="color: #ff7875; font-weight: 600">{{ <span style="color: #ff7875; font-weight: 600">{{
classData.recent_total_ac classData.recentTotalAc
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="时间段平均AC"> <n-descriptions-item label="时间段平均AC">
<span style="color: #73d13d; font-weight: 600">{{ <span style="color: #73d13d; font-weight: 600">{{
classData.recent_avg_ac?.toFixed(2) classData.recentAvgAc?.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="时间段中位数AC"> <n-descriptions-item label="时间段中位数AC">
<span style="color: #ffc53d; font-weight: 600">{{ <span style="color: #ffc53d; font-weight: 600">{{
classData.recent_median_ac?.toFixed(2) classData.recentMedianAc?.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="时间段前10名平均"> <n-descriptions-item label="时间段前10名平均">
<span style="color: #ff4d4f; font-weight: 600">{{ <span style="color: #ff4d4f; font-weight: 600">{{
classData.recent_top10_avg?.toFixed(2) classData.recentTop10Avg?.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="活跃学生数" :span="2"> <n-descriptions-item label="活跃学生数" :span="2">
<span style="color: #1890ff; font-weight: 600">{{ <span style="color: #1890ff; font-weight: 600">{{
classData.recent_active_count classData.recentActiveCount
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
</n-descriptions> </n-descriptions>

View File

@@ -16,9 +16,9 @@ function goto() {
</script> </script>
<template> <template>
{{ rank.accepted_number }} / {{ rank.acceptedNumber }} /
<n-button text type="primary" @click="goto"> <n-button text type="primary" @click="goto">
{{ rank.submission_number }} {{ rank.submissionNumber }}
</n-button> </n-button>
</template> </template>

View File

@@ -24,18 +24,16 @@ const contestStore = useContestStore()
<div v-html="contestStore.contest.description"></div> <div v-html="contestStore.contest.description"></div>
<n-descriptions bordered label-placement="left" :column="1"> <n-descriptions bordered label-placement="left" :column="1">
<n-descriptions-item label="开始时间"> <n-descriptions-item label="开始时间">
{{ {{ parseTime(contestStore.contest.startTime, "YYYY年M月D日 HH:mm:ss") }}
parseTime(contestStore.contest.start_time, "YYYY年M月D日 HH:mm:ss")
}}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="结束时间"> <n-descriptions-item label="结束时间">
{{ parseTime(contestStore.contest.end_time, "YYYY年M月D日 HH:mm:ss") }} {{ parseTime(contestStore.contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="比赛类型"> <n-descriptions-item label="比赛类型">
<ContestType :contest="contestStore.contest" /> <ContestType :contest="contestStore.contest" />
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="发起人"> <n-descriptions-item label="发起人">
{{ contestStore.contest.created_by.username }} {{ contestStore.contest.createdBy.username }}
</n-descriptions-item> </n-descriptions-item>
</n-descriptions> </n-descriptions>
</n-popover> </n-popover>

View File

@@ -78,7 +78,7 @@ const chartData = computed(() => {
// 收集所有AC事件并按时间排序 // 收集所有AC事件并按时间排序
const events: AcEvent[] = [] const events: AcEvent[] = []
topUsers.forEach((rank, userIndex) => { topUsers.forEach((rank, userIndex) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => { Object.entries(rank.submissionInfo).forEach(([problemId, info]) => {
if (info.is_ac) { if (info.is_ac) {
events.push({ time: info.ac_time, userIndex, problemId }) events.push({ time: info.ac_time, userIndex, problemId })
} }
@@ -100,7 +100,7 @@ const chartData = computed(() => {
// 用于记录每个用户每道题的错误次数 // 用于记录每个用户每道题的错误次数
const userErrors: Map<string, number>[] = topUsers.map(() => new Map()) const userErrors: Map<string, number>[] = topUsers.map(() => new Map())
topUsers.forEach((rank, i) => { topUsers.forEach((rank, i) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => { Object.entries(rank.submissionInfo).forEach(([problemId, info]) => {
if (info.error_number > 0) { if (info.error_number > 0) {
userErrors[i].set(problemId, info.error_number) userErrors[i].set(problemId, info.error_number)
} }

View File

@@ -3,7 +3,7 @@ import { useRouteQuery } from "@vueuse/router"
import { NTag } from "naive-ui" import { NTag } from "naive-ui"
import { getContestList } from "oj/api" import { getContestList } from "oj/api"
import { duration, parseTime } from "utils/functions" import { duration, parseTime } from "utils/functions"
import type { Contest } from "utils/types" import type { OjContest } from "utils/types"
import ContestTitle from "shared/components/ContestTitle.vue" import ContestTitle from "shared/components/ContestTitle.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"
@@ -29,7 +29,7 @@ const { query, clearQuery } = usePagination<ContestQuery>({
tag: useRouteQuery("tag", "").value, tag: useRouteQuery("tag", "").value,
}) })
const data = ref<Contest[]>([]) const data = ref<OjContest[]>([])
const total = ref(0) const total = ref(0)
const options: SelectOption[] = [ const options: SelectOption[] = [
@@ -46,7 +46,7 @@ const tags: SelectOption[] = [
{ label: "期末", value: "期末" }, { label: "期末", value: "期末" },
] ]
const columns: DataTableColumn<Contest>[] = [ const columns: DataTableColumn<OjContest>[] = [
{ {
title: renderTableTitle("状态", "streamline-emojis:collision"), title: renderTableTitle("状态", "streamline-emojis:collision"),
key: "status", key: "status",
@@ -74,13 +74,13 @@ const columns: DataTableColumn<Contest>[] = [
title: renderTableTitle("开始时间", "fluent-emoji-flat:eleven-thirty"), title: renderTableTitle("开始时间", "fluent-emoji-flat:eleven-thirty"),
key: "start_time", key: "start_time",
width: 180, width: 180,
render: (row) => parseTime(row.start_time), render: (row) => parseTime(row.startTime),
}, },
{ {
title: renderTableTitle("比赛时长", "streamline-emojis:fishing-pole"), title: renderTableTitle("比赛时长", "streamline-emojis:fishing-pole"),
key: "duration", key: "duration",
width: 180, width: 180,
render: (row) => duration(row.start_time, row.end_time), render: (row) => duration(row.startTime, row.endTime),
}, },
] ]
@@ -116,11 +116,11 @@ watchDebounced(() => query.keyword, listContests, {
// 监听其他查询条件变化 // 监听其他查询条件变化
watch(() => [query.page, query.limit, query.status, query.tag], listContests) watch(() => [query.page, query.limit, query.status, query.tag], listContests)
function rowProps(row: Contest) { function rowProps(row: OjContest) {
return { return {
style: "cursor: pointer", style: "cursor: pointer",
onClick() { onClick() {
if (!userStore.isAuthed && row.contest_type === ContestType.private) { if (!userStore.isAuthed && row.contestType === ContestType.private) {
authStore.openLoginModal() authStore.openLoginModal()
} else { } else {
router.push("/contest/" + row.id) router.push("/contest/" + row.id)

View File

@@ -87,7 +87,7 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
key: "total_time", key: "total_time",
width: 120, width: 120,
align: "center", align: "center",
render: (row) => secondsToDuration(row.total_time), render: (row) => secondsToDuration(row.totalTime),
}, },
]) ])
@@ -129,8 +129,8 @@ async function addColumns() {
() => problem.title, () => problem.title,
), ),
render: (row) => { render: (row) => {
if (row.submission_info[problem.id]) { if (row.submissionInfo[problem.id]) {
const status = row.submission_info[problem.id] const status = row.submissionInfo[problem.id]
let acTime let acTime
let errorNumber let errorNumber
if (status.is_ac) { if (status.is_ac) {
@@ -162,8 +162,8 @@ async function addColumns() {
cellProps: (row) => { cellProps: (row) => {
let backgroundColor = "" let backgroundColor = ""
let color = theme.value.textColorBase let color = theme.value.textColorBase
if (row.submission_info[problem.id]) { if (row.submissionInfo[problem.id]) {
const status = row.submission_info[problem.id] const status = row.submissionInfo[problem.id]
if (status.is_first_ac) { if (status.is_first_ac) {
backgroundColor = theme.value.primaryColor backgroundColor = theme.value.primaryColor
color = theme.value.baseColor color = theme.value.baseColor

View File

@@ -185,8 +185,8 @@ const goSubmissions = () => {
} }
const goEdit = () => { const goEdit = () => {
const url = problem.value!.contest const url = problem.value!.contestId
? `/admin/contest/${problem.value!.contest}/problem/edit/${problem.value!.id}` ? `/admin/contest/${problem.value!.contestId}/problem/edit/${problem.value!.id}`
: `/admin/problem/edit/${problem.value!.id}` : `/admin/problem/edit/${problem.value!.id}`
window.open(router.resolve(url).href, "_blank") window.open(router.resolve(url).href, "_blank")
} }

View File

@@ -32,8 +32,8 @@ const { problem } = storeToRefs(problemStore)
const problemSetId = computed(() => route.params.problemSetId) const problemSetId = computed(() => route.params.problemSetId)
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果 // SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value?.sql_config) const isSQL = computed(() => !!problem.value?.sqlConfig)
const sqlDisplay = computed(() => problem.value?.sql_display ?? null) const sqlDisplay = computed(() => problem.value?.sqlDisplay ?? null)
const sqlExpectedQuery = computed(() => { const sqlExpectedQuery = computed(() => {
const exp = sqlDisplay.value?.expected const exp = sqlDisplay.value?.expected
return exp && "columns" in exp ? exp : null return exp && "columns" in exp ? exp : null
@@ -71,7 +71,7 @@ watch(
// AC 或失败次数 >= 3 时加载推荐 // AC 或失败次数 >= 3 时加载推荐
watch( watch(
() => [problem.value?._id, problem.value?.my_status, problemStore.failCount], () => [problem.value?._id, problem.value?.myStatus, problemStore.failCount],
([, status, failCount]) => { ([, status, failCount]) => {
if (status === 0 || (failCount as number) >= 3) { if (status === 0 || (failCount as number) >= 3) {
loadSimilarProblems() loadSimilarProblems()
@@ -82,9 +82,9 @@ watch(
const hasTriedButNotPassed = computed(() => { const hasTriedButNotPassed = computed(() => {
return ( return (
problem.value?.my_status !== undefined && problem.value?.myStatus !== undefined &&
problem.value?.my_status !== null && problem.value?.myStatus !== null &&
problem.value?.my_status !== 0 problem.value?.myStatus !== 0
) )
}) })
@@ -177,8 +177,8 @@ function ruleTagType(engine: string): "error" | "success" | "info" {
} }
const astRulesForDisplay = computed(() => { const astRulesForDisplay = computed(() => {
if (!problem.value?.ast_rules) return [] if (!problem.value?.astRules) return []
return Object.entries(problem.value.ast_rules).filter( return Object.entries(problem.value.astRules).filter(
([, rules]) => rules.length > 0, ([, rules]) => rules.length > 0,
) )
}) })
@@ -249,7 +249,7 @@ function type(status: ProblemStatus) {
<!-- 已通过 --> <!-- 已通过 -->
<n-alert <n-alert
class="status-alert" class="status-alert"
v-if="problem.my_status === 0" v-if="problem.myStatus === 0"
type="success" type="success"
title="🎉 本 题 已 经 被 你 解 决 啦" title="🎉 本 题 已 经 被 你 解 决 啦"
> >
@@ -291,7 +291,7 @@ function type(status: ProblemStatus) {
</p> </p>
<MdPreview <MdPreview
preview-theme="vuepress" preview-theme="vuepress"
:model-value="problem.input_description" :model-value="problem.inputDescription"
:theme="isDark ? 'dark' : 'light'" :theme="isDark ? 'dark' : 'light'"
/> />
@@ -303,7 +303,7 @@ function type(status: ProblemStatus) {
</p> </p>
<MdPreview <MdPreview
preview-theme="vuepress" preview-theme="vuepress"
:model-value="problem.output_description" :model-value="problem.outputDescription"
:theme="isDark ? 'dark' : 'light'" :theme="isDark ? 'dark' : 'light'"
/> />
</template> </template>
@@ -338,7 +338,7 @@ function type(status: ProblemStatus) {
:total-rows="sqlExpectedQuery.total_rows" :total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated" :truncated="sqlExpectedQuery.truncated"
/> />
<p v-if="!problem.sql_config?.order_sensitive" class="sqlNote"> <p v-if="!problem.sqlConfig?.order_sensitive" class="sqlNote">
结果顺序不限 结果顺序不限
</p> </p>
</template> </template>

View File

@@ -11,13 +11,13 @@ const { renderError, renderFlowchart } = useMermaid()
const renderProblemFlowchart = async () => { const renderProblemFlowchart = async () => {
await renderFlowchart( await renderFlowchart(
mermaidContainer.value, mermaidContainer.value,
problem.value?.mermaid_code ?? "", problem.value?.mermaidCode ?? "",
) )
} }
onMounted(renderProblemFlowchart) onMounted(renderProblemFlowchart)
watch(() => problem.value?.mermaid_code, renderProblemFlowchart) watch(() => problem.value?.mermaidCode, renderProblemFlowchart)
</script> </script>
<template> <template>

View File

@@ -30,7 +30,7 @@ const beatRate = ref("0")
const yearlyACData = ref<YearlyACData[]>([]) const yearlyACData = ref<YearlyACData[]>([])
const data = computed(() => { const data = computed(() => {
const status = problem.value!.statistic_info const status = problem.value!.statisticInfo
const labels = [] const labels = []
for (let i in status) { for (let i in status) {
if (status[i] !== 0) { if (status[i] !== 0) {
@@ -50,14 +50,14 @@ const numbers = computed(() => {
return [ return [
{ {
icon: "streamline-ultimate-color:checklist", icon: "streamline-ultimate-color:checklist",
title: problem.value?.submission_number ?? 0, title: problem.value?.submissionNumber ?? 0,
content: "总提交", content: "总提交",
int: true, int: true,
suffix: "", suffix: "",
}, },
{ {
icon: "streamline-emojis:woman-raising-hand-2", icon: "streamline-emojis:woman-raising-hand-2",
title: problem.value?.accepted_number ?? 0, title: problem.value?.acceptedNumber ?? 0,
content: "通过数", content: "通过数",
int: true, int: true,
suffix: "", suffix: "",
@@ -65,8 +65,8 @@ const numbers = computed(() => {
{ {
icon: "fluent-emoji:chart-increasing", icon: "fluent-emoji:chart-increasing",
title: getACRateNumber( title: getACRateNumber(
problem.value?.accepted_number ?? 0, problem.value?.acceptedNumber ?? 0,
problem.value?.submission_number ?? 0, problem.value?.submissionNumber ?? 0,
), ),
content: "通过率", content: "通过率",
int: false, int: false,
@@ -115,10 +115,10 @@ onMounted(() => {
{{ problem._id }} {{ problem._id }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="出题人"> <n-descriptions-item label="出题人">
{{ problem.created_by.username }} {{ problem.createdBy.username }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="创建时间"> <n-descriptions-item label="创建时间">
{{ parseTime(problem.create_time) }} {{ parseTime(problem.createTime) }}
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="难度"> <n-descriptions-item label="难度">
<n-tag :type="getTagColor(problem.difficulty)"> <n-tag :type="getTagColor(problem.difficulty)">
@@ -150,7 +150,7 @@ onMounted(() => {
</n-card> </n-card>
</n-gi> </n-gi>
</n-grid> </n-grid>
<div class="pie" v-if="problem && problem.submission_number > 0"> <div class="pie" v-if="problem && problem.submissionNumber > 0">
<Pie :data="data" :options="options" /> <Pie :data="data" :options="options" />
</div> </div>
<ProblemYearlyChart :data="yearlyACData" /> <ProblemYearlyChart :data="yearlyACData" />

View File

@@ -10,17 +10,17 @@ defineProps<{
<n-flex align="center"> <n-flex align="center">
<span>{{ problem.title }}</span> <span>{{ problem.title }}</span>
<Icon <Icon
v-if="problem.allow_flowchart" v-if="problem.allowFlowchart"
width="18" width="18"
icon="vscode-icons:file-type-drawio" icon="vscode-icons:file-type-drawio"
/> />
<Icon <Icon
v-else-if="problem.show_flowchart" v-else-if="problem.showFlowchart"
width="18" width="18"
icon="vscode-icons:file-type-graphql" icon="vscode-icons:file-type-graphql"
/> />
<Icon <Icon
v-if="problem.has_ast_rules" v-if="problem.hasAstRules"
width="18" width="18"
icon="vscode-icons:file-type-light-todo" icon="vscode-icons:file-type-light-todo"
/> />

View File

@@ -80,7 +80,7 @@ const wheelItems = REACTIONS.map((item, index) => ({
style: getWheelItemStyle(index), style: getWheelItemStyle(index),
})) }))
const solved = computed(() => problem.value?.my_status === 0) const solved = computed(() => problem.value?.myStatus === 0)
const locked = computed(() => mine.value !== null) const locked = computed(() => mine.value !== null)
const canInteract = computed( const canInteract = computed(
() => () =>

View File

@@ -8,7 +8,7 @@ import { useUserStore } from "shared/store/user"
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants" import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
import type { Submission } from "utils/types" import type { SubmissionListItem } from "utils/types"
import SubmissionDetail from "oj/submission/detail.vue" import SubmissionDetail from "oj/submission/detail.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
@@ -29,19 +29,19 @@ function showCodePanel(id: string, problem: string) {
toggleCodePanel(true) toggleCodePanel(true)
} }
const columns: DataTableColumn<Submission>[] = [ const columns: DataTableColumn<SubmissionListItem>[] = [
{ {
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"), title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
key: "create_time", key: "create_time",
width: 200, 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: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"), title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"),
key: "id", key: "id",
minWidth: 160, minWidth: 160,
render: (row) => { render: (row) => {
if (!row.show_link) if (!row.showLink)
return h(NFlex, { align: "center" }, () => [ return h(NFlex, { align: "center" }, () => [
h("span", row.id.slice(0, 12)), h("span", row.id.slice(0, 12)),
h( h(
@@ -90,7 +90,7 @@ const class_ac_count = ref(0)
const all_ac_count = ref(0) const all_ac_count = ref(0)
const loading = ref(false) const loading = ref(false)
const submissions = ref<Submission[]>([]) const submissions = ref<SubmissionListItem[]>([])
const total = ref(0) const total = ref(0)
const query = reactive({ const query = reactive({
limit: 10, limit: 10,
@@ -126,8 +126,8 @@ async function listSubmissions() {
...query, ...query,
myself: "1", myself: "1",
offset, offset,
problem_id: (route.params.problemID as string) ?? "", problemId: (route.params.problemID as string) ?? "",
contest_id: (route.params.contestID as string) ?? "", contestId: (route.params.contestID as string) ?? "",
}) })
submissions.value = res.data.results submissions.value = res.data.results
total.value = res.data.total total.value = res.data.total
@@ -138,10 +138,10 @@ async function getRankOfThisProblem() {
const res = await getRankOfProblem((route.params.problemID as string) ?? "") const res = await getRankOfProblem((route.params.problemID as string) ?? "")
loading.value = false loading.value = false
class_name.value = res.data.class_name class_name.value = res.data.className
rank.value = res.data.rank rank.value = res.data.rank
class_ac_count.value = res.data.class_ac_count class_ac_count.value = res.data.classAcCount
all_ac_count.value = res.data.all_ac_count all_ac_count.value = res.data.allAcCount
} }
onMounted(() => { onMounted(() => {

View File

@@ -35,7 +35,7 @@ const chartData = computed(() => ({
datasets: [ datasets: [
{ {
label: "AC 率", label: "AC 率",
data: props.data.map((d) => d.ac_rate), data: props.data.map((d) => d.acRate),
fill: true, fill: true,
tension: 0.3, tension: 0.3,
backgroundColor: "rgba(99, 179, 237, 0.2)", backgroundColor: "rgba(99, 179, 237, 0.2)",
@@ -58,7 +58,7 @@ const chartOptions = computed(() => ({
callbacks: { callbacks: {
label: (context: any) => { label: (context: any) => {
const d = props.data[context.dataIndex] const d = props.data[context.dataIndex]
return [`AC 率: ${d.ac_rate}%`, `通过: ${d.accepted} / ${d.total}`] return [`AC 率: ${d.acRate}%`, `通过: ${d.accepted} / ${d.total}`]
}, },
}, },
}, },

View File

@@ -47,9 +47,9 @@ const msg = computed(() => {
if ( if (
result !== SubmissionStatus.ast_check_failed && result !== SubmissionStatus.ast_check_failed &&
props.submission.statistic_info?.err_info props.submission.statisticInfo?.err_info
) { ) {
msg += props.submission.statistic_info.err_info msg += props.submission.statisticInfo.err_info
} }
return msg return msg
@@ -161,15 +161,13 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
<n-flex <n-flex
vertical vertical
v-if=" v-if="
msg || msg || infoTable.length || submission.statisticInfo?.ast_results?.length
infoTable.length ||
submission.statistic_info?.ast_results?.length
" "
> >
<n-card v-if="submission.statistic_info?.ast_results?.length" embedded> <n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
<n-flex vertical :size="8"> <n-flex vertical :size="8">
<n-flex <n-flex
v-for="(rule, i) in submission.statistic_info.ast_results" v-for="(rule, i) in submission.statisticInfo.ast_results"
:key="i" :key="i"
align="center" align="center"
:size="6" :size="6"

View File

@@ -130,22 +130,22 @@ async function submit() {
// 1. 构建提交数据 // 1. 构建提交数据
const data: SubmitCodePayload = { const data: SubmitCodePayload = {
problem_id: problem.value!.id, problemId: problem.value!.id,
language: codeStore.code.language, language: codeStore.code.language,
code: codeStore.code.value, code: codeStore.code.value,
} }
if (contestID) { if (contestID) {
data.contest_id = parseInt(contestID) data.contestId = parseInt(contestID)
} }
// 2. 提交代码到后端 // 2. 提交代码到后端
isSubmittingRequest.value = true isSubmittingRequest.value = true
try { try {
const res = await submitCode(data) const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`) console.log(`[Submit] 代码已提交: ID=${res.data.submissionId}`)
// 3. 启动冷却 + 监控 // 3. 启动冷却 + 监控
startCooldown() startCooldown()
startMonitoring(res.data.submission_id) startMonitoring(res.data.submissionId)
showResult.value = true showResult.value = true
} finally { } finally {
isSubmittingRequest.value = false isSubmittingRequest.value = false
@@ -183,7 +183,7 @@ watch(
return return
// 1. 刷新题目状态 // 1. 刷新题目状态
problem.value!.my_status = 0 problem.value!.myStatus = 0
// 2. 创建ProblemSetSubmission记录更新题单进度 // 2. 创建ProblemSetSubmission记录更新题单进度
if (problemSetId) { if (problemSetId) {

View File

@@ -135,16 +135,16 @@ async function submitFlowchartData() {
try { try {
const response = await submitFlowchart({ const response = await submitFlowchart({
problem_id: problem.value!.id, problemId: problem.value!.id,
mermaid_code: mermaidCode, mermaidCode,
flowchart_data: { flowchartData: {
compressed: true, compressed: true,
data: compressed, data: compressed,
}, },
}) })
// 获取提交ID并订阅更新 // 获取提交ID并订阅更新
const submissionId = response.data.submission_id const submissionId = response.data.submissionId
if (submissionId) { if (submissionId) {
subscribeToSubmission(submissionId) subscribeToSubmission(submissionId)
@@ -183,18 +183,34 @@ async function getSubmission(submissionPage = 0) {
) )
submissionCount.value = data.count submissionCount.value = data.count
const submission = data.submission const submission = data.submission
myFlowchartZippedStr.value = submission.flowchart_data.data // 翻到没有提交的页时后端返回 null契约里 submission 是 nullable——
myMermaidCode.value = submission.mermaid_code || "" // 原来的 any 让这里看起来非空,真翻到那一页会直接抛
if (!submission) {
myFlowchartZippedStr.value = ""
myMermaidCode.value = ""
modalRating.value = { score: 0, grade: "" }
evaluation.value = {
score: 0,
grade: "",
feedback: "",
suggestions: "",
criteria_details: {},
}
return
}
myFlowchartZippedStr.value = String(submission.flowchartData.data ?? "")
myMermaidCode.value = submission.mermaidCode || ""
modalRating.value = { modalRating.value = {
score: submission.ai_score, score: submission.aiScore ?? 0,
grade: submission.ai_grade, grade: (submission.aiGrade ?? "") as Rating["grade"],
} }
evaluation.value = { evaluation.value = {
score: submission.ai_score, score: submission.aiScore ?? 0,
grade: submission.ai_grade, grade: (submission.aiGrade ?? "") as Rating["grade"],
feedback: submission.ai_feedback, feedback: submission.aiFeedback ?? "",
suggestions: submission.ai_suggestions, suggestions: submission.aiSuggestions ?? "",
criteria_details: submission.ai_criteria_details, criteria_details:
submission.aiCriteriaDetails as Evaluation["criteria_details"],
} }
} }

View File

@@ -55,7 +55,7 @@ const { isMobile, isDesktop } = useBreakpoints()
const tabOptions = computed(() => { const tabOptions = computed(() => {
const options: string[] = ["content"] const options: string[] = ["content"]
if (problem.value?.show_flowchart) { if (problem.value?.showFlowchart) {
options.push("flowchart") options.push("flowchart")
} }
@@ -113,7 +113,7 @@ async function init() {
problem.value = res.data problem.value = res.data
} catch (err: any) { } catch (err: any) {
problem.value = null problem.value = null
if (err.data === "Contest has not started yet.") { if (err.error === "contest-not-started") {
errMsg.value = "比赛还没有开始" errMsg.value = "比赛还没有开始"
} }
} }
@@ -159,7 +159,7 @@ watch(
<ProblemContent /> <ProblemContent />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="problem.show_flowchart && problem.mermaid_code" v-if="problem.showFlowchart && problem.mermaidCode"
name="flowchart" name="flowchart"
tab="流程图表" tab="流程图表"
> >
@@ -211,7 +211,7 @@ watch(
<ProblemContent /> <ProblemContent />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="problem.show_flowchart && problem.mermaid_code" v-if="problem.showFlowchart && problem.mermaidCode"
name="flowchart" name="flowchart"
tab="流程图表" tab="流程图表"
> >
@@ -251,7 +251,7 @@ watch(
<n-tab-pane name="content" tab="描述"> <n-tab-pane name="content" tab="描述">
<ProblemContent /> <ProblemContent />
</n-tab-pane> </n-tab-pane>
<n-tab-pane v-if="problem.show_flowchart" name="flowchart" tab="流程"> <n-tab-pane v-if="problem.showFlowchart" name="flowchart" tab="流程">
<ProblemFlowchart /> <ProblemFlowchart />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="editor" tab="代码"> <n-tab-pane name="editor" tab="代码">

View File

@@ -35,7 +35,8 @@ function getDifficultyTag(difficulty: string) {
function getProgressPercentage() { function getProgressPercentage() {
if (!props.problemSet) return 0 if (!props.problemSet) return 0
return Math.round( return Math.round(
(props.problemSet.completed_count / props.problemSet.problems_count) * 100, ((props.problemSet.completedCount ?? 0) / props.problemSet.problemsCount) *
100,
) )
} }
@@ -78,7 +79,7 @@ function handleJoin() {
<n-flex align="center" v-if="isJoined"> <n-flex align="center" v-if="isJoined">
<n-text strong>完成进度</n-text> <n-text strong>完成进度</n-text>
<n-text> <n-text>
{{ problemSet.completed_count }} / {{ problemSet.problems_count }} {{ problemSet.completedCount }} / {{ problemSet.problemsCount }}
</n-text> </n-text>
</n-flex> </n-flex>
<n-progress <n-progress

View File

@@ -41,7 +41,7 @@ function handleProblemClick(problemId: string) {
style="margin-right: 10px" style="margin-right: 10px"
width="48" width="48"
icon="fluent-emoji:check-mark-button" icon="fluent-emoji:check-mark-button"
v-if="problemSetProblem.is_completed" v-if="problemSetProblem.isCompleted"
/> />
<n-flex vertical style="flex: 1"> <n-flex vertical style="flex: 1">
@@ -60,7 +60,7 @@ function handleProblemClick(problemId: string) {
{{ DIFFICULTY[problemSetProblem.problem.difficulty] }} {{ DIFFICULTY[problemSetProblem.problem.difficulty] }}
</n-tag> </n-tag>
<n-text type="info">分数{{ problemSetProblem.score }}</n-text> <n-text type="info">分数{{ problemSetProblem.score }}</n-text>
<n-text v-if="!problemSetProblem.is_required">选做</n-text> <n-text v-if="!problemSetProblem.isRequired">选做</n-text>
</n-flex> </n-flex>
</n-flex> </n-flex>
</n-flex> </n-flex>

View File

@@ -16,7 +16,7 @@ const total = ref(0)
const statistics = ref<{ const statistics = ref<{
total: number total: number
completed: number completed: number
avg_progress: number avgProgress: number
} | null>(null) } | null>(null)
const classFilter = ref<string>("") const classFilter = ref<string>("")
const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">( const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">(
@@ -42,17 +42,17 @@ async function loadUserProgress() {
const params: { const params: {
limit?: number limit?: number
offset?: number offset?: number
class_name?: string className?: string
completion_status?: "" | "completed" | "in_progress" | "not_started" completionStatus?: "" | "completed" | "in_progress" | "not_started"
} = { } = {
limit: query.limit, limit: query.limit,
offset, offset,
} }
if (classFilter.value.trim()) { if (classFilter.value.trim()) {
params.class_name = classFilter.value.trim() params.className = classFilter.value.trim()
} }
if (completionFilter.value) { if (completionFilter.value) {
params.completion_status = completionFilter.value params.completionStatus = completionFilter.value
} }
const res = await getProblemSetUserProgress(problemSetId.value, params) const res = await getProblemSetUserProgress(problemSetId.value, params)
@@ -92,7 +92,7 @@ const stats = computed(() => {
return { return {
total: statistics.value.total, total: statistics.value.total,
completed: statistics.value.completed, completed: statistics.value.completed,
avgProgress: Math.round(statistics.value.avg_progress), avgProgress: Math.round(statistics.value.avgProgress),
} }
} }
// 如果后端还没有返回统计数据,使用默认值 // 如果后端还没有返回统计数据,使用默认值
@@ -128,7 +128,7 @@ const progressColumns = [
key: "join_time", key: "join_time",
width: 180, width: 180,
render: (row: ProblemSetProgress) => render: (row: ProblemSetProgress) =>
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"), parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
}, },
{ {
title: "已完成数量", title: "已完成数量",
@@ -140,12 +140,12 @@ const progressColumns = [
key: "completed_problems", key: "completed_problems",
width: 300, width: 300,
render: (row: ProblemSetProgress) => { render: (row: ProblemSetProgress) => {
if (row.progress_percentage === 100) { if (row.progressPercentage === 100) {
return "全部题目已完成" return "全部题目已完成"
} }
if (row.progress_percentage > 50 && row.progress_percentage < 100) { if (row.progressPercentage > 50 && row.progressPercentage < 100) {
const completedProblemIds = new Set( const completedProblemIds = new Set(
row.completed_problems.map((p: any) => p.id), row.completedProblems.map((p: any) => p.id),
) )
const incompleteProblems = allProblems.value.filter( const incompleteProblems = allProblems.value.filter(
(p) => !completedProblemIds.has(p.id), (p) => !completedProblemIds.has(p.id),
@@ -164,7 +164,7 @@ const progressColumns = [
} }
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [ return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(NFlex, {}, () => h(NFlex, {}, () =>
row.completed_problems.map((problem: any) => row.completedProblems.map((problem: any) =>
h( h(
NTag, NTag,
{ {
@@ -184,7 +184,7 @@ const progressColumns = [
key: "progress_percentage", key: "progress_percentage",
width: 120, width: 120,
render: (row: ProblemSetProgress) => { render: (row: ProblemSetProgress) => {
return `${row.progress_percentage.toFixed(0)}%` return `${row.progressPercentage.toFixed(0)}%`
}, },
}, },
{ {
@@ -192,7 +192,7 @@ const progressColumns = [
key: "is_completed", key: "is_completed",
width: 100, width: 100,
render: (row: ProblemSetProgress) => { render: (row: ProblemSetProgress) => {
if (row.is_completed) { if (row.isCompleted) {
return h(NTag, { type: "success" }, () => "已完成") return h(NTag, { type: "success" }, () => "已完成")
} else { } else {
return h(NTag, { type: "warning" }, () => "进行中") return h(NTag, { type: "warning" }, () => "进行中")

View File

@@ -35,7 +35,7 @@ const activeTab = ref("problems")
async function loadProblemSetDetail() { async function loadProblemSetDetail() {
const res = await getProblemSetDetail(problemSetId.value) const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res.data problemSet.value = res.data
isJoined.value = res.data.user_progress?.is_joined || false isJoined.value = res.data.userProgress?.isJoined || false
} }
async function loadProblems() { async function loadProblems() {
@@ -48,14 +48,14 @@ async function loadUserBadges() {
const res = await getUserBadges() const res = await getUserBadges()
userBadges.value = res.data.filter( userBadges.value = res.data.filter(
(badge: UserBadgeType) => badge.badge.problemset === problemSetId.value, (badge: UserBadgeType) => badge.badge.problemsetId === problemSetId.value,
) )
} }
async function init() { async function init() {
await Promise.all([loadProblemSetDetail(), loadProblems()]) await Promise.all([loadProblemSetDetail(), loadProblems()])
if (isJoined.value) { if (isJoined.value) {
if (problemSet.value?.user_progress?.is_completed) { if (problemSet.value?.userProgress?.isCompleted) {
celebrate() celebrate()
} }
loadUserBadges() loadUserBadges()
@@ -100,7 +100,7 @@ async function handleJoinProblemSet() {
const showTabs = computed( const showTabs = computed(
() => () =>
userStore.isSuperAdmin || userStore.isSuperAdmin ||
(isJoined.value && problemSet.value?.user_progress?.is_completed), (isJoined.value && problemSet.value?.userProgress?.isCompleted),
) )
onMounted(init) onMounted(init)

View File

@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import { getProblemSetList } from "../api" import { getProblemSetList } from "../api"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { ProblemSetList } from "utils/types" import type { ProblemSet } from "utils/types"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
@@ -12,7 +12,7 @@ const router = useRouter()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const total = ref(0) const total = ref(0)
const problemSets = ref<ProblemSetList[]>([]) const problemSets = ref<ProblemSet[]>([])
interface ProblemSetQuery { interface ProblemSetQuery {
keyword: string keyword: string
@@ -164,27 +164,25 @@ watch(
<n-flex justify="space-between" align="center"> <n-flex justify="space-between" align="center">
<n-flex> <n-flex>
<Icon width="20" icon="streamline-emojis:blossom" /> <Icon width="20" icon="streamline-emojis:blossom" />
<n-text>{{ problemSet.problems_count }} 道题目</n-text> <n-text>{{ problemSet.problemsCount }} 道题目</n-text>
</n-flex> </n-flex>
<n-flex align="center" style="height: 28px"> <n-flex align="center" style="height: 28px">
<!-- 用户进度显示 --> <!-- 用户进度显示 -->
<n-progress <n-progress
v-if=" v-if="
problemSet.user_progress?.is_joined && problemSet.userProgress?.isJoined &&
!problemSet.user_progress?.is_completed !problemSet.userProgress?.isCompleted
" "
type="line" type="line"
:percentage=" :percentage="
Math.round(problemSet.user_progress.progress_percentage) Math.round(problemSet.userProgress.progressPercentage)
" "
:height="4" :height="4"
:border-radius="2" :border-radius="2"
style="width: 100px" style="width: 100px"
:color=" :color="
getProgressColor( getProgressColor(problemSet.userProgress.progressPercentage)
problemSet.user_progress.progress_percentage,
)
" "
/> />
<n-tag type="warning" v-if="problemSet.status === 'archived'"> <n-tag type="warning" v-if="problemSet.status === 'archived'">
@@ -192,17 +190,14 @@ watch(
</n-tag> </n-tag>
<n-tag <n-tag
v-if=" v-if="
problemSet.user_progress?.is_joined && problemSet.userProgress?.isJoined &&
!problemSet.user_progress?.is_completed !problemSet.userProgress?.isCompleted
" "
type="warning" type="warning"
> >
已加入 已加入
</n-tag> </n-tag>
<n-tag <n-tag v-if="problemSet.userProgress?.isCompleted" type="error">
v-if="problemSet.user_progress?.is_completed"
type="error"
>
已完成 已完成
</n-tag> </n-tag>
</n-flex> </n-flex>
@@ -212,7 +207,7 @@ watch(
<n-flex align="center" justify="space-between"> <n-flex align="center" justify="space-between">
<n-text depth="3"> <n-text depth="3">
创建于 创建于
{{ parseTime(problemSet.create_time, "YYYY-MM-DD") }} {{ parseTime(problemSet.createTime, "YYYY-MM-DD") }}
</n-text> </n-text>
<n-flex> <n-flex>
<n-tooltip <n-tooltip
@@ -227,7 +222,7 @@ watch(
width="24" width="24"
height="24" height="24"
object-fit="cover" object-fit="cover"
:class="{ 'earned-badge': badge.is_earned }" :class="{ 'earned-badge': badge.isEarned }"
/> />
</template> </template>
<n-flex vertical size="small"> <n-flex vertical size="small">
@@ -238,12 +233,12 @@ watch(
获取条件: 获取条件:
{{ {{
getConditionText( getConditionText(
badge.condition_type, badge.conditionType,
badge.condition_value, badge.conditionValue,
) )
}} }}
</span> </span>
<n-text type="primary" v-if="badge.is_earned"> <n-text type="primary" v-if="badge.isEarned">
✓ 已获得 ✓ 已获得
</n-text> </n-text>
</n-flex> </n-flex>

View File

@@ -31,7 +31,7 @@ const data = computed(() => {
const datasets: any[] = [ const datasets: any[] = [
{ {
label: props.type === ChartType.Rank ? "已解决" : "做题数", label: props.type === ChartType.Rank ? "已解决" : "做题数",
data: props.rankData.map((rank) => rank.accepted_number), data: props.rankData.map((rank) => rank.acceptedNumber),
backgroundColor: [ backgroundColor: [
"rgba(255, 99, 132, 0.2)", "rgba(255, 99, 132, 0.2)",
"rgba(255, 159, 64, 0.2)", "rgba(255, 159, 64, 0.2)",
@@ -87,7 +87,7 @@ const data = computed(() => {
if (props.type === ChartType.Rank) { if (props.type === ChartType.Rank) {
datasets.push({ datasets.push({
label: "总提交数", label: "总提交数",
data: props.rankData.map((rank) => rank.submission_number), data: props.rankData.map((rank) => rank.submissionNumber),
hidden: true, hidden: true,
}) })
} }

View File

@@ -1,4 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type {
ClassComparison,
ClassRankItem as ClassRank,
ClassUserRank,
Rank,
} from "utils/types"
import { formatISO, sub, type Duration } from "date-fns" import { formatISO, sub, type Duration } from "date-fns"
import { NButton, NFlex } from "naive-ui" import { NButton, NFlex } from "naive-ui"
import { import {
@@ -10,7 +16,6 @@ import {
} from "oj/api" } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { getACRate, getCSRFToken } from "utils/functions" import { getACRate, getCSRFToken } from "utils/functions"
import type { Rank } from "utils/types"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { ChartType } from "utils/constants" import { ChartType } from "utils/constants"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
@@ -39,6 +44,7 @@ const query = reactive({
limit: 10, limit: 10,
page: 1, page: 1,
}) })
const message = useMessage()
const rankChart = ref<Rank[]>([]) const rankChart = ref<Rank[]>([])
const activityChart = ref<Rank[]>([]) const activityChart = ref<Rank[]>([])
const duration = ref("months:1") const duration = ref("months:1")
@@ -46,7 +52,7 @@ const classData = ref<ClassRank[]>([])
const classQuery = reactive({ const classQuery = reactive({
grade: gradeOptions[0].value, grade: gradeOptions[0].value,
}) })
const myClassData = ref<UserRank[]>([]) const myClassData = ref<ClassUserRank["ranks"]>([])
const myRank = ref(-1) const myRank = ref(-1)
const myClassName = ref("") const myClassName = ref("")
const myClassScope = ref<"window" | "all">("window") const myClassScope = ref<"window" | "all">("window")
@@ -137,44 +143,6 @@ async function analyzeSingleClassWithAI() {
} }
} }
interface ClassRank {
rank: number
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
ac_rate: number
}
interface ClassComparison {
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
median_ac: number
q1_ac: number
q3_ac: number
iqr: number
std_dev: number
top10_avg: number
middle80_avg: number
bottom10_avg: number
excellent_rate: number
pass_rate: number
active_rate: number
ac_rate: number
composite_score: number
}
interface UserRank {
rank: number
username: string
accepted_number: number
submission_number: number
}
async function init() { async function init() {
const offset = (query.page - 1) * query.limit const offset = (query.page - 1) * query.limit
const res = await getRank(offset, query.limit, 100) const res = await getRank(offset, query.limit, 100)
@@ -251,7 +219,7 @@ const columns: DataTableColumn<Rank>[] = [
key: "rate", key: "rate",
width: 120, width: 120,
align: "center", align: "center",
render: (row) => getACRate(row.accepted_number, row.submission_number), render: (row) => getACRate(row.acceptedNumber, row.submissionNumber),
}, },
] ]
@@ -269,15 +237,14 @@ async function listActivity() {
const current = Date.now() const current = Date.now()
const start = formatISO(sub(current, subOptions.value)) const start = formatISO(sub(current, subOptions.value))
const res = await getActivityRank(start) const res = await getActivityRank(start)
activityChart.value = res.data.map( // 活动榜只有「用户名 + 做题数」,塞进榜单图表复用的 Rank 形状里
(d: { username: string; count: number }) => ({ activityChart.value = res.data.map((d, index) => ({
user: { id: index,
username: d.username, user: { id: index, username: d.username, realName: null },
}, acceptedNumber: d.count,
accepted_number: d.count, submissionNumber: 0,
submission_number: 0, mood: null,
}), }))
)
} }
async function listRank() { async function listRank() {
@@ -321,7 +288,7 @@ const classColumns: DataTableColumn<ClassRank>[] = [
title: "班级", title: "班级",
key: "class_name", key: "class_name",
render: (row) => render: (row) =>
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}`, `${row.className.slice(0, 2)}计算机${row.className.slice(2)}`,
minWidth: 120, minWidth: 120,
titleAlign: "center", titleAlign: "center",
align: "center", align: "center",
@@ -360,7 +327,7 @@ const classColumns: DataTableColumn<ClassRank>[] = [
width: 90, width: 90,
titleAlign: "center", titleAlign: "center",
align: "center", align: "center",
render: (row) => `${row.ac_rate}%`, render: (row) => `${row.acRate}%`,
}, },
{ {
title: "详情", title: "详情",
@@ -374,14 +341,14 @@ const classColumns: DataTableColumn<ClassRank>[] = [
{ {
text: true, text: true,
type: "info", type: "info",
onClick: () => loadClassDetail(row.class_name), onClick: () => loadClassDetail(row.className),
}, },
() => "查看", () => "查看",
), ),
}, },
] ]
const myClassColumns: DataTableColumn<UserRank>[] = [ const myClassColumns: DataTableColumn<ClassUserRank["ranks"][number]>[] = [
{ {
title: "排名", title: "排名",
key: "rank", key: "rank",
@@ -448,7 +415,7 @@ async function listClassRank() {
if (!userStore.user) { if (!userStore.user) {
await userStore.getMyProfile() await userStore.getMyProfile()
} }
const className = userStore.user?.class_name const className = userStore.user?.className
if (className) { if (className) {
classQuery.grade = parseInt(className.slice(0, 2)) classQuery.grade = parseInt(className.slice(0, 2))
} }
@@ -464,8 +431,8 @@ async function listMyClassRank() {
: 0 : 0
const limit = myClassScope.value === "all" ? myClassQuery.limit : undefined const limit = myClassScope.value === "all" ? myClassQuery.limit : undefined
const res = await getUserClassRank(myClassScope.value, offset, limit) const res = await getUserClassRank(myClassScope.value, offset, limit)
myRank.value = res.data.my_rank myRank.value = res.data.myRank
myClassName.value = res.data.class_name myClassName.value = res.data.className
myClassData.value = res.data.ranks myClassData.value = res.data.ranks
myClassTotal.value = res.data.total ?? res.data.ranks.length myClassTotal.value = res.data.total ?? res.data.ranks.length
if (myClassScope.value === "window") { if (myClassScope.value === "window") {
@@ -610,7 +577,7 @@ watch(
preset="card" preset="card"
:title=" :title="
classDetailData classDetailData
? `${classDetailData.class_name.slice(0, 2)}计算机${classDetailData.class_name.slice(2)}班` ? `${classDetailData.className.slice(0, 2)}计算机${classDetailData.className.slice(2)}班`
: '班级详情' : '班级详情'
" "
:style="{ width: '700px', maxWidth: '95vw' }" :style="{ width: '700px', maxWidth: '95vw' }"
@@ -621,7 +588,7 @@ watch(
<n-gi> <n-gi>
<n-statistic <n-statistic
label="总AC数" label="总AC数"
:value="classDetailData.total_ac" :value="classDetailData.totalAc"
size="large" size="large"
class="stat-total-ac" class="stat-total-ac"
> >
@@ -633,7 +600,7 @@ watch(
<n-gi> <n-gi>
<n-statistic <n-statistic
label="平均AC数" label="平均AC数"
:value="classDetailData.avg_ac.toFixed(2)" :value="classDetailData.avgAc.toFixed(2)"
size="large" size="large"
class="stat-avg-ac" class="stat-avg-ac"
> >
@@ -648,7 +615,7 @@ watch(
<n-gi> <n-gi>
<n-statistic <n-statistic
label="中位数AC数" label="中位数AC数"
:value="classDetailData.median_ac.toFixed(2)" :value="classDetailData.medianAc.toFixed(2)"
size="large" size="large"
class="stat-median-ac" class="stat-median-ac"
> >
@@ -663,7 +630,7 @@ watch(
<n-gi> <n-gi>
<n-statistic <n-statistic
label="总提交数" label="总提交数"
:value="classDetailData.total_submission" :value="classDetailData.totalSubmission"
size="large" size="large"
class="stat-total-submission" class="stat-total-submission"
> >
@@ -678,7 +645,7 @@ watch(
<n-gi> <n-gi>
<n-statistic <n-statistic
label="AC率" label="AC率"
:value="classDetailData.ac_rate.toFixed(1) + '%'" :value="classDetailData.acRate.toFixed(1) + '%'"
size="large" size="large"
class="stat-ac-rate" class="stat-ac-rate"
> >
@@ -699,12 +666,12 @@ watch(
> >
<n-descriptions-item label="第一四分位数(Q1)"> <n-descriptions-item label="第一四分位数(Q1)">
<span style="color: #9254de; font-weight: 500">{{ <span style="color: #9254de; font-weight: 500">{{
classDetailData.q1_ac.toFixed(2) classDetailData.q1Ac.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="第三四分位数(Q3)"> <n-descriptions-item label="第三四分位数(Q3)">
<span style="color: #f759ab; font-weight: 500">{{ <span style="color: #f759ab; font-weight: 500">{{
classDetailData.q3_ac.toFixed(2) classDetailData.q3Ac.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="四分位距(IQR)"> <n-descriptions-item label="四分位距(IQR)">
@@ -714,27 +681,27 @@ watch(
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="标准差"> <n-descriptions-item label="标准差">
<span style="color: #fa8c16; font-weight: 500">{{ <span style="color: #fa8c16; font-weight: 500">{{
classDetailData.std_dev.toFixed(2) classDetailData.stdDev.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="前10%均值"> <n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{ <span style="color: #cf1322; font-weight: 600">{{
classDetailData.top10_avg.toFixed(2) classDetailData.top10Avg.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="中间80%均值"> <n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{ <span style="color: #389e0d; font-weight: 600">{{
classDetailData.middle80_avg.toFixed(2) classDetailData.middle80Avg.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="后10%均值"> <n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{ <span style="color: #096dd9; font-weight: 500">{{
classDetailData.bottom10_avg.toFixed(2) classDetailData.bottom10Avg.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="人数"> <n-descriptions-item label="人数">
<span style="color: #1890ff; font-weight: 600">{{ <span style="color: #1890ff; font-weight: 600">{{
classDetailData.user_count classDetailData.userCount
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
</n-descriptions> </n-descriptions>
@@ -743,35 +710,35 @@ watch(
<n-space vertical :size="10"> <n-space vertical :size="10">
<n-progress <n-progress
type="line" type="line"
:percentage="classDetailData.excellent_rate" :percentage="classDetailData.excellentRate"
:show-indicator="true" :show-indicator="true"
:border-radius="4" :border-radius="4"
> >
<template #default <template #default
>优秀率: >优秀率:
{{ classDetailData.excellent_rate.toFixed(1) }}%</template {{ classDetailData.excellentRate.toFixed(1) }}%</template
> >
</n-progress> </n-progress>
<n-progress <n-progress
type="line" type="line"
:percentage="classDetailData.pass_rate" :percentage="classDetailData.passRate"
:show-indicator="true" :show-indicator="true"
:border-radius="4" :border-radius="4"
status="success" status="success"
> >
<template #default <template #default
>及格率: {{ classDetailData.pass_rate.toFixed(1) }}%</template >及格率: {{ classDetailData.passRate.toFixed(1) }}%</template
> >
</n-progress> </n-progress>
<n-progress <n-progress
type="line" type="line"
:percentage="classDetailData.active_rate" :percentage="classDetailData.activeRate"
:show-indicator="true" :show-indicator="true"
:border-radius="4" :border-radius="4"
status="info" status="info"
> >
<template #default <template #default
>参与度: {{ classDetailData.active_rate.toFixed(1) }}%</template >参与度: {{ classDetailData.activeRate.toFixed(1) }}%</template
> >
</n-progress> </n-progress>
</n-space> </n-space>
@@ -784,7 +751,7 @@ watch(
style="margin-top: 12px" style="margin-top: 12px"
> >
<n-tag type="success" size="large"> <n-tag type="success" size="large">
综合分: {{ classDetailData.composite_score.toFixed(1) }} 综合分: {{ classDetailData.compositeScore.toFixed(1) }}
</n-tag> </n-tag>
<n-button <n-button
type="info" type="info"

View File

@@ -13,13 +13,14 @@ export const useAIStore = defineStore("ai", () => {
const targetUsername = ref("") const targetUsername = ref("")
const durationData = ref<DurationData[]>([]) const durationData = ref<DurationData[]>([])
const detailsData = reactive<DetailsData>({ const detailsData = reactive<DetailsData>({
user: "",
start: "", start: "",
end: "", end: "",
grade: "B", grade: "",
class_name: "", className: null,
tags: {}, tags: {},
difficulty: {}, difficulty: {},
contest_count: 0, contestCount: 0,
solved: [], solved: [],
flowcharts: [], flowcharts: [],
}) })
@@ -44,10 +45,10 @@ export const useAIStore = defineStore("ai", () => {
detailsData.end = res.data.end detailsData.end = res.data.end
detailsData.solved = res.data.solved detailsData.solved = res.data.solved
detailsData.grade = res.data.grade detailsData.grade = res.data.grade
detailsData.class_name = res.data.class_name detailsData.className = res.data.className
detailsData.tags = res.data.tags detailsData.tags = res.data.tags
detailsData.difficulty = res.data.difficulty detailsData.difficulty = res.data.difficulty
detailsData.contest_count = res.data.contest_count detailsData.contestCount = res.data.contestCount
detailsData.flowcharts = res.data.flowcharts detailsData.flowcharts = res.data.flowcharts
} }

View File

@@ -21,8 +21,8 @@ export const useContestStore = defineStore("contest", () => {
const contestStatus = computed<ContestStatus>(() => { const contestStatus = computed<ContestStatus>(() => {
if (!contest.value) return ContestStatus.initial if (!contest.value) return ContestStatus.initial
const start = getTime(parseISO(contest.value.start_time.toString())) const start = getTime(parseISO(contest.value.startTime.toString()))
const end = getTime(parseISO(contest.value.end_time.toString())) const end = getTime(parseISO(contest.value.endTime.toString()))
if (start > now.value) { if (start > now.value) {
return ContestStatus.not_started return ContestStatus.not_started
} else if (end < now.value) { } else if (end < now.value) {
@@ -36,10 +36,10 @@ export const useContestStore = defineStore("contest", () => {
if (contestStatus.value === ContestStatus.finished) { if (contestStatus.value === ContestStatus.finished) {
return "已结束" return "已结束"
} else if (contestStatus.value === ContestStatus.not_started) { } else if (contestStatus.value === ContestStatus.not_started) {
const d = duration(formatISO(now.value), contest.value!.start_time, true) const d = duration(formatISO(now.value), contest.value!.startTime, true)
return "距离比赛开始 " + d return "距离比赛开始 " + d
} else { } else {
const d = duration(formatISO(now.value), contest.value!.end_time, true) const d = duration(formatISO(now.value), contest.value!.endTime, true)
return "距离比赛结束 " + d return "距离比赛结束 " + d
} }
}) })
@@ -48,11 +48,11 @@ export const useContestStore = defineStore("contest", () => {
() => () =>
userStore.isSuperAdmin || userStore.isSuperAdmin ||
(userStore.isAuthed && (userStore.isAuthed &&
contest.value?.created_by.id === userStore.user!.id), contest.value?.createdBy.id === userStore.user!.id),
) )
const isPrivate = computed( const isPrivate = computed(
() => contest.value!.contest_type === ContestType.private, () => contest.value!.contestType === ContestType.private,
) )
async function init(contestID: string) { async function init(contestID: string) {
@@ -65,7 +65,7 @@ export const useContestStore = defineStore("contest", () => {
now.value = now.value + 1000 now.value = now.value + 1000
}, 1000) }, 1000)
} }
if (contest.value?.contest_type === ContestType.private) { if (contest.value?.contestType === ContestType.private) {
const res = await getContestAccess(contestID) const res = await getContestAccess(contestID)
toggleAccess(res.data.access) toggleAccess(res.data.access)
} }

View File

@@ -8,7 +8,7 @@ export const useProblemStore = defineStore("problem", () => {
const failCount = ref(0) const failCount = ref(0)
const languages = computed<LANGUAGE[]>(() => { const languages = computed<LANGUAGE[]>(() => {
if (route.name === "problem" && problem.value?.allow_flowchart) { if (route.name === "problem" && problem.value?.allowFlowchart) {
return ["Flowchart", ...problem.value?.languages] return ["Flowchart", ...problem.value?.languages]
} }
return problem.value?.languages ?? [] return problem.value?.languages ?? []

View File

@@ -5,7 +5,7 @@
<n-card title="流程图预览"> <n-card title="流程图预览">
<template #header-extra> <template #header-extra>
<n-button <n-button
v-if="!renderError && submission?.mermaid_code" v-if="!renderError && submission?.mermaidCode"
quaternary quaternary
size="small" size="small"
@click="showLargeImage = true" @click="showLargeImage = true"
@@ -42,12 +42,12 @@
<n-gi :span="2"> <n-gi :span="2">
<!-- AI反馈 --> <!-- AI反馈 -->
<n-card <n-card
v-if="submission.ai_feedback" v-if="submission.aiFeedback"
size="small" size="small"
title="AI反馈" title="AI反馈"
style="margin-bottom: 16px" style="margin-bottom: 16px"
> >
<n-text>{{ submission.ai_feedback }}</n-text> <n-text>{{ submission.aiFeedback }}</n-text>
</n-card> </n-card>
<!-- 改进建议 --> <!-- 改进建议 -->
@@ -69,15 +69,12 @@
<!-- 详细评分 --> <!-- 详细评分 -->
<n-card <n-card
v-if=" v-if="Object.keys(criteriaDetails).length > 0"
submission.ai_criteria_details &&
Object.keys(submission.ai_criteria_details).length > 0
"
size="small" size="small"
title="详细评分" title="详细评分"
> >
<div <div
v-for="(detail, key) in submission.ai_criteria_details" v-for="(detail, key) in criteriaDetails"
:key="key" :key="key"
style="margin-bottom: 12px" style="margin-bottom: 12px"
> >
@@ -121,11 +118,38 @@ const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
const { renderError, renderFlowchart } = useMermaid() const { renderError, renderFlowchart } = useMermaid()
const submission = ref<FlowchartSubmission | null>(null) const submission = ref<FlowchartSubmission | null>(null)
/**
* 评分项明细。契约里是 `Record<string, unknown>` —— 内容是 AI 模型原样吐出的 JSON
* 后端不校验形状,所以这里只能按约定断言,字段缺失时用 0 / 空串兜底。
*/
const criteriaDetails = computed<
Record<string, { score: number; max: number; comment: string }>
>(() => {
const raw = submission.value?.aiCriteriaDetails ?? {}
return Object.fromEntries(
Object.entries(raw).map(([key, value]) => {
const item = (value ?? {}) as Partial<{
score: number
max: number
comment: string
}>
return [
key,
{
score: item.score ?? 0,
max: item.max ?? 0,
comment: item.comment ?? "",
},
]
}),
)
})
const loading = ref(false) const loading = ref(false)
const rendering = ref(false) const rendering = ref(false)
const showLargeImage = ref(false) const showLargeImage = ref(false)
const suggestionLines = computed(() => const suggestionLines = computed(() =>
splitSuggestionLines(submission.value?.ai_suggestions), splitSuggestionLines(submission.value?.aiSuggestions),
) )
function splitSuggestionLines(suggestions?: string | null) { function splitSuggestionLines(suggestions?: string | null) {
@@ -154,12 +178,12 @@ async function loadSubmission() {
submission.value = res.data submission.value = res.data
// 渲染流程图 // 渲染流程图
if (submission.value?.mermaid_code) { if (submission.value?.mermaidCode) {
rendering.value = true rendering.value = true
await nextTick() await nextTick()
await renderFlowchart( await renderFlowchart(
mermaidContainer.value, mermaidContainer.value,
submission.value.mermaid_code, submission.value.mermaidCode,
) )
rendering.value = false rendering.value = false
} }

View File

@@ -14,6 +14,7 @@ defineProps<{
function gradeType(grade: Grade) { function gradeType(grade: Grade) {
return ( return (
{ {
"": "default",
S: "success", S: "success",
A: "info", A: "info",
B: "warning", B: "warning",

View File

@@ -1,5 +1,5 @@
<template> <template>
<n-flex v-if="props.submission.show_link" align="center"> <n-flex v-if="props.submission.showLink" align="center">
<n-button text type="info" @click="$emit('showCode')"> <n-button text type="info" @click="$emit('showCode')">
{{ props.submission.id.slice(0, 12) }} {{ props.submission.id.slice(0, 12) }}
</n-button> </n-button>
@@ -43,7 +43,7 @@ defineEmits(["showCode"])
const userStore = useUserStore() const userStore = useUserStore()
const isOwnSubmission = computed( const isOwnSubmission = computed(
() => userStore.profile?.user?.id === props.submission.user_id, () => userStore.profile?.user?.id === props.submission.userId,
) )
function goto() { function goto() {

View File

@@ -77,10 +77,10 @@ function copyToCat() {
} }
function copyToProblem() { function copyToProblem() {
const { code, language, contest } = submission.value! const { code, language, contestId } = submission.value!
// 编辑器的 storageKey 用 display idproblem._id等于 props.problemID // 编辑器的 storageKey 用 display idproblem._id等于 props.problemID
// 而非 submission.problem内部数字 id // 而非 submission.problem内部数字 id
const contestIDForKey = contest || null const contestIDForKey = contestId || null
const storageKey = `problem_${props.problemID}_contest_${contestIDForKey}_lang_${language}` const storageKey = `problem_${props.problemID}_contest_${contestIDForKey}_lang_${language}`
storage.set(storageKey, code) storage.set(storageKey, code)
// 设置语言 + 代码localStorage 覆盖全新挂载的编辑器, // 设置语言 + 代码localStorage 覆盖全新挂载的编辑器,
@@ -89,10 +89,10 @@ function copyToProblem() {
codeStore.setCode(code) codeStore.setCode(code)
const problemSetId = (route.params.problemSetId as string) ?? "" const problemSetId = (route.params.problemSetId as string) ?? ""
if (contest) { if (contestId) {
router.push({ router.push({
name: "contest problem", name: "contest problem",
params: { contestID: String(contest), problemID: props.problemID }, params: { contestID: String(contestId), problemID: props.problemID },
}) })
} else if (problemSetId) { } else if (problemSetId) {
router.push({ router.push({
@@ -121,7 +121,7 @@ onMounted(init)
:title="JUDGE_STATUS[submission.result]['title']" :title="JUDGE_STATUS[submission.result]['title']"
> >
<n-flex> <n-flex>
<span>提交时间{{ parseTime(submission.create_time) }}</span> <span>提交时间{{ parseTime(submission.createTime) }}</span>
<span>编程语言{{ LANGUAGE_SHOW_VALUE[submission.language] }}</span> <span>编程语言{{ LANGUAGE_SHOW_VALUE[submission.language] }}</span>
<span>用户{{ submission.username }}</span> <span>用户{{ submission.username }}</span>
</n-flex> </n-flex>

View File

@@ -9,6 +9,7 @@ import {
retryFlowchartSubmission, retryFlowchartSubmission,
} from "oj/api" } from "oj/api"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { Grade as GradeValue } from "utils/types"
import type { import type {
FlowchartSubmissionListItem, FlowchartSubmissionListItem,
LANGUAGE, LANGUAGE,
@@ -101,7 +102,7 @@ async function listSubmissions() {
if (query.language === "Flowchart") { if (query.language === "Flowchart") {
const res = await getFlowchartSubmissions({ const res = await getFlowchartSubmissions({
username: query.username, username: query.username,
problem_id: query.problem, problemId: query.problem,
myself: query.myself, myself: query.myself,
offset, offset,
limit: query.limit, limit: query.limit,
@@ -114,8 +115,8 @@ async function listSubmissions() {
const res = await getSubmissions({ const res = await getSubmissions({
...query, ...query,
offset, offset,
problem_id: query.problem, problemId: query.problem,
contest_id: (route.params.contestID as string) ?? "", contestId: (route.params.contestID as string) ?? "",
language: query.language, language: query.language,
today: query.today, today: query.today,
}) })
@@ -233,7 +234,7 @@ const columns = computed(() => {
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"), title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
key: "create_time", key: "create_time",
minWidth: 200, minWidth: 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: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"), title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
@@ -263,7 +264,7 @@ const columns = computed(() => {
onClick: () => problemClicked(row), onClick: () => problemClicked(row),
onSearch: () => (query.problem = row.problem), onSearch: () => (query.problem = row.problem),
}, },
() => `${row.problem} ${row.problem_title}`, () => `${row.problem} ${row.problemTitle}`,
), ),
}, },
{ {
@@ -318,7 +319,7 @@ const flowchartColumns = computed(() => {
{ {
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"), title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
key: "create_time", 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"),
}, },
{ {
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"), title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
@@ -340,7 +341,7 @@ const flowchartColumns = computed(() => {
onClick: () => problemClicked(row), onClick: () => problemClicked(row),
onSearch: () => (query.problem = row.problem), onSearch: () => (query.problem = row.problem),
}, },
() => `${row.problem} ${row.problem_title}`, () => `${row.problem} ${row.problemTitle}`,
), ),
}, },
{ {
@@ -349,7 +350,11 @@ const flowchartColumns = computed(() => {
"streamline-ultimate-color:analytics-bars-3d", "streamline-ultimate-color:analytics-bars-3d",
), ),
key: "ai_score", key: "ai_score",
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }), render: (row) =>
h(Grade, {
score: row.aiScore ?? 0,
grade: (row.aiGrade ?? "") as GradeValue,
}),
}, },
{ {
title: renderTableTitle( title: renderTableTitle(
@@ -545,9 +550,9 @@ const flowchartColumns = computed(() => {
<n-text>流程图评分详情</n-text> <n-text>流程图评分详情</n-text>
<n-text <n-text
v-if="selectedFlowchart" v-if="selectedFlowchart"
:type="getGradeType(selectedFlowchart.ai_grade)" :type="getGradeType(selectedFlowchart.aiGrade ?? '')"
> >
{{ selectedFlowchart.ai_score }} {{ selectedFlowchart.ai_grade }} {{ selectedFlowchart.aiScore }} {{ selectedFlowchart.aiGrade }}
</n-text> </n-text>
</n-flex> </n-flex>
</template> </template>

View File

@@ -1,29 +1,27 @@
import { DIFFICULTY } from "utils/constants" import { DIFFICULTY } from "utils/constants"
import { getACRate } from "utils/functions" import { getACRate } from "utils/functions"
import type { Problem } from "utils/types" import type { ProblemFiltered, ProblemListItem } from "utils/types"
// 把后端的 Problem 塑形成列表需要的形状,与请求逻辑解耦。 // 把后端的列表项塑形成列表需要的形状,与请求逻辑解耦。
export function filterResult(result: Problem) { export function filterResult(result: ProblemListItem): ProblemFiltered {
const newResult = { return {
id: result.id, id: result.id,
_id: result._id, _id: result._id,
title: result.title, title: result.title,
difficulty: DIFFICULTY[result.difficulty], difficulty: DIFFICULTY[result.difficulty],
tags: result.tags, tags: result.tags,
submission: result.submission_number, submission: result.submissionNumber,
rate: getACRate(result.accepted_number, result.submission_number), rate: getACRate(result.acceptedNumber, result.submissionNumber),
status: "", // null / undefined 都表示「没做过」
author: result.created_by.username, status:
allow_flowchart: result.allow_flowchart, result.myStatus === null || result.myStatus === undefined
show_flowchart: result.show_flowchart, ? "not_test"
has_ast_rules: result.has_ast_rules, : result.myStatus === 0
? "passed"
: "failed",
author: result.createdBy.username,
allowFlowchart: result.allowFlowchart,
showFlowchart: result.showFlowchart,
hasAstRules: result.hasAstRules,
} }
if (result.my_status === null || result.my_status === undefined) {
newResult.status = "not_test"
} else if (result.my_status === 0) {
newResult.status = "passed"
} else {
newResult.status = "failed"
}
return newResult
} }

View File

@@ -64,7 +64,7 @@ async function init() {
try { try {
const res = await getProfile(route.query.name as string) const res = await getProfile(route.query.name as string)
profile.value = res.data profile.value = res.data
const acm = res.data.acm_problems_status.problems || {} const acm = res.data!.acmProblemsStatus.problems || {}
const ac: string[] = [] const ac: string[] = []
Object.keys(acm).forEach((id) => { Object.keys(acm).forEach((id) => {
if (acm[id]["status"] === 0) { if (acm[id]["status"] === 0) {
@@ -74,7 +74,7 @@ async function init() {
ac.sort() ac.sort()
problems.value = ac problems.value = ac
if (profile.value.submission_number > 0) { if (profile.value.submissionNumber > 0) {
const metricsRes = await getMetrics(profile.value.user.id) const metricsRes = await getMetrics(profile.value.user.id)
firstSubmissionAt.value = parseTime(metricsRes.data.first) firstSubmissionAt.value = parseTime(metricsRes.data.first)
latestSubmissionAt.value = parseTime(metricsRes.data.latest) latestSubmissionAt.value = parseTime(metricsRes.data.latest)
@@ -130,13 +130,13 @@ const metrics = computed(() => {
}, },
{ {
icon: "fluent-emoji:candy", icon: "fluent-emoji:candy",
title: profile.value?.accepted_number ?? 0, title: profile.value?.acceptedNumber ?? 0,
content: "已解决的题目数量", content: "已解决的题目数量",
animate: true, animate: true,
}, },
{ {
icon: "fluent-emoji:thinking-face", icon: "fluent-emoji:thinking-face",
title: profile.value?.submission_number ?? 0, title: profile.value?.submissionNumber ?? 0,
content: "总提交数量", content: "总提交数量",
animate: true, animate: true,
}, },
@@ -186,7 +186,7 @@ onMounted(() => {
</n-flex> </n-flex>
<n-grid <n-grid
v-if="profile && profile.submission_number > 0" v-if="profile && profile.submissionNumber > 0"
class="wrapper" class="wrapper"
:cols="isDesktop ? 2 : 1" :cols="isDesktop ? 2 : 1"
:x-gap="10" :x-gap="10"

View File

@@ -8,7 +8,7 @@
<n-flex size="large" vertical> <n-flex size="large" vertical>
<n-flex align="center"> <n-flex align="center">
<div>发送时间</div> <div>发送时间</div>
<div>{{ parseTime(item.create_time, "YYYY年M月D日 HH:mm:ss") }}</div> <div>{{ parseTime(item.createTime, "YYYY年M月D日 HH:mm:ss") }}</div>
<div>发送者</div> <div>发送者</div>
<div>{{ item.sender.username }}</div> <div>{{ item.sender.username }}</div>
</n-flex> </n-flex>

View File

@@ -30,7 +30,7 @@ async function upload({ file }: UploadCustomRequestOptions) {
async function saveProfile() { async function saveProfile() {
try { try {
await updateProfile({ await updateProfile({
real_name: userStore.profile?.real_name ?? "", realName: userStore.profile?.realName ?? "",
mood: userStore.profile?.mood ?? "", mood: userStore.profile?.mood ?? "",
}) })
message.success("更改成功") message.success("更改成功")
@@ -55,7 +55,7 @@ async function saveProfile() {
</n-upload> </n-upload>
</n-form-item> </n-form-item>
<!-- <n-form-item label="真名"> <!-- <n-form-item label="真名">
<n-input v-model:value="userStore.profile.real_name" /> <n-input v-model:value="userStore.profile.realName" />
</n-form-item> --> </n-form-item> -->
<n-form-item label="个性签名"> <n-form-item label="个性签名">
<n-input v-model:value="userStore.profile.mood" /> <n-input v-model:value="userStore.profile.mood" />

View File

@@ -1,6 +1,6 @@
import { userProfileSchema } from "@oj2/contract" import { userProfileSchema } from "@oj2/contract"
import api2 from "utils/api2" import api2 from "utils/api2"
import type { ApiResponse } from "utils/http" import type { ApiResponse } from "utils/api2"
import type { Profile, Tag } from "utils/types" import type { Profile, Tag } from "utils/types"
export function login(data: { username: string; password: string }) { export function login(data: { username: string; password: string }) {
@@ -26,37 +26,10 @@ export async function getProfile(
username ? `profiles/${encodeURIComponent(username)}` : "me", username ? `profiles/${encodeURIComponent(username)}` : "me",
) )
if (response.data === null) return { error: null, data: null } if (response.data === null) return { error: null, data: null }
const profile = userProfileSchema.parse(response.data) // 形状与契约一致不再逐字段搬运zod 解析仍保留,形状对不上要当场炸
return { return {
error: null, error: null,
data: { data: userProfileSchema.parse(response.data) as Profile,
id: profile.id,
user: {
id: profile.user.id,
username: profile.user.username,
real_name: profile.realName ?? "",
email: profile.user.email ?? "",
admin_type: profile.user.adminType as Profile["user"]["admin_type"],
problem_permission: profile.user.problemPermission,
create_time: profile.user.createTime as unknown as Date,
last_login: profile.user.lastLogin as unknown as Date,
open_api: profile.user.openApi,
is_disabled: profile.user.isDisabled,
class_name: profile.user.className,
},
real_name: profile.realName ?? "",
acm_problems_status:
profile.acmProblemsStatus as Profile["acm_problems_status"],
avatar: profile.avatar,
blog: profile.blog as null,
mood: profile.mood ?? "",
github: profile.github ?? "",
school: profile.school ?? "",
major: profile.major ?? "",
language: profile.language ?? "",
accepted_number: profile.acceptedNumber,
submission_number: profile.submissionNumber,
},
} }
} }

View File

@@ -24,12 +24,10 @@ const authorOptions = ref([{ label: "全部", value: "" }])
async function getAuthorOptions() { async function getAuthorOptions() {
authorOptions.value = [{ label: "全部", value: "" }] authorOptions.value = [{ label: "全部", value: "" }]
const res = await getAuthors(all) const res = await getAuthors(all)
const remotes = res.data.map( const remotes = res.data.map((item) => ({
(item: { username: string; problem_count: number }) => ({ label: `${item.username} (${item.problemCount})`,
label: `${item.username} (${item.problem_count})`, value: item.username,
value: item.username, }))
}),
)
authorOptions.value = [...authorOptions.value, ...remotes] authorOptions.value = [...authorOptions.value, ...remotes]
} }
</script> </script>

View File

@@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { ContestType } from "utils/constants" import { ContestType } from "utils/constants"
import type { Contest } from "utils/types" import type { Contest, OjContest } from "utils/types"
defineProps<{ contest: Contest }>() defineProps<{ contest: Contest | OjContest }>()
</script> </script>
<template> <template>
<n-flex> <n-flex>
<Icon <Icon
v-if="contest.contest_type === ContestType.private" v-if="contest.contestType === ContestType.private"
:height="24" :height="24"
icon="streamline-ultimate-color:shield-lock" icon="streamline-ultimate-color:shield-lock"
></Icon> ></Icon>

View File

@@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ContestType } from "utils/constants" import { ContestType } from "utils/constants"
import type { Contest } from "utils/types" import type { Contest, OjContest } from "utils/types"
interface Props { interface Props {
contest: Contest contest: Contest | OjContest
size?: "small" size?: "small"
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const isPrivate = computed( const isPrivate = computed(
() => props.contest.contest_type === ContestType.private, () => props.contest.contestType === ContestType.private,
) )
</script> </script>

View File

@@ -278,7 +278,7 @@ function handleMenuSelect(key: string) {
<n-flex align="center"> <n-flex align="center">
<n-flex align="center" class="title" @click="goHome"> <n-flex align="center" class="title" @click="goHome">
<Icon icon="streamline-emojis:dog" :height="30"></Icon> <Icon icon="streamline-emojis:dog" :height="30"></Icon>
<div>{{ configStore.config?.website_name }}</div> <div>{{ configStore.config?.websiteName }}</div>
<div v-if="showEnvVersion">({{ envVersion }})</div> <div v-if="showEnvVersion">({{ envVersion }})</div>
</n-flex> </n-flex>
<div> <div>
@@ -331,7 +331,7 @@ function handleMenuSelect(key: string) {
</n-button> </n-button>
<n-button <n-button
tertiary tertiary
v-if="configStore.config?.allow_register" v-if="configStore.config?.allowRegister"
@click="authStore.openSignupModal()" @click="authStore.openSignupModal()"
> >
注册 注册

View File

@@ -22,7 +22,7 @@ const isClassLogin = computed(() => Boolean(form.value.class))
const classList = computed<SelectOption[]>(() => { const classList = computed<SelectOption[]>(() => {
const defaults = [{ label: "没有我所在的班级", value: "" }] const defaults = [{ label: "没有我所在的班级", value: "" }]
const configs = const configs =
configStore.config?.class_list.map((item) => ({ configStore.config?.classList.map((item) => ({
label: `${item.slice(0, 2)}计算机${item.slice(2)}`, label: `${item.slice(0, 2)}计算机${item.slice(2)}`,
value: `ks${item}`, value: `ks${item}`,
})) ?? [] })) ?? []
@@ -53,9 +53,10 @@ async function submit() {
} }
await login(merged) await login(merged)
} catch (err: any) { } catch (err: any) {
if (err.data === "Your account has been disabled") { // 判错误码而不是错误文案:文案在后端,改一个字这里就静默掉进「无法登录」
if (err.error === "account-disabled") {
authStore.setLoginError("此账号已被封禁") authStore.setLoginError("此账号已被封禁")
} else if (err.data === "Invalid username or password") { } else if (err.error === "invalid-credentials") {
authStore.setLoginError("用户名或密码不正确") authStore.setLoginError("用户名或密码不正确")
} else { } else {
authStore.setLoginError("无法登录") authStore.setLoginError("无法登录")
@@ -177,12 +178,12 @@ onMounted(() => {
:loading="isLoading" :loading="isLoading"
@click="submit" @click="submit"
:style="{ :style="{
flex: configStore.config?.allow_register ? '0 0 auto' : '1', flex: configStore.config?.allowRegister ? '0 0 auto' : '1',
}" }"
> >
登录 登录
</n-button> </n-button>
<n-button v-if="configStore.config?.allow_register" @click="goSignup"> <n-button v-if="configStore.config?.allowRegister" @click="goSignup">
没有账号立即注册 没有账号立即注册
</n-button> </n-button>
</n-flex> </n-flex>

View File

@@ -33,33 +33,31 @@ const hasAnalysis = computed(() => !!loginSummaryStore.analysis)
<n-gi> <n-gi>
<n-statistic <n-statistic
label="新增题目" label="新增题目"
:value="loginSummaryStore.summary?.new_problem_count ?? 0" :value="loginSummaryStore.summary?.newProblemCount ?? 0"
/> />
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-statistic <n-statistic
label="提交次数" label="提交次数"
:value="loginSummaryStore.summary?.submission_count ?? 0" :value="loginSummaryStore.summary?.submissionCount ?? 0"
/> />
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-statistic <n-statistic
label="AC 次数" label="AC 次数"
:value="loginSummaryStore.summary?.accepted_count ?? 0" :value="loginSummaryStore.summary?.acceptedCount ?? 0"
/> />
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-statistic <n-statistic
label="AC 题目数" label="AC 题目数"
:value="loginSummaryStore.summary?.solved_count ?? 0" :value="loginSummaryStore.summary?.solvedCount ?? 0"
/> />
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-statistic <n-statistic
label="流程图提交" label="流程图提交"
:value=" :value="loginSummaryStore.summary?.flowchartSubmissionCount ?? 0"
loginSummaryStore.summary?.flowchart_submission_count ?? 0
"
/> />
</n-gi> </n-gi>
</n-grid> </n-grid>

View File

@@ -48,9 +48,9 @@ function submit() {
password: form.value.password, password: form.value.password,
}) })
} catch (err: any) { } catch (err: any) {
if (err.data === "Username already exists") { if (err.error === "username-exists") {
authStore.setSignupError("用户名已存在") authStore.setSignupError("用户名已存在")
} else if (err.data === "Email already exists") { } else if (err.error === "email-exists") {
authStore.setSignupError("邮箱已存在") authStore.setSignupError("邮箱已存在")
} else { } else {
authStore.setSignupError("无法注册") authStore.setSignupError("无法注册")

View File

@@ -15,7 +15,7 @@
<n-text strong>{{ badge.badge.name }}</n-text> <n-text strong>{{ badge.badge.name }}</n-text>
<n-tag type="info"> 获得条件{{ getConditionText() }} </n-tag> <n-tag type="info"> 获得条件{{ getConditionText() }} </n-tag>
<n-text depth="3"> <n-text depth="3">
获得时间{{ parseTime(badge.earned_time, "YYYY-MM-DD HH:mm:ss") }} 获得时间{{ parseTime(badge.earnedTime, "YYYY-MM-DD HH:mm:ss") }}
</n-text> </n-text>
</n-flex> </n-flex>
</n-card> </n-card>
@@ -38,15 +38,15 @@ function handleImageError(event: Event) {
} }
function getConditionText() { function getConditionText() {
const { condition_type, condition_value } = props.badge.badge const { conditionType, conditionValue } = props.badge.badge
switch (condition_type) { switch (conditionType) {
case "all_problems": case "all_problems":
return "完成所有题目" return "完成所有题目"
case "problem_count": case "problem_count":
return `完成 ${condition_value} 道题目` return `完成 ${conditionValue} 道题目`
case "score": case "score":
return `获得 ${condition_value}` return `获得 ${conditionValue}`
default: default:
return "未知条件" return "未知条件"
} }

Some files were not shown because too many files have changed in this diff Show More