Files
OJ2/apps/web/src/utils/constants.ts
T
xuyueandClaude Opus 5 2b07040aee
Deploy / deploy (push) Has been cancelled
refactor(时段): 两份手抄的选项列表与五份 "weeks:1" 解析收成一处
时段选项的 value 是 `<date-fns 单位>:<数量>`,把它解成 Duration 的那段 `split(":")`
在**五个组件里各写了一遍**(提交统计、流程图统计、AI 分析页、榜单页、班级对比页),
每份的兜底还都不一样;选项列表也抄了好几份:

- 两个统计面板逐字相同地拼「10/20/30 分钟 + DURATION_OPTIONS + 全部时段」;
- rank/list.vue 和 class/pk.vue 各手写了同样的五条长时段,pk.vue 里还留着一句
  「与 rank/list.vue 保持一致」的注释 —— 靠注释同步的东西迟早不同步。

现在:`PANEL_DURATION_OPTIONS`(面板用,含分钟级和 all)、`LONG_DURATION_OPTIONS`
(榜单/班级对比用,从 DURATION_OPTIONS 派生)、`durationFromValue()`(唯一解析)。
各站点自己的兜底保留在原处,那部分本来就该各不相同。

## 行为零变化,逐条比对过

把改动前各处手写的列表原样取出来和新的比:面板 12 条、榜单 5 条、班级对比 6 条,
标签与取值逐条一致;11 个时段值的解析结果与旧的内联写法逐个相同。

唯一的差异在 `all`:旧写法产出 `{all: NaN}`,新写法回 null。**两边都到不了** ——
三处用到 subOptions 的地方全在 `query.duration === "all" ? … : …` 的 else 分支里,
三元短路,all 时根本不求值。逐处确认过。

## 实跑

- 榜单页:下拉 5 条顺序正确;切一周内,请求从 start=2026-08-10(months:1)
  变成 start=2026-09-03(weeks:1),正好差 7 天;
- 班级对比页:下拉 6 条含「全部时间」;两个班 PK,全部时间和一个月内都正常出结果;
- 提交统计面板:默认 minutes:10,统计请求的 start 正好比 end 早 10 分钟。

另:ChartJS.register 那 15 处**没有动**。逐个列出来看,它们注册的是各自需要的那一套,
不是同一份重复(真正逐字相同的只有 4 个 Bar 图和 2 个 Line 图),而且组件各自声明依赖
正是 chart.js 该用的方式 —— 没用到的控制器不会进包,忘了注册会当场抛
`"bar" is not a registered controller`,是响的不是静默的。集中注册只会把懒加载的图表
代码推进首屏,为省 6 处重复不值得。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 20:28:33 -06:00

