feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
2026-08-06 21:18:16 -06:00
parent 3c975e85ee
commit ae1fb329b5
258 changed files with 40490 additions and 91 deletions

View File

@@ -0,0 +1,160 @@
<script lang="ts" setup>
import {
deleteContestProblem,
deleteProblem,
makeProblemPublic,
} from "admin/api"
import download from "utils/download"
interface Props {
problemID: number
problemDisplayID: string
}
const props = defineProps<Props>()
const emit = defineEmits(["updated"])
const route = useRoute()
const router = useRouter()
const message = useMessage()
const isContestProblem = computed(
() => route.name === "admin contest problem list",
)
const showMakePublicModal = ref(false)
const newDisplayID = ref("")
async function handleDeleteProblem() {
try {
if (route.name === "admin contest problem list") {
await deleteContestProblem(props.problemID)
} else {
await deleteProblem(props.problemID)
}
message.success("删除成功")
emit("updated")
} catch (err: any) {
if (err.data === "Can't delete the problem as it has submissions") {
message.error("这道题有提交之后,就不能被删除")
} else {
message.error("删除失败")
}
}
}
function downloads() {
download("test_case?problem_id=" + props.problemID)
}
function goEdit() {
const name = route.name!.toString().replace("list", "edit")
router.push({ name, params: { problemID: props.problemID } })
}
function goCheck() {
let data = router.resolve("/problem/" + props.problemDisplayID)
if (route.name === "admin contest problem list") {
data = router.resolve({
name: "contest problem",
params: {
contestID: route.params.contestID,
problemID: props.problemDisplayID,
},
})
}
window.open(data.href, "_blank")
}
function openMakePublicModal() {
newDisplayID.value = ""
showMakePublicModal.value = true
}
async function handleMakePublic() {
if (!newDisplayID.value.trim()) {
message.error("请输入新的题目编号")
return
}
try {
await makeProblemPublic(props.problemID, newDisplayID.value.trim())
message.success("已成功转为公开题目(需要手动设置可见)")
showMakePublicModal.value = false
emit("updated") // 刷新列表
} catch (err: any) {
if (err.data === "Duplicate display ID") {
message.error("该题目编号已存在,请使用其他编号")
} else if (err.data === "Already be a public problem") {
message.error("该题目已经是公开题目")
} else {
message.error("转换失败:" + (err.data || "未知错误"))
}
}
}
</script>
<template>
<n-flex>
<n-button size="small" secondary type="primary" @click="goEdit">
编辑
</n-button>
<n-button size="small" secondary type="info" @click="goCheck">
查看
</n-button>
<n-tooltip v-if="isContestProblem">
<template #trigger>
<n-button
size="small"
secondary
type="warning"
@click="openMakePublicModal"
>
公开
</n-button>
</template>
将此竞赛题目转为公开题目
</n-tooltip>
<n-popconfirm @positive-click="handleDeleteProblem">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
</template>
确定删除这道题目吗相关的提交也会被相应删除哦 😯
</n-popconfirm>
<n-tooltip>
<template #trigger>
<n-button size="small" secondary @click="downloads">下载</n-button>
</template>
下载测试用例
</n-tooltip>
</n-flex>
<n-modal
v-model:show="showMakePublicModal"
preset="card"
title="转为公开题目"
style="width: 500px"
>
<n-space vertical>
<p>
将竞赛题目转为公开题目后会创建一个新的公开题目副本原题目保持不变
</p>
<n-form>
<n-form-item label="新的题目编号" required>
<n-input
v-model:value="newDisplayID"
placeholder="例如: 1001"
clearable
@keyup.enter="handleMakePublic"
/>
</n-form-item>
</n-form>
<n-alert type="info" title="提示:请输入一个未被使用的题目编号">
</n-alert>
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="showMakePublicModal = false">取消</n-button>
<n-button type="primary" @click="handleMakePublic">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { addProblemForContest } from "admin/api"
interface Props {
problemID: number
contestID: string
nextDisplayId?: string
}
const props = defineProps<Props>()
const emit = defineEmits(["added"])
const message = useMessage()
const displayID = ref(props.nextDisplayId || "")
async function addProblem() {
if (!displayID.value) return
try {
await addProblemForContest(
props.contestID,
props.problemID,
displayID.value,
)
emit("added")
} catch (err: any) {
if (err.data === "Duplicate display id in this contest") {
message.error("显示编号重复了,请重新写一个")
} else if (err.data === "Contest has ended") {
message.error("这场比赛已经结束了,不能添加题目")
} else {
message.error(err.data)
}
}
}
</script>
<template>
<n-popconfirm :show-icon="false" @positive-click="addProblem">
<template #trigger>
<n-button secondary size="small" type="primary">添加</n-button>
</template>
<n-flex vertical>
<span>请输入在这场比赛中的显示编号</span>
<n-input autofocus v-model:value="displayID" />
</n-flex>
</n-popconfirm>
</template>
<style scoped></style>

