feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
2026-08-06 21:18:16 -06:00
parent 3c975e85ee
commit ae1fb329b5
258 changed files with 40490 additions and 91 deletions

View File

@@ -0,0 +1,370 @@
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
export enum SubmissionStatus {
compile_error = -2,
wrong_answer = -1,
accepted = 0,
time_limit_exceeded = 1 | 2,
memory_limit_exceeded = 3,
runtime_error = 4,
system_error = 5,
pending = 6,
judging = 7,
partial_accepted = 8,
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",
},
}
export const CONTEST_TYPE = {
PUBLIC: "Public",
PRIVATE: "Password Protected",
}
export const USER_TYPE = {
REGULAR_USER: "Regular User",
STUDENT_ADMIN: "Student Admin",
TEACHER_ADMIN: "Teacher Admin",
SUPER_ADMIN: "Super Admin",
}
export const PROBLEM_PERMISSION = {
NONE: "None",
OWN: "Own",
ALL: "All",
}
export const STORAGE_KEY = {
AUTHED: "authed",
LANGUAGE: "problemLanguage",
LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags",
DEMO_MODE: "demoMode",
}
export const DIFFICULTY = {
Low: "简单",
Mid: "中等",
High: "困难",
}
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_ID = {
C: 50,
"C++": 54,
Java: 62,
Python3: 71,
Python2: 0,
JavaScript: 0,
Golang: 0,
Flowchart: 0,
SQL: 0,
} 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:112px 的稀有度标签根本看不清。取色见 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",
},
}
// 时间范围配置
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" },
]

View File

@@ -0,0 +1,23 @@
import axios from "axios"
const http = axios.create({
baseURL: "/api/admin",
responseType: "blob",
xsrfHeaderName: "X-CSRFToken",
xsrfCookieName: "csrftoken",
})
async function download(url: string) {
const res = await http.get(url)
const headers = res.headers
const link = document.createElement("a")
link.href = window.URL.createObjectURL(
new window.Blob([res.data], { type: headers["content-type"] }),
)
link.download = (headers["content-disposition"] || "").split("filename=")[1]
document.body.appendChild(link)
link.click()
link.remove()
}
export default download

View File

@@ -0,0 +1,600 @@
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)
}
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["admin_type"]): {
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,
},
}
return roleMap[role] || roleMap[USER_TYPE.REGULAR_USER]
}
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 rect = target.getBoundingClientRect()
const clickX = e.clientX - rect.left - rect.width / 2
const clickY = e.clientY - rect.top - rect.height / 2
// 计算飞出方向(随机方向)
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"

View File

