中职学生看不懂英文报错。原来提交前用浏览器里的 Skulpt 拦语法错误,只弹一句 「第 N 行存在语法错误」——Skulpt 九成报的是 bad input / bad token,没有列号。 - 提交前的语法检查挪到服务端:/code/format 先用 CPython 编译一遍(只编译不执行), api 镜像加 python3-minimal(3.13,与判题机同版本,+17MB),前端删掉 Skulpt - 报错按规则表翻成中文(pythonError.ts),生产库 2695 条 Python 编译错误覆盖 99.7%; 提交前拦下的和判题回来的编译错误共用同一张卡片 - 编辑器里标红出错行和 ^ 指的位置 - 中文标点(历史编译错误的 22%)一键换成英文,跳过字符串和注释、处理 f 字符串花括号; 594 条里修好 593 条,经 tokenize 核对没有改到字符串内容;走编辑器事务,可 Ctrl+Z Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
039992dc86
commit
2a4405d2ea
16 files changed
+733
-100
No files matched your search
@@ -87,6 +87,7 @@ const envSchema = z.object({
|
||||
AI_HINT_DIAGNOSE: z.preprocess(blankAsUnset, z.enum(["1", "0"]).optional()),
|
||||
RUFF_PATH: text("ruff"),
|
||||
CLANG_FORMAT_PATH: text("clang-format"),
|
||||
PYTHON_PATH: text("python3"),
|
||||
})
|
||||
|
||||
function parseEnv() {
|
||||
@@ -171,4 +172,6 @@ export const config = {
|
||||
aiHintDiagnose: env.AI_HINT_DIAGNOSE === "1",
|
||||
ruffPath: env.RUFF_PATH,
|
||||
clangFormatPath: env.CLANG_FORMAT_PATH,
|
||||
/** 提交前的 Python 语法检查用(services/format-code.ts),要和判题机同一个大版本 */
|
||||
pythonPath: env.PYTHON_PATH,
|
||||
}
|
||||
@@ -239,7 +239,8 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => {
|
||||
return failure(
|
||||
c,
|
||||
error.kind === "syntax" ? 400 : 500,
|
||||
error.kind === "syntax" ? "format-error" : "format-tool-error",
|
||||
// syntax-error 的 message 是 CPython 的报错原文,前端拿去翻成中文
|
||||
error.kind === "syntax" ? "syntax-error" : "format-tool-error",
|
||||
error.message,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,16 +47,55 @@ function formatSql(code: string) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 CPython 编译一遍(只编译不执行),有语法错误就返回报错原文,没有返回 null。
|
||||
*
|
||||
* 这一步原来在浏览器里用 Skulpt 做,但 Skulpt 的报错九成是「bad input」「bad token」、
|
||||
* 只有行号没有列号,前端没法据此告诉学生错在哪。换成和判题机同版本的 CPython 之后,
|
||||
* 报错和判题机的编译错误一字不差,前端那张中文翻译表(pythonError.ts)两边通用。
|
||||
*
|
||||
* 输出刻意对齐判题机的 `err_info`:`format_exception_only` 再整体 strip,文件名也叫
|
||||
* solution.py。子进程里不 import 标准库之外的东西,python3-minimal 就够。
|
||||
*
|
||||
* 解释器起不来(开发机没装、路径配错)就当没检查,返回 undefined —— 放行之后判题机
|
||||
* 照样会判出编译错误,只是慢几秒。
|
||||
*/
|
||||
const SYNTAX_CHECK_SCRIPT = `
|
||||
import sys, traceback
|
||||
source = sys.stdin.buffer.read().decode("utf-8", "replace")
|
||||
try:
|
||||
compile(source, "solution.py", "exec")
|
||||
except SyntaxError as error:
|
||||
sys.stdout.write("".join(traceback.format_exception_only(error)).strip())
|
||||
sys.exit(1)
|
||||
`
|
||||
|
||||
async function checkPythonSyntax(code: string): Promise<string | null | undefined> {
|
||||
let result: Awaited<ReturnType<typeof runFormatter>>
|
||||
try {
|
||||
result = await runFormatter([config.pythonPath, "-I", "-c", SYNTAX_CHECK_SCRIPT], code)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (result.exitCode === 0) return null
|
||||
// 退出码 1 且有输出才是语法错误;别的(被超时杀掉、解释器自己报错)都当没检查
|
||||
return result.exitCode === 1 && result.stdout ? result.stdout : undefined
|
||||
}
|
||||
|
||||
export async function formatCode(code: string, language: "python" | "c" | "cpp" | "sql") {
|
||||
if (language === "sql") return formatSql(code)
|
||||
|
||||
if (language === "python") {
|
||||
const syntaxError = await checkPythonSyntax(code)
|
||||
if (syntaxError) throw new CodeFormatError(syntaxError, "syntax")
|
||||
const result = await runFormatter(
|
||||
[config.ruffPath, "format", "--stdin-filename", "code.py", "-"],
|
||||
code,
|
||||
)
|
||||
// CPython 都编译过了 ruff 还失败,是 ruff 的问题(比如还不认识新语法),
|
||||
// 不是学生的:按工具故障报,前端会原样提交
|
||||
if (result.exitCode !== 0) {
|
||||
throw new CodeFormatError(result.stderr || "Invalid Python syntax", "syntax")
|
||||
throw new CodeFormatError(result.stderr || "ruff format failed", "tool")
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"normalize.css": "^8.0.1",
|
||||
"pinia": "^4.0.3",
|
||||
"pinyin-pro": "^3.29.3",
|
||||
"skulpt": "^1.2.0",
|
||||
"vue": "^3.5.41",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import storage from "utils/storage"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
|
||||
import { errorMarkExtensions } from "oj/problem/utils/errorMark"
|
||||
import EditorToolbar from "./EditorToolbar.vue"
|
||||
|
||||
const FlowchartEditor = defineAsyncComponent(
|
||||
@@ -79,6 +80,9 @@ watch(
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
// 拼一次、留同一个数组:每次渲染新建会让编辑器反复重配扩展
|
||||
const editorExtensions = [...editTraceExtensions, ...errorMarkExtensions]
|
||||
|
||||
const contestID = route.params.contestID || null
|
||||
const storageKey = computed(
|
||||
() => `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
@@ -146,7 +150,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
||||
:language="codeStore.code.language"
|
||||
:problem-id="problem!._id"
|
||||
:height="editorHeight"
|
||||
:extra-extensions="editTraceExtensions"
|
||||
:extra-extensions="editorExtensions"
|
||||
@update:model-value="changeCode"
|
||||
/>
|
||||
</n-flex>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { findChinesePunctuation } from "oj/problem/utils/chinesePunctuation"
|
||||
import { applyFixes, clearErrorMark, currentCode, showErrorMark } from "oj/problem/utils/errorMark"
|
||||
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
|
||||
|
||||
/**
|
||||
* Python 语法错误的中文说明卡片。两处用它:提交前的语法检查(SubmitCode,报错来自
|
||||
* 服务端的 CPython)和判题回来的编译错误(SubmissionResult,来自判题机)。两边是
|
||||
* 同一个大版本的 CPython,报错原文一样,所以共用一张翻译表。
|
||||
*
|
||||
* 挂上就在编辑器里标出位置,卸掉就清掉。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** CPython 的报错原文(判题机的 `err_info` 或 `/code/format` 的 syntax-error) */
|
||||
errInfo: string
|
||||
}>()
|
||||
|
||||
const codeStore = useCodeStore()
|
||||
|
||||
const explanation = computed(() => explainPythonCompileError(props.errInfo))
|
||||
|
||||
/** 判题机的临时目录名(`/judger/run/<32 位随机串>/`)对学生没有意义,只剩文件名 */
|
||||
const rawError = computed(() => props.errInfo.replace(/\/judger\/run\/[^/"]+\//g, ""))
|
||||
|
||||
/** 出错那一行拆成三段,中间那段是 `^` 标的地方 */
|
||||
const sourceParts = computed(() => {
|
||||
const ex = explanation.value
|
||||
if (!ex?.sourceLine) return null
|
||||
const { sourceLine: text, caret } = ex
|
||||
if (!caret) return { before: text, marked: "", after: "" }
|
||||
return {
|
||||
before: text.slice(0, caret.from),
|
||||
marked: text.slice(caret.from, caret.to),
|
||||
after: text.slice(caret.to),
|
||||
}
|
||||
})
|
||||
|
||||
// 数的是编辑器里**现在**的代码:学生可能已经自己改掉几处了
|
||||
const punctuationFixCount = computed(() =>
|
||||
explanation.value?.punctuation ? findChinesePunctuation(codeStore.code.value).length : 0,
|
||||
)
|
||||
const fixedCount = ref<number | null>(null)
|
||||
|
||||
function fixPunctuation() {
|
||||
const code = currentCode()
|
||||
if (code === null) return
|
||||
const fixes = findChinesePunctuation(code)
|
||||
if (applyFixes(fixes)) fixedCount.value = fixes.length
|
||||
}
|
||||
|
||||
watch(
|
||||
explanation,
|
||||
(ex) => {
|
||||
fixedCount.value = null
|
||||
if (ex?.line) showErrorMark(ex.line, ex.sourceLine, ex.caret)
|
||||
else clearErrorMark()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(clearErrorMark)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card embedded>
|
||||
<n-flex vertical :size="12">
|
||||
<template v-if="explanation">
|
||||
<div class="explain">
|
||||
<b v-if="explanation.line">第 {{ explanation.line }} 行:</b>{{ explanation.message }}
|
||||
</div>
|
||||
<pre v-if="sourceParts" class="explain-code">{{ sourceParts.before
|
||||
}}<mark>{{ sourceParts.marked }}</mark>{{ sourceParts.after }}</pre>
|
||||
<n-flex v-if="explanation.punctuation" align="center">
|
||||
<n-button v-if="punctuationFixCount" type="primary" size="small" @click="fixPunctuation">
|
||||
把中文标点都换成英文({{ punctuationFixCount }} 处)
|
||||
</n-button>
|
||||
<n-text v-else-if="fixedCount" type="success">
|
||||
换好了 {{ fixedCount }} 处,再提交一次试试。
|
||||
</n-text>
|
||||
</n-flex>
|
||||
<n-collapse>
|
||||
<n-collapse-item title="原始报错(英文)" name="raw">
|
||||
<div class="raw">{{ rawError }}</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</template>
|
||||
<!-- 翻译表没覆盖到的句式(生产数据里约 0.3%):照旧给原文 -->
|
||||
<template v-else>
|
||||
<div class="explain">请仔细检查,看看代码的格式是不是写错了!</div>
|
||||
<div class="raw">{{ rawError }}</div>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.explain {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.explain-code {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.explain-code mark {
|
||||
color: inherit;
|
||||
background-color: rgba(208, 48, 80, 0.25);
|
||||
text-decoration: underline wavy #d03050;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.raw {
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type { Submission } from "utils/types"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import PythonErrorExplain from "./PythonErrorExplain.vue"
|
||||
import { useAIStream } from "shared/composables/aiStream"
|
||||
import { submitHintFeedback } from "oj/api"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
@@ -84,9 +85,27 @@ function resetHint() {
|
||||
|
||||
onUnmounted(stopTyping)
|
||||
|
||||
/** 判题机的临时目录名(`/judger/run/<32 位随机串>/`)对学生没有意义,只剩文件名 */
|
||||
function stripJudgePath(text: string) {
|
||||
return text.replace(/\/judger\/run\/[^/"]+\//g, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Python 的编译错误交给 PythonErrorExplain 翻成中文。只管 Python:C / C++ 的
|
||||
* gcc 报错另是一套句式,还没做。
|
||||
*/
|
||||
const pythonCompileError = computed(() => {
|
||||
const submission = props.submission
|
||||
if (!submission || submission.result !== SubmissionStatus.compile_error) return ""
|
||||
if (submission.language !== "Python") return ""
|
||||
return submission.statisticInfo?.err_info ?? ""
|
||||
})
|
||||
|
||||
// 错误信息格式化
|
||||
const msg = computed(() => {
|
||||
if (!props.submission) return ""
|
||||
// 走 PythonErrorExplain 那张中文卡片,英文原文在它的折叠区里
|
||||
if (pythonCompileError.value) return ""
|
||||
|
||||
let msg = ""
|
||||
const result = props.submission.result
|
||||
@@ -101,7 +120,7 @@ const msg = computed(() => {
|
||||
}
|
||||
|
||||
if (result !== SubmissionStatus.ast_check_failed && props.submission.statisticInfo?.err_info) {
|
||||
msg += props.submission.statisticInfo.err_info
|
||||
msg += stripJudgePath(props.submission.statisticInfo.err_info)
|
||||
}
|
||||
|
||||
return msg
|
||||
@@ -249,8 +268,14 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||
</n-alert>
|
||||
<n-flex
|
||||
vertical
|
||||
v-if="msg || infoTable.length || submission.statisticInfo?.ast_results?.length"
|
||||
v-if="
|
||||
pythonCompileError ||
|
||||
msg ||
|
||||
infoTable.length ||
|
||||
submission.statisticInfo?.ast_results?.length
|
||||
"
|
||||
>
|
||||
<PythonErrorExplain v-if="pythonCompileError" :err-info="pythonCompileError" />
|
||||
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
|
||||
<n-flex vertical :size="8">
|
||||
<n-flex
|
||||
|
||||
@@ -14,13 +14,13 @@ import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { restartEditTrace, snapshotEditTrace } from "oj/problem/utils/editTrace"
|
||||
import { checkPythonSyntax, prefetchPythonSyntaxChecker } from "oj/problem/utils/pythonSyntaxCheck"
|
||||
|
||||
// ==================== 异步组件 ====================
|
||||
const ProblemReaction = defineAsyncComponent(() => import("./ProblemReaction.vue"))
|
||||
// 结果面板第一次弹出(也就是第一次提交)时才加载:它带着 DataTable,而判题要等
|
||||
// 好几秒,这点下载时间藏得住。进页面就加载的话,只看题不提交的人也要付这笔
|
||||
const SubmissionResult = defineAsyncComponent(() => import("./SubmissionResult.vue"))
|
||||
const PythonErrorExplain = defineAsyncComponent(() => import("./PythonErrorExplain.vue"))
|
||||
|
||||
// ==================== 基础状态 ====================
|
||||
const userStore = useUserStore()
|
||||
@@ -34,7 +34,6 @@ const problemSetId = (route.params.problemSetId as string) ?? ""
|
||||
|
||||
const router = useRouter()
|
||||
const [commentPanel] = useToggle()
|
||||
const message = useMessage()
|
||||
|
||||
function closeCommentPanel() {
|
||||
commentPanel.value = false
|
||||
@@ -52,19 +51,14 @@ const showResult = ref(false)
|
||||
const isFormatting = ref(false)
|
||||
const isSubmittingRequest = ref(false)
|
||||
|
||||
// ==================== Python 语法检测器预取 ====================
|
||||
// 选中 Python 时就把 Skulpt 拉下来,避免点提交时才开始下载。
|
||||
// 但它有 ~226KB gzip,比题面和编辑器加起来还大:没登录的提交不了,不拉;
|
||||
// 登录了也等浏览器空闲再拉,别和首屏的题面、编辑器抢带宽
|
||||
watch(
|
||||
() => codeStore.code.language === "Python" && userStore.isAuthed,
|
||||
(needed) => {
|
||||
if (!needed) return
|
||||
if ("requestIdleCallback" in window) requestIdleCallback(prefetchPythonSyntaxChecker)
|
||||
else setTimeout(prefetchPythonSyntaxChecker, 1000)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
/**
|
||||
* 提交前语法检查查出来的错误(CPython 的报错原文),有它时结果面板显示中文说明、
|
||||
* 不显示上一次的判题结果。这次没交上去,所以不是一条提交、也不数进失败次数。
|
||||
*
|
||||
* 检查在服务端做(`/code/format` 里先用 CPython 编译一遍),原来是浏览器里的 Skulpt:
|
||||
* 那个只能报「第 N 行有错」,而且要下载 ~226KB。
|
||||
*/
|
||||
const syntaxErrorInfo = ref("")
|
||||
|
||||
// ==================== 提交冷却 ====================
|
||||
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
|
||||
@@ -115,16 +109,10 @@ const buttonState = computed(() =>
|
||||
async function submit() {
|
||||
if (buttonState.value.disabled) return
|
||||
|
||||
// 0. Python 语法检测
|
||||
if (codeStore.code.language === "Python") {
|
||||
const syntaxError = await checkPythonSyntax(codeStore.code.value)
|
||||
if (syntaxError) {
|
||||
message.warning(`第 ${syntaxError.line} 行存在语法错误,请修正后再提交`)
|
||||
return
|
||||
}
|
||||
}
|
||||
syntaxErrorInfo.value = ""
|
||||
|
||||
// 0.5 提交前自动格式化(Python 用 ruff,C/C++ 用 clang-format,SQL 用 sqlparse)
|
||||
// 0. 提交前自动格式化(Python 用 ruff,C/C++ 用 clang-format,SQL 用 sqlparse)。
|
||||
// Python 在格式化之前先由服务端的 CPython 查一遍语法,有错就不提交
|
||||
const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
|
||||
if (["python", "c", "cpp", "sql"].includes(formatLang)) {
|
||||
isFormatting.value = true
|
||||
@@ -135,9 +123,10 @@ async function submit() {
|
||||
})
|
||||
codeStore.setCode(res.code)
|
||||
} catch (e) {
|
||||
if (errorCode(e) === "format-error") {
|
||||
// 仅 Python 会出现:代码本身存在语法错误
|
||||
message.warning(`代码格式化失败:${errorMessage(e)},请检查代码后重试`)
|
||||
if (errorCode(e) === "syntax-error") {
|
||||
// 仅 Python 会出现:message 是 CPython 的报错原文,交给 PythonErrorExplain 翻译
|
||||
syntaxErrorInfo.value = errorMessage(e)
|
||||
showResult.value = true
|
||||
return
|
||||
}
|
||||
// server-error / 网络异常:格式化工具问题,静默降级,提交原代码
|
||||
@@ -268,15 +257,19 @@ watch(
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<!-- 结果展示 -->
|
||||
<SubmissionResult :submission="submission" />
|
||||
<!-- 结果展示。SubmissionResult 用 v-show 留着:它身上可能挂着正在生成的 AI 提示 -->
|
||||
<n-flex v-if="syntaxErrorInfo" vertical style="max-width: 560px">
|
||||
<n-alert type="warning" title="代码有语法错误,还没有提交" />
|
||||
<PythonErrorExplain :err-info="syntaxErrorInfo" />
|
||||
</n-flex>
|
||||
<SubmissionResult v-show="!syntaxErrorInfo" :submission="submission" />
|
||||
</n-popover>
|
||||
|
||||
<!-- 结果面板点一下别处就收起来,而 showResult 只在提交时被置 true ——
|
||||
原来唯一的重开方式是「再提交一次」,AI 提示读到一半去看眼题面就回不来了。
|
||||
只在这次会话提交过之后才出现,没提交时工具栏保持原样。 -->
|
||||
<n-button
|
||||
v-if="submission && !showResult"
|
||||
v-if="(submission || syntaxErrorInfo) && !showResult"
|
||||
:size="isDesktop ? 'medium' : 'small'"
|
||||
@click="showResult = true"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PUNCTUATION_MAP } from "./pythonError"
|
||||
|
||||
export interface PunctuationFix {
|
||||
from: number
|
||||
to: number
|
||||
insert: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 找出 Python 代码里**字符串和注释之外**的中文标点,给出换成英文的改动。
|
||||
*
|
||||
* 字符串里的中文标点是合法的,`print("你好,世界")` 里那个逗号动了就改了输出,
|
||||
* 所以不能整篇替换。中文引号「“ ”」另算:它们出现在代码里,本身就是没切输入法
|
||||
* 打出来的字符串引号,要换成英文引号,而**它们中间的内容原样保留** —— 否则
|
||||
* `print(“你好,世界”)` 会被改成 `print("你好,世界")`,输出跟着变了。
|
||||
*
|
||||
* f 字符串花括号里的是代码不是文字(`f"{s:.2f}"`、`f"{len(a)}"`),照样要换;
|
||||
* `{{` 是转义出来的字面花括号,不算。拿生产库 591 条中文标点的编译错误核过:
|
||||
* 修完之后报错点名的那个字符还在的只剩 1 条,是学生自己的引号就是乱的。
|
||||
*
|
||||
* 不是完整的词法分析:只认引号、三引号、字符串前缀、反斜杠转义和 `#` 注释,
|
||||
* 这对学生代码够用。单引号字符串遇到换行就当它结束,免得一个没配对的引号把
|
||||
* 后面整篇都当成字符串。
|
||||
*/
|
||||
export function findChinesePunctuation(code: string): PunctuationFix[] {
|
||||
const fixes: PunctuationFix[] = []
|
||||
|
||||
function fix(at: number) {
|
||||
const english = PUNCTUATION_MAP[code[at]!]
|
||||
if (english) fixes.push({ from: at, to: at + 1, insert: english })
|
||||
}
|
||||
|
||||
/** 从 start 往后扫一个字符串的内容,返回结束位置(收尾引号之后) */
|
||||
function skipString(start: number, triple: boolean, fstring: boolean, closers: string[]) {
|
||||
let depth = 0
|
||||
let j = start
|
||||
while (j < code.length) {
|
||||
const ch = code[j]!
|
||||
if (ch === "\\") {
|
||||
j += 2
|
||||
continue
|
||||
}
|
||||
if (!triple && ch === "\n") return j
|
||||
if (fstring) {
|
||||
if (ch === "{" && depth === 0 && code[j + 1] === "{") {
|
||||
j += 2
|
||||
continue
|
||||
}
|
||||
if (ch === "{") depth++
|
||||
else if (ch === "}" && depth > 0) depth--
|
||||
else if (depth > 0) fix(j)
|
||||
if (depth > 0 || ch === "{" || ch === "}") {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
}
|
||||
const closer = closers.find((c) => code.startsWith(c, j))
|
||||
if (closer) {
|
||||
// 中文收尾引号也要换成英文
|
||||
if (closer.length === 1) fix(j)
|
||||
return j + closer.length
|
||||
}
|
||||
j++
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
let i = 0
|
||||
while (i < code.length) {
|
||||
const ch = code[i]!
|
||||
if (ch === "#") {
|
||||
const end = code.indexOf("\n", i)
|
||||
i = end === -1 ? code.length : end
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
// 前缀是紧挨着引号的 f / r / b / u 组合,前面不能再连着别的标识符字符
|
||||
const prefix = code.slice(0, i).match(/(?<![\w])[rRbBfFuU]{1,2}$/)?.[0] ?? ""
|
||||
const quote = code.startsWith(ch.repeat(3), i) ? ch.repeat(3) : ch
|
||||
i = skipString(i + quote.length, quote.length === 3, /f/i.test(prefix), [quote])
|
||||
continue
|
||||
}
|
||||
if (ch === "“" || ch === "”" || ch === "‘" || ch === "’") {
|
||||
const double = ch === "“" || ch === "”"
|
||||
fix(i)
|
||||
// 收尾可能打成中文也可能打成英文,两种都认
|
||||
i = skipString(i + 1, false, false, double ? ["”", "“", '"'] : ["’", "‘", "'"])
|
||||
continue
|
||||
}
|
||||
fix(i)
|
||||
i++
|
||||
}
|
||||
return fixes
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { StateEffect, StateField, type EditorState } from "@codemirror/state"
|
||||
import { Decoration, EditorView, ViewPlugin, type DecorationSet } from "@codemirror/view"
|
||||
import type { PunctuationFix } from "./chinesePunctuation"
|
||||
|
||||
/**
|
||||
* 在题目页的编辑器里标出编译错误的位置:整行淡红底,`^` 标的那一段加红波浪线。
|
||||
*
|
||||
* 中职学生读不懂英文报错,「看这里」比任何文字都直接。标记跟着编辑映射位置
|
||||
* (在上面插一行,标记跟着往下走),下一次提交时清掉。
|
||||
*/
|
||||
|
||||
interface Mark {
|
||||
line: number
|
||||
/** 行内列号,[from, to);没有就只标整行 */
|
||||
from?: number
|
||||
to?: number
|
||||
}
|
||||
|
||||
const setMark = StateEffect.define<Mark | null>()
|
||||
|
||||
const lineDeco = Decoration.line({ class: "cm-error-line" })
|
||||
const rangeDeco = Decoration.mark({ class: "cm-error-range" })
|
||||
|
||||
function build(state: EditorState, mark: Mark): DecorationSet {
|
||||
if (mark.line < 1 || mark.line > state.doc.lines) return Decoration.none
|
||||
const line = state.doc.line(mark.line)
|
||||
const ranges = [lineDeco.range(line.from)]
|
||||
if (mark.from !== undefined && mark.to !== undefined) {
|
||||
const from = line.from + Math.min(mark.from, line.length)
|
||||
const to = line.from + Math.min(mark.to, line.length)
|
||||
if (to > from) ranges.push(rangeDeco.range(from, to))
|
||||
}
|
||||
return Decoration.set(ranges)
|
||||
}
|
||||
|
||||
const markField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(deco, tr) {
|
||||
deco = deco.map(tr.changes)
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setMark)) deco = effect.value ? build(tr.state, effect.value) : Decoration.none
|
||||
}
|
||||
return deco
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
})
|
||||
|
||||
/**
|
||||
* 题目页当前那个编辑器。结果面板和编辑器是兄弟组件,隔着好几层,
|
||||
* 与其一路透传 ref,不如让编辑器挂载时自己登记。只挂在题目页主编辑器上
|
||||
* (ProblemEditor 的扩展数组),自测、流程图那些编辑器不登记。
|
||||
*/
|
||||
let current: EditorView | null = null
|
||||
|
||||
const tracker = ViewPlugin.define((view) => {
|
||||
current = view
|
||||
return {
|
||||
destroy() {
|
||||
if (current === view) current = null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const theme = EditorView.baseTheme({
|
||||
".cm-error-line": { backgroundColor: "rgba(208, 48, 80, 0.12)" },
|
||||
".cm-error-range": {
|
||||
textDecoration: "underline wavy #d03050",
|
||||
textUnderlineOffset: "3px",
|
||||
backgroundColor: "rgba(208, 48, 80, 0.18)",
|
||||
},
|
||||
})
|
||||
|
||||
export const errorMarkExtensions = [markField, tracker, theme]
|
||||
|
||||
/**
|
||||
* 标出第 `line` 行并滚过去。`sourceLine` 是报错里打出来的那一行(去了行首空格),
|
||||
* 编辑器里这一行对得上才标列范围 —— 学生看结果之前可能已经改过代码了,
|
||||
* 对不上时列号没有意义,只标整行。
|
||||
*/
|
||||
export function showErrorMark(
|
||||
line: number,
|
||||
sourceLine: string | null,
|
||||
caret: { from: number; to: number } | null,
|
||||
) {
|
||||
const view = current
|
||||
if (!view || line < 1 || line > view.state.doc.lines) return
|
||||
const text = view.state.doc.line(line).text
|
||||
const mark: Mark = { line }
|
||||
if (caret && sourceLine !== null) {
|
||||
const stripped = text.replace(/^ +/, "")
|
||||
if (stripped.trimEnd() === sourceLine.trimEnd()) {
|
||||
const indent = text.length - stripped.length
|
||||
mark.from = indent + caret.from
|
||||
mark.to = indent + caret.to
|
||||
}
|
||||
}
|
||||
const pos = view.state.doc.line(line).from
|
||||
view.dispatch({
|
||||
effects: [setMark.of(mark), EditorView.scrollIntoView(pos, { y: "center" })],
|
||||
})
|
||||
}
|
||||
|
||||
export function clearErrorMark() {
|
||||
current?.dispatch({ effects: setMark.of(null) })
|
||||
}
|
||||
|
||||
/** 当前编辑器里的代码;编辑器没挂载时为 null */
|
||||
export function currentCode() {
|
||||
return current?.state.doc.toString() ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 在编辑器里批量替换。走编辑器事务而不是直接改 store 里的字符串:
|
||||
* 这样一次 Ctrl+Z 就能撤回,协作中的对方也能同步到。
|
||||
*
|
||||
* 不打 userEvent:editTrace 按 `isUserEvent("input")` 数「自己敲的字」,
|
||||
* 打成 `input.*` 就会把这次替换算成学生手打的。
|
||||
*/
|
||||
export function applyFixes(fixes: PunctuationFix[]) {
|
||||
if (!current || !fixes.length) return false
|
||||
current.dispatch({ changes: fixes, effects: setMark.of(null) })
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 把 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
|
||||
}
|
||||
|
||||
const WHERE_RE = /^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]: " ",
|
||||
}
|
||||
|
||||
/**
|
||||
* 标点的名字。说明里**只说名字、不拿字形对比**:「:」和「:」、「,」和「,」在正文字体里
|
||||
* 几乎一样,「把「:」换成「:」」这句话学生看不出区别。「冒号是中文的,要换成英文的」
|
||||
* 看得懂,要换哪一个由卡片里那行代码的标红来指。
|
||||
*/
|
||||
const PUNCTUATION_NAMES: Record<string, string> = {
|
||||
"(": "左括号",
|
||||
")": "右括号",
|
||||
",": "逗号",
|
||||
":": "冒号",
|
||||
";": "分号",
|
||||
"【": "左方括号",
|
||||
"】": "右方括号",
|
||||
"{": "左花括号",
|
||||
"}": "右花括号",
|
||||
"!": "感叹号",
|
||||
"?": "问号",
|
||||
"=": "等号",
|
||||
"+": "加号",
|
||||
"-": "减号",
|
||||
"*": "星号",
|
||||
"/": "斜杠",
|
||||
"<": "小于号",
|
||||
">": "大于号",
|
||||
"“": "引号",
|
||||
"”": "引号",
|
||||
"‘": "单引号",
|
||||
"’": "单引号",
|
||||
""": "引号",
|
||||
[IDEOGRAPHIC_SPACE]: "空格",
|
||||
}
|
||||
|
||||
type Rule = [RegExp, (match: RegExpMatchArray) => string]
|
||||
|
||||
const RULES: Rule[] = [
|
||||
[
|
||||
/^invalid character '(.)'/,
|
||||
([, char]) => {
|
||||
// 数学符号不是「中文的」,是写法不对:代码里乘除用 * 和 /
|
||||
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 ':'/,
|
||||
() => "这一行末尾少了英文冒号「:」。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\?/,
|
||||
() => "这里好像少了逗号。括号里的几样东西之间,要用英文逗号「,」隔开。",
|
||||
],
|
||||
[
|
||||
/Maybe you meant '==' instead of '='|Maybe you meant '==' or ':=' instead of '='|perhaps you meant "=="/,
|
||||
() =>
|
||||
"判断「是不是相等」要用两个等号「==」。一个等号「=」的意思是「把右边的值存进左边的变量」。",
|
||||
],
|
||||
[
|
||||
/^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/,
|
||||
() =>
|
||||
"标出来的地方写法不对。对照教程检查这一行:括号、引号、冒号、运算符有没有写错、漏写或多写。",
|
||||
],
|
||||
]
|
||||
|
||||
function translate(message: string) {
|
||||
for (const [pattern, render] of RULES) {
|
||||
const match = message.match(pattern)
|
||||
if (match) return render(match)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 翻不出来(判题机自己的报错、没见过的句式)就返回 null,界面照旧显示原文 */
|
||||
export function explainPythonCompileError(errInfo: string): 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
|
||||
const message = translate(detail)
|
||||
if (!message) 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 char = detail.match(/^invalid character '(.)'/)?.[1]
|
||||
return {
|
||||
line: where ? Number(where[1]) : null,
|
||||
sourceLine,
|
||||
caret,
|
||||
message,
|
||||
punctuation: char && PUNCTUATION_MAP[char] ? char : null,
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
export interface PythonSyntaxError {
|
||||
line: number
|
||||
}
|
||||
|
||||
let skulptPromise: Promise<any> | null = null
|
||||
|
||||
/**
|
||||
* 按需加载 Skulpt(约 233KB gzip),只在提交 Python 代码时才下载。
|
||||
* 结果缓存,同一页面只加载一次。
|
||||
*/
|
||||
function loadSkulpt(): Promise<any> {
|
||||
if (!skulptPromise) {
|
||||
// @ts-ignore - skulpt has no type definitions
|
||||
skulptPromise = import("skulpt").then((m) => m.default ?? m)
|
||||
}
|
||||
return skulptPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 提前把 Skulpt 拉下来,避免点提交时才开始下载。
|
||||
* 失败不影响功能,提交时会再试一次。
|
||||
*/
|
||||
export function prefetchPythonSyntaxChecker() {
|
||||
loadSkulpt().catch(() => {
|
||||
skulptPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 Skulpt 检测 Python 代码中的语法错误。
|
||||
* 只编译不执行,不受 input() 等 IO 调用影响。
|
||||
* 加载失败时返回 null(放行提交),交给后端判题兜底。
|
||||
*/
|
||||
export async function checkPythonSyntax(code: string): Promise<PythonSyntaxError | null> {
|
||||
let Sk: any
|
||||
try {
|
||||
Sk = await loadSkulpt()
|
||||
} catch {
|
||||
skulptPromise = null
|
||||
return null
|
||||
}
|
||||
|
||||
Sk.configure({ output: () => {} })
|
||||
try {
|
||||
Sk.compile(code, "prog.py", "exec")
|
||||
return null
|
||||
} catch (e) {
|
||||
// Skulpt 的语法错误对象,不是 Error 的子类
|
||||
const traceback = (e as { traceback?: { lineno?: number }[] }).traceback
|
||||
const line = traceback?.[0]?.lineno ?? 1
|
||||
return { line }
|
||||
}
|
||||
}
|
||||
@@ -136,10 +136,10 @@ export default defineConfig(() => {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
build: {
|
||||
// 超过 500 kB 的三个 chunk 都是懒加载的:skulpt(Python 语法检查,产物名叫
|
||||
// main-*.js,是 skulpt 自己入口的名字,不是应用入口)、wangeditor(后台富文本)、
|
||||
// @mermaid-js/parser。默认阈值下这条警告每次构建都在报,真涨了反而没人看,
|
||||
// 所以抬到比它们略高。首屏入口 index-*.js 在 175 kB 左右
|
||||
// 超过 500 kB 的两个 chunk 都是懒加载的:wangeditor(后台富文本,TextEditor-*.js)、
|
||||
// @mermaid-js/parser,各 660 kB 上下。默认阈值下这条警告每次构建都在报,真涨了
|
||||
// 反而没人看,所以抬到比它们略高。首屏入口 index-*.js 在 175 kB 左右。
|
||||
// (原来还有 Python 语法检查用的 skulpt,语法检查挪到服务端的 CPython 之后删了)
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
server: {
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
"normalize.css": "^8.0.1",
|
||||
"pinia": "^4.0.3",
|
||||
"pinyin-pro": "^3.29.3",
|
||||
"skulpt": "^1.2.0",
|
||||
"vue": "^3.5.41",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
@@ -1295,8 +1294,6 @@
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmjs.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsbi": ["jsbi@3.2.5", "https://registry.npmjs.com/jsbi/-/jsbi-3.2.5.tgz", {}, "sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "https://registry.npmjs.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "https://registry.npmjs.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
@@ -1525,8 +1522,6 @@
|
||||
|
||||
"sirv": ["sirv@3.0.2", "https://registry.npmjs.com/sirv/-/sirv-3.0.2.tgz", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"skulpt": ["skulpt@1.2.0", "https://registry.npmjs.com/skulpt/-/skulpt-1.2.0.tgz", { "dependencies": { "jsbi": "^3.1.4" } }, "sha512-T0cv0sdSOXLlIJTuyXSeYJ3TFdWYSZfX2PcBLpRKrKZ3dTbVQXRMiYudSuko2xzcyMif47DS2ShatCwjCbsSsA=="],
|
||||
|
||||
"slate": ["slate@0.124.1", "https://registry.npmjs.com/slate/-/slate-0.124.1.tgz", {}, "sha512-ii7DwezgvbLAyKtHBIunjTR1kzbNfYLCUKLMzJELlbTZkvHzX4DzN7HKIwcakf6dPxO6AoeT/P7kHOcyTym/hA=="],
|
||||
|
||||
"slate-history": ["slate-history@0.115.0", "https://registry.npmjs.com/slate-history/-/slate-history-0.115.0.tgz", { "peerDependencies": { "slate": ">=0.114.3" } }, "sha512-QdUm9aVyQFz6JG4a84Z6Um+tJpZJmsh9bjXwTVTvYiN4rdKbqL6+/4HT94on1WYxe10Q4vY6mA6BCpoYxgF3tQ=="],
|
||||
|
||||
+8
-2
@@ -114,6 +114,12 @@ ENV NODE_ENV=production
|
||||
|
||||
# clang-format 给 C 代码格式化用,对应旧镜像 apt 装的那个。
|
||||
#
|
||||
# python3-minimal 给提交前的 Python 语法检查用(services/format-code.ts 的
|
||||
# checkPythonSyntax,只编译不执行)。trixie 自带的是 3.13,和判题机同一个大版本 ——
|
||||
# 这一点是要的:报错原文要和判题机的编译错误一字不差,前端才能用同一张翻译表。
|
||||
# **判题机换 Python 大版本时这里要一起换**(Debian 换代或者改装 python3.x-minimal)。
|
||||
# 实测只让 /usr 多 17MB。
|
||||
#
|
||||
# **不装 ca-certificates。** AI 接口那条 https 出站不需要它 —— Bun 和 Node 一样
|
||||
# 内嵌了一份根证书,走自己那份,不读系统的 `/etc/ssl/certs`。实测:把探针二进制
|
||||
# 丢进裸 debian:trixie-slim(没有 ca-certificates)请求 api.deepseek.com,
|
||||
@@ -133,11 +139,11 @@ ENV NODE_ENV=production
|
||||
ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||
RUN sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends clang-format \
|
||||
&& apt-get install -y --no-install-recommends clang-format python3-minimal \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ruff 给 Python 代码格式化用。旧后端是 pip 装的 ruff==0.15.12,这里取同版本的
|
||||
# 官方静态二进制 —— 不为它引入整个 Python 运行时。
|
||||
# 官方静态二进制(上面那个 python3-minimal 没有 pip,也不该为 ruff 装)。
|
||||
COPY --from=ghcr.io/astral-sh/ruff:0.15.12 /ruff /usr/local/bin/ruff
|
||||
|
||||
COPY --from=artifacts /artifacts/oj2-api /usr/local/bin/oj2-api
|
||||
|
||||
@@ -22,6 +22,11 @@ registry 上的 `oj-image/judge:latest` 和 `:1.6.1` 是同一份镜像(config
|
||||
| base | debian:trixie-slim(2024 年的) | debian:trixie-slim(当前) |
|
||||
| 镜像体积 | 1.1 GB | **433 MB** |
|
||||
|
||||
⚠️ **换 Python 大版本时,api 镜像(`docker/Dockerfile` 的 `python3-minimal`)要一起换。**
|
||||
提交前的语法检查在 api 那边用 CPython 编译一遍,前端再按报错原文翻成中文(翻译表在
|
||||
`apps/web/src/oj/problem/utils/pythonError.ts`)。两边版本不一致的话,同一份代码
|
||||
在提交前和判题时报的句式可能不同,翻译表只能对上其中一边。
|
||||
|
||||
砍语言的依据:前端的题目语言复选框从来只给 Python / C / C++ / SQL,
|
||||
生产库 12 万条提交里 Java 44 条、Golang 15 条、JavaScript 3 条,全是很早以前的。
|
||||
契约 `judgeLanguageSchema` 里那几个键留着(渲染历史提交要用),只是判题机不再认。
|
||||
|
||||
Reference in new issue
Block a user