style(格式化): 改成单引号、一行最长 160,全仓重新格式化
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 13:52:24 +08:00
1 parent 530e1a3d5d
commit 195069450f
480 files changed
+15507 -24650

No files matched your search

+23 -30
View File
@@ -1,10 +1,10 @@
import { createDiscreteApi } from "naive-ui"
import { useAuthModalStore } from "shared/store/authModal"
import { authFetch } from "./auth"
import { STORAGE_KEY } from "./constants"
import storage from "./storage"
import { createDiscreteApi } from 'naive-ui'
import { useAuthModalStore } from 'shared/store/authModal'
import { authFetch } from './auth'
import { STORAGE_KEY } from './constants'
import storage from './storage'
const { message: toast } = createDiscreteApi(["message"])
const { message: toast } = createDiscreteApi(['message'])
/** 后端失败响应体的形状,见 apps/api/src/http.ts 的 failure */
interface ErrorPayload {
@@ -27,7 +27,7 @@ export class ApiError extends Error {
readonly data: string,
) {
super(data)
this.name = "ApiError"
this.name = 'ApiError'
}
}
@@ -41,7 +41,7 @@ export function errorCode(err: unknown) {
}
/** 给用户看的文案:接口错误用后端文案,其它一律 fallback —— 别把 TypeError 的英文甩给学生 */
export function errorMessage(err: unknown, fallback = "未知错误") {
export function errorMessage(err: unknown, fallback = '未知错误') {
return isApiError(err) && err.data ? err.data : fallback
}
@@ -54,28 +54,24 @@ export function errorMessage(err: unknown, fallback = "未知错误") {
* 同一个错误码从两条路进来,学生看到的结果不该有两个样子。
*/
/** handleGlobalApiError 已经替调用方提示过的错误码,调用方别再弹第二遍 */
export const GLOBALLY_HANDLED_CODES: readonly string[] = [
"login-required",
"account-disabled",
"permission-denied",
]
export const GLOBALLY_HANDLED_CODES: readonly string[] = ['login-required', 'account-disabled', 'permission-denied']
export function handleGlobalApiError(code: string, message?: string) {
if (code === "login-required") {
if (code === 'login-required') {
storage.remove(STORAGE_KEY.AUTHED)
useAuthModalStore().openLoginModal()
} else if (code === "account-disabled") {
} else if (code === 'account-disabled') {
// 这里**不能**弹登录框:账号已经被禁用,登进去还是会被拒,
// 学生会陷入「弹框 → 登录 → 又弹框」的死循环,且看不出发生了什么。
// 清掉登录态并明确告知,会话在中途被禁用时也走这一支。
storage.remove(STORAGE_KEY.AUTHED)
toast.error("账号已被禁用,请联系老师")
} else if (code === "permission-denied") {
toast.error(message || "权限不足")
toast.error('账号已被禁用,请联系老师')
} else if (code === 'permission-denied') {
toast.error(message || '权限不足')
}
}
const NETWORK_ERROR_MESSAGE = "网络异常,请检查网络后重试"
const NETWORK_ERROR_MESSAGE = '网络异常,请检查网络后重试'
interface RequestConfig {
/** 拼进 query string。值是 ""、null、undefined 的键直接丢掉,调用方不用自己挑 */
@@ -87,7 +83,7 @@ interface RequestConfig {
function withQuery(url: string, params?: Record<string, unknown>) {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(params ?? {})) {
if (value !== "" && value !== null && value !== undefined) query.append(key, String(value))
if (value !== '' && value !== null && value !== undefined) query.append(key, String(value))
}
const search = query.toString()
return search ? `${url}?${search}` : url
@@ -99,7 +95,7 @@ async function request<T>(method: string, url: string, body: unknown, config: Re
if (body instanceof FormData) {
init.body = body
} else if (body !== undefined) {
init.headers = { "Content-Type": "application/json" }
init.headers = { 'Content-Type': 'application/json' }
init.body = JSON.stringify(body)
}
@@ -108,13 +104,13 @@ async function request<T>(method: string, url: string, body: unknown, config: Re
response = await authFetch(withQuery(`/api/${url}`, config.params), init)
} catch {
// 请求根本没到后端(断网、反代挂了),学生看到的就是这一句
throw new ApiError("network-error", NETWORK_ERROR_MESSAGE)
throw new ApiError('network-error', NETWORK_ERROR_MESSAGE)
}
const payload = (await response.json().catch(() => null)) as ({ data?: T } & ErrorPayload) | null
if (response.ok) return payload?.data as T
// 没有 JSON 信封 = 不是后端回的(网关的错误页之类),当网络问题
const code = payload?.error?.code ?? "network-error"
const code = payload?.error?.code ?? 'network-error'
const message = payload?.error?.message ?? NETWORK_ERROR_MESSAGE
handleGlobalApiError(code, message)
throw new ApiError(code, message)
@@ -131,13 +127,10 @@ async function request<T>(method: string, url: string, body: unknown, config: Re
* 成功没有区别。
*/
const api = {
get: <T>(url: string, config?: RequestConfig) => request<T>("GET", url, undefined, config),
post: <T>(url: string, data?: unknown, config?: RequestConfig) =>
request<T>("POST", url, data, config),
put: <T>(url: string, data?: unknown, config?: RequestConfig) =>
request<T>("PUT", url, data, config),
delete: <T>(url: string, config?: RequestConfig) =>
request<T>("DELETE", url, config?.data, config),
get: <T>(url: string, config?: RequestConfig) => request<T>('GET', url, undefined, config),
post: <T>(url: string, data?: unknown, config?: RequestConfig) => request<T>('POST', url, data, config),
put: <T>(url: string, data?: unknown, config?: RequestConfig) => request<T>('PUT', url, data, config),
delete: <T>(url: string, config?: RequestConfig) => request<T>('DELETE', url, config?.data, config),
}
export default api
+22 -26
View File
@@ -1,6 +1,6 @@
import type { AuthTokens } from "@oj2/contract"
import { STORAGE_KEY } from "./constants"
import storage from "./storage"
import type { AuthTokens } from '@oj2/contract'
import { STORAGE_KEY } from './constants'
import storage from './storage'
/**
* 登录态的两张 token,存 localStorage。
@@ -36,23 +36,19 @@ export function clearTokens() {
export function getRefreshToken(): string | null {
const value = storage.get(STORAGE_KEY.REFRESH_TOKEN)
return typeof value === "string" ? value : null
return typeof value === 'string' ? value : null
}
function readAccess(): StoredAccess | null {
const value = storage.get(STORAGE_KEY.ACCESS_TOKEN) as StoredAccess | null
return value && typeof value.token === "string" && typeof value.expiresAt === "number"
? value
: null
return value && typeof value.token === 'string' && typeof value.expiresAt === 'number' ? value : null
}
/**
* 换 token 的结果。`network-error` 时**不清** token:断网、后端重启都是暂时的,
* 清掉等于让学生重新登录;只有后端明确说 refresh token 不认了才清。
*/
export type RefreshOutcome =
| { token: string; code?: undefined }
| { token: null; code: "refresh-invalid" | "account-disabled" | "network-error" }
export type RefreshOutcome = { token: string; code?: undefined } | { token: null; code: 'refresh-invalid' | 'account-disabled' | 'network-error' }
let inflight: Promise<RefreshOutcome> | null = null
@@ -73,7 +69,7 @@ export function refreshAccessToken(stale?: string) {
*/
function withRefreshLock<T>(task: () => Promise<T>): Promise<T> {
const locks = (navigator as Navigator & { locks?: LockManager }).locks
return locks ? (locks.request("oj2-token-refresh", task) as Promise<T>) : task()
return locks ? (locks.request('oj2-token-refresh', task) as Promise<T>) : task()
}
async function runRefresh(stale?: string): Promise<RefreshOutcome> {
@@ -82,18 +78,18 @@ async function runRefresh(stale?: string): Promise<RefreshOutcome> {
return { token: access.token }
}
const refreshToken = getRefreshToken()
if (!refreshToken) return { token: null, code: "refresh-invalid" }
if (!refreshToken) return { token: null, code: 'refresh-invalid' }
let response: Response
try {
// 裸 fetch:走 authFetch / utils/api 会再来换 token,绕回这里
response = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
} catch {
return { token: null, code: "network-error" }
return { token: null, code: 'network-error' }
}
const body = (await response.json().catch(() => null)) as {
data?: AuthTokens
@@ -104,12 +100,12 @@ async function runRefresh(stale?: string): Promise<RefreshOutcome> {
return { token: body.data.accessToken }
}
const code = body?.error?.code
if (code === "refresh-invalid" || code === "account-disabled") {
if (code === 'refresh-invalid' || code === 'account-disabled') {
clearTokens()
return { token: null, code }
}
// 5xx、网关的错误页:当成暂时的,同上
return { token: null, code: "network-error" }
return { token: null, code: 'network-error' }
}
/**
@@ -122,7 +118,7 @@ export async function getAccessToken() {
if (!access && !getRefreshToken()) return null
const outcome = await refreshAccessToken()
if (outcome.token) return outcome.token
return outcome.code === "network-error" ? (access?.token ?? null) : null
return outcome.code === 'network-error' ? (access?.token ?? null) : null
}
/** 后端回的错误码。读的是副本,原响应体留给调用方 */
@@ -153,17 +149,17 @@ export async function authFetch(input: string, init: RequestInit = {}) {
const token = await getAccessToken()
const response = await send(token)
if (response.status !== 401 || !token) return response
if ((await responseCode(response)) !== "token-expired") return response
if ((await responseCode(response)) !== 'token-expired') return response
const outcome = await refreshAccessToken(token)
if (outcome.code === "network-error") throw new TypeError("换 token 时网络异常")
if (outcome.code === 'network-error') throw new TypeError('换 token 时网络异常')
// 账号被禁用:换 token 那一步就知道了,原请求不必再发。造一个和后端同形的响应,
// 调用方按原样处理(弹「账号已被禁用」而不是登录框)
if (outcome.code === "account-disabled") {
return new Response(
JSON.stringify({ error: { code: "account-disabled", message: "账号已被禁用,请联系老师" } }),
{ status: 403, headers: { "Content-Type": "application/json" } },
)
if (outcome.code === 'account-disabled') {
return new Response(JSON.stringify({ error: { code: 'account-disabled', message: '账号已被禁用,请联系老师' } }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
}
// 换不到就拿掉 token 重发:要登录的接口回 login-required 弹登录框,可选登录的当游客
return send(outcome.token)
+4 -4
View File
@@ -1,11 +1,11 @@
import { NO_CLASS } from "@oj2/contract"
import { NO_CLASS } from '@oj2/contract'
/**
* 班级下拉的标签:`253` → `25计算机3班`,和统计面板、班级 PK 同一个写法。
* null(接口里没填班级的号)和 NO_CLASS(筛选里的「没填班级」)都写成「没填班级」
*/
export function classLabel(className: string | null) {
if (className === null || className === NO_CLASS) return "没填班级"
if (className === null || className === NO_CLASS) return '没填班级'
if (!/^\d{3,}$/.test(className)) return className
return `${className.slice(0, 2)}计算机${className.slice(2)}班`
}
@@ -37,10 +37,10 @@ export function groupClassOptions<T extends ClassOption = ClassOption>(
const grouped = [...groups]
.sort((a, b) => (a[0] === first ? -1 : b[0] === first ? 1 : b[0].localeCompare(a[0])))
.map(([grade, list]) => ({
type: "group" as const,
type: 'group' as const,
label: `${grade} 级`,
key: `grade-${grade}`,
children: list.sort((a, b) => a.localeCompare(b, "zh", { numeric: true })).map(toOption),
children: list.sort((a, b) => a.localeCompare(b, 'zh', { numeric: true })).map(toOption),
}))
return [...grouped, ...rest.map(toOption)]
}
+155 -159
View File
@@ -1,5 +1,5 @@
import type { JudgeStatusValue } from "@oj2/contract"
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
import type { JudgeStatusValue } from '@oj2/contract'
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from './types'
// 与后端 judge/status.ts 的 JudgeStatus 逐条对齐(submitting 除外,见下)。
// 注意:原来 time_limit_exceeded 写成 `1 | 2`,TS 按位或算成 3,和
@@ -32,192 +32,190 @@ type SyncedWithContract =
export const _submissionStatusSynced: SyncedWithContract = true
export enum ContestStatus {
initial = "2", // 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
not_started = "1",
underway = "0",
finished = "-1",
initial = '2', // 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
not_started = '1',
underway = '0',
finished = '-1',
}
export enum ContestType {
public = "Public",
private = "Password Protected",
public = 'Public',
private = 'Password Protected',
}
export const JUDGE_STATUS: {
[key in SUBMISSION_RESULT]: {
name: string
title: string
type: "error" | "success" | "warning" | "info"
type: 'error' | 'success' | 'warning' | 'info'
}
} = {
"-2": {
name: "编译失败",
title: "编译失败",
type: "warning",
'-2': {
name: '编译失败',
title: '编译失败',
type: 'warning',
},
"-1": {
name: "答案错误",
title: "答案错误",
type: "error",
'-1': {
name: '答案错误',
title: '答案错误',
type: 'error',
},
"0": {
name: "答案正确",
title: "答案正确",
type: "success",
'0': {
name: '答案正确',
title: '答案正确',
type: 'success',
},
"1": {
name: "运行超时",
title: "运行超时",
type: "error",
'1': {
name: '运行超时',
title: '运行超时',
type: 'error',
},
"2": {
name: "运行超时",
title: "运行超时",
type: "error",
'2': {
name: '运行超时',
title: '运行超时',
type: 'error',
},
"3": {
name: "内存超限",
title: "内存超限",
type: "error",
'3': {
name: '内存超限',
title: '内存超限',
type: 'error',
},
"4": {
name: "运行时错误",
title: "运行时错误",
type: "warning",
'4': {
name: '运行时错误',
title: '运行时错误',
type: 'warning',
},
"5": {
name: "系统错误",
title: "系统错误",
type: "error",
'5': {
name: '系统错误',
title: '系统错误',
type: 'error',
},
"6": {
name: "等待评分",
title: "等待评分",
type: "warning",
'6': {
name: '等待评分',
title: '等待评分',
type: 'warning',
},
"7": {
name: "正在评分",
title: "正在评分",
type: "warning",
'7': {
name: '正在评分',
title: '正在评分',
type: 'warning',
},
"8": {
name: "部分正确",
title: "部分正确",
type: "warning",
'8': {
name: '部分正确',
title: '部分正确',
type: 'warning',
},
"9": {
name: "正在提交",
title: "正在提交",
type: "info",
'9': {
name: '正在提交',
title: '正在提交',
type: 'info',
},
"10": {
name: "语法未通过",
title: "答案正确,但语法未通过",
type: "success",
'10': {
name: '语法未通过',
title: '答案正确,但语法未通过',
type: 'success',
},
}
export const CONTEST_STATUS: {
[key in ContestStatus]: {
name: string
type: "default" | "success" | "warning"
type: 'default' | 'success' | 'warning'
}
} = {
// 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
"2": {
name: "未开始",
type: "warning",
'2': {
name: '未开始',
type: 'warning',
},
"1": {
name: "未开始",
type: "warning",
'1': {
name: '未开始',
type: 'warning',
},
"0": {
name: "进行中",
type: "success",
'0': {
name: '进行中',
type: 'success',
},
// 灰的不是红的:列表里九成是已结束的比赛,一片红像满屏报错
"-1": {
name: "已结束",
type: "default",
'-1': {
name: '已结束',
type: 'default',
},
}
// 角色与题目权限的字符串在 `@oj2/contract` 的 roles.ts 定义,这里只是转出去,
// 调用方照旧写 `USER_TYPE.SUPER_ADMIN`。原先这里是这批字符串的第三份手抄副本。
export { USER_TYPE, PROBLEM_PERMISSION } from "@oj2/contract"
export { USER_TYPE, PROBLEM_PERMISSION } from '@oj2/contract'
export const STORAGE_KEY = {
AUTHED: "authed",
ACCESS_TOKEN: "accessToken",
REFRESH_TOKEN: "refreshToken",
LANGUAGE: "problemLanguage",
ADMIN_PROBLEM: "adminProblem",
ADMIN_SQL_PROBLEM: "adminSqlProblem",
ADMIN_CODE_PROBLEM: "adminCodeProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags",
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
DEMO_MODE: "demoMode",
LOGIN_CLASS: "loginClass",
LOGIN_MODE: "loginMode",
RANK_CLASS: "rankClass",
AUTHED: 'authed',
ACCESS_TOKEN: 'accessToken',
REFRESH_TOKEN: 'refreshToken',
LANGUAGE: 'problemLanguage',
ADMIN_PROBLEM: 'adminProblem',
ADMIN_SQL_PROBLEM: 'adminSqlProblem',
ADMIN_CODE_PROBLEM: 'adminCodeProblem',
ADMIN_PROBLEM_TAGS: 'adminProblemTags',
ADMIN_SIDER_COLLAPSED: 'adminSiderCollapsed',
DEMO_MODE: 'demoMode',
LOGIN_CLASS: 'loginClass',
LOGIN_MODE: 'loginMode',
RANK_CLASS: 'rankClass',
}
export const DIFFICULTY = {
Low: "简单",
Mid: "中等",
High: "困难",
Low: '简单',
Mid: '中等',
High: '困难',
} as const
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}'
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,
'C++': cppSource,
Java: javaSource,
Python: pythonSource,
JavaScript: "",
Golang: "",
Flowchart: "",
SQL: "",
JavaScript: '',
Golang: '',
Flowchart: '',
SQL: '',
} as const
export const LANGUAGE_FORMAT_VALUE = {
C: "c",
"C++": "cpp",
Java: "java",
Python: "python",
JavaScript: "javascript",
Golang: "go",
Flowchart: "flowchart",
SQL: "sql",
C: 'c',
'C++': 'cpp',
Java: 'java',
Python: 'python',
JavaScript: 'javascript',
Golang: 'go',
Flowchart: 'flowchart',
SQL: 'sql',
} as const
export const LANGUAGE_SHOW_VALUE = {
Flowchart: "流程图",
C: "C语言",
"C++": "C++",
Java: "Java",
Python: "Python",
JavaScript: "JS",
Golang: "Go",
SQL: "SQL",
Flowchart: '流程图',
C: 'C语言',
'C++': 'C++',
Java: 'Java',
Python: 'Python',
JavaScript: 'JS',
Golang: 'Go',
SQL: 'SQL',
} as const
export const ICON_SET = {
Flowchart: "vscode-icons:file-type-drawio",
Python: "devicon:python",
C: "devicon:c",
"C++": "devicon:cplusplus",
Java: "devicon:java",
JavaScript: "devicon:javascript",
Golang: "devicon:go",
SQL: "devicon:sqlite",
Flowchart: 'vscode-icons:file-type-drawio',
Python: '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
@@ -248,7 +246,7 @@ const blankTemplate = `//PREPEND BEGIN
export const CODE_TEMPLATES = {
C: cTemplate,
"C++": cppTemplate,
'C++': cppTemplate,
Python: blankTemplate,
Java: blankTemplate,
JavaScript: blankTemplate,
@@ -259,36 +257,36 @@ export const CODE_TEMPLATES = {
// 成就稀有度
export const RARITY_LABEL: Record<AchievementRarity, string> = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
bronze: '青铜',
silver: '白银',
gold: '黄金',
platinum: '白金',
}
// 边框、色块用的原色:饱和度够高,明暗底上都醒目。
// 青铜往红偏(色相 23),黄金往纯黄推(色相 47),中间隔开两档亮度,
// 不然两个都落在橙棕区,扫一眼分不出来
export const RARITY_COLOR: Record<AchievementRarity, string> = {
bronze: "#c2703d",
silver: "#9fa6b2",
gold: "#f2c012",
platinum: "#7dd3fc",
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>> = {
export const RARITY_TEXT_COLOR: Record<'dark' | 'light', Record<AchievementRarity, string>> = {
dark: {
bronze: "#e08d63",
silver: "#b6bcc7",
gold: "#f2c012",
platinum: "#7dd3fc",
bronze: '#e08d63',
silver: '#b6bcc7',
gold: '#f2c012',
platinum: '#7dd3fc',
},
light: {
bronze: "#a13d1e",
silver: "#6b7280",
gold: "#8f6f00",
platinum: "#0369a1",
bronze: '#a13d1e',
silver: '#6b7280',
gold: '#8f6f00',
platinum: '#0369a1',
},
}
@@ -300,7 +298,7 @@ export const RARITY_TEXT_COLOR: Record<"dark" | "light", Record<AchievementRarit
* 它按「键长度 + 字节序」重排,读出来会变成 完整性 / 清晰度 / 规范性 / 逻辑正确性,
* 40 分的那项排到最后。凡是要展示评分明细的地方都按这个顺序排,别直接遍历对象。
*/
export const FLOWCHART_CRITERIA_ORDER = ["逻辑正确性", "完整性", "规范性", "清晰度"]
export const FLOWCHART_CRITERIA_ORDER = ['逻辑正确性', '完整性', '规范性', '清晰度']
/** 按 FLOWCHART_CRITERIA_ORDER 排序,表里没有的键排在后面并保持原有相对顺序 */
export function sortFlowchartCriteria<T>(details: Record<string, T>): [string, T][] {
@@ -322,9 +320,7 @@ export const CLASS_NAME_MAX_DIGITS = 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}}`,
)
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)
@@ -336,18 +332,18 @@ export const REACTIONS: {
icon: string
}[] = [
{
key: "too_easy",
label: "太简单",
icon: "fluent-emoji:smiling-face-with-sunglasses",
key: 'too_easy',
label: '太简单',
icon: 'fluent-emoji:smiling-face-with-sunglasses',
},
{ key: "too_hard", label: "太难了", icon: "fluent-emoji:exploding-head" },
{ key: 'too_hard', label: '太难了', icon: 'fluent-emoji:exploding-head' },
{
key: "confusing",
label: "没看懂",
icon: "fluent-emoji:face-with-spiral-eyes",
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" },
{ 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' },
]
+7 -18
View File
@@ -1,4 +1,4 @@
import type { z } from "zod"
import type { z } from 'zod'
/**
* 契约的运行时闸门。**只挂在三条路径上**:题目详情、提交详情、用户资料
@@ -21,23 +21,16 @@ import type { z } from "zod"
* 面向学生的生产站点,少一个字段页面照样能用,整页崩掉不行。所以这里:
* 记一条带端点和字段路径的 `console.error`,然后**返回原始数据**。
*/
export function contract<T extends z.ZodType>(
endpoint: string,
schema: T,
value: unknown,
): z.infer<T> {
export function contract<T extends z.ZodType>(endpoint: string, schema: T, value: unknown): z.infer<T> {
const result = schema.safeParse(value)
if (result.success) return result.data
const issues = result.error.issues
.slice(0, 5)
.map((issue) => `${issue.path.join(".") || "(根)"}: ${issue.message}`)
.join(";")
const more = result.error.issues.length > 5 ? `;另有 ${result.error.issues.length - 5} 处` : ""
console.error(
`[契约] ${endpoint} 的响应不符合契约 —— ${issues}${more}\n` +
" 已放行原始数据(页面照常渲染)。契约在 packages/contract/src/。",
)
.map((issue) => `${issue.path.join('.') || '(根)'}: ${issue.message}`)
.join(';')
const more = result.error.issues.length > 5 ? `;另有 ${result.error.issues.length - 5} 处` : ''
console.error(`[契约] ${endpoint} 的响应不符合契约 —— ${issues}${more}\n` + ' 已放行原始数据(页面照常渲染)。契约在 packages/contract/src/。')
// 放行原始数据。断言在这里是**有意的**:形状确实可能不符,但调用方需要的是
// 「能渲染的东西」而不是一个异常,分歧已经通过上面那条日志暴露出来了。
return value as z.infer<T>
@@ -51,11 +44,7 @@ export function contract<T extends z.ZodType>(
* default / transform 时,放行原文和放行解析结果只差 zod 剥掉的多余字段,前端用不上。
* 给 schema 加 default / transform 之前,得先把调用方从这里挪回 `contract`。
*/
export function contractDeferred<T extends z.ZodType>(
endpoint: string,
loadSchema: () => Promise<T>,
value: unknown,
): z.infer<T> {
export function contractDeferred<T extends z.ZodType>(endpoint: string, loadSchema: () => Promise<T>, value: unknown): z.infer<T> {
loadSchema()
.then((schema) => contract(endpoint, schema, value))
.catch(() => {
+7 -7
View File
@@ -1,8 +1,8 @@
import { createDiscreteApi } from "naive-ui"
import { authFetch } from "./auth"
import { createDiscreteApi } from 'naive-ui'
import { authFetch } from './auth'
// 脱离 n-message-provider 也能弹,理由同 utils/api.ts
const { message: toast } = createDiscreteApi(["message"])
const { message: toast } = createDiscreteApi(['message'])
/** 失败时读后端的错误信封;不是 JSON(网关的 HTML 错误页之类)就用兜底文案 */
async function failureMessage(response: Response) {
@@ -10,7 +10,7 @@ async function failureMessage(response: Response) {
error?: { message?: string }
} | null
const message = payload?.error?.message
return typeof message === "string" && message ? message : "下载失败"
return typeof message === 'string' && message ? message : '下载失败'
}
// 指向 /api/admin。响应是 zip 二进制,不走 { error, data } 信封,所以不经 utils/api
@@ -19,7 +19,7 @@ async function download(url: string) {
try {
response = await authFetch(`/api/admin/${url}`)
} catch {
toast.error("下载失败")
toast.error('下载失败')
return
}
if (!response.ok) {
@@ -27,9 +27,9 @@ async function download(url: string) {
toast.error(await failureMessage(response))
return
}
const link = document.createElement("a")
const link = document.createElement('a')
link.href = window.URL.createObjectURL(await response.blob())
link.download = (response.headers.get("content-disposition") || "").split("filename=")[1] ?? ""
link.download = (response.headers.get('content-disposition') || '').split('filename=')[1] ?? ''
document.body.appendChild(link)
link.click()
link.remove()
+47 -49
View File
@@ -1,6 +1,6 @@
import { classifyDiff, type DiffKind } from "utils/wrongAnswer"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import type { ClassBoardCell } from "utils/types"
import { classifyDiff, type DiffKind } from 'utils/wrongAnswer'
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
import type { ClassBoardCell } from 'utils/types'
/**
* 课堂看板:一道题错着的人「按错法归堆」(设计稿「课堂看板重设计」A+B)。老师讲错误时
@@ -9,7 +9,7 @@ import type { ClassBoardCell } from "utils/types"
*
* 依据是提交里存的 runtime_error / sample_check(判题机写的),和提交详情、题目页结果区同源。
*/
export type BoardLatest = NonNullable<ClassBoardCell["latest"]>
export type BoardLatest = NonNullable<ClassBoardCell['latest']>
export interface ErrorKind {
/** 归堆用:同一个 key 是同一种错 */
@@ -20,73 +20,71 @@ export interface ErrorKind {
}
const DIFF_LABEL: Record<DiffKind, string> = {
"no-output": "什么都没输出",
whitespace: "只差空格、换行",
punctuation: "中英文标点不对",
case: "大小写不对",
extra: "多输出了东西",
missing: "少输出了东西",
"number-format": "小数位数不对",
values: "算出来的数不对",
other: "输出和例子不一样",
'no-output': '什么都没输出',
whitespace: '只差空格、换行',
punctuation: '中英文标点不对',
case: '大小写不对',
extra: '多输出了东西',
missing: '少输出了东西',
'number-format': '小数位数不对',
values: '算出来的数不对',
other: '输出和例子不一样',
}
/** runtime-diagnosis.ts 细分出来的 kind 里常见的几种;没列的按异常类型归 */
const KIND_LABEL: Record<string, string> = {
"int-parse": "int() 转换失败",
"float-parse": "float() 转换失败",
unpack: "拆出来的个数对不上",
"unpack-number": "一个数拆给好几个变量",
"str-concat": "文字和数字用 + 连",
operand: "文字和数字一起算",
compare: "文字和数字比大小",
"not-int": "该给整数的地方不是整数",
"format-spec": "保留小数的格式写错",
"not-callable": "把不是函数的当函数用",
"func-value": "函数名后面忘了括号",
"none-value": "拿 None 接着用",
"map-value": "map() 的结果没转成列表",
'int-parse': 'int() 转换失败',
'float-parse': 'float() 转换失败',
unpack: '拆出来的个数对不上',
'unpack-number': '一个数拆给好几个变量',
'str-concat': '文字和数字用 + 连',
operand: '文字和数字一起算',
compare: '文字和数字比大小',
'not-int': '该给整数的地方不是整数',
'format-spec': '保留小数的格式写错',
'not-callable': '把不是函数的当函数用',
'func-value': '函数名后面忘了括号',
'none-value': '拿 None 接着用',
'map-value': 'map() 的结果没转成列表',
}
const TYPE_LABEL: Record<string, string> = {
EOFError: "input() 用多了",
IndexError: "下标越界",
ZeroDivisionError: "除以 0",
KeyError: "字典里没有这个键",
RecursionError: "递归停不下来",
UnboundLocalError: "变量还没赋值就用",
ModuleNotFoundError: "导入了没有的模块",
ImportError: "导入了没有的模块",
ValueError: "值不符合要求",
TypeError: "类型不对",
EOFError: 'input() 用多了',
IndexError: '下标越界',
ZeroDivisionError: '除以 0',
KeyError: '字典里没有这个键',
RecursionError: '递归停不下来',
UnboundLocalError: '变量还没赋值就用',
ModuleNotFoundError: '导入了没有的模块',
ImportError: '导入了没有的模块',
ValueError: '值不符合要求',
TypeError: '类型不对',
}
/** C / C++ 只有信号:11 段错误、8 除零 */
const SIGNAL_LABEL: Record<number, string> = {
11: "数组越界或指针出错",
8: "除以 0",
11: '数组越界或指针出错',
8: '除以 0',
}
export function errorKind(latest: BoardLatest): ErrorKind {
const result = latest.result
if (result === SubmissionStatus.wrong_answer) {
const check = latest.sampleCheck
if (!check) return { key: "wa", label: "答案错误", line: null }
if (check.passed) return { key: "wa-hidden", label: "例子都对,错在没公开的测试点", line: null }
const diff = classifyDiff(check.expected ?? "", check.output ?? "")
if (!check) return { key: 'wa', label: '答案错误', line: null }
if (check.passed) return { key: 'wa-hidden', label: '例子都对,错在没公开的测试点', line: null }
const diff = classifyDiff(check.expected ?? '', check.output ?? '')
return { key: `wa-${diff}`, label: DIFF_LABEL[diff], line: null }
}
if (result === SubmissionStatus.runtime_error) {
const rt = latest.runtimeError
const line = rt?.line ?? null
if (!rt) return { key: "re", label: "运行时出错", line }
if (!rt) return { key: 're', label: '运行时出错', line }
if (rt.signal && SIGNAL_LABEL[rt.signal]) {
return { key: `re-signal-${rt.signal}`, label: SIGNAL_LABEL[rt.signal]!, line }
}
if (rt.type === "NameError") {
return rt.name
? { key: `re-name-${rt.name}`, label: `「${rt.name}」没有定义`, line }
: { key: "re-name", label: "用了没定义的名字", line }
if (rt.type === 'NameError') {
return rt.name ? { key: `re-name-${rt.name}`, label: `「${rt.name}」没有定义`, line } : { key: 're-name', label: '用了没定义的名字', line }
}
if (rt.kind && KIND_LABEL[rt.kind]) {
return { key: `re-${rt.kind}`, label: KIND_LABEL[rt.kind]!, line }
@@ -94,14 +92,14 @@ export function errorKind(latest: BoardLatest): ErrorKind {
if (rt.type && TYPE_LABEL[rt.type]) {
return { key: `re-${rt.type}`, label: TYPE_LABEL[rt.type]!, line }
}
return { key: "re", label: "运行时出错", line }
return { key: 're', label: '运行时出错', line }
}
if (result === SubmissionStatus.compile_error) {
return { key: "ce", label: "编译没通过", line: null }
return { key: 'ce', label: '编译没通过', line: null }
}
return {
key: `r${result}`,
label: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? "没通过",
label: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? '没通过',
line: null,
}
}
+12 -14
View File
@@ -1,4 +1,4 @@
import type { FlowchartCaseReport, FlowchartCaseResult } from "@oj2/contract"
import type { FlowchartCaseReport, FlowchartCaseResult } from '@oj2/contract'
/**
* 读出一次流程图评分里「走例子」的报告(2026-10 起的评法);老提交是四项分,给 null。
@@ -11,14 +11,14 @@ export function caseReport(details: Record<string, unknown> | null | undefined)
const cases = details.cases.map((raw): FlowchartCaseResult => {
const row = (raw ?? {}) as Partial<FlowchartCaseResult>
return {
name: String(row.name ?? ""),
input: String(row.input ?? ""),
expected: String(row.expected ?? ""),
output: String(row.output ?? ""),
name: String(row.name ?? ''),
input: String(row.input ?? ''),
expected: String(row.expected ?? ''),
output: String(row.output ?? ''),
ok: row.ok === true,
path: Array.isArray(row.path) ? row.path.map(String) : [],
wrongAt: typeof row.wrongAt === "string" ? row.wrongAt : null,
why: String(row.why ?? ""),
wrongAt: typeof row.wrongAt === 'string' ? row.wrongAt : null,
why: String(row.why ?? ''),
}
})
return {
@@ -26,7 +26,7 @@ export function caseReport(details: Record<string, unknown> | null | undefined)
passed: cases.filter((row) => row.ok).length,
total: cases.length,
cases,
focus: typeof details.focus === "string" ? details.focus : null,
focus: typeof details.focus === 'string' ? details.focus : null,
} satisfies FlowchartCaseReport
}
@@ -36,14 +36,12 @@ export function caseReport(details: Record<string, unknown> | null | undefined)
* 会让人泄气
*/
export function flowchartVerdict(report: FlowchartCaseReport, grade: string | null) {
if (report.passed === report.total) return grade === "S" ? "画对了,画得很清楚" : "画对了"
if (report.passed * 2 >= report.total) return "快画对了"
return report.passed > 0 ? "对了一部分" : "还没画对"
if (report.passed === report.total) return grade === 'S' ? '画对了,画得很清楚' : '画对了'
if (report.passed * 2 >= report.total) return '快画对了'
return report.passed > 0 ? '对了一部分' : '还没画对'
}
/** 「6 组走对 3 组」「6 组全部走对」 */
export function caseHeadline(report: FlowchartCaseReport) {
return report.passed === report.total
? `${report.total} 组全部走对`
: `${report.total} 组走对 ${report.passed} 组`
return report.passed === report.total ? `${report.total} 组全部走对` : `${report.total} 组走对 ${report.passed} 组`
}
+72 -87
View File
@@ -1,14 +1,14 @@
import { TIME_ZONE_OFFSET_MINUTES, toAdminType } from "@oj2/contract"
import type { JudgeCaseResult, JudgeInfo } from "@oj2/contract"
import { AdminUser, Submission } from "./types"
import { JUDGE_STATUS, USER_TYPE } from "./constants"
import { strFromU8, strToU8, unzlibSync, zipSync, zlibSync, type Zippable } from "fflate"
import copyTextFallback from "copy-text-to-clipboard"
import { normalizeDate } from "@vueuse/core"
import { TIME_ZONE_OFFSET_MINUTES, toAdminType } from '@oj2/contract'
import type { JudgeCaseResult, JudgeInfo } from '@oj2/contract'
import { AdminUser, Submission } from './types'
import { JUDGE_STATUS, USER_TYPE } from './constants'
import { strFromU8, strToU8, unzlibSync, zipSync, zlibSync, type Zippable } from 'fflate'
import copyTextFallback from 'copy-text-to-clipboard'
import { normalizeDate } from '@vueuse/core'
function calculateACRate(acCount: number, totalCount: number): string {
if (totalCount === 0) return "0.00"
if (acCount >= totalCount) return "100.00"
if (totalCount === 0) return '0.00'
if (acCount >= totalCount) return '100.00'
return ((acCount / totalCount) * 100).toFixed(2)
}
@@ -24,7 +24,7 @@ function calculateACRate(acCount: number, totalCount: number): string {
* 直接信判题机(`JudgeCaseResult`)。
*/
export function submissionCaseResults(info: unknown): JudgeCaseResult[] {
if (!info || typeof info !== "object" || !("data" in info)) return []
if (!info || typeof info !== 'object' || !('data' in info)) return []
const data = (info as JudgeInfo).data
return Array.isArray(data) ? data : []
}
@@ -35,15 +35,15 @@ export function submissionCaseResults(info: unknown): JudgeCaseResult[] {
* 一个都没过不给:「通过 0/8」只是把「答案错误」换个更刺眼的说法再说一遍。
* 全过也不给:AC 用不着;AST_CHECK_FAILED 是测试点全过、语法规则没过,那边有自己的规则清单。
*/
export function submissionPartialCases(submission: Pick<Submission, "caseSummary"> | undefined) {
export function submissionPartialCases(submission: Pick<Submission, 'caseSummary'> | undefined) {
const summary = submission?.caseSummary
if (!summary || summary.passed === 0 || summary.passed >= summary.total) return null
return summary
}
/** 结果标题:部分测试点通过时缀上「通过 x/y 个测试点」 */
export function submissionResultTitle(submission: Pick<Submission, "result" | "caseSummary">) {
const title = JUDGE_STATUS[submission.result]["title"]
export function submissionResultTitle(submission: Pick<Submission, 'result' | 'caseSummary'>) {
const title = JUDGE_STATUS[submission.result]['title']
const partial = submissionPartialCases(submission)
return partial ? `${title} · 通过 ${partial.passed}/${partial.total} 个测试点` : title
}
@@ -58,29 +58,29 @@ export function getACRateNumber(acCount: number, totalCount: number): number {
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) {
if (value != null && value !== '' && value !== undefined) {
query[key as keyof T] = value
}
return query
}, {} as Partial<T>)
}
export function getTagColor(tag: "Low" | "Mid" | "High" | "简单" | "中等" | "困难") {
export function getTagColor(tag: 'Low' | 'Mid' | 'High' | '简单' | '中等' | '困难') {
// 中等是橙色,和题目列表、相似题推荐一个颜色(原来这里是蓝,同一道题进了详情页就变色)
return <"success" | "warning" | "error">{
Low: "success",
Mid: "warning",
High: "error",
简单: "success",
中等: "warning",
困难: "error",
return <'success' | 'warning' | 'error'>{
Low: 'success',
Mid: 'warning',
High: 'error',
简单: 'success',
中等: 'warning',
困难: 'error',
}[tag]
}
// 2023-04-03T02:43:28.673156Z
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
const pad2 = (value: number) => String(value).padStart(2, "0")
const pad2 = (value: number) => String(value).padStart(2, '0')
/**
* 取一个时刻在东八区的年月日时分秒(数字)。无效日期返回 null。
@@ -116,9 +116,9 @@ export function zonedYear() {
* 长度不同的 token 靠正则的**顺序**区分:`YYYY` 必须排在 `M`/`D` 前面,
* 否则 `MM` 会被拆成两个 `M`。
*/
export function parseTime(utc: Date | string, format = "YYYY年M月D日") {
export function parseTime(utc: Date | string, format = 'YYYY年M月D日') {
const parts = zonedParts(utc)
if (!parts) return ""
if (!parts) return ''
const table: Record<string, string> = {
YYYY: String(parts.year),
MM: pad2(parts.month),
@@ -164,9 +164,7 @@ function addMonthsUTC(date: Date, months: number) {
const moved = new Date(date)
moved.setUTCDate(1)
moved.setUTCMonth(moved.getUTCMonth() + months)
const lastDay = new Date(
Date.UTC(moved.getUTCFullYear(), moved.getUTCMonth() + 1, 0),
).getUTCDate()
const lastDay = new Date(Date.UTC(moved.getUTCFullYear(), moved.getUTCMonth() + 1, 0)).getUTCDate()
moved.setUTCDate(Math.min(day, lastDay))
return moved
}
@@ -197,25 +195,22 @@ function getDurationObject(start: Date | string, end: Date | string): Duration {
}
}
function formatDurationUnits(
duration: Duration,
units: Array<{ key: keyof Duration; suffix: string }>,
): string {
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("")
.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: "秒" }] : []),
{ 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)
}
@@ -225,8 +220,8 @@ export function duration(start: Date | string, end: Date | string, showSeconds =
* 0 显示成短横线而不是「0 分钟」——「没学过」和「学了不到一分钟」不是一回事。
*/
export function readableDuration(seconds: number): string {
if (seconds <= 0) return "-"
if (seconds < 60) return "不到 1 分钟"
if (seconds <= 0) return '-'
if (seconds < 60) return '不到 1 分钟'
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `${minutes} 分钟`
const hours = Math.floor(minutes / 60)
@@ -239,10 +234,8 @@ export function readableDuration(seconds: number): string {
* `duration()` 会拆出「1月6天59分钟」这种,比赛列表里读不下去;一天以上只说几天
*/
export function contestLength(start: Date | string, end: Date | string): string {
const minutes = Math.round(
(normalizeDate(end).getTime() - normalizeDate(start).getTime()) / 60_000,
)
if (!Number.isFinite(minutes) || minutes <= 0) return ""
const minutes = Math.round((normalizeDate(end).getTime() - normalizeDate(start).getTime()) / 60_000)
if (!Number.isFinite(minutes) || minutes <= 0) return ''
if (minutes < 60) return `${minutes} 分钟`
if (minutes < 24 * 60) {
const h = Math.floor(minutes / 60)
@@ -255,38 +248,35 @@ export function contestLength(start: Date | string, end: Date | string): string
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: "天" },
{ key: 'years' as const, suffix: '年' },
{ key: 'months' as const, suffix: '月' },
{ key: 'days' as const, suffix: '天' },
]
const result = formatDurationUnits(durationObj, units)
return result || "一天以内"
return result || '一天以内'
}
export function secondsToDuration(seconds: number): string {
// 直接算,不走 getDurationObject:那个会拆出年、月,原来只把天和小时
// 折回小时,超过一个月就把月丢了(剩 40 天 1 小时显示成 217:00:00)
const total = Math.max(0, Math.floor(seconds))
const pad = (n: number) => String(n).padStart(2, "0")
return [Math.floor(total / 3600), pad(Math.floor(total / 60) % 60), pad(total % 60)].join(":")
const pad = (n: number) => String(n).padStart(2, '0')
return [Math.floor(total / 3600), pad(Math.floor(total / 60) % 60), pad(total % 60)].join(':')
}
export function submissionMemoryFormat(memory: number | string | undefined) {
if (memory === undefined) return "--"
if (memory === undefined) return '--'
// 1048576 = 1024 * 1024
let t = parseInt(memory + "") / 1048576
return String(t.toFixed(0)) + "MB"
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"
if (time === undefined) return '--'
return time + 'ms'
}
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay = 100,
): (...args: Parameters<T>) => void {
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)
@@ -294,26 +284,26 @@ export function debounce<T extends (...args: any[]) => any>(
}
}
export function getUserRole(role: AdminUser["adminType"]): {
type: "default" | "info" | "warning" | "error"
label: "普通" | "生管" | "师管" | "超管"
export function getUserRole(role: AdminUser['adminType']): {
type: 'default' | 'info' | 'warning' | 'error'
label: '普通' | '生管' | '师管' | '超管'
} {
const roleMap = {
[USER_TYPE.REGULAR_USER]: {
type: "default" as const,
label: "普通" as const,
type: 'default' as const,
label: '普通' as const,
},
[USER_TYPE.STUDENT_ADMIN]: {
type: "info" as const,
label: "生管" as const,
type: 'info' as const,
label: '生管' as const,
},
[USER_TYPE.TEACHER_ADMIN]: {
type: "warning" as const,
label: "师管" as const,
type: 'warning' as const,
label: '师管' as const,
},
[USER_TYPE.SUPER_ADMIN]: {
type: "error" as const,
label: "超管" as const,
type: 'error' as const,
label: '超管' as const,
},
}
@@ -328,23 +318,21 @@ export function unique<T>(arr: T[]): T[] {
export function base64EncodeUtf8(string?: string): string {
try {
return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? "")))
return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? '')))
} catch (error) {
console.error("编码失败:", error)
return ""
console.error('编码失败:', error)
return ''
}
}
export function base64DecodeUtf8(bytes?: string): string {
try {
if (!bytes) return ""
if (!bytes) return ''
const latin = atob(bytes)
return new TextDecoder("utf-8").decode(
Uint8Array.from({ length: latin.length }, (_, index) => latin.charCodeAt(index)),
)
return new TextDecoder('utf-8').decode(Uint8Array.from({ length: latin.length }, (_, index) => latin.charCodeAt(index)))
} catch (error) {
console.error("解码失败:", error)
return ""
console.error('解码失败:', error)
return ''
}
}
@@ -367,15 +355,12 @@ export function decompressFromBase64(base64: string): string {
* @param files 文件名和文本内容
* @param mtime 归档内的修改时间,默认当前时间
*/
export function createZipBlob(
files: { name: string; content: string }[],
mtime: Date = new Date(),
): Blob {
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" })
return new Blob([zipSync(entries, { mtime })], { type: 'application/zip' })
}
/**
@@ -391,7 +376,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
await navigator.clipboard.writeText(text)
return true
} catch (error) {
console.warn("Clipboard API 复制失败,尝试使用回退方法:", error)
console.warn('Clipboard API 复制失败,尝试使用回退方法:', error)
}
}
@@ -400,7 +385,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
const success = copyTextFallback(text)
return success
} catch (error) {
console.error("复制失败:", error)
console.error('复制失败:', error)
return false
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
import type { RunnableLanguage, TrialRunRequest, TrialRunResponse } from "@oj2/contract"
import api from "./api"
import type { LANGUAGE } from "./types"
import type { RunnableLanguage, TrialRunRequest, TrialRunResponse } from '@oj2/contract'
import api from './api'
import type { LANGUAGE } from './types'
/**
* 试运行:走本站判题机(`POST /trial-runs`,见 apps/api/src/judge/trial.ts),不算提交。
@@ -9,10 +9,10 @@ import type { LANGUAGE } from "./types"
* 原来走的是另一台 Judge0(C 是 GCC 9、参数也不同),2026-09 换掉了。
*/
export function trialRun(payload: TrialRunRequest) {
return api.post<TrialRunResponse>("trial-runs", payload)
return api.post<TrialRunResponse>('trial-runs', payload)
}
/** 判题机跑得了的语言。Flowchart、SQL 不是可执行代码 / 不走判题机,历史语言镜像里已经没有了 */
export function isRunnableLanguage(language: LANGUAGE): language is RunnableLanguage {
return language === "C" || language === "C++" || language === "Python"
return language === 'C' || language === 'C++' || language === 'Python'
}
+103 -172
View File
@@ -44,33 +44,33 @@ const IDEOGRAPHIC_SPACE = String.fromCharCode(0x3000)
/** 中文标点 → 英文。「。」不在表里:它多半是句末多打的,换成「.」照样错,只能删 */
export const PUNCTUATION_MAP: Record<string, string> = {
"(": "(",
")": ")",
",": ",",
":": ":",
";": ";",
"【": "[",
"】": "]",
"{": "{",
"}": "}",
"!": "!",
"?": "?",
"=": "=",
"+": "+",
"-": "-",
"*": "*",
"/": "/",
"<": "<",
">": ">",
"“": '"',
"”": '"',
"‘": "'",
"’": "'",
""": '"',
"#": "#",
"×": "*",
"÷": "/",
[IDEOGRAPHIC_SPACE]: " ",
'(': '(',
')': ')',
',': ',',
':': ':',
';': ';',
'【': '[',
'】': ']',
'{': '{',
'}': '}',
'!': '!',
'?': '?',
'=': '=',
'+': '+',
'-': '-',
'*': '*',
'/': '/',
'<': '<',
'>': '>',
'“': '"',
'”': '"',
'‘': "'",
'’': "'",
'"': '"',
'#': '#',
'×': '*',
'÷': '/',
[IDEOGRAPHIC_SPACE]: ' ',
}
/**
@@ -79,31 +79,31 @@ export const PUNCTUATION_MAP: Record<string, string> = {
* 看得懂,要换哪一个由卡片里那行代码的标红来指。
*/
export const PUNCTUATION_NAMES: Record<string, string> = {
"(": "左括号",
")": "右括号",
",": "逗号",
":": "冒号",
";": "分号",
"【": "左方括号",
"】": "右方括号",
"{": "左花括号",
"}": "右花括号",
"!": "感叹号",
"?": "问号",
"=": "等号",
"+": "加号",
"-": "减号",
"*": "星号",
"/": "斜杠",
"<": "小于号",
">": "大于号",
"“": "引号",
"”": "引号",
"‘": "单引号",
"’": "单引号",
""": "引号",
"#": "井号",
[IDEOGRAPHIC_SPACE]: "空格",
'(': '左括号',
')': '右括号',
',': '逗号',
':': '冒号',
';': '分号',
'【': '左方括号',
'】': '右方括号',
'{': '左花括号',
'}': '右花括号',
'!': '感叹号',
'?': '问号',
'=': '等号',
'+': '加号',
'-': '减号',
'*': '星号',
'/': '斜杠',
'<': '小于号',
'>': '大于号',
'“': '引号',
'”': '引号',
'‘': '单引号',
'’': '单引号',
'"': '引号',
'#': '井号',
[IDEOGRAPHIC_SPACE]: '空格',
}
/**
@@ -122,9 +122,9 @@ function stripStrings(source: string) {
function hasTopLevelComma(source: string) {
let depth = 0
for (const ch of stripStrings(source)) {
if ("([{".includes(ch)) depth++
else if (")]}".includes(ch)) depth--
else if (ch === "," && depth === 0) return true
if ('([{'.includes(ch)) depth++
else if (')]}'.includes(ch)) depth--
else if (ch === ',' && depth === 0) return true
}
return false
}
@@ -137,147 +137,83 @@ const RULES: Rule[] = [
/^invalid character '(.)'/u,
([, char]) => {
// 数学符号不是「中文的」,是写法不对:代码里乘除用 * 和 /
if (char === "×") return "这里用了乘号「×」,代码里的乘法要写成星号「*」。"
if (char === "÷") return "这里用了除号「÷」,代码里的除法要写成斜杠「/」。"
if (char === "√")
return "代码里没有根号「√」:开平方写成 x ** 0.5,或者 import math 之后用 math.sqrt(x)。"
if (char === "²" || char === "³")
return `代码里没有上标的「${char}」:平方写成 x ** 2,立方写成 x ** 3。`
if (char === "≤") return "代码里没有「≤」:小于等于要写成小于号加等号 <=。"
if (char === "≥") return "代码里没有「≥」:大于等于要写成大于号加等号 >=。"
if (char === "≠") return "代码里没有「≠」:不等于要写成感叹号加等号 !=。"
if (char === "、")
return "代码里不用顿号「、」:几样东西之间要用英文逗号隔开。写代码的时候先切换到英文输入法。"
if (char === '×') return '这里用了乘号「×」,代码里的乘法要写成星号「*」。'
if (char === '÷') return '这里用了除号「÷」,代码里的除法要写成斜杠「/」。'
if (char === '√') return '代码里没有根号「√」:开平方写成 x ** 0.5,或者 import math 之后用 math.sqrt(x)。'
if (char === '²' || char === '³') return `代码里没有上标的「${char}」:平方写成 x ** 2,立方写成 x ** 3。`
if (char === '≤') return '代码里没有「≤」:小于等于要写成小于号加等号 <=。'
if (char === '≥') return '代码里没有「≥」:大于等于要写成大于号加等号 >=。'
if (char === '≠') return '代码里没有「≠」:不等于要写成感叹号加等号 !=。'
if (char === '、') return '代码里不用顿号「、」:几样东西之间要用英文逗号隔开。写代码的时候先切换到英文输入法。'
const name = PUNCTUATION_NAMES[char!]
if (name) {
return `这里的${name}是中文输入法打出来的,要换成英文的${name}。写代码的时候先切换到英文输入法。`
}
if (char === "。") return "这里多了一个中文句号「。」,代码里不用句号,删掉它。"
if (char === '。') return '这里多了一个中文句号「。」,代码里不用句号,删掉它。'
return `这里有一个不能出现在代码里的字符「${char}」,多半是中文输入法打出来的,删掉后用英文输入法重新打。`
},
],
[
/^invalid non-printable character/,
() => "这里有一个看不见的特殊字符,多半是从别处复制过来的。把这一行删掉,自己重新打一遍。",
],
[/^invalid non-printable character/, () => '这里有一个看不见的特殊字符,多半是从别处复制过来的。把这一行删掉,自己重新打一遍。'],
[
/^expected ':'/,
(_, source) =>
/^else\b\s*[^\s:]/.test(source)
? "else 后面不能写条件,它的意思是「上面的条件都不满足」。还要再判断一个条件,就用 elif 条件:。"
: "这一行末尾少了英文冒号「:」。if、elif、else、for、while、def 这些行的最后都要有冒号。",
? 'else 后面不能写条件,它的意思是「上面的条件都不满足」。还要再判断一个条件,就用 elif 条件:。'
: '这一行末尾少了英文冒号「:」。if、elif、else、for、while、def 这些行的最后都要有冒号。',
],
[
/^expected an indented block after '(\w+)' statement on line (\d+)/,
([, keyword, owner]) =>
`这一行要往里缩进(按一下 Tab 键):它是第 ${owner} 行那个 ${keyword} 要管的代码。`,
],
[
/^expected an indented block/,
() => "这一行要往里缩进(按一下 Tab 键):上一行以冒号结尾,它下面的代码都要缩进。",
],
[
/^unexpected indent/,
() => "这一行前面多了空格。只有 if、for 这类以冒号结尾的行,它下面的代码才要往里缩进。",
],
[
/^unindent does not match any outer indentation level/,
() =>
"这一行前面的空格数和上面对不齐。同一层的代码,前面的空格要一样多,建议统一用 Tab 键缩进。",
],
[
/^inconsistent use of tabs and spaces/,
() => "这一行的缩进混用了 Tab 键和空格键,统一只用 Tab 键缩进。",
([, keyword, owner]) => `这一行要往里缩进(按一下 Tab 键):它是第 ${owner} 行那个 ${keyword} 要管的代码。`,
],
[/^expected an indented block/, () => '这一行要往里缩进(按一下 Tab 键):上一行以冒号结尾,它下面的代码都要缩进。'],
[/^unexpected indent/, () => '这一行前面多了空格。只有 if、for 这类以冒号结尾的行,它下面的代码才要往里缩进。'],
[/^unindent does not match any outer indentation level/, () => '这一行前面的空格数和上面对不齐。同一层的代码,前面的空格要一样多,建议统一用 Tab 键缩进。'],
[/^inconsistent use of tabs and spaces/, () => '这一行的缩进混用了 Tab 键和空格键,统一只用 Tab 键缩进。'],
[
/^invalid syntax\. Perhaps you forgot a comma\?/,
// 抽样一半以上是拼文字漏了 +:print(a+"是"a+"岁")
() =>
"这里的几样东西之间少了连接的符号:要分开输出,用英文逗号「,」隔开;要把文字拼在一起,用加号「+」。",
() => '这里的几样东西之间少了连接的符号:要分开输出,用英文逗号「,」隔开;要把文字拼在一起,用加号「+」。',
],
[
/Maybe you meant '==' instead of '='|Maybe you meant '==' or ':=' instead of '='|perhaps you meant "=="/,
(_, source) => {
// 只有条件里才是真想判断相等;别的行是想赋值,写法错了
if (CONDITION_LINE.test(source))
return "判断「是不是相等」要用两个等号「==」。一个等号「=」的意思是「把右边的值存进左边的变量」。"
if (/^\w+\s*=[^=].*,\s*\w+\s*=[^=]/.test(stripStrings(source)))
return "一行里给几个变量赋值,要写成 a, b = 96, 92,或者分成几行,一行写一个。"
if (/^\w+\s*\(/.test(source))
return '括号里不能直接写等号「=」。要把等号显示出来,就把它放进引号里,比如 print(a, "+", b, "=", a + b)。'
if (CONDITION_LINE.test(source)) return '判断「是不是相等」要用两个等号「==」。一个等号「=」的意思是「把右边的值存进左边的变量」。'
if (/^\w+\s*=[^=].*,\s*\w+\s*=[^=]/.test(stripStrings(source))) return '一行里给几个变量赋值,要写成 a, b = 96, 92,或者分成几行,一行写一个。'
if (/^\w+\s*\(/.test(source)) return '括号里不能直接写等号「=」。要把等号显示出来,就把它放进引号里,比如 print(a, "+", b, "=", a + b)。'
return null
},
],
[
/^cannot assign to /,
() =>
"等号「=」的左边只能是一个变量名,意思是「把右边算出来的值存进这个变量」。比如要写成 c = a + b,不能写成 a + b = c。",
],
[
/^unterminated (triple-quoted )?(f-)?string literal/,
() => "这一行的引号没有配对。字符串要用一对英文引号括起来,开头一个、结尾一个。",
],
[
/^'(.)' was never closed/,
([, open]) => `这里的「${open}」没有配对的另一半。数一数左右括号是不是一样多。`,
],
[/^cannot assign to /, () => '等号「=」的左边只能是一个变量名,意思是「把右边算出来的值存进这个变量」。比如要写成 c = a + b,不能写成 a + b = c。'],
[/^unterminated (triple-quoted )?(f-)?string literal/, () => '这一行的引号没有配对。字符串要用一对英文引号括起来,开头一个、结尾一个。'],
[/^'(.)' was never closed/, ([, open]) => `这里的「${open}」没有配对的另一半。数一数左右括号是不是一样多。`],
[/^unmatched '(.)'/, ([, close]) => `这里多了一个「${close}」,前面没有和它配对的括号。`],
[
/^closing parenthesis '(.)' does not match opening parenthesis '(.)'/,
([, close, open]) => `括号配错了:「${open}」要用和它配对的括号来关,不能用「${close}」。`,
],
[
/^Missing parentheses in call to '(\w+)'/,
([, name]) => `${name} 后面要加括号,写成 ${name}(...)。`,
],
[
/^invalid decimal literal/,
() => "数字后面紧跟着字母了。比如「2a」要写成「2*a」;变量名也不能用数字开头。",
],
[
/^unexpected character after line continuation character/,
() => "这里的反斜杠「\\」用错了。除法要用「/」,「\\」只能出现在引号里面。",
],
[
/^'(break|continue)' (not properly in|outside) loop/,
([, keyword]) => `${keyword} 只能写在 for 或 while 循环里面(要缩进在循环的下面)。`,
],
[
/^'return' outside function/,
() => "return 只能写在 def 定义的函数里面(要缩进在 def 的下面)。",
],
[
/^f-string: (expecting '}'|single '}' is not allowed)/,
() => "f 字符串里的花括号「{ }」没有配对,每个「{」都要有一个「}」。",
],
[
/^f-string: valid expression required/,
() => "f 字符串的花括号「{ }」里要写变量名或算式,不能空着。",
],
[
/^leading zeros in decimal integer literals/,
() => "整数不能用 0 开头,比如「07」要写成「7」。",
],
[/^Missing parentheses in call to '(\w+)'/, ([, name]) => `${name} 后面要加括号,写成 ${name}(...)。`],
[/^invalid decimal literal/, () => '数字后面紧跟着字母了。比如「2a」要写成「2*a」;变量名也不能用数字开头。'],
[/^unexpected character after line continuation character/, () => '这里的反斜杠「\\」用错了。除法要用「/」,「\\」只能出现在引号里面。'],
[/^'(break|continue)' (not properly in|outside) loop/, ([, keyword]) => `${keyword} 只能写在 for 或 while 循环里面(要缩进在循环的下面)。`],
[/^'return' outside function/, () => 'return 只能写在 def 定义的函数里面(要缩进在 def 的下面)。'],
[/^f-string: (expecting '}'|single '}' is not allowed)/, () => 'f 字符串里的花括号「{ }」没有配对,每个「{」都要有一个「}」。'],
[/^f-string: valid expression required/, () => 'f 字符串的花括号「{ }」里要写变量名或算式,不能空着。'],
[/^leading zeros in decimal integer literals/, () => '整数不能用 0 开头,比如「07」要写成「7」。'],
[
/^invalid syntax/,
(_, source) => {
if (/^elif\s*:/.test(source))
return "elif 后面要写条件,比如 elif a > 60:。不需要条件的话就用 else:。"
if (/^else\s+if\b/.test(source)) return "Python 里「否则如果」要写成 elif,不是 else if。"
if (/^(esle|eles|els|elese|esif|elsif|elseif|eilf|elfi)\b/.test(source))
return "这一行开头的关键字拼错了:「否则」是 else,「否则如果」是 elif。"
if (/^elif\s*:/.test(source)) return 'elif 后面要写条件,比如 elif a > 60:。不需要条件的话就用 else:。'
if (/^else\s+if\b/.test(source)) return 'Python 里「否则如果」要写成 elif,不是 else if。'
if (/^(esle|eles|els|elese|esif|elsif|elseif|eilf|elfi)\b/.test(source)) return '这一行开头的关键字拼错了:「否则」是 else,「否则如果」是 elif。'
const code = stripStrings(source)
if (/=<|=>/.test(code)) return "小于等于要写成 <=,大于等于要写成 >=,等号放在后面。"
if (/=<|=>/.test(code)) return '小于等于要写成 <=,大于等于要写成 >=,等号放在后面。'
if (CONDITION_LINE.test(source) && hasTopLevelComma(source))
return "条件之间不能用逗号连:两个都要满足用 and,满足一个就行用 or,比如 if a > 9 and a < 100:。"
return '条件之间不能用逗号连:两个都要满足用 and,满足一个就行用 or,比如 if a > 9 and a < 100:。'
return null
},
],
[
/^invalid syntax/,
() =>
"标出来的地方写法不对。对照教程检查这一行:括号、引号、冒号、运算符有没有写错、漏写或多写。",
],
[/^invalid syntax/, () => '标出来的地方写法不对。对照教程检查这一行:括号、引号、冒号、运算符有没有写错、漏写或多写。'],
]
function translate(message: string, source: string) {
@@ -303,16 +239,13 @@ function looksLikeC(code: string) {
* 翻不出来(判题机自己的报错、没见过的句式)就返回 null,界面照旧显示原文。
* `code` 是学生的代码,用来认出「交的其实是 C」
*/
export function explainPythonCompileError(
errInfo: string,
code = "",
): PythonErrorExplanation | null {
export function explainPythonCompileError(errInfo: string, code = ''): PythonErrorExplanation | null {
const text = errInfo.trim()
const short = text.match(SHORT_RE)
if (short) {
const [, , detail, line] = short
const message = translate(detail!, "")
const message = translate(detail!, '')
if (!message) return null
return {
line: Number(line),
@@ -324,27 +257,25 @@ export function explainPythonCompileError(
}
const where = text.match(WHERE_RE)
const lines = text.split("\n")
const lines = text.split('\n')
const last = lines[lines.length - 1]!
const detail = last.replace(/^\w+Error: /, "")
const detail = last.replace(/^\w+Error: /, '')
if (detail === last) return null
let sourceLine: string | null = null
let caret: PythonErrorExplanation["caret"] = null
let caret: PythonErrorExplanation['caret'] = null
if (where) {
const index = lines.findIndex((line) => WHERE_RE.test(line))
const code = lines[index + 1]
const marks = lines[index + 2]
if (code?.startsWith(" ")) sourceLine = code.slice(4)
if (sourceLine !== null && marks?.startsWith(" ")) {
const from = marks.indexOf("^")
if (from >= 4) caret = { from: from - 4, to: marks.lastIndexOf("^") - 4 + 1 }
if (code?.startsWith(' ')) sourceLine = code.slice(4)
if (sourceLine !== null && marks?.startsWith(' ')) {
const from = marks.indexOf('^')
if (from >= 4) caret = { from: from - 4, to: marks.lastIndexOf('^') - 4 + 1 }
}
}
const message = looksLikeC(code)
? "这是 C 语言的代码,可提交时选的语言是 Python。把语言换成 C 再交。"
: translate(detail, sourceLine?.trim() ?? "")
const message = looksLikeC(code) ? '这是 C 语言的代码,可提交时选的语言是 Python。把语言换成 C 再交。' : translate(detail, sourceLine?.trim() ?? '')
if (!message) return null
const char = detail.match(/^invalid character '(.)'/u)?.[1]
+35 -45
View File
@@ -1,5 +1,5 @@
import { handleGlobalApiError } from "./api"
import { authFetch } from "./auth"
import { handleGlobalApiError } from './api'
import { authFetch } from './auth'
export interface JSONEventStreamHandlers<T = any> {
onMessage: (data: T, event?: string) => void
@@ -7,31 +7,28 @@ export interface JSONEventStreamHandlers<T = any> {
signal?: AbortSignal | null
}
export async function consumeJSONEventStream<T = any>(
response: Response,
handlers: JSONEventStreamHandlers<T>,
) {
export async function consumeJSONEventStream<T = any>(response: Response, handlers: JSONEventStreamHandlers<T>) {
if (!response.body) {
throw new Error("当前环境不支持可读流")
throw new Error('当前环境不支持可读流')
}
const reader = response.body.getReader()
const decoder = new TextDecoder("utf-8")
let buffer = ""
const decoder = new TextDecoder('utf-8')
let buffer = ''
const { onMessage, onEvent, signal } = handlers
const handleEvent = (raw: string) => {
const lines = raw.split("\n")
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:")) {
if (trimmed.startsWith('event:')) {
eventName = trimmed.slice(6).trim()
} else if (trimmed.startsWith("data:")) {
} else if (trimmed.startsWith('data:')) {
dataLines.push(trimmed.slice(5).trim())
}
}
@@ -43,7 +40,7 @@ export async function consumeJSONEventStream<T = any>(
return
}
const payloadStr = dataLines.join("\n")
const payloadStr = dataLines.join('\n')
let parsed: T
try {
@@ -56,19 +53,19 @@ export async function consumeJSONEventStream<T = any>(
}
const processBuffer = (flush = false) => {
let idx = buffer.indexOf("\n\n")
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")
idx = buffer.indexOf('\n\n')
}
if (flush && buffer.trim()) {
handleEvent(buffer.trim())
buffer = ""
buffer = ''
}
}
@@ -107,31 +104,28 @@ async function aiStreamError(response: Response) {
const body = (await response.json().catch(() => null)) as {
error?: { code?: string; message?: string }
} | null
const code = body?.error?.code ?? "network-error"
const code = body?.error?.code ?? 'network-error'
handleGlobalApiError(code, body?.error?.message)
switch (code) {
case "login-required":
return new Error("请先登录")
case "account-disabled":
return new Error("账号已被禁用,请联系老师")
case "too-many-requests":
return new Error("AI 请求太频繁了,歇一会儿再试")
case "hint-locked":
return new Error("再多试几次,AI 提示会自动解锁")
case "contest-hint-disabled":
return new Error("比赛中不提供 AI 提示")
case "permission-denied":
return new Error("没有权限使用这个功能")
case 'login-required':
return new Error('请先登录')
case 'account-disabled':
return new Error('账号已被禁用,请联系老师')
case 'too-many-requests':
return new Error('AI 请求太频繁了,歇一会儿再试')
case 'hint-locked':
return new Error('再多试几次,AI 提示会自动解锁')
case 'contest-hint-disabled':
return new Error('比赛中不提供 AI 提示')
case 'permission-denied':
return new Error('没有权限使用这个功能')
default:
return new Error("AI 分析生成失败")
return new Error('AI 分析生成失败')
}
}
/** AI 流的事件形状,和 apps/api/src/services/ai.ts 的 streamChat / streamWhole 一致 */
type AIStreamEvent =
| { type: "delta"; content?: string }
| { type: "error"; message?: string }
| ({ type: "done" } & Record<string, unknown>)
type AIStreamEvent = { type: 'delta'; content?: string } | { type: 'error'; message?: string } | ({ type: 'done' } & Record<string, unknown>)
export interface AIStreamHandlers<Done> {
onDelta: (content: string) => void
@@ -148,14 +142,10 @@ export interface AIStreamHandlers<Done> {
* 中文,见 services/ai.ts 的 CLIENT_ERROR_MESSAGE)。被 abort 时抛 AbortError,
* 调用方一般用 `useAIStream` 就不用自己分辨。
*/
export async function streamAI<Done = Record<string, unknown>>(
path: string,
body: unknown,
handlers: AIStreamHandlers<Done>,
) {
export async function streamAI<Done = Record<string, unknown>>(path: string, body: unknown, handlers: AIStreamHandlers<Done>) {
const response = await authFetch(`/api/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: handlers.signal,
})
@@ -163,11 +153,11 @@ export async function streamAI<Done = Record<string, unknown>>(
await consumeJSONEventStream<AIStreamEvent>(response, {
signal: handlers.signal,
onMessage(event) {
if (event.type === "delta") {
if (event.type === 'delta') {
if (event.content) handlers.onDelta(event.content)
} else if (event.type === "error") {
throw new Error(event.message || "AI 服务异常")
} else if (event.type === "done") {
} else if (event.type === 'error') {
throw new Error(event.message || 'AI 服务异常')
} else if (event.type === 'done') {
handlers.onDone?.(event as Done)
}
},
+4 -9
View File
@@ -1,6 +1,6 @@
import { DIFFICULTY } from "utils/constants"
import { getACRate } from "utils/functions"
import type { ProblemRow, ProblemListItem } from "utils/types"
import { DIFFICULTY } from 'utils/constants'
import { getACRate } from 'utils/functions'
import type { ProblemRow, ProblemListItem } from 'utils/types'
// 把后端的列表项塑形成列表页需要的形状,与请求逻辑解耦。
export function toProblemRow(result: ProblemListItem): ProblemRow {
@@ -13,12 +13,7 @@ export function toProblemRow(result: ProblemListItem): ProblemRow {
submissionCount: result.submissionNumber,
acRate: getACRate(result.acceptedNumber, result.submissionNumber),
// null / undefined 都表示「没做过」
status:
result.myStatus === null || result.myStatus === undefined
? "not_test"
: result.myStatus === 0
? "passed"
: "failed",
status: result.myStatus === null || result.myStatus === undefined ? 'not_test' : result.myStatus === 0 ? 'passed' : 'failed',
author: result.createdBy.username,
allowFlowchart: result.allowFlowchart,
showFlowchart: result.showFlowchart,
+49 -112
View File
@@ -13,14 +13,14 @@ import type {
ChallengeInput,
CreateAnnouncementRequest,
ProblemLanguage,
} from "@oj2/contract"
} from '@oj2/contract'
/**
* 个人主页数据。`acmProblemsStatus` 的**内容**保持 snake_case ——
* 它是 user_profile.acm_problems_status 的 JSONB 原文,库里存量数据就是这套键名,
* 改名等于要把全部历史行迁一遍。
*/
export type Profile = Omit<UserProfile, "user" | "acmProblemsStatus"> & {
export type Profile = Omit<UserProfile, 'user' | 'acmProblemsStatus'> & {
user: SessionUser
acmProblemsStatus: AcmProblemsStatus
}
@@ -60,7 +60,7 @@ export type LANGUAGE = ProblemLanguage
* 而契约那边是 `Record<string, unknown>`,等于渲染表格的那段代码全靠手抄件兜底。
* 键名的 snake_case 是 JSONB 原文,见契约 sqlDisplaySchema 的注释。
*/
export type { SqlConfig, SqlDisplay, SqlDisplayTable, SqlDisplayColumn } from "@oj2/contract"
export type { SqlConfig, SqlDisplay, SqlDisplayTable, SqlDisplayColumn } from '@oj2/contract'
/**
* 判题状态码 + 前端本地的「正在提交」(9)。
@@ -72,30 +72,24 @@ export type { SqlConfig, SqlDisplay, SqlDisplayTable, SqlDisplayColumn } from "@
*/
export type SUBMISSION_RESULT = JudgeStatus | 9
export type ProblemStatus = "passed" | "failed" | "not_test"
export type ProblemStatus = 'passed' | 'failed' | 'not_test'
/**
* 题目标签。用契约的 —— 它比手抄那份多一个 `problemCount`,
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
*/
export type { Tag, TagCategory } from "@oj2/contract"
export type { ProblemStats } from "@oj2/contract"
export type { TodaySubmissionStatistics } from "@oj2/contract"
export type { Tag, TagCategory } from '@oj2/contract'
export type { ProblemStats } from '@oj2/contract'
export type { TodaySubmissionStatistics } from '@oj2/contract'
export type {
AdminTag,
RenameTagResponse,
BatchProblemTagResponse,
SqlTestCaseScript,
GenerateSqlTestCaseResponse,
} from "@oj2/contract"
export type { AdminTag, RenameTagResponse, BatchProblemTagResponse, SqlTestCaseScript, GenerateSqlTestCaseResponse } from '@oj2/contract'
/**
* 上传测试点的返回。取契约 —— 手抄那份少了三个字段
* (stripped_output_md5 / input_size / output_size)。这套键名保持 snake_case
* 是因为它会原样落进 problem.test_case_score 和判题沙箱读的 info 文件。
*/
export type { UploadTestCaseResponse } from "@oj2/contract"
export type { UploadTestCaseResponse } from '@oj2/contract'
/**
* 题目表单里的测试点。落库的只有 {input_name, output_name, score} 三个键 ——
@@ -103,26 +97,18 @@ export type { UploadTestCaseResponse } from "@oj2/contract"
* 被剥掉(旧后端的 serializer 也是这么干的,见契约 problemTestCaseScoreSchema)。
* score 不在上传响应里,是上传完成后按测试点数量平分补上去的。
*/
export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
export type { ProblemTestCaseScore as Testcase } from '@oj2/contract'
/**
* AST 代码要求。原来这里手抄的那份少了 label / exact / outer / inner ——
* 编辑器写得出 label 和 exact,判题机也认,只有这个类型不认。形状在契约里。
*/
export type { AstRule, AstRules, AstRuleEngine } from "@oj2/contract"
export type { AstRule, AstRules, AstRuleEngine } from '@oj2/contract'
export type {
ProblemDetail,
ProblemListItem,
AdminProblemListItem,
AdminProblemList,
} from "@oj2/contract"
export type { ProblemDetail, ProblemListItem, AdminProblemListItem, AdminProblemList } from '@oj2/contract'
/** 后台题目详情:比 oj 侧多 answers / testCase* / astRules */
export type AdminProblem = Omit<
ContractAdminProblem,
"languages" | "template" | "answers" | "astRules"
> & {
export type AdminProblem = Omit<ContractAdminProblem, 'languages' | 'template' | 'answers' | 'astRules'> & {
languages: LANGUAGE[]
template: { [key in LANGUAGE]?: string }
// 契约里 answers[].language 是 string,这里收窄成 LANGUAGE
@@ -130,21 +116,9 @@ export type AdminProblem = Omit<
astRules?: AstRules | null
}
type ExcludeKeys =
| "id"
| "createdBy"
| "createTime"
| "lastUpdateTime"
| "statisticInfo"
| "acceptedNumber"
| "submissionNumber"
| "isPublic"
| "contestId"
type ExcludeKeys = 'id' | 'createdBy' | 'createTime' | 'lastUpdateTime' | 'statisticInfo' | 'acceptedNumber' | 'submissionNumber' | 'isPublic' | 'contestId'
export type BlankProblem = Omit<
AdminProblem,
ExcludeKeys | "hint" | "mermaidCode" | "flowchartData" | "challenges"
> & {
export type BlankProblem = Omit<AdminProblem, ExcludeKeys | 'hint' | 'mermaidCode' | 'flowchartData' | 'challenges'> & {
/** 提交的挑战:新加的不带 id,后端发(见契约 challengeInputSchema) */
challenges?: Record<string, ChallengeInput[]> | null
id?: number
@@ -163,11 +137,11 @@ export interface ProblemRow {
id: number
title: string
// 比赛进行中难度不下发,见 contract 的 maskedProblemDifficultySchema
difficulty: "简单" | "中等" | "困难" | null
difficulty: '简单' | '中等' | '困难' | null
tags: string[]
submissionCount: number
acRate: string
status: "not_test" | "passed" | "failed"
status: 'not_test' | 'passed' | 'failed'
author: string
allowFlowchart: boolean
showFlowchart: boolean
@@ -207,7 +181,7 @@ export type {
ProblemSetClassStudent,
ProblemSetLock,
UserBadge,
} from "@oj2/contract"
} from '@oj2/contract'
export interface Code {
language: LANGUAGE
@@ -215,7 +189,7 @@ export interface Code {
}
/** 提交代码的请求体。取契约的形状,只把 language 收窄成前端的 LANGUAGE 联合 */
export type SubmitCodePayload = Omit<CreateSubmissionRequest, "language"> & {
export type SubmitCodePayload = Omit<CreateSubmissionRequest, 'language'> & {
language: LANGUAGE
}
@@ -228,12 +202,9 @@ export const FlowchartSubmissionStatus = {
FAILED: 3, // 评分失败
} as const
export type {
FlowchartSubmission,
FlowchartListItem as FlowchartSubmissionListItem,
} from "@oj2/contract"
export type { FlowchartSubmission, FlowchartListItem as FlowchartSubmissionListItem } from '@oj2/contract'
export type { CreateFlowchartRequest as SubmitFlowchartPayload } from "@oj2/contract"
export type { CreateFlowchartRequest as SubmitFlowchartPayload } from '@oj2/contract'
/**
* 提交详情。`statisticInfo` / `language` 的窄化在契约里
@@ -245,71 +216,62 @@ export type { CreateFlowchartRequest as SubmitFlowchartPayload } from "@oj2/cont
* 伪状态,后端永远不会下发,见 constants.ts 的 SubmissionStatus.submitting。
* 这是「前端自己造的状态」,不属于契约能描述的东西。
*/
export type Submission = Omit<SubmissionDetail, "result"> & {
export type Submission = Omit<SubmissionDetail, 'result'> & {
result: SUBMISSION_RESULT
}
export type SubmissionListItem = ContractSubmissionListItem
export interface SubmissionListPayload {
myself?: "1" | "0"
myself?: '1' | '0'
result?: string
username?: string
/** "1" = username 精确匹配(默认是「包含」)。协作中老师看某个学生的提交用 */
exactUsername?: "1"
exactUsername?: '1'
/** 班级(user.class_name,如 `253`),精确匹配;和 username 可以同时用 */
className?: string
contestId?: string
problemDisplayId?: string
language: LANGUAGE | ""
today?: "1" | "0"
language: LANGUAGE | ''
today?: '1' | '0'
page: number
limit: number
offset: number
}
export type { SessionUser } from "@oj2/contract"
export type { SessionUser } from '@oj2/contract'
export type Rank = RankProfile
/** 榜单里「我」的位置:比 Rank 多一个全服名次 */
/** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */
export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract"
export type { WeeklyRank, WeeklyRankItem } from '@oj2/contract'
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem, LastVisit } from "@oj2/contract"
export type { ClassActivity, ClassActivityProblem, LastVisit } from '@oj2/contract'
/** 课堂看板:一个班今天这节课的题 × 全班学生 */
export type {
ClassBoard,
ClassBoardProblem,
ClassBoardStudent,
ClassBoardCell,
ClassLesson,
} from "@oj2/contract"
export type { ClassBoard, ClassBoardProblem, ClassBoardStudent, ClassBoardCell, ClassLesson } from '@oj2/contract'
/** 知识点地图:每个知识点做对几道、在第几档,只给自己看 */
export type { KnowledgeLevel, KnowledgeMap } from "@oj2/contract"
export type { KnowledgeLevel, KnowledgeMap } from '@oj2/contract'
/**
* 学生侧的比赛:不含 password / visible。后台用 `AdminContest` ——
* oj 侧的 contestSchema 永远不含 password,后台要能看到(告诉学生)
*/
export type { Contest, AdminContest } from "@oj2/contract"
export type { Contest, AdminContest } from '@oj2/contract'
export type BlankContest = Omit<
AdminContest,
"id" | "createdBy" | "createTime" | "lastUpdateTime" | "status" | "contestType"
>
export type BlankContest = Omit<AdminContest, 'id' | 'createdBy' | 'createTime' | 'lastUpdateTime' | 'status' | 'contestType'>
/**
* `acm_contest_rank.submission_info` 的 JSONB 内容,形状在契约
* (`contestSubmissionInfoSchema`)。别和 `submission.info`(判题结果)混了。
*/
export type { ContestSubmissionInfo } from "@oj2/contract"
export type { ContestSubmissionInfo } from '@oj2/contract'
export type { WebsiteConfig } from "@oj2/contract"
export type { WebsiteConfig } from '@oj2/contract'
export type {
JudgeServer as Server,
@@ -341,7 +303,7 @@ export type {
LearnExerciseAttempt,
LearnExerciseAttemptList,
LearnStudentDetail,
} from "@oj2/contract"
} from '@oj2/contract'
/**
* 公告。两侧形状**不同**,原来这里只手抄了后台那份、oj 侧也拿它当类型用:
@@ -349,12 +311,7 @@ export type {
* (见 apps/api/src/routes/content.ts),于是类型声称有、实际是 undefined。
* 现在各取各的契约类型。
*/
export type {
Announcement,
AnnouncementListItem,
AdminAnnouncement,
AdminAnnouncementListItem,
} from "@oj2/contract"
export type { Announcement, AnnouncementListItem, AdminAnnouncement, AdminAnnouncementListItem } from '@oj2/contract'
/** 后台编辑表单:请求体 + id(新建时填 0,提交前由 api 层剥掉) */
export type AnnouncementEdit = CreateAnnouncementRequest & { id: number }
@@ -365,8 +322,8 @@ export type AnnouncementEdit = CreateAnnouncementRequest & { id: number }
*
* 注意 `ReactionCounts` 是 Partial 的:后端只下发有票的类型,没人投的键不出现。
*/
export type { ReactionKey, ReactionCounts, ReactionState } from "@oj2/contract"
import type { ReactionKey } from "@oj2/contract"
export type { ReactionKey, ReactionCounts, ReactionState } from '@oj2/contract'
import type { ReactionKey } from '@oj2/contract'
/**
* 教程。直接取契约 —— 手抄的那份把 `createdBy` 写成了可选的 `User`(后端下发的是
@@ -376,24 +333,18 @@ import type { ReactionKey } from "@oj2/contract"
* 列表项也用契约的:它比 `Omit<Tutorial, "content">` **还少一个 code** ——
* 后端列表接口连 code 一起省了,手抄那份声称它在。
*/
export type {
AdminTutorial as Tutorial,
AdminTutorialListItem as TutorialListItem,
} from "@oj2/contract"
export type { AdminTutorial as Tutorial, AdminTutorialListItem as TutorialListItem } from '@oj2/contract'
/** 学生自己的自学留痕,学习页的目录拿它打勾 */
export type { TutorialProgress } from "@oj2/contract"
import type { AdminExercise, AdminTutorial, CreateSubmissionRequest } from "@oj2/contract"
export type { TutorialProgress } from '@oj2/contract'
import type { AdminExercise, AdminTutorial, CreateSubmissionRequest } from '@oj2/contract'
/**
* 教程编辑表单。只留可编辑字段 —— createdBy / createdAt / updatedAt 由后端产出,
* 新建时压根不存在(对齐 BlankProblem / BlankContest 的写法)。「示例代码」字段界面上
* 去掉了(正文里的代码块能直接运行),挂的题走单独的接口。
*/
export type TutorialEdit = Omit<
AdminTutorial,
"createdBy" | "createdAt" | "updatedAt" | "code" | "problems"
>
export type TutorialEdit = Omit<AdminTutorial, 'createdBy' | 'createdAt' | 'updatedAt' | 'code' | 'problems'>
export interface ExerciseMcqData {
question: string
@@ -438,42 +389,28 @@ export interface ExerciseGroupData {
answer: number[]
}
export type { ExerciseType } from "@oj2/contract"
export type { ExerciseType } from '@oj2/contract'
/**
* 练习题。契约里 `data` 是 Record<string, unknown>(各题型结构不同,后端不校验),
* 前端按题型收窄成判别联合 —— 组件靠它区分七种题型的字段。
*/
export type Exercise = Omit<AdminExercise, "data"> & {
export type Exercise = Omit<AdminExercise, 'data'> & {
/** 学生端才有:当前学生做对过没有(后台的练习列表不下发) */
solved?: boolean
data:
| ExerciseMcqData
| ExerciseSortData
| ExerciseFillData
| ExerciseMatchData
| ExercisePredictData
| ExerciseDebugData
| ExerciseGroupData
data: ExerciseMcqData | ExerciseSortData | ExerciseFillData | ExerciseMatchData | ExercisePredictData | ExerciseDebugData | ExerciseGroupData
}
export type { ProblemDifficulty }
// ==================== 成就相关类型 ====================
import type { AchievementNotification, PendingAchievement } from "@oj2/contract"
import type { AchievementNotification, PendingAchievement } from '@oj2/contract'
export type {
Achievement,
AchievementList,
AchievementRarity,
AchievementClassBoard,
AchievementNotification,
PendingAchievement,
} from "@oj2/contract"
export type { Achievement, AchievementList, AchievementRarity, AchievementClassBoard, AchievementNotification, PendingAchievement } from '@oj2/contract'
/**
* 弹窗队列里的条目。`/achievements/pending` 拉来的没有 kind,
* WebSocket 推来的有 —— 两个来源会汇进同一个队列。
*/
export type QueuedAchievement = PendingAchievement & Partial<Pick<AchievementNotification, "kind">>
export type QueuedAchievement = PendingAchievement & Partial<Pick<AchievementNotification, 'kind'>>
+51 -68
View File
@@ -1,5 +1,5 @@
import type { StatisticInfo } from "@oj2/contract"
import { PUNCTUATION_MAP, PUNCTUATION_NAMES } from "utils/pythonError"
import type { StatisticInfo } from '@oj2/contract'
import { PUNCTUATION_MAP, PUNCTUATION_NAMES } from 'utils/pythonError'
/**
* 答案错误时,比较学生在**公开样例**上的输出和正确输出,归一类、给一句中文提示。
@@ -12,50 +12,41 @@ import { PUNCTUATION_MAP, PUNCTUATION_NAMES } from "utils/pythonError"
*
* 样例是公开的,这里可以放心地把具体的值说出来(「正确输出是 84.10,你输出的是 84.1」)。
*/
export type SampleCheck = NonNullable<StatisticInfo["sample_check"]>
export type SampleCheck = NonNullable<StatisticInfo['sample_check']>
export type DiffKind =
| "no-output"
| "whitespace"
| "punctuation"
| "case"
| "extra"
| "missing"
| "number-format"
| "values"
| "other"
export type DiffKind = 'no-output' | 'whitespace' | 'punctuation' | 'case' | 'extra' | 'missing' | 'number-format' | 'values' | 'other'
const NUMBER = /-?\d+(?:\.\d+)?/g
function squash(text: string) {
return text.replace(/\s+/g, " ").trim()
return text.replace(/\s+/g, ' ').trim()
}
function toEnglishPunctuation(text: string) {
return [...text].map((ch) => PUNCTUATION_MAP[ch] ?? ch).join("")
return [...text].map((ch) => PUNCTUATION_MAP[ch] ?? ch).join('')
}
export function classifyDiff(expected: string, output: string): DiffKind {
const e = squash(expected)
const o = squash(output)
if (!o) return "no-output"
if (e === o) return "whitespace"
if (!o) return 'no-output'
if (e === o) return 'whitespace'
if (squash(toEnglishPunctuation(expected)) === squash(toEnglishPunctuation(output))) {
return "punctuation"
return 'punctuation'
}
if (e.toLowerCase() === o.toLowerCase()) return "case"
if (e.toLowerCase() === o.toLowerCase()) return 'case'
// 数字先于「多了内容」判:007 里包含 7、3.0 里包含 3,先判包含就会把它们当成
// 「多打了说明文字」,给出完全不相干的建议
const en = e.match(NUMBER) ?? []
const on = o.match(NUMBER) ?? []
const sameShape = e.replace(NUMBER, "N") === o.replace(NUMBER, "N") && en.length === on.length
const sameShape = e.replace(NUMBER, 'N') === o.replace(NUMBER, 'N') && en.length === on.length
if (sameShape && en.length) {
const sameValues = en.every((value, i) => Math.abs(Number(value) - Number(on[i])) < 1e-9)
return sameValues ? "number-format" : "values"
return sameValues ? 'number-format' : 'values'
}
if (e && o.includes(e)) return "extra"
if (o && e.includes(o)) return "missing"
return "other"
if (e && o.includes(e)) return 'extra'
if (o && e.includes(o)) return 'missing'
return 'other'
}
/** 第一处不一样的标点:[正确输出里的, 你输出的] */
@@ -81,32 +72,29 @@ function punctuationHint(expected: string, output: string) {
return `内容对了,但标点不一样:正确输出用的是中文的${name},你输出的是英文的${name}。输出里的标点要和题目完全一样。`
}
}
return "内容对了,但标点不一样:输出里的标点要和题目完全一样,注意分清中文标点和英文标点。"
return '内容对了,但标点不一样:输出里的标点要和题目完全一样,注意分清中文标点和英文标点。'
}
function numberFormatHint(expected: string, output: string, language: string) {
const en = squash(expected).match(NUMBER) ?? []
const on = squash(output).match(NUMBER) ?? []
const i = en.findIndex((value, k) => value !== on[k])
if (i < 0) return "数字的写法和正确输出不一样。"
if (i < 0) return '数字的写法和正确输出不一样。'
const want = en[i]!
const got = on[i]!
const decimals = want.split(".")[1]?.length ?? 0
const decimals = want.split('.')[1]?.length ?? 0
const head = `正确输出是 ${want},你输出的是 ${got}。`
// 007 和 7:逆序数、取位数一类题的老毛病,跟小数没关系
if (/^-?0\d/.test(got)) {
const how = language === "Python" ? "用 int() 转成整数再输出" : "按整数(%d)输出"
const how = language === 'Python' ? '用 int() 转成整数再输出' : '按整数(%d)输出'
return `${head}数字前面多了 0。要输出的是一个数,不是一串数字字符:${how},前面的 0 就没了。`
}
if (decimals > 0) {
const how =
language === "Python"
? `Python 可以写 f"{x:.${decimals}f}"`
: `C 语言可以写 printf("%.${decimals}f", x)`
const how = language === 'Python' ? `Python 可以写 f"{x:.${decimals}f}"` : `C 语言可以写 printf("%.${decimals}f", x)`
return `${head}题目要求保留 ${decimals} 位小数,${how}。`
}
if (!got.includes(".")) return `${head}数字的写法和正确输出不一样。`
if (language === "Python") {
if (!got.includes('.')) return `${head}数字的写法和正确输出不一样。`
if (language === 'Python') {
return `${head}这里要输出整数:Python 里 / 算出来的总是小数,整除要用 //,或者用 int() 转换。`
}
return `${head}这里要输出整数,不要带小数点:printf 用 %d 输出整数。`
@@ -117,36 +105,31 @@ export function hasInputPrompt(code: string) {
return /\binput\(\s*[fr]?["'][^"'\n]*\S[^"'\n]*["']\s*\)/.test(code)
}
export function explainDiff(
kind: DiffKind,
check: SampleCheck,
code: string,
language: string,
): string {
const expected = check.expected ?? ""
const output = check.output ?? ""
const printName = language === "Python" ? "print" : "printf"
export function explainDiff(kind: DiffKind, check: SampleCheck, code: string, language: string): string {
const expected = check.expected ?? ''
const output = check.output ?? ''
const printName = language === 'Python' ? 'print' : 'printf'
switch (kind) {
case "no-output":
case 'no-output':
return `你的程序什么都没有输出。检查一下是不是忘了写 ${printName},或者 ${printName} 写在了执行不到的地方(比如某个 if 的里面)。`
case "whitespace":
return "内容对了,只是空格或换行不一样。下面把空格画成了「·」、换行画成了「↵」,对照着看看哪一行多了或少了。"
case "punctuation":
case 'whitespace':
return '内容对了,只是空格或换行不一样。下面把空格画成了「·」、换行画成了「↵」,对照着看看哪一行多了或少了。'
case 'punctuation':
return punctuationHint(expected, output)
case "case":
return "内容对了,只是字母的大小写不一样。输出要和题目的大小写完全一样。"
case "extra":
if (language === "Python" && hasInputPrompt(code)) {
case 'case':
return '内容对了,只是字母的大小写不一样。输出要和题目的大小写完全一样。'
case 'extra':
if (language === 'Python' && hasInputPrompt(code)) {
return 'input() 括号里的提示文字也会被输出,判题时就对不上了。把 input("……") 里的文字删掉,只写 input()。'
}
return "你的输出比正确输出多了一些内容。题目要输出什么就只输出什么,不要加「请输入」「结果是」这类提示或说明文字。"
case "missing":
return "你的输出比正确输出少了一部分,对照看看漏了什么。"
case "number-format":
return '你的输出比正确输出多了一些内容。题目要输出什么就只输出什么,不要加「请输入」「结果是」这类提示或说明文字。'
case 'missing':
return '你的输出比正确输出少了一部分,对照看看漏了什么。'
case 'number-format':
return numberFormatHint(expected, output, language)
case "values":
return "输出的格式对了,但算出来的数不对。拿这个例子的输入,自己一步一步算一遍,看看程序在哪一步和你想的不一样。"
case "other":
case 'values':
return '输出的格式对了,但算出来的数不对。拿这个例子的输入,自己一步一步算一遍,看看程序在哪一步和你想的不一样。'
case 'other':
return otherHint(check)
}
}
@@ -163,28 +146,28 @@ function quote(text: string, max = 24) {
* 改成把具体的值说出来,再告诉他拿这个例子的输入去走一遍程序(设计文档第 6 节)。
*/
function otherHint(check: SampleCheck) {
const expected = (check.expected ?? "").replace(/\s+$/, "")
const output = (check.output ?? "").replace(/\s+$/, "")
const expected = (check.expected ?? '').replace(/\s+$/, '')
const output = (check.output ?? '').replace(/\s+$/, '')
const no = (check.index ?? 0) + 1
const input = (check.input ?? "").trim()
const input = (check.input ?? '').trim()
const walk =
input && !input.includes("\n") && input.length <= 24
input && !input.includes('\n') && input.length <= 24
? `拿例子 ${no} 的输入${quote(input)},一行一行走一遍你的程序,看它走进了哪个分支、算出了什么。`
: `拿例子 ${no} 的输入,一行一行走一遍你的程序,看它走进了哪个分支、算出了什么。`
if (!expected.includes("\n") && !output.includes("\n")) {
if (!expected.includes('\n') && !output.includes('\n')) {
return `你的程序输出了${quote(output)},正确的是${quote(expected)}。${walk}`
}
const line = firstDifferentLine(expected, output)
const want = expected.split("\n")[line] ?? ""
const got = output.split("\n")[line]
const gotText = got === undefined ? "什么都没有" : quote(got)
const want = expected.split('\n')[line] ?? ''
const got = output.split('\n')[line]
const gotText = got === undefined ? '什么都没有' : quote(got)
return `从第 ${line + 1} 行开始不一样:正确的是${quote(want)},你输出的是${gotText}。${walk}`
}
/** 第一处不一样的行号,0 起;完全一样(只差结尾空白)时为 -1 */
export function firstDifferentLine(expected: string, output: string) {
const e = expected.replace(/\s+$/, "").split("\n")
const o = output.replace(/\s+$/, "").split("\n")
const e = expected.replace(/\s+$/, '').split('\n')
const o = output.replace(/\s+$/, '').split('\n')
for (let i = 0; i < Math.max(e.length, o.length); i++) {
if (e[i] !== o[i]) return i
}