排查这个面板时发现的一串问题和缺口,一起修掉。改动集中在 `GET /submissions/statistics` 和 `StatisticsPanel.vue`,两边互相咬着, 拆不成独立的 commit。 **「已解决」数的不是题数**(真在显示错数字)。`count(*) filter (accepted)` 按用户名分组、没有 distinct problem_id,同一道题重复 AC 会重复计数。查单道题时 看不出来,老师查「这节课全班」时「已解决 5」可能是同一道题交了 5 次。新增 `solvedCount = count(distinct problem_id) filter (accepted)`,表格那一列换成它; `acceptedCount` 保留,正确率的分子仍然是提交条数。 **正确率把判题中的算进了分母**。PENDING / JUDGING 进分母不进分子,全班同时交卷的 那几秒正确率凭空掉一截 —— 而老师盯着看的就是这个数。分母改成判完的条数, `UNJUDGED_RESULTS` 提到 judge/status.ts 共用。总提交仍是全部条数(交了就算交过, 否则人数口径会跟着变,正在判的学生会掉进「没交」名单),另外下发 `judgingCount`, 非零时面板多显示一块「判题中」,三个数字才对得上。 **「未完成」实际是「一次没交」**。做了但一次没对的学生既不在「完成人数」也不在 未完成名单,等于从屏幕上消失 —— 而那恰恰是最该去看一眼的人。新增 `dataAttempted`,未完成栏拆成「还没交」/「交了没对」两组,tab 计数是两者之和。 请假隐藏对两组同时生效:他们同样占着班级人数这个分母,只藏一半会把完成度算错。 **点名字能看到错在哪**。`dataAttempted` 带上最近一条提交的题号、状态和 `statistic_info.err_info`(截断 400 字),点名字弹出来,还能一步跳到代码。 老师不用再切到提交列表、翻到这个人、点开代码。 **支持一次查几道题**。题号框接受 `1001,1005,1010`(中英文逗号、分号、空格都当 分隔符,投影前手敲不该因为打了全角逗号就查不出来),有一个题号不存在就整体 404。 **完成 = 这几道全解决**;只填一道时和原来完全等价,不填题号时退回「至少做出一道」。 差一道的人落在「交了没全对」里,名字后面缀 `2/3题`。 **零提交时整块面板消失**。判空条件是 `count.total > 0`,可一节课刚开始一条提交都 没有、后端已经把整份花名册当作「未完成」返回了 —— 最该看名单的时刻反而只显示 「暂无数据」。流程图那边更彻底:`/flowcharts/statistics` 的零提交分支把 `dataUnaccepted` 写死成空数组,名单压根没下发。 **面板不会自己刷新**。打开是空的、要点一次按钮,拿到的是那一刻的快照。改成打开即查 + 每 15 秒滚动重查,页面切到后台就停,关掉面板随组件卸载停掉。上一次没回来就跳过 这一次。班级从手打 `ks251` 换成下拉(选项来自网站配置的 class_list,和登录框同一份), 保留自由输入,查过的班级记进 localStorage 下次带上 —— 机房电脑一台对一个班。 **明细查询没有 LIMIT**。展开行用的 submissionItems 原来把窗口内**全部**提交捞进内存 再原样序列化,「全部时段 + 不填条件」就是十几万条。改成只取有 AC 的人、每人最近 50 条, 截断用窗口函数发生在数据库侧;表格「提交数」仍是真实总数。 **顺带**:`personRate` 前端从来没读过(完成度是前端按「减掉请假人数的分母」自己算的), 从契约里删掉;「语法未过」的题数单列出来(AST_CHECK_FAILED 全站仍然算通过,口径没动, 只是让老师看得见谁是绕过语法要求做出来的,那批人教学上没达标)。 实跑验证(dev 全栈 + 浏览器): - 已解决:student 两条 AC 都在题号 5 上 —— 改前显示 2,改后显示 1 - 正确率:临时把两条改成 PENDING/JUDGING,12 条提交 2 条通过 —— 16.67% → 20%, 面板多出「判题中 2」,表格显示「12(2 条判题中)」 - 未完成两栏:student2 没交、student 交了 12 次没对,请假隐藏 student 后 班级人数 2→1、tab 2→1、出现「恢复 1 位」 - 错因弹层:点 student 弹出「最近一次:1020 · 编译失败 / Test case not found / 看代码」 - 多题:`1004` 完成 1 人;`1004,1005` 完成 0 人、交了没全对 student 1/2题 8次; 全角逗号同上;`1004,9999` 报 `Problem 9999 does not exist` - 自动刷新:打开即发请求,之后 04:44:22 → 04:44:36 → 04:44:51 → 04:45:06 每 15 秒 一次且窗口跟着滚;关掉面板后 20 秒请求数不再增长 - 班级下拉:选「25计算机1班」→ ks251,重开面板自动带上并立刻查 tsc / vue-tsc / check:routes 全过。验证用的 dev 库改动已还原。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
378 lines
9.4 KiB
TypeScript
378 lines
9.4 KiB
TypeScript
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))
|
||
}
|
||
|
||
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
|
||
|
||
// 班级号的位数范围。学生用户名形如 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" },
|
||
]
|