Deploy / deploy (push) Waiting to run
上游 QingdaoU/JudgeServer 停更在 2024-04(registry 上的 latest 和 1.6.1 是同一份 镜像,编译器停在 gcc-13),没有新版可拉,所以自己重编。docker/judge/ 是只改工具链 的 Dockerfile 分叉,server/ 和 Judger/ 从上游固定 commit b28aa56 拉,一行没动。 镜像 oj2-judge-2(不在任何 registry 上:本机 build.sh --save → scp → docker load) - gcc/g++ 13 → 14.2,Python 3.12 → 3.13.5,都是 trixie 默认 - Go / JDK / Node 整套删掉:前端的题目语言复选框从来只给 C / C++ / Python / SQL, 12 万条提交里 Java 44 条、Golang 15、JavaScript 3,全是很早以前的 - 体积 1.1GB → 433MB;默认走清华源,构建 12 分钟 → 40 秒(--no-mirror 换回官方) - deploy.sh 加一道自检:镜像不在本机就中止,并打印该跑的三条命令 C 的编译参数加三个 -Wno-error(implicit-function-declaration / int-conversion / incompatible-pointer-types):gcc-14 把它们从 warning 提成了 error,而 -w 压不住。 语言值统一成 Python(迁移 0019 / 0020) - 0019:Python3(104527 条提交)与 Python2(3 条)并成 Python,一并改掉 937 道题的 languages、75 个 template 键、15 个 ast_rules 键、257 条 answers、1235 个用户的 成就指标 _languages(languages_used 重算,总和 1928 → 1925,少的 3 个是同时用过 两种 Python 的人) - 0020:把 Java / JavaScript / Golang 从 84 道题的可选语言里摘掉 —— 不摘的话那些题 的语言下拉还能选 Java,提交必 SYSTEM_ERROR - 契约新增 normalizeLanguage() 别名表,判题侧一律走 judgeConfigFor():旧客户端 localStorage 里的 Python3、迁移前排进队列的任务都还能判;协作的语言归一也走它, 否则上线那一刻学生页面里的 Python3 会静默落到 C - 回滚要连数据一起回,只滚代码会让所有 Python 提交变 SYSTEM_ERROR 实跑 - 判题冒烟 docker/judge/smoke.ts 13 条全过:三种语言、六种状态码、gcc 宽松度 - 拿备份里的真实代码逐文件比对新旧镜像的编译结果,0 差异:C 提交 1951 份 (1725 过 / 226 CE)、C++ 882 份、Python 2000 份、20 篇 C 教程的 93 个代码块。 不加那三个 -Wno-error 的话,C 有 26 份会从能过变成 CE - 迁移在灌了 12.4 万行真实数据的一次性库里跑过:0 残留、没有题目被清空; dev 库用真正的执行器跑通 - check:ast 56 个 target 全过,前后端 typecheck 均 0 顺带记下一个升级之前就有的坑(现在随 Go 一起消失,写在 README 里):GOCACHE 指向 容器的 tmpfs,判题机重启后第一次 Go 提交是冷构建,Go 1.22 要 5.6 秒 CPU、超过 3 秒 的编译预算,于是重启后第一个交 Go 的学生必吃一次 CE,后面的人缓存热了又都正常。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
284 lines
7.8 KiB
Vue
284 lines
7.8 KiB
Vue
<script setup lang="ts">
|
||
import type { LANGUAGE, Testcase } from "utils/types"
|
||
import { createZipBlob } from "utils/functions"
|
||
import { createTestSubmission } from "utils/judge"
|
||
import { uploadTestcases } from "../../api"
|
||
|
||
interface FileEntry {
|
||
id: number
|
||
in: string
|
||
out: string
|
||
error: boolean
|
||
}
|
||
|
||
interface Props {
|
||
answers: { language: LANGUAGE; code: string }[]
|
||
samples?: { input: string; output: string }[]
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
const emit = defineEmits<{
|
||
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
|
||
}>()
|
||
|
||
const message = useMessage()
|
||
|
||
let nextId = 0
|
||
|
||
function makeInitialFiles(): FileEntry[] {
|
||
const fromSamples = (props.samples ?? []).map((s) => ({
|
||
id: nextId++,
|
||
in: s.input,
|
||
out: s.output,
|
||
error: false,
|
||
}))
|
||
const total = Math.ceil(Math.max(fromSamples.length, 1) / 5) * 5
|
||
const extra = total - fromSamples.length
|
||
return [
|
||
...fromSamples,
|
||
...Array.from({ length: extra }, () => ({
|
||
id: nextId++,
|
||
in: "",
|
||
out: "",
|
||
error: false,
|
||
})),
|
||
]
|
||
}
|
||
|
||
const files = ref<FileEntry[]>(makeInitialFiles())
|
||
|
||
const selectedLanguage = ref<LANGUAGE>("Python")
|
||
|
||
// 始终显示所有语言,不管有没有答案代码
|
||
const availableLanguages = computed(() =>
|
||
props.answers.map((a) => ({ label: a.language, value: a.language })),
|
||
)
|
||
|
||
const hasAnyAnswerCode = computed(() =>
|
||
props.answers.some((a) => a.code.trim()),
|
||
)
|
||
|
||
// 当前选中语言是否有答案代码(用于控制"先运行"按钮)
|
||
const hasAnswerCode = computed(() => {
|
||
const answer = props.answers.find(
|
||
(a) => a.language === selectedLanguage.value,
|
||
)
|
||
return !!answer?.code.trim()
|
||
})
|
||
|
||
// 当语言列表变化时,确保 selectedLanguage 始终指向一个有效值
|
||
watch(
|
||
availableLanguages,
|
||
(langs) => {
|
||
if (
|
||
langs.length &&
|
||
!langs.find((l) => l.value === selectedLanguage.value)
|
||
) {
|
||
selectedLanguage.value = langs[0].value
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
const isRunning = ref(false)
|
||
const isUploading = ref(false)
|
||
|
||
/**
|
||
* 一个测试点必须有输出,但不一定有输入 —— 打印类题目(输出星号矩形、只靠
|
||
* print 的格式化输出题…)的 `N.in` 本来就该是空文件。
|
||
*
|
||
* 原来「这行算不算数」判的是 `f.in.trim()`,把「这题没有输入」和「这行我还没填」
|
||
* 撞成了同一种状态:输入留空 → 点「先运行」整行被删 → 一行不剩 → 「上传」
|
||
* 永远是灰的。老师只能往输入框里塞个「无」才存得下去,那两个字节就真的进了 stdin。
|
||
*/
|
||
function hasOutput(file: FileEntry) {
|
||
return !!file.out.trim() && !file.error
|
||
}
|
||
|
||
/** 老师碰过这一行没有。面板一开就铺 5 个空行,`+1` / `+5` 还能再加,空行是占位不是测试点 */
|
||
function isFilled(file: FileEntry) {
|
||
return !!(file.in.trim() || file.out.trim())
|
||
}
|
||
|
||
/** 真正会被打进 zip 的行:有输出就算数,输入空不空无所谓 */
|
||
const uploadable = computed(() => files.value.filter(hasOutput))
|
||
|
||
const canUpload = computed(
|
||
() =>
|
||
!isRunning.value &&
|
||
uploadable.value.length > 0 &&
|
||
// 填了输入却没跑出输出:要么还没运行,要么运行炸了,这种状态不许传
|
||
files.value.every((f) => !f.in.trim() || hasOutput(f)),
|
||
)
|
||
|
||
function reset() {
|
||
files.value = Array.from({ length: 5 }, () => ({
|
||
id: nextId++,
|
||
in: "",
|
||
out: "",
|
||
error: false,
|
||
}))
|
||
}
|
||
|
||
function add(n: number) {
|
||
files.value.push(
|
||
...Array.from({ length: n }, () => ({
|
||
id: nextId++,
|
||
in: "",
|
||
out: "",
|
||
error: false,
|
||
})),
|
||
)
|
||
}
|
||
|
||
function remove(index: number) {
|
||
files.value.splice(index, 1)
|
||
}
|
||
|
||
async function run() {
|
||
const answer = props.answers.find(
|
||
(a) => a.language === selectedLanguage.value,
|
||
)
|
||
if (!answer?.code.trim()) return
|
||
|
||
// 过滤没填过的行,去重(按输入内容)。全没填就留一行,当成无输入题跑一次 ——
|
||
// 无输入题只可能有一个测试点,去重那条规则本来也会把多行空输入并成一行。
|
||
const seen = new Set<string>()
|
||
const kept = files.value.filter((f) => {
|
||
if (!isFilled(f)) return false
|
||
if (seen.has(f.in)) return false
|
||
seen.add(f.in)
|
||
return true
|
||
})
|
||
files.value = kept.length > 0 ? kept : files.value.slice(0, 1)
|
||
|
||
// 清空旧输出
|
||
files.value = files.value.map((f) => ({ ...f, out: "", error: false }))
|
||
|
||
isRunning.value = true
|
||
await Promise.all(
|
||
files.value.map(async (_, i) => {
|
||
try {
|
||
const result = await createTestSubmission(
|
||
{ language: selectedLanguage.value, value: answer.code },
|
||
files.value[i].in,
|
||
)
|
||
files.value[i] = {
|
||
...files.value[i],
|
||
out: result.output,
|
||
error: result.status !== 3,
|
||
}
|
||
} catch {
|
||
files.value[i] = { ...files.value[i], out: "", error: true }
|
||
}
|
||
}),
|
||
)
|
||
isRunning.value = false
|
||
}
|
||
|
||
async function upload() {
|
||
isUploading.value = true
|
||
try {
|
||
const data = uploadable.value.flatMap((f, i) => [
|
||
{ name: `${i + 1}.in`, content: f.in },
|
||
{ name: `${i + 1}.out`, content: f.out },
|
||
])
|
||
|
||
const blob = createZipBlob(data)
|
||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||
|
||
const res = await uploadTestcases(file)
|
||
// score 不在上传响应里,是这里按测试点数量平分补上的(余数给最后一个)
|
||
const entries = res.info
|
||
const baseScore = Math.floor(100 / entries.length)
|
||
const remainder = 100 - baseScore * entries.length
|
||
const testcases: Testcase[] = entries.map((entry, i) => ({
|
||
input_name: entry.input_name,
|
||
output_name: entry.output_name,
|
||
score: i === entries.length - 1 ? baseScore + remainder : baseScore,
|
||
}))
|
||
|
||
emit("uploaded", res.id, testcases)
|
||
message.success("上传成功")
|
||
} catch {
|
||
message.error("上传失败")
|
||
} finally {
|
||
isUploading.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<n-flex vertical>
|
||
<n-alert
|
||
v-if="!hasAnyAnswerCode"
|
||
type="warning"
|
||
:show-icon="false"
|
||
style="margin-bottom: 8px"
|
||
>
|
||
还没有填写答案代码,请先在上方"本题参考答案"中填写至少一种语言的答案,再来生成测试用例
|
||
</n-alert>
|
||
<n-flex align="center" wrap>
|
||
<n-select
|
||
style="width: 120px"
|
||
:options="availableLanguages"
|
||
v-model:value="selectedLanguage"
|
||
/>
|
||
<n-button :disabled="isRunning" @click="reset">清空</n-button>
|
||
<n-button :disabled="isRunning" @click="add(1)">+1</n-button>
|
||
<n-button :disabled="isRunning" @click="add(5)">+5</n-button>
|
||
<n-tooltip :disabled="hasAnswerCode">
|
||
<template #trigger>
|
||
<span>
|
||
<n-button
|
||
type="success"
|
||
:loading="isRunning"
|
||
:disabled="!hasAnswerCode"
|
||
@click="run"
|
||
>
|
||
先运行
|
||
</n-button>
|
||
</span>
|
||
</template>
|
||
请先在题目中填写答案代码
|
||
</n-tooltip>
|
||
<n-button
|
||
type="primary"
|
||
:loading="isUploading"
|
||
:disabled="!canUpload"
|
||
@click="upload"
|
||
>
|
||
上传
|
||
</n-button>
|
||
</n-flex>
|
||
|
||
<n-flex
|
||
v-for="(file, index) in files"
|
||
:key="file.id"
|
||
align="start"
|
||
style="gap: 8px"
|
||
>
|
||
<n-flex vertical style="flex: 1">
|
||
<span>{{ index + 1 }}.in</span>
|
||
<n-input type="textarea" v-model:value="file.in" :rows="3" />
|
||
</n-flex>
|
||
<n-flex vertical style="flex: 1">
|
||
<span>{{ index + 1 }}.out</span>
|
||
<n-input
|
||
type="textarea"
|
||
v-model:value="file.out"
|
||
:rows="3"
|
||
:status="file.out ? (file.error ? 'error' : 'success') : undefined"
|
||
/>
|
||
</n-flex>
|
||
<n-button
|
||
:disabled="files.length === 1 || isRunning"
|
||
style="margin-top: 22px"
|
||
@click="remove(index)"
|
||
>
|
||
删除
|
||
</n-button>
|
||
</n-flex>
|
||
</n-flex>
|
||
</template>
|