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

@@ -2,11 +2,17 @@ import axios, { type AxiosRequestConfig } from "axios"
import { createDiscreteApi } from "naive-ui"
import { useAuthModalStore } from "shared/store/authModal"
import { STORAGE_KEY } from "./constants"
import type { ApiResponse } from "./http"
import storage from "./storage"
const { message: toast } = createDiscreteApi(["message"])
// 后端统一返回 { error, data } 信封;拦截器剥掉 axios 外层后,
// 调用方拿到的就是这个信封data 才是真正的业务数据。
export interface ApiResponse<T = any> {
error: string | null
data: T
}
interface Api2Error {
error?: {
code?: string
@@ -51,15 +57,10 @@ instance.interceptors.response.use(
const payload = error.response?.data as Api2Error | undefined
const code = payload?.error?.code ?? "network-error"
const message = payload?.error?.message ?? "Request failed"
const legacyMessage =
code === "invalid-credentials"
? "Invalid username or password"
: code === "account-disabled"
? "Your account has been disabled"
: message
// 与 utils/http.ts 的拦截器保持一致:这两种错误全站都是同样的处理
// 不放在这里的话每个调用点都得自己 catch漏一个就是「点了没反应」。
// 这几种错误全站都是同样的处理,不放在这里的话每个调用点都得自己 catch
// 漏一个就是「点了没反应」。需要分支处理的调用方一律判 `err.error` 里的
// 错误码,**不要**去 match `err.data` 的文案 —— 文案是后端可以随时改的。
if (code === "login-required") {
storage.remove(STORAGE_KEY.AUTHED)
useAuthModalStore().openLoginModal()
@@ -68,12 +69,12 @@ instance.interceptors.response.use(
// 学生会陷入「弹框 → 登录 → 又弹框」的死循环,且看不出发生了什么。
// 清掉登录态并明确告知,会话在中途被禁用时也走这一支。
storage.remove(STORAGE_KEY.AUTHED)
toast.error(legacyMessage || "账号已被禁用,请联系老师")
toast.error("账号已被禁用,请联系老师")
} else if (code === "permission-denied") {
toast.error(legacyMessage || "权限不足")
toast.error(message || "权限不足")
}
return Promise.reject({ error: code, data: legacyMessage })
return Promise.reject({ error: code, data: message })
},
)

View File

@@ -1,16 +1,22 @@
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
// 与后端 judge/status.ts 的 JudgeStatus 逐条对齐submitting 除外,见下)。
// 注意:原来 time_limit_exceeded 写成 `1 | 2`TS 按位或算成 3
// memory_limit_exceeded 撞了同一个值 —— 后端这两个是分开的两个码。
export enum SubmissionStatus {
compile_error = -2,
wrong_answer = -1,
accepted = 0,
time_limit_exceeded = 1 | 2,
cpu_time_limit_exceeded = 1,
real_time_limit_exceeded = 2,
memory_limit_exceeded = 3,
runtime_error = 4,
system_error = 5,
pending = 6,
judging = 7,
partial_accepted = 8,
// 前端自造的伪状态:点了提交、还没拿到判题结果时本地先填 9。
// 后端永远不会下发它,所以契约的 judgeStatusSchema 里没有。
submitting = 9,
ast_check_failed = 10,
}
@@ -157,7 +163,7 @@ export const DIFFICULTY = {
Low: "简单",
Mid: "中等",
High: "困难",
}
} as const
const cSource =
"#include<stdio.h>\r\n\r\nint main()\r\n{\r\n \r\n return 0;\r\n}"

View File

@@ -139,7 +139,7 @@ export function debounce<T extends (...args: any[]) => any>(
}
}
export function getUserRole(role: User["admin_type"]): {
export function getUserRole(role: User["adminType"]): {
type: "default" | "info" | "warning" | "error"
label: "普通" | "学生管理员" | "教师管理员" | "超管"
} {

View File

@@ -1,79 +0,0 @@
import axios, { type AxiosRequestConfig } from "axios"
import { createDiscreteApi } from "naive-ui"
import { useAuthModalStore } from "shared/store/authModal"
import storage from "./storage"
import { STORAGE_KEY } from "./constants"
const { message } = createDiscreteApi(["message"])
// 后端统一返回 { error, data } 信封;拦截器剥掉 axios 外层后,
// 调用方拿到的就是这个信封data 才是真正的业务数据。
export interface ApiResponse<T = any> {
error: string | null
data: T
}
// 让 http.get<T>() 的类型真实反映"解包后返回信封"这件事,
// 调用方 res.data 直接拿到带类型的 T不再依赖 axios 的 AxiosResponse 巧合对齐。
interface Http {
get<T = any>(
url: string,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
delete<T = any>(
url: string,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
post<T = any>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
put<T = any>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
}
const instance = axios.create({
baseURL: "/api",
xsrfHeaderName: "X-CSRFToken",
xsrfCookieName: "csrftoken",
})
// 统一剥掉空字符串 / null / undefined 的 query 参数,
// 各 api 函数不必再手写过滤逻辑(保留 0、false
instance.interceptors.request.use((config) => {
if (config.params) {
config.params = Object.fromEntries(
Object.entries(config.params).filter(
([, v]) => v !== "" && v !== null && v !== undefined,
),
)
}
return config
})
instance.interceptors.response.use(
(res) => {
if (res.data.error) {
if (res.data.error === "login-required") {
storage.remove(STORAGE_KEY.AUTHED)
useAuthModalStore().openLoginModal()
} else if (res.data.error === "permission-denied") {
message.error(res.data.data || "权限不足")
}
return Promise.reject(res.data)
} else {
return Promise.resolve(res.data)
}
},
(err) => {
return Promise.reject(err)
},
)
const http = instance as unknown as Http
export default http

View File

@@ -1,27 +0,0 @@
import type { ApiResponse } from "./http"
/**
* 新后端一律 camelCase而现存组件读的都是旧 Django 的 snake_case。
* 迁移期在 api 层做一次键名转换,组件不动 —— 否则每搬一个端点就要顺带改一堆 .vue
* 改动面大到没法一个个验。
*
* 迁移完成后这一层应当整体拆掉,届时组件改成 camelCase 是一次性的机械替换。
*/
function snakeKey(key: string) {
return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
}
export function toLegacy<T>(value: unknown): T {
if (Array.isArray(value)) return value.map((item) => toLegacy(item)) as T
if (!value || typeof value !== "object") return value as T
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [snakeKey(key), toLegacy(item)]),
) as T
}
export async function legacyResponse<T>(
request: Promise<ApiResponse<unknown>>,
): Promise<ApiResponse<T>> {
const response = await request
return { error: response.error, data: toLegacy<T>(response.data) }
}

View File

@@ -23,11 +23,11 @@ export function usePermissions() {
canManageAllProblems: computed(
() =>
userStore.user?.problem_permission === "All" || userStore.isSuperAdmin,
userStore.user?.problemPermission === "All" || userStore.isSuperAdmin,
),
canManageOwnProblems: computed(
() =>
userStore.user?.problem_permission === "Own" && !userStore.isSuperAdmin,
userStore.user?.problemPermission === "Own" && !userStore.isSuperAdmin,
),
getUserPermissionLevel: computed(() => {
@@ -39,7 +39,7 @@ export function usePermissions() {
getProblemPermissionLevel: computed(() => {
if (!userStore.user) return "无权限"
switch (userStore.user.problem_permission) {
switch (userStore.user.problemPermission) {
case "All":
return "管理所有题目"
case "Own":

View File

@@ -1,45 +1,47 @@
import { ContestStatus, ContestType, LANGUAGE_SHOW_VALUE } from "./constants"
import { LANGUAGE_SHOW_VALUE } from "./constants"
import type {
AdminProblem as ContractAdminProblem,
SubmissionDetail,
SubmissionListItem as ContractSubmissionListItem,
AdminContest,
AdminUser,
RankProfile,
SessionUser,
UserProfile,
EmbeddedSubmission as ContractEmbeddedSubmission,
Grade,
ProblemDetail,
ProblemDifficulty,
} from "@oj2/contract"
export interface Profile {
id: number
user: User
real_name: string
acm_problems_status: {
problems: {
[key: string]: {
_id: string
status: number
}
}
/**
* 个人主页数据。`acmProblemsStatus` 的**内容**保持 snake_case ——
* 它是 user_profile.acm_problems_status 的 JSONB 原文,回滚时旧后端还要读。
*/
export type Profile = Omit<UserProfile, "user" | "acmProblemsStatus"> & {
user: SessionUser
acmProblemsStatus: AcmProblemsStatus
}
export interface AcmProblemsStatus {
problems?: {
[key: string]: { _id: string; status: number }
}
contest_problems?: {
[key: string]: { [key: string]: { _id: string; status: number } }
}
avatar: string
blog: null
mood: string
github: string
school: string
major: string
language: string
accepted_number: number
submission_number: number
}
export type UserAdminType =
"Regular User" | "Student Admin" | "Teacher Admin" | "Super Admin"
export interface User {
id: number
username: string
real_name: string
email: string
admin_type: UserAdminType
problem_permission: string
create_time: Date
last_login: Date
open_api: boolean
is_disabled: boolean
/**
* 后台用户管理里的用户。`rawPassword` 是明文密码,只有超管专属接口下发 ——
* 老师要能查学生密码,见契约 adminUserSchema 的注释。
*/
export type User = AdminUser & {
// 编辑表单里临时填的新密码,不在响应里
password?: string
raw_password?: string
class_name?: string | null
}
export type LANGUAGE =
@@ -95,7 +97,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test"
interface SampleUser {
id: number
username: string
real_name: string | null
realName: string | null
}
export interface Tag {
@@ -103,11 +105,13 @@ export interface Tag {
name: string
}
export interface AdminTag {
id: number
name: string
problem_count: number
}
export type {
AdminTag,
RenameTagResponse,
BatchProblemTagResponse,
SqlTestCaseScript,
GenerateSqlTestCaseResponse,
} from "@oj2/contract"
export interface TestcaseUploadedReturns {
id: string
@@ -120,88 +124,89 @@ export interface Testcase {
score: string
}
export interface Problem {
_id: string
id: number
tags: string[]
created_by: SampleUser
/**
* 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化:
* - `languages` / `template` 的键窄化成 LANGUAGE组件按语言查模板要靠它
* - `astRules` / `sqlConfig` / `sqlDisplay` 契约里是 Record<string, unknown>
* 这里给出组件实际读的形状
*/
export type Problem = Omit<
ProblemDetail,
"languages" | "template" | "sqlConfig" | "sqlDisplay"
> & {
languages: LANGUAGE[]
template: { [key in LANGUAGE]?: string }
title: string
description: string
input_description: string
output_description: string
samples: {
input: string
output: string
}[]
hint: string
languages: Array<LANGUAGE>
create_time: Date
last_update_time: null
time_limit: number
memory_limit: number
difficulty: "Low" | "Mid" | "High"
source: string
prompt: string
answers: { language: LANGUAGE; code: string }[]
submission_number: number
accepted_number: number
statistic_info: { [key: string]: number }
share_submission: boolean
contest: number
my_status: number
my_failed_count?: number
visible: boolean
// 流程图相关字段
allow_flowchart: boolean
mermaid_code?: string
flowchart_data?: Record<string, any>
flowchart_hint?: string
show_flowchart?: boolean
ast_rules?: {
[key: string]: {
engine: string
target?: string
min?: number
max?: number
message: string
}[]
} | null
has_ast_rules?: boolean
// SQL 题配置(非 SQL 题为 null
sql_config?: SQLConfig | null
// SQL 题展示数据(后端保存题目时自动生成)
sql_display?: SQLDisplay | null
sqlConfig?: SQLConfig | null
sqlDisplay?: SQLDisplay | null
astRules?: AstRules | null
hasAstRules?: boolean
visible?: boolean
answers?: { language: LANGUAGE; code: string }[]
}
export type AdminProblem = Problem &
AlterProblem & {
// 后台题目列表接口附带的最高票评价,比赛题目列表不返回
top_reaction?: { type: ReactionKey; count: number } | null
}
export type AstRules = {
[key: string]: {
engine: string
target?: string
min?: number
max?: number
message: string
}[]
}
interface AlterProblem {
test_case_id: string
test_case_score: Testcase[]
contest_id?: string
export type {
ProblemDetail,
ProblemListItem,
AdminProblemListItem,
AdminProblemList,
} from "@oj2/contract"
/** 后台题目详情:比 oj 侧多 answers / testCase* / astRules */
export type AdminProblem = Omit<
ContractAdminProblem,
| "languages"
| "template"
| "testCaseScore"
| "sqlConfig"
| "sqlDisplay"
| "samples"
| "answers"
| "astRules"
> & {
languages: LANGUAGE[]
template: { [key in LANGUAGE]?: string }
// 测试点条目的键名由判题沙箱定,保持 snake_case
testCaseScore: Testcase[]
samples: { input: string; output: string }[]
answers: { language: LANGUAGE; code: string }[]
sqlConfig?: SQLConfig | null
sqlDisplay?: SQLDisplay | null
astRules?: AstRules | null
}
type ExcludeKeys =
| "id"
| "created_by"
| "create_time"
| "last_update_time"
| "my_status"
| "contest"
| "statistic_info"
| "accepted_number"
| "submission_number"
| "createdBy"
| "createTime"
| "lastUpdateTime"
| "statisticInfo"
| "acceptedNumber"
| "submissionNumber"
| "isPublic"
| "contestId"
export type BlankProblem = Omit<Problem, ExcludeKeys> &
AlterProblem & { id?: number }
export type BlankProblem = Omit<
AdminProblem,
ExcludeKeys | "hint" | "mermaidCode"
> & {
id?: number
// 新建比赛题时由 detail.vue 在提交前写进来
contestId?: number | null
// 表单里恒为字符串:初值 "",从服务器载入时归一化。
// v-model 要的是 lvalue模板里没法 ?? 兜底,所以在类型上就收掉 null
hint: string
mermaidCode: string
}
export interface ProblemFiltered {
_id: string
@@ -213,9 +218,9 @@ export interface ProblemFiltered {
rate: string
status: "not_test" | "passed" | "failed"
author: string
allow_flowchart: boolean
show_flowchart: boolean
has_ast_rules: boolean
allowFlowchart: boolean
showFlowchart: boolean
hasAstRules: boolean
}
export interface AdminProblemFiltered {
@@ -224,112 +229,35 @@ export interface AdminProblemFiltered {
title: string
visible: boolean
username: string
create_time: string
difficulty: "Low" | "Mid" | "High"
createTime: string
difficulty: ProblemDifficulty
tags: string[]
has_ast_rules: boolean
allow_flowchart: boolean
show_flowchart: boolean
hasAstRules: boolean
allowFlowchart: boolean
showFlowchart: boolean
// 比赛题目列表接口不返回这个字段
top_reaction?: { type: ReactionKey; count: number } | null
topReaction?: { type: ReactionKey; count: number } | null
}
// 题单相关类型
export interface ProblemSet {
id: number
title: string
description: string
created_by: SampleUser
create_time: Date
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
end_time: Date | null
visible: boolean
problems_count: number
completed_count: number
user_progress: {
is_joined: boolean
progress_percentage: number
completed_count: number
total_count: number
is_completed: boolean
}
}
export type {
ProblemSet,
ProblemSetList,
ProblemSetBadge,
ProblemSetProblem,
ProblemSetProgress,
ProblemSetProgressList,
UserBadge,
} from "@oj2/contract"
export interface ProblemSetList {
id: number
title: string
description: string
created_by: SampleUser
create_time: Date
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
end_time: Date | null
problems_count: number
visible: boolean
user_progress: {
is_joined: boolean
progress_percentage: number
completed_count: number
total_count: number
is_completed: boolean
}
badges: ProblemSetBadge[]
}
export interface ProblemSetProblem {
id: number
problemset: number
problem: Problem
order: number
is_required: boolean
score: number
hint: string
is_completed: boolean
}
export interface ProblemSetBadge {
id: number
problemset: number
name: string
description: string
icon: string
condition_type: "all_problems" | "problem_count" | "score"
condition_value: number
is_earned?: boolean
}
export interface UserBadge {
id: number
user: number
badge: ProblemSetBadge
earned_time: Date
}
export interface CompletedProblem {
id: number
_id: string
title: string
}
export interface ProblemSetProgress {
id: number
problemset: ProblemSetList
user: SampleUser
join_time: Date
completed_problems_count: number
total_problems_count: number
progress_percentage: number
is_completed: boolean
completed_problems: CompletedProblem[]
}
export type { CompletedProblem } from "@oj2/contract"
export interface CreateProblemSetData {
title: string
description: string
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
end_time?: Date | null
endTime?: Date | null
}
export interface EditProblemSetData {
@@ -338,7 +266,7 @@ export interface EditProblemSetData {
description?: string
difficulty?: "Easy" | "Medium" | "Hard"
status?: "active" | "archived" | "draft"
end_time?: Date | null
endTime?: Date | null
visible?: boolean
}
@@ -348,10 +276,10 @@ export interface Code {
}
export interface SubmitCodePayload {
problem_id: number
problemId: number
language: LANGUAGE
code: string
contest_id?: number
contestId?: number
}
// ==================== 流程图相关类型 ====================
@@ -363,47 +291,17 @@ export const FlowchartSubmissionStatus = {
FAILED: 3, // 评分失败
} as const
export interface FlowchartSubmission {
id: string
user: number
problem: number
mermaid_code: string
flowchart_data: Record<string, any>
status: number
create_time: string
ai_score?: number
ai_grade?: string
ai_feedback?: string
ai_suggestions?: string
ai_criteria_details: Record<string, any>
ai_provider?: string
ai_model?: string
processing_time?: number
evaluation_time?: string
}
export type {
FlowchartSubmission,
FlowchartListItem as FlowchartSubmissionListItem,
} from "@oj2/contract"
// 列表接口返回的字段(包含 username 和 problem_title
export interface FlowchartSubmissionListItem {
id: string
create_time: string
evaluation_time: string
ai_score: number
ai_grade: Grade
ai_model: string
ai_provider: string
processing_time: number
status: number
username: string
problem_title: string
problem: string
show_link: boolean
}
export interface SubmitFlowchartPayload {
problem_id: number
mermaid_code: string
flowchart_data?: Record<string, any>
}
export type { CreateFlowchartRequest as SubmitFlowchartPayload } from "@oj2/contract"
/**
* 判题机原始输出。契约里是 `info: z.unknown()` —— 后端不校验沙箱产物,
* 这些键名是沙箱定的,**保持 snake_case**,不要跟着响应字段一起改名。
*/
interface Info {
err: string | null
data: {
@@ -420,53 +318,57 @@ interface Info {
}[]
}
export interface Submission {
id: string
create_time: Date
user_id: number
username: string
code: string
result: SUBMISSION_RESULT
info: Info
language: LANGUAGE
shared: boolean
show_link: boolean
statistic_info: {
score?: number
err_info?: string
time_cost?: number
memory_cost?: number
ast_results?: Array<{ description: string; passed: boolean }>
}
ip: string
contest: number
problem: number // 不是 display_id
can_unshare: boolean
/**
* 判题产出的统计。**键名保持 snake_case** —— 这是 submission.statistic_info
* JSONB 的原文,判题机写进去、回滚时旧后端还要读,不能跟着响应字段一起改名。
*/
export interface StatisticInfo {
score?: number
err_info?: string
time_cost?: number
memory_cost?: number
ast_results?: Array<{ description: string; passed: boolean }>
}
export interface SubmissionListItem {
id: string
problem: string
problem_title: string
show_link: boolean
create_time: string
user_id: number
username: string
result: SUBMISSION_RESULT
/**
* 提交详情。以契约的 SubmissionDetail 为准,只窄化两处 unknown
* `info` 是判题沙箱原始输出,`statisticInfo` 是判题写的 JSONB —— 两者内部都是 snake。
*/
export type Submission = Omit<
SubmissionDetail,
"info" | "statisticInfo" | "language" | "result"
> & {
info: Info
statisticInfo: StatisticInfo
language: LANGUAGE
// 比契约多一个 9点了提交、还没拿到结果时前端本地先填这个伪状态
// 见 constants.ts 的 SubmissionStatus.submitting
result: SUBMISSION_RESULT
}
/** 站内信里嵌的提交problem 是展示题号而非数字 id且不含 info / ip / contestId */
export type EmbeddedSubmission = Omit<
ContractEmbeddedSubmission,
"statisticInfo" | "language"
> & {
statisticInfo: StatisticInfo
language: LANGUAGE
}
export type SubmissionListItem = Omit<
ContractSubmissionListItem,
"statisticInfo" | "language"
> & {
statisticInfo: StatisticInfo
language: LANGUAGE
shared: boolean
statistic_info: {
time_cost: number
memory_cost: number
}
}
export interface SubmissionListPayload {
myself?: "1" | "0"
result?: string
username?: string
contest_id?: string
problem_id?: string
contestId?: string
problemId?: string
language: LANGUAGE | ""
today?: "1" | "0"
page: number
@@ -474,59 +376,37 @@ export interface SubmissionListPayload {
offset: number
}
export interface Rank {
id: number
user: SampleUser
acm_problems_status: {
problems: {
[key: string]: {
_id: string
status: number
}
}
contest_problems?: {
[key: string]: {
[key: string]: {
_id: string
status: number
}
}
}
}
real_name: null | string
avatar: string
blog: null
mood: null | string
github: null
school: null | string
major: null | string
language: null | string
accepted_number: number
submission_number: number
}
export type { SessionUser } from "@oj2/contract"
export interface Contest extends BlankContest {
id: number
created_by: SampleUser
status: ContestStatus
contest_type: ContestType
create_time: string
now: string
last_update_time: string
}
export type Rank = RankProfile
export interface BlankContest {
title: string
description: string
tag: string
start_time: string
end_time: string
password: string
visible: boolean
allowed_ip_ranges: { value: string }[]
}
export type {
ClassComparison,
ClassRankItem,
ClassUserRank,
} from "@oj2/contract"
interface SubmissionInfo {
/** 后台比赛。oj 侧的 contestSchema 永远不含 password后台要能看到告诉学生 */
export type Contest = AdminContest
/** 学生侧的比赛:不含 password / visible / allowedIpRanges */
export type { Contest as OjContest } from "@oj2/contract"
export type BlankContest = Omit<
AdminContest,
| "id"
| "createdBy"
| "createTime"
| "lastUpdateTime"
| "status"
| "contestType"
>
/**
* acm_contest_rank.submission_info 的 JSONB 内容。**键名保持 snake_case** ——
* 判题写进去、回滚时旧后端还要读,不能跟着响应字段一起改名。
*/
export interface SubmissionInfo {
is_ac: boolean
ac_time: number
is_first_ac: boolean
@@ -534,42 +414,34 @@ interface SubmissionInfo {
checked?: boolean
}
export interface ContestRank {
id: number
user: SampleUser
submission_number: number
accepted_number: number
total_time: number
submission_info: { [key: string]: SubmissionInfo }
contest: number
/**
* 榜单行。`submissionInfo` 的**内容**仍是 snake_case —— 它是 acm_contest_rank
* 表的 JSONB 原文,回滚时旧后端还要读,见 SubmissionInfo。
*/
export type ContestRank = Omit<
import("@oj2/contract").ContestRankItem,
"submissionInfo"
> & {
submissionInfo: { [key: string]: SubmissionInfo }
}
export interface WebsiteConfig {
website_base_url: string
website_name: string
website_name_shortcut: string
website_footer: string
allow_register: boolean
submission_list_show_all: boolean
class_list: string[] & never[]
enable_maxkb: boolean
}
export type { WebsiteConfig } from "@oj2/contract"
export interface Server {
id: number
status: "abnormal" | "normal"
hostname: string
ip: string
judger_version: string
cpu_core: number
memory_usage: number
cpu_usage: number
last_heartbeat: Date
create_time: Date
task_number: number
service_url: string
is_disabled: boolean
}
export type {
JudgeServer as Server,
JudgeServerList,
DashboardInfo,
OrphanTestCase,
AdminUser,
AdminUserList,
AcmHelperItem,
AdminContestList,
AdminAiReport,
AdminAiReportListItem,
AdminAiReportList,
StuckProblem,
AcTrend,
} from "@oj2/contract"
export interface AnnouncementEdit {
id: number
@@ -581,16 +453,20 @@ export interface AnnouncementEdit {
}
export interface Announcement extends AnnouncementEdit {
created_by: SampleUser
create_time: Date
last_update_time: Date
createdBy: SampleUser
createTime: string
lastUpdateTime: string
}
/** 列表不下发正文:公告是 8MB 上限的富文本,列表页只显示标题 */
export type AnnouncementListItem = Omit<Announcement, "content">
export interface Message {
sender: User
create_time: Date
id: number
sender: SampleUser
createTime: string
message: string
submission: Submission
submission: EmbeddedSubmission
}
export interface CreateMessage {
@@ -621,14 +497,17 @@ export interface Tutorial {
title: string
content: string
code: string
is_public: boolean
isPublic: boolean
order: number
type: "python" | "c"
created_by?: User
updated_at?: Date
created_at?: Date
createdBy?: User
updatedAt?: string
createdAt?: string
}
/** 后台教程列表不下发正文:教程正文是整篇 markdown列表只排序和切换可见性 */
export type TutorialListItem = Omit<Tutorial, "content">
export interface ExerciseMcqData {
question: string
options: string[]
@@ -689,102 +568,33 @@ export interface Exercise {
order: number
}
export interface DurationData {
unit: string
index: number
start: string
end: string
grade: Grade
problem_count: number
submission_count: number
}
export type {
DurationData,
FlowchartSummary,
SolvedProblem,
AiDetail as DetailsData,
} from "@oj2/contract"
export interface SolvedProblem {
problem: {
title: string
display_id: string
contest_title: string
contest_id: number
}
ac_time: string
rank: number
ac_count: number
grade: Grade
period_rank: number
period_ac_count: number
difficulty: string
}
export interface FlowchartSummary {
problem__id: string
problem_title: string
submission_count: number
best_score: number
best_grade: string
latest_submission_time: string
avg_score: number
}
export interface DetailsData {
start: string
end: string
grade: Grade
class_name: string
tags: { [key: string]: number }
difficulty: { [key: string]: number }
contest_count: number
solved: SolvedProblem[]
flowcharts: FlowchartSummary[]
}
export type Grade = "S" | "A" | "B" | "C"
// 评级。空串是「无评级」,后端在没有可用数据时真会下发,见契约 gradeSchema
export type { Grade, ProblemDifficulty }
// ==================== 成就相关类型 ====================
export type AchievementRarity = "bronze" | "silver" | "gold" | "platinum"
import type { AchievementNotification, PendingAchievement } from "@oj2/contract"
export interface Achievement {
id: number
name: string
description: string
icon: string
rarity: AchievementRarity
hidden: boolean
// 隐藏成就未解锁时,后端已做掩码处理,以下四个字段为 null
metric: string | null
operator: "gte" | "lte" | null
threshold: number | null
unlocked: boolean
unlock_time: string | null
backfilled: boolean
progress: number | null
unlock_rate: number
}
export type {
Achievement,
AchievementList,
AchievementRarity,
AchievementRarityStat,
AchievementSummary,
AchievementNotification,
PendingAchievement,
} from "@oj2/contract"
export interface AchievementRarityStat {
rarity: AchievementRarity
label: string
total: number
unlocked: number
}
export interface PendingAchievement {
id: number
name: string
description: string
icon: string
rarity: AchievementRarity
// 弹窗队列里混着两种东西:全站成就和题单奖章。它们的 id 来自两张不同的表,
// 数值会重叠,所以去重和标记已读都必须带上 kind 一起判断。
// pending 接口只返回成就,不带这个字段,缺省按 achievement 处理。
kind?: "achievement" | "badge"
}
export interface AchievementSummary {
username: string
total: number
unlocked: number
percent: number
rarity: AchievementRarityStat[]
recent: PendingAchievement[]
}
/**
* 弹窗队列里的条目。`/achievements/pending` 拉来的没有 kind
* WebSocket 推来的有 —— 两个来源会汇进同一个队列。
*/
export type QueuedAchievement = PendingAchievement &
Partial<Pick<AchievementNotification, "kind">>