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:
@@ -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 })
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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: "普通" | "学生管理员" | "教师管理员" | "超管"
|
||||
} {
|
||||
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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">>
|
||||
|
||||
Reference in New Issue
Block a user