admin/problem/components/problemForm.ts:判空富文本 isBlankHtml(原来三份,CodePreview 里还有 一份)、难度选项、返回列表的路由、保存失败的提示、标签超长校验、新建页的草稿 (useCreateDraft:停 0.8 秒存、什么都没填就清、编辑页不存)。 纯搬家。新建 / 编辑 × 编程 / SQL 四个页面改前改后整页截图逐像素一致;两种新建页填个 标题、刷新能恢复,「清空草稿」之后本地存储清掉。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8a20d12f9
commit
ff3fdc0b1d
4 files changed
+116
-90
No files matched your search
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import { PROBLEM_TAG_MAX_LENGTH, TEST_CASE_MAX_CASES } from "@oj2/contract"
|
||||
import { TEST_CASE_MAX_CASES } from "@oj2/contract"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import { StorageSerializers } from "@vueuse/core"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
|
||||
@@ -24,6 +23,15 @@ import {
|
||||
previewSQLTestcase,
|
||||
uploadTestcases,
|
||||
} from "../api"
|
||||
import {
|
||||
cleanTags,
|
||||
difficultyOptions,
|
||||
isBlankHtml,
|
||||
problemListRoute,
|
||||
saveErrorText,
|
||||
tagTooLongText,
|
||||
useCreateDraft,
|
||||
} from "./components/problemForm"
|
||||
import {
|
||||
explainSqlError,
|
||||
groupSummary,
|
||||
@@ -94,13 +102,6 @@ const blankForm = (): SqlForm => ({
|
||||
|
||||
const form = reactive<SqlForm>(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 testCase = reactive({ id: "", score: [] as Testcase[] })
|
||||
|
||||
let nextKey = 0
|
||||
@@ -118,28 +119,17 @@ const showMore = ref(false)
|
||||
const ready = ref(false)
|
||||
|
||||
// 新建时存草稿:出一道 SQL 题要写好几段脚本,误关页面全没了太亏
|
||||
// 初值是 null 时 VueUse 推不出类型,会拿 String() 存成 "[object Object]",得显式指定
|
||||
const draft = useLocalStorage<{ form: SqlForm; scripts: string[] } | null>(
|
||||
STORAGE_KEY.ADMIN_SQL_PROBLEM,
|
||||
null,
|
||||
{ serializer: StorageSerializers.object },
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => [form, groups.value.map((g) => g.sql)] as const,
|
||||
() => {
|
||||
if (!ready.value || !isCreate.value) return
|
||||
const scripts = groups.value.map((g) => g.sql)
|
||||
const empty =
|
||||
const draft = useCreateDraft<{ form: SqlForm; scripts: string[] }>(STORAGE_KEY.ADMIN_SQL_PROBLEM, {
|
||||
source: () => [form, groups.value.map((g) => g.sql)] as const,
|
||||
enabled: () => ready.value && isCreate.value,
|
||||
empty: () =>
|
||||
!form._id &&
|
||||
!form.title &&
|
||||
isBlankHtml(form.description) &&
|
||||
!form.answer &&
|
||||
!scripts.some(Boolean)
|
||||
draft.value = empty ? null : { form: { ...form }, scripts }
|
||||
},
|
||||
{ debounce: 800, deep: true },
|
||||
)
|
||||
!groups.value.some((g) => g.sql),
|
||||
take: () => ({ form: { ...form }, scripts: groups.value.map((g) => g.sql) }),
|
||||
})
|
||||
|
||||
function clearDraft() {
|
||||
draft.value = null
|
||||
@@ -212,12 +202,9 @@ const tagOptions = computed(() =>
|
||||
)
|
||||
|
||||
function onTagsChange(tags: string[]) {
|
||||
const tooLong = tags.find((t) => t.length > PROBLEM_TAG_MAX_LENGTH)
|
||||
if (tooLong) {
|
||||
message.error(`标签最多 ${PROBLEM_TAG_MAX_LENGTH} 个字:${tooLong}`)
|
||||
return
|
||||
}
|
||||
form.tags = unique(tags.map((t) => t.trim()).filter(Boolean))
|
||||
const cleaned = cleanTags(tags)
|
||||
if ("tooLong" in cleaned) message.error(tagTooLongText(cleaned.tooLong))
|
||||
else form.tags = cleaned.tags
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 测试数据:自动重跑
|
||||
@@ -415,25 +402,15 @@ async function save() {
|
||||
} else {
|
||||
message.success("保存好了")
|
||||
}
|
||||
router.push(
|
||||
props.contestID
|
||||
? { name: "admin contest problem list", params: { contestID: props.contestID } }
|
||||
: { name: "admin problem list" },
|
||||
)
|
||||
router.push(problemListRoute(props.contestID))
|
||||
} catch (err) {
|
||||
message.error(
|
||||
errorCode(err) === "display-id-exists" ? "显示编号重复了,换一个" : errorMessage(err),
|
||||
)
|
||||
message.error(saveErrorText(err))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const backTo = computed(() =>
|
||||
props.contestID
|
||||
? { name: "admin contest problem list", params: { contestID: props.contestID } }
|
||||
: { name: "admin problem list" },
|
||||
)
|
||||
const backTo = computed(() => problemListRoute(props.contestID))
|
||||
|
||||
onMounted(async () => {
|
||||
getProblemTagList().then((res) => (tagList.value = res))
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getTagColor } from "utils/functions"
|
||||
import type { AstRules, BlankProblem, LANGUAGE } from "utils/types"
|
||||
import type { AstCheckState, FlowchartMode, PreviewTab } from "./editorTypes"
|
||||
import type { TemplateParts } from "./codeTemplate"
|
||||
import { isBlankHtml } from "./problemForm"
|
||||
|
||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||
|
||||
@@ -41,8 +42,6 @@ const emit = defineEmits<{ renderState: [ok: boolean]; collapse: [] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
|
||||
const isBlankHtml = (html: string) => !html.trim() || html === "<p><br></p>"
|
||||
|
||||
// ---------------------------------------------------------------- 语法要求
|
||||
|
||||
const KIND_TAG_TYPE = { require: "success", forbid: "error", count: "info" } as const
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract"
|
||||
import { StorageSerializers, useLocalStorage, watchDebounced } from "@vueuse/core"
|
||||
import type { WatchSource } from "vue"
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { unique } from "utils/functions"
|
||||
|
||||
/**
|
||||
* 编程题(detail.vue)、SQL 题(SqlDetail.vue)两个出题页共用的零件。两页要填的东西几乎
|
||||
* 不重合,所以没合成一页(见 SqlDetail.vue 顶部),但这几样原来是逐字抄了两三份。
|
||||
*/
|
||||
|
||||
/** 富文本编辑器清空之后留下的是一个空段落,不是空串 */
|
||||
export const isBlankHtml = (html: string) => !html.trim() || html === "<p><br></p>"
|
||||
|
||||
export const difficultyOptions = (["Low", "Mid", "High"] as const).map((value) => ({
|
||||
label: DIFFICULTY[value],
|
||||
value,
|
||||
}))
|
||||
|
||||
/** 出题页的「返回」:比赛题回这场比赛的题目列表,公开题回题库 */
|
||||
export function problemListRoute(contestID: string | undefined) {
|
||||
return contestID
|
||||
? { name: "admin contest problem list", params: { contestID } }
|
||||
: { name: "admin problem list" }
|
||||
}
|
||||
|
||||
/** 保存失败给老师看的话。编号撞了是最常见的一种,单独说清楚 */
|
||||
export function saveErrorText(err: unknown) {
|
||||
return errorCode(err) === "display-id-exists" ? "显示编号重复了,换一个" : errorMessage(err)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下拉里新打的标签:去空格、去重。有超长的就返回它(调用方提示、这次改动不收),
|
||||
* 后端也卡这个长度,先在这儿拦省一次往返
|
||||
*/
|
||||
export function cleanTags(tags: string[]): { tags: string[] } | { tooLong: string } {
|
||||
const tooLong = tags.find((t) => t.length > PROBLEM_TAG_MAX_LENGTH)
|
||||
if (tooLong) return { tooLong }
|
||||
return { tags: unique(tags.map((t) => t.trim()).filter(Boolean)) }
|
||||
}
|
||||
|
||||
export const tagTooLongText = (tag: string) => `标签最多 ${PROBLEM_TAG_MAX_LENGTH} 个字:${tag}`
|
||||
|
||||
/**
|
||||
* 新建页的草稿:改动停 0.8 秒存一次,什么都没填就清掉。编辑页不存 —— 原来编辑页也往
|
||||
* 同一个 key 里写,没保存就离开的话,那道题的 id 会跟着草稿漂到新建页。
|
||||
*
|
||||
* `enabled` 在页面数据摆好之前要返回 false,不然加载时那一下会把空表单存进去。
|
||||
*/
|
||||
export function useCreateDraft<T>(
|
||||
key: string,
|
||||
options: { source: WatchSource; enabled: () => boolean; empty: () => boolean; take: () => T },
|
||||
) {
|
||||
// 初值是 null 时 VueUse 推不出类型,会拿 String() 存成 "[object Object]",得显式指定
|
||||
const draft = useLocalStorage<T | null>(key, null, { serializer: StorageSerializers.object })
|
||||
watchDebounced(
|
||||
options.source,
|
||||
() => {
|
||||
if (!options.enabled()) return
|
||||
draft.value = options.empty() ? null : options.take()
|
||||
},
|
||||
{ debounce: 800, deep: true },
|
||||
)
|
||||
return draft
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { PROBLEM_TAG_MAX_LENGTH, TEST_CASE_MAX_CASES, type RunnableLanguage } from "@oj2/contract"
|
||||
import { StorageSerializers } from "@vueuse/core"
|
||||
import { TEST_CASE_MAX_CASES, type RunnableLanguage } from "@oj2/contract"
|
||||
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 { errorMessage } from "utils/api"
|
||||
import { DIFFICULTY, STORAGE_KEY } from "utils/constants"
|
||||
import download from "utils/download"
|
||||
import { createZipBlob, parseTime, unique } from "utils/functions"
|
||||
@@ -24,6 +23,15 @@ import CaseTable from "./components/CaseTable.vue"
|
||||
import CodePreview from "./components/CodePreview.vue"
|
||||
import PreviewStrip from "./components/PreviewStrip.vue"
|
||||
import { usePreviewCollapsed } from "./components/previewCollapse"
|
||||
import {
|
||||
cleanTags,
|
||||
difficultyOptions,
|
||||
isBlankHtml,
|
||||
problemListRoute,
|
||||
saveErrorText,
|
||||
tagTooLongText,
|
||||
useCreateDraft,
|
||||
} from "./components/problemForm"
|
||||
import {
|
||||
caseFiles,
|
||||
caseStatus,
|
||||
@@ -135,14 +143,6 @@ const blankForm = (): CodeForm => ({
|
||||
|
||||
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>"
|
||||
|
||||
// ---------------------------------------------------------------- 标签
|
||||
@@ -176,13 +176,12 @@ function toggleKnowledge(name: string) {
|
||||
}
|
||||
|
||||
function onOtherTagsChange(tags: string[]) {
|
||||
const tooLong = tags.find((t) => t.length > PROBLEM_TAG_MAX_LENGTH)
|
||||
if (tooLong) {
|
||||
message.error(`标签最多 ${PROBLEM_TAG_MAX_LENGTH} 个字:${tooLong}`)
|
||||
const cleaned = cleanTags(tags)
|
||||
if ("tooLong" in cleaned) {
|
||||
message.error(tagTooLongText(cleaned.tooLong))
|
||||
return
|
||||
}
|
||||
const others = unique(tags.map((t) => t.trim()).filter(Boolean))
|
||||
form.tags = unique([...form.tags.filter((t) => knowledgeSet.value.has(t)), ...others])
|
||||
form.tags = unique([...form.tags.filter((t) => knowledgeSet.value.has(t)), ...cleaned.tags])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 标准答案
|
||||
@@ -525,11 +524,6 @@ interface Draft {
|
||||
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)
|
||||
|
||||
@@ -543,25 +537,21 @@ function snapshot() {
|
||||
}
|
||||
let baseline = ""
|
||||
|
||||
watchDebounced(
|
||||
() => [form, rows.value.map((r) => [r.input, r.output, r.example])] as const,
|
||||
() => {
|
||||
if (!ready.value || !isCreate.value) return
|
||||
const empty =
|
||||
// 新建时存草稿(编辑页不存,见 useCreateDraft)
|
||||
const draft = useCreateDraft<Draft>(STORAGE_KEY.ADMIN_CODE_PROBLEM, {
|
||||
source: () => [form, rows.value.map((r) => [r.input, r.output, r.example])] as const,
|
||||
enabled: () => ready.value && isCreate.value,
|
||||
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
|
||||
: {
|
||||
!rows.value.some((r) => r.input.trim() || r.output.trim()),
|
||||
take: () => ({
|
||||
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
|
||||
@@ -841,9 +831,7 @@ async function save() {
|
||||
: { name: "admin problem edit", params: { problemID: created.id } },
|
||||
)
|
||||
} catch (err) {
|
||||
message.error(
|
||||
errorCode(err) === "display-id-exists" ? "显示编号重复了,换一个" : errorMessage(err),
|
||||
)
|
||||
message.error(saveErrorText(err))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -866,11 +854,7 @@ onBeforeRouteLeave(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const backTo = computed(() =>
|
||||
props.contestID
|
||||
? { name: "admin contest problem list", params: { contestID: props.contestID } }
|
||||
: { name: "admin problem list" },
|
||||
)
|
||||
const backTo = computed(() => problemListRoute(props.contestID))
|
||||
|
||||
const authorLine = computed(() =>
|
||||
props.initial
|
||||
|
||||
Reference in new issue
Block a user