Files
OJ2/apps/web/src/utils/functions.ts
T
xuyueandClaude Opus 5.5 e7b95d211d fix(题目): 修掉题目页重构审查查出的问题——提交途中换题会把结果算到新题上、运行例子把对的菱形判错、点评完「下一题」不跳等
题目页重构之后按「功能对照 / 状态 / 结果页 / 布局与后端」四路审查了一遍,查出的问题一起修:

提交与换题
- 提交、画流程图途中换题:A 题格式化好的代码会盖掉 B 的草稿,A 判对了把 B 标成已解决、
  弹 B 的点评、题单里跳回题单。现在每个请求回来先核对题目,轮询和推送回来的也核对提交编号
- 画着流程图换到不能画的题,语言退到题目的第一门、还被记成偏好;现在回到学生习惯的那门
- 强制点评评完点「继续 / 下一题」,学生之前在看「题目」页签的话会停在原题
- 离开题目页之后判题、流程图的 WebSocket 一直连着,现在闲 15 分钟断开

运行例子
- 输出开头的空格被削掉,打印菱形这类题对的代码也显示「没对上」
- C 的编译警告拼进了「你的输出」(后台生成测试点也会写进 .out)
- Python 的语法错误(中文标点)被说成「下标有没有越界、除数是不是 0」
- 比赛里仍然把空格画成「·」、把不同的地方标红

入口与协作
- 比赛里不给「课堂统计」、不绑协作:房间和统计都按题号找,比赛题号和公开题撞号
- 「看他交过什么」按整名匹配,不再混进学号相近的同学
- 协作中点上下文条、相似题、下一题开新标签,协作不断
- 题单入口给管理员的「⋯」里留「本题提交」

结果与题面
- 「去自己输入拿 51 这些数试试」取到了题面 HTML 里的颜色值;「题目原话」露出 "
- 流程图 A 级的学生标题下还写着「还没交过」
- 「我的提交」上方补回「答案错误 × 3」这样的计数
- 流程图历次评分能点开看那一版的图;读分数失败不再说「还没交过流程图」;手机上能看流程图点评
- 「结果」页签有新结果没看时加粗上色,看过就恢复
- 空画布拖进第一个节点画布会跳一下;画布底下空着一条
- 比赛没开始写成「已结束」、倒计时超过一个月算错;分隔条拖到头工具栏被挤坏;题单里的题目页有备案页脚
- 流程图历次分数只读需要的几列(一个学生一道题 23 次提交:96 kB → 2 kB)
- 比赛密码、比赛没开始等几条学生看得到的英文报错换成中文

设计文档照实际做法更新了第 5.5、5.6、6、8、9、10、11 节。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:07:16 +08:00

