Files
OJ2/apps/web/src/utils/functions.ts
T
xuyue aab0404ed7
Deploy / deploy (push) Has been cancelled
refactor(契约): 语言与判题产物的形状收进契约,学生端高频响应接上运行时校验
契约在前端一直只当类型包用:45 处引用里几乎全是 import type,三个 .parse() 后面
还都紧跟一个 as 把校验结果断言回去,等于没校验。这一轮把形状的来源收拢。

## 语言:三份真相并成一份

前端 utils/types.ts 手写了一份 9 值的语言联合,后端 judge/languages.ts 有自己的一套,
生产库又有一套。手抄那份**漏了 SQL**,而生产库 961 道题里有 9 道 SQL 题、
124191 条提交里有 91 条 SQL 提交 —— 这些提交的 language 在前端类型上是 undefined。
现在唯一来源是契约的 problemLanguageSchema,constants.ts 的显示映射以它为键,
契约里加语言而那边没补映射会当场编译不过。

## 判题产物:按生产数据实测收紧

judgeInfoSchema / statisticInfoSchema 的形状来自 124191 条提交的实测,不是手抄:

- info.data 有 12048 条是 null(编译失败等没有逐测试点结果),前端手抄的 Info
  却把 data 写成非空数组 —— 这 12048 条在类型上根本不成立;
- info 还允许**空对象**:非管理员看提交详情时后端下发 info: {}(权限投影)。
  收紧时必须把它算进去,否则每条非管理员看的提交详情直接 500 —— 本地实测复现过;
- statistic_info 的五个键按出现次数定成全部可选;另有 8916 条 JSONB 原文因内嵌
  带转义的 shell 输出不是合法 JSON,被后端 objectValue() 兜成 { value: ... },
  所以不能用严格对象,否则这 8916 条会被误判成分歧。

## 运行时闸门

新增 utils/contract.ts:safeParse 失败时记一条分歧(去重、控制台可见、
window.__OJ2_CONTRACT_DRIFT__ 可查)后**放行原始数据**,不白屏 —— 面向学生的
生产站点,字段空着比整页崩掉可接受。接在 7 条高频链路上:/site、/site/online、
/problems、/problems/:id、/submissions、/submissions/:id、/me。

提交详情的 info 是联合类型,调用方不再直接取 .data,统一走
utils/functions.ts 的 submissionCaseResults()。

## 验证

- vue-tsc 与 tsc -p apps/api 均 exit 0;vite build 通过;
- 契约 schema 直跑真实接口:7/7 通过(自包含脚本,从登录到详情全链路);
- 用生产备份复核收紧后的约束:961 题的 languages 无越界值,template 只出现
  C/Python3 两个键 —— 不会因为这次收紧在生产上抛错。
2026-09-10 04:19:41 -06:00

