## 两个开关同时打开会做出一个空 tab 后端在 `allowFlowchart` 为真时把 `mermaidCode` 置成 null(不能把标准答案下发给 正要自己画图的学生,见 routes/problem.ts)。而前端 `tabOptions` 只看 `showFlowchart` 就把 "flowchart" 加进选项,面板那边却要求 `showFlowchart && mermaidCode` —— 两个开关都打开时,选项存在、面板不存在, URL 里带 `?tab=flowchart` 就会选中一个渲染不出任何东西的页签。 三个断点条件还各不相同(两处要求两者都有,第三处只看 showFlowchart,那处会拿 null 去渲染 ProblemFlowchart)。统一成一个 `canShowFlowchart`。 后台那边把「显示标准流程图」在允许提交流程图时置灰并说明原因,再补一个 watch 把存量数据里两个都开着的情况纠正掉 —— 它们本来就是互斥的。 ## 「渲染成功」只进不出 保存前的校验靠 `mermaidRenderSuccess`,而 MermaidEditor 只在成功时 emit、 这个 ref 也就只会从 false 变 true,永不复位。**先写对、再改坏,照样能存进库。** 改成上报渲染结果本身(`render-state`),并在 modelValue 一变就立刻打回 「未验证」,等防抖后的渲染真跑完再报结论 —— 只挂防抖那一支的话,改完 300ms 内 点保存读到的还是上一次的结论,刚改坏的代码会被当成校验通过。宁可让出题人多等 一下,也不能放脏数据进库。 实测:改动后 50ms 读到 false(此时保存会被拦),渲染完成后回到 true; 贴一段坏语法则一直是 false。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
931 lines
26 KiB
Vue
931 lines
26 KiB
Vue
<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, 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: "",
|
||
inputDescription: "",
|
||
outputDescription: "",
|
||
timeLimit: 1000,
|
||
memoryLimit: 64,
|
||
difficulty: "Low",
|
||
visible: false,
|
||
shareSubmission: false,
|
||
tags: [],
|
||
languages: ["Python3", "C"] as LANGUAGE[],
|
||
template: {} as { [key in LANGUAGE]?: string },
|
||
samples: [
|
||
{ input: "", output: "" },
|
||
{ input: "", output: "" },
|
||
{ input: "", output: "" },
|
||
],
|
||
testCaseId: "",
|
||
testCaseScore: [] as Testcase[],
|
||
hint: "",
|
||
source: "",
|
||
prompt: "",
|
||
answers: [] as { language: LANGUAGE; code: string }[],
|
||
contestId: null,
|
||
allowFlowchart: false,
|
||
showFlowchart: false,
|
||
mermaidCode: "",
|
||
flowchartHint: "",
|
||
astRules: null,
|
||
sqlConfig: null,
|
||
sqlDisplay: 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
|
||
// 两个流程图开关是互斥的:allowFlowchart 为真时后端不会把 mermaidCode 下发给
|
||
// 学生,showFlowchart 就成了一个点进去什么都没有的 tab。UI 上已经把开关置灰,
|
||
// 这里再把存量数据里两个都开着的情况纠正掉。
|
||
watch(
|
||
() => problem.value?.allowFlowchart,
|
||
(allow) => {
|
||
if (allow) problem.value.showFlowchart = false
|
||
},
|
||
)
|
||
|
||
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.sqlConfig) {
|
||
problem.value.sqlConfig = { mode: "query", order_sensitive: false }
|
||
}
|
||
currentActiveAnswer.value = "SQL"
|
||
// 代码规则检查基于 Python/C 的 AST 解析,对 SQL 没有意义,清空避免脏数据
|
||
if (problem.value.astRules) {
|
||
problem.value.astRules = null
|
||
}
|
||
// 流程图依赖 Python 答案生成,对 SQL 没有意义
|
||
problem.value.allowFlowchart = false
|
||
problem.value.showFlowchart = false
|
||
} else if (problem.value.sqlConfig) {
|
||
problem.value.sqlConfig = 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.inputDescription = data.inputDescription
|
||
problem.value.outputDescription = data.outputDescription
|
||
problem.value.timeLimit = data.timeLimit
|
||
problem.value.memoryLimit = data.memoryLimit
|
||
problem.value.memoryLimit = data.memoryLimit
|
||
problem.value.difficulty = data.difficulty
|
||
problem.value.visible = data.visible
|
||
problem.value.shareSubmission = data.shareSubmission
|
||
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.testCaseId = data.testCaseId
|
||
problem.value.testCaseScore = data.testCaseScore
|
||
problem.value.hint = data.hint ?? ""
|
||
problem.value.source = data.source
|
||
problem.value.prompt = data.prompt
|
||
// 流程图相关字段
|
||
problem.value.allowFlowchart = data.allowFlowchart
|
||
problem.value.showFlowchart = data.showFlowchart
|
||
problem.value.mermaidCode = data.mermaidCode ?? ""
|
||
problem.value.flowchartHint = data.flowchartHint ?? ""
|
||
problem.value.astRules = data.astRules ?? null
|
||
problem.value.sqlConfig = data.sqlConfig ?? 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.contestId) {
|
||
problem.value.contestId = problem.value.contestId
|
||
}
|
||
|
||
// 下面是用来显示的:
|
||
// 代码模板 和 模板开关
|
||
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
|
||
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 {
|
||
// 失败走 catch —— 原来这里还有一句 `if (res.error)`(拿 { error, data }
|
||
// 信封当返回值),被 @ts-ignore 压着,信封拆掉之后就是一段死代码了
|
||
const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value })
|
||
// score 不在上传响应里,前端按测试点数量平分补上
|
||
const entries = res.info
|
||
const testcases: Testcase[] = entries.map((entry) => ({
|
||
input_name: entry.input_name,
|
||
output_name: entry.output_name,
|
||
// 取值与原来的 `.toFixed(0)` 逐字相同,只是不再包成字符串
|
||
score: Number((100 / entries.length).toFixed(0)),
|
||
}))
|
||
problem.value.testCaseScore = testcases
|
||
problem.value.testCaseId = res.id
|
||
} catch (err) {
|
||
message.error("上传测试用例失败")
|
||
}
|
||
}
|
||
|
||
function downloadTestcases() {
|
||
download(`problems/${problem.value.id}/test-cases`)
|
||
}
|
||
|
||
// Mermaid 渲染事件处理。这里必须原样接受 false ——
|
||
// 原来只在成功时置 true、永不复位,先写对再改坏就能把语法错误的代码存进库
|
||
function onMermaidRenderState(ok: boolean) {
|
||
mermaidRenderSuccess.value = ok
|
||
}
|
||
|
||
// 题目是否有漏写的
|
||
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.inputDescription || !problem.value.outputDescription))
|
||
) {
|
||
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.testCaseScore.length === 0) {
|
||
message.error("测试用例没有上传")
|
||
hasErrors = true
|
||
} else if (problem.value.languages.length === 0) {
|
||
message.error("编程语言没有选择")
|
||
hasErrors = true
|
||
}
|
||
// SQL 题验证
|
||
else if (isSQLProblem.value && !problem.value.sqlConfig?.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.showFlowchart || problem.value.allowFlowchart) {
|
||
if (!problem.value.mermaidCode || problem.value.mermaidCode.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.contestId = Number(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.error === "display-id-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.mermaidCode = res.flowchart
|
||
}
|
||
|
||
const showGeneratorModal = ref(false)
|
||
|
||
function handleTestcasesGenerated(
|
||
testCaseId: string,
|
||
testCaseScore: Testcase[],
|
||
) {
|
||
problem.value.testCaseId = testCaseId
|
||
problem.value.testCaseScore = 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.inputDescription"
|
||
title="输入的描述"
|
||
/>
|
||
<TextEditor
|
||
v-if="ready && !isSQLProblem"
|
||
v-model:value="problem.outputDescription"
|
||
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.sqlConfig"
|
||
inline
|
||
label-placement="left"
|
||
>
|
||
<n-form-item label="SQL 题型">
|
||
<n-radio-group v-model:value="problem.sqlConfig.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.sqlConfig.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.astRules!"
|
||
: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.sqlConfig?.mode ?? 'query'"
|
||
:problem-id="problem.id"
|
||
@uploaded="handleTestcasesGenerated"
|
||
/>
|
||
|
||
<n-alert
|
||
class="box"
|
||
v-if="problem.testCaseScore.length"
|
||
:show-icon="false"
|
||
type="info"
|
||
>
|
||
<template #header>
|
||
<n-flex align="center">
|
||
<div>
|
||
测试组编号 {{ problem.testCaseId.slice(0, 12) }} 共有
|
||
{{ problem.testCaseScore.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.allowFlowchart" />
|
||
</n-form-item>
|
||
<n-form-item label="显示标准流程图">
|
||
<n-flex align="center">
|
||
<n-switch
|
||
v-model:value="problem.showFlowchart"
|
||
:disabled="problem.allowFlowchart"
|
||
/>
|
||
<n-text v-if="problem.allowFlowchart" depth="3" style="font-size: 12px">
|
||
让学生自己画图时,标准流程图不会下发给学生,这个开关没有意义
|
||
</n-text>
|
||
</n-flex>
|
||
</n-form-item>
|
||
</n-form>
|
||
|
||
<n-form>
|
||
<n-form-item>
|
||
<MermaidEditor
|
||
v-model="problem.mermaidCode"
|
||
@render-state="onMermaidRenderState"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item label="流程图提示信息(选填)">
|
||
<n-input
|
||
v-model:value="problem.flowchartHint"
|
||
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>
|