Files
OJ2/apps/web/src/admin/problem/detail.vue
T
xuyueandClaude Opus 5.5 02e2fa7d9b feat(后台): 手机上也能用了——菜单收进左边抽屉,列表一行一张卡,不用再横着滑
- 侧栏手机上不再占一列图标,顶栏左边 ☰ 拉出整张菜单,按返回键先关菜单
- 题目、比赛、用户、公告、题单列表手机上一项一张卡:名字一行、小字一行、按钮一行,
  「学生看得到」开关单独一列靠右
- 卡点分析、AI 报告手机上点开一项整屏看,返回键关掉;年度趋势、自学情况、首页改成上下排
- 出题页、教程编辑、题单编辑、标签、设置手机上排成一列;测试数据「删掉」手机上常驻
- 弹框一律收进屏幕宽度

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:21:58 +08:00

1749 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { PROBLEM_TAG_MAX_LENGTH, TEST_CASE_MAX_CASES, type RunnableLanguage } from "@oj2/contract"
import { StorageSerializers } from "@vueuse/core"
import { useThemeVars } from "naive-ui"
import PageHeader from "admin/components/PageHeader.vue"
import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue"
import { errorCode, errorMessage } from "utils/api"
import { DIFFICULTY, STORAGE_KEY } from "utils/constants"
import download from "utils/download"
import { createZipBlob, parseTime, unique } from "utils/functions"
import type { AdminProblem, AstRules, BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
import {
createContestProblem,
createProblem,
editProblem,
generateFlowchartFromPythonCode,
generateTestInputs,
getTestCaseFiles,
uploadTestcases,
} from "../api"
import AstRulesEditor from "./components/AstRulesEditor.vue"
import CaseTable from "./components/CaseTable.vue"
import CodePreview from "./components/CodePreview.vue"
import PreviewStrip from "./components/PreviewStrip.vue"
import { usePreviewCollapsed } from "./components/previewCollapse"
import {
caseFiles,
caseStatus,
newRow,
normalizeCase,
readCaseZip,
sameOutput,
useCaseRunner,
type AnswerSource,
type CaseRow,
} from "./components/caseRows"
import {
buildTemplate,
parseTemplate,
wrapWithTemplate,
type TemplateParts,
} from "./components/codeTemplate"
import type { AstCheckState, FlowchartMode, PreviewTab } from "./components/editorTypes"
import {
emptyDoc,
parseDoc,
toMermaid,
type FlowDoc,
} from "shared/components/FlowchartEditor/model"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
const FlowchartEditor = defineAsyncComponent(
() => import("shared/components/FlowchartEditor/index.vue"),
)
/**
* 编程题(C / C++ / Python)的出题页,设计稿「编程题出题页」方案 B:左边写,右边是学生
* 打开这道题看到的样子 —— 和 SQL 出题页(SqlDetail.vue)同一个结构。两种题由 editor.vue 分流。
*
* 和原来那页最大的不同:
* - 标准答案是中心。例子和测试数据的输出都由它在判题机上跑出来,不再手抄;
* - 例子就是测试数据里勾了「当例子」的那几组,不再单独一份;
* - 改老题时测试数据从文件读回来就地改,保存时整包重传(太大的除外,见契约 TEST_CASE_EDIT_*);
* - 预制代码只写学生看得到的那段,PREPEND / APPEND 的标记在 codeTemplate.ts 里拼。
*/
const props = defineProps<{
problemID?: string
contestID?: string
/** 编辑时外层已经拉过一次题目(要靠它判断题型),直接给进来 */
initial?: AdminProblem | null
}>()
const message = useMessage()
const dialog = useDialog()
const router = useRouter()
const theme = useThemeVars()
const isCreate = computed(() => !props.problemID)
const RUNNABLE: RunnableLanguage[] = ["Python", "C", "C++"]
// ---------------------------------------------------------------- 表单
interface CodeForm {
_id: string
title: string
difficulty: BlankProblem["difficulty"]
visible: boolean
tags: string[]
description: string
inputDescription: string
outputDescription: string
hint: string
source: string
prompt: string
timeLimit: number
memoryLimit: number
languages: RunnableLanguage[]
answers: Record<RunnableLanguage, string>
templates: Record<RunnableLanguage, TemplateParts>
astRules: AstRules | null
flowchartMode: FlowchartMode
mermaidCode: string
/** 画布画的参考流程图;null 是老题(只有手写的 Mermaid)或者还没画 */
flowchartDoc: FlowDoc | null
flowchartHint: string
}
const blankParts = (): TemplateParts => ({ prepend: "", template: "", append: "" })
const blankForm = (): CodeForm => ({
_id: "",
title: "",
difficulty: "Low",
visible: false,
tags: [],
description: "",
inputDescription: "",
outputDescription: "",
hint: "",
source: "",
prompt: "",
timeLimit: 1000,
memoryLimit: 64,
languages: ["Python", "C"],
answers: { Python: "", C: "", "C++": "" },
templates: { Python: blankParts(), C: blankParts(), "C++": blankParts() },
astRules: null,
flowchartMode: "none",
mermaidCode: "",
flowchartDoc: null,
flowchartHint: "",
})
const form = reactive<CodeForm>(blankForm())
/** 富文本编辑器清空之后留下的是一个空段落,不是空串 */
const isBlankHtml = (html: string) => !html.trim() || html === "<p><br></p>"
const difficultyOptions = (["Low", "Mid", "High"] as const).map((value) => ({
label: DIFFICULTY[value],
value,
}))
const NO_INPUT = "<p>没有输入</p>"
// ---------------------------------------------------------------- 标签
const tagList = shallowRef<Tag[]>([])
const knowledgeTags = computed(() =>
tagList.value
.filter((t) => t.category === "knowledge")
.sort((a, b) => b.problemCount - a.problemCount)
.map((t) => t.name),
)
const knowledgeSet = computed(() => new Set(knowledgeTags.value))
const showAllKnowledge = ref(false)
/** 知识点先露最常用的 10 个,选中的始终露着 */
const visibleKnowledge = computed(() => {
if (showAllKnowledge.value) return knowledgeTags.value
const top = knowledgeTags.value.slice(0, 10)
return unique([...top, ...knowledgeTags.value.filter((t) => form.tags.includes(t))])
})
/** 主题和新建的标签走下拉(主题 25 个、还会越来越多,铺不开) */
const otherTags = computed(() => form.tags.filter((t) => !knowledgeSet.value.has(t)))
const themeOptions = computed(() =>
unique([
...tagList.value.filter((t) => t.category === "theme").map((t) => t.name),
...otherTags.value,
]).map((name) => ({ label: name, value: name })),
)
function toggleKnowledge(name: string) {
form.tags = form.tags.includes(name) ? form.tags.filter((t) => t !== name) : [...form.tags, name]
}
function onOtherTagsChange(tags: string[]) {
const tooLong = tags.find((t) => t.length > PROBLEM_TAG_MAX_LENGTH)
if (tooLong) {
message.error(`标签最多 ${PROBLEM_TAG_MAX_LENGTH} 个字:${tooLong}`)
return
}
const others = unique(tags.map((t) => t.trim()).filter(Boolean))
form.tags = unique([...form.tags.filter((t) => knowledgeSet.value.has(t)), ...others])
}
// ---------------------------------------------------------------- 标准答案
const answerTab = ref<RunnableLanguage>("Python")
watch(
() => form.languages,
(langs) => {
if (langs.length && !langs.includes(answerTab.value)) answerTab.value = langs[0]!
},
)
/**
* 拿来跑测试数据的那份答案:开着的那个页签有答案就用它,否则按 Python → C → C++ 找第一份。
* 套上题目模板的前后两段,和学生提交时拼出来的是同一份代码。
*/
const source = computed<AnswerSource | null>(() => {
const candidates = [answerTab.value, ...RUNNABLE].filter((lang) => form.languages.includes(lang))
const lang = candidates.find((l) => form.answers[l].trim())
if (!lang) return null
return { language: lang, code: wrapWithTemplate(form.answers[lang], form.templates[lang]) }
})
/** 代码框跟着行数长:5 行的答案别占 260 高,长答案也别撑出一屏 */
function codeHeight(code: string, minLines: number, maxLines: number) {
const lines = Math.min(Math.max(code.split("\n").length + 1, minLines), maxLines)
return `${lines * 21 + 12}px`
}
const answerSummary = computed(() => {
const written = form.languages.filter((l) => form.answers[l].trim())
if (!written.length) return "还没写"
const lines = form.answers[written[0]!].trim().split("\n").length
return `${written.join("、")} · ${lines} 行`
})
// ---------------------------------------------------------------- 测试数据
const rows = ref<CaseRow[]>([])
/** 太大不展开的测试数据:只有数量和大小,例子单独写在 exampleRows */
const big = ref<{ count: number; totalBytes: number } | null>(null)
const exampleRows = ref<CaseRow[]>([])
/** 现在挂在题目上的测试点 */
const testCase = reactive({ id: "", score: [] as Testcase[] })
/** 打开时读回来的那份(没改过就不用重传) */
let loadedCases: { input: string; output: string }[] | null = null
/** 页面顶上的说明:测试点读不出来、原来的例子补进了测试数据…… */
const caseNotice = ref("")
const { running, runAll, rerunFailed } = useCaseRunner(rows, source)
const examplesRunner = useCaseRunner(exampleRows, source)
watchDebounced(
() => [source.value, rows.value.map((r) => `${r.key}:${r.input}`)] as const,
() => void runAll(),
{ debounce: 600, deep: true },
)
watchDebounced(
() => [source.value, exampleRows.value.map((r) => `${r.key}:${r.input}`)] as const,
() => void examplesRunner.runAll(),
{ debounce: 600, deep: true },
)
const examples = computed(() =>
(big.value ? exampleRows.value : rows.value.filter((r) => r.example)).map((r) => ({
input: r.input,
output: r.output,
})),
)
/** 「测试数据第 1–4 组」 */
const examplesNote = computed(() => {
if (big.value) return ""
const picked = rows.value.flatMap((r, i) => (r.example ? [i + 1] : []))
if (!picked.length) return ""
const contiguous = picked.every((n, i) => i === 0 || n === picked[i - 1]! + 1)
if (picked.length === 1) return `测试数据第 ${picked[0]} 组`
return contiguous
? `测试数据第 ${picked[0]}–${picked.at(-1)} 组`
: `测试数据第 ${picked.join("、")} 组`
})
const caseSummary = computed(() => {
if (big.value) return `${big.value.count} 组 · ${formatBytes(big.value.totalBytes)}`
const statuses = rows.value.map((r) => caseStatus(r, source.value))
const examplesCount = rows.value.filter((r) => r.example).length
const failed = statuses.filter((s) => s === "error").length
const changed = statuses.filter((s) => s === "changed").length
const parts = [`${rows.value.length} 组`]
if (failed) parts.push(`${failed} 组没跑通`)
if (changed) parts.push(`${changed} 组输出变了`)
parts.push(examplesCount ? `${examplesCount} 组当例子` : "还没勾例子")
return parts.join(" · ")
})
const changedCount = computed(
() => rows.value.filter((r) => caseStatus(r, source.value) === "changed").length,
)
const saveNote = computed(() => {
if (big.value) return "例子都跑通了"
const picked = rows.value.filter((r) => r.example).length
const base = source.value
? `${rows.value.length} 组都跑通了,${picked} 组当例子`
: `${rows.value.length} 组,${picked} 组当例子`
return changedCount.value
? `${base} · ${changedCount.value} 组的输出和原来不一样(保存后按新的判,交过的不会自动重判)`
: base
})
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
/** 测试数据加满了(见契约 TEST_CASE_MAX_CASES) */
const casesFull = computed(() => rows.value.length >= TEST_CASE_MAX_CASES)
function addRow() {
if (casesFull.value) return
// 空着的第一组(新建页)直接用掉;例子一组都还没勾时,新加的这组先勾上
const noExample = !rows.value.some((r) => r.example)
rows.value.push(newRow("", "", noExample))
}
const generatingInputs = ref(false)
async function aiInputs() {
const answer = source.value
if (!answer) return
generatingInputs.value = true
try {
const res = await generateTestInputs({
description: form.description,
inputDescription: form.inputDescription,
language: answer.language,
answer: form.answers[answer.language],
existing: rows.value.map((r) => r.input).slice(0, 100),
})
if (!res.inputs.length) {
message.info("AI 没想出新的输入")
return
}
// 空着的行先填掉,再往后加,加到上限为止
const blanks = rows.value.filter((r) => !r.input.trim() && !r.output.trim())
const inputs = res.inputs.slice(0, blanks.length + TEST_CASE_MAX_CASES - rows.value.length)
if (!inputs.length) {
message.info(`测试数据最多 ${TEST_CASE_MAX_CASES} 组,已经满了`)
return
}
for (const input of inputs) {
const blank = blanks.shift()
if (blank) blank.input = input
else rows.value.push(newRow(input))
}
message.success(
inputs.length < res.inputs.length
? `加了 ${inputs.length} 组,到 ${TEST_CASE_MAX_CASES} 组的上限了`
: `加了 ${inputs.length} 组,输出马上跑出来`,
)
} catch (err) {
message.error(errorMessage(err, "AI 没想出来,稍后再试"))
} finally {
generatingInputs.value = false
}
}
/** 导入 zip:拆得开就换成一组组;太大就直接上传,换成「不展开」 */
async function importZip({ file }: UploadCustomRequestOptions) {
const raw = file.file
if (!raw) return
const parsed = readCaseZip(new Uint8Array(await raw.arrayBuffer()))
if (parsed.ok) {
const exampleInputs = new Set(examples.value.map((e) => e.input.trimEnd()))
rows.value = parsed.cases.map((c) =>
newRow(c.input, c.output, exampleInputs.has(c.input.trimEnd())),
)
if (!rows.value.some((r) => r.example) && rows.value[0]) rows.value[0].example = true
big.value = null
message.success(`导入了 ${parsed.cases.length} 组`)
return
}
if (parsed.reason !== "too-large") {
message.error(parsed.reason)
return
}
try {
// 先把现在的例子拿下来:big 一设上,examples 就改读 exampleRows 了
const keep = examples.value
const res = await uploadTestcases(raw)
setTestCase(res.id, res.info)
big.value = {
count: res.info.length,
totalBytes: res.info.reduce((sum, e) => sum + e.input_size + e.output_size, 0),
}
if (!exampleRows.value.length) {
exampleRows.value = keep.map((e) => newRow(e.input, e.output, true))
}
rows.value = []
loadedCases = null
message.success(`导入了 ${res.info.length} 组(太大,页面上不展开)`)
} catch (err) {
message.error(errorMessage(err, "上传失败"))
}
}
function downloadZip() {
if (big.value) {
if (props.initial) download(`problems/${props.initial.id}/test-cases`)
return
}
const blob = createZipBlob(caseFiles(rows.value))
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `${form._id || "problem"}_test_cases.zip`
link.click()
URL.revokeObjectURL(url)
}
function setTestCase(id: string, info: { input_name: string; output_name: string }[]) {
// 分值平分,余数给最后一组;只留落库的三个键(见契约 problemTestCaseScoreSchema)
const base = Math.floor(100 / info.length)
const rest = 100 - base * info.length
testCase.id = id
testCase.score = info.map((entry, i) => ({
input_name: entry.input_name,
output_name: entry.output_name,
score: i === info.length - 1 ? base + rest : base,
}))
}
// ---------------------------------------------------------------- 学生怎么写 · 流程图
const templateTab = ref<RunnableLanguage>("Python")
watch(
() => form.languages,
(langs) => {
if (langs.length && !langs.includes(templateTab.value)) templateTab.value = langs[0]!
},
{ immediate: true },
)
const showHidden = ref(false)
const astChecks = ref<AstCheckState>({})
const answersForAst = computed(() => ({ ...form.answers }) as Partial<Record<LANGUAGE, string>>)
const templatesForPreview = computed(
() => ({ ...form.templates }) as Partial<Record<LANGUAGE, TemplateParts>>,
)
const writeSummary = computed(() => {
const langs = form.languages.length ? `${form.languages.join("、")} 可交` : "还没选语言"
const withTemplate = form.languages.filter((l) => form.templates[l].template.trim())
const tpl = withTemplate.length ? `预制代码 ${withTemplate.join("、")}` : "没有预制代码"
const ruleCount = form.languages.reduce((n, l) => n + (form.astRules?.[l]?.length ?? 0), 0)
return `${langs} · ${tpl} · ${ruleCount ? `${ruleCount} 条语法要求` : "没有语法要求"}`
})
const FLOWCHART_LABEL: Record<FlowchartMode, string> = {
none: "不用",
show: "给学生看参考流程图",
draw: "让学生自己画",
}
const mermaidOk = ref(false)
/**
* 老题的参考图是手写的 Mermaid(没有结构树):照旧给代码框;新画的、或者点了「改用画布画」
* 的用画布。保存时画布的图以它为准,后端照它重新生成 Mermaid
*/
const legacyMermaid = computed(() => !form.flowchartDoc && !!form.mermaidCode.trim())
/** 右边预览画的那张:画布有图就照画布生成,不然是老的代码 */
const previewMermaid = computed(() => {
if (!form.flowchartDoc) return form.mermaidCode
return form.flowchartDoc.steps.length ? toMermaid(form.flowchartDoc) : ""
})
const aiDrawing = ref(false)
async function aiFlowchart() {
aiDrawing.value = true
try {
const res = await generateFlowchartFromPythonCode(form.answers.Python)
const doc = parseDoc(res.doc)
if (!doc) throw new Error("AI 画出来的图读不了")
form.flowchartDoc = doc
preview.value = "flowchart"
} catch (err) {
message.error(errorMessage(err, "生成失败,请稍后再试"))
} finally {
aiDrawing.value = false
}
}
const moreSummary = computed(() => {
const parts: string[] = []
if (!isBlankHtml(form.hint)) parts.push("有提示")
if (form.source.trim()) parts.push(`来源:${form.source.trim()}`)
if (form.timeLimit !== 1000 || form.memoryLimit !== 64)
parts.push(`${form.timeLimit} 毫秒 / ${form.memoryLimit}MB`)
if (form.prompt.trim()) parts.push("给 AI 的知识点")
return parts.length ? parts.join(" · ") : "提示、来源、时间 / 内存限制、给 AI 看的知识点"
})
// ---------------------------------------------------------------- 折叠 · 右栏跟着走
const open = reactive({
statement: true,
answer: true,
cases: true,
write: false,
flowchart: false,
more: false,
})
const preview = ref<PreviewTab>("statement")
const previewCollapsed = usePreviewCollapsed()
watch(
() => open.write,
(v) => v && (preview.value = "editor"),
)
watch(
() => open.flowchart,
(v) => v && (preview.value = "flowchart"),
)
watch(
() => [form.templates, form.astRules],
() => open.write && (preview.value = "editor"),
{ deep: true },
)
watch(
() => [form.mermaidCode, form.flowchartMode],
() => open.flowchart && (preview.value = "flowchart"),
)
// ---------------------------------------------------------------- 加载 · 草稿
interface Draft {
form: CodeForm
rows: { input: string; output: string; example: boolean }[]
}
// 新建时存草稿(编辑页不存:原来编辑页也往同一个 key 里写,没保存就离开的话,
// 那道题的 id 会跟着草稿漂到新建页)。初值 null 时 VueUse 推不出类型,得显式指定
const draft = useLocalStorage<Draft | null>(STORAGE_KEY.ADMIN_CODE_PROBLEM, null, {
serializer: StorageSerializers.object,
})
const ready = ref(false)
const draftRestored = ref(false)
function snapshot() {
return JSON.stringify([
form,
rows.value.map((r) => [r.input, r.output, r.example]),
exampleRows.value.map((r) => [r.input, r.output]),
testCase.id,
])
}
let baseline = ""
watchDebounced(
() => [form, rows.value.map((r) => [r.input, r.output, r.example])] as const,
() => {
if (!ready.value || !isCreate.value) return
const empty =
!form._id &&
!form.title &&
isBlankHtml(form.description) &&
!RUNNABLE.some((l) => form.answers[l].trim()) &&
!rows.value.some((r) => r.input.trim() || r.output.trim())
draft.value = empty
? null
: {
form: JSON.parse(JSON.stringify(form)),
rows: rows.value.map((r) => ({ input: r.input, output: r.output, example: r.example })),
}
},
{ debounce: 800, deep: true },
)
function clearDraft() {
draft.value = null
Object.assign(form, blankForm())
rows.value = [newRow("", "", true)]
draftRestored.value = false
}
function loadDraft() {
// 旧版出题页的草稿(另一个 key、另一种形状)不认了
localStorage.removeItem(STORAGE_KEY.ADMIN_PROBLEM)
const saved = draft.value
// 草稿是本机存的,形状不对(旧版本、被别的页面写坏)就当没有,别让整页起不来
if (saved && typeof saved.form === "object" && Array.isArray(saved.rows)) {
const merged = { ...blankForm(), ...saved.form }
merged.answers = { ...blankForm().answers, ...saved.form.answers }
merged.templates = { ...blankForm().templates, ...saved.form.templates }
Object.assign(form, merged)
rows.value = saved.rows.map((r) =>
newRow(String(r.input ?? ""), String(r.output ?? ""), !!r.example),
)
draftRestored.value = true
} else {
draft.value = null
}
if (!rows.value.length) rows.value = [newRow("", "", true)]
}
function sameInput(a: string, b: string) {
return sameOutput(a, b)
}
async function loadProblem(data: AdminProblem) {
const languages = RUNNABLE.filter((l) => data.languages.includes(l))
const answers = blankForm().answers
for (const a of data.answers) {
if (RUNNABLE.includes(a.language as RunnableLanguage)) {
answers[a.language as RunnableLanguage] = a.code
}
}
const templates = blankForm().templates
for (const lang of RUNNABLE) templates[lang] = parseTemplate(data.template[lang])
Object.assign(form, {
_id: data._id,
title: data.title,
difficulty: data.difficulty,
visible: data.visible,
tags: data.tags,
description: data.description,
inputDescription: data.inputDescription,
outputDescription: data.outputDescription,
hint: data.hint ?? "",
source: data.source ?? "",
prompt: data.prompt ?? "",
timeLimit: data.timeLimit,
memoryLimit: data.memoryLimit,
languages: languages.length ? languages : ["Python"],
answers,
templates,
astRules: data.astRules ?? null,
flowchartMode: data.allowFlowchart ? "draw" : data.showFlowchart ? "show" : "none",
mermaidCode: data.mermaidCode ?? "",
flowchartDoc: parseDoc(data.flowchartData),
flowchartHint: data.flowchartHint ?? "",
} satisfies CodeForm)
answerTab.value = RUNNABLE.find((l) => languages.includes(l) && answers[l].trim()) ?? "Python"
testCase.id = data.testCaseId
testCase.score = data.testCaseScore
let files: Awaited<ReturnType<typeof getTestCaseFiles>> | null = null
try {
files = await getTestCaseFiles(data.id)
} catch {
files = null
}
if (!files) {
caseNotice.value =
"这道题的测试点文件读不出来(可能被清理掉了)。下面是题面上的例子,补几组测试数据再保存,会换成新的一份。"
rows.value = data.samples.map((s) => newRow(s.input, s.output, true))
testCase.id = ""
return
}
if (!files.editable) {
big.value = { count: files.count, totalBytes: files.totalBytes }
exampleRows.value = data.samples.map((s) => newRow(s.input, s.output, true, s.output))
return
}
loadedCases = files.cases.map((c) => normalizeCase(c.input, c.output))
rows.value = loadedCases.map((c) => newRow(c.input, c.output, false, c.output))
// 例子对回测试数据:输入一样的那组勾上;对不上的(老题的例子多半不在测试数据里)补到最后
let appended = 0
for (const sample of data.samples) {
const match = rows.value.find((r) => !r.example && sameInput(r.input, sample.input))
if (match) match.example = true
else {
rows.value.push(newRow(sample.input, sample.output, true, sample.output))
appended += 1
}
}
if (appended) {
caseNotice.value = `原来的例子有 ${appended} 组不在测试数据里,已经加在最后、勾成例子 —— 保存后它们也会拿来判。`
}
}
// ---------------------------------------------------------------- 保存
interface Blocker {
text: string
/** 点了展开哪一块 */
section: keyof typeof open
severe?: boolean
}
const blockers = computed<Blocker[]>(() => {
const list: Blocker[] = []
if (!form._id.trim() || !form.title.trim())
list.push({ text: "填编号和题目", section: "statement" })
if (isBlankHtml(form.description)) list.push({ text: "写描述", section: "statement" })
if (isBlankHtml(form.inputDescription))
list.push({ text: "写输入说明(没有输入就点「没有输入」)", section: "statement" })
if (isBlankHtml(form.outputDescription)) list.push({ text: "写输出说明", section: "statement" })
if (!form.tags.length) list.push({ text: "选一个知识点标签", section: "statement" })
if (!form.languages.length) list.push({ text: "选能交的语言", section: "write" })
if (!source.value && (isCreate.value || !rows.value.length))
list.push({ text: "写标准答案", section: "answer" })
const caseRows = big.value ? exampleRows.value : rows.value
if (!big.value && !rows.value.length) list.push({ text: "加一组测试数据", section: "cases" })
if (!big.value && rows.value.length > TEST_CASE_MAX_CASES)
list.push({
text: `删掉 ${rows.value.length - TEST_CASE_MAX_CASES} 组测试数据(最多 ${TEST_CASE_MAX_CASES} 组)`,
section: "cases",
severe: true,
})
if (caseRows.some((r) => caseStatus(r, source.value) === "running"))
list.push({ text: "等测试数据跑完", section: "cases" })
caseRows.forEach((r, i) => {
if (caseStatus(r, source.value) === "error")
list.push({
text: `改好${big.value ? `例子 ${i + 1}` : `第 ${i + 1} 组`}(答案在这组上出错了)`,
section: "cases",
severe: true,
})
})
if (!source.value && caseRows.some((r) => !r.output.trim()))
list.push({ text: "补上没写的输出", section: "cases" })
if (!examples.value.length)
list.push({
text: big.value
? "加一个例子(题面上至少给学生一个)"
: "勾一组当例子(题面上至少给学生一个)",
section: "cases",
})
for (const [lang, state] of Object.entries(astChecks.value)) {
if (!form.languages.includes(lang as RunnableLanguage)) continue
if ("error" in state) list.push({ text: state.error, section: "write", severe: true })
else
state.rules.forEach((rule) => {
if (rule.passed === false)
list.push({
text: `改好语法要求「${rule.description}」(标准答案自己没过)`,
section: "write",
severe: true,
})
})
}
if (form.flowchartMode === "show") {
if (legacyMermaid.value) {
if (!mermaidOk.value)
list.push({ text: "改好流程图的代码(画不出来)", section: "flowchart", severe: true })
} else if (!form.flowchartDoc?.steps.length) {
list.push({ text: "画参考流程图", section: "flowchart" })
}
}
// 学生画的图拼成程序拿例子和测试数据跑(flowchart/program.ts),一组例子都没有就没得评
if (form.flowchartMode === "draw" && !examples.value.length) {
list.push({ text: "勾至少一组测试数据当例子(流程图照例子评)", section: "cases" })
}
return list
})
function jump(blocker: Blocker) {
open[blocker.section] = true
nextTick(() =>
document
.getElementById(`section-${blocker.section}`)
?.scrollIntoView({ behavior: "smooth", block: "start" }),
)
}
const saving = ref(false)
async function uploadRows() {
const blob = createZipBlob(caseFiles(rows.value))
const res = await uploadTestcases(new File([blob], "testcase.zip", { type: "application/zip" }))
setTestCase(res.id, res.info)
}
function casesChanged() {
if (!testCase.id || !loadedCases) return true
if (loadedCases.length !== rows.value.length) return true
return rows.value.some(
(r, i) => r.input !== loadedCases![i]!.input || !sameOutput(r.output, loadedCases![i]!.output),
)
}
async function save() {
if (blockers.value.length) {
jump(blockers.value[0]!)
return
}
saving.value = true
try {
if (!big.value && casesChanged()) await uploadRows()
const template: Record<string, string> = {}
for (const lang of form.languages) {
const built = buildTemplate(form.templates[lang])
if (built) template[lang] = built
}
const body: BlankProblem = {
_id: form._id.trim(),
title: form.title.trim(),
description: form.description,
inputDescription: form.inputDescription,
outputDescription: form.outputDescription,
samples: examples.value,
testCaseId: testCase.id,
testCaseScore: testCase.score,
timeLimit: form.timeLimit,
memoryLimit: form.memoryLimit,
difficulty: form.difficulty,
visible: form.visible,
tags: form.tags,
languages: form.languages,
template,
hint: isBlankHtml(form.hint) ? "" : form.hint,
source: form.source.trim(),
prompt: form.prompt.trim(),
answers: form.languages
.filter((lang) => form.answers[lang].trim())
.map((lang) => ({ language: lang, code: form.answers[lang] })),
allowFlowchart: form.flowchartMode === "draw",
showFlowchart: form.flowchartMode === "show",
mermaidCode: form.flowchartMode === "none" ? form.mermaidCode : form.mermaidCode.trim(),
flowchartData:
form.flowchartMode === "show" && form.flowchartDoc?.steps.length
? { ...form.flowchartDoc }
: null,
flowchartHint: form.flowchartHint.trim(),
astRules: form.astRules,
sqlConfig: null,
sqlDisplay: null,
contestId: props.contestID ? Number(props.contestID) : null,
}
if (!isCreate.value) {
await editProblem({ ...body, id: props.initial!.id })
// 存好了:现在这份就是基准
loadedCases = rows.value.map((r) => ({ input: r.input, output: r.output }))
rows.value.forEach((r) => (r.saved = r.output))
caseNotice.value = ""
baseline = snapshot()
message.success("保存好了")
return
}
const created = props.contestID ? await createContestProblem(body) : await createProblem(body)
draft.value = null
baseline = snapshot()
message.success("题目建好了")
router.replace(
props.contestID
? {
name: "admin contest problem edit",
params: { contestID: props.contestID, problemID: created.id },
}
: { name: "admin problem edit", params: { problemID: created.id } },
)
} catch (err) {
message.error(
errorCode(err) === "display-id-exists" ? "显示编号重复了,换一个" : errorMessage(err),
)
} finally {
saving.value = false
}
}
// 改了没保存就离开:新建页有草稿兜着,编辑页没有
onBeforeRouteLeave(() => {
if (isCreate.value || !ready.value || snapshot() === baseline) return true
return new Promise<boolean>((resolve) => {
dialog.warning({
title: "还没保存",
content: "改的东西还没保存,离开就没了。",
positiveText: "不要了,离开",
negativeText: "留下来",
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
onMaskClick: () => resolve(false),
})
})
})
const backTo = computed(() =>
props.contestID
? { name: "admin contest problem list", params: { contestID: props.contestID } }
: { name: "admin problem list" },
)
const authorLine = computed(() =>
props.initial
? `${props.initial.createdBy.username} 出的 · ${parseTime(props.initial.createTime, "YYYY-MM-DD")}`
: "",
)
onMounted(async () => {
getProblemTagList().then((res) => (tagList.value = res))
if (isCreate.value) loadDraft()
else if (props.initial) await loadProblem(props.initial)
ready.value = true
await nextTick()
baseline = snapshot()
void runAll()
void examplesRunner.runAll()
})
</script>
<template>
<PageHeader :title="isCreate ? '新建题目' : '编辑题目'" :back="backTo">
<template #extra>
<span class="kindTag">编程题</span>
<n-text v-if="authorLine" depth="3" class="note">{{ authorLine }}</n-text>
</template>
<template #actions>
<n-button v-if="isCreate && draft" quaternary @click="clearDraft">清空草稿</n-button>
</template>
</PageHeader>
<div v-if="ready" class="codeEditor" :class="{ collapsed: previewCollapsed }">
<!-- 左:写 -->
<div class="write">
<div v-if="draftRestored" class="notice">上次没保存的草稿已经恢复</div>
<div v-if="caseNotice" class="notice warn">{{ caseNotice }}</div>
<!-- 题面 -->
<section id="section-statement" class="card">
<button type="button" class="cardHead" @click="open.statement = !open.statement">
<h3>题面</h3>
<span v-if="!open.statement" class="summary">
{{ form._id }} {{ form.title }} · {{ DIFFICULTY[form.difficulty] }} ·
{{ form.tags.join("、") || "没有标签" }}
</span>
<span class="grow"></span>
<span class="caret">{{ open.statement ? "⌃" : "›" }}</span>
</button>
<template v-if="open.statement">
<div class="headRow">
<n-input
v-model:value="form._id"
placeholder="编号"
aria-label="显示编号"
class="pid"
/>
<n-input
v-model:value="form.title"
placeholder="题目,比如:星之卡比的复制能力"
aria-label="题目"
class="grow"
/>
<n-select
v-model:value="form.difficulty"
:options="difficultyOptions"
aria-label="难度"
class="difficulty"
/>
<label class="switch">
<n-switch v-model:value="form.visible" size="small" />
<span>可见</span>
</label>
</div>
<TextEditor v-model:value="form.description" title="" compact :min-height="160" />
<div class="io">
<div class="field">
<div class="fieldHead">
<span class="label">输入</span>
<span class="grow"></span>
<n-button
v-if="form.inputDescription !== NO_INPUT"
size="tiny"
@click="form.inputDescription = NO_INPUT"
>
没有输入
</n-button>
</div>
<TextEditor v-model:value="form.inputDescription" title="" mini :min-height="60" />
</div>
<div class="field">
<div class="fieldHead"><span class="label">输出</span></div>
<TextEditor v-model:value="form.outputDescription" title="" mini :min-height="60" />
</div>
</div>
<div class="tagRow">
<span class="label tagLabel">知识点</span>
<div class="chips">
<button
v-for="name in visibleKnowledge"
:key="name"
type="button"
class="chip"
:class="{ on: form.tags.includes(name) }"
:aria-pressed="form.tags.includes(name)"
@click="toggleKnowledge(name)"
>
{{ name }}
</button>
<button
v-if="knowledgeTags.length > 10"
type="button"
class="chip more"
@click="showAllKnowledge = !showAllKnowledge"
>
{{ showAllKnowledge ? "收起" : `更多 ${knowledgeTags.length - 10} 个 ▾` }}
</button>
</div>
</div>
<div class="tagRow">
<span class="label tagLabel">主题</span>
<n-select
:value="otherTags"
:options="themeOptions"
multiple
filterable
tag
size="small"
placeholder="选填:选一个主题,或者打字建新的"
@update:value="onOtherTagsChange"
/>
</div>
</template>
</section>
<!-- 标准答案 -->
<section id="section-answer" class="card">
<button type="button" class="cardHead" @click="open.answer = !open.answer">
<h3>标准答案</h3>
<span v-if="!open.answer" class="summary">{{ answerSummary }}</span>
<span class="grow"></span>
<n-text v-if="open.answer" depth="3" class="note">学生看不到</n-text>
<span class="caret">{{ open.answer ? "⌃" : "›" }}</span>
</button>
<template v-if="open.answer">
<div class="subHead">
<n-radio-group v-model:value="answerTab" size="small">
<n-radio-button v-for="lang in form.languages" :key="lang" :value="lang">
{{ lang }}{{ form.answers[lang].trim() ? "" : " 没写" }}
</n-radio-button>
</n-radio-group>
</div>
<n-text depth="3" class="note">
例子和测试数据的输出都由它跑出来;流程图、AI 分析也看它。写一种语言就够<template
v-if="source && source.language !== answerTab"
>,现在按 {{ source.language }} 的跑</template
>。
</n-text>
<CodeEditor
v-for="lang in form.languages"
v-show="answerTab === lang"
:key="lang"
v-model:value="form.answers[lang]"
:language="lang"
:font-size="14"
:height="codeHeight(form.answers[lang], 8, 24)"
/>
</template>
</section>
<!-- 测试数据 -->
<section id="section-cases" class="card">
<button type="button" class="cardHead" @click="open.cases = !open.cases">
<h3>测试数据</h3>
<span class="summary">{{ caseSummary }}</span>
<span class="grow"></span>
<span class="caret">{{ open.cases ? "⌃" : "›" }}</span>
</button>
<template v-if="open.cases">
<div class="toolRow">
<template v-if="!big">
<n-button
size="tiny"
:loading="generatingInputs"
:disabled="!source || casesFull"
:title="
casesFull
? `最多 ${TEST_CASE_MAX_CASES} 组,已经满了`
: source
? '按题面和标准答案,让 AI 想几组边界情况'
: '先写标准答案'
"
@click="aiInputs"
>
AI 想几组输入
</n-button>
<n-button
size="tiny"
:disabled="casesFull"
:title="casesFull ? `最多 ${TEST_CASE_MAX_CASES} 组,已经满了` : undefined"
@click="addRow"
>+ 加一组</n-button
>
</template>
<span class="grow"></span>
<n-button
v-if="rows.some((r) => r.error)"
size="tiny"
:disabled="running"
@click="rerunFailed"
>
重跑没跑通的
</n-button>
<n-upload :show-file-list="false" accept=".zip" :custom-request="importZip">
<n-button size="tiny" quaternary>导入 zip</n-button>
</n-upload>
<n-button
v-if="!big ? rows.length : !!initial && testCase.id === initial.testCaseId"
size="tiny"
quaternary
@click="downloadZip"
>
下载 zip
</n-button>
</div>
<template v-if="big">
<div class="notice">
有 {{ big.count }} 组、共 {{ formatBytes(big.totalBytes) }},太大了,页面上不展开 ——
下载下来改、再导入。导入的 zip 里是 1.in 1.out 2.in 2.out ……
</div>
<div class="subHead">
<b class="small">例子</b>
<n-text depth="3" class="note">单独写,输出照样由答案跑</n-text>
<span class="grow"></span>
<n-button size="tiny" @click="exampleRows.push(newRow('', '', true))"
>+ 加一个</n-button
>
</div>
<CaseTable v-model:rows="exampleRows" :source="source" examples-only />
</template>
<template v-else>
<div v-if="!source && !isCreate" class="notice">
这道题没存标准答案,下面的输出是当初上传的文件,可以直接改。在上面写上答案,输出就改成跟着答案跑。
</div>
<div
v-if="
!rows.length ||
(rows.length === 1 && !rows[0]!.input && !rows[0]!.output && isCreate)
"
class="empty"
>
{{
source
? "加几组输入,输出会自己跑出来。"
: "先在上面写好标准答案,再加几组输入 —— 输出会自己跑出来。"
}}
例子之外最好再有几组边界情况(0、负数、最大值……)。
</div>
<CaseTable v-if="rows.length" v-model:rows="rows" :source="source" />
<n-text depth="3" class="note">
勾上「当例子」的几组按顺序写进题面的例子{{
source ? ";改了输入,输出马上重跑" : ""
}}。
</n-text>
</template>
</template>
</section>
<!-- 学生怎么写 -->
<section id="section-write" class="card">
<button type="button" class="cardHead" @click="open.write = !open.write">
<h3>学生怎么写</h3>
<span v-if="!open.write" class="summary">{{ writeSummary }}</span>
<span class="grow"></span>
<span class="caret">{{ open.write ? "⌃" : "›" }}</span>
</button>
<template v-if="open.write">
<div class="inlineRow">
<span class="label rowLabel">能交的语言</span>
<n-checkbox-group v-model:value="form.languages">
<n-space>
<n-checkbox v-for="lang in RUNNABLE" :key="lang" :value="lang" :label="lang" />
</n-space>
</n-checkbox-group>
</div>
<n-divider class="thin" />
<div class="inlineRow">
<span class="label rowLabel">预制代码</span>
<n-radio-group v-model:value="templateTab" size="small">
<n-radio-button v-for="lang in form.languages" :key="lang" :value="lang">
{{ lang }}{{ form.templates[lang].template.trim() ? "" : " 用默认的" }}
</n-radio-button>
</n-radio-group>
</div>
<n-text depth="3" class="note">
学生打开编辑器时里面已经有的代码,比如先把输入写好,让学生专心写判断。
</n-text>
<template v-for="lang in form.languages" :key="lang">
<template v-if="templateTab === lang">
<CodeEditor
v-model:value="form.templates[lang].template"
:language="lang"
:font-size="14"
:height="codeHeight(form.templates[lang].template, 4, 16)"
placeholder="不写就用全站默认的"
/>
<n-button text size="small" class="hiddenToggle" @click="showHidden = !showHidden">
{{ showHidden ? "▾" : "›" }} 藏在学生代码前面 / 后面一起运行的代码(学生看不到)
</n-button>
<template v-if="showHidden">
<span class="label">前面</span>
<CodeEditor
v-model:value="form.templates[lang].prepend"
:language="lang"
:font-size="13"
height="80px"
/>
<span class="label">后面</span>
<CodeEditor
v-model:value="form.templates[lang].append"
:language="lang"
:font-size="13"
height="80px"
/>
</template>
</template>
</template>
<n-divider class="thin" />
<AstRulesEditor
v-model="form.astRules"
v-model:checks="astChecks"
:languages="form.languages"
:answers="answersForAst"
/>
</template>
</section>
<!-- 流程图 -->
<section id="section-flowchart" class="card">
<button type="button" class="cardHead" @click="open.flowchart = !open.flowchart">
<h3>流程图</h3>
<span v-if="!open.flowchart" class="summary">{{
FLOWCHART_LABEL[form.flowchartMode]
}}</span>
<n-text v-else depth="3" class="note">选填,三选一</n-text>
<span class="grow"></span>
<span class="caret">{{ open.flowchart ? "⌃" : "›" }}</span>
</button>
<template v-if="open.flowchart">
<n-radio-group v-model:value="form.flowchartMode" class="modes">
<label class="mode" :class="{ on: form.flowchartMode === 'none' }">
<n-radio value="none" /><b>不用</b>
</label>
<label class="mode" :class="{ on: form.flowchartMode === 'show' }">
<n-radio value="show" />
<span>
<b>给学生看一张参考流程图</b>
<small>题面里多一条「老师给的参考流程图」,默认收着,学生点开才看</small>
</span>
</label>
<label class="mode" :class="{ on: form.flowchartMode === 'draw' }">
<n-radio value="draw" />
<span>
<b>让学生自己画</b>
<small>按例子和测试数据评;画到 A / S 级算做完</small>
</span>
</label>
</n-radio-group>
<!-- 学生画:按例子和测试数据评(拼成程序跑),不再需要参考图 -->
<n-text v-if="form.flowchartMode === 'draw'" depth="3" class="note">
学生画的图会拼成程序,拿这道题的例子和测试数据跑一遍,逐组看输出对不对,不用准备参考图。
</n-text>
<template v-if="form.flowchartMode === 'show'">
<div class="inlineRow">
<span class="label">{{ legacyMermaid ? "图的代码(Mermaid)" : "参考流程图" }}</span>
<span class="grow"></span>
<n-button
v-if="legacyMermaid"
size="tiny"
quaternary
@click="form.flowchartDoc = emptyDoc()"
>
改用画布画
</n-button>
<n-button
size="tiny"
:loading="aiDrawing"
:disabled="!form.answers.Python.trim()"
:title="form.answers.Python.trim() ? '' : '先写 Python 的标准答案'"
@click="aiFlowchart"
>
AI 照 Python 答案画一张
</n-button>
</div>
<template v-if="legacyMermaid">
<n-input
v-model:value="form.mermaidCode"
type="textarea"
class="mono"
:autosize="{ minRows: 6, maxRows: 18 }"
/>
<n-text :type="mermaidOk ? 'success' : 'error'" class="note">
{{ mermaidOk ? "✓ 画得出来(右边就是)" : "✗ 还画不出来,看右边的报错" }}
</n-text>
</template>
<div v-else class="flowCanvas">
<FlowchartEditor v-model:value="form.flowchartDoc" :draft="false" height="460px" />
</div>
</template>
</template>
</section>
<!-- 提示和其他 -->
<section id="section-more" class="card">
<button type="button" class="cardHead" @click="open.more = !open.more">
<h3>提示和其他</h3>
<span v-if="!open.more" class="summary">{{ moreSummary }}</span>
<n-text v-else depth="3" class="note">都是选填</n-text>
<span class="grow"></span>
<span class="caret">{{ open.more ? "⌃" : "›" }}</span>
</button>
<template v-if="open.more">
<span class="label">提示(学生卡住时点开看)</span>
<TextEditor v-model:value="form.hint" title="" simple :min-height="60" />
<div class="inlineRow wrap">
<label class="inlineRow grow">
<span class="label">来源</span>
<n-input v-model:value="form.source" placeholder="比如:改编自某道题" />
</label>
<label class="inlineRow">
<span class="label">时间限制</span>
<n-input-number
v-model:value="form.timeLimit"
:min="1"
:max="60000"
:show-button="false"
style="width: 110px"
>
<template #suffix>毫秒</template>
</n-input-number>
</label>
<label class="inlineRow">
<span class="label">内存限制</span>
<n-input-number
v-model:value="form.memoryLimit"
:min="1"
:max="1024"
:show-button="false"
style="width: 96px"
>
<template #suffix>MB</template>
</n-input-number>
</label>
</div>
<label class="inlineRow">
<span class="label">给 AI 看的知识点</span>
<n-input v-model:value="form.prompt" placeholder="比如:列表去重、while 循环" />
</label>
</template>
</section>
<div class="saveBar">
<n-button type="primary" :loading="saving" :disabled="!!blockers.length" @click="save">
保存
</n-button>
<div v-if="blockers.length" class="blockers">
<span class="blockersHead">保存前还要:</span>
<template v-for="(b, i) in blockers" :key="i">
<span v-if="i > 0" class="sep">·</span>
<button type="button" class="blocker" :class="{ severe: b.severe }" @click="jump(b)">
{{ b.text }}
</button>
</template>
</div>
<n-text v-else :type="changedCount ? 'warning' : 'success'" class="note">
{{ saveNote }}
</n-text>
</div>
</div>
<!-- 右:学生看到的 -->
<!-- 收起时用 v-show 不卸载:流程图渲染成没成功(mermaidOk)还要接着报给保存按钮 -->
<CodePreview
v-show="!previewCollapsed"
v-model:tab="preview"
:display-id="form._id"
:title="form.title"
:difficulty="form.difficulty"
:description="form.description"
:input-description="form.inputDescription"
:output-description="form.outputDescription"
:hint="form.hint"
:examples="examples"
:examples-note="examplesNote"
:big-data="!!big"
:languages="form.languages"
:templates="templatesForPreview"
:ast-rules="form.astRules"
:ast-checks="astChecks"
:flowchart-mode="form.flowchartMode"
:mermaid-code="previewMermaid"
@render-state="mermaidOk = $event"
@collapse="previewCollapsed = true"
/>
<PreviewStrip v-if="previewCollapsed" @expand="previewCollapsed = false" />
</div>
</template>
<style scoped>
.kindTag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 3px;
background-color: rgba(24, 160, 88, 0.12);
color: v-bind("theme.primaryColorPressed");
font-size: 12px;
font-weight: 600;
}
.note {
font-size: 12px;
}
/* 写的一边是主角,看的一边小一点(用户 2026-10-09:编辑区域要大);右栏可以收起来 */
.codeEditor {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 20px;
align-items: start;
}
.codeEditor.collapsed {
grid-template-columns: minmax(0, 1fr) 36px;
}
/* 窄屏(老师的小笔记本)放不下两栏:预览挪到下面 */
@media (max-width: 1180px) {
.codeEditor,
.codeEditor.collapsed {
grid-template-columns: minmax(0, 1fr);
}
}
.write {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.notice {
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
line-height: 1.6;
background-color: rgba(128, 128, 128, 0.08);
}
.notice.warn {
background-color: rgba(240, 160, 32, 0.12);
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
background: v-bind("theme.cardColor");
scroll-margin-top: 12px;
}
.card :deep(.editorWrapper) {
margin-bottom: 0;
}
.cardHead {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.cardHead h3 {
margin: 0;
font-size: 15px;
flex: none;
width: 84px;
}
.summary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: v-bind("theme.textColor3");
}
.caret {
color: v-bind("theme.textColor3");
}
.headRow,
.subHead,
.toolRow,
.tagRow,
.inlineRow,
.fieldHead {
display: flex;
align-items: center;
gap: 8px;
}
.inlineRow.wrap {
flex-wrap: wrap;
gap: 10px 16px;
}
.tagRow {
align-items: flex-start;
}
.pid {
width: 84px;
flex: none;
}
.difficulty {
width: 84px;
flex: none;
}
.grow {
flex: 1 1 auto;
min-width: 0;
}
.switch {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
white-space: nowrap;
}
.label {
font-size: 13px;
color: v-bind("theme.textColor2");
flex: none;
}
.rowLabel {
width: 72px;
}
.tagLabel {
width: 44px;
line-height: 24px;
}
.small {
font-size: 13px;
}
/*
* align-items: start 不能省:wangEditor 给内容区写死了 height: 100%,格子把 .field 拉伸成
* 确定高度之后这个 100% 就按外框算,工具栏的高度被挤出去,编辑框盖到下面的知识点那行上
*/
.io {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.fieldHead {
height: 24px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
height: 24px;
padding: 0 9px;
border-radius: 3px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.chip.on {
border-color: rgba(24, 160, 88, 0.5);
background-color: rgba(24, 160, 88, 0.12);
color: v-bind("theme.primaryColorPressed");
font-weight: 600;
}
.chip.more {
color: v-bind("theme.textColor3");
}
.empty {
padding: 14px 10px;
text-align: center;
font-size: 13px;
line-height: 1.7;
color: v-bind("theme.textColor3");
}
.thin {
margin: 2px 0;
}
.hiddenToggle {
align-self: flex-start;
font-size: 12px;
}
.modes {
display: flex;
flex-direction: column;
gap: 6px;
}
.mode {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 12px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.mode.on {
border-color: v-bind("theme.primaryColor");
background-color: rgba(24, 160, 88, 0.06);
}
.mode span {
display: flex;
flex-direction: column;
gap: 2px;
}
.mode small {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.mono :deep(textarea) {
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
font-size: 13px;
}
.saveBar {
position: sticky;
bottom: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0 14px;
background: v-bind("theme.bodyColor");
border-top: 1px solid v-bind("theme.borderColor");
}
/* 一行放不下就折,最多两行:新建页一开始缺七八样,别把保存栏撑成半屏 */
.blockers {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
font-size: 13px;
line-height: 1.6;
padding-top: 4px;
}
.blockersHead,
.sep {
color: v-bind("theme.textColor3");
}
.sep {
margin: 0 4px;
}
.blocker {
display: inline;
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: v-bind("theme.textColor1");
text-align: left;
cursor: pointer;
}
.blocker.severe {
color: v-bind("theme.errorColor");
}
.blocker:hover {
text-decoration: underline;
}
.flowCanvas {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
overflow: hidden;
}
/* 手机:卡片收窄,挤在一行的东西折下去 */
@media (max-width: 768px) {
.card {
padding: 12px;
}
/* 编号 + 题目一行,难度 + 可见折到第二行 */
.headRow {
flex-wrap: wrap;
}
.headRow .grow {
flex: 1 1 180px;
}
.io {
grid-template-columns: minmax(0, 1fr);
}
/* 老题面里的图片带着写死的像素宽(有 1118px 的),把整页撑出横向滚动 */
.card :deep(.w-e-image-container) {
max-width: 100%;
height: auto !important;
}
.card :deep(.w-e-image-container img) {
height: auto !important;
}
.inlineRow {
flex-wrap: wrap;
}
.blockers {
-webkit-line-clamp: 3;
}
}
</style>