View File

@@ -0,0 +1,397 @@
<script setup lang="ts">
import type { LANGUAGE } from "utils/types"
interface AstRule {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
}
interface Props {
modelValue: { [key: string]: AstRule[] } | null
languages: LANGUAGE[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "update:modelValue", value: { [key: string]: AstRule[] } | null): void
}>()
const activeTab = ref(props.languages[0] || "Python3")
const ENGINE_OPTIONS: SelectOption[] = [
{
label: "节点检查",
type: "group",
key: "node_group",
children: [
{ label: "必须存在", value: "must_exist_node" },
{ label: "不能存在", value: "must_not_exist_node" },
{ label: "出现次数", value: "count_node" },
],
},
{
label: "函数调用",
type: "group",
key: "func_group",
children: [
{ label: "必须调用函数", value: "must_call_function" },
{ label: "不能调用函数", value: "must_not_call_function" },
{ label: "函数调用次数", value: "count_function_call" },
],
},
{
label: "方法调用",
type: "group",
key: "method_group",
children: [
{ label: "必须调用方法", value: "must_call_method" },
{ label: "不能调用方法", value: "must_not_call_method" },
],
},
{
label: "运算符",
type: "group",
key: "op_group",
children: [{ label: "必须使用运算符", value: "must_use_operator" }],
},
]
const NODE_TARGET_OPTIONS: SelectOption[] = [
{ label: "for 循环", value: "for_loop" },
{ label: "while 循环", value: "while_loop" },
{ label: "if 条件", value: "if_statement" },
{ label: "else 子句", value: "else_clause" },
{ label: "函数定义", value: "function_definition" },
{ label: "return 语句", value: "return" },
{ label: "break 语句", value: "break" },
{ label: "continue 语句", value: "continue" },
{ label: "列表推导式", value: "list_comprehension" },
{ label: "列表", value: "list_literal" },
{ label: "字典", value: "dict_literal" },
{ label: "集合", value: "set_literal" },
{ label: "f-string", value: "f_string" },
{ label: "try-except", value: "try_except" },
{ label: "类定义", value: "class_definition" },
]
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
{ label: "+", value: "+" },
{ label: "-", value: "-" },
{ label: "*", value: "*" },
{ label: "/", value: "/" },
{ label: "//", value: "//" },
{ label: "%", value: "%" },
{ label: "**", value: "**" },
{ label: "+=", value: "+=" },
{ label: "-=", value: "-=" },
{ label: "==", value: "==" },
{ label: "!=", value: "!=" },
{ label: ">", value: ">" },
{ label: ">=", value: ">=" },
{ label: "<", value: "<" },
{ label: "<=", value: "<=" },
{ label: "and / &&", value: "and" },
{ label: "or / ||", value: "or" },
{ label: "not / !", value: "not" },
]
const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"]
const FUNCTION_ENGINES = [
"must_call_function",
"must_not_call_function",
"count_function_call",
]
const METHOD_ENGINES = ["must_call_method", "must_not_call_method"]
const OPERATOR_ENGINES = ["must_use_operator"]
const COUNT_ENGINES = ["count_node", "count_function_call"]
function isNodeEngine(engine: string) {
return NODE_ENGINES.includes(engine)
}
function isFunctionEngine(engine: string) {
return FUNCTION_ENGINES.includes(engine)
}
function isMethodEngine(engine: string) {
return METHOD_ENGINES.includes(engine)
}
function isOperatorEngine(engine: string) {
return OPERATOR_ENGINES.includes(engine)
}
function isCountEngine(engine: string) {
return COUNT_ENGINES.includes(engine)
}
const COUNT_MODE_OPTIONS: SelectOption[] = [
{ label: "精确", value: "exact" },
{ label: "范围", value: "range" },
]
function getCountMode(rule: AstRule): "exact" | "range" {
return rule.exact !== undefined ? "exact" : "range"
}
function updateCountMode(lang: string, index: number, mode: "exact" | "range") {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (mode === "exact") {
rule.exact = rule.min ?? 1
delete rule.min
delete rule.max
} else {
delete rule.exact
}
rules[index] = rule
updateRules(lang, rules)
}
function updateExactCount(lang: string, index: number, v: number | null) {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (v === null) delete rule.exact
else rule.exact = v
rules[index] = rule
updateRules(lang, rules)
}
function needsTargetDropdown(engine: string) {
return isNodeEngine(engine)
}
function needsTargetInput(engine: string) {
return isFunctionEngine(engine) || isMethodEngine(engine)
}
function needsOperatorDropdown(engine: string) {
return isOperatorEngine(engine)
}
function getRulesForLang(lang: string): AstRule[] {
if (!props.modelValue) return []
return props.modelValue[lang] || []
}
function updateRules(lang: string, rules: AstRule[]) {
const current = { ...(props.modelValue || {}) }
if (rules.length === 0) {
delete current[lang]
} else {
current[lang] = rules
}
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
}
function getTargetLabel(engine: string, target: string): string | undefined {
if (isNodeEngine(engine))
return (NODE_TARGET_OPTIONS.find((o) => o.value === target) as any)?.label
if (isOperatorEngine(engine))
return (OPERATOR_TARGET_OPTIONS.find((o) => o.value === target) as any)
?.label
return undefined
}
function addRule(lang: string) {
const rules = [...getRulesForLang(lang)]
rules.push({
engine: "must_exist_node",
target: "for_loop",
label: "for 循环",
message: "",
})
updateRules(lang, rules)
}
function removeRule(lang: string, index: number) {
const rules = [...getRulesForLang(lang)]
rules.splice(index, 1)
updateRules(lang, rules)
}
function updateRule(lang: string, index: number, field: string, value: any) {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (field === "engine") {
rule.engine = value
if (isNodeEngine(value)) {
rule.target = "for_loop"
rule.label = "for 循环"
} else if (isOperatorEngine(value)) {
rule.target = "+"
rule.label = "+"
} else {
rule.target = ""
delete rule.label
}
delete rule.min
delete rule.max
delete rule.exact
} else if (field === "target") {
rule.target = value
const lbl = getTargetLabel(rule.engine, value)
if (lbl) rule.label = lbl
else delete rule.label
} else if (field === "min") {
if (value === null || value === undefined) delete rule.min
else rule.min = value
} else if (field === "max") {
if (value === null || value === undefined) delete rule.max
else rule.max = value
} else if (field === "message") {
rule.message = value
}
rules[index] = rule
updateRules(lang, rules)
}
watch(
() => props.languages,
(langs) => {
if (langs.length && !langs.includes(activeTab.value as LANGUAGE)) {
activeTab.value = langs[0]
}
},
)
</script>
<template>
<n-collapse>
<n-collapse-item title="代码规则检查(选填)" name="ast-rules">
<n-tabs v-if="languages.length" type="segment" v-model:value="activeTab">
<n-tab-pane
v-for="lang in languages"
:key="lang"
:name="lang"
:tab="lang"
>
<n-flex vertical>
<div
v-for="(rule, index) in getRulesForLang(lang)"
:key="index"
style="margin-bottom: 8px"
>
<n-flex align="center" :wrap="false">
<n-select
:options="ENGINE_OPTIONS"
:value="rule.engine"
@update:value="
(v: string) => updateRule(lang, index, 'engine', v)
"
style="width: 150px"
size="small"
/>
<n-select
v-if="needsTargetDropdown(rule.engine)"
:options="NODE_TARGET_OPTIONS"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
style="width: 150px"
size="small"
filterable
/>
<n-input
v-if="needsTargetInput(rule.engine)"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
placeholder="函数/方法名"
style="width: 150px"
size="small"
/>
<n-select
v-if="needsOperatorDropdown(rule.engine)"
:options="OPERATOR_TARGET_OPTIONS"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
style="width: 150px"
size="small"
/>
<template v-if="isCountEngine(rule.engine)">
<n-select
:options="COUNT_MODE_OPTIONS"
:value="getCountMode(rule)"
@update:value="
(v: 'exact' | 'range') => updateCountMode(lang, index, v)
"
style="width: 80px"
size="small"
/>
<n-input-number
v-if="getCountMode(rule) === 'exact'"
:value="rule.exact ?? null"
@update:value="
(v: number | null) => updateExactCount(lang, index, v)
"
placeholder="次数"
style="width: 100px"
size="small"
:min="1"
clearable
/>
<template v-else>
<n-input-number
:value="rule.min ?? null"
@update:value="
(v: number | null) => updateRule(lang, index, 'min', v)
"
placeholder="最少"
style="width: 100px"
size="small"
:min="0"
clearable
/>
<n-input-number
:value="rule.max ?? null"
@update:value="
(v: number | null) => updateRule(lang, index, 'max', v)
"
placeholder="最多"
style="width: 100px"
size="small"
:min="0"
clearable
/>
</template>
</template>
<n-input
:value="rule.message"
@update:value="
(v: string) => updateRule(lang, index, 'message', v)
"
placeholder="错误提示(选填)"
style="flex: 1"
size="small"
/>
<n-button
size="small"
tertiary
type="error"
@click="removeRule(lang, index)"
>
删除
</n-button>
</n-flex>
</div>
<n-button
size="small"
tertiary
type="primary"
@click="addRule(lang)"
>
添加规则
</n-button>
</n-flex>
</n-tab-pane>
</n-tabs>
<n-empty v-else description="请先选择编程语言" />
</n-collapse-item>
</n-collapse>
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { AdminTag } from "utils/types"
import { batchTagProblems, getTagAdminList } from "admin/api"
interface Props {
show: boolean
problemIds: number[]
action: "add" | "remove"
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:show": [value: boolean]
done: []
}>()
const message = useMessage()
const tags = ref<AdminTag[]>([])
const selected = ref<string[]>([])
const newTags = ref<string[]>([])
const title = computed(() =>
props.action === "add" ? "批量添加标签" : "批量移除标签",
)
const selectedSet = computed(() => new Set(selected.value))
const names = computed(() =>
props.action === "add"
? Array.from(new Set([...selected.value, ...newTags.value]))
: selected.value,
)
function toggleTag(name: string) {
const set = new Set(selected.value)
if (set.has(name)) set.delete(name)
else set.add(name)
selected.value = Array.from(set)
}
async function listTags() {
const res = await getTagAdminList()
tags.value = res.data
}
function close() {
emit("update:show", false)
}
async function submit() {
if (!names.value.length) {
message.error("请先选择标签")
return
}
const res = await batchTagProblems(
props.problemIds,
names.value,
props.action,
)
const verb = props.action === "add" ? "添加" : "移除"
message.success(
`已为 ${res.data.problem_count} 道题${verb} ${res.data.tag_count} 个标签`,
)
close()
emit("done")
}
watch(
() => props.show,
(show) => {
if (!show) return
selected.value = []
newTags.value = []
listTags()
},
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="title"
style="width: 600px"
:mask-closable="false"
@close="close"
>
<n-flex vertical size="large">
<div>已选中 {{ problemIds.length }} 道题目</div>
<n-flex size="small">
<n-tag
v-for="tag in tags"
:key="tag.id"
checkable
:checked="selectedSet.has(tag.name)"
@update:checked="toggleTag(tag.name)"
>
{{ tag.name }}{{ tag.problem_count }}
</n-tag>
</n-flex>
<n-dynamic-tags v-if="action === 'add'" v-model:value="newTags" />
<n-flex justify="end">
<n-button @click="close">取消</n-button>
<n-button type="primary" @click="submit">确定</n-button>
</n-flex>
</n-flex>
</n-modal>
</template>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import { getProblemList } from "admin/api"
import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemFiltered } from "utils/types"
import AddButton from "./AddButton.vue"
interface Props {
show: boolean
count: number
nextDisplayId?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "update:show", value: boolean): void
(e: "change"): void
}>()
const route = useRoute()
const query = reactive({
page: 1,
limit: 10,
keyword: "",
})
const total = ref(0)
const problems = shallowRef<AdminProblemFiltered[]>([])
const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "编号", key: "_id", width: 80 },
{ title: "标题", key: "title" },
{
title: "选项",
key: "add",
render: (row) =>
h(AddButton, {
problemID: row.id,
contestID: route.params.contestID as string,
nextDisplayId: props.nextDisplayId,
onAdded: () => emit("change"),
}),
width: 60,
},
]
async function getList() {
const offset = (query.page - 1) * query.limit
const res = await getProblemList(offset, query.limit, query.keyword, "", "")
total.value = res.total
problems.value = res.results
}
watch(
() => props.show,
(value) => {
if (value) getList()
},
)
watch(() => [query.limit, query.page], getList)
watchDebounced(
() => query.keyword,
() => {
query.page = 1
getList()
},
{ debounce: 500, maxWait: 1000 },
)
</script>
<template>
<n-modal
:mask-closable="false"
:show="props.show"
preset="card"
style="width: 600px"
title="从题库中添加"
@close="$emit('update:show', false)"
>
<n-input
class="search"
v-model:value="query.keyword"
clearable
placeholder="搜索标题或编号"
/>
<n-data-table striped :columns="columns" :data="problems" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</n-modal>
</template>
<style scoped>
.search {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,345 @@
<script setup lang="ts">
import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
import {
generateSQLTestcase,
getSQLTestcaseScripts,
previewSQLTestcase,
uploadTestcases,
} from "../../api"
interface ScriptEntry {
id: number
sql: string
display: SQLDisplay | null
error: string
// 标准答案或题型改过之后,旧预览结果作废,需重新预览才能上传
stale: boolean
}
interface Props {
answers: { language: LANGUAGE; code: string }[]
mode: "query" | "modify"
problemId?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
}>()
const message = useMessage()
let nextId = 0
function blankEntry(): ScriptEntry {
return { id: nextId++, sql: "", display: null, error: "", stale: false }
}
const scripts = ref<ScriptEntry[]>([blankEntry(), blankEntry(), blankEntry()])
const refSQL = computed(
() =>
props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ??
"",
)
const isPreviewing = ref(false)
const isUploading = ref(false)
const isGenerating = ref(false)
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
const hasBlankScript = computed(() => scripts.value.some((s) => !s.sql.trim()))
const filledCount = computed(
() => scripts.value.filter((s) => s.sql.trim()).length,
)
const canUpload = computed(() => {
const filled = scripts.value.filter((s) => s.sql.trim())
return (
!isPreviewing.value &&
// 至少 2 个数据不同的测试点,防止学生对照题目页的期望结果硬编码
filled.length >= 2 &&
filled.every((s) => s.display && !s.error && !s.stale)
)
})
watch([refSQL, () => props.mode], () => {
for (const s of scripts.value) {
if (s.display || s.error) s.stale = true
}
})
// 编辑已有 SQL 题时回显已上传的脚本;新题或旧格式测试点则保持空白
onMounted(async () => {
if (!props.problemId) return
try {
const res = await getSQLTestcaseScripts(props.problemId)
if (res.data.length) {
scripts.value = res.data.map((f) => ({ ...blankEntry(), sql: f.content }))
}
} catch {}
})
function add() {
scripts.value.push(blankEntry())
}
function remove(index: number) {
scripts.value.splice(index, 1)
}
function reset() {
scripts.value = [blankEntry(), blankEntry(), blankEntry()]
}
function expectedQuery(d: SQLDisplay) {
return "columns" in d.expected ? d.expected : null
}
function changedTables(d: SQLDisplay) {
return "changed_tables" in d.expected ? d.expected.changed_tables : []
}
async function generate() {
const blanks = scripts.value.filter((s) => !s.sql.trim())
if (!blanks.length) return
isGenerating.value = true
await Promise.all(
blanks.map(async (s) => {
try {
const res = await generateSQLTestcase({
ref_sql: refSQL.value,
mode: props.mode,
})
s.sql = res.data.sql
} catch (err) {
const data = (err as { data?: unknown })?.data
message.error(typeof data === "string" ? data : "AI 生成失败")
}
}),
)
isGenerating.value = false
await preview()
}
async function preview() {
// 丢弃空脚本
scripts.value = scripts.value.filter((s) => s.sql.trim())
if (!scripts.value.length) {
scripts.value = [blankEntry()]
return
}
isPreviewing.value = true
await Promise.all(
scripts.value.map(async (s) => {
s.display = null
s.error = ""
s.stale = false
try {
const res = await previewSQLTestcase({
init_sql: s.sql,
ref_sql: refSQL.value,
mode: props.mode,
})
s.display = res.data
} catch (err) {
const data = (err as { data?: unknown })?.data
s.error = typeof data === "string" ? data : "预览失败"
}
}),
)
isPreviewing.value = false
}
async function upload() {
isUploading.value = true
try {
const data = scripts.value
.filter((s) => s.sql.trim())
.map((s, i) => ({
name: `${i + 1}.sql`,
content: s.sql,
}))
const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file, { sql: true })
const testcases: Testcase[] = res.data.info
const baseScore = Math.floor(100 / testcases.length)
const remainder = 100 - baseScore * testcases.length
testcases.forEach((tc, i) => {
tc.score = String(
i === testcases.length - 1 ? baseScore + remainder : baseScore,
)
})
emit("uploaded", res.data.id, testcases)
message.success("上传成功")
} catch {
message.error("上传失败")
} finally {
isUploading.value = false
}
}
</script>
<template>
<n-flex vertical>
<n-alert
v-if="!refSQL"
type="warning"
:show-icon="false"
style="margin-bottom: 8px"
>
还没有填写 SQL 标准答案请先在上方"本题参考答案"中填写再来编写测试点
</n-alert>
<n-flex align="center" wrap>
<n-button :disabled="isPreviewing || isGenerating" @click="reset">
清空
</n-button>
<n-button :disabled="isPreviewing || isGenerating" @click="add">
+1
</n-button>
<n-tooltip :disabled="!!refSQL && hasBlankScript">
<template #trigger>
<span>
<n-button
:loading="isGenerating"
:disabled="!refSQL || !hasBlankScript || isPreviewing"
@click="generate"
>
AI 生成
</n-button>
</span>
</template>
{{ !refSQL ? "请先填写 SQL 标准答案" : "所有脚本都写好了,无需生成" }}
</n-tooltip>
<n-tooltip :disabled="!!refSQL && hasAnyScript">
<template #trigger>
<span>
<n-button
type="success"
:loading="isPreviewing"
:disabled="!refSQL || !hasAnyScript || isGenerating"
@click="preview"
>
预览验证
</n-button>
</span>
</template>
{{ !refSQL ? "请先填写 SQL 标准答案" : "请先填写数据脚本" }}
</n-tooltip>
<n-tooltip :disabled="canUpload || isPreviewing">
<template #trigger>
<span>
<n-button
type="primary"
:loading="isUploading"
:disabled="!canUpload || isGenerating"
@click="upload"
>
上传
</n-button>
</span>
</template>
{{
filledCount < 2
? "SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果"
: "所有脚本预览验证通过后才能上传"
}}
</n-tooltip>
</n-flex>
<div v-for="(s, index) in scripts" :key="s.id" class="scriptBox">
<n-flex justify="space-between" align="center">
<strong>{{ index + 1 }}.sql</strong>
<n-button
size="small"
:disabled="scripts.length === 1 || isPreviewing || isGenerating"
@click="remove(index)"
>
删除
</n-button>
</n-flex>
<n-input
type="textarea"
v-model:value="s.sql"
:rows="8"
placeholder="-- 本测试点的建表 + 插入数据脚本
CREATE TABLE ...;
INSERT INTO ...;"
:status="
s.error ? 'error' : s.display && !s.stale ? 'success' : undefined
"
/>
<n-alert v-if="s.error" type="error" :show-icon="false">
{{ s.error }}
</n-alert>
<template v-if="s.display">
<n-alert v-if="s.stale" type="warning" :show-icon="false">
标准答案或题型已修改以下预览已过期请重新预览
</n-alert>
<div :class="{ stalePreview: s.stale }">
<p class="previewTitle">数据表</p>
<div v-for="t in s.display.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
<p class="previewTitle">期望结果</p>
<SQLDataTable
v-if="expectedQuery(s.display)"
:columns="expectedQuery(s.display)!.columns"
:rows="expectedQuery(s.display)!.rows"
:total-rows="expectedQuery(s.display)!.total_rows"
:truncated="expectedQuery(s.display)!.truncated"
/>
<div v-for="t in changedTables(s.display)" :key="t.name">
<p class="sqlTableName">
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name}` }}
</p>
<SQLDataTable
v-if="!t.dropped"
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
</div>
</template>
</div>
</n-flex>
</template>
<style scoped>
.scriptBox {
border: 1px solid var(--n-border-color, rgba(128, 128, 128, 0.2));
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.previewTitle {
font-weight: bold;
margin: 4px 0;
}
.sqlTableName {
font-weight: 500;
margin: 4px 0;
opacity: 0.85;
}
.stalePreview {
opacity: 0.45;
}
</style>

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import { NButton, NTag } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemFiltered } from "utils/types"
import { batchTagProblems, getProblemList } from "admin/api"
interface Props {
show: boolean
tagId: number
tagName: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:show": [value: boolean]
changed: []
}>()
const router = useRouter()
const message = useMessage()
const problems = ref<AdminProblemFiltered[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keyword = ref("")
const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "显示编号", key: "_id", width: 100 },
{
title: "标题",
key: "title",
minWidth: 200,
render: (row) =>
h(
NButton,
{ text: true, type: "primary", onClick: () => goEdit(row) },
() => row.title,
),
},
{
title: "可见",
key: "visible",
width: 80,
render: (row) =>
h(
NTag,
{ size: "small", type: row.visible ? "success" : "default" },
() => (row.visible ? "公开" : "隐藏"),
),
},
{
title: "选项",
key: "actions",
width: 110,
render: (row) =>
h(
NButton,
{ size: "small", type: "error", onClick: () => removeTag(row) },
() => "移除标签",
),
},
]
async function listProblems() {
if (page.value < 1) page.value = 1
const offset = (page.value - 1) * limit.value
const res = await getProblemList(
offset,
limit.value,
keyword.value,
"",
undefined,
props.tagId,
)
problems.value = res.results
total.value = res.total
}
function close() {
emit("update:show", false)
}
function goEdit(row: AdminProblemFiltered) {
close()
router.push({ name: "admin problem edit", params: { problemID: row.id } })
}
async function removeTag(row: AdminProblemFiltered) {
await batchTagProblems([row.id], [props.tagName], "remove")
message.success(`已移除「${row.title}」的标签`)
emit("changed")
// 移掉本页最后一条时退回上一页,交给下面的 watcher 重新拉取
if (problems.value.length === 1 && page.value > 1) {
page.value -= 1
} else {
listProblems()
}
}
// 改搜索词就回到第一页
watch(keyword, () => (page.value = 1))
// 每次打开弹窗重置状态,拉取交给下面的 watcher
watch(
() => props.show,
(show) => {
if (!show) return
page.value = 1
keyword.value = ""
},
)
// 打开 / 翻页 / 改每页条数 / 改搜索词都走这里,防抖把同一批变更合并成一次请求
watchDebounced(
() => [props.show, props.tagId, page.value, limit.value, keyword.value],
() => {
if (!props.show) return
listProblems()
},
{ debounce: 300, maxWait: 800 },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="`标签「${tagName}」下的题目`"
style="width: 720px"
@close="close"
>
<n-flex vertical size="large">
<n-flex justify="space-between" align="center">
<span> {{ total }} 道题</span>
<n-input
v-model:value="keyword"
style="width: 220px"
placeholder="输入标题关键字"
clearable
/>
</n-flex>
<n-data-table striped :columns="columns" :data="problems" />
<Pagination :total="total" v-model:limit="limit" v-model:page="page" />
</n-flex>
</n-modal>
</template>

View File

@@ -0,0 +1,263 @@
<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)
const hasAnyInput = computed(() => files.value.some((f) => f.in.trim()))
const canUpload = computed(
() =>
!isRunning.value &&
hasAnyInput.value &&
files.value.filter((f) => f.in.trim()).every((f) => f.out && !f.error),
)
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>()
files.value = files.value.filter((f) => {
if (!f.in.trim()) return false
if (seen.has(f.in)) return false
seen.add(f.in)
return true
})
// 清空旧输出
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 = files.value
.filter((f) => f.in.trim() && f.out && !f.error)
.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)
const testcases: Testcase[] = res.data.info
const baseScore = Math.floor(100 / testcases.length)
const remainder = 100 - baseScore * testcases.length
testcases.forEach((tc, i) => {
tc.score = String(
i === testcases.length - 1 ? baseScore + remainder : baseScore,
)
})
emit("uploaded", res.data.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 && hasAnyInput">
<template #trigger>
<span>
<n-button
type="success"
:loading="isRunning"
:disabled="!hasAnswerCode || !hasAnyInput"
@click="run"
>
先运行
</n-button>
</span>
</template>
{{ !hasAnswerCode ? "请先在题目中填写答案代码" : "请先填写输入" }}
</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>