refactor(前端): shared / utils 不再 import oj 和 admin 下的任何东西
依赖方向原来是反的:顶栏、登录框、举手列表这些 shared 组件从 oj/ 里拿东西。 - 跨端的接口挪进 shared/api.ts(按 apps/web/CLAUDE.md 的分法):学生端题目列表、 出题人列表、这节课的题、待弹的成就 - 这节课的 store → shared/store/lesson.ts;头像 RankAvatar → shared/components - 纯函数挪进 utils/:举手列表的错法归类 errorKind、它依赖的答案错误分类 wrongAnswer 和 Python 报错翻译 pythonError、题目列表行的 toProblemRow - 只有后台在用的出题人下拉 AuthorSelect 挪回 admin/components 纯搬家。首页、题目列表、题目页、排名、课堂看板、后台题目列表打开无报错, 顶栏题号框敲 1001 回车直达题目页。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4aadc7722
commit
e8a20d12f9
38 files changed
+78
-78
No files matched your search
@@ -1,17 +1,8 @@
|
||||
import type { MarkAchievementsRead } from "@oj2/contract"
|
||||
import api from "utils/api"
|
||||
import type { AchievementList, PendingAchievement } from "utils/types"
|
||||
import type { AchievementList } from "utils/types"
|
||||
|
||||
export function getAchievements(name?: string) {
|
||||
return api.get<AchievementList>("achievements", {
|
||||
params: name ? { username: name } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function getPendingAchievements() {
|
||||
return api.get<PendingAchievement[]>("achievements/pending")
|
||||
}
|
||||
|
||||
export function markAchievementsRead(ids: number[]) {
|
||||
return api.post("achievements/pending/read", { ids } satisfies MarkAchievementsRead)
|
||||
}
|
||||
+2
-29
@@ -23,7 +23,6 @@ import {
|
||||
type ProfileOverview,
|
||||
type TutorialSummary,
|
||||
type Tutorial,
|
||||
type ClassActivity,
|
||||
type ClassToday,
|
||||
type LastVisit,
|
||||
type ClassBoard,
|
||||
@@ -40,10 +39,8 @@ import {
|
||||
type WeeklyRank,
|
||||
type ProblemRank,
|
||||
type CreateSubmissionResponse,
|
||||
type ProblemAuthor,
|
||||
type ProblemListItem,
|
||||
type ProblemStats,
|
||||
type ProblemList,
|
||||
type ProblemProgress,
|
||||
type CreateFlowchartResponse,
|
||||
type FlowchartScores,
|
||||
@@ -64,7 +61,7 @@ import {
|
||||
} from "@oj2/contract"
|
||||
import api from "utils/api"
|
||||
import { contract } from "utils/contract"
|
||||
import { toProblemRow } from "oj/transforms"
|
||||
import { toProblemRow } from "utils/transforms"
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementListItem,
|
||||
@@ -82,26 +79,12 @@ import type {
|
||||
|
||||
/**
|
||||
* 题目详情 / 提交详情的 schema 按需加载,和请求并行发出。静态 import 会把 zod
|
||||
* 运行时带进首页 —— 题目列表的 getProblemList 也在这个文件里。
|
||||
* 运行时带进首页 —— 首页、题目列表都 import 这个文件。
|
||||
* 闸门本身仍是同步的(等 schema 到了再返回),submissionDetailSchema 有 `.catch()`,
|
||||
* 放行原文和解析结果不等价,所以不能像 /me 那样改成 contractDeferred。
|
||||
*/
|
||||
const loadDetailSchemas = () => import("./detailSchemas")
|
||||
|
||||
export async function getProblemList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
searchParams: Record<string, unknown> = {},
|
||||
) {
|
||||
const res = await api.get<ProblemList>("problems", {
|
||||
params: { paging: true, offset, limit, ...searchParams },
|
||||
})
|
||||
return {
|
||||
results: res.results.map(toProblemRow),
|
||||
total: res.total,
|
||||
}
|
||||
}
|
||||
|
||||
/** 题目列表顶上那行和左栏的进度;没登录是 null */
|
||||
export function getProblemProgress() {
|
||||
return api.get<ProblemProgress | null>("problems/progress")
|
||||
@@ -115,12 +98,6 @@ export function getRandomProblem(params: { tag: string; type: string; language:
|
||||
return api.get<string>("problems/random", { params })
|
||||
}
|
||||
|
||||
export function getAuthors(all = false) {
|
||||
return api.get<ProblemAuthor[]>("problem-authors", {
|
||||
params: { all: all ? "1" : "0" },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getProblem(problemID: string, contestID: string) {
|
||||
const endpoint = contestID
|
||||
? `contests/${encodeURIComponent(contestID)}/problems/${encodeURIComponent(problemID)}`
|
||||
@@ -316,10 +293,6 @@ export function getKnowledgeMap() {
|
||||
return api.get<KnowledgeMap>("me/knowledge")
|
||||
}
|
||||
|
||||
export function getClassActivity() {
|
||||
return api.get<ClassActivity>("me/class-activity")
|
||||
}
|
||||
|
||||
/** 老师首页「今天的课」:今天在上课的每个班 */
|
||||
export function getClassToday() {
|
||||
return api.get<ClassToday>("classroom/today")
|
||||
|
||||
@@ -15,7 +15,7 @@ import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from "utils/types"
|
||||
import ExplainModal, { type ExplainPerson } from "./components/ExplainModal.vue"
|
||||
import { errorKind, type ErrorKind } from "./errorKind"
|
||||
import { errorKind, type ErrorKind } from "utils/errorKind"
|
||||
|
||||
/**
|
||||
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { classifyDiff, type DiffKind } from "oj/problem/utils/wrongAnswer"
|
||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import type { ClassBoardCell } from "utils/types"
|
||||
|
||||
/**
|
||||
* 课堂看板:一道题错着的人「按错法归堆」(设计稿「课堂看板重设计」A+B)。老师讲错误时
|
||||
* 要的是「这道题全班错在哪」:同一种错讲一遍就够,所以按最后一次提交归类,堆名要短,
|
||||
* 一眼能读完 —— 长的中文解释在点开之后的弹层里(和提交详情同一份)。
|
||||
*
|
||||
* 依据是提交里存的 runtime_error / sample_check(判题机写的),和提交详情、题目页结果区同源。
|
||||
*/
|
||||
export type BoardLatest = NonNullable<ClassBoardCell["latest"]>
|
||||
|
||||
export interface ErrorKind {
|
||||
/** 归堆用:同一个 key 是同一种错 */
|
||||
key: string
|
||||
label: string
|
||||
/** 报错指到的行,答案错误没有 */
|
||||
line: number | null
|
||||
}
|
||||
|
||||
const DIFF_LABEL: Record<DiffKind, string> = {
|
||||
"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() 的结果没转成列表",
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
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",
|
||||
}
|
||||
|
||||
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 ?? "")
|
||||
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.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.kind && KIND_LABEL[rt.kind]) {
|
||||
return { key: `re-${rt.kind}`, label: KIND_LABEL[rt.kind]!, line }
|
||||
}
|
||||
if (rt.type && TYPE_LABEL[rt.type]) {
|
||||
return { key: `re-${rt.type}`, label: TYPE_LABEL[rt.type]!, line }
|
||||
}
|
||||
return { key: "re", label: "运行时出错", line }
|
||||
}
|
||||
if (result === SubmissionStatus.compile_error) {
|
||||
return { key: "ce", label: "编译没通过", line: null }
|
||||
}
|
||||
return {
|
||||
key: `r${result}`,
|
||||
label: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? "没通过",
|
||||
line: null,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } fr
|
||||
import { Icon } from "@iconify/vue"
|
||||
import {
|
||||
getAnnouncementList,
|
||||
getClassActivity,
|
||||
getKnowledgeMap,
|
||||
getContestList,
|
||||
getLastVisit,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
getTutorials,
|
||||
getWeeklyRank,
|
||||
} from "oj/api"
|
||||
import { getClassActivity } from "shared/api"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { pickHeadline, type Headline } from "oj/user/knowledge"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { errorMessage } from "utils/api"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import { trialRun } from "utils/judge"
|
||||
import { explainCCompileError } from "oj/problem/utils/cError"
|
||||
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
|
||||
import { explainPythonCompileError } from "utils/pythonError"
|
||||
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
showPunctuationMarks,
|
||||
} from "oj/problem/utils/errorMark"
|
||||
import { explainCCompileError } from "oj/problem/utils/cError"
|
||||
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
|
||||
import { explainPythonCompileError } from "utils/pythonError"
|
||||
|
||||
/**
|
||||
* 编译错误的中文说明卡片:Python 的语法错误走 pythonError.ts,C / C++ 的 gcc 报错走
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { RouteLocationRaw } from "vue-router"
|
||||
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
|
||||
import { useContestStore } from "oj/store/contest"
|
||||
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
||||
import { useLessonStore } from "oj/store/lesson"
|
||||
import { useLessonStore } from "shared/store/lesson"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useLessonStore } from "oj/store/lesson"
|
||||
import { useLessonStore } from "shared/store/lesson"
|
||||
|
||||
/**
|
||||
* 通过之后告诉学生「这节课的下一题是哪道」。
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
import CollabBar from "./CollabBar.vue"
|
||||
import { useFlowchartStore } from "oj/store/flowchart"
|
||||
import { useLessonStore } from "oj/store/lesson"
|
||||
import { useLessonStore } from "shared/store/lesson"
|
||||
|
||||
const FlowchartEditor = defineAsyncComponent(
|
||||
() => import("shared/components/FlowchartEditor/index.vue"),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getReaction } from "oj/api"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useSubmissionStore } from "oj/store/submission"
|
||||
import { useFireworks } from "oj/problem/composables/useFireworks"
|
||||
import { useLessonStore } from "oj/store/lesson"
|
||||
import { useLessonStore } from "shared/store/lesson"
|
||||
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
|
||||
@@ -21,7 +21,7 @@ import WrongAnswerExplain from "./WrongAnswerExplain.vue"
|
||||
import LessonNext from "./LessonNext.vue"
|
||||
import ResultHeader from "./ResultHeader.vue"
|
||||
import SimilarProblems from "./SimilarProblems.vue"
|
||||
import { useLessonStore } from "oj/store/lesson"
|
||||
import { useLessonStore } from "shared/store/lesson"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
|
||||
|
||||
@@ -5,12 +5,7 @@ import { useThemeVars } from "naive-ui"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { markdownToText } from "oj/problem/utils/plainText"
|
||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import {
|
||||
classifyDiff,
|
||||
explainDiff,
|
||||
firstDifferentLine,
|
||||
type SampleCheck,
|
||||
} from "oj/problem/utils/wrongAnswer"
|
||||
import { classifyDiff, explainDiff, firstDifferentLine, type SampleCheck } from "utils/wrongAnswer"
|
||||
|
||||
/**
|
||||
* 答案错误时的样例对比(数据见后端 judge/run.ts 的 checkSamples)。
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Icon } from "@iconify/vue"
|
||||
import type { ProblemProgress, ProblemTypeFilter, Tag } from "@oj2/contract"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { getAuthors, getProblemList, getProblemProgress, getRandomProblem } from "oj/api"
|
||||
import { getProblemProgress, getRandomProblem } from "oj/api"
|
||||
import { getAuthors, getProblemList } from "shared/api"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useTone } from "shared/composables/tone"
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { findChinesePunctuation } from "./chinesePunctuation"
|
||||
import { PUNCTUATION_NAMES, type PythonErrorExplanation } from "./pythonError"
|
||||
import { PUNCTUATION_NAMES, type PythonErrorExplanation } from "utils/pythonError"
|
||||
|
||||
/**
|
||||
* 把 C / C++ 的编译错误(gcc 的原文)翻成中文,和 pythonError.ts 同一个思路:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PUNCTUATION_MAP } from "./pythonError"
|
||||
import { PUNCTUATION_MAP } from "utils/pythonError"
|
||||
|
||||
export interface PunctuationFix {
|
||||
from: number
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
/**
|
||||
* 把 Python 的编译错误(`statistic_info.err_info`)翻成中文。
|
||||
*
|
||||
* 学生是中职生,英文报错等于没有报错。好在句式非常集中:2025 秋以来 2700 条
|
||||
* Python 编译错误里,前 12 种句子占 91%、前 16 种占 97%,所以这里是一张规则表,
|
||||
* 不是通用翻译。第二名「invalid character」(22%)几乎全是中文标点,另有一键修复,
|
||||
* 见 chinesePunctuation.ts。
|
||||
*
|
||||
* 判题机给的原文有两种形状:
|
||||
*
|
||||
* File "/judger/run/<随机目录>/solution.py", line 7
|
||||
* print("{:.0f}"."{:.2f}".format(x))
|
||||
* ^^^^^^^^
|
||||
* SyntaxError: invalid syntax
|
||||
*
|
||||
* Sorry: IndentationError: unexpected indent (solution.py, line 3)
|
||||
*
|
||||
* 第一种的代码行是 Python 去掉**行首空格**之后再垫 4 个空格打出来的,`^` 的列号
|
||||
* 按去掉之后的算(traceback 的 `_format_syntax_error`,3.10 起如此)。
|
||||
*/
|
||||
|
||||
export interface PythonErrorExplanation {
|
||||
/** 出错的行,1 起 */
|
||||
line: number | null
|
||||
/** 判题机打出来的那一行代码(去掉了行首空格),用来和编辑器里的对位 */
|
||||
sourceLine: string | null
|
||||
/** `^` 在 sourceLine 里标的范围,[from, to) */
|
||||
caret: { from: number; to: number } | null
|
||||
/** 给学生看的中文说明,不含行号 */
|
||||
message: string
|
||||
/** 报错点名的那个中文标点;有它才出「一键换成英文」 */
|
||||
punctuation: string | null
|
||||
}
|
||||
|
||||
// 行首可能有空格:报错前面先打了一条 SyntaxWarning(比如 `3and`)时,File 那行是缩进的
|
||||
const WHERE_RE = /^\s*File "[^"]*", line (\d+)/m
|
||||
const SHORT_RE = /^Sorry: (\w+Error): (.*) \([^,()]*, line (\d+)\)\s*$/m
|
||||
|
||||
/**
|
||||
* 中文空格(U+3000)。写成常量而不是字面量:Oxfmt 会把字符串里的 `\u3000` 转义
|
||||
* 还原成原字符,那样源码里就是一个看不见的空白。
|
||||
*/
|
||||
const IDEOGRAPHIC_SPACE = String.fromCharCode(0x3000)
|
||||
|
||||
/** 中文标点 → 英文。「。」不在表里:它多半是句末多打的,换成「.」照样错,只能删 */
|
||||
export const PUNCTUATION_MAP: Record<string, string> = {
|
||||
"(": "(",
|
||||
")": ")",
|
||||
",": ",",
|
||||
":": ":",
|
||||
";": ";",
|
||||
"【": "[",
|
||||
"】": "]",
|
||||
"{": "{",
|
||||
"}": "}",
|
||||
"!": "!",
|
||||
"?": "?",
|
||||
"=": "=",
|
||||
"+": "+",
|
||||
"-": "-",
|
||||
"*": "*",
|
||||
"/": "/",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"“": '"',
|
||||
"”": '"',
|
||||
"‘": "'",
|
||||
"’": "'",
|
||||
""": '"',
|
||||
"#": "#",
|
||||
"×": "*",
|
||||
"÷": "/",
|
||||
[IDEOGRAPHIC_SPACE]: " ",
|
||||
}
|
||||
|
||||
/**
|
||||
* 标点的名字。说明里**只说名字、不拿字形对比**:「:」和「:」、「,」和「,」在正文字体里
|
||||
* 几乎一样,「把「:」换成「:」」这句话学生看不出区别。「冒号是中文的,要换成英文的」
|
||||
* 看得懂,要换哪一个由卡片里那行代码的标红来指。
|
||||
*/
|
||||
export const PUNCTUATION_NAMES: Record<string, string> = {
|
||||
"(": "左括号",
|
||||
")": "右括号",
|
||||
",": "逗号",
|
||||
":": "冒号",
|
||||
";": "分号",
|
||||
"【": "左方括号",
|
||||
"】": "右方括号",
|
||||
"{": "左花括号",
|
||||
"}": "右花括号",
|
||||
"!": "感叹号",
|
||||
"?": "问号",
|
||||
"=": "等号",
|
||||
"+": "加号",
|
||||
"-": "减号",
|
||||
"*": "星号",
|
||||
"/": "斜杠",
|
||||
"<": "小于号",
|
||||
">": "大于号",
|
||||
"“": "引号",
|
||||
"”": "引号",
|
||||
"‘": "单引号",
|
||||
"’": "单引号",
|
||||
""": "引号",
|
||||
"#": "井号",
|
||||
[IDEOGRAPHIC_SPACE]: "空格",
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则除了报错原文,还能看出错的那一行(判题机打出来的,去掉了行首空格)。同一句报错
|
||||
* 学生错法不同,要看那一行才分得开:`expected ':'` 九成是 `else a < 60:`,行尾明明有冒号;
|
||||
* 「Maybe you meant '=='」只有 if / while 那行才是真想判断相等。返回 null 就接着往下找。
|
||||
*/
|
||||
type Rule = [RegExp, (match: RegExpMatchArray, source: string) => string | null]
|
||||
|
||||
/** 去掉引号里的内容,只看代码本身的符号 */
|
||||
function stripStrings(source: string) {
|
||||
return source.replace(/(["'])(?:\\.|(?!\1).)*\1/g, '""')
|
||||
}
|
||||
|
||||
/** 括号外面的逗号:`if a > 9, a < 100:` 里有,`if x in (1, 2):` 里没有 */
|
||||
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
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const CONDITION_LINE = /^(if|elif|while)\b/
|
||||
|
||||
const RULES: Rule[] = [
|
||||
[
|
||||
// u:emoji 是两个 UTF-16 单元,不带 u 的 (.) 对不上
|
||||
/^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 "代码里不用顿号「、」:几样东西之间要用英文逗号隔开。写代码的时候先切换到英文输入法。"
|
||||
const name = PUNCTUATION_NAMES[char!]
|
||||
if (name) {
|
||||
return `这里的${name}是中文输入法打出来的,要换成英文的${name}。写代码的时候先切换到英文输入法。`
|
||||
}
|
||||
if (char === "。") return "这里多了一个中文句号「。」,代码里不用句号,删掉它。"
|
||||
return `这里有一个不能出现在代码里的字符「${char}」,多半是中文输入法打出来的,删掉后用英文输入法重新打。`
|
||||
},
|
||||
],
|
||||
[
|
||||
/^invalid non-printable character/,
|
||||
() => "这里有一个看不见的特殊字符,多半是从别处复制过来的。把这一行删掉,自己重新打一遍。",
|
||||
],
|
||||
[
|
||||
/^expected ':'/,
|
||||
(_, source) =>
|
||||
/^else\b\s*[^\s:]/.test(source)
|
||||
? "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 键缩进。",
|
||||
],
|
||||
[
|
||||
/^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)。'
|
||||
return null
|
||||
},
|
||||
],
|
||||
[
|
||||
/^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」。",
|
||||
],
|
||||
[
|
||||
/^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。"
|
||||
const code = stripStrings(source)
|
||||
if (/=<|=>/.test(code)) return "小于等于要写成 <=,大于等于要写成 >=,等号放在后面。"
|
||||
if (CONDITION_LINE.test(source) && hasTopLevelComma(source))
|
||||
return "条件之间不能用逗号连:两个都要满足用 and,满足一个就行用 or,比如 if a > 9 and a < 100:。"
|
||||
return null
|
||||
},
|
||||
],
|
||||
[
|
||||
/^invalid syntax/,
|
||||
() =>
|
||||
"标出来的地方写法不对。对照教程检查这一行:括号、引号、冒号、运算符有没有写错、漏写或多写。",
|
||||
],
|
||||
]
|
||||
|
||||
function translate(message: string, source: string) {
|
||||
for (const [pattern, render] of RULES) {
|
||||
const match = message.match(pattern)
|
||||
const text = match && render(match, source)
|
||||
if (text) return text
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 选了 Python 却交了 C 代码:全库 310 条,报错各式各样(多半是 invalid syntax),
|
||||
* 挨个解释毫无意义,该说的只有一句
|
||||
*/
|
||||
function looksLikeC(code: string) {
|
||||
if (/^\s*#\s*include\b|\bint\s+main\s*\(/m.test(code)) return true
|
||||
// 只凭 printf 不算:Python 代码里把 print 手误成 printf 的不少
|
||||
return /\b(printf|scanf)\s*\(|\bcout\s*<</.test(code) && !/\b(print|input)\s*\(/.test(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻不出来(判题机自己的报错、没见过的句式)就返回 null,界面照旧显示原文。
|
||||
* `code` 是学生的代码,用来认出「交的其实是 C」
|
||||
*/
|
||||
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!, "")
|
||||
if (!message) return null
|
||||
return {
|
||||
line: Number(line),
|
||||
sourceLine: null,
|
||||
caret: null,
|
||||
message,
|
||||
punctuation: null,
|
||||
}
|
||||
}
|
||||
|
||||
const where = text.match(WHERE_RE)
|
||||
const lines = text.split("\n")
|
||||
const last = lines[lines.length - 1]!
|
||||
const detail = last.replace(/^\w+Error: /, "")
|
||||
if (detail === last) return null
|
||||
|
||||
let sourceLine: string | null = 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 }
|
||||
}
|
||||
}
|
||||
|
||||
const message = looksLikeC(code)
|
||||
? "这是 C 语言的代码,可提交时选的语言是 Python。把语言换成 C 再交。"
|
||||
: translate(detail, sourceLine?.trim() ?? "")
|
||||
if (!message) return null
|
||||
|
||||
const char = detail.match(/^invalid character '(.)'/u)?.[1]
|
||||
return {
|
||||
line: where ? Number(where[1]) : null,
|
||||
sourceLine,
|
||||
caret,
|
||||
message,
|
||||
punctuation: char && PUNCTUATION_MAP[char] ? char : null,
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import type { StatisticInfo } from "@oj2/contract"
|
||||
import { PUNCTUATION_MAP, PUNCTUATION_NAMES } from "./pythonError"
|
||||
|
||||
/**
|
||||
* 答案错误时,比较学生在**公开样例**上的输出和正确输出,归一类、给一句中文提示。
|
||||
* 数据来自后端 judge/run.ts 的 checkSamples(`statistic_info.sample_check`)。
|
||||
*
|
||||
* 分类是拿 2025 秋以来 5055 条「样例上就错了」的答案错误定的:
|
||||
* 格式和内容都不同 44%、数值不对 15%、没有输出 10%、多了文字 9%、只差空格换行 9%、
|
||||
* 少了内容 5%、中英文标点 4%、数字写法(小数位数)2.5%、大小写 1%。
|
||||
* 前几类之外的「几乎对了」合起来约三分之一,一句话就能改对。
|
||||
*
|
||||
* 样例是公开的,这里可以放心地把具体的值说出来(「正确输出是 84.10,你输出的是 84.1」)。
|
||||
*/
|
||||
export type SampleCheck = NonNullable<StatisticInfo["sample_check"]>
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
function toEnglishPunctuation(text: string) {
|
||||
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 (squash(toEnglishPunctuation(expected)) === squash(toEnglishPunctuation(output))) {
|
||||
return "punctuation"
|
||||
}
|
||||
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
|
||||
if (sameShape && en.length) {
|
||||
const sameValues = en.every((value, i) => Math.abs(Number(value) - Number(on[i])) < 1e-9)
|
||||
return sameValues ? "number-format" : "values"
|
||||
}
|
||||
if (e && o.includes(e)) return "extra"
|
||||
if (o && e.includes(o)) return "missing"
|
||||
return "other"
|
||||
}
|
||||
|
||||
/** 第一处不一样的标点:[正确输出里的, 你输出的] */
|
||||
function firstPunctuationPair(expected: string, output: string) {
|
||||
const e = [...squash(expected)]
|
||||
const o = [...squash(output)]
|
||||
for (let i = 0; i < Math.min(e.length, o.length); i++) {
|
||||
if (e[i] !== o[i]) return [e[i]!, o[i]!] as const
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function punctuationHint(expected: string, output: string) {
|
||||
const pair = firstPunctuationPair(expected, output)
|
||||
if (pair) {
|
||||
const [want, got] = pair
|
||||
// 中文的那个才在名字表里
|
||||
const name = PUNCTUATION_NAMES[got] ?? PUNCTUATION_NAMES[want]
|
||||
if (name && PUNCTUATION_MAP[got] === want) {
|
||||
return `内容对了,但标点不一样:正确输出用的是英文的${name},你输出的是中文的${name}。输出里的标点要和题目完全一样。`
|
||||
}
|
||||
if (name && PUNCTUATION_MAP[want] === got) {
|
||||
return `内容对了,但标点不一样:正确输出用的是中文的${name},你输出的是英文的${name}。输出里的标点要和题目完全一样。`
|
||||
}
|
||||
}
|
||||
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 "数字的写法和正确输出不一样。"
|
||||
const want = en[i]!
|
||||
const got = on[i]!
|
||||
const decimals = want.split(".")[1]?.length ?? 0
|
||||
const head = `正确输出是 ${want},你输出的是 ${got}。`
|
||||
// 007 和 7:逆序数、取位数一类题的老毛病,跟小数没关系
|
||||
if (/^-?0\d/.test(got)) {
|
||||
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)`
|
||||
return `${head}题目要求保留 ${decimals} 位小数,${how}。`
|
||||
}
|
||||
if (!got.includes(".")) return `${head}数字的写法和正确输出不一样。`
|
||||
if (language === "Python") {
|
||||
return `${head}这里要输出整数:Python 里 / 算出来的总是小数,整除要用 //,或者用 int() 转换。`
|
||||
}
|
||||
return `${head}这里要输出整数,不要带小数点:printf 用 %d 输出整数。`
|
||||
}
|
||||
|
||||
/** Python 的 input("请输入……"):提示文字会被打印进输出,判题时必然对不上 */
|
||||
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"
|
||||
switch (kind) {
|
||||
case "no-output":
|
||||
return `你的程序什么都没有输出。检查一下是不是忘了写 ${printName},或者 ${printName} 写在了执行不到的地方(比如某个 if 的里面)。`
|
||||
case "whitespace":
|
||||
return "内容对了,只是空格或换行不一样。下面把空格画成了「·」、换行画成了「↵」,对照着看看哪一行多了或少了。"
|
||||
case "punctuation":
|
||||
return punctuationHint(expected, output)
|
||||
case "case":
|
||||
return "内容对了,只是字母的大小写不一样。输出要和题目的大小写完全一样。"
|
||||
case "extra":
|
||||
if (language === "Python" && hasInputPrompt(code)) {
|
||||
return 'input() 括号里的提示文字也会被输出,判题时就对不上了。把 input("……") 里的文字删掉,只写 input()。'
|
||||
}
|
||||
return "你的输出比正确输出多了一些内容。题目要输出什么就只输出什么,不要加「请输入」「结果是」这类提示或说明文字。"
|
||||
case "missing":
|
||||
return "你的输出比正确输出少了一部分,对照看看漏了什么。"
|
||||
case "number-format":
|
||||
return numberFormatHint(expected, output, language)
|
||||
case "values":
|
||||
return "输出的格式对了,但算出来的数不对。拿这个例子的输入,自己一步一步算一遍,看看程序在哪一步和你想的不一样。"
|
||||
case "other":
|
||||
return otherHint(check)
|
||||
}
|
||||
}
|
||||
|
||||
/** 放进一句话里的值:太长的截断,免得一句提示撑满一屏 */
|
||||
function quote(text: string, max = 24) {
|
||||
const flat = text.trim()
|
||||
return `「${flat.length > max ? `${flat.slice(0, max)}…` : flat}」`
|
||||
}
|
||||
|
||||
/**
|
||||
* 「其他」:格式和内容都不一样(占样例错误的 44%)。原来只说「对照一下,找找从哪里开始
|
||||
* 不一样」,对 D / E 这种整个值不同的情况没用 —— 学生看得出不一样,看不出为什么。
|
||||
* 改成把具体的值说出来,再告诉他拿这个例子的输入去走一遍程序(设计文档第 6 节)。
|
||||
*/
|
||||
function otherHint(check: SampleCheck) {
|
||||
const expected = (check.expected ?? "").replace(/\s+$/, "")
|
||||
const output = (check.output ?? "").replace(/\s+$/, "")
|
||||
const no = (check.index ?? 0) + 1
|
||||
const input = (check.input ?? "").trim()
|
||||
const walk =
|
||||
input && !input.includes("\n") && input.length <= 24
|
||||
? `拿例子 ${no} 的输入${quote(input)},一行一行走一遍你的程序,看它走进了哪个分支、算出了什么。`
|
||||
: `拿例子 ${no} 的输入,一行一行走一遍你的程序,看它走进了哪个分支、算出了什么。`
|
||||
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)
|
||||
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")
|
||||
for (let i = 0; i < Math.max(e.length, o.length); i++) {
|
||||
if (e[i] !== o[i]) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { RankRow, WeeklyChampion } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import RankAvatar from "./RankAvatar.vue"
|
||||
import RankAvatar from "shared/components/RankAvatar.vue"
|
||||
|
||||
/** 本班最近几周的每周冠军,底下再挂这张榜上「进步最大」的那个人 */
|
||||
defineProps<{
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useBackClose } from "shared/composables/backClose"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { useRankPalette } from "../palette"
|
||||
import RankAvatar from "./RankAvatar.vue"
|
||||
import RankAvatar from "shared/components/RankAvatar.vue"
|
||||
|
||||
// Markdown 连着整套代码高亮(两百多 KB),这里要等点开 / AI 写完才用得上,别让它拖慢页面
|
||||
const Markdown = defineAsyncComponent(() => import("shared/components/Markdown.vue"))
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { rgba } from "shared/composables/tone"
|
||||
import { USERNAME_CLASS_RE } from "utils/constants"
|
||||
|
||||
/**
|
||||
* 排名页的头像。九成以上的学生没换过头像(1797 个号是 default.png),一排一模一样的
|
||||
* 默认图分不出人,所以默认头像画成「名字最后一个字 + 彩色圆」,颜色按用户名固定 ——
|
||||
* 同一个人在领奖台、赛道、「你」那张卡里是同一个颜色。自己是实心绿。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
username: string
|
||||
avatar: string | null
|
||||
size: number
|
||||
me?: boolean
|
||||
}>()
|
||||
|
||||
const isDark = useDark()
|
||||
/** 头像文件丢了(备份恢复后没拷上传目录、被删)就退回名字那个字,别露出破图 */
|
||||
const broken = ref(false)
|
||||
watch(
|
||||
() => props.avatar,
|
||||
() => (broken.value = false),
|
||||
)
|
||||
|
||||
const PALETTE = ["#2f6fd0", "#c76a12", "#6a4bd8", "#0f7a6c", "#b4315f", "#4d5967", "#8a6a00"]
|
||||
|
||||
const letter = computed(() => {
|
||||
const name = props.username.replace(USERNAME_CLASS_RE, "") || props.username
|
||||
return Array.from(name).at(-1) ?? "?"
|
||||
})
|
||||
|
||||
const colors = computed(() => {
|
||||
if (props.me) return { background: "#18a058", color: "#ffffff" }
|
||||
let hash = 0
|
||||
for (const char of props.username) hash = (hash * 31 + char.codePointAt(0)!) >>> 0
|
||||
const base = PALETTE[hash % PALETTE.length]!
|
||||
return { background: rgba(base, isDark.value ? 0.28 : 0.13), color: base }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
v-if="avatar && !broken"
|
||||
class="avatar"
|
||||
:src="avatar"
|
||||
alt=""
|
||||
@error="broken = true"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="avatar letter"
|
||||
aria-hidden="true"
|
||||
:style="{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
fontSize: `${Math.round(size * 0.5)}px`,
|
||||
...colors,
|
||||
}"
|
||||
>{{ letter }}</span
|
||||
>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.letter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@ import { useThemeVars } from "naive-ui"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { gapText, toPass } from "../utils"
|
||||
import MoodPopover from "./MoodPopover.vue"
|
||||
import RankAvatar from "./RankAvatar.vue"
|
||||
import RankAvatar from "shared/components/RankAvatar.vue"
|
||||
|
||||
export interface MiniRank {
|
||||
label: string
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useThemeVars } from "naive-ui"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useRankPalette } from "../palette"
|
||||
import RankAvatar from "./RankAvatar.vue"
|
||||
import RankAvatar from "shared/components/RankAvatar.vue"
|
||||
|
||||
/**
|
||||
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
|
||||
|
||||
@@ -5,7 +5,7 @@ import UserName from "shared/components/UserName.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useRankPalette } from "../palette"
|
||||
import MoodPopover from "./MoodPopover.vue"
|
||||
import RankAvatar from "./RankAvatar.vue"
|
||||
import RankAvatar from "shared/components/RankAvatar.vue"
|
||||
|
||||
/**
|
||||
* 赛道:每人一根条,条越长做对越多,一样长的先做到的排前面(设计稿 G1 / G2)。
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RankRow } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useRankPalette } from "../palette"
|
||||
import RankAvatar from "./RankAvatar.vue"
|
||||
import RankAvatar from "shared/components/RankAvatar.vue"
|
||||
|
||||
/** 这周本班前 5 名(做对过新题的),每周一清零 */
|
||||
const props = defineProps<{ rows: RankRow[]; meId?: number; loaded: boolean }>()
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { getClassActivity } from "oj/api"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import type { ClassActivity, ClassActivityProblem } from "utils/types"
|
||||
|
||||
/** 同一节课里来回换题不用每次都重拉;过了这么久再进题目页就重拉一次 */
|
||||
const STALE_MS = 2 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 「这节课的题」:首页「班里在做」那几道(老师在课堂看板布置的,或者从同班提交推断的)。
|
||||
*
|
||||
* 题目页有两处要用:顶部的课堂条(做完几道、每道的状态),和通过之后的「下一题」。
|
||||
* 原来「下一题」每次通过都自己拉一遍,现在两处读同一份,通过之后由判题那边
|
||||
* (SubmissionEffects)先把这道就地标成做完、再重拉一次。
|
||||
*/
|
||||
export const useLessonStore = defineStore("lesson", () => {
|
||||
const activity = ref<ClassActivity | null>(null)
|
||||
let loadedAt = 0
|
||||
let loading: Promise<void> | null = null
|
||||
/**
|
||||
* 只认最后发出去的那次。进页面那次还在路上时学生就交对了:先就地标成做完、再强制重拉 ——
|
||||
* 先发的那次要是后到,会把「做完」盖回去,「下一题」又指回刚做完的这道
|
||||
*/
|
||||
let sequence = 0
|
||||
|
||||
/** 没拉过或者旧了才拉;force 用在刚通过一道题之后 */
|
||||
function load(force = false) {
|
||||
if (!force && activity.value && Date.now() - loadedAt < STALE_MS) return loading
|
||||
if (!force && loading) return loading
|
||||
const mine = ++sequence
|
||||
loading = getClassActivity()
|
||||
.then((res) => {
|
||||
if (mine !== sequence) return
|
||||
activity.value = res
|
||||
loadedAt = Date.now()
|
||||
})
|
||||
// 拉不到就没有课堂条,不影响做题
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (mine === sequence) loading = null
|
||||
})
|
||||
return loading
|
||||
}
|
||||
|
||||
/** 刚通过:不等重拉,先把这道标成做完,课堂条上的「做完 N/M」马上加一 */
|
||||
function markAccepted(problemDisplayId: string) {
|
||||
const item = find(problemDisplayId)
|
||||
if (item) item.myStatus = "accepted"
|
||||
}
|
||||
|
||||
function find(problemDisplayId: string): ClassActivityProblem | undefined {
|
||||
const id = problemDisplayId.toLowerCase()
|
||||
return activity.value?.problems.find((item) => item.problemDisplayId.toLowerCase() === id)
|
||||
}
|
||||
|
||||
/** 这道题在不在这节课里;不在(学生在做别的)就没有课堂条、也没有「下一题」 */
|
||||
function includes(problemDisplayId: string) {
|
||||
return !!find(problemDisplayId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过这道之后该去哪一道:按列表顺序排在这道**后面**、还没通过的第一道,后面都做完了
|
||||
* 再从头找 —— 学生常常跳着做,先做完了第 3 道再回头做第 1 道。当前这道本身不看它的
|
||||
* myStatus:重拉回来之前,它还是通过之前那一刻的状态。
|
||||
* 这道不在这节课里返回 null;在,但别的都做完了,next 为 null。
|
||||
*/
|
||||
function nextAfter(problemDisplayId: string) {
|
||||
const list = activity.value?.problems ?? []
|
||||
const id = problemDisplayId.toLowerCase()
|
||||
const index = list.findIndex((item) => item.problemDisplayId.toLowerCase() === id)
|
||||
if (index < 0) return null
|
||||
const rest = [...list.slice(index + 1), ...list.slice(0, index)].filter(
|
||||
(item) => item.myStatus !== "accepted",
|
||||
)
|
||||
return { total: list.length, next: rest[0] ?? null, remaining: rest.length }
|
||||
}
|
||||
|
||||
/** 老师布置时选的语言,给界面写「C语言」;没选(推断出来的课、以前布置的)为空 */
|
||||
const languageText = computed(() => {
|
||||
const language = activity.value?.language
|
||||
return language ? LANGUAGE_SHOW_VALUE[language] : ""
|
||||
})
|
||||
|
||||
/** 老师布置的叫「老师布置的题」(选了语言带上语言),推断出来的叫「这节课的题」 */
|
||||
const label = computed(() =>
|
||||
activity.value?.source === "teacher"
|
||||
? `老师布置的题${languageText.value ? ` · ${languageText.value}` : ""}`
|
||||
: "这节课的题",
|
||||
)
|
||||
|
||||
return { activity, label, languageText, load, markAccepted, includes, nextAfter }
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { explainCCompileError } from "oj/problem/utils/cError"
|
||||
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
|
||||
import { explainPythonCompileError } from "utils/pythonError"
|
||||
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import type { Submission } from "utils/types"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { getACRate } from "utils/functions"
|
||||
import type { ProblemRow, ProblemListItem } from "utils/types"
|
||||
|
||||
// 把后端的列表项塑形成列表页需要的形状,与请求逻辑解耦。
|
||||
export function toProblemRow(result: ProblemListItem): ProblemRow {
|
||||
return {
|
||||
id: result.id,
|
||||
_id: result._id,
|
||||
title: result.title,
|
||||
difficulty: result.difficulty ? DIFFICULTY[result.difficulty] : null,
|
||||
tags: result.tags,
|
||||
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",
|
||||
author: result.createdBy.username,
|
||||
allowFlowchart: result.allowFlowchart,
|
||||
showFlowchart: result.showFlowchart,
|
||||
hasAstRules: result.hasAstRules,
|
||||
isSql: result.isSql,
|
||||
solvedUsers: result.solvedUsers,
|
||||
triedUsers: result.triedUsers,
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { resetAvatar, uploadAvatar } from "oj/api"
|
||||
import RankAvatar from "oj/rank/components/RankAvatar.vue"
|
||||
import RankAvatar from "shared/components/RankAvatar.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MOOD_MAX, STUDENT_ROLES, type ProfileOverview } from "@oj2/contract"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { clearMood, setRankHidden, updateProfile } from "oj/api"
|
||||
import RankAvatar from "oj/rank/components/RankAvatar.vue"
|
||||
import RankAvatar from "shared/components/RankAvatar.vue"
|
||||
import { classLabel } from "utils/classes"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
Reference in new issue
Block a user