623 lines
18 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 { toAdminType } from "@oj2/contract"
import type { JudgeCaseResult, SubmissionDetail } from "@oj2/contract"
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { User } from "./types"
import { USER_TYPE } from "./constants"
import {
strFromU8,
strToU8,
unzlibSync,
zipSync,
zlibSync,
type Zippable,
} from "fflate"
import copyTextFallback from "copy-text-to-clipboard"
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` 是**联合类型**:判题机写的完整形状,或者空对象 —— 后者是后端对
* 非管理员下发的权限投影(`routes/submission.ts` 的 `full ? row.submission.info : {}`),
* 也是待判提交的初值。所以调用方不能直接 `.data`,得先在这里收口。
*
* 另外 `data` 本身也可能是 null:生产库 124191 条提交里有 12048 条是编译失败之类
* 没有逐测试点结果的情形。两种「没有」在这里一并归成空数组。
*/
export function submissionCaseResults(
info: SubmissionDetail["info"] | null | undefined,
): JudgeCaseResult[] {
if (!info || !("data" in info) || !info.data) return []
return info.data
}
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
export function parseTime(utc: Date | string, format = "YYYY年M月D日") {
const time = useDateFormat(utc, format, { locales: "zh-CN" })
return time.value
}
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 {
const duration = intervalToDuration({
start: 0,
end: seconds * 1000,
})
const hours = (duration.days ?? 0) * 24 + (duration.hours ?? 0)
const pad = (n: number) => String(n).padStart(2, "0")
return [hours, pad(duration.minutes ?? 0), pad(duration.seconds ?? 0)].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: User["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 encode(string?: string): string {
try {
return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? "")))
} catch (error) {
console.error("编码失败:", error)
return ""
}
}
export function decode(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 utoa(data: string): string {
const buffer = strToU8(data)
const zipped = zlibSync(buffer, { level: 9 })
const binary = strFromU8(zipped, true)
return btoa(binary)
}
export function atou(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()
}
/**
* 恶搞效果函数 - 随机触发不同的页面恶搞效果
*/
export function trickOrTreat() {
const effects = [
// 效果1: 中文乱码
() => {
document.body.innerHTML = document.body.innerHTML.replace(
/[\u4e00-\u9fa5]/g,
function (c) {
return String.fromCharCode(c.charCodeAt(0) ^ 0xa5) // 将中文字符转为乱码
},
)
},
// 效果2: 页面一直缩放
() => {
const style = document.createElement("style")
style.id = "trick-scale-style"
style.textContent = `
body {
animation: trickScale 0.5s ease-in-out infinite alternate;
}
@keyframes trickScale {
from { transform: scale(0.8); }
to { transform: scale(1.2); }
}
`
document.head.appendChild(style)
},
// 效果3: 页面左右颠倒
() => {
document.body.style.transform = "scaleX(-1)"
},
// 效果4: 去掉鼠标
() => {
const style = document.createElement("style")
style.id = "trick-cursor-none-style"
style.textContent = `
* {
cursor: none !important;
}
`
document.head.appendChild(style)
},
// 效果5: 页面一直旋转
() => {
const style = document.createElement("style")
style.id = "trick-rotate-style"
style.textContent = `
body {
animation: trickRotate 2s linear infinite;
}
@keyframes trickRotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
document.head.appendChild(style)
},
// 效果6: 页面上下颠倒
() => {
document.body.style.transform = "scaleY(-1)"
},
// 效果7: 页面颜色反转(反色)
() => {
const style = document.createElement("style")
style.id = "trick-invert-style"
style.textContent = `
body {
filter: invert(1) !important;
}
`
document.head.appendChild(style)
},
// 效果8: 页面抖动/震动
() => {
const style = document.createElement("style")
style.id = "trick-shake-style"
style.textContent = `
body {
animation: trickShake 0.1s ease-in-out infinite;
}
@keyframes trickShake {
0%, 100% { transform: translate(0, 0); }
25% { transform: translate(-5px, -5px); }
50% { transform: translate(5px, 5px); }
75% { transform: translate(-5px, 5px); }
}
`
document.head.appendChild(style)
},
// 效果9: 页面模糊
() => {
const style = document.createElement("style")
style.id = "trick-blur-style"
style.textContent = `
body {
filter: blur(5px) !important;
}
`
document.head.appendChild(style)
},
// 效果10: 点击哪里,哪里的DOM就飞掉
() => {
// 添加CSS动画样式
const style = document.createElement("style")
style.id = "trick-flyaway-style"
style.textContent = `
.trick-flyaway {
animation: trickFlyAway 0.5s ease-out forwards !important;
pointer-events: none !important;
}
@keyframes trickFlyAway {
0% {
transform: translate(0, 0) rotate(0deg);
opacity: 1;
}
100% {
transform: translate(var(--fly-x, 500px), var(--fly-y, -500px)) rotate(720deg);
opacity: 0;
}
}
`
document.head.appendChild(style)
// 添加全局点击事件监听器
const clickHandler = (e: MouseEvent) => {
const target = e.target as HTMLElement
// 跳过body和html元素,以及已经飞走的元素
if (
target === document.body ||
target === document.documentElement ||
target.classList.contains("trick-flyaway")
) {
return
}
// 计算飞出方向(随机方向)
const angle = Math.random() * Math.PI * 2
const distance = 1000 + Math.random() * 500
const flyX = Math.cos(angle) * distance
const flyY = Math.sin(angle) * distance
// 设置CSS变量
target.style.setProperty("--fly-x", `${flyX}px`)
target.style.setProperty("--fly-y", `${flyY}px`)
// 添加飞走动画类
target.classList.add("trick-flyaway")
// 动画结束后移除元素或隐藏
setTimeout(() => {
target.style.display = "none"
}, 500)
}
document.addEventListener("click", clickHandler, true)
},
// 效果11: 页面元素随机位置
() => {
const style = document.createElement("style")
style.id = "trick-random-position-style"
style.textContent = `
* {
position: relative !important;
}
`
document.head.appendChild(style)
// 随机移动所有元素
const allElements = document.querySelectorAll("*")
allElements.forEach((el) => {
if (el === document.body || el === document.documentElement) return
const element = el as HTMLElement
const randomX = (Math.random() - 0.5) * 200
const randomY = (Math.random() - 0.5) * 200
element.style.transform = `translate(${randomX}px, ${randomY}px)`
})
},
// 效果12: 页面变成3D倾斜效果
() => {
const style = document.createElement("style")
style.id = "trick-3d-tilt-style"
style.textContent = `
body {
perspective: 1000px;
transform-style: preserve-3d;
animation: trick3DTilt 3s ease-in-out infinite alternate;
}
@keyframes trick3DTilt {
0% {
transform: perspective(1000px) rotateX(0deg) rotateY(0deg);
}
25% {
transform: perspective(1000px) rotateX(15deg) rotateY(-15deg);
}
50% {
transform: perspective(1000px) rotateX(-15deg) rotateY(15deg);
}
75% {
transform: perspective(1000px) rotateX(15deg) rotateY(15deg);
}
100% {
transform: perspective(1000px) rotateX(-15deg) rotateY(-15deg);
}
}
`
document.head.appendChild(style)
},
// 效果13: 页面所有链接失效
() => {
const clickHandler = (e: MouseEvent) => {
const target = e.target as HTMLElement
// 检查是否是链接或按钮
if (
target.tagName === "A" ||
target.closest("a") ||
(target.tagName === "BUTTON" && !target.closest("n-button"))
) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
return false
}
}
document.addEventListener("click", clickHandler, true)
},
// 效果14: 页面变成波浪效果
() => {
const style = document.createElement("style")
style.id = "trick-wave-style"
style.textContent = `
body {
animation: trickWave 2s ease-in-out infinite;
}
@keyframes trickWave {
0%, 100% {
transform: translateY(0) scaleY(1);
}
25% {
transform: translateY(-10px) scaleY(1.02);
}
50% {
transform: translateY(0) scaleY(1);
}
75% {
transform: translateY(10px) scaleY(0.98);
}
}
`
document.head.appendChild(style)
},
// 效果15: 页面变成故障效果(Glitch)
() => {
const style = document.createElement("style")
style.id = "trick-glitch-style"
style.textContent = `
body {
animation: trickGlitch 0.3s infinite;
}
@keyframes trickGlitch {
0% {
transform: translate(0);
filter: hue-rotate(0deg);
}
20% {
transform: translate(-2px, 2px);
filter: hue-rotate(90deg);
}
40% {
transform: translate(-2px, -2px);
filter: hue-rotate(180deg);
}
60% {
transform: translate(2px, 2px);
filter: hue-rotate(270deg);
}
80% {
transform: translate(2px, -2px);
filter: hue-rotate(360deg);
}
100% {
transform: translate(0);
filter: hue-rotate(0deg);
}
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(255, 0, 0, 0.03) 2px,
rgba(255, 0, 0, 0.03) 4px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 2px,
rgba(0, 255, 255, 0.03) 2px,
rgba(0, 255, 255, 0.03) 4px
);
pointer-events: none;
z-index: 9999;
mix-blend-mode: difference;
}
`
document.head.appendChild(style)
},
]
// 随机选择一种效果
const randomEffect = effects[Math.floor(Math.random() * effects.length)]
randomEffect()
}
// function getChromeVersion() {
// var raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./)
// return raw ? parseInt(raw[2], 10) : 0
// }
// export const isLowVersion = getChromeVersion() < 80
// export const protocol = isLowVersion ? "http" : "https"