Some checks failed
Deploy / deploy (push) Has been cancelled
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在 web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts` 还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边 什么风格」。 - 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认, printWidth 80 —— 和前端已有的格式一致,不另立一套宽度); - 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建 配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉; - `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照 (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会 重写的 `auto-imports.d.ts` / `components.d.ts`; - 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、 前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是 prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。 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>("Python3")
|
||
|
||
// 始终显示所有语言,不管有没有答案代码
|
||
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>
|