中职学生看不懂英文报错。原来提交前用浏览器里的 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
@@ -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"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user