372 lines
13 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 { TIME_ZONE_OFFSET_MINUTES, toAdminType } from "@oj2/contract"
import type { JudgeCaseResult, JudgeInfo } from "@oj2/contract"
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { AdminUser, Submission } from "./types"
import { JUDGE_STATUS, USER_TYPE } from "./constants"
import { strFromU8, strToU8, unzlibSync, zipSync, zlibSync, type Zippable } from "fflate"
import copyTextFallback from "copy-text-to-clipboard"
import { normalizeDate } from "@vueuse/core"
import { customAlphabet } from "nanoid"
function calculateACRate(acCount: number, totalCount: number): string {
if (totalCount === 0) return "0.00"
if (acCount >= totalCount) return "100.00"
return ((acCount / totalCount) * 100).toFixed(2)
}
/**
* 从 `submission.info` 里取测试点明细,取不到就返回空数组。
*
* `info` 在契约里是 `z.unknown()`(判题产物不在读出侧校验,见契约那边的说明),
* 它有三种真实取值:判题机写的完整形状、**空对象**(后端对非管理员下发的
* `info: {}`,也是待判提交的初值)、以及 `data: null`(编译失败等没有逐测试点
* 结果的情形)。三种「没有」在这里一并归成空数组。
*
* **这是判题产物在前端唯一需要的运行时判断** —— 有没有 data 数组。数组项的形状
* 直接信判题机(`JudgeCaseResult`)。
*/
export function submissionCaseResults(info: unknown): JudgeCaseResult[] {
if (!info || typeof info !== "object" || !("data" in info)) return []
const data = (info as JudgeInfo).data
return Array.isArray(data) ? data : []
}
/**
* 没通过、但已经过了一部分测试点时的进度,其余情况(通过、一个都没过、没有逐点结果)为 null。
*
* 一个都没过不给:「通过 0/8」只是把「答案错误」换个更刺眼的说法再说一遍。
* 全过也不给:AC 用不着;AST_CHECK_FAILED 是测试点全过、语法规则没过,那边有自己的规则清单。
*/
export function submissionPartialCases(submission: Pick<Submission, "caseSummary"> | undefined) {
const summary = submission?.caseSummary
if (!summary || summary.passed === 0 || summary.passed >= summary.total) return null
return summary
}
/** 结果标题:部分测试点通过时缀上「通过 x/y 个测试点」 */
export function submissionResultTitle(submission: Pick<Submission, "result" | "caseSummary">) {
const title = JUDGE_STATUS[submission.result]["title"]
const partial = submissionPartialCases(submission)
return partial ? `${title} · 通过 ${partial.passed}/${partial.total} 个测试点` : title
}
export function getACRate(acCount: number, totalCount: number): string {
return `${calculateACRate(acCount, totalCount)}%`
}
export function getACRateNumber(acCount: number, totalCount: number): number {
return parseFloat(calculateACRate(acCount, totalCount))
}
export function filterEmptyValue<T extends Record<string, any>>(object: T): Partial<T> {
return Object.entries(object).reduce((query, [key, value]) => {
if (value != null && value !== "" && value !== undefined) {
query[key as keyof T] = value
}
return query
}, {} as Partial<T>)
}
export function getTagColor(tag: "Low" | "Mid" | "High" | "简单" | "中等" | "困难") {
return <"success" | "info" | "error">{
Low: "success",
Mid: "info",
High: "error",
简单: "success",
中等: "info",
困难: "error",
}[tag]
}
// 2023-04-03T02:43:28.673156Z
/**
* 把时段选项的 `value`(`"weeks:1"`、`"minutes:10"`)解成 date-fns 的 Duration。
*
* 认不出来返回 null —— 「全部时段」那个 `all` 走的就是这条,调用方本来就该在
* `duration === "all"` 时不带时间条件。原来这段 `split(":")` 在五个组件里各写了一遍
* (两个统计面板、AI 分析页、榜单页、班级对比页),每份的兜底还都不一样。
*/
export function durationFromValue(
// 放宽到 SelectOption["value"] 那个形状:这些值直接来自 n-select 的绑定,
// Naive 那边的类型是 string | number | undefined。数字解不出来,照样回 null
value: string | number | null | undefined,
): Duration | null {
const [unit, amount] = String(value ?? "").split(":")
const count = Number(amount)
if (!unit || !Number.isFinite(count)) return null
return { [unit]: count } as Duration
}
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
const pad2 = (value: number) => String(value).padStart(2, "0")
/**
* 取一个时刻在东八区的年月日时分秒(数字)。无效日期返回 null。
*
* 站内所有时间一律按**东八区**展示、不跟浏览器时区走,口径是契约里的
* `TIME_ZONE`(和后端 `apps/api/src/time.ts` 同一个常量)。平移固定偏移后读 `getUTC*`,
* 就是北京的墙上时间。**不要在组件里写 `getFullYear()` / `getMonth()` / `getDate()`**
* —— 那是浏览器本地部件。
*/
export function zonedParts(value: Date | string) {
const time = normalizeDate(value).getTime()
if (Number.isNaN(time)) return null
const wall = new Date(time + OFFSET_MS)
return {
year: wall.getUTCFullYear(),
month: wall.getUTCMonth() + 1,
day: wall.getUTCDate(),
hour: wall.getUTCHours(),
minute: wall.getUTCMinutes(),
second: wall.getUTCSeconds(),
}
}
/** 东八区的当前年份。跨年那几个小时里它和 `new Date().getFullYear()` 会差一年 */
export function zonedYear() {
return zonedParts(new Date())!.year
}
/**
* 按东八区格式化。格式串只认下面这几个 token(站内实际用到的就这些),
* 其余字符原样输出,所以 `YYYY年M月D日` 这种中英混排也能用。
*
* 长度不同的 token 靠正则的**顺序**区分:`YYYY` 必须排在 `M`/`D` 前面,
* 否则 `MM` 会被拆成两个 `M`。
*/
export function parseTime(utc: Date | string, format = "YYYY年M月D日") {
const parts = zonedParts(utc)
if (!parts) return ""
const table: Record<string, string> = {
YYYY: String(parts.year),
MM: pad2(parts.month),
DD: pad2(parts.day),
HH: pad2(parts.hour),
mm: pad2(parts.minute),
ss: pad2(parts.second),
M: String(parts.month),
D: String(parts.day),
}
return format.replace(/YYYY|MM|DD|HH|mm|ss|M|D/g, (token) => table[token]!)
}
/**
* Naive 的 `n-date-picker` 没有 `timezone` 属性,按**浏览器本地**渲染绑定的时间戳,
* 所以要平移一次再交给它。这两个函数互为逆运算,东八区的机器上是恒等:
*
* toPickerValue(真实时刻) → 绑给 n-date-picker,本地渲染出来正好是北京墙上时间
* fromPickerValue(选择器值) → 换回真实时刻,再 formatISO / 存库
*
* ⚠️ **只有 `n-date-picker` 需要这一对。** 显示时间用 `parseTime`,别把平移过的值喂给它。
*/
export function toPickerValue(instant: number) {
return instant + OFFSET_MS + new Date(instant).getTimezoneOffset() * 60_000
}
export function fromPickerValue(value: number) {
return value - OFFSET_MS - new Date(value).getTimezoneOffset() * 60_000
}
function getDurationObject(start: Date | string, end: Date | string) {
return intervalToDuration({
start: getTime(parseISO(start.toString())),
end: getTime(parseISO(end.toString())),
})
}
function formatDurationUnits(
duration: Duration,
units: Array<{ key: keyof Duration; suffix: string }>,
): string {
return units
.filter(({ key }) => duration[key])
.map(({ key, suffix }) => duration[key] + suffix)
.join("")
}
export function duration(start: Date | string, end: Date | string, showSeconds = false): string {
const durationObj = getDurationObject(start, end)
const units = [
{ key: "years" as const, suffix: "年" },
{ key: "months" as const, suffix: "月" },
{ key: "days" as const, suffix: "天" },
{ key: "hours" as const, suffix: "小时" },
{ key: "minutes" as const, suffix: "分钟" },
...(showSeconds ? [{ key: "seconds" as const, suffix: "秒" }] : []),
]
return formatDurationUnits(durationObj, units)
}
/**
* 自学时长的显示。心跳是 15 秒一跳,本来就精确不到秒,一律按分钟取整;
* 0 显示成短横线而不是「0 分钟」——「没学过」和「学了不到一分钟」不是一回事。
*/
export function readableDuration(seconds: number): string {
if (seconds <= 0) return "-"
if (seconds < 60) return "不到 1 分钟"
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `${minutes} 分钟`
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
}
export function durationToDays(start: Date | string, end: Date | string): string {
const durationObj = getDurationObject(start, end)
const units = [
{ key: "years" as const, suffix: "年" },
{ key: "months" as const, suffix: "月" },
{ key: "days" as const, suffix: "天" },
]
const result = formatDurationUnits(durationObj, units)
return result || "一天以内"
}
export function secondsToDuration(seconds: number): string {
// 直接算,不走 date-fns 的 intervalToDuration:那个会拆出年、月,原来只把天和小时
// 折回小时,超过一个月就把月丢了(剩 40 天 1 小时显示成 217:00:00)
const total = Math.max(0, Math.floor(seconds))
const pad = (n: number) => String(n).padStart(2, "0")
return [Math.floor(total / 3600), pad(Math.floor(total / 60) % 60), pad(total % 60)].join(":")
}
export function submissionMemoryFormat(memory: number | string | undefined) {
if (memory === undefined) return "--"
// 1048576 = 1024 * 1024
let t = parseInt(memory + "") / 1048576
return String(t.toFixed(0)) + "MB"
}
export function submissionTimeFormat(time: number | string | undefined) {
if (time === undefined) return "--"
return time + "ms"
}
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay = 100,
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>
return (...args: Parameters<T>) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), delay)
}
}
export function getUserRole(role: AdminUser["adminType"]): {
type: "default" | "info" | "warning" | "error"
label: "普通" | "生管" | "师管" | "超管"
} {
const roleMap = {
[USER_TYPE.REGULAR_USER]: {
type: "default" as const,
label: "普通" as const,
},
[USER_TYPE.STUDENT_ADMIN]: {
type: "info" as const,
label: "生管" as const,
},
[USER_TYPE.TEACHER_ADMIN]: {
type: "warning" as const,
label: "师管" as const,
},
[USER_TYPE.SUPER_ADMIN]: {
type: "error" as const,
label: "超管" as const,
},
}
// role 是从接口来的裸字符串,toAdminType 把认不出来的值归到「普通」,
// 归一逻辑和后端共用同一个函数(@oj2/contract 的 roles.ts)
return roleMap[toAdminType(role)]
}
export function unique<T>(arr: T[]): T[] {
return [...new Set(arr)]
}
export function base64EncodeUtf8(string?: string): string {
try {
return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? "")))
} catch (error) {
console.error("编码失败:", error)
return ""
}
}
export function base64DecodeUtf8(bytes?: string): string {
try {
if (!bytes) return ""
const latin = atob(bytes)
return new TextDecoder("utf-8").decode(
Uint8Array.from({ length: latin.length }, (_, index) => latin.charCodeAt(index)),
)
} catch (error) {
console.error("解码失败:", error)
return ""
}
}
export function compressToBase64(data: string): string {
const buffer = strToU8(data)
const zipped = zlibSync(buffer, { level: 9 })
const binary = strFromU8(zipped, true)
return btoa(binary)
}
export function decompressFromBase64(base64: string): string {
const binary = atob(base64)
const buffer = strToU8(binary, true)
const unzipped = unzlibSync(buffer)
return strFromU8(unzipped)
}
/**
* 把若干文本文件打包成 zip Blob
* @param files 文件名和文本内容
* @param mtime 归档内的修改时间,默认当前时间
*/
export function createZipBlob(
files: { name: string; content: string }[],
mtime: Date = new Date(),
): Blob {
const entries: Zippable = {}
for (const f of files) {
entries[f.name] = strToU8(f.content)
}
return new Blob([zipSync(entries, { mtime })], { type: "application/zip" })
}
/**
* 复制文本到剪贴板
* 优先使用 Clipboard API(支持在 modal 中使用),失败时回退到 copy-text-to-clipboard
* @param text 要复制的文本
* @returns Promise<boolean> 复制是否成功
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// 优先使用现代 Clipboard API
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch (error) {
console.warn("Clipboard API 复制失败,尝试使用回退方法:", error)
}
}
// 回退到 copy-text-to-clipboard
try {
const success = copyTextFallback(text)
return success
} catch (error) {
console.error("复制失败:", error)
return false
}
}
export function getRandomId() {
const nanoid = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz")
return nanoid()
}