410 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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,
}
export enum ContestStatus {
initial = "2", // 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
not_started = "1",
underway = "0",
finished = "-1",
}
export enum ContestType {
public = "Public",
private = "Password Protected",
}
export const JUDGE_STATUS: {
[key in SUBMISSION_RESULT]: {
name: string
title: string
type: "error" | "success" | "warning" | "info"
}
} = {
"-2": {
name: "编译失败",
title: "编译失败",
type: "warning",
},
"-1": {
name: "答案错误",
title: "答案错误",
type: "error",
},
"0": {
name: "答案正确",
title: "答案正确",
type: "success",
},
"1": {
name: "运行超时",
title: "运行超时",
type: "error",
},
"2": {
name: "运行超时",
title: "运行超时",
type: "error",
},
"3": {
name: "内存超限",
title: "内存超限",
type: "error",
},
"4": {
name: "运行时错误",
title: "运行时错误",
type: "warning",
},
"5": {
name: "系统错误",
title: "系统错误",
type: "error",
},
"6": {
name: "等待评分",
title: "等待评分",
type: "warning",
},
"7": {
name: "正在评分",
title: "正在评分",
type: "warning",
},
"8": {
name: "部分正确",
title: "部分正确",
type: "warning",
},
"9": {
name: "正在提交",
title: "正在提交",
type: "info",
},
"10": {
name: "语法未通过",
title: "答案正确,但语法未通过",
type: "success",
},
}
export const CONTEST_STATUS: {
[key in ContestStatus]: {
name: string
type: "error" | "success" | "warning"
}
} = {
// 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
"2": {
name: "未开始",
type: "warning",
},
"1": {
name: "未开始",
type: "warning",
},
"0": {
name: "进行中",
type: "success",
},
"-1": {
name: "已结束",
type: "error",
},
}
// 角色与题目权限的字符串在 `@oj2/contract` 的 roles.ts 定义,这里只是转出去,
// 调用方照旧写 `USER_TYPE.SUPER_ADMIN`。原先这里是这批字符串的第三份手抄副本。
export { USER_TYPE, PROBLEM_PERMISSION } from "@oj2/contract"
export const STORAGE_KEY = {
AUTHED: "authed",
LANGUAGE: "problemLanguage",
LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags",
DEMO_MODE: "demoMode",
LOGIN_CLASS: "loginClass",
STATISTICS_CLASS: "statisticsClass",
}
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}"
const cppSource =
"#include<iostream>\r\n\r\nusing namespace std;\r\n\r\nint main()\r\n{\r\n \r\n return 0;\r\n}"
const pythonSource = ""
const javaSource =
'public class Main {\r\n public static void main(String[] args) {\r\n System.out.println("黄岩一职");\r\n }\r\n}'
export const SOURCES = {
C: cSource,
"C++": cppSource,
Java: javaSource,
Python3: pythonSource,
Python2: "",
JavaScript: "",
Golang: "",
Flowchart: "",
SQL: "",
} as const
export const LANGUAGE_FORMAT_VALUE = {
C: "c",
"C++": "cpp",
Java: "java",
Python2: "python",
Python3: "python",
JavaScript: "javascript",
Golang: "go",
Flowchart: "flowchart",
SQL: "sql",
} as const
export const LANGUAGE_SHOW_VALUE = {
Flowchart: "流程图",
C: "C语言",
"C++": "C++",
Java: "Java",
Python2: "Python",
Python3: "Python",
JavaScript: "JS",
Golang: "Go",
SQL: "SQL",
} as const
export const ICON_SET = {
Flowchart: "vscode-icons:file-type-drawio",
Python2: "devicon:python",
Python3: "devicon:python",
C: "devicon:c",
"C++": "devicon:cplusplus",
Java: "devicon:java",
JavaScript: "devicon:javascript",
Golang: "devicon:go",
SQL: "devicon:sqlite",
} as const
const cTemplate = `//TEMPLATE BEGIN
#include <stdio.h>
int main() {
printf("黄岩一职");
return 0;
}
//TEMPLATE END`
const cppTemplate = `//TEMPLATE BEGIN
#include <iostream>
int main() {
return 0;
}
//TEMPLATE END`
const blankTemplate = `//PREPEND BEGIN
//PREPEND END
//TEMPLATE BEGIN
//TEMPLATE END
//APPEND BEGIN
//APPEND END`
export const CODE_TEMPLATES = {
C: cTemplate,
"C++": cppTemplate,
Python2: blankTemplate,
Python3: blankTemplate,
Java: blankTemplate,
JavaScript: blankTemplate,
Golang: blankTemplate,
Flowchart: blankTemplate,
SQL: blankTemplate,
} as const
export enum ScreenMode {
both = "双栏",
code = "自测",
problem = "题目",
}
export enum ChartType {
Rank,
Activity,
}
// 成就稀有度
export const RARITY_LABEL: Record<AchievementRarity, string> = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
// 边框、色块用的原色:饱和度够高,明暗底上都醒目。
// 青铜往红偏(色相 23),黄金往纯黄推(色相 47),中间隔开两档亮度,
// 不然两个都落在橙棕区,扫一眼分不出来
export const RARITY_COLOR: Record<AchievementRarity, string> = {
bronze: "#c2703d",
silver: "#9fa6b2",
gold: "#f2c012",
platinum: "#7dd3fc",
}
// 文字要另配一套。上面那组是照深色底调的,搬到白底上白金只有 1.7:1、
// 黄金 2.2:1,12px 的稀有度标签根本看不清。取色见 useRarityColor
export const RARITY_TEXT_COLOR: Record<
"dark" | "light",
Record<AchievementRarity, string>
> = {
dark: {
bronze: "#e08d63",
silver: "#b6bcc7",
gold: "#f2c012",
platinum: "#7dd3fc",
},
light: {
bronze: "#a13d1e",
silver: "#6b7280",
gold: "#8f6f00",
platinum: "#0369a1",
},
}
// 时间范围配置
/**
* 流程图评分项的展示顺序(按分值从高到低)。
*
* `ai_criteria_details` 存在 jsonb 列里,而 Postgres 的 jsonb **不保留键序** ——
* 它按「键长度 + 字节序」重排,读出来会变成 完整性 / 清晰度 / 规范性 / 逻辑正确性,
* 40 分的那项排到最后。凡是要展示评分明细的地方都按这个顺序排,别直接遍历对象。
*/
export const FLOWCHART_CRITERIA_ORDER = [
"逻辑正确性",
"完整性",
"规范性",
"清晰度",
]
/** 按 FLOWCHART_CRITERIA_ORDER 排序,表里没有的键排在后面并保持原有相对顺序 */
export function sortFlowchartCriteria<T>(
details: Record<string, T>,
): [string, T][] {
const rank = (key: string) => {
const i = FLOWCHART_CRITERIA_ORDER.indexOf(key)
return i === -1 ? Number.MAX_SAFE_INTEGER : i
}
return Object.entries(details).sort(([a], [b]) => rank(a) - rank(b))
}
/**
* 时段选项。`value` 是 `<date-fns 的单位>:<数量>`,由 `durationFromValue()` 解析 ——
* 别在组件里再手写一遍 `split(":")`,那套解析原来散在五个文件里。
*/
export const DURATION_OPTIONS = [
{ label: "本节课内", value: "hours:1" },
{ label: "两节课内", value: "hours:2" },
{ label: "一天内", value: "days:1" },
{ label: "一周内", value: "weeks:1" },
{ label: "一个月内", value: "months:1" },
{ label: "两个月内", value: "months:2" },
{ label: "半年内", value: "months:6" },
{ label: "一年内", value: "years:1" },
] as const
/**
* 两个统计面板(提交统计、流程图统计)的时段下拉。
*
* 比通用的那份多了头尾:前面三档分钟级是给**上课当场**用的 —— 老师布置完一道题,
* 想看的就是「刚才这十分钟谁交了」;末尾的 `all` 不是一个时长,`query.duration === "all"`
* 会走各自的分支不带时间条件,所以它永远不会进 durationFromValue()。
*
* 原来这份列表在两个面板里逐字抄了两遍。
*/
export const PANEL_DURATION_OPTIONS = [
{ label: "10分钟内", value: "minutes:10" },
{ label: "20分钟内", value: "minutes:20" },
{ label: "30分钟内", value: "minutes:30" },
...DURATION_OPTIONS,
{ label: "全部时段", value: "all" },
] as const
/**
* 榜单和班级对比用的长时段,一周起步。
*
* 这两个页面看的是长期趋势,「本节课内」这种窗口在那儿没有意义 —— 全班一小时内的
* AC 数拉出来比不出什么。原来 rank/list.vue 和 class/pk.vue 各手抄了一份同样的五条,
* pk.vue 里还留着「与 rank/list.vue 保持一致」的注释,现在从上面那份派生。
*/
export const LONG_DURATION_OPTIONS = DURATION_OPTIONS.filter(
(option) => !["hours:1", "hours:2", "days:1"].includes(option.value),
)
// 班级号的位数范围。学生用户名形如 ks<班级号><姓名>,班级号还要跟
// 网站配置里的班级列表对得上。
// 后端 OnlineJudge/utils/shortcuts.py 的 CLASS_NAME_MIN/MAX_DIGITS
// 是同一条规则的另一份,改这里必须同时改那边。
export const CLASS_NAME_MIN_DIGITS = 3
export const CLASS_NAME_MAX_DIGITS = 4
/** 合法班级号:3~4 位纯数字 */
export const CLASS_NAME_RE = new RegExp(
`^\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}$`,
)
/** 用户名开头的 ks<班级号>,用于从用户名里认出班级 */
export const USERNAME_CLASS_RE = new RegExp(
`^ks\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}`,
)
/** 班级号作为数字时的上下界,给 n-input-number 用 */
export const CLASS_NAME_MIN_VALUE = 10 ** (CLASS_NAME_MIN_DIGITS - 1)
export const CLASS_NAME_MAX_VALUE = 10 ** CLASS_NAME_MAX_DIGITS - 1
export const REACTIONS: {
key: ReactionKey
label: string
icon: string
}[] = [
{
key: "too_easy",
label: "太简单",
icon: "fluent-emoji:smiling-face-with-sunglasses",
},
{ key: "too_hard", label: "太难了", icon: "fluent-emoji:exploding-head" },
{
key: "confusing",
label: "没看懂",
icon: "fluent-emoji:face-with-spiral-eyes",
},
{ key: "buggy", label: "题目有错", icon: "fluent-emoji:bug" },
{ key: "learned", label: "学到了", icon: "fluent-emoji:light-bulb" },
{ key: "interesting", label: "有意思", icon: "fluent-emoji:star-struck" },
{ key: "want_explain", label: "想听讲解", icon: "fluent-emoji:books" },
]