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,920 @@
<script setup lang="ts">
import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue"
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
import SQLTestcaseEditor from "./components/SQLTestcaseEditor.vue"
import AstRulesEditor from "./components/AstRulesEditor.vue"
import {
CODE_TEMPLATES,
LANGUAGE_SHOW_VALUE,
STORAGE_KEY,
} from "utils/constants"
import download from "utils/download"
import { unique } from "utils/functions"
import type {
BlankProblem,
LANGUAGE,
SQLConfig,
Tag,
Testcase,
} from "utils/types"
import {
createContestProblem,
createProblem,
editContestProblem,
editProblem,
generateFlowchartFromPythonCode,
getProblem,
uploadTestcases,
} from "../api"
const CodeEditor = defineAsyncComponent(
() => import("shared/components/CodeEditor.vue"),
)
const MermaidEditor = defineAsyncComponent(
() => import("shared/components/MermaidEditor.vue"),
)
interface Props {
problemID?: string
contestID?: string
}
const message = useMessage()
const route = useRoute()
const router = useRouter()
const props = defineProps<Props>()
const title = computed(
() =>
({
"admin problem create": "新建题目",
"admin problem edit": "编辑题目",
"admin contest problem create": "新建比赛题目",
"admin contest problem edit": "编辑比赛题目",
})[route.name as string],
)
const isAIGenerating = ref(false)
const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
_id: "",
title: "",
description: "",
input_description: "",
output_description: "",
time_limit: 1000,
memory_limit: 64,
difficulty: "Low" as "Low" | "Mid" | "High",
visible: false,
share_submission: false,
tags: [],
languages: ["Python3", "C"] as LANGUAGE[],
template: {} as { [key in LANGUAGE]?: string },
samples: [
{ input: "", output: "" },
{ input: "", output: "" },
{ input: "", output: "" },
],
test_case_id: "",
test_case_score: [] as Testcase[],
hint: "",
source: "",
prompt: "",
answers: [] as { language: LANGUAGE; code: string }[],
contest_id: "",
allow_flowchart: false,
mermaid_code: "",
flowchart_data: {},
flowchart_hint: "",
show_flowchart: false,
ast_rules: null as { [key: string]: any[] } | null,
sql_config: null as SQLConfig | null,
})
// 从服务器来的tag列表
const tagList = shallowRef<Tag[]>([])
const tagListLoaded = ref(false)
const selectedTags = ref<string[]>([])
const newTags = ref<string[]>([])
const selectedTagSet = computed(() => new Set(selectedTags.value))
let syncingTagInputs = false
function normalizeTagNames(tags: unknown): string[] {
if (!Array.isArray(tags)) return []
return unique(
tags
.map((tag) => (typeof tag === "string" ? tag : tag?.name))
.filter((tag): tag is string => !!tag),
)
}
function syncProblemTags() {
problem.value.tags = unique([...selectedTags.value, ...newTags.value])
}
function syncTagInputsFromProblemTags(tags: unknown = problem.value.tags) {
const tagNames = normalizeTagNames(tags)
const existingTagNames = new Set(tagList.value.map((tag) => tag.name))
syncingTagInputs = true
if (!tagListLoaded.value) {
selectedTags.value = tagNames
newTags.value = []
} else {
selectedTags.value = tagNames.filter((tag) => existingTagNames.has(tag))
newTags.value = tagNames.filter((tag) => !existingTagNames.has(tag))
}
syncingTagInputs = false
syncProblemTags()
}
function toggleTag(name: string) {
const set = new Set(selectedTags.value)
if (set.has(name)) set.delete(name)
else set.add(name)
selectedTags.value = Array.from(set)
}
function validateNewTags(v: string[]) {
const existing = new Set(tagList.value.map((t) => t.name))
const blanks: string[] = []
for (const tag of unique(v)) {
if (existing.has(tag)) {
message.error("已经存在标签:" + tag)
break
}
blanks.push(tag)
}
newTags.value = blanks
}
// 这几个用的少,就不缓存本地了
const [needTemplate, toggleNeedTemplate] = useToggle(false)
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
const currentActiveTemplate = ref<LANGUAGE>("Python3")
const currentActiveAnswer = ref<LANGUAGE>("Python3")
// 给 TextEditor 用
const [ready, toggleReady] = useToggle(false)
// Mermaid 渲染状态
const mermaidRenderSuccess = ref(false)
const difficultyOptions: SelectOption[] = [
{ label: "简单", value: "Low" },
{ label: "中等", value: "Mid" },
{ label: "困难", value: "High" },
]
const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" },
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
{ label: LANGUAGE_SHOW_VALUE["SQL"], value: "SQL" },
]
const isSQLProblem = computed(() => !!problem.value?.languages.includes("SQL"))
// SQL 题联动SQL 必须是唯一语言(后端强校验),不需要预制代码,自动初始化 sql_config
watch(
() => problem.value?.languages,
(langs) => {
if (!langs) return
if (langs.includes("SQL")) {
if (langs.length > 1) {
problem.value.languages = ["SQL"]
return
}
needTemplate.value = false
if (!problem.value.sql_config) {
problem.value.sql_config = { mode: "query", order_sensitive: false }
}
currentActiveAnswer.value = "SQL"
// 代码规则检查基于 Python/C 的 AST 解析,对 SQL 没有意义,清空避免脏数据
if (problem.value.ast_rules) {
problem.value.ast_rules = null
}
// 流程图依赖 Python 答案生成,对 SQL 没有意义
problem.value.allow_flowchart = false
problem.value.show_flowchart = false
} else if (problem.value.sql_config) {
problem.value.sql_config = null
}
},
{ immediate: true },
)
async function getProblemDetail() {
if (!props.problemID) {
syncTagInputsFromProblemTags()
toggleReady(true)
return
}
try {
const { data } = await getProblem(props.problemID)
problem.value.id = data.id
problem.value._id = data._id
problem.value.title = data.title
problem.value.description = data.description
problem.value.input_description = data.input_description
problem.value.output_description = data.output_description
problem.value.time_limit = data.time_limit
problem.value.memory_limit = data.memory_limit
problem.value.memory_limit = data.memory_limit
problem.value.difficulty = data.difficulty
problem.value.visible = data.visible
problem.value.share_submission = data.share_submission
problem.value.tags = normalizeTagNames(data.tags)
problem.value.languages = data.languages
problem.value.template = data.template
problem.value.samples = data.samples
problem.value.samples = data.samples
problem.value.test_case_id = data.test_case_id
problem.value.test_case_score = data.test_case_score
problem.value.hint = data.hint
problem.value.source = data.source
problem.value.prompt = data.prompt
// 流程图相关字段
problem.value.allow_flowchart = data.allow_flowchart
problem.value.show_flowchart = data.show_flowchart
problem.value.mermaid_code = data.mermaid_code ?? ""
problem.value.flowchart_hint = data.flowchart_hint ?? ""
problem.value.flowchart_data = data.flowchart_data
problem.value.ast_rules = data.ast_rules ?? null
problem.value.sql_config = data.sql_config ?? null
if (data.answers && data.answers.length) {
problem.value.answers = data.answers
} else {
problem.value.answers = data.languages.map((lang: LANGUAGE) => ({
language: lang,
code: "",
}))
}
if (problem.value.contest_id) {
problem.value.contest_id = problem.value.contest_id
}
// 下面是用来显示的:
// 代码模板 和 模板开关
problem.value.languages.forEach((lang) => {
if (data.template[lang]) {
template[lang] = data.template[lang]
toggleNeedTemplate(true)
}
})
// 标签
syncTagInputsFromProblemTags(problem.value.tags)
toggleReady(true)
} catch (error) {
message.error("获取题目失败")
router.push({ name: "admin problem list" })
}
}
async function getTagList() {
const res = await getProblemTagList()
tagList.value = res.data
tagListLoaded.value = true
syncTagInputsFromProblemTags()
}
function addSample() {
problem.value.samples.push({ input: "", output: "" })
}
function removeSample(index: number) {
problem.value.samples.splice(index, 1)
}
function resetTemplate(language: LANGUAGE) {
template[language] = CODE_TEMPLATES[language]
}
async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
try {
const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value })
// @ts-ignore
if (res.error) {
message.error("上传测试用例失败")
return
}
const testcases = res.data.info
for (let file of testcases) {
file.score = (100 / testcases.length).toFixed(0)
}
problem.value.test_case_score = testcases
problem.value.test_case_id = res.data.id
} catch (err) {
message.error("上传测试用例失败")
}
}
function downloadTestcases() {
download("test_case?problem_id=" + problem.value.id)
}
// Mermaid 渲染事件处理
function onMermaidRenderSuccess() {
mermaidRenderSuccess.value = true
}
// 题目是否有漏写的
async function validateProblem() {
let hasErrors = false
// 标题
if (!problem.value._id || !problem.value.title) {
message.error("编号或标题没有填写")
hasErrors = true
}
// 标签
else if (selectedTags.value.length === 0 && newTags.value.length === 0) {
message.error("标签没有填写")
hasErrors = true
}
// 题目
else if (
!problem.value.description ||
(!isSQLProblem.value &&
(!problem.value.input_description || !problem.value.output_description))
) {
message.error("题目或输入或输出没有填写")
hasErrors = true
}
// 样例
else if (!isSQLProblem.value && problem.value.samples.length == 0) {
message.error("样例没有填写")
hasErrors = true
}
// 样例是空的
else if (
!isSQLProblem.value &&
problem.value.samples.some(
(sample) => sample.output === "" || sample.input === "",
)
) {
message.error("空样例没有删干净")
hasErrors = true
}
// 测试用例
else if (problem.value.test_case_score.length === 0) {
message.error("测试用例没有上传")
hasErrors = true
} else if (problem.value.languages.length === 0) {
message.error("编程语言没有选择")
hasErrors = true
}
// SQL 题验证
else if (isSQLProblem.value && !problem.value.sql_config?.mode) {
message.error("SQL 题需要选择题型(查询题/增删改题)")
hasErrors = true
} else if (
isSQLProblem.value &&
!problem.value.answers.find(
(ans) => ans.language === "SQL" && ans.code.trim() !== "",
)
) {
message.error("SQL 题必须填写标准答案(判题时用它生成期望结果)")
hasErrors = true
}
// 流程图验证
else if (problem.value.show_flowchart || problem.value.allow_flowchart) {
if (
!problem.value.mermaid_code ||
problem.value.mermaid_code.trim() === ""
) {
message.error("启用了流程图功能,但流程图代码为空")
hasErrors = true
} else if (!mermaidRenderSuccess.value) {
message.error("Mermaid 代码尚未成功渲染,请检查代码语法")
hasErrors = true
}
}
// 通过了
else {
hasErrors = false
}
return hasErrors
}
function getTemplate() {
if (!needTemplate.value) {
problem.value.template = {}
} else {
problem.value.languages.forEach((lang) => {
if (CODE_TEMPLATES[lang] !== template[lang]) {
problem.value.template[lang] = template[lang]
} else {
delete problem.value.template[lang]
}
})
}
}
function filterHint() {
// 编辑器会自动添加一段 HTML
if (problem.value.hint === "<p><br></p>") {
problem.value.hint = ""
}
}
function filterAnswers() {
problem.value.answers = problem.value.answers.filter(
(ans) => ans.code.trim() !== "",
)
}
function filterSamplesForSQL() {
// SQL 题不展示样例;后端 CreateSampleSerializer 也不接受空字符串样例
if (isSQLProblem.value) problem.value.samples = []
}
async function submit() {
const hasValidationErrors = await validateProblem()
if (hasValidationErrors) return
filterHint()
getTemplate()
filterAnswers()
filterSamplesForSQL()
syncProblemTags()
const api = {
"admin problem create": createProblem,
"admin problem edit": editProblem,
"admin contest problem create": createContestProblem,
"admin contest problem edit": editContestProblem,
}[route.name as string]
if (
route.name === "admin contest problem create" ||
route.name === "admin contest problem edit"
) {
problem.value.contest_id = props.contestID
}
try {
await api!(problem.value)
problem.value = null
selectedTags.value = []
newTags.value = []
if (
route.name === "admin problem create" ||
route.name === "admin contest problem create"
) {
message.success("恭喜你 💐 出题成功")
}
if (
route.name === "admin problem create" ||
route.name === "admin problem edit"
) {
router.push({ name: "admin problem list" })
} else {
router.push({
name: "admin contest problem list",
params: { contestID: props.contestID },
})
}
} catch (err: any) {
if (err.data === "Display ID already exists") {
message.error("显示编号重复了,请换一个显示编号")
} else {
message.error(err.data)
}
}
}
const showClear = computed(
() =>
route.name === "admin problem create" ||
route.name === "admin contest problem create",
)
function clear() {
problem.value = null
selectedTags.value = []
newTags.value = []
// 为了给所有状态初始化,刷新页面
location.reload()
}
async function generateMermaid() {
isAIGenerating.value = true
const res = await generateFlowchartFromPythonCode(
problem.value.answers.filter((a) => a.language === "Python3")[0].code,
)
isAIGenerating.value = false
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
problem.value.mermaid_code = res.data.flowchart
}
const showGeneratorModal = ref(false)
function handleTestcasesGenerated(
testCaseId: string,
testCaseScore: Testcase[],
) {
problem.value.test_case_id = testCaseId
problem.value.test_case_score = testCaseScore
showGeneratorModal.value = false
}
onMounted(() => {
getTagList()
getProblemDetail()
})
watch([selectedTags, newTags], ([sel, newT]) => {
if (syncingTagInputs) return
problem.value.tags = unique([...sel, ...newT])
})
watch(
() => problem.value.languages,
(langs) => {
const answers = langs.map((lang) => {
const existing = problem.value.answers.find(
(ans) => ans.language === lang,
)
return existing || { language: lang, code: "" }
})
problem.value.answers = answers
},
{ immediate: true },
)
</script>
<template>
<n-flex>
<h2 class="title">{{ title }}</h2>
<n-button v-if="showClear" @click="clear">清空缓存</n-button>
</n-flex>
<n-form inline label-placement="left">
<n-form-item label="显示编号">
<n-input class="w-100" v-model:value="problem._id" />
</n-form-item>
<n-form-item label="题目">
<n-input class="problemTitleInput" v-model:value="problem.title" />
</n-form-item>
<n-form-item label="难度">
<n-select
class="w-100"
:options="difficultyOptions"
v-model:value="problem.difficulty"
/>
</n-form-item>
<n-form-item label="可见">
<n-switch v-model:value="problem.visible" />
</n-form-item>
</n-form>
<n-form label-placement="left" :show-feedback="false">
<n-form-item label="标签">
<n-flex vertical style="width: 100%">
<n-flex size="small" style="flex-wrap: wrap">
<n-tag
v-for="tag in tagList"
:key="tag.id"
checkable
:checked="selectedTagSet.has(tag.name)"
@update:checked="toggleTag(tag.name)"
>
{{ tag.name }}
</n-tag>
</n-flex>
<n-dynamic-tags
v-model:value="newTags"
@update:value="validateNewTags"
/>
</n-flex>
</n-form-item>
</n-form>
<TextEditor
v-if="ready"
v-model:value="problem.description"
title="题目的描述"
:min-height="300"
/>
<TextEditor
v-if="ready && !isSQLProblem"
v-model:value="problem.input_description"
title="输入的描述"
/>
<TextEditor
v-if="ready && !isSQLProblem"
v-model:value="problem.output_description"
title="输出的描述"
/>
<template v-if="!isSQLProblem">
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
<n-flex justify="space-between" align="center">
<strong>测试样例 {{ index + 1 }}</strong>
<n-button
tertiary
type="warning"
size="small"
@click="removeSample(index)"
>
删除 {{ index + 1 }}
</n-button>
</n-flex>
<n-grid x-gap="20" cols="2">
<n-gi span="1">
<n-flex vertical>
<span>输入样例</span>
<n-input type="textarea" v-model:value="sample.input" />
</n-flex>
</n-gi>
<n-gi span="1">
<n-flex vertical>
<span>输出样例</span>
<n-input type="textarea" v-model:value="sample.output" />
</n-flex>
</n-gi>
</n-grid>
</div>
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
添加用例
</n-button>
</template>
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
<n-form>
<n-form-item label="题目的来源(选填)">
<n-input
v-model:value="problem.source"
placeholder="比如来自某道题的改编等,或者网上的资料"
/>
</n-form-item>
<n-form-item label="本题的考察知识点(选填,用于 AI 分析)">
<n-input
v-model:value="problem.prompt"
placeholder="比如考察选择、循环、算法等知识点"
/>
</n-form-item>
</n-form>
<n-divider />
<h2 class="title">代码区域</h2>
<n-form inline label-placement="left">
<n-form-item label="编程语言">
<n-checkbox-group v-model:value="problem.languages">
<n-flex align="center">
<n-checkbox
v-for="(language, index) in languageOptions"
:key="index"
:value="language.value"
:label="language.label"
/>
</n-flex>
</n-checkbox-group>
</n-form-item>
<n-form-item v-if="!isSQLProblem">
<n-checkbox
v-model:checked="needTemplate"
label="预制代码(显示在编辑器中,帮助快速上手)"
/>
</n-form-item>
<n-form-item>
<n-button
v-if="needTemplate"
size="small"
tertiary
type="warning"
@click="resetTemplate(currentActiveTemplate)"
>
重置 {{ LANGUAGE_SHOW_VALUE[currentActiveTemplate] }} 的预制代码
</n-button>
</n-form-item>
</n-form>
<n-form
v-if="isSQLProblem && problem.sql_config"
inline
label-placement="left"
>
<n-form-item label="SQL 题型">
<n-radio-group v-model:value="problem.sql_config.mode">
<n-radio-button value="query">查询题比对查询结果</n-radio-button>
<n-radio-button value="modify">
增删改题比对执行后的表数据
</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="严格比对行顺序">
<n-switch v-model:value="problem.sql_config.order_sensitive" />
<n-text depth="3" style="margin-left: 12px">
题目要求 ORDER BY 时开启关闭则按无序集合比对
</n-text>
</n-form-item>
</n-form>
<n-grid :cols="2" x-gap="20">
<n-gi>
<n-form>
<n-form-item
:label="
isSQLProblem
? '标准答案(必填,判题依据:每个测试点会运行它生成期望结果)'
: '本题参考答案(选填,用于 AI 分析,不会泄露)'
"
>
<n-tabs
type="segment"
default-value="Python3"
v-model:value="currentActiveAnswer"
>
<n-tab-pane
v-for="(answer, index) in problem.answers"
:key="index"
:name="answer.language"
>
<CodeEditor
v-model:value="answer.code"
:language="answer.language"
:font-size="16"
height="300px"
/>
</n-tab-pane>
</n-tabs>
</n-form-item>
</n-form>
</n-gi>
<n-gi>
<n-form v-if="needTemplate">
<n-form-item label="编写预制代码">
<n-tabs
type="segment"
default-value="Python3"
v-model:value="currentActiveTemplate"
>
<n-tab-pane
v-for="(lang, index) in problem.languages"
:key="index"
:name="lang"
>
<CodeEditor
v-model:value="template[lang]"
:language="lang"
:font-size="16"
height="300px"
/>
</n-tab-pane>
</n-tabs>
</n-form-item>
</n-form>
</n-gi>
</n-grid>
<n-grid v-if="!isSQLProblem" :cols="2">
<n-gi :span="1">
<AstRulesEditor
v-model="problem.ast_rules!"
:languages="problem.languages"
/>
</n-gi>
</n-grid>
<n-divider />
<h2 class="title">测试用例区域</h2>
<n-flex v-if="!isSQLProblem" align="center" style="margin-bottom: 12px">
<div>
<n-button type="success" @click="showGeneratorModal = true">
直接生成
</n-button>
</div>
<div>
<n-upload
:show-file-list="false"
accept=".zip"
:custom-request="handleUploadTestcases"
>
<n-button type="info">手动上传</n-button>
</n-upload>
</div>
<n-tooltip placement="right" style="max-width: 320px; white-space: normal">
<template #trigger>
<n-button text>温馨提醒</n-button>
</template>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</n-tooltip>
</n-flex>
<SQLTestcaseEditor
v-if="isSQLProblem"
:answers="problem.answers"
:mode="problem.sql_config?.mode ?? 'query'"
:problem-id="problem.id"
@uploaded="handleTestcasesGenerated"
/>
<n-alert
class="box"
v-if="problem.test_case_score.length"
:show-icon="false"
type="info"
>
<template #header>
<n-flex align="center">
<div>
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
{{ problem.test_case_score.length }}
条测试用例
</div>
<n-button
v-if="problem.id"
tertiary
type="info"
size="small"
@click="downloadTestcases"
>
下载
</n-button>
</n-flex>
</template>
</n-alert>
<n-modal
v-model:show="showGeneratorModal"
preset="card"
title="测试用例生成器"
style="width: 80vw; max-width: 900px"
:mask-closable="false"
display-directive="show"
>
<TestcaseGenerator
:answers="problem.answers"
:samples="problem.samples"
@uploaded="handleTestcasesGenerated"
/>
</n-modal>
<template v-if="!isSQLProblem">
<n-divider />
<h2 class="title">流程图区域</h2>
<!-- 流程图相关设置 -->
<n-form inline label-placement="left" :show-feedback="false">
<n-form-item label="根据上面的【Python答案】智能生成 Mermaid 代码">
<n-button
type="primary"
size="small"
:disabled="
!problem.answers.filter((a) => a.language === 'Python3')[0]?.code
.length
"
:loading="isAIGenerating"
@click="generateMermaid"
>
AI 生成
</n-button>
</n-form-item>
<n-form-item label="允许提交流程图">
<n-switch v-model:value="problem.allow_flowchart" />
</n-form-item>
<n-form-item label="显示标准流程图">
<n-switch v-model:value="problem.show_flowchart" />
</n-form-item>
</n-form>
<n-form>
<n-form-item>
<MermaidEditor
v-model="problem.mermaid_code"
@render-success="onMermaidRenderSuccess"
/>
</n-form-item>
<n-form-item label="流程图提示信息(选填)">
<n-input
v-model:value="problem.flowchart_hint"
placeholder="请输入流程图相关的提示信息,帮助学生理解题目要求"
/>
</n-form-item>
</n-form>
</template>
<n-flex style="margin: 16px 0 120px" align="center" justify="end">
<n-button type="primary" @click="submit">提交</n-button>
</n-flex>
</template>
<style scoped>
.title {
margin-top: 0;
}
.box {
margin-bottom: 20px;
}
.w-100 {
width: 100px;
}
.problemTitleInput {
width: 300px;
}
.addSamples {
width: 100%;
}
</style>