Files
OJ2/apps/web/src/utils/functions.ts
yuetsh 2c4d56b29a refactor(后端): 清掉 Django 遗留的死列与手工级联,角色字符串收成一份
三条迁移,一次部署(0008/0009 含 DROP COLUMN,需要 OJ2_ALLOW_DESTRUCTIVE=1):

- 0008 删 IP 相关:比赛 IP 白名单(前端本来就没有输入框,detail.vue 无条件置空)、
  submission.ip(前端从未显示过)、以及一次都没被调用过的 IP 限流桶。
  judge_server.ip 是运维数据,保留。
- 0009 删九个只有 Django 时代写过、OJ2 一次都没读过的列:user 的 auth_token /
  open_api / open_api_appkey / session_keys,user_profile 的 blog / github /
  school / major / language。open_api 后台连开关都没有,那段「已经开着就不重置
  appkey」的逻辑从上线起没进过 if。判据是「全仓零读取」而不是「看着没用」——
  raw_password 同样刺眼却是在用的,别一起清掉。
- 0010 给 17 条外键补上删除动作,不再是 Django 留下的一律 NO ACTION。父行消失后
  必然无意义、且不构成学生留痕的走 CASCADE(中间表、题单/教程/成就的组成部分、
  user_profile 与 user_stat);需要人看见的继续拦着——submission.problem_id、
  以及 user 的绝大多数外键,删用户撞外键会被 handler 翻译成「请改为禁用账号」,
  这是有意的:全 CASCADE 会静默抹掉成就与进度,而 submission.user_id 压根没有
  外键,结果是一半删一半留。六处手工级联随之删掉。

角色字符串收进 packages/contract/src/roles.ts:原先 ADMIN_ROLES / TEACHER_ROLES
在两个文件各抄一份、学生口径在四个文件各写一遍、前端 USER_TYPE 是第三份副本。
AuthUser.adminType 与 drizzle 的列都收窄成联合类型,二十多处 `=== "Super Admin"`
从此受编译器管着($type 是纯 TS 层的,generate 确认不产生任何 SQL 变更)。

顺带删掉 db/relations.ts —— drizzle-kit pull 的产物,全仓零引用。

一处行为变化:后台用户列表传非法的 ?type= 回 400,不再静默返回空列表;界面上的
下拉只有合法值,打不到这条。

验证:tsc / vue-tsc / vite build / check:routes 全过;三条迁移在 dev 库执行,
并逐条建 fixture 走 HTTP 接口验过删除连坐与拦截(题单五张子表连坐、user_badge
二级连坐、删有提交的题目仍 409、删有表情的用户仍 409)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeJoYc2t2d7cThVqMBYrBF
2026-09-02 22:57:45 -06:00

613 lines
17 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 { 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)
}
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 getCSRFToken(): string {
if (typeof document === "undefined") {
return ""
}
const match = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/)
return match ? decodeURIComponent(match[1]) : ""
}
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"