@@ -0,0 +1,79 @@
import axios, { type AxiosRequestConfig } from "axios"
import { createDiscreteApi } from "naive-ui"
import { useAuthModalStore } from "shared/store/authModal"
import storage from "./storage"
import { STORAGE_KEY } from "./constants"
const { message } = createDiscreteApi(["message"])
// 后端统一返回 { error, data } 信封;拦截器剥掉 axios 外层后,
// 调用方拿到的就是这个信封data 才是真正的业务数据。
export interface ApiResponse<T = any> {
error: string | null
data: T
}
// 让 http.get<T>() 的类型真实反映"解包后返回信封"这件事,
// 调用方 res.data 直接拿到带类型的 T不再依赖 axios 的 AxiosResponse 巧合对齐。
interface Http {
get<T = any>(
url: string,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
delete<T = any>(
url: string,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
post<T = any>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
put<T = any>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
}
const instance = axios.create({
baseURL: "/api",
xsrfHeaderName: "X-CSRFToken",
xsrfCookieName: "csrftoken",
})
// 统一剥掉空字符串 / null / undefined 的 query 参数,
// 各 api 函数不必再手写过滤逻辑(保留 0、false
instance.interceptors.request.use((config) => {
if (config.params) {
config.params = Object.fromEntries(
Object.entries(config.params).filter(
([, v]) => v !== "" && v !== null && v !== undefined,
),
)
}
return config
})
instance.interceptors.response.use(
(res) => {
if (res.data.error) {
if (res.data.error === "login-required") {
storage.remove(STORAGE_KEY.AUTHED)
useAuthModalStore().openLoginModal()
} else if (res.data.error === "permission-denied") {
message.error(res.data.data || "权限不足")
}
return Promise.reject(res.data)
} else {
return Promise.resolve(res.data)
}
},
(err) => {
return Promise.reject(err)
},
)
const http = instance as unknown as Http
export default http

View File

@@ -0,0 +1,37 @@
import axios from "axios"
import { decode, encode } from "./functions"
import { Code } from "./types"
const http = axios.create({ baseURL: import.meta.env.PUBLIC_JUDGE0_URL })
export async function createTestSubmission(code: Code, input: string) {
const encodedCode = encode(code.value)
const id = {
C: 50,
"C++": 54,
Java: 62,
Golang: 60,
JavaScript: 63,
Python2: 70,
Python3: 71,
}[code.language]
let compilerOptions = ""
if (id === 50) compilerOptions = "-lm" // 解决 GCC 的链接问题
const payload = {
source_code: encodedCode,
language_id: id,
stdin: encode(input),
redirect_stderr_to_stdout: true,
compiler_options: compilerOptions,
}
const response = await http.post("/submissions", payload, {
params: { base64_encoded: true, wait: true },
})
const data = response.data
return {
status: data.status && data.status.id,
output: [decode(data.compile_output), decode(data.stdout)]
.join("\n")
.trim(),
}
}

View File

@@ -0,0 +1,108 @@
import { useUserStore } from "shared/store/user"
export function usePermissions() {
const userStore = useUserStore()
return {
isAuthenticated: computed(() => userStore.isAuthed),
isAdminRole: computed(() => userStore.isAdminRole),
isTeacherOrAbove: computed(() => userStore.isTeacherOrAbove),
isSuperAdmin: computed(() => userStore.isSuperAdmin),
hasProblemPermission: computed(() => userStore.hasProblemPermission),
canManageUsers: computed(() => userStore.isSuperAdmin),
canManageAnnouncements: computed(() => userStore.isSuperAdmin),
canManageTutorials: computed(() => userStore.isSuperAdmin),
canManageSystemConfig: computed(() => userStore.isSuperAdmin),
canSendMessages: computed(() => userStore.isSuperAdmin),
canManageProblems: computed(() => userStore.hasProblemPermission),
canManageContests: computed(() => userStore.isTeacherOrAbove),
canManageProblemsets: computed(() => userStore.isTeacherOrAbove),
canViewClassroomData: computed(() => userStore.isTeacherOrAbove),
canManageAllProblems: computed(
() =>
userStore.user?.problem_permission === "All" || userStore.isSuperAdmin,
),
canManageOwnProblems: computed(
() =>
userStore.user?.problem_permission === "Own" && !userStore.isSuperAdmin,
),
getUserPermissionLevel: computed(() => {
if (userStore.isSuperAdmin) return "超级管理员"
if (userStore.isTeacherAdmin) return "教师管理员"
if (userStore.isStudentAdmin) return "学生管理员"
return "普通用户"
}),
getProblemPermissionLevel: computed(() => {
if (!userStore.user) return "无权限"
switch (userStore.user.problem_permission) {
case "All":
return "管理所有题目"
case "Own":
return "管理自己的题目"
case "None":
return "无题目权限"
default:
return "无权限"
}
}),
}
}
export function checkRoutePermission(routeName: string): boolean {
const userStore = useUserStore()
const superAdminRoutes = [
"admin home",
"admin config",
"admin user list",
"admin user generate",
"admin announcement list",
"admin announcement create",
"admin announcement edit",
"admin message list",
"admin tutorial list",
"admin tutorial create",
"admin tutorial edit",
]
const teacherAdminRoutes = [
"admin contest list",
"admin contest create",
"admin contest edit",
"admin contest problem list",
"admin contest problem create",
"admin contest problem edit",
"admin contest helper",
"admin problemset list",
"admin problemset create",
"admin problemset edit",
"admin problemset detail",
"admin stuck problems",
"admin top ac trend",
]
const problemPermissionRoutes = [
"admin problem list",
"admin problem create",
"admin problem edit",
]
if (superAdminRoutes.includes(routeName)) {
return userStore.isSuperAdmin
}
if (teacherAdminRoutes.includes(routeName)) {
return userStore.isTeacherOrAbove
}
if (problemPermissionRoutes.includes(routeName)) {
return userStore.hasProblemPermission
}
return true
}

View File

@@ -0,0 +1,9 @@
import { Icon } from "@iconify/vue"
import { NFlex } from "naive-ui"
export function renderTableTitle(title: string, icon: string): any {
return h(NFlex, { align: "center", size: 4 }, () => [
h(Icon, { icon: icon, width: 20, height: 20 }),
title,
])
}

View File

@@ -0,0 +1,21 @@
const localStorage = window.localStorage
export default {
set(key: string, value: any) {
localStorage.setItem(key, JSON.stringify(value))
},
get(key: string) {
const content = localStorage.getItem(key)
if (content) {
return JSON.parse(content)
} else {
return null
}
},
remove(key: string) {
localStorage.removeItem(key)
},
clear() {
localStorage.clear()
},
}

View File

@@ -0,0 +1,94 @@
export interface JSONEventStreamHandlers<T = any> {
onMessage: (data: T, event?: string) => void
onEvent?: (event: string) => void
signal?: AbortSignal | null
}
export async function consumeJSONEventStream<T = any>(
response: Response,
handlers: JSONEventStreamHandlers<T>,
) {
if (!response.body) {
throw new Error("当前环境不支持可读流")
}
const reader = response.body.getReader()
const decoder = new TextDecoder("utf-8")
let buffer = ""
const { onMessage, onEvent, signal } = handlers
const handleEvent = (raw: string) => {
const lines = raw.split("\n")
let eventName: string | undefined
const dataLines: string[] = []
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
if (trimmed.startsWith("event:")) {
eventName = trimmed.slice(6).trim()
} else if (trimmed.startsWith("data:")) {
dataLines.push(trimmed.slice(5).trim())
}
}
if (dataLines.length === 0) {
if (eventName && onEvent) {
onEvent(eventName)
}
return
}
const payloadStr = dataLines.join("\n")
let parsed: T
try {
parsed = JSON.parse(payloadStr)
} catch (error) {
throw new Error(`无法解析服务端事件数据: ${payloadStr}`)
}
onMessage(parsed, eventName)
}
const processBuffer = (flush = false) => {
let idx = buffer.indexOf("\n\n")
while (idx !== -1) {
const rawEvent = buffer.slice(0, idx)
buffer = buffer.slice(idx + 2)
if (rawEvent.trim()) {
handleEvent(rawEvent)
}
idx = buffer.indexOf("\n\n")
}
if (flush && buffer.trim()) {
handleEvent(buffer.trim())
buffer = ""
}
}
try {
while (true) {
if (signal?.aborted) {
await reader.cancel()
break
}
const { value, done } = await reader.read()
if (value) {
buffer += decoder.decode(value, { stream: true })
processBuffer()
}
if (done) {
buffer += decoder.decode()
processBuffer(true)
break
}
}
} finally {
reader.releaseLock()
}
}

790
apps/web/src/utils/types.ts Normal file
View File

@@ -0,0 +1,790 @@
import { ContestStatus, ContestType, LANGUAGE_SHOW_VALUE } from "./constants"
export interface Profile {
id: number
user: User
real_name: string
acm_problems_status: {
problems: {
[key: string]: {
_id: string
status: number
}
}
}
avatar: string
blog: null
mood: string
github: string
school: string
major: string
language: string
accepted_number: number
submission_number: number
}
export type UserAdminType =
"Regular User" | "Student Admin" | "Teacher Admin" | "Super Admin"
export interface User {
id: number
username: string
real_name: string
email: string
admin_type: UserAdminType
problem_permission: string
create_time: Date
last_login: Date
open_api: boolean
is_disabled: boolean
password?: string
raw_password?: string
class_name?: string | null
}
export type LANGUAGE =
| "C"
| "C++"
| "Python2"
| "Python3"
| "Java"
| "JavaScript"
| "Golang"
| "Flowchart"
| "SQL"
export interface SQLConfig {
mode: "query" | "modify"
order_sensitive: boolean
}
export interface SQLDisplayColumn {
name: string
type?: string
}
export interface SQLDisplayTable {
name: string
columns: SQLDisplayColumn[]
rows: (string | number | null)[][]
total_rows: number
truncated: boolean
dropped?: boolean
}
export interface SQLDisplay {
tables: SQLDisplayTable[]
expected:
| {
columns: SQLDisplayColumn[]
rows: (string | number | null)[][]
total_rows: number
truncated: boolean
}
| { changed_tables: SQLDisplayTable[] }
}
export type LANGUAGE_SHOW_LABEL =
(typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE]
export type SUBMISSION_RESULT =
-2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
export type ProblemStatus = "passed" | "failed" | "not_test"
interface SampleUser {
id: number
username: string
real_name: string | null
}
export interface Tag {
id: number
name: string
}
export interface AdminTag {
id: number
name: string
problem_count: number
}
export interface TestcaseUploadedReturns {
id: string
info: Testcase[]
}
export interface Testcase {
input_name: string
output_name: string
score: string
}
export interface Problem {
_id: string
id: number
tags: string[]
created_by: SampleUser
template: { [key in LANGUAGE]?: string }
title: string
description: string
input_description: string
output_description: string
samples: {
input: string
output: string
}[]
hint: string
languages: Array<LANGUAGE>
create_time: Date
last_update_time: null
time_limit: number
memory_limit: number
difficulty: "Low" | "Mid" | "High"
source: string
prompt: string
answers: { language: LANGUAGE; code: string }[]
submission_number: number
accepted_number: number
statistic_info: { [key: string]: number }
share_submission: boolean
contest: number
my_status: number
my_failed_count?: number
visible: boolean
// 流程图相关字段
allow_flowchart: boolean
mermaid_code?: string
flowchart_data?: Record<string, any>
flowchart_hint?: string
show_flowchart?: boolean
ast_rules?: {
[key: string]: {
engine: string
target?: string
min?: number
max?: number
message: string
}[]
} | null
has_ast_rules?: boolean
// SQL 题配置(非 SQL 题为 null
sql_config?: SQLConfig | null
// SQL 题展示数据(后端保存题目时自动生成)
sql_display?: SQLDisplay | null
}
export type AdminProblem = Problem &
AlterProblem & {
// 后台题目列表接口附带的最高票评价,比赛题目列表不返回
top_reaction?: { type: ReactionKey; count: number } | null
}
interface AlterProblem {
test_case_id: string
test_case_score: Testcase[]
contest_id?: string
}
type ExcludeKeys =
| "id"
| "created_by"
| "create_time"
| "last_update_time"
| "my_status"
| "contest"
| "statistic_info"
| "accepted_number"
| "submission_number"
export type BlankProblem = Omit<Problem, ExcludeKeys> &
AlterProblem & { id?: number }
export interface ProblemFiltered {
_id: string
id: number
title: string
difficulty: "简单" | "中等" | "困难"
tags: string[]
submission: number
rate: string
status: "not_test" | "passed" | "failed"
author: string
allow_flowchart: boolean
show_flowchart: boolean
has_ast_rules: boolean
}
export interface AdminProblemFiltered {
_id: string
id: number
title: string
visible: boolean
username: string
create_time: string
difficulty: "Low" | "Mid" | "High"
tags: string[]
has_ast_rules: boolean
allow_flowchart: boolean
show_flowchart: boolean
// 比赛题目列表接口不返回这个字段
top_reaction?: { type: ReactionKey; count: number } | null
}
// 题单相关类型
export interface ProblemSet {
id: number
title: string
description: string
created_by: SampleUser
create_time: Date
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
end_time: Date | null
visible: boolean
problems_count: number
completed_count: number
user_progress: {
is_joined: boolean
progress_percentage: number
completed_count: number
total_count: number
is_completed: boolean
}
}
export interface ProblemSetList {
id: number
title: string
description: string
created_by: SampleUser
create_time: Date
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
end_time: Date | null
problems_count: number
visible: boolean
user_progress: {
is_joined: boolean
progress_percentage: number
completed_count: number
total_count: number
is_completed: boolean
}
badges: ProblemSetBadge[]
}
export interface ProblemSetProblem {
id: number
problemset: number
problem: Problem
order: number
is_required: boolean
score: number
hint: string
is_completed: boolean
}
export interface ProblemSetBadge {
id: number
problemset: number
name: string
description: string
icon: string
condition_type: "all_problems" | "problem_count" | "score"
condition_value: number
is_earned?: boolean
}
export interface UserBadge {
id: number
user: number
badge: ProblemSetBadge
earned_time: Date
}
export interface CompletedProblem {
id: number
_id: string
title: string
}
export interface ProblemSetProgress {
id: number
problemset: ProblemSetList
user: SampleUser
join_time: Date
completed_problems_count: number
total_problems_count: number
progress_percentage: number
is_completed: boolean
completed_problems: CompletedProblem[]
}
export interface CreateProblemSetData {
title: string
description: string
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
end_time?: Date | null
}
export interface EditProblemSetData {
id: number
title?: string
description?: string
difficulty?: "Easy" | "Medium" | "Hard"
status?: "active" | "archived" | "draft"
end_time?: Date | null
visible?: boolean
}
export interface Code {
language: LANGUAGE
value: string
}
export interface SubmitCodePayload {
problem_id: number
language: LANGUAGE
code: string
contest_id?: number
}
// ==================== 流程图相关类型 ====================
export const FlowchartSubmissionStatus = {
PENDING: 0, // 等待AI评分
PROCESSING: 1, // AI评分中
COMPLETED: 2, // 评分完成
FAILED: 3, // 评分失败
} as const
export interface FlowchartSubmission {
id: string
user: number
problem: number
mermaid_code: string
flowchart_data: Record<string, any>
status: number
create_time: string
ai_score?: number
ai_grade?: string
ai_feedback?: string
ai_suggestions?: string
ai_criteria_details: Record<string, any>
ai_provider?: string
ai_model?: string
processing_time?: number
evaluation_time?: string
}
// 列表接口返回的字段(包含 username 和 problem_title
export interface FlowchartSubmissionListItem {
id: string
create_time: string
evaluation_time: string
ai_score: number
ai_grade: Grade
ai_model: string
ai_provider: string
processing_time: number
status: number
username: string
problem_title: string
problem: string
show_link: boolean
}
export interface SubmitFlowchartPayload {
problem_id: number
mermaid_code: string
flowchart_data?: Record<string, any>
}
interface Info {
err: string | null
data: {
error: number
memory: number
output: null
result: SUBMISSION_RESULT
signal: number
cpu_time: number
exit_code: number
real_time: number
test_case: string
output_md5: string
}[]
}
export interface Submission {
id: string
create_time: Date
user_id: number
username: string
code: string
result: SUBMISSION_RESULT
info: Info
language: LANGUAGE
shared: boolean
show_link: boolean
statistic_info: {
score?: number
err_info?: string
time_cost?: number
memory_cost?: number
ast_results?: Array<{ description: string; passed: boolean }>
}
ip: string
contest: number
problem: number // 不是 display_id
can_unshare: boolean
}
export interface SubmissionListItem {
id: string
problem: string
problem_title: string
show_link: boolean
create_time: string
user_id: number
username: string
result: SUBMISSION_RESULT
language: LANGUAGE
shared: boolean
statistic_info: {
time_cost: number
memory_cost: number
}
}
export interface SubmissionListPayload {
myself?: "1" | "0"
result?: string
username?: string
contest_id?: string
problem_id?: string
language: LANGUAGE | ""
today?: "1" | "0"
page: number
limit: number
offset: number
}
export interface Rank {
id: number
user: SampleUser
acm_problems_status: {
problems: {
[key: string]: {
_id: string
status: number
}
}
contest_problems?: {
[key: string]: {
[key: string]: {
_id: string
status: number
}
}
}
}
real_name: null | string
avatar: string
blog: null
mood: null | string
github: null
school: null | string
major: null | string
language: null | string
accepted_number: number
submission_number: number
}
export interface Contest extends BlankContest {
id: number
created_by: SampleUser
status: ContestStatus
contest_type: ContestType
create_time: string
now: string
last_update_time: string
}
export interface BlankContest {
title: string
description: string
tag: string
start_time: string
end_time: string
password: string
visible: boolean
allowed_ip_ranges: { value: string }[]
}
interface SubmissionInfo {
is_ac: boolean
ac_time: number
is_first_ac: boolean
error_number: number
checked?: boolean
}
export interface ContestRank {
id: number
user: SampleUser
submission_number: number
accepted_number: number
total_time: number
submission_info: { [key: string]: SubmissionInfo }
contest: number
}
export interface WebsiteConfig {
website_base_url: string
website_name: string
website_name_shortcut: string
website_footer: string
allow_register: boolean
submission_list_show_all: boolean
class_list: string[] & never[]
enable_maxkb: boolean
}
export interface Server {
id: number
status: "abnormal" | "normal"
hostname: string
ip: string
judger_version: string
cpu_core: number
memory_usage: number
cpu_usage: number
last_heartbeat: Date
create_time: Date
task_number: number
service_url: string
is_disabled: boolean
}
export interface AnnouncementEdit {
id: number
title: string
tag: string
content: string
visible: boolean
top: boolean
}
export interface Announcement extends AnnouncementEdit {
created_by: SampleUser
create_time: Date
last_update_time: Date
}
export interface Message {
sender: User
create_time: Date
message: string
submission: Submission
}
export interface CreateMessage {
sender: string
recipient: string
submission: string
message: string
}
export type ReactionKey =
| "too_easy"
| "too_hard"
| "confusing"
| "buggy"
| "learned"
| "interesting"
| "want_explain"
export type ReactionCounts = Record<ReactionKey, number>
export interface ReactionState {
mine: ReactionKey | null
counts: ReactionCounts | null
}
export interface Tutorial {
id: number
title: string
content: string
code: string
is_public: boolean
order: number
type: "python" | "c"
created_by?: User
updated_at?: Date
created_at?: Date
}
export interface ExerciseMcqData {
question: string
options: string[]
answer: number[]
}
export interface ExerciseSortData {
question: string
lines: string[]
}
export interface ExerciseFillData {
question: string
code: string
}
export interface ExerciseMatchData {
question: string
left: string[]
right: string[]
answer: number[]
}
export interface ExercisePredictData {
question: string
code: string
answer: string[]
}
export interface ExerciseDebugData {
question: string
lines: string[]
answer: number[]
explanation?: string
}
export interface ExerciseGroupData {
question: string
buckets: string[]
items: string[]
answer: number[]
}
export type ExerciseType =
"mcq" | "sort" | "fill" | "match" | "predict" | "debug" | "group"
export interface Exercise {
id: number
type: ExerciseType
data:
| ExerciseMcqData
| ExerciseSortData
| ExerciseFillData
| ExerciseMatchData
| ExercisePredictData
| ExerciseDebugData
| ExerciseGroupData
order: number
}
export interface DurationData {
unit: string
index: number
start: string
end: string
grade: Grade
problem_count: number
submission_count: number
}
export interface SolvedProblem {
problem: {
title: string
display_id: string
contest_title: string
contest_id: number
}
ac_time: string
rank: number
ac_count: number
grade: Grade
period_rank: number
period_ac_count: number
difficulty: string
}
export interface FlowchartSummary {
problem__id: string
problem_title: string
submission_count: number
best_score: number
best_grade: string
latest_submission_time: string
avg_score: number
}
export interface DetailsData {
start: string
end: string
grade: Grade
class_name: string
tags: { [key: string]: number }
difficulty: { [key: string]: number }
contest_count: number
solved: SolvedProblem[]
flowcharts: FlowchartSummary[]
}
export type Grade = "S" | "A" | "B" | "C"
// ==================== 成就相关类型 ====================
export type AchievementRarity = "bronze" | "silver" | "gold" | "platinum"
export interface Achievement {
id: number
name: string
description: string
icon: string
rarity: AchievementRarity
hidden: boolean
// 隐藏成就未解锁时,后端已做掩码处理,以下四个字段为 null
metric: string | null
operator: "gte" | "lte" | null
threshold: number | null
unlocked: boolean
unlock_time: string | null
backfilled: boolean
progress: number | null
unlock_rate: number
}
export interface AchievementRarityStat {
rarity: AchievementRarity
label: string
total: number
unlocked: number
}
export interface PendingAchievement {
id: number
name: string
description: string
icon: string
rarity: AchievementRarity
// 弹窗队列里混着两种东西:全站成就和题单奖章。它们的 id 来自两张不同的表,
// 数值会重叠,所以去重和标记已读都必须带上 kind 一起判断。
// pending 接口只返回成就,不带这个字段,缺省按 achievement 处理。
kind?: "achievement" | "badge"
}
export interface AchievementSummary {
username: string
total: number
unlocked: number
percent: number
rarity: AchievementRarityStat[]
recent: PendingAchievement[]
}