feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
370
apps/web/src/utils/constants.ts
Normal file
370
apps/web/src/utils/constants.ts
Normal 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:1,12px 的稀有度标签根本看不清。取色见 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" },
|
||||
]
|
||||
23
apps/web/src/utils/download.ts
Normal file
23
apps/web/src/utils/download.ts
Normal 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
|
||||
600
apps/web/src/utils/functions.ts
Normal file
600
apps/web/src/utils/functions.ts
Normal 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"
|
||||
79
apps/web/src/utils/http.ts
Normal file
79
apps/web/src/utils/http.ts
Normal 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
|
||||
37
apps/web/src/utils/judge.ts
Normal file
37
apps/web/src/utils/judge.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
108
apps/web/src/utils/permissions.ts
Normal file
108
apps/web/src/utils/permissions.ts
Normal 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
|
||||
}
|
||||
9
apps/web/src/utils/renders.ts
Normal file
9
apps/web/src/utils/renders.ts
Normal 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,
|
||||
])
|
||||
}
|
||||
21
apps/web/src/utils/storage.ts
Normal file
21
apps/web/src/utils/storage.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
94
apps/web/src/utils/stream.ts
Normal file
94
apps/web/src/utils/stream.ts
Normal 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
790
apps/web/src/utils/types.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user