三条迁移,一次部署(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
613 lines
17 KiB
TypeScript
613 lines
17 KiB
TypeScript
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"
|