Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
530e1a3d5d
commit
195069450f
480 files changed
+15507
-24650
No files matched your search
@@ -1,45 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import { TEST_CASE_MAX_CASES } from "@oj2/contract"
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
|
||||
import SqlProblemData from "oj/problem/components/SqlProblemData.vue"
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { errorCode, errorMessage, isApiError } from "utils/api"
|
||||
import { DIFFICULTY, STORAGE_KEY } from "utils/constants"
|
||||
import { createZipBlob, getTagColor, unique } from "utils/functions"
|
||||
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from "utils/types"
|
||||
import PreviewStrip from "./components/PreviewStrip.vue"
|
||||
import { usePreviewCollapsed } from "./components/previewCollapse"
|
||||
import {
|
||||
createContestProblem,
|
||||
createProblem,
|
||||
editProblem,
|
||||
generateSQLTestcase,
|
||||
getSQLTestcaseScripts,
|
||||
previewSQLTestcase,
|
||||
uploadTestcases,
|
||||
} from "../api"
|
||||
import {
|
||||
cleanTags,
|
||||
difficultyOptions,
|
||||
isBlankHtml,
|
||||
problemListRoute,
|
||||
saveErrorText,
|
||||
tagTooLongText,
|
||||
useCreateDraft,
|
||||
} from "./components/problemForm"
|
||||
import {
|
||||
explainSqlError,
|
||||
groupSummary,
|
||||
type GroupStatus,
|
||||
type ScriptGroup,
|
||||
} from "./components/sqlScript"
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { TEST_CASE_MAX_CASES } from '@oj2/contract'
|
||||
import Markdown from 'shared/components/Markdown.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
|
||||
import SqlProblemData from 'oj/problem/components/SqlProblemData.vue'
|
||||
import { getProblemTagList } from 'shared/api'
|
||||
import TextEditor from 'shared/components/TextEditor.vue'
|
||||
import { errorCode, errorMessage, isApiError } from 'utils/api'
|
||||
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
|
||||
import { createZipBlob, getTagColor, unique } from 'utils/functions'
|
||||
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
|
||||
import PreviewStrip from './components/PreviewStrip.vue'
|
||||
import { usePreviewCollapsed } from './components/previewCollapse'
|
||||
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../api'
|
||||
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './components/problemForm'
|
||||
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './components/sqlScript'
|
||||
|
||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
|
||||
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
|
||||
const wrapLines = [EditorView.lineWrapping]
|
||||
|
||||
@@ -71,7 +50,7 @@ const isCreate = computed(() => !props.problemID)
|
||||
interface SqlForm {
|
||||
_id: string
|
||||
title: string
|
||||
difficulty: BlankProblem["difficulty"]
|
||||
difficulty: BlankProblem['difficulty']
|
||||
visible: boolean
|
||||
tags: string[]
|
||||
description: string
|
||||
@@ -85,35 +64,35 @@ interface SqlForm {
|
||||
}
|
||||
|
||||
const blankForm = (): SqlForm => ({
|
||||
_id: "",
|
||||
title: "",
|
||||
difficulty: "Low",
|
||||
_id: '',
|
||||
title: '',
|
||||
difficulty: 'Low',
|
||||
visible: false,
|
||||
tags: ["SQL"],
|
||||
description: "",
|
||||
hint: "",
|
||||
source: "",
|
||||
prompt: "",
|
||||
tags: ['SQL'],
|
||||
description: '',
|
||||
hint: '',
|
||||
source: '',
|
||||
prompt: '',
|
||||
memoryLimit: 64,
|
||||
timeLimit: 1000,
|
||||
sqlConfig: { mode: "query", order_sensitive: false },
|
||||
answer: "",
|
||||
sqlConfig: { mode: 'query', order_sensitive: false },
|
||||
answer: '',
|
||||
})
|
||||
|
||||
const form = reactive<SqlForm>(blankForm())
|
||||
|
||||
const testCase = reactive({ id: "", score: [] as Testcase[] })
|
||||
const testCase = reactive({ id: '', score: [] as Testcase[] })
|
||||
|
||||
let nextKey = 0
|
||||
function newGroup(sql = ""): ScriptGroup {
|
||||
return { key: nextKey++, sql, ranFor: "", display: null, error: "", errorRaw: "", running: "" }
|
||||
function newGroup(sql = ''): ScriptGroup {
|
||||
return { key: nextKey++, sql, ranFor: '', display: null, error: '', errorRaw: '', running: '' }
|
||||
}
|
||||
const groups = ref<ScriptGroup[]>([])
|
||||
const selected = ref(0)
|
||||
/** 打开时库里那几份脚本:没改过就不用重新上传 */
|
||||
let savedScripts: string[] = []
|
||||
/** 已有的测试数据没读出来。空白编辑器和「本来就没有」长得一样,必须说出来 */
|
||||
const loadError = ref("")
|
||||
const loadError = ref('')
|
||||
|
||||
const showMore = ref(false)
|
||||
const ready = ref(false)
|
||||
@@ -122,12 +101,7 @@ const ready = ref(false)
|
||||
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 &&
|
||||
!groups.value.some((g) => g.sql),
|
||||
empty: () => !form._id && !form.title && isBlankHtml(form.description) && !form.answer && !groups.value.some((g) => g.sql),
|
||||
take: () => ({ form: { ...form }, scripts: groups.value.map((g) => g.sql) }),
|
||||
})
|
||||
|
||||
@@ -142,7 +116,7 @@ async function load() {
|
||||
if (isCreate.value) {
|
||||
// 草稿是本机存的,形状不对(旧版本、被别的页面写坏)就当没有,别让整页起不来
|
||||
const saved = draft.value
|
||||
if (saved && typeof saved.form === "object" && Array.isArray(saved.scripts)) {
|
||||
if (saved && typeof saved.form === 'object' && Array.isArray(saved.scripts)) {
|
||||
Object.assign(form, blankForm(), saved.form)
|
||||
groups.value = saved.scripts.map((sql) => newGroup(String(sql)))
|
||||
} else {
|
||||
@@ -161,13 +135,13 @@ async function load() {
|
||||
visible: data.visible,
|
||||
tags: data.tags,
|
||||
description: data.description,
|
||||
hint: data.hint ?? "",
|
||||
source: data.source ?? "",
|
||||
prompt: data.prompt ?? "",
|
||||
hint: data.hint ?? '',
|
||||
source: data.source ?? '',
|
||||
prompt: data.prompt ?? '',
|
||||
memoryLimit: data.memoryLimit,
|
||||
timeLimit: data.timeLimit,
|
||||
sqlConfig: data.sqlConfig ?? { mode: "query", order_sensitive: false },
|
||||
answer: data.answers.find((a) => a.language === "SQL")?.code ?? "",
|
||||
sqlConfig: data.sqlConfig ?? { mode: 'query', order_sensitive: false },
|
||||
answer: data.answers.find((a) => a.language === 'SQL')?.code ?? '',
|
||||
} satisfies SqlForm)
|
||||
testCase.id = data.testCaseId
|
||||
testCase.score = data.testCaseScore
|
||||
@@ -178,11 +152,9 @@ async function load() {
|
||||
} catch (err) {
|
||||
const code = errorCode(err)
|
||||
// 测试点是旧格式(不是 SQL 脚本)时后端回 not-sql-test-case,当作「还没有」就对了
|
||||
if (code !== "problem-not-found" && code !== "not-sql-test-case") {
|
||||
const detail = isApiError(err) && typeof err.data === "string" ? `(${err.data})` : ""
|
||||
loadError.value =
|
||||
`已有的测试数据没读出来${detail}。下面是空白的,不是这道题真实的测试数据 —— ` +
|
||||
`直接保存会把它换掉,先排查测试点文件再改。`
|
||||
if (code !== 'problem-not-found' && code !== 'not-sql-test-case') {
|
||||
const detail = isApiError(err) && typeof err.data === 'string' ? `(${err.data})` : ''
|
||||
loadError.value = `已有的测试数据没读出来${detail}。下面是空白的,不是这道题真实的测试数据 —— ` + `直接保存会把它换掉,先排查测试点文件再改。`
|
||||
}
|
||||
}
|
||||
if (groups.value.length < 2) {
|
||||
@@ -203,7 +175,7 @@ const tagOptions = computed(() =>
|
||||
|
||||
function onTagsChange(tags: string[]) {
|
||||
const cleaned = cleanTags(tags)
|
||||
if ("tooLong" in cleaned) message.error(tagTooLongText(cleaned.tooLong))
|
||||
if ('tooLong' in cleaned) message.error(tagTooLongText(cleaned.tooLong))
|
||||
else form.tags = cleaned.tags
|
||||
}
|
||||
|
||||
@@ -213,14 +185,13 @@ function onTagsChange(tags: string[]) {
|
||||
* 一组数据跑出来的结果取决于这几样,任何一样变了,原来的结果就作废。
|
||||
* 排在第一也算:删掉第 1 组之后,原来的第 2 组成了题面展示的那组,增删改题得重查它改没改到行
|
||||
*/
|
||||
const signatureOf = (g: ScriptGroup) =>
|
||||
JSON.stringify([form.answer, form.sqlConfig.mode, g.sql, groups.value[0] === g])
|
||||
const signatureOf = (g: ScriptGroup) => JSON.stringify([form.answer, form.sqlConfig.mode, g.sql, groups.value[0] === g])
|
||||
|
||||
function statusOf(g: ScriptGroup): GroupStatus {
|
||||
if (!g.sql.trim()) return "empty"
|
||||
if (!form.answer.trim()) return "no-answer"
|
||||
if (g.ranFor !== signatureOf(g)) return "running"
|
||||
return g.error ? "error" : "ok"
|
||||
if (!g.sql.trim()) return 'empty'
|
||||
if (!form.answer.trim()) return 'no-answer'
|
||||
if (g.ranFor !== signatureOf(g)) return 'running'
|
||||
return g.error ? 'error' : 'ok'
|
||||
}
|
||||
|
||||
async function run(g: ScriptGroup) {
|
||||
@@ -228,8 +199,8 @@ async function run(g: ScriptGroup) {
|
||||
if (!g.sql.trim() || !form.answer.trim()) return
|
||||
if (g.ranFor === signature || g.running === signature) return
|
||||
g.running = signature
|
||||
let display: ScriptGroup["display"] = null
|
||||
let errorRaw = ""
|
||||
let display: ScriptGroup['display'] = null
|
||||
let errorRaw = ''
|
||||
try {
|
||||
display = await previewSQLTestcase({
|
||||
initSql: g.sql,
|
||||
@@ -238,11 +209,10 @@ async function run(g: ScriptGroup) {
|
||||
shown: groups.value[0] === g,
|
||||
})
|
||||
} catch (err) {
|
||||
errorRaw =
|
||||
isApiError(err) && typeof err.data === "string" ? err.data : errorMessage(err, "跑不起来")
|
||||
errorRaw = isApiError(err) && typeof err.data === 'string' ? err.data : errorMessage(err, '跑不起来')
|
||||
}
|
||||
// 跑的过程中又改了:这次的结果照样记下(ranFor 说明它对应哪一版),新的那版另有一次在跑
|
||||
if (g.running === signature) g.running = ""
|
||||
if (g.running === signature) g.running = ''
|
||||
g.ranFor = signature
|
||||
g.display = display
|
||||
g.error = errorRaw && explainSqlError(errorRaw)
|
||||
@@ -291,7 +261,7 @@ async function generateGroup() {
|
||||
selected.value = groups.value.length - 1
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(errorMessage(err, "AI 没写出来,稍后再试"))
|
||||
message.error(errorMessage(err, 'AI 没写出来,稍后再试'))
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
@@ -303,13 +273,12 @@ const current = computed(() => groups.value[selected.value])
|
||||
|
||||
/** 为什么还不能保存。按出题的顺序查,只说第一条 */
|
||||
const blocker = computed(() => {
|
||||
if (!form._id.trim() || !form.title.trim()) return "编号和题目还没填"
|
||||
if (isBlankHtml(form.description)) return "题目描述还没写"
|
||||
if (!form.tags.length) return "至少要有一个标签"
|
||||
if (!form.answer.trim()) return "标准答案还没写"
|
||||
if (groups.value.length < 2) return "测试数据至少要 2 组"
|
||||
if (groups.value.length > TEST_CASE_MAX_CASES)
|
||||
return `测试数据最多 ${TEST_CASE_MAX_CASES} 组,删掉 ${groups.value.length - TEST_CASE_MAX_CASES} 组`
|
||||
if (!form._id.trim() || !form.title.trim()) return '编号和题目还没填'
|
||||
if (isBlankHtml(form.description)) return '题目描述还没写'
|
||||
if (!form.tags.length) return '至少要有一个标签'
|
||||
if (!form.answer.trim()) return '标准答案还没写'
|
||||
if (groups.value.length < 2) return '测试数据至少要 2 组'
|
||||
if (groups.value.length > TEST_CASE_MAX_CASES) return `测试数据最多 ${TEST_CASE_MAX_CASES} 组,删掉 ${groups.value.length - TEST_CASE_MAX_CASES} 组`
|
||||
for (const [i, g] of groups.value.entries()) {
|
||||
if (!g.sql.trim()) return `第 ${i + 1} 组还是空的,写上或删掉`
|
||||
}
|
||||
@@ -321,17 +290,17 @@ const blocker = computed(() => {
|
||||
}
|
||||
for (const [i, g] of groups.value.entries()) {
|
||||
const status = statusOf(g)
|
||||
if (status === "running") return "测试数据还在跑……"
|
||||
if (status === "error") return `第 ${i + 1} 组还没跑通,改好或删掉才能保存`
|
||||
if (status === 'running') return '测试数据还在跑……'
|
||||
if (status === 'error') return `第 ${i + 1} 组还没跑通,改好或删掉才能保存`
|
||||
}
|
||||
return ""
|
||||
return ''
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
async function uploadScripts(scripts: string[]) {
|
||||
const blob = createZipBlob(scripts.map((content, i) => ({ name: `${i + 1}.sql`, content })))
|
||||
const res = await uploadTestcases(new File([blob], "testcase.zip", { type: "application/zip" }), {
|
||||
const res = await uploadTestcases(new File([blob], 'testcase.zip', { type: 'application/zip' }), {
|
||||
sql: true,
|
||||
})
|
||||
// 分值平分,余数给最后一组;只留落库的三个键(见契约 problemTestCaseScoreSchema)
|
||||
@@ -353,18 +322,15 @@ async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const scripts = groups.value.map((g) => g.sql)
|
||||
const changed =
|
||||
!testCase.id ||
|
||||
scripts.length !== savedScripts.length ||
|
||||
scripts.some((sql, i) => sql !== savedScripts[i])
|
||||
const changed = !testCase.id || scripts.length !== savedScripts.length || scripts.some((sql, i) => sql !== savedScripts[i])
|
||||
if (changed) await uploadScripts(scripts)
|
||||
|
||||
const body: BlankProblem = {
|
||||
_id: form._id.trim(),
|
||||
title: form.title.trim(),
|
||||
description: form.description,
|
||||
inputDescription: "",
|
||||
outputDescription: "",
|
||||
inputDescription: '',
|
||||
outputDescription: '',
|
||||
samples: [],
|
||||
testCaseId: testCase.id,
|
||||
testCaseScore: testCase.score,
|
||||
@@ -373,16 +339,16 @@ async function save() {
|
||||
difficulty: form.difficulty,
|
||||
visible: form.visible,
|
||||
tags: form.tags,
|
||||
languages: ["SQL"],
|
||||
languages: ['SQL'],
|
||||
template: {},
|
||||
hint: isBlankHtml(form.hint) ? "" : form.hint,
|
||||
hint: isBlankHtml(form.hint) ? '' : form.hint,
|
||||
source: form.source,
|
||||
prompt: form.prompt,
|
||||
answers: [{ language: "SQL", code: form.answer }],
|
||||
answers: [{ language: 'SQL', code: form.answer }],
|
||||
allowFlowchart: false,
|
||||
showFlowchart: false,
|
||||
mermaidCode: "",
|
||||
flowchartHint: "",
|
||||
mermaidCode: '',
|
||||
flowchartHint: '',
|
||||
astRules: null,
|
||||
sqlConfig: form.sqlConfig,
|
||||
sqlDisplay: null,
|
||||
@@ -398,9 +364,9 @@ async function save() {
|
||||
savedScripts = scripts
|
||||
if (isCreate.value) {
|
||||
draft.value = null
|
||||
message.success("SQL 题建好了")
|
||||
message.success('SQL 题建好了')
|
||||
} else {
|
||||
message.success("保存好了")
|
||||
message.success('保存好了')
|
||||
}
|
||||
router.push(problemListRoute(props.contestID))
|
||||
} catch (err) {
|
||||
@@ -436,43 +402,20 @@ onMounted(async () => {
|
||||
<section class="card">
|
||||
<div class="headRow">
|
||||
<n-input v-model:value="form._id" placeholder="编号" aria-label="显示编号" class="pid" />
|
||||
<n-input
|
||||
v-model:value="form.title"
|
||||
placeholder="题目,比如:统计平均工资超过6000元的部门"
|
||||
aria-label="题目"
|
||||
class="grow"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="form.difficulty"
|
||||
:options="difficultyOptions"
|
||||
aria-label="难度"
|
||||
class="difficulty"
|
||||
/>
|
||||
<n-input v-model:value="form.title" placeholder="题目,比如:统计平均工资超过6000元的部门" 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="" simple :min-height="140" />
|
||||
<n-text depth="3" class="note">
|
||||
数据表和期望结果会自动接在描述后面给学生看(用第 1
|
||||
组测试数据),描述里不用再抄一遍表结构。
|
||||
</n-text>
|
||||
<n-text depth="3" class="note"> 数据表和期望结果会自动接在描述后面给学生看(用第 1 组测试数据),描述里不用再抄一遍表结构。 </n-text>
|
||||
<div class="tagRow">
|
||||
<span class="label">标签</span>
|
||||
<n-select
|
||||
:value="form.tags"
|
||||
:options="tagOptions"
|
||||
multiple
|
||||
filterable
|
||||
tag
|
||||
placeholder="选一个,或者打字建新的"
|
||||
@update:value="onTagsChange"
|
||||
/>
|
||||
<n-select :value="form.tags" :options="tagOptions" multiple filterable tag placeholder="选一个,或者打字建新的" @update:value="onTagsChange" />
|
||||
</div>
|
||||
<n-button text type="primary" class="moreToggle" @click="showMore = !showMore">
|
||||
提示 · 来源 · 内存限制 {{ showMore ? "▴" : "▾" }}
|
||||
</n-button>
|
||||
<n-button text type="primary" class="moreToggle" @click="showMore = !showMore"> 提示 · 来源 · 内存限制 {{ showMore ? '▴' : '▾' }} </n-button>
|
||||
<div v-if="showMore" class="more">
|
||||
<div class="label">提示(学生卡住时点开看)</div>
|
||||
<TextEditor v-model:value="form.hint" title="" simple :min-height="60" />
|
||||
@@ -483,13 +426,7 @@ onMounted(async () => {
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="label">内存限制</span>
|
||||
<n-input-number
|
||||
v-model:value="form.memoryLimit"
|
||||
:min="1"
|
||||
:max="1024"
|
||||
:show-button="false"
|
||||
style="width: 110px"
|
||||
>
|
||||
<n-input-number v-model:value="form.memoryLimit" :min="1" :max="1024" :show-button="false" style="width: 110px">
|
||||
<template #suffix>MB</template>
|
||||
</n-input-number>
|
||||
</label>
|
||||
@@ -508,32 +445,15 @@ onMounted(async () => {
|
||||
<n-radio-button value="query">查询 · 比结果</n-radio-button>
|
||||
<n-radio-button value="modify">增删改 · 比执行完的表</n-radio-button>
|
||||
</n-radio-group>
|
||||
<n-checkbox
|
||||
v-if="form.sqlConfig.mode === 'query'"
|
||||
v-model:checked="form.sqlConfig.order_sensitive"
|
||||
size="small"
|
||||
>
|
||||
按顺序比
|
||||
</n-checkbox>
|
||||
<n-checkbox v-if="form.sqlConfig.mode === 'query'" v-model:checked="form.sqlConfig.order_sensitive" size="small"> 按顺序比 </n-checkbox>
|
||||
</div>
|
||||
<div class="subHead">
|
||||
<span class="label">标准答案</span>
|
||||
<n-text depth="3" class="note">
|
||||
{{
|
||||
form.sqlConfig.mode === "query"
|
||||
? "每组数据都拿它跑出期望结果,学生看不到"
|
||||
: "执行完之后,被改过的表就是期望结果,学生看不到"
|
||||
}}
|
||||
{{ form.sqlConfig.mode === 'query' ? '每组数据都拿它跑出期望结果,学生看不到' : '执行完之后,被改过的表就是期望结果,学生看不到' }}
|
||||
</n-text>
|
||||
</div>
|
||||
<CodeEditor
|
||||
v-model:value="form.answer"
|
||||
language="SQL"
|
||||
:extra-extensions="wrapLines"
|
||||
:font-size="14"
|
||||
height="120px"
|
||||
placeholder="SELECT ... FROM ..."
|
||||
/>
|
||||
<CodeEditor v-model:value="form.answer" language="SQL" :extra-extensions="wrapLines" :font-size="14" height="120px" placeholder="SELECT ... FROM ..." />
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@@ -556,11 +476,7 @@ onMounted(async () => {
|
||||
>
|
||||
AI 再写一组
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
:disabled="groupsFull"
|
||||
:title="groupsFull ? `最多 ${TEST_CASE_MAX_CASES} 组,已经满了` : undefined"
|
||||
@click="addGroup"
|
||||
<n-button size="tiny" :disabled="groupsFull" :title="groupsFull ? `最多 ${TEST_CASE_MAX_CASES} 组,已经满了` : undefined" @click="addGroup"
|
||||
>+ 加一组</n-button
|
||||
>
|
||||
</div>
|
||||
@@ -587,17 +503,8 @@ onMounted(async () => {
|
||||
<template v-else-if="g.display">{{ groupSummary(g.display) }}</template>
|
||||
</span>
|
||||
<span v-if="statusOf(g) === 'ok'" class="mark ok" aria-label="跑通了">✓</span>
|
||||
<span v-else-if="statusOf(g) === 'error'" class="mark error" aria-label="没跑通"
|
||||
>✗</span
|
||||
>
|
||||
<n-button
|
||||
v-if="groups.length > 1"
|
||||
size="tiny"
|
||||
quaternary
|
||||
class="remove"
|
||||
:aria-label="`删掉第 ${i + 1} 组`"
|
||||
@click.stop="removeGroup(i)"
|
||||
>
|
||||
<span v-else-if="statusOf(g) === 'error'" class="mark error" aria-label="没跑通">✗</span>
|
||||
<n-button v-if="groups.length > 1" size="tiny" quaternary class="remove" :aria-label="`删掉第 ${i + 1} 组`" @click.stop="removeGroup(i)">
|
||||
删掉
|
||||
</n-button>
|
||||
</div>
|
||||
@@ -620,9 +527,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<div class="saveBar">
|
||||
<n-button type="primary" :loading="saving" :disabled="!!blocker" @click="save">
|
||||
保存
|
||||
</n-button>
|
||||
<n-button type="primary" :loading="saving" :disabled="!!blocker" @click="save"> 保存 </n-button>
|
||||
<n-text v-if="blocker" :type="blocker.includes('没跑通') ? 'error' : 'default'" depth="2">
|
||||
{{ blocker }}
|
||||
</n-text>
|
||||
@@ -640,14 +545,12 @@ onMounted(async () => {
|
||||
第 {{ i + 1 }} 组<span v-if="statusOf(g) === 'error'" class="error"> ✗</span>
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
<n-button size="small" quaternary title="收起,专心写左边" @click="previewCollapsed = true">
|
||||
收起 ›
|
||||
</n-button>
|
||||
<n-button size="small" quaternary title="收起,专心写左边" @click="previewCollapsed = true"> 收起 › </n-button>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="titleRow">
|
||||
<h2>
|
||||
<span v-if="form._id">{{ form._id }}</span> {{ form.title || "(题目还没起名)" }}
|
||||
<span v-if="form._id">{{ form._id }}</span> {{ form.title || '(题目还没起名)' }}
|
||||
</h2>
|
||||
<ProblemTypeTag kind="sql" />
|
||||
<n-tag size="small" :bordered="false" :type="getTagColor(form.difficulty)">
|
||||
@@ -656,33 +559,18 @@ onMounted(async () => {
|
||||
</div>
|
||||
<h3 class="title">描述</h3>
|
||||
<Markdown html :content="form.description" />
|
||||
<p v-if="selected > 0" class="otherGroup">
|
||||
学生看到的是第 1 组,下面是第 {{ selected + 1 }} 组跑出来的样子
|
||||
</p>
|
||||
<p v-if="selected > 0" class="otherGroup">学生看到的是第 1 组,下面是第 {{ selected + 1 }} 组跑出来的样子</p>
|
||||
<template v-if="current">
|
||||
<p v-if="statusOf(current) === 'no-answer'" class="placeholder">
|
||||
写好标准答案,这里就跑出期望结果
|
||||
</p>
|
||||
<p v-else-if="statusOf(current) === 'empty'" class="placeholder">
|
||||
第 {{ selected + 1 }} 组还没写脚本
|
||||
</p>
|
||||
<p v-else-if="statusOf(current) === 'running' && !current.display" class="placeholder">
|
||||
在跑……
|
||||
</p>
|
||||
<p v-if="statusOf(current) === 'no-answer'" class="placeholder">写好标准答案,这里就跑出期望结果</p>
|
||||
<p v-else-if="statusOf(current) === 'empty'" class="placeholder">第 {{ selected + 1 }} 组还没写脚本</p>
|
||||
<p v-else-if="statusOf(current) === 'running' && !current.display" class="placeholder">在跑……</p>
|
||||
<div v-else-if="statusOf(current) === 'error'" class="errorBox">
|
||||
{{ current.error }}
|
||||
<div v-if="current.error.startsWith('标准答案在这组数据上')" class="raw">
|
||||
每组数据都要建齐标准答案用到的表和列。
|
||||
</div>
|
||||
<div v-if="current.error !== current.errorRaw" class="raw small">
|
||||
原话:{{ current.errorRaw }}
|
||||
</div>
|
||||
<div v-if="current.error.startsWith('标准答案在这组数据上')" class="raw">每组数据都要建齐标准答案用到的表和列。</div>
|
||||
<div v-if="current.error !== current.errorRaw" class="raw small">原话:{{ current.errorRaw }}</div>
|
||||
</div>
|
||||
<div v-else-if="current.display" :class="{ stale: statusOf(current) === 'running' }">
|
||||
<SqlProblemData
|
||||
:display="current.display"
|
||||
:order-sensitive="form.sqlConfig.order_sensitive"
|
||||
>
|
||||
<SqlProblemData :display="current.display" :order-sensitive="form.sqlConfig.order_sensitive">
|
||||
<template #title="{ text }">
|
||||
<h3 class="title sqlTitle">{{ text }}</h3>
|
||||
</template>
|
||||
@@ -732,13 +620,13 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.card :deep(.editorWrapper) {
|
||||
@@ -794,7 +682,7 @@ onMounted(async () => {
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -848,12 +736,12 @@ onMounted(async () => {
|
||||
|
||||
.group.on {
|
||||
background-color: rgba(24, 160, 88, 0.1);
|
||||
box-shadow: inset 3px 0 0 v-bind("theme.primaryColor");
|
||||
box-shadow: inset 3px 0 0 v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.group.on.bad {
|
||||
background-color: rgba(208, 48, 80, 0.08);
|
||||
box-shadow: inset 3px 0 0 v-bind("theme.errorColor");
|
||||
box-shadow: inset 3px 0 0 v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.groupNo {
|
||||
@@ -864,7 +752,7 @@ onMounted(async () => {
|
||||
.shown {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.primaryColorPressed");
|
||||
color: v-bind('theme.primaryColorPressed');
|
||||
}
|
||||
|
||||
.summary {
|
||||
@@ -873,7 +761,7 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.mark {
|
||||
@@ -882,15 +770,15 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: v-bind("theme.successColor");
|
||||
color: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.error {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.summary.error {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.remove {
|
||||
@@ -912,8 +800,8 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0 14px;
|
||||
background: v-bind("theme.bodyColor");
|
||||
border-top: 1px solid v-bind("theme.borderColor");
|
||||
background: v-bind('theme.bodyColor');
|
||||
border-top: 1px solid v-bind('theme.borderColor');
|
||||
}
|
||||
|
||||
.preview {
|
||||
@@ -936,10 +824,10 @@ onMounted(async () => {
|
||||
.page {
|
||||
max-height: calc(100vh - 170px);
|
||||
overflow: auto;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
padding: 18px 22px;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
@@ -963,7 +851,7 @@ onMounted(async () => {
|
||||
line-height: 1.4;
|
||||
margin: 0 0 6px;
|
||||
padding-left: 9px;
|
||||
border-left: 3px solid v-bind("theme.primaryColor");
|
||||
border-left: 3px solid v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.sqlTitle {
|
||||
@@ -976,12 +864,12 @@ onMounted(async () => {
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background-color: rgba(128, 128, 128, 0.08);
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
margin: 14px 0 0;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.errorBox {
|
||||
@@ -989,13 +877,13 @@ onMounted(async () => {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(208, 48, 80, 0.08);
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.errorBox .raw {
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
}
|
||||
|
||||
.errorBox .small {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { StuckProblem, StuckProblemDetail } from "utils/types"
|
||||
import { getStuckProblemDetail, getStuckProblems } from "admin/api"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { groupClassOptions } from "utils/classes"
|
||||
import { periodRange, type Period } from "oj/statistics/period"
|
||||
import { useBackClose } from "shared/composables/backClose"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import "oj/learn/learn.css"
|
||||
import StuckDetail from "./components/StuckDetail.vue"
|
||||
import { avgTries, rate } from "./components/stuckMath"
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { StuckProblem, StuckProblemDetail } from 'utils/types'
|
||||
import { getStuckProblemDetail, getStuckProblems } from 'admin/api'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { groupClassOptions } from 'utils/classes'
|
||||
import { periodRange, type Period } from 'oj/statistics/period'
|
||||
import { useBackClose } from 'shared/composables/backClose'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import 'oj/learn/learn.css'
|
||||
import StuckDetail from './components/StuckDetail.vue'
|
||||
import { avgTries, rate } from './components/stuckMath'
|
||||
|
||||
/**
|
||||
* 卡点分析(设计稿「卡点与趋势」页的定稿 StuckFinalAll / StuckFinalClass):
|
||||
@@ -27,18 +27,18 @@ const { isMobile } = useBreakpoints()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const PERIODS: { label: string; value: Period }[] = [
|
||||
{ label: "这学期", value: "term" },
|
||||
{ label: "最近 30 天", value: "30d" },
|
||||
{ label: "最近 7 天", value: "7d" },
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: '这学期', value: 'term' },
|
||||
{ label: '最近 30 天', value: '30d' },
|
||||
{ label: '最近 7 天', value: '7d' },
|
||||
{ label: '全部', value: 'all' },
|
||||
]
|
||||
const THRESHOLDS = [5, 10, 20, 50]
|
||||
|
||||
const className = useRouteQuery<string>("className", "", { mode: "replace" })
|
||||
const period = useRouteQuery<string>("period", "term", { mode: "replace" })
|
||||
const tag = useRouteQuery<string>("tag", "", { mode: "replace" })
|
||||
const picked = useRouteQuery<string>("problem", "", { mode: "replace" })
|
||||
const min = useRouteQuery<string>("min", "", { mode: "replace" })
|
||||
const className = useRouteQuery<string>('className', '', { mode: 'replace' })
|
||||
const period = useRouteQuery<string>('period', 'term', { mode: 'replace' })
|
||||
const tag = useRouteQuery<string>('tag', '', { mode: 'replace' })
|
||||
const picked = useRouteQuery<string>('problem', '', { mode: 'replace' })
|
||||
const min = useRouteQuery<string>('min', '', { mode: 'replace' })
|
||||
|
||||
const problems = ref<StuckProblem[]>([])
|
||||
const detail = ref<StuckProblemDetail | null>(null)
|
||||
@@ -47,9 +47,7 @@ const detailLoading = ref(false)
|
||||
|
||||
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
|
||||
const threshold = computed(() => Number(min.value) || (className.value ? 10 : 20))
|
||||
const thresholdOptions = computed(() =>
|
||||
THRESHOLDS.map((n) => ({ label: `至少 ${n} 人做过`, value: n })),
|
||||
)
|
||||
const thresholdOptions = computed(() => THRESHOLDS.map((n) => ({ label: `至少 ${n} 人做过`, value: n })))
|
||||
|
||||
function firstRate(row: StuckProblem) {
|
||||
return rate(row.firstTry, row.users)
|
||||
@@ -73,14 +71,11 @@ const knowledge = computed(() => {
|
||||
groups.set(name, group)
|
||||
}
|
||||
}
|
||||
const all = visible.value.reduce(
|
||||
(sum, row) => ({ users: sum.users + row.users, firstTry: sum.firstTry + row.firstTry }),
|
||||
{ users: 0, firstTry: 0 },
|
||||
)
|
||||
const all = visible.value.reduce((sum, row) => ({ users: sum.users + row.users, firstTry: sum.firstTry + row.firstTry }), { users: 0, firstTry: 0 })
|
||||
return [
|
||||
{
|
||||
name: "",
|
||||
label: "全部",
|
||||
name: '',
|
||||
label: '全部',
|
||||
problems: visible.value.length,
|
||||
rate: rate(all.firstTry, all.users),
|
||||
},
|
||||
@@ -95,19 +90,13 @@ const knowledge = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const activeTag = computed(() =>
|
||||
knowledge.value.some((row) => row.name === tag.value) ? tag.value : "",
|
||||
)
|
||||
const activeTag = computed(() => (knowledge.value.some((row) => row.name === tag.value) ? tag.value : ''))
|
||||
|
||||
const list = computed(() =>
|
||||
visible.value
|
||||
.filter((row) => !activeTag.value || row.tags.includes(activeTag.value))
|
||||
.sort((a, b) => firstRate(a) - firstRate(b) || b.users - a.users),
|
||||
visible.value.filter((row) => !activeTag.value || row.tags.includes(activeTag.value)).sort((a, b) => firstRate(a) - firstRate(b) || b.users - a.users),
|
||||
)
|
||||
|
||||
const selected = computed(
|
||||
() => list.value.find((row) => String(row.id) === picked.value) ?? list.value[0] ?? null,
|
||||
)
|
||||
const selected = computed(() => list.value.find((row) => String(row.id) === picked.value) ?? list.value[0] ?? null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -182,13 +171,7 @@ onMounted(load)
|
||||
@update:value="(value: string | null) => (className = value ?? '')"
|
||||
/>
|
||||
<div class="seg">
|
||||
<button
|
||||
v-for="option in PERIODS"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:class="{ on: period === option.value }"
|
||||
@click="period = option.value"
|
||||
>
|
||||
<button v-for="option in PERIODS" :key="option.value" type="button" :class="{ on: period === option.value }" @click="period = option.value">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -203,14 +186,7 @@ onMounted(load)
|
||||
<span class="muted small">一次就对</span>
|
||||
</div>
|
||||
<div class="know-list">
|
||||
<button
|
||||
v-for="row in knowledge"
|
||||
:key="row.name"
|
||||
type="button"
|
||||
class="item"
|
||||
:class="{ on: row.name === activeTag }"
|
||||
@click="tag = row.name"
|
||||
>
|
||||
<button v-for="row in knowledge" :key="row.name" type="button" class="item" :class="{ on: row.name === activeTag }" @click="tag = row.name">
|
||||
<span class="line">
|
||||
<span class="name">{{ row.label }}</span>
|
||||
<span class="grow" />
|
||||
@@ -226,7 +202,7 @@ onMounted(load)
|
||||
|
||||
<section class="card problems">
|
||||
<div class="card-head">
|
||||
<span class="strong">{{ activeTag || "全部" }}</span>
|
||||
<span class="strong">{{ activeTag || '全部' }}</span>
|
||||
<span class="muted small num">{{ list.length }} 道</span>
|
||||
<span class="grow" />
|
||||
<n-select
|
||||
@@ -238,30 +214,17 @@ onMounted(load)
|
||||
/>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div v-if="!list.length && !loading" class="none muted">
|
||||
这段时间还没有 {{ threshold }} 人以上做过的题
|
||||
</div>
|
||||
<button
|
||||
v-for="row in list"
|
||||
:key="row.id"
|
||||
type="button"
|
||||
class="item"
|
||||
:class="{ on: row.id === selected?.id }"
|
||||
@click="pick(row)"
|
||||
>
|
||||
<div v-if="!list.length && !loading" class="none muted">这段时间还没有 {{ threshold }} 人以上做过的题</div>
|
||||
<button v-for="row in list" :key="row.id" type="button" class="item" :class="{ on: row.id === selected?.id }" @click="pick(row)">
|
||||
<span class="line">
|
||||
<span class="pid muted num">{{ row.displayId }}</span>
|
||||
<span class="ptitle">{{ row.title }}</span>
|
||||
<span v-if="row.users > row.solved" class="stuck num">
|
||||
{{ row.users - row.solved }} 人没对
|
||||
</span>
|
||||
<span v-if="row.users > row.solved" class="stuck num"> {{ row.users - row.solved }} 人没对 </span>
|
||||
</span>
|
||||
<span class="line indent">
|
||||
<span class="track short"><span :style="{ width: `${firstRate(row)}%` }" /></span>
|
||||
<span class="pct num">{{ firstRate(row) }}%</span>
|
||||
<span class="muted small num nowrap">
|
||||
{{ row.users }} 人 · 平均 {{ avgTries(row) }} 次
|
||||
</span>
|
||||
<span class="muted small num nowrap"> {{ row.users }} 人 · 平均 {{ avgTries(row) }} 次 </span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -272,7 +235,7 @@ onMounted(load)
|
||||
<n-button quaternary circle @click="detailOpen = false">
|
||||
<template #icon><Icon icon="ph:arrow-left" /></template>
|
||||
</n-button>
|
||||
<span class="strong">{{ activeTag || "全部" }}</span>
|
||||
<span class="strong">{{ activeTag || '全部' }}</span>
|
||||
</div>
|
||||
<n-spin :show="detailLoading">
|
||||
<StuckDetail v-if="detail" :detail="detail" :class-name="className" :period="period" />
|
||||
@@ -526,7 +489,7 @@ onMounted(load)
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow-y: auto;
|
||||
background: v-bind("theme.bodyColor");
|
||||
background: v-bind('theme.bodyColor');
|
||||
}
|
||||
.detail-bar {
|
||||
position: sticky;
|
||||
@@ -538,7 +501,7 @@ onMounted(load)
|
||||
height: 48px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--learn-line);
|
||||
background: v-bind("theme.bodyColor");
|
||||
background: v-bind('theme.bodyColor');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import type { GradeTrend } from "utils/types"
|
||||
import { getGradeTrend } from "admin/api"
|
||||
import "oj/learn/learn.css"
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import type { GradeTrend } from 'utils/types'
|
||||
import { getGradeTrend } from 'admin/api'
|
||||
import 'oj/learn/learn.css'
|
||||
|
||||
/**
|
||||
* 年度趋势(设计稿「卡点与趋势」页的 TrendB):选一届,和往届(或另一届)在两边都做过的题上
|
||||
@@ -17,15 +17,15 @@ const router = useRouter()
|
||||
/** 和后端的 GRADE_MIN_USERS 一致,只用在空状态的文案里 */
|
||||
const MIN_USERS = 20
|
||||
const SAME = 2
|
||||
const PAST = "past"
|
||||
const PAST = 'past'
|
||||
|
||||
const data = ref<GradeTrend | null>(null)
|
||||
const loading = ref(false)
|
||||
const grade = useRouteQuery<string>("grade", "", { mode: "replace" })
|
||||
const compare = useRouteQuery<string>("compare", PAST, { mode: "replace" })
|
||||
const grade = useRouteQuery<string>('grade', '', { mode: 'replace' })
|
||||
const compare = useRouteQuery<string>('compare', PAST, { mode: 'replace' })
|
||||
|
||||
const grades = computed(() => data.value?.grades ?? [])
|
||||
const selected = computed(() => data.value?.grade ?? "")
|
||||
const selected = computed(() => data.value?.grade ?? '')
|
||||
|
||||
function rate(part: number, whole: number) {
|
||||
return whole ? Math.round((part / whole) * 100) : 0
|
||||
@@ -34,14 +34,12 @@ function rate(part: number, whole: number) {
|
||||
const pastGrades = computed(() => grades.value.filter((g) => g < selected.value))
|
||||
const pastLabel = computed(() => {
|
||||
const list = pastGrades.value
|
||||
if (!list.length) return "往届"
|
||||
if (!list.length) return '往届'
|
||||
return list.length === 1 ? `往届(${list[0]} 级)` : `往届(${list.at(-1)}–${list[0]} 级)`
|
||||
})
|
||||
const otherValue = computed(() => data.value?.compare ?? PAST)
|
||||
const otherLabel = computed(() =>
|
||||
otherValue.value === PAST ? pastLabel.value : `${otherValue.value} 级`,
|
||||
)
|
||||
const shortOther = computed(() => (otherValue.value === PAST ? "往届" : `${otherValue.value} 级`))
|
||||
const otherLabel = computed(() => (otherValue.value === PAST ? pastLabel.value : `${otherValue.value} 级`))
|
||||
const shortOther = computed(() => (otherValue.value === PAST ? '往届' : `${otherValue.value} 级`))
|
||||
|
||||
const gradeOptions = computed(() => grades.value.map((g) => ({ label: `${g} 级`, value: g })))
|
||||
const compareOptions = computed(() => [
|
||||
@@ -56,15 +54,9 @@ const rows = computed(() =>
|
||||
return { row, mine: row.mine, theirs: row.theirs, now, before, delta: now - before }
|
||||
}),
|
||||
)
|
||||
const worse = computed(() =>
|
||||
rows.value.filter((item) => item.delta < -SAME).sort((a, b) => a.delta - b.delta),
|
||||
)
|
||||
const same = computed(() =>
|
||||
rows.value.filter((item) => Math.abs(item.delta) <= SAME).sort((a, b) => a.delta - b.delta),
|
||||
)
|
||||
const better = computed(() =>
|
||||
rows.value.filter((item) => item.delta > SAME).sort((a, b) => b.delta - a.delta),
|
||||
)
|
||||
const worse = computed(() => rows.value.filter((item) => item.delta < -SAME).sort((a, b) => a.delta - b.delta))
|
||||
const same = computed(() => rows.value.filter((item) => Math.abs(item.delta) <= SAME).sort((a, b) => a.delta - b.delta))
|
||||
const better = computed(() => rows.value.filter((item) => item.delta > SAME).sort((a, b) => b.delta - a.delta))
|
||||
|
||||
const summary = computed(() => {
|
||||
const total = rows.value.reduce(
|
||||
@@ -84,18 +76,18 @@ const summary = computed(() => {
|
||||
|
||||
const groups = computed(() =>
|
||||
[
|
||||
{ key: "worse", label: `比${shortOther.value}差`, items: worse.value },
|
||||
{ key: "same", label: "差不多", items: same.value },
|
||||
{ key: "better", label: `比${shortOther.value}好`, items: better.value },
|
||||
{ key: 'worse', label: `比${shortOther.value}差`, items: worse.value },
|
||||
{ key: 'same', label: '差不多', items: same.value },
|
||||
{ key: 'better', label: `比${shortOther.value}好`, items: better.value },
|
||||
].filter((group) => group.items.length),
|
||||
)
|
||||
|
||||
function tone(delta: number) {
|
||||
return delta < -SAME ? "worse" : delta > SAME ? "better" : "same"
|
||||
return delta < -SAME ? 'worse' : delta > SAME ? 'better' : 'same'
|
||||
}
|
||||
|
||||
function problemHref(displayId: string) {
|
||||
return router.resolve({ name: "problem", params: { problemID: displayId } }).href
|
||||
return router.resolve({ name: 'problem', params: { problemID: displayId } }).href
|
||||
}
|
||||
|
||||
function pickGrade(value: string) {
|
||||
@@ -127,19 +119,9 @@ watch([grade, compare], load, { immediate: true })
|
||||
<span class="title">年度趋势</span>
|
||||
<span class="grow" />
|
||||
<span class="muted">比</span>
|
||||
<n-select
|
||||
:value="selected || null"
|
||||
:options="gradeOptions"
|
||||
class="grade-select"
|
||||
@update:value="pickGrade"
|
||||
/>
|
||||
<n-select :value="selected || null" :options="gradeOptions" class="grade-select" @update:value="pickGrade" />
|
||||
<span class="muted">和</span>
|
||||
<n-select
|
||||
:value="otherValue"
|
||||
:options="compareOptions"
|
||||
class="compare-select"
|
||||
@update:value="(value: string) => (compare = value)"
|
||||
/>
|
||||
<n-select :value="otherValue" :options="compareOptions" class="compare-select" @update:value="(value: string) => (compare = value)" />
|
||||
</div>
|
||||
|
||||
<n-spin :show="loading">
|
||||
@@ -151,9 +133,7 @@ watch([grade, compare], load, { immediate: true })
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">
|
||||
一次就对<template v-if="data?.days !== null">
|
||||
· 各自开始做题的头 {{ data?.days }} 天</template
|
||||
>
|
||||
一次就对<template v-if="data?.days !== null"> · 各自开始做题的头 {{ data?.days }} 天</template>
|
||||
</div>
|
||||
<div class="value num">
|
||||
{{ summary.now }}%<span class="unit"> / {{ shortOther }} {{ summary.before }}%</span>
|
||||
@@ -165,9 +145,7 @@ watch([grade, compare], load, { immediate: true })
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">比{{ shortOther }}好</div>
|
||||
<div class="value num better-text">
|
||||
{{ better.length }}<span class="unit"> 道</span>
|
||||
</div>
|
||||
<div class="value num better-text">{{ better.length }}<span class="unit"> 道</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,9 +153,7 @@ watch([grade, compare], load, { immediate: true })
|
||||
<div class="row axis">
|
||||
<span class="c-title" />
|
||||
<span class="c-track ticks num">
|
||||
<span v-for="tick in [0, 25, 50, 75, 100]" :key="tick" :style="{ left: `${tick}%` }"
|
||||
>{{ tick }}%</span
|
||||
>
|
||||
<span v-for="tick in [0, 25, 50, 75, 100]" :key="tick" :style="{ left: `${tick}%` }">{{ tick }}%</span>
|
||||
</span>
|
||||
<span class="legend">
|
||||
<span><i class="dot-now" />{{ selected }} 级</span>
|
||||
@@ -210,13 +186,8 @@ watch([grade, compare], load, { immediate: true })
|
||||
<b :class="`${tone(item.delta)}-text`">{{ item.now }}%</b>
|
||||
<span class="muted"> / {{ item.before }}%</span>
|
||||
</span>
|
||||
<span class="c-delta num strong" :class="`${tone(item.delta)}-text`">
|
||||
{{ item.delta > 0 ? "+" : "" }}{{ item.delta }}
|
||||
</span>
|
||||
<span class="c-users muted small num">
|
||||
{{ selected }} 级 {{ item.mine.users }} 人 · {{ shortOther }}
|
||||
{{ item.theirs.users }} 人
|
||||
</span>
|
||||
<span class="c-delta num strong" :class="`${tone(item.delta)}-text`"> {{ item.delta > 0 ? '+' : '' }}{{ item.delta }} </span>
|
||||
<span class="c-users muted small num"> {{ selected }} 级 {{ item.mine.users }} 人 · {{ shortOther }} {{ item.theirs.users }} 人 </span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
|
||||
import download from "utils/download"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { errorCode, errorMessage } from 'utils/api'
|
||||
import { deleteContestProblem, deleteProblem, makeProblemPublic } from 'admin/api'
|
||||
import download from 'utils/download'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
|
||||
/**
|
||||
* 题目列表一行的操作:编辑、在前台看、下载测试数据、删除直接摆出来(用户说的:不要收进 ⋯)。
|
||||
@@ -17,7 +17,7 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const dialog = useDialog()
|
||||
const emit = defineEmits(["updated"])
|
||||
const emit = defineEmits(['updated'])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -27,25 +27,25 @@ const message = useMessage()
|
||||
// 转成公开题要想好新题号,也留给电脑,都不摆
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
const isContestProblem = computed(() => route.name === "admin contest problem list")
|
||||
const isContestProblem = computed(() => route.name === 'admin contest problem list')
|
||||
|
||||
const showMakePublicModal = ref(false)
|
||||
const newDisplayID = ref("")
|
||||
const newDisplayID = ref('')
|
||||
|
||||
async function handleDeleteProblem() {
|
||||
try {
|
||||
if (route.name === "admin contest problem list") {
|
||||
if (route.name === 'admin contest problem list') {
|
||||
await deleteContestProblem(props.problemID)
|
||||
} else {
|
||||
await deleteProblem(props.problemID)
|
||||
}
|
||||
message.success("删除成功")
|
||||
emit("updated")
|
||||
message.success('删除成功')
|
||||
emit('updated')
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "problem-has-submissions") {
|
||||
message.error("这道题有提交之后,就不能被删除")
|
||||
if (errorCode(err) === 'problem-has-submissions') {
|
||||
message.error('这道题有提交之后,就不能被删除')
|
||||
} else {
|
||||
message.error("删除失败")
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,57 +55,57 @@ function downloads() {
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
const name = route.name!.toString().replace("list", "edit")
|
||||
const name = route.name!.toString().replace('list', 'edit')
|
||||
router.push({ name, params: { problemID: props.problemID } })
|
||||
}
|
||||
|
||||
function goCheck() {
|
||||
let data = router.resolve("/problem/" + props.problemDisplayID)
|
||||
if (route.name === "admin contest problem list") {
|
||||
let data = router.resolve('/problem/' + props.problemDisplayID)
|
||||
if (route.name === 'admin contest problem list') {
|
||||
data = router.resolve({
|
||||
name: "contest problem",
|
||||
name: 'contest problem',
|
||||
params: {
|
||||
contestID: route.params.contestID,
|
||||
problemID: props.problemDisplayID,
|
||||
},
|
||||
})
|
||||
}
|
||||
window.open(data.href, "_blank")
|
||||
window.open(data.href, '_blank')
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
dialog.warning({
|
||||
title: `删除 ${props.problemDisplayID}?`,
|
||||
content: "删掉就找不回来了。",
|
||||
positiveText: "删除",
|
||||
negativeText: "不删",
|
||||
content: '删掉就找不回来了。',
|
||||
positiveText: '删除',
|
||||
negativeText: '不删',
|
||||
onPositiveClick: handleDeleteProblem,
|
||||
})
|
||||
}
|
||||
|
||||
function openMakePublicModal() {
|
||||
newDisplayID.value = ""
|
||||
newDisplayID.value = ''
|
||||
showMakePublicModal.value = true
|
||||
}
|
||||
|
||||
async function handleMakePublic() {
|
||||
if (!newDisplayID.value.trim()) {
|
||||
message.error("请输入新的题目编号")
|
||||
message.error('请输入新的题目编号')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await makeProblemPublic(props.problemID, newDisplayID.value.trim())
|
||||
message.success("已成功转为公开题目(需要手动设置可见)")
|
||||
message.success('已成功转为公开题目(需要手动设置可见)')
|
||||
showMakePublicModal.value = false
|
||||
emit("updated") // 刷新列表
|
||||
emit('updated') // 刷新列表
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "display-id-exists") {
|
||||
message.error("该题目编号已存在,请使用其他编号")
|
||||
} else if (errorCode(err) === "already-public") {
|
||||
message.error("该题目已经是公开题目")
|
||||
if (errorCode(err) === 'display-id-exists') {
|
||||
message.error('该题目编号已存在,请使用其他编号')
|
||||
} else if (errorCode(err) === 'already-public') {
|
||||
message.error('该题目已经是公开题目')
|
||||
} else {
|
||||
message.error("转换失败:" + errorMessage(err, "未知错误"))
|
||||
message.error('转换失败:' + errorMessage(err, '未知错误'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,41 +114,19 @@ async function handleMakePublic() {
|
||||
<n-flex :size="6" :wrap="isMobile">
|
||||
<n-button size="small" @click="goEdit">编辑</n-button>
|
||||
<n-button size="small" title="在前台打开这道题" @click="goCheck">前台看</n-button>
|
||||
<n-button v-if="!isMobile" size="small" title="下载测试数据" @click="downloads">
|
||||
测试数据
|
||||
</n-button>
|
||||
<n-button v-if="isContestProblem && !isMobile" size="small" @click="openMakePublicModal">
|
||||
转成公开题
|
||||
</n-button>
|
||||
<n-button v-if="!isMobile" size="small" title="下载测试数据" @click="downloads"> 测试数据 </n-button>
|
||||
<n-button v-if="isContestProblem && !isMobile" size="small" @click="openMakePublicModal"> 转成公开题 </n-button>
|
||||
<span :title="props.triedUsers ? `有 ${props.triedUsers} 人交过,删不掉` : undefined">
|
||||
<n-button
|
||||
size="small"
|
||||
quaternary
|
||||
type="error"
|
||||
:disabled="!!props.triedUsers"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
<n-button size="small" quaternary type="error" :disabled="!!props.triedUsers" @click="confirmDelete"> 删除 </n-button>
|
||||
</span>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showMakePublicModal"
|
||||
preset="card"
|
||||
title="转为公开题目"
|
||||
style="width: 500px"
|
||||
>
|
||||
<n-modal v-model:show="showMakePublicModal" preset="card" title="转为公开题目" style="width: 500px">
|
||||
<n-space vertical>
|
||||
<p>将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。</p>
|
||||
<n-form>
|
||||
<n-form-item label="新的题目编号" required>
|
||||
<n-input
|
||||
v-model:value="newDisplayID"
|
||||
placeholder="例如: 1001"
|
||||
clearable
|
||||
@keyup.enter="handleMakePublic"
|
||||
/>
|
||||
<n-input v-model:value="newDisplayID" placeholder="例如: 1001" clearable @keyup.enter="handleMakePublic" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-alert type="info" title="提示:请输入一个未被使用的题目编号"> </n-alert>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import { addProblemForContest } from "admin/api"
|
||||
import { errorCode, errorMessage } from 'utils/api'
|
||||
import { addProblemForContest } from 'admin/api'
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
@@ -8,22 +8,22 @@ interface Props {
|
||||
nextDisplayId?: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["added"])
|
||||
const emit = defineEmits(['added'])
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const displayID = ref(props.nextDisplayId || "")
|
||||
const displayID = ref(props.nextDisplayId || '')
|
||||
|
||||
async function addProblem() {
|
||||
if (!displayID.value) return
|
||||
try {
|
||||
await addProblemForContest(props.contestID, props.problemID, displayID.value)
|
||||
emit("added")
|
||||
emit('added')
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "display-id-exists") {
|
||||
message.error("显示编号重复了,请重新写一个")
|
||||
} else if (errorCode(err) === "contest-ended") {
|
||||
message.error("这场比赛已经结束了,不能添加题目")
|
||||
if (errorCode(err) === 'display-id-exists') {
|
||||
message.error('显示编号重复了,请重新写一个')
|
||||
} else if (errorCode(err) === 'contest-ended') {
|
||||
message.error('这场比赛已经结束了,不能添加题目')
|
||||
} else {
|
||||
message.error(errorMessage(err))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { getLibraryProblemList } from "admin/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import type { AdminProblemRow } from "utils/types"
|
||||
import AddButton from "./AddButton.vue"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import { getLibraryProblemList } from 'admin/api'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import type { AdminProblemRow } from 'utils/types'
|
||||
import AddButton from './AddButton.vue'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -12,8 +12,8 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:show", value: boolean): void
|
||||
(e: "change"): void
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'change'): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -21,23 +21,23 @@ const route = useRoute()
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: "",
|
||||
keyword: '',
|
||||
})
|
||||
const total = ref(0)
|
||||
const problems = shallowRef<AdminProblemRow[]>([])
|
||||
|
||||
const columns: DataTableColumn<AdminProblemRow>[] = [
|
||||
{ title: "编号", key: "_id", width: 80 },
|
||||
{ title: "标题", key: "title" },
|
||||
{ title: '编号', key: '_id', width: 80 },
|
||||
{ title: '标题', key: 'title' },
|
||||
{
|
||||
title: "选项",
|
||||
key: "add",
|
||||
title: '选项',
|
||||
key: 'add',
|
||||
render: (row) =>
|
||||
h(AddButton, {
|
||||
problemID: row.id,
|
||||
contestID: route.params.contestID as string,
|
||||
nextDisplayId: props.nextDisplayId,
|
||||
onAdded: () => emit("change"),
|
||||
onAdded: () => emit('change'),
|
||||
}),
|
||||
width: 60,
|
||||
},
|
||||
@@ -68,14 +68,7 @@ watchDebounced(
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<n-modal
|
||||
:mask-closable="false"
|
||||
:show="props.show"
|
||||
preset="card"
|
||||
style="width: 600px"
|
||||
title="从题库中添加"
|
||||
@close="$emit('update:show', false)"
|
||||
>
|
||||
<n-modal :mask-closable="false" :show="props.show" preset="card" style="width: 600px" title="从题库中添加" @close="$emit('update:show', false)">
|
||||
<n-input class="search" v-model:value="query.keyword" clearable placeholder="搜索标题或编号" />
|
||||
<n-data-table :loading="loading" striped :columns="columns" :data="problems" />
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AST_NODE_TARGETS_BY_LANGUAGE,
|
||||
AST_OPERATOR_TARGETS_BY_LANGUAGE,
|
||||
AST_SUPPORTED_LANGUAGES,
|
||||
} from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { AstRule, AstRules, LANGUAGE } from "utils/types"
|
||||
import { checkAstRules } from "../../api"
|
||||
import type { AstCheckState } from "./editorTypes"
|
||||
import { AST_NODE_TARGETS_BY_LANGUAGE, AST_OPERATOR_TARGETS_BY_LANGUAGE, AST_SUPPORTED_LANGUAGES } from '@oj2/contract'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import type { AstRule, AstRules, LANGUAGE } from 'utils/types'
|
||||
import { checkAstRules } from '../../api'
|
||||
import type { AstCheckState } from './editorTypes'
|
||||
|
||||
interface Props {
|
||||
modelValue: AstRules | null
|
||||
@@ -24,20 +20,16 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: AstRules | null): void
|
||||
(e: 'update:modelValue', value: AstRules | null): void
|
||||
}>()
|
||||
|
||||
// 判题机只认 C / Python,别的语言配了规则也一条都不会跑(judge/ast.ts 的
|
||||
// loadLanguage 返回 null 就直接放行)。原来这里按题目的全部语言开 tab,老师给
|
||||
// C++ 配的规则存得下、题目页也照常显示成「要求」,判题却从不检查。
|
||||
const supportedLanguages = computed(() =>
|
||||
props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)),
|
||||
)
|
||||
const unsupportedLanguages = computed(() =>
|
||||
props.languages.filter((lang) => !AST_SUPPORTED_LANGUAGES.includes(lang)),
|
||||
)
|
||||
const supportedLanguages = computed(() => props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)))
|
||||
const unsupportedLanguages = computed(() => props.languages.filter((lang) => !AST_SUPPORTED_LANGUAGES.includes(lang)))
|
||||
|
||||
const activeTab = ref(supportedLanguages.value[0] || "Python")
|
||||
const activeTab = ref(supportedLanguages.value[0] || 'Python')
|
||||
|
||||
const theme = useThemeVars()
|
||||
|
||||
@@ -47,7 +39,7 @@ const theme = useThemeVars()
|
||||
* 规则配错(节点类型对不上)时完全静默:「必须使用 X」永远失败、「不能使用 X」永远通过。
|
||||
* 拿标准答案先跑一遍,标准答案自己都过不了,多半是规则配错了。顺带拿到学生看到的那句话
|
||||
*/
|
||||
const checks = defineModel<AstCheckState>("checks", { default: () => ({}) })
|
||||
const checks = defineModel<AstCheckState>('checks', { default: () => ({}) })
|
||||
|
||||
let generation = 0
|
||||
|
||||
@@ -59,12 +51,12 @@ async function runChecks() {
|
||||
if (!rules.length) continue
|
||||
try {
|
||||
next[lang] = await checkAstRules({
|
||||
language: lang as "C" | "C++" | "Python",
|
||||
code: props.answers[lang] ?? "",
|
||||
language: lang as 'C' | 'C++' | 'Python',
|
||||
code: props.answers[lang] ?? '',
|
||||
rules,
|
||||
})
|
||||
} catch (err) {
|
||||
next[lang] = { error: errorMessage(err, "这组规则检查不了") }
|
||||
next[lang] = { error: errorMessage(err, '这组规则检查不了') }
|
||||
}
|
||||
}
|
||||
// 跑的过程中又改了:这一轮作废,新的那轮另有一次
|
||||
@@ -79,7 +71,7 @@ watchDebounced(() => [props.modelValue, props.answers, supportedLanguages.value]
|
||||
|
||||
function checkOf(lang: string, index: number) {
|
||||
const state = checks.value[lang]
|
||||
if (!state || "error" in state) return null
|
||||
if (!state || 'error' in state) return null
|
||||
return state.rules[index] ?? null
|
||||
}
|
||||
|
||||
@@ -89,39 +81,39 @@ function countOf(lang: string) {
|
||||
|
||||
const ENGINE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
label: "节点检查",
|
||||
type: "group",
|
||||
key: "node_group",
|
||||
label: '节点检查',
|
||||
type: 'group',
|
||||
key: 'node_group',
|
||||
children: [
|
||||
{ label: "必须存在", value: "must_exist_node" },
|
||||
{ label: "不能存在", value: "must_not_exist_node" },
|
||||
{ label: "出现次数", value: "count_node" },
|
||||
{ label: '必须存在', value: 'must_exist_node' },
|
||||
{ label: '不能存在', value: 'must_not_exist_node' },
|
||||
{ label: '出现次数', value: 'count_node' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "函数调用",
|
||||
type: "group",
|
||||
key: "func_group",
|
||||
label: '函数调用',
|
||||
type: 'group',
|
||||
key: 'func_group',
|
||||
children: [
|
||||
{ label: "必须调用函数", value: "must_call_function" },
|
||||
{ label: "不能调用函数", value: "must_not_call_function" },
|
||||
{ label: "函数调用次数", value: "count_function_call" },
|
||||
{ label: '必须调用函数', value: 'must_call_function' },
|
||||
{ label: '不能调用函数', value: 'must_not_call_function' },
|
||||
{ label: '函数调用次数', value: 'count_function_call' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "方法调用",
|
||||
type: "group",
|
||||
key: "method_group",
|
||||
label: '方法调用',
|
||||
type: 'group',
|
||||
key: 'method_group',
|
||||
children: [
|
||||
{ label: "必须调用方法", value: "must_call_method" },
|
||||
{ label: "不能调用方法", value: "must_not_call_method" },
|
||||
{ label: '必须调用方法', value: 'must_call_method' },
|
||||
{ label: '不能调用方法', value: 'must_not_call_method' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "运算符",
|
||||
type: "group",
|
||||
key: "op_group",
|
||||
children: [{ label: "必须使用运算符", value: "must_use_operator" }],
|
||||
label: '运算符',
|
||||
type: 'group',
|
||||
key: 'op_group',
|
||||
children: [{ label: '必须使用运算符', value: 'must_use_operator' }],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -143,11 +135,11 @@ function operatorTargetOptions(lang: string): SelectOption[] {
|
||||
}))
|
||||
}
|
||||
|
||||
const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"]
|
||||
const FUNCTION_ENGINES = ["must_call_function", "must_not_call_function", "count_function_call"]
|
||||
const METHOD_ENGINES = ["must_call_method", "must_not_call_method"]
|
||||
const OPERATOR_ENGINES = ["must_use_operator"]
|
||||
const COUNT_ENGINES = ["count_node", "count_function_call"]
|
||||
const NODE_ENGINES = ['must_exist_node', 'must_not_exist_node', 'count_node']
|
||||
const FUNCTION_ENGINES = ['must_call_function', 'must_not_call_function', 'count_function_call']
|
||||
const METHOD_ENGINES = ['must_call_method', 'must_not_call_method']
|
||||
const OPERATOR_ENGINES = ['must_use_operator']
|
||||
const COUNT_ENGINES = ['count_node', 'count_function_call']
|
||||
|
||||
function isNodeEngine(engine: string) {
|
||||
return NODE_ENGINES.includes(engine)
|
||||
@@ -166,18 +158,18 @@ function isCountEngine(engine: string) {
|
||||
}
|
||||
|
||||
const COUNT_MODE_OPTIONS: SelectOption[] = [
|
||||
{ label: "正好", value: "exact" },
|
||||
{ label: "范围", value: "range" },
|
||||
{ label: '正好', value: 'exact' },
|
||||
{ label: '范围', value: 'range' },
|
||||
]
|
||||
|
||||
function getCountMode(rule: AstRule): "exact" | "range" {
|
||||
return rule.exact !== undefined ? "exact" : "range"
|
||||
function getCountMode(rule: AstRule): 'exact' | 'range' {
|
||||
return rule.exact !== undefined ? 'exact' : 'range'
|
||||
}
|
||||
|
||||
function updateCountMode(lang: string, index: number, mode: "exact" | "range") {
|
||||
function updateCountMode(lang: string, index: number, mode: 'exact' | 'range') {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
if (mode === "exact") {
|
||||
if (mode === 'exact') {
|
||||
rule.exact = rule.min ?? 1
|
||||
delete rule.min
|
||||
delete rule.max
|
||||
@@ -222,7 +214,7 @@ function updateRules(lang: string, rules: AstRule[]) {
|
||||
} else {
|
||||
current[lang] = rules
|
||||
}
|
||||
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
|
||||
emit('update:modelValue', Object.keys(current).length > 0 ? current : null)
|
||||
}
|
||||
|
||||
function getTargetLabel(lang: string, engine: string, target: string): string | undefined {
|
||||
@@ -235,10 +227,10 @@ function getTargetLabel(lang: string, engine: string, target: string): string |
|
||||
function addRule(lang: string) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
rules.push({
|
||||
engine: "must_exist_node",
|
||||
target: "for_loop",
|
||||
label: getTargetLabel(lang, "must_exist_node", "for_loop"),
|
||||
message: "",
|
||||
engine: 'must_exist_node',
|
||||
target: 'for_loop',
|
||||
label: getTargetLabel(lang, 'must_exist_node', 'for_loop'),
|
||||
message: '',
|
||||
})
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
@@ -253,16 +245,16 @@ function updateRule(lang: string, index: number, field: string, value: any) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
|
||||
if (field === "engine") {
|
||||
if (field === 'engine') {
|
||||
rule.engine = value
|
||||
if (isNodeEngine(value)) {
|
||||
rule.target = "for_loop"
|
||||
rule.label = getTargetLabel(lang, value, "for_loop")
|
||||
rule.target = 'for_loop'
|
||||
rule.label = getTargetLabel(lang, value, 'for_loop')
|
||||
} else if (isOperatorEngine(value)) {
|
||||
rule.target = "+"
|
||||
rule.target = '+'
|
||||
delete rule.label
|
||||
} else {
|
||||
rule.target = ""
|
||||
rule.target = ''
|
||||
delete rule.label
|
||||
}
|
||||
delete rule.min
|
||||
@@ -270,18 +262,18 @@ function updateRule(lang: string, index: number, field: string, value: any) {
|
||||
delete rule.exact
|
||||
// 次数引擎不给默认值的话,存下去就是一条恒真规则
|
||||
if (isCountEngine(value)) rule.exact = 1
|
||||
} else if (field === "target") {
|
||||
} else if (field === 'target') {
|
||||
rule.target = value
|
||||
const lbl = getTargetLabel(lang, rule.engine, value)
|
||||
if (lbl) rule.label = lbl
|
||||
else delete rule.label
|
||||
} else if (field === "min") {
|
||||
} else if (field === 'min') {
|
||||
if (value === null || value === undefined) delete rule.min
|
||||
else rule.min = value
|
||||
} else if (field === "max") {
|
||||
} else if (field === 'max') {
|
||||
if (value === null || value === undefined) delete rule.max
|
||||
else rule.max = value
|
||||
} else if (field === "message") {
|
||||
} else if (field === 'message') {
|
||||
rule.message = value
|
||||
}
|
||||
|
||||
@@ -299,13 +291,8 @@ watch(supportedLanguages, (langs) => {
|
||||
<template>
|
||||
<div class="astRules">
|
||||
<div class="head">
|
||||
<span class="label">{{ challenge ? "判定" : "语法要求" }}</span>
|
||||
<div
|
||||
v-if="supportedLanguages.length && !challenge"
|
||||
class="langs"
|
||||
role="tablist"
|
||||
aria-label="语言"
|
||||
>
|
||||
<span class="label">{{ challenge ? '判定' : '语法要求' }}</span>
|
||||
<div v-if="supportedLanguages.length && !challenge" class="langs" role="tablist" aria-label="语言">
|
||||
<button
|
||||
v-for="lang in supportedLanguages"
|
||||
:key="lang"
|
||||
@@ -316,7 +303,7 @@ watch(supportedLanguages, (langs) => {
|
||||
:class="{ on: activeTab === lang }"
|
||||
@click="activeTab = lang"
|
||||
>
|
||||
{{ lang }} · {{ countOf(lang) ? `${countOf(lang)} 条` : "没有" }}
|
||||
{{ lang }} · {{ countOf(lang) ? `${countOf(lang)} 条` : '没有' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
@@ -324,8 +311,7 @@ watch(supportedLanguages, (langs) => {
|
||||
<n-text v-else depth="3" class="note">几条都满足才算做到</n-text>
|
||||
</div>
|
||||
<n-text v-if="unsupportedLanguages.length" depth="3" class="note">
|
||||
{{ unsupportedLanguages.join("、") }} 检查不了语法要求(判题机只认
|
||||
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }})
|
||||
{{ unsupportedLanguages.join('、') }} 检查不了语法要求(判题机只认 {{ AST_SUPPORTED_LANGUAGES.join(' / ') }})
|
||||
</n-text>
|
||||
<n-text v-if="!supportedLanguages.length" depth="3" class="note">先选能交的语言</n-text>
|
||||
<template v-for="lang in supportedLanguages" :key="lang">
|
||||
@@ -418,31 +404,19 @@ watch(supportedLanguages, (langs) => {
|
||||
size="tiny"
|
||||
class="message"
|
||||
:placeholder="
|
||||
checkOf(lang, index)?.description
|
||||
? `学生看到「${checkOf(lang, index)!.description}」,想换个说法写这里`
|
||||
: '学生看到的话(不填就按规则自动写)'
|
||||
checkOf(lang, index)?.description ? `学生看到「${checkOf(lang, index)!.description}」,想换个说法写这里` : '学生看到的话(不填就按规则自动写)'
|
||||
"
|
||||
@update:value="(v: string) => updateRule(lang, index, 'message', v)"
|
||||
/>
|
||||
<template v-if="challenge">
|
||||
<n-text v-if="checkOf(lang, index)?.passed === true" depth="3" class="note">
|
||||
标准答案这样写也满足
|
||||
</n-text>
|
||||
<n-text v-if="checkOf(lang, index)?.passed === true" depth="3" class="note"> 标准答案这样写也满足 </n-text>
|
||||
<n-text v-else-if="checkOf(lang, index)?.passed === false" depth="3" class="note">
|
||||
标准答案不是这样写的{{
|
||||
checkOf(lang, index)!.actual !== undefined
|
||||
? `(数到 ${checkOf(lang, index)!.actual} 次)`
|
||||
: ""
|
||||
}}
|
||||
标准答案不是这样写的{{ checkOf(lang, index)!.actual !== undefined ? `(数到 ${checkOf(lang, index)!.actual} 次)` : '' }}
|
||||
</n-text>
|
||||
</template>
|
||||
<span v-else-if="checkOf(lang, index)?.passed === true" class="ok">✓ 标准答案过了</span>
|
||||
<span v-else-if="checkOf(lang, index)?.passed === false" class="bad">
|
||||
✗ 标准答案自己没过{{
|
||||
checkOf(lang, index)!.actual !== undefined
|
||||
? `(数到 ${checkOf(lang, index)!.actual} 次)`
|
||||
: ""
|
||||
}}
|
||||
✗ 标准答案自己没过{{ checkOf(lang, index)!.actual !== undefined ? `(数到 ${checkOf(lang, index)!.actual} 次)` : '' }}
|
||||
</span>
|
||||
<n-text v-else-if="checkOf(lang, index)" depth="3">没有 {{ lang }} 的标准答案</n-text>
|
||||
</div>
|
||||
@@ -472,14 +446,14 @@ watch(supportedLanguages, (langs) => {
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
color: v-bind('theme.textColor2');
|
||||
width: 72px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.langs {
|
||||
display: flex;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -491,17 +465,17 @@ watch(supportedLanguages, (langs) => {
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lang + .lang {
|
||||
border-left: 1px solid v-bind("theme.borderColor");
|
||||
border-left: 1px solid v-bind('theme.borderColor');
|
||||
}
|
||||
|
||||
.lang.on {
|
||||
background-color: rgba(24, 160, 88, 0.12);
|
||||
color: v-bind("theme.primaryColorPressed");
|
||||
color: v-bind('theme.primaryColorPressed');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -524,7 +498,7 @@ watch(supportedLanguages, (langs) => {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
border-radius: 4px;
|
||||
background-color: rgba(128, 128, 128, 0.04);
|
||||
font-size: 13px;
|
||||
@@ -557,17 +531,17 @@ watch(supportedLanguages, (langs) => {
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: v-bind("theme.successColor");
|
||||
color: v-bind('theme.successColor');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bad {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ruleError {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTag } from "utils/types"
|
||||
import { batchTagProblems, getTagAdminList } from "admin/api"
|
||||
import type { AdminTag } from 'utils/types'
|
||||
import { batchTagProblems, getTagAdminList } from 'admin/api'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
problemIds: number[]
|
||||
action: "add" | "remove"
|
||||
action: 'add' | 'remove'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [value: boolean]
|
||||
'update:show': [value: boolean]
|
||||
done: []
|
||||
}>()
|
||||
|
||||
@@ -20,15 +20,11 @@ const tags = ref<AdminTag[]>([])
|
||||
const selected = ref<string[]>([])
|
||||
const newTags = ref<string[]>([])
|
||||
|
||||
const title = computed(() => (props.action === "add" ? "批量添加标签" : "批量移除标签"))
|
||||
const title = computed(() => (props.action === 'add' ? '批量添加标签' : '批量移除标签'))
|
||||
|
||||
const selectedSet = computed(() => new Set(selected.value))
|
||||
|
||||
const names = computed(() =>
|
||||
props.action === "add"
|
||||
? Array.from(new Set([...selected.value, ...newTags.value]))
|
||||
: selected.value,
|
||||
)
|
||||
const names = computed(() => (props.action === 'add' ? Array.from(new Set([...selected.value, ...newTags.value])) : selected.value))
|
||||
|
||||
function toggleTag(name: string) {
|
||||
const set = new Set(selected.value)
|
||||
@@ -43,19 +39,19 @@ async function listTags() {
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:show", false)
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!names.value.length) {
|
||||
message.error("请先选择标签")
|
||||
message.error('请先选择标签')
|
||||
return
|
||||
}
|
||||
const res = await batchTagProblems(props.problemIds, names.value, props.action)
|
||||
const verb = props.action === "add" ? "添加" : "移除"
|
||||
const verb = props.action === 'add' ? '添加' : '移除'
|
||||
message.success(`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`)
|
||||
close()
|
||||
emit("done")
|
||||
emit('done')
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -70,24 +66,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="title"
|
||||
style="width: 600px"
|
||||
:mask-closable="false"
|
||||
@close="close"
|
||||
>
|
||||
<n-modal :show="show" preset="card" :title="title" style="width: 600px" :mask-closable="false" @close="close">
|
||||
<n-flex vertical size="large">
|
||||
<div>已选中 {{ problemIds.length }} 道题目</div>
|
||||
<n-flex size="small">
|
||||
<n-tag
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
checkable
|
||||
:checked="selectedSet.has(tag.name)"
|
||||
@update:checked="toggleTag(tag.name)"
|
||||
>
|
||||
<n-tag v-for="tag in tags" :key="tag.id" checkable :checked="selectedSet.has(tag.name)" @update:checked="toggleTag(tag.name)">
|
||||
{{ tag.name }}({{ tag.problemCount }})
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { caseStatus, type AnswerSource, type CaseRow } from "./caseRows"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { caseStatus, type AnswerSource, type CaseRow } from './caseRows'
|
||||
|
||||
/**
|
||||
* 测试数据一组一行:输入 | 输出 | 当例子(设计稿「编程题出题页」B)。
|
||||
* 有标准答案时输出是跑出来的、只读;没有时(老题)输出手写。
|
||||
*/
|
||||
const rows = defineModel<CaseRow[]>("rows", { required: true })
|
||||
const rows = defineModel<CaseRow[]>('rows', { required: true })
|
||||
const props = defineProps<{
|
||||
source: AnswerSource | null
|
||||
/** 只有例子这一列表的时候(测试数据太大、单独写例子)不显示「当例子」 */
|
||||
@@ -23,14 +23,13 @@ const CLIP_LINES = 6
|
||||
const expanded = ref(new Set<number>())
|
||||
|
||||
function lines(text: string) {
|
||||
return text.split("\n")
|
||||
return text.split('\n')
|
||||
}
|
||||
|
||||
function clipped(row: CaseRow) {
|
||||
const all = lines(row.output)
|
||||
if (expanded.value.has(row.key) || all.length <= CLIP_LINES + 1)
|
||||
return { text: row.output, rest: 0 }
|
||||
return { text: all.slice(0, CLIP_LINES).join("\n"), rest: all.length - CLIP_LINES }
|
||||
if (expanded.value.has(row.key) || all.length <= CLIP_LINES + 1) return { text: row.output, rest: 0 }
|
||||
return { text: all.slice(0, CLIP_LINES).join('\n'), rest: all.length - CLIP_LINES }
|
||||
}
|
||||
|
||||
function toggle(key: number) {
|
||||
@@ -52,17 +51,12 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
<div class="row head">
|
||||
<span></span>
|
||||
<span>输入</span>
|
||||
<span>{{ source ? "输出(答案跑出来的)" : "输出(手写)" }}</span>
|
||||
<span>{{ source ? '输出(答案跑出来的)' : '输出(手写)' }}</span>
|
||||
<span v-if="!examplesOnly">当例子</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in rows"
|
||||
:key="row.key"
|
||||
class="row"
|
||||
:class="{ bad: status(row) === 'error', changed: status(row) === 'changed' }"
|
||||
>
|
||||
<div v-for="(row, index) in rows" :key="row.key" class="row" :class="{ bad: status(row) === 'error', changed: status(row) === 'changed' }">
|
||||
<b class="no">{{ index + 1 }}</b>
|
||||
<n-input
|
||||
v-model:value="row.input"
|
||||
@@ -86,25 +80,16 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
<div v-if="status(row) === 'error'" class="cell errorCell">{{ row.error }}</div>
|
||||
<template v-else>
|
||||
<pre class="cell" :class="{ stale: status(row) === 'running' }">{{
|
||||
clipped(row).text || (status(row) === "running" ? "在跑……" : "(没有输出)")
|
||||
clipped(row).text || (status(row) === 'running' ? '在跑……' : '(没有输出)')
|
||||
}}</pre>
|
||||
<button v-if="clipped(row).rest" type="button" class="more" @click="toggle(row.key)">
|
||||
⋯ 还有 {{ clipped(row).rest }} 行
|
||||
</button>
|
||||
<button
|
||||
v-else-if="expanded.has(row.key)"
|
||||
type="button"
|
||||
class="more"
|
||||
@click="toggle(row.key)"
|
||||
>
|
||||
收起
|
||||
</button>
|
||||
<button v-if="clipped(row).rest" type="button" class="more" @click="toggle(row.key)">⋯ 还有 {{ clipped(row).rest }} 行</button>
|
||||
<button v-else-if="expanded.has(row.key)" type="button" class="more" @click="toggle(row.key)">收起</button>
|
||||
<div v-if="status(row) === 'changed'" class="was">
|
||||
变了 · 原来存的是
|
||||
<del>{{
|
||||
lines(row.saved ?? "")
|
||||
lines(row.saved ?? '')
|
||||
.slice(0, 3)
|
||||
.join(" ⏎ ") || "(空)"
|
||||
.join(' ⏎ ') || '(空)'
|
||||
}}</del>
|
||||
</div>
|
||||
</template>
|
||||
@@ -123,15 +108,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
<template v-else-if="status(row) === 'error'">✗</template>
|
||||
<template v-else-if="status(row) === 'changed'">●</template>
|
||||
</span>
|
||||
<n-button
|
||||
quaternary
|
||||
size="tiny"
|
||||
class="remove"
|
||||
:aria-label="`删掉第 ${index + 1} 组`"
|
||||
@click="remove(index)"
|
||||
>
|
||||
删掉
|
||||
</n-button>
|
||||
<n-button quaternary size="tiny" class="remove" :aria-label="`删掉第 ${index + 1} 组`" @click="remove(index)"> 删掉 </n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,7 +126,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 7px 4px;
|
||||
border-top: 1px solid v-bind("theme.dividerColor");
|
||||
border-top: 1px solid v-bind('theme.dividerColor');
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -162,7 +139,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
border-top: 0;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.row.changed {
|
||||
@@ -176,7 +153,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
|
||||
.mono :deep(textarea),
|
||||
.cell {
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
|
||||
font-family: ui-monospace, 'Cascadia Mono', Consolas, 'Microsoft YaHei', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -196,7 +173,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
min-height: 26px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
background-color: rgba(128, 128, 128, 0.05);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
@@ -209,8 +186,8 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
.errorCell {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: v-bind("theme.errorColor");
|
||||
border-color: v-bind("theme.errorColorSuppl");
|
||||
color: v-bind('theme.errorColor');
|
||||
border-color: v-bind('theme.errorColorSuppl');
|
||||
background-color: rgba(208, 48, 80, 0.06);
|
||||
}
|
||||
|
||||
@@ -219,7 +196,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
padding: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -228,11 +205,11 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
}
|
||||
|
||||
.was {
|
||||
color: v-bind("theme.warningColorPressed");
|
||||
color: v-bind('theme.warningColorPressed');
|
||||
}
|
||||
|
||||
.was del {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.example {
|
||||
@@ -245,15 +222,15 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
}
|
||||
|
||||
.mark.ok {
|
||||
color: v-bind("theme.successColor");
|
||||
color: v-bind('theme.successColor');
|
||||
}
|
||||
|
||||
.mark.error {
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
}
|
||||
|
||||
.mark.changed {
|
||||
color: v-bind("theme.warningColor");
|
||||
color: v-bind('theme.warningColor');
|
||||
}
|
||||
|
||||
.remove {
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
type AstRules,
|
||||
type ChallengeInput,
|
||||
type ChallengeSuggestionsResponse,
|
||||
} from "@oj2/contract"
|
||||
import { errorMessage } from "utils/api"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import { getChallengeSuggestions } from "../../api"
|
||||
import AstRulesEditor from "./AstRulesEditor.vue"
|
||||
} from '@oj2/contract'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import type { LANGUAGE } from 'utils/types'
|
||||
import { getChallengeSuggestions } from '../../api'
|
||||
import AstRulesEditor from './AstRulesEditor.vue'
|
||||
|
||||
/**
|
||||
* 出题页的「挑战」:做对之后,同一道题加一条写法上的限制再做一遍(设计稿 StkAdmin)。
|
||||
@@ -29,15 +29,13 @@ const props = defineProps<{
|
||||
problemId: number | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: Record<string, ChallengeInput[]> | null): void
|
||||
(e: 'update:modelValue', value: Record<string, ChallengeInput[]> | null): void
|
||||
}>()
|
||||
|
||||
const languages = computed(() =>
|
||||
props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)),
|
||||
)
|
||||
const tab = ref<string>(languages.value[0] ?? "Python")
|
||||
const languages = computed(() => props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)))
|
||||
const tab = ref<string>(languages.value[0] ?? 'Python')
|
||||
watch(languages, (list) => {
|
||||
if (!list.includes(tab.value as LANGUAGE)) tab.value = list[0] ?? "Python"
|
||||
if (!list.includes(tab.value as LANGUAGE)) tab.value = list[0] ?? 'Python'
|
||||
})
|
||||
|
||||
const list = computed(() => props.modelValue?.[tab.value] ?? [])
|
||||
@@ -46,7 +44,7 @@ function update(next: ChallengeInput[]) {
|
||||
const value = { ...props.modelValue }
|
||||
if (next.length) value[tab.value] = next
|
||||
else delete value[tab.value]
|
||||
emit("update:modelValue", Object.keys(value).length ? value : null)
|
||||
emit('update:modelValue', Object.keys(value).length ? value : null)
|
||||
}
|
||||
|
||||
function patch(index: number, change: Partial<ChallengeInput>) {
|
||||
@@ -57,7 +55,7 @@ function remove(index: number) {
|
||||
update(list.value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function add(item: ChallengeInput = { wording: "", rules: [] }) {
|
||||
function add(item: ChallengeInput = { wording: '', rules: [] }) {
|
||||
update([...list.value, item])
|
||||
}
|
||||
|
||||
@@ -75,19 +73,19 @@ const full = computed(() => list.value.length >= CHALLENGES_PER_LANGUAGE_MAX)
|
||||
|
||||
const suggestions = ref<Record<string, ChallengeSuggestionsResponse>>({})
|
||||
const loading = ref(false)
|
||||
const error = ref("")
|
||||
const error = ref('')
|
||||
|
||||
async function findSuggestions() {
|
||||
if (!props.problemId) return
|
||||
loading.value = true
|
||||
error.value = ""
|
||||
error.value = ''
|
||||
try {
|
||||
suggestions.value = {
|
||||
...suggestions.value,
|
||||
[tab.value]: await getChallengeSuggestions(props.problemId, tab.value),
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = errorMessage(err, "没找成")
|
||||
error.value = errorMessage(err, '没找成')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -98,9 +96,7 @@ const current = computed(() => suggestions.value[tab.value] ?? null)
|
||||
/** 已经加上的不再列 */
|
||||
const shown = computed(() => {
|
||||
const taken = list.value.map((item) => JSON.stringify(item.rules))
|
||||
return (current.value?.suggestions ?? []).filter(
|
||||
(item) => !taken.includes(JSON.stringify(item.rules)),
|
||||
)
|
||||
return (current.value?.suggestions ?? []).filter((item) => !taken.includes(JSON.stringify(item.rules)))
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -108,7 +104,7 @@ const shown = computed(() => {
|
||||
<div class="challenge-editor">
|
||||
<n-radio-group v-if="languages.length > 1" v-model:value="tab" size="small">
|
||||
<n-radio-button v-for="lang in languages" :key="lang" :value="lang">
|
||||
{{ lang }}{{ modelValue?.[lang]?.length ? ` ${modelValue[lang]!.length} 条` : "" }}
|
||||
{{ lang }}{{ modelValue?.[lang]?.length ? ` ${modelValue[lang]!.length} 条` : '' }}
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
|
||||
@@ -133,46 +129,26 @@ const shown = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-text v-if="!languages.length" depth="3" class="note">
|
||||
只有 Python、C、C++ 能配挑战,先在「学生怎么写」里勾上其中一种。
|
||||
</n-text>
|
||||
<n-text v-if="!languages.length" depth="3" class="note"> 只有 Python、C、C++ 能配挑战,先在「学生怎么写」里勾上其中一种。 </n-text>
|
||||
<template v-else>
|
||||
<div class="actions">
|
||||
<n-button size="small" :disabled="full" @click="add()">+ 自己写一条</n-button>
|
||||
<n-button
|
||||
v-if="problemId"
|
||||
size="small"
|
||||
:loading="loading"
|
||||
:disabled="full"
|
||||
@click="findSuggestions"
|
||||
>
|
||||
从做对的代码里找
|
||||
</n-button>
|
||||
<n-button v-if="problemId" size="small" :loading="loading" :disabled="full" @click="findSuggestions"> 从做对的代码里找 </n-button>
|
||||
</div>
|
||||
<n-text v-if="error" type="error" class="note">{{ error }}</n-text>
|
||||
|
||||
<div v-if="current" class="suggestions">
|
||||
<n-text depth="3" class="note">
|
||||
{{ current.acCount }} 个人用 {{ tab }} 做对过,下面是有一批人换了种写法也做对的地方
|
||||
</n-text>
|
||||
<n-text depth="3" class="note"> {{ current.acCount }} 个人用 {{ tab }} 做对过,下面是有一批人换了种写法也做对的地方 </n-text>
|
||||
<n-text v-if="!shown.length" depth="3" class="note">没找到别的写法</n-text>
|
||||
<div v-for="item in shown" :key="JSON.stringify(item.rules)" class="suggestion">
|
||||
<div class="suggestionHead">
|
||||
<div class="suggestionText">
|
||||
<b>{{ item.wording }}</b>
|
||||
<n-text depth="3" class="note">
|
||||
{{ item.did }} 人这样做对<template v-if="item.firstShare !== null"
|
||||
>,第一次做对时 {{ item.firstShare }}% 这样写</template
|
||||
>
|
||||
{{ item.did }} 人这样做对<template v-if="item.firstShare !== null">,第一次做对时 {{ item.firstShare }}% 这样写</template>
|
||||
</n-text>
|
||||
</div>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="full"
|
||||
@click="add({ wording: item.wording, rules: item.rules })"
|
||||
>
|
||||
加上
|
||||
</n-button>
|
||||
<n-button size="small" :disabled="full" @click="add({ wording: item.wording, rules: item.rules })"> 加上 </n-button>
|
||||
</div>
|
||||
<pre class="example">{{ item.example }}</pre>
|
||||
</div>
|
||||
@@ -255,7 +231,7 @@ const shown = computed(() => {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
|
||||
font-family: ui-monospace, 'Cascadia Mono', Consolas, 'Microsoft YaHei', monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import Markdown from "shared/components/Markdown.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
import { DIFFICULTY, SOURCES } from "utils/constants"
|
||||
import { getTagColor } from "utils/functions"
|
||||
import type { AstRules, BlankProblem, LANGUAGE } from "utils/types"
|
||||
import type { FlowDoc } from "shared/components/FlowchartEditor/model"
|
||||
import type { AstCheckState, FlowchartMode, PreviewTab } from "./editorTypes"
|
||||
import type { TemplateParts } from "./codeTemplate"
|
||||
import { isBlankHtml } from "./problemForm"
|
||||
import Markdown from 'shared/components/Markdown.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useMermaid } from 'shared/composables/useMermaid'
|
||||
import { DIFFICULTY, SOURCES } from 'utils/constants'
|
||||
import { getTagColor } from 'utils/functions'
|
||||
import type { AstRules, BlankProblem, LANGUAGE } from 'utils/types'
|
||||
import type { FlowDoc } from 'shared/components/FlowchartEditor/model'
|
||||
import type { AstCheckState, FlowchartMode, PreviewTab } from './editorTypes'
|
||||
import type { TemplateParts } from './codeTemplate'
|
||||
import { isBlankHtml } from './problemForm'
|
||||
|
||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||
const FlowchartView = defineAsyncComponent(
|
||||
() => import("shared/components/FlowchartEditor/FlowchartView.vue"),
|
||||
)
|
||||
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
|
||||
const FlowchartView = defineAsyncComponent(() => import('shared/components/FlowchartEditor/FlowchartView.vue'))
|
||||
|
||||
/**
|
||||
* 编程题出题页的右栏:学生打开这道题看到的样子(设计稿「编程题出题页」B)。
|
||||
@@ -23,7 +21,7 @@ const FlowchartView = defineAsyncComponent(
|
||||
const props = defineProps<{
|
||||
displayId: string
|
||||
title: string
|
||||
difficulty: BlankProblem["difficulty"]
|
||||
difficulty: BlankProblem['difficulty']
|
||||
description: string
|
||||
inputDescription: string
|
||||
outputDescription: string
|
||||
@@ -43,38 +41,29 @@ const props = defineProps<{
|
||||
flowchartDoc: FlowDoc | null
|
||||
}>()
|
||||
|
||||
const tab = defineModel<PreviewTab>("tab", { required: true })
|
||||
const tab = defineModel<PreviewTab>('tab', { required: true })
|
||||
const emit = defineEmits<{ renderState: [ok: boolean]; collapse: [] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
|
||||
// ---------------------------------------------------------------- 语法要求
|
||||
|
||||
const KIND_TAG_TYPE = { require: "success", forbid: "error", count: "info" } as const
|
||||
const KIND_TAG_TYPE = { require: 'success', forbid: 'error', count: 'info' } as const
|
||||
|
||||
function requirementsOf(lang: string) {
|
||||
const rules = props.astRules?.[lang] ?? []
|
||||
const state = props.astChecks[lang]
|
||||
return rules.map((rule, i) => ({
|
||||
text:
|
||||
rule.message ||
|
||||
(state && !("error" in state) ? state.rules[i]?.description : "") ||
|
||||
"(规则还没写完)",
|
||||
kind: rule.engine.startsWith("must_not")
|
||||
? ("forbid" as const)
|
||||
: rule.engine.startsWith("count")
|
||||
? ("count" as const)
|
||||
: ("require" as const),
|
||||
text: rule.message || (state && !('error' in state) ? state.rules[i]?.description : '') || '(规则还没写完)',
|
||||
kind: rule.engine.startsWith('must_not') ? ('forbid' as const) : rule.engine.startsWith('count') ? ('count' as const) : ('require' as const),
|
||||
}))
|
||||
}
|
||||
|
||||
const requirementLanguages = computed(() =>
|
||||
props.languages.filter((lang) => (props.astRules?.[lang]?.length ?? 0) > 0),
|
||||
)
|
||||
const requirementLanguages = computed(() => props.languages.filter((lang) => (props.astRules?.[lang]?.length ?? 0) > 0))
|
||||
|
||||
// ---------------------------------------------------------------- 编辑器
|
||||
|
||||
const editorLanguage = ref<LANGUAGE>(props.languages[0] ?? "Python")
|
||||
const editorLanguage = ref<LANGUAGE>(props.languages[0] ?? 'Python')
|
||||
watch(
|
||||
() => props.languages,
|
||||
(langs) => {
|
||||
@@ -92,18 +81,18 @@ const editorCode = computed(() => {
|
||||
|
||||
const referenceDoc = computed(() => (props.flowchartDoc?.steps.length ? props.flowchartDoc : null))
|
||||
|
||||
const container = useTemplateRef<HTMLElement>("chart")
|
||||
const container = useTemplateRef<HTMLElement>('chart')
|
||||
const { renderFlowchart, renderError, renderSuccess } = useMermaid()
|
||||
|
||||
async function render() {
|
||||
await renderFlowchart(container.value, props.mermaidCode)
|
||||
emit("renderState", renderSuccess.value)
|
||||
emit('renderState', renderSuccess.value)
|
||||
}
|
||||
|
||||
// 改了先报「没验证」,防抖之后的渲染跑完再报结果(和 MermaidEditor 同一个理由)
|
||||
watch(
|
||||
() => props.mermaidCode,
|
||||
() => emit("renderState", false),
|
||||
() => emit('renderState', false),
|
||||
)
|
||||
watchDebounced(() => props.mermaidCode, render, { debounce: 300, maxWait: 1000 })
|
||||
onMounted(() => nextTick(render))
|
||||
@@ -117,20 +106,16 @@ onMounted(() => nextTick(render))
|
||||
<n-radio-group v-model:value="tab" size="small">
|
||||
<n-radio-button value="statement">题面</n-radio-button>
|
||||
<n-radio-button value="editor">编辑器</n-radio-button>
|
||||
<n-radio-button value="flowchart" :class="{ dim: flowchartMode === 'none' }">
|
||||
流程图
|
||||
</n-radio-button>
|
||||
<n-radio-button value="flowchart" :class="{ dim: flowchartMode === 'none' }"> 流程图 </n-radio-button>
|
||||
</n-radio-group>
|
||||
<n-button size="small" quaternary title="收起,专心写左边" @click="emit('collapse')">
|
||||
收起 ›
|
||||
</n-button>
|
||||
<n-button size="small" quaternary title="收起,专心写左边" @click="emit('collapse')"> 收起 › </n-button>
|
||||
</div>
|
||||
|
||||
<!-- 题面 -->
|
||||
<div v-show="tab === 'statement'" class="page">
|
||||
<div class="titleRow">
|
||||
<span v-if="displayId" class="pid">{{ displayId }}</span>
|
||||
<h2>{{ title || "(题目还没起名)" }}</h2>
|
||||
<h2>{{ title || '(题目还没起名)' }}</h2>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<n-tag size="small" :bordered="false" :type="getTagColor(difficulty)">
|
||||
@@ -144,13 +129,7 @@ onMounted(() => nextTick(render))
|
||||
<span v-if="requirementLanguages.length > 1 || languages.length > 1" class="langLabel">
|
||||
{{ lang }}
|
||||
</span>
|
||||
<n-tag
|
||||
v-for="(rule, i) in requirementsOf(lang)"
|
||||
:key="`${lang}-${i}`"
|
||||
:type="KIND_TAG_TYPE[rule.kind]"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
<n-tag v-for="(rule, i) in requirementsOf(lang)" :key="`${lang}-${i}`" :type="KIND_TAG_TYPE[rule.kind]" size="small" :bordered="false">
|
||||
{{ rule.text }}
|
||||
</n-tag>
|
||||
</template>
|
||||
@@ -188,7 +167,7 @@ onMounted(() => nextTick(render))
|
||||
</template>
|
||||
</div>
|
||||
<p v-else class="placeholder">
|
||||
{{ bigData ? "在左边加一个例子" : "在左边的测试数据里勾几组「当例子」" }}
|
||||
{{ bigData ? '在左边加一个例子' : '在左边的测试数据里勾几组「当例子」' }}
|
||||
</p>
|
||||
|
||||
<template v-if="flowchartMode === 'show'">
|
||||
@@ -217,40 +196,23 @@ onMounted(() => nextTick(render))
|
||||
</div>
|
||||
<div v-if="(astRules?.[editorLanguage]?.length ?? 0) > 0" class="requirements">
|
||||
<span class="requirementsHead">语法要求</span>
|
||||
<n-tag
|
||||
v-for="(rule, i) in requirementsOf(editorLanguage)"
|
||||
:key="i"
|
||||
:type="KIND_TAG_TYPE[rule.kind]"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
<n-tag v-for="(rule, i) in requirementsOf(editorLanguage)" :key="i" :type="KIND_TAG_TYPE[rule.kind]" size="small" :bordered="false">
|
||||
{{ rule.text }}
|
||||
</n-tag>
|
||||
</div>
|
||||
<p class="muted small">
|
||||
{{
|
||||
editorParts?.template.trim()
|
||||
? "学生打开编辑器时里面已经有这些:"
|
||||
? '学生打开编辑器时里面已经有这些:'
|
||||
: SOURCES[editorLanguage]
|
||||
? `没写 ${editorLanguage} 的预制代码,学生打开是全站默认的:`
|
||||
: `没写 ${editorLanguage} 的预制代码,学生打开是空白的`
|
||||
}}
|
||||
</p>
|
||||
<CodeEditor
|
||||
v-if="editorCode"
|
||||
:value="editorCode"
|
||||
:language="editorLanguage"
|
||||
:font-size="14"
|
||||
height="320px"
|
||||
readonly
|
||||
/>
|
||||
<CodeEditor v-if="editorCode" :value="editorCode" :language="editorLanguage" :font-size="14" height="320px" readonly />
|
||||
<p v-if="editorParts?.prepend.trim() || editorParts?.append.trim()" class="muted small">
|
||||
提交时,学生的代码{{
|
||||
editorParts?.prepend.trim() && editorParts?.append.trim()
|
||||
? "前后"
|
||||
: editorParts?.prepend.trim()
|
||||
? "前面"
|
||||
: "后面"
|
||||
editorParts?.prepend.trim() && editorParts?.append.trim() ? '前后' : editorParts?.prepend.trim() ? '前面' : '后面'
|
||||
}}还会拼上你藏起来的代码。
|
||||
</p>
|
||||
</div>
|
||||
@@ -258,9 +220,7 @@ onMounted(() => nextTick(render))
|
||||
<!-- 流程图:一直挂着,改了图的代码随时知道画不画得出来 -->
|
||||
<div v-show="tab === 'flowchart'" class="page">
|
||||
<p v-if="flowchartMode === 'none'" class="placeholder">这道题不用流程图</p>
|
||||
<p v-else-if="flowchartMode === 'draw'" class="placeholder">
|
||||
学生在题目页右边自己画,交上去按例子和测试数据评
|
||||
</p>
|
||||
<p v-else-if="flowchartMode === 'draw'" class="placeholder">学生在题目页右边自己画,交上去按例子和测试数据评</p>
|
||||
<template v-else>
|
||||
<p class="muted small">学生在题面例子下面看到的是:</p>
|
||||
<div v-if="referenceDoc" class="reference">
|
||||
@@ -304,10 +264,10 @@ onMounted(() => nextTick(render))
|
||||
.page {
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow: auto;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
padding: 18px 22px;
|
||||
background: v-bind("theme.cardColor");
|
||||
background: v-bind('theme.cardColor');
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -326,7 +286,7 @@ onMounted(() => nextTick(render))
|
||||
}
|
||||
|
||||
.pid {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
/* 和题目页的小节标题一个样子(oj/problem/components/ProblemContent.vue) */
|
||||
@@ -336,7 +296,7 @@ onMounted(() => nextTick(render))
|
||||
line-height: 1.4;
|
||||
margin: 6px 0 0;
|
||||
padding-left: 9px;
|
||||
border-left: 3px solid v-bind("theme.primaryColor");
|
||||
border-left: 3px solid v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.io {
|
||||
@@ -363,7 +323,7 @@ onMounted(() => nextTick(render))
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -374,7 +334,7 @@ onMounted(() => nextTick(render))
|
||||
|
||||
.placeholder {
|
||||
margin: 0;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
/* 参考流程图:和学生题面(ProblemContent.vue)同一种浅灰底 */
|
||||
@@ -398,7 +358,7 @@ onMounted(() => nextTick(render))
|
||||
.sampleLabel,
|
||||
.sampleNo {
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.sampleNo {
|
||||
@@ -412,7 +372,7 @@ onMounted(() => nextTick(render))
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(128, 128, 128, 0.08);
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
|
||||
font-family: ui-monospace, 'Cascadia Mono', Consolas, 'Microsoft YaHei', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre;
|
||||
@@ -432,7 +392,7 @@ onMounted(() => nextTick(render))
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.errorColor");
|
||||
color: v-bind('theme.errorColor');
|
||||
background-color: rgba(208, 48, 80, 0.08);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
/**
|
||||
* 「新建题目」先选题型:编程题和 SQL 题是两张不同的出题页,测试数据的格式也不一样,
|
||||
@@ -7,39 +7,33 @@ import { Icon } from "@iconify/vue"
|
||||
* 每项要两行(名字 + 一句话),n-dropdown 的选项是固定行高,放不下,所以自己画
|
||||
*/
|
||||
defineProps<{ label: string; primary?: boolean }>()
|
||||
const emit = defineEmits<{ select: [kind: "code" | "sql"] }>()
|
||||
const emit = defineEmits<{ select: [kind: 'code' | 'sql'] }>()
|
||||
|
||||
const show = ref(false)
|
||||
|
||||
const OPTIONS = [
|
||||
{
|
||||
kind: "code",
|
||||
icon: "ph:code-bold",
|
||||
title: "编程题",
|
||||
note: "C / C++ / Python,有输入输出说明和样例,也能挂流程图、语法要求",
|
||||
kind: 'code',
|
||||
icon: 'ph:code-bold',
|
||||
title: '编程题',
|
||||
note: 'C / C++ / Python,有输入输出说明和样例,也能挂流程图、语法要求',
|
||||
},
|
||||
{
|
||||
kind: "sql",
|
||||
icon: "ph:database-bold",
|
||||
title: "SQL 题",
|
||||
note: "学生写 SQL 查数据库;给几组建表脚本和一条标准答案就行",
|
||||
kind: 'sql',
|
||||
icon: 'ph:database-bold',
|
||||
title: 'SQL 题',
|
||||
note: '学生写 SQL 查数据库;给几组建表脚本和一条标准答案就行',
|
||||
},
|
||||
] as const
|
||||
|
||||
function pick(kind: "code" | "sql") {
|
||||
function pick(kind: 'code' | 'sql') {
|
||||
show.value = false
|
||||
emit("select", kind)
|
||||
emit('select', kind)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-popover
|
||||
v-model:show="show"
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
:show-arrow="false"
|
||||
style="padding: 6px; width: 340px"
|
||||
>
|
||||
<n-popover v-model:show="show" trigger="click" placement="bottom-end" :show-arrow="false" style="padding: 6px; width: 340px">
|
||||
<template #trigger>
|
||||
<n-button :type="primary ? 'primary' : 'default'" icon-placement="right">
|
||||
{{ label }}
|
||||
@@ -47,13 +41,7 @@ function pick(kind: "code" | "sql") {
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="menu">
|
||||
<button
|
||||
v-for="option in OPTIONS"
|
||||
:key="option.kind"
|
||||
type="button"
|
||||
class="option"
|
||||
@click="pick(option.kind)"
|
||||
>
|
||||
<button v-for="option in OPTIONS" :key="option.kind" type="button" class="option" @click="pick(option.kind)">
|
||||
<Icon :icon="option.icon" :width="20" class="icon" :class="option.kind" />
|
||||
<span class="text">
|
||||
<b>{{ option.title }}</b>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
|
||||
/**
|
||||
* 出题页右栏「学生看到的样子」收起来之后剩下的那一条:竖着贴在右边,点一下展开。
|
||||
@@ -27,18 +27,18 @@ const theme = useThemeVars()
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 6px;
|
||||
background: v-bind("theme.cardColor");
|
||||
color: v-bind("theme.textColor2");
|
||||
background: v-bind('theme.cardColor');
|
||||
color: v-bind('theme.textColor2');
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.strip:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
border-color: v-bind('theme.primaryColor');
|
||||
}
|
||||
|
||||
.arrow {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { zonedParts } from "utils/functions"
|
||||
import type { StuckProblemDetail, StuckStudent } from "utils/types"
|
||||
import UserName from "shared/components/UserName.vue"
|
||||
import { classLabel } from "utils/classes"
|
||||
import { resultName, useResultColor } from "oj/statistics/results"
|
||||
import { avgTries, rate } from "./stuckMath"
|
||||
import { zonedParts } from 'utils/functions'
|
||||
import type { StuckProblemDetail, StuckStudent } from 'utils/types'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { classLabel } from 'utils/classes'
|
||||
import { resultName, useResultColor } from 'oj/statistics/results'
|
||||
import { avgTries, rate } from './stuckMath'
|
||||
|
||||
/**
|
||||
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
|
||||
@@ -34,14 +34,12 @@ const summary = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const errorTotal = computed(() =>
|
||||
(props.detail.errors ?? []).reduce((sum, row) => sum + row.count, 0),
|
||||
)
|
||||
const errorTotal = computed(() => (props.detail.errors ?? []).reduce((sum, row) => sum + row.count, 0))
|
||||
|
||||
const byClass = computed(() => {
|
||||
const groups = new Map<string, StuckStudent[]>()
|
||||
for (const row of students.value) {
|
||||
const key = row.className ?? ""
|
||||
const key = row.className ?? ''
|
||||
groups.set(key, [...(groups.get(key) ?? []), row])
|
||||
}
|
||||
return [...groups]
|
||||
@@ -62,11 +60,11 @@ const byClass = computed(() => {
|
||||
})
|
||||
|
||||
const BUCKETS = [
|
||||
{ label: "1 次", min: 1, max: 1 },
|
||||
{ label: "2 次", min: 2, max: 2 },
|
||||
{ label: "3 次", min: 3, max: 3 },
|
||||
{ label: "4–5 次", min: 4, max: 5 },
|
||||
{ label: "6 次以上", min: 6, max: Infinity },
|
||||
{ label: '1 次', min: 1, max: 1 },
|
||||
{ label: '2 次', min: 2, max: 2 },
|
||||
{ label: '3 次', min: 3, max: 3 },
|
||||
{ label: '4–5 次', min: 4, max: 5 },
|
||||
{ label: '6 次以上', min: 6, max: Infinity },
|
||||
]
|
||||
const distribution = computed(() => {
|
||||
const solved = students.value.filter((row) => row.solved)
|
||||
@@ -85,9 +83,7 @@ const mostTries = computed(() =>
|
||||
.slice(0, 6),
|
||||
)
|
||||
|
||||
const stuckStudents = computed(() =>
|
||||
students.value.filter((row) => !row.solved).sort((a, b) => b.tries - a.tries),
|
||||
)
|
||||
const stuckStudents = computed(() => students.value.filter((row) => !row.solved).sort((a, b) => b.tries - a.tries))
|
||||
|
||||
const currentYear = (() => {
|
||||
const now = zonedParts(new Date())!
|
||||
@@ -103,8 +99,8 @@ const history = computed(() =>
|
||||
)
|
||||
|
||||
function gap(minutes: number | null) {
|
||||
if (minutes === null) return ""
|
||||
if (minutes < 1) return "不到 1 分钟"
|
||||
if (minutes === null) return ''
|
||||
if (minutes < 1) return '不到 1 分钟'
|
||||
if (minutes < 60) return `${minutes} 分钟`
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)} 小时`
|
||||
return `隔了 ${Math.round(minutes / 1440)} 天`
|
||||
@@ -119,12 +115,12 @@ const links = computed(() => {
|
||||
if (!row) return null
|
||||
const scope = props.className ? { className: props.className } : {}
|
||||
return {
|
||||
problem: href({ name: "problem", params: { problemID: row.displayId } }),
|
||||
problem: href({ name: 'problem', params: { problemID: row.displayId } }),
|
||||
statistics: href({
|
||||
name: "statistics",
|
||||
name: 'statistics',
|
||||
query: { problem: row.displayId, period: props.period, ...scope },
|
||||
}),
|
||||
submissions: href({ name: "submissions", query: { problem: row.displayId, ...scope } }),
|
||||
submissions: href({ name: 'submissions', query: { problem: row.displayId, ...scope } }),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -156,9 +152,7 @@ const links = computed(() => {
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">还没做对</div>
|
||||
<div class="big num" :class="{ red: summary.stuck }">
|
||||
{{ summary.stuck }}<span class="unit"> 人</span>
|
||||
</div>
|
||||
<div class="big num" :class="{ red: summary.stuck }">{{ summary.stuck }}<span class="unit"> 人</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,11 +196,7 @@ const links = computed(() => {
|
||||
<div class="dist">
|
||||
<div v-for="row in distribution" :key="row.label" class="dcol">
|
||||
<span class="small num strong">{{ row.count }} 人</span>
|
||||
<span
|
||||
class="dbar"
|
||||
:class="{ first: row.min === 1, many: row.min === 6 }"
|
||||
:style="{ height: `${row.height}px` }"
|
||||
/>
|
||||
<span class="dbar" :class="{ first: row.min === 1, many: row.min === 6 }" :style="{ height: `${row.height}px` }" />
|
||||
<span class="muted small">{{ row.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,14 +226,10 @@ const links = computed(() => {
|
||||
</template>
|
||||
|
||||
<div v-if="history.length" class="block">
|
||||
<div class="muted small">历年一次就对{{ className ? "(全站)" : "" }}</div>
|
||||
<div class="muted small">历年一次就对{{ className ? '(全站)' : '' }}</div>
|
||||
<div v-for="row in history" :key="row.label" class="hrow num">
|
||||
<span class="hlabel" :class="{ muted: !row.current }">
|
||||
{{ row.label }}{{ row.current ? " 这学年" : "" }}
|
||||
</span>
|
||||
<span class="track mid" :class="{ past: !row.current }"
|
||||
><span :style="{ width: `${row.rate}%` }"
|
||||
/></span>
|
||||
<span class="hlabel" :class="{ muted: !row.current }"> {{ row.label }}{{ row.current ? ' 这学年' : '' }} </span>
|
||||
<span class="track mid" :class="{ past: !row.current }"><span :style="{ width: `${row.rate}%` }" /></span>
|
||||
<span class="pct" :class="{ strong: row.current }">{{ row.rate }}%</span>
|
||||
<span class="muted small">{{ row.users }} 人</span>
|
||||
</div>
|
||||
@@ -260,17 +246,10 @@ const links = computed(() => {
|
||||
<UserName :username="row.username" class="uname" />
|
||||
<span class="muted num ntries">交了 {{ row.tries }} 次</span>
|
||||
<span class="legend" :style="{ color: resultColor(row.lastResult) }">
|
||||
<i :style="{ background: resultColor(row.lastResult) }" />{{
|
||||
resultName(row.lastResult)
|
||||
}}
|
||||
<i :style="{ background: resultColor(row.lastResult) }" />{{ resultName(row.lastResult) }}
|
||||
</span>
|
||||
<span class="grow" />
|
||||
<a
|
||||
:href="href({ path: `/submission/${row.lastSubmissionId}` })"
|
||||
target="_blank"
|
||||
class="link small"
|
||||
>看代码</a
|
||||
>
|
||||
<a :href="href({ path: `/submission/${row.lastSubmissionId}` })" target="_blank" class="link small">看代码</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import type { AdminProblemRow } from "utils/types"
|
||||
import { batchTagProblems, getProblemList } from "admin/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import { NButton, NTag } from 'naive-ui'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import type { AdminProblemRow } from 'utils/types'
|
||||
import { batchTagProblems, getProblemList } from 'admin/api'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [value: boolean]
|
||||
'update:show': [value: boolean]
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
@@ -25,34 +25,29 @@ const problems = ref<AdminProblemRow[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const keyword = ref("")
|
||||
const keyword = ref('')
|
||||
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
const allColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
{ title: "题号", key: "_id", width: 90 },
|
||||
{ title: '题号', key: '_id', width: 90 },
|
||||
{
|
||||
title: "题目",
|
||||
key: "title",
|
||||
title: '题目',
|
||||
key: 'title',
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(NButton, { text: true, type: "primary", onClick: () => goEdit(row) }, () => row.title),
|
||||
render: (row) => h(NButton, { text: true, type: 'primary', onClick: () => goEdit(row) }, () => row.title),
|
||||
},
|
||||
{
|
||||
title: "学生看得到",
|
||||
key: "visible",
|
||||
title: '学生看得到',
|
||||
key: 'visible',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NTag, { size: "small", bordered: false, type: row.visible ? "success" : "default" }, () =>
|
||||
row.visible ? "看得到" : "藏着",
|
||||
),
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false, type: row.visible ? 'success' : 'default' }, () => (row.visible ? '看得到' : '藏着')),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
render: (row) =>
|
||||
h(NButton, { size: "small", quaternary: true, onClick: () => removeTag(row) }, () => "摘掉"),
|
||||
render: (row) => h(NButton, { size: 'small', quaternary: true, onClick: () => removeTag(row) }, () => '摘掉'),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -69,27 +64,25 @@ const { loading, track } = useListLoader()
|
||||
async function listProblems() {
|
||||
if (page.value < 1) page.value = 1
|
||||
const offset = (page.value - 1) * limit.value
|
||||
const res = await track(
|
||||
getProblemList(offset, limit.value, keyword.value, "", undefined, props.tagId),
|
||||
)
|
||||
const res = await track(getProblemList(offset, limit.value, keyword.value, '', undefined, props.tagId))
|
||||
if (!res) return
|
||||
problems.value = res.results
|
||||
total.value = res.total
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:show", false)
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
function goEdit(row: AdminProblemRow) {
|
||||
close()
|
||||
router.push({ name: "admin problem edit", params: { problemID: row.id } })
|
||||
router.push({ name: 'admin problem edit', params: { problemID: row.id } })
|
||||
}
|
||||
|
||||
async function removeTag(row: AdminProblemRow) {
|
||||
await batchTagProblems([row.id], [props.tagName], "remove")
|
||||
await batchTagProblems([row.id], [props.tagName], 'remove')
|
||||
message.success(`已移除「${row.title}」的标签`)
|
||||
emit("changed")
|
||||
emit('changed')
|
||||
// 移掉本页最后一条时退回上一页,交给下面的 watcher 重新拉取
|
||||
if (problems.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1
|
||||
@@ -107,7 +100,7 @@ watch(
|
||||
(show) => {
|
||||
if (!show) return
|
||||
page.value = 1
|
||||
keyword.value = ""
|
||||
keyword.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
@@ -123,22 +116,11 @@ watchDebounced(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="`标签「${tagName}」下的题目`"
|
||||
style="width: 720px"
|
||||
@close="close"
|
||||
>
|
||||
<n-modal :show="show" preset="card" :title="`标签「${tagName}」下的题目`" style="width: 720px" @close="close">
|
||||
<n-flex vertical size="large">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<span>共 {{ total }} 道题</span>
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
:style="{ width: isMobile ? '160px' : '220px' }"
|
||||
placeholder="题号或题目名"
|
||||
clearable
|
||||
/>
|
||||
<n-input v-model:value="keyword" :style="{ width: isMobile ? '160px' : '220px' }" placeholder="题号或题目名" clearable />
|
||||
</n-flex>
|
||||
<n-data-table :loading="loading" striped :columns="columns" :data="problems" />
|
||||
<Pagination :total="total" v-model:limit="limit" v-model:page="page" />
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import {
|
||||
TEST_CASE_EDIT_MAX_CASES,
|
||||
TEST_CASE_EDIT_MAX_FILE_BYTES,
|
||||
TEST_CASE_MAX_CASES,
|
||||
} from "@oj2/contract"
|
||||
import type { RunnableLanguage } from "@oj2/contract"
|
||||
import { strFromU8, unzipSync } from "fflate"
|
||||
import type { Ref } from "vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import { trialRun } from "utils/judge"
|
||||
import { TEST_CASE_EDIT_MAX_CASES, TEST_CASE_EDIT_MAX_FILE_BYTES, TEST_CASE_MAX_CASES } from '@oj2/contract'
|
||||
import type { RunnableLanguage } from '@oj2/contract'
|
||||
import { strFromU8, unzipSync } from 'fflate'
|
||||
import type { Ref } from 'vue'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { JUDGE_STATUS, SubmissionStatus } from 'utils/constants'
|
||||
import { trialRun } from 'utils/judge'
|
||||
|
||||
/**
|
||||
* 编程题出题页的测试数据(设计稿「编程题出题页」B):一组一行,输出由标准答案在判题机上
|
||||
@@ -37,19 +33,19 @@ export interface AnswerSource {
|
||||
code: string
|
||||
}
|
||||
|
||||
export type CaseStatus = "manual" | "empty" | "running" | "error" | "changed" | "ok"
|
||||
export type CaseStatus = 'manual' | 'empty' | 'running' | 'error' | 'changed' | 'ok'
|
||||
|
||||
let nextKey = 0
|
||||
|
||||
export function newRow(input = "", output = "", example = false, saved: string | null = null) {
|
||||
export function newRow(input = '', output = '', example = false, saved: string | null = null) {
|
||||
return {
|
||||
key: nextKey++,
|
||||
input,
|
||||
output,
|
||||
example,
|
||||
saved,
|
||||
ranFor: "",
|
||||
error: "",
|
||||
ranFor: '',
|
||||
error: '',
|
||||
} satisfies CaseRow
|
||||
}
|
||||
|
||||
@@ -59,32 +55,31 @@ export function newRow(input = "", output = "", example = false, saved: string |
|
||||
*/
|
||||
export function normalizeCase(input: string, output: string) {
|
||||
return {
|
||||
input: input.replace(/\r\n/g, "\n").replace(/\n$/, ""),
|
||||
output: output.replace(/\r\n/g, "\n").trimEnd(),
|
||||
input: input.replace(/\r\n/g, '\n').replace(/\n$/, ''),
|
||||
output: output.replace(/\r\n/g, '\n').trimEnd(),
|
||||
}
|
||||
}
|
||||
|
||||
/** 判题机比对时去掉的是整段输出末尾的空白(见 services/test-case.ts 的 rstrip),这里同一个口径 */
|
||||
export function sameOutput(a: string, b: string) {
|
||||
return a.replace(/\r\n/g, "\n").trimEnd() === b.replace(/\r\n/g, "\n").trimEnd()
|
||||
return a.replace(/\r\n/g, '\n').trimEnd() === b.replace(/\r\n/g, '\n').trimEnd()
|
||||
}
|
||||
|
||||
const signatureOf = (source: AnswerSource, row: CaseRow) =>
|
||||
JSON.stringify([source.language, source.code, row.input])
|
||||
const signatureOf = (source: AnswerSource, row: CaseRow) => JSON.stringify([source.language, source.code, row.input])
|
||||
|
||||
export function caseStatus(row: CaseRow, source: AnswerSource | null): CaseStatus {
|
||||
if (!source) return "manual"
|
||||
if (row.ranFor !== signatureOf(source, row)) return "running"
|
||||
if (row.error) return "error"
|
||||
if (row.saved !== null && !sameOutput(row.saved, row.output)) return "changed"
|
||||
return "ok"
|
||||
if (!source) return 'manual'
|
||||
if (row.ranFor !== signatureOf(source, row)) return 'running'
|
||||
if (row.error) return 'error'
|
||||
if (row.saved !== null && !sameOutput(row.saved, row.output)) return 'changed'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/** 答案报错时给老师看的一句话:状态 + 输出的最后一行(Python 的异常原文就在那) */
|
||||
function describeFailure(result: SubmissionStatus, output: string, line?: number | null) {
|
||||
const name = JUDGE_STATUS[result]?.name ?? "没跑通"
|
||||
const last = output.trimEnd().split("\n").at(-1)?.trim()
|
||||
const where = line ? `(第 ${line} 行)` : ""
|
||||
const name = JUDGE_STATUS[result]?.name ?? '没跑通'
|
||||
const last = output.trimEnd().split('\n').at(-1)?.trim()
|
||||
const where = line ? `(第 ${line} 行)` : ''
|
||||
return last ? `${name}:${last}${where}` : `${name}${where}`
|
||||
}
|
||||
|
||||
@@ -118,26 +113,26 @@ export function useCaseRunner(rows: Ref<CaseRow[]>, source: Ref<AnswerSource | n
|
||||
})
|
||||
batch.forEach((row, i) => {
|
||||
row.ranFor = signatures[i]!
|
||||
if (response.status === "compile-error") {
|
||||
row.output = ""
|
||||
row.error = `标准答案编译不过:${response.message.trim().split("\n").at(-1) ?? ""}`
|
||||
} else if (response.status === "too-much-output") {
|
||||
row.output = ""
|
||||
row.error = "输出太多(多半是死循环在不停打印)"
|
||||
if (response.status === 'compile-error') {
|
||||
row.output = ''
|
||||
row.error = `标准答案编译不过:${response.message.trim().split('\n').at(-1) ?? ''}`
|
||||
} else if (response.status === 'too-much-output') {
|
||||
row.output = ''
|
||||
row.error = '输出太多(多半是死循环在不停打印)'
|
||||
} else {
|
||||
const run = response.cases[i]!
|
||||
if (run.result === SubmissionStatus.accepted) {
|
||||
row.output = run.output.trimEnd()
|
||||
row.error = ""
|
||||
row.error = ''
|
||||
} else {
|
||||
row.output = ""
|
||||
row.output = ''
|
||||
row.error = describeFailure(run.result, run.output, run.runtimeError?.line)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
// 判题机忙、连不上:这一批记成没跑通,别无限重试;老师点「重跑」再来
|
||||
const reason = errorMessage(err, "判题机暂时连不上")
|
||||
const reason = errorMessage(err, '判题机暂时连不上')
|
||||
batch.forEach((row, i) => {
|
||||
row.ranFor = signatures[i]!
|
||||
row.error = reason
|
||||
@@ -156,7 +151,7 @@ export function useCaseRunner(rows: Ref<CaseRow[]>, source: Ref<AnswerSource | n
|
||||
/** 「重跑」:把没跑通的几组作废重来 */
|
||||
function rerunFailed() {
|
||||
rows.value.forEach((row) => {
|
||||
if (row.error) row.ranFor = ""
|
||||
if (row.error) row.ranFor = ''
|
||||
})
|
||||
void runAll()
|
||||
}
|
||||
@@ -168,44 +163,37 @@ export function useCaseRunner(rows: Ref<CaseRow[]>, source: Ref<AnswerSource | n
|
||||
* 导入 zip:在浏览器里拆成一组组。和后端 collectPairs 同一个规矩 —— 从 1.in / 1.out
|
||||
* 开始连续编号,断了就停。太大(见契约 TEST_CASE_EDIT_*)就不拆,交给调用方直接上传。
|
||||
*/
|
||||
export function readCaseZip(
|
||||
archive: Uint8Array,
|
||||
): { ok: true; cases: { input: string; output: string }[] } | { ok: false; reason: string } {
|
||||
export function readCaseZip(archive: Uint8Array): { ok: true; cases: { input: string; output: string }[] } | { ok: false; reason: string } {
|
||||
let files: Record<string, Uint8Array>
|
||||
try {
|
||||
files = unzipSync(archive, { filter: (file) => /^\d+\.(in|out)$/.test(file.name) })
|
||||
} catch {
|
||||
return { ok: false, reason: "压缩包损坏,或者不是 zip" }
|
||||
return { ok: false, reason: '压缩包损坏,或者不是 zip' }
|
||||
}
|
||||
const cases: { input: string; output: string }[] = []
|
||||
for (let i = 1; files[`${i}.in`] && files[`${i}.out`]; i += 1) {
|
||||
const input = files[`${i}.in`]!
|
||||
const output = files[`${i}.out`]!
|
||||
if (
|
||||
input.length > TEST_CASE_EDIT_MAX_FILE_BYTES ||
|
||||
output.length > TEST_CASE_EDIT_MAX_FILE_BYTES
|
||||
)
|
||||
return { ok: false, reason: "too-large" }
|
||||
if (input.length > TEST_CASE_EDIT_MAX_FILE_BYTES || output.length > TEST_CASE_EDIT_MAX_FILE_BYTES) return { ok: false, reason: 'too-large' }
|
||||
cases.push(normalizeCase(strFromU8(input), strFromU8(output)))
|
||||
}
|
||||
if (!cases.length)
|
||||
return { ok: false, reason: "压缩包里没有从 1.in / 1.out 开始连续编号的测试点" }
|
||||
if (!cases.length) return { ok: false, reason: '压缩包里没有从 1.in / 1.out 开始连续编号的测试点' }
|
||||
if (cases.length > TEST_CASE_MAX_CASES)
|
||||
return {
|
||||
ok: false,
|
||||
reason: `测试数据最多 ${TEST_CASE_MAX_CASES} 组,这个压缩包里有 ${cases.length} 组,挑有代表性的留下`,
|
||||
}
|
||||
if (cases.length > TEST_CASE_EDIT_MAX_CASES) return { ok: false, reason: "too-large" }
|
||||
if (cases.length > TEST_CASE_EDIT_MAX_CASES) return { ok: false, reason: 'too-large' }
|
||||
return { ok: true, cases }
|
||||
}
|
||||
|
||||
/** 打包上传前的文件内容:CRLF 统一成 LF,非空输入补结尾换行(C 的 scanf / gets 读最后一行更稳) */
|
||||
export function caseFiles(rows: { input: string; output: string }[]) {
|
||||
const lf = (text: string) => text.replace(/\r\n/g, "\n")
|
||||
const lf = (text: string) => text.replace(/\r\n/g, '\n')
|
||||
return rows.flatMap((row, i) => {
|
||||
const input = lf(row.input)
|
||||
return [
|
||||
{ name: `${i + 1}.in`, content: input && !input.endsWith("\n") ? `${input}\n` : input },
|
||||
{ name: `${i + 1}.in`, content: input && !input.endsWith('\n') ? `${input}\n` : input },
|
||||
{ name: `${i + 1}.out`, content: lf(row.output) },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -14,18 +14,17 @@ export interface TemplateParts {
|
||||
append: string
|
||||
}
|
||||
|
||||
const section = (raw: string, name: string) =>
|
||||
raw.match(new RegExp(`//${name} BEGIN\\n([\\s\\S]+?)//${name} END`))?.[1] ?? ""
|
||||
const section = (raw: string, name: string) => raw.match(new RegExp(`//${name} BEGIN\\n([\\s\\S]+?)//${name} END`))?.[1] ?? ''
|
||||
|
||||
/** 段落末尾那个换行是标记格式要的,不是内容,显示时去掉 */
|
||||
const strip = (text: string) => text.replace(/\n$/, "")
|
||||
const strip = (text: string) => text.replace(/\n$/, '')
|
||||
|
||||
export function parseTemplate(raw: string | undefined): TemplateParts {
|
||||
if (!raw) return { prepend: "", template: "", append: "" }
|
||||
if (!raw) return { prepend: '', template: '', append: '' }
|
||||
return {
|
||||
prepend: strip(section(raw, "PREPEND")),
|
||||
template: strip(section(raw, "TEMPLATE")),
|
||||
append: strip(section(raw, "APPEND")),
|
||||
prepend: strip(section(raw, 'PREPEND')),
|
||||
template: strip(section(raw, 'TEMPLATE')),
|
||||
append: strip(section(raw, 'APPEND')),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +39,7 @@ export function buildTemplate(parts: TemplateParts): string | null {
|
||||
`//PREPEND BEGIN\n${body(parts.prepend)}//PREPEND END`,
|
||||
`//TEMPLATE BEGIN\n${body(parts.template)}//TEMPLATE END`,
|
||||
`//APPEND BEGIN\n${body(parts.append)}//APPEND END`,
|
||||
].join("\n\n")
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
/** 跑标准答案时套上前后两段,和判题时(routes/trial-run.ts、judgeSubmission)一个拼法 */
|
||||
@@ -51,5 +50,5 @@ export function wrapWithTemplate(code: string, parts: TemplateParts) {
|
||||
|
||||
/** 一段内容:非空时统一以一个换行结尾 */
|
||||
function body(text: string) {
|
||||
return text.trim() ? `${text.replace(/\n*$/, "")}\n` : ""
|
||||
return text.trim() ? `${text.replace(/\n*$/, '')}\n` : ''
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { AstCheckResponse } from "@oj2/contract"
|
||||
import type { AstCheckResponse } from '@oj2/contract'
|
||||
|
||||
/**
|
||||
* 出题页里每种语言的语法要求自测结果:每条规则学生看到的那句话 + 标准答案过不过。
|
||||
* 规则本身配得不对(后端 astRulesError)时是一句错误。右边预览和保存前的检查都看它
|
||||
*/
|
||||
export type AstCheckState = Record<string, { rules: AstCheckResponse["rules"] } | { error: string }>
|
||||
export type AstCheckState = Record<string, { rules: AstCheckResponse['rules'] } | { error: string }>
|
||||
|
||||
/** 右栏「学生看到的样子」开着哪一页 */
|
||||
export type PreviewTab = "statement" | "editor" | "flowchart"
|
||||
export type PreviewTab = 'statement' | 'editor' | 'flowchart'
|
||||
|
||||
/** 流程图三选一:不用 / 给学生看参考图(showFlowchart)/ 让学生自己画、按这张图打分(allowFlowchart) */
|
||||
export type FlowchartMode = "none" | "show" | "draw"
|
||||
export type FlowchartMode = 'none' | 'show' | 'draw'
|
||||
@@ -3,5 +3,5 @@
|
||||
* 记在本机:老师想专心写的时候收起来,下次打开还是收着的
|
||||
*/
|
||||
export function usePreviewCollapsed() {
|
||||
return useStorage("oj2:admin-problem-preview-collapsed", false)
|
||||
return useStorage('oj2:admin-problem-preview-collapsed', false)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
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"
|
||||
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)两个出题页共用的零件。两页要填的东西几乎
|
||||
@@ -11,23 +11,21 @@ import { unique } from "utils/functions"
|
||||
*/
|
||||
|
||||
/** 富文本编辑器清空之后留下的是一个空段落,不是空串 */
|
||||
export const isBlankHtml = (html: string) => !html.trim() || html === "<p><br></p>"
|
||||
export const isBlankHtml = (html: string) => !html.trim() || html === '<p><br></p>'
|
||||
|
||||
export const difficultyOptions = (["Low", "Mid", "High"] as const).map((value) => ({
|
||||
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" }
|
||||
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)
|
||||
return errorCode(err) === 'display-id-exists' ? '显示编号重复了,换一个' : errorMessage(err)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,10 +46,7 @@ export const tagTooLongText = (tag: string) => `标签最多 ${PROBLEM_TAG_MAX_L
|
||||
*
|
||||
* `enabled` 在页面数据摆好之前要返回 false,不然加载时那一下会把空表单存进去。
|
||||
*/
|
||||
export function useCreateDraft<T>(
|
||||
key: string,
|
||||
options: { source: WatchSource; enabled: () => boolean; empty: () => boolean; take: () => T },
|
||||
) {
|
||||
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(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SqlDisplay } from "utils/types"
|
||||
import { changedRowCount, tableChanges } from "oj/problem/utils/sqlChanges"
|
||||
import type { SqlDisplay } from 'utils/types'
|
||||
import { changedRowCount, tableChanges } from 'oj/problem/utils/sqlChanges'
|
||||
|
||||
/** 出题页的一组测试数据:一份建表 + 插数据的脚本,和它跑出来的样子 */
|
||||
export interface ScriptGroup {
|
||||
@@ -16,7 +16,7 @@ export interface ScriptGroup {
|
||||
running: string
|
||||
}
|
||||
|
||||
export type GroupStatus = "empty" | "no-answer" | "running" | "ok" | "error"
|
||||
export type GroupStatus = 'empty' | 'no-answer' | 'running' | 'ok' | 'error'
|
||||
|
||||
/**
|
||||
* SQLite 的报错是英文,出题的老师未必看得懂。常见的几种翻成一句中文,
|
||||
@@ -28,17 +28,17 @@ export function explainSqlError(message: string) {
|
||||
[/no such column: (\S+)/, (m) => `没有 ${m[1]} 这一列`],
|
||||
[/table (\S+) already exists/, (m) => `${m[1]} 这张表建了两次`],
|
||||
[/near "([^"]*)": syntax error/, (m) => `语法有错,在「${m[1]}」附近`],
|
||||
[/incomplete input/, () => "语句没写完(少了右括号或分号?)"],
|
||||
[/incomplete input/, () => '语句没写完(少了右括号或分号?)'],
|
||||
[/UNIQUE constraint failed: (\S+)/, (m) => `${m[1]} 有重复的值`],
|
||||
[/(\d+) values for (\d+) columns/, (m) => `一行给了 ${m[1]} 个值,但表有 ${m[2]} 列`],
|
||||
]
|
||||
if (message.includes("标准答案未修改任何表数据")) {
|
||||
return "标准答案在这组数据上一行都没改:第 1 组是题面上给学生看的,里面要有会被改到的行"
|
||||
if (message.includes('标准答案未修改任何表数据')) {
|
||||
return '标准答案在这组数据上一行都没改:第 1 组是题面上给学生看的,里面要有会被改到的行'
|
||||
}
|
||||
for (const [pattern, say] of rules) {
|
||||
const match = message.match(pattern)
|
||||
if (match) {
|
||||
const where = message.startsWith("标准答案") ? "标准答案在这组数据上" : "这组的脚本"
|
||||
const where = message.startsWith('标准答案') ? '标准答案在这组数据上' : '这组的脚本'
|
||||
return `${where}跑不起来:${say(match)}`
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,9 @@ export function explainSqlError(message: string) {
|
||||
|
||||
/** 列表里那一行的小结:几张表各几行 → 期望几行 / 改了几行 */
|
||||
export function groupSummary(display: SqlDisplay) {
|
||||
const rows = display.tables.map((t) => t.total_rows).join(" + ")
|
||||
const rows = display.tables.map((t) => t.total_rows).join(' + ')
|
||||
const expected = display.expected
|
||||
if ("columns" in expected) return `${rows} 行 → 期望 ${expected.total_rows} 行`
|
||||
if ('columns' in expected) return `${rows} 行 → 期望 ${expected.total_rows} 行`
|
||||
const changed = expected.changed_tables.reduce((sum, table) => {
|
||||
if (table.dropped) return sum + 1
|
||||
const changes = tableChanges(
|
||||
|
||||
@@ -7,5 +7,5 @@ export function rate(part: number, whole: number) {
|
||||
|
||||
/** 做对的人平均交了几次(只数做对那次及之前的) */
|
||||
export function avgTries(row: { solved: number; solvedTries: number }) {
|
||||
return row.solved ? (row.solvedTries / row.solved).toFixed(1) : "-"
|
||||
return row.solved ? (row.solvedTries / row.solved).toFixed(1) : '-'
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { isSqlProblem } from "@oj2/contract"
|
||||
import type { AdminProblem } from "utils/types"
|
||||
import { getProblem } from "../api"
|
||||
import ProblemDetail from "./detail.vue"
|
||||
import SqlDetail from "./SqlDetail.vue"
|
||||
import { isSqlProblem } from '@oj2/contract'
|
||||
import type { AdminProblem } from 'utils/types'
|
||||
import { getProblem } from '../api'
|
||||
import ProblemDetail from './detail.vue'
|
||||
import SqlDetail from './SqlDetail.vue'
|
||||
|
||||
/**
|
||||
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
|
||||
@@ -20,25 +20,21 @@ const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const loaded = shallowRef<AdminProblem | null>(null)
|
||||
const kind = ref<"code" | "sql" | null>(null)
|
||||
const kind = ref<'code' | 'sql' | null>(null)
|
||||
|
||||
async function resolve() {
|
||||
kind.value = null
|
||||
loaded.value = null
|
||||
if (!props.problemID) {
|
||||
kind.value = route.query.type === "sql" ? "sql" : "code"
|
||||
kind.value = route.query.type === 'sql' ? 'sql' : 'code'
|
||||
return
|
||||
}
|
||||
try {
|
||||
loaded.value = await getProblem(props.problemID)
|
||||
kind.value = isSqlProblem(loaded.value) ? "sql" : "code"
|
||||
kind.value = isSqlProblem(loaded.value) ? 'sql' : 'code'
|
||||
} catch {
|
||||
message.error("获取题目失败")
|
||||
router.push(
|
||||
props.contestID
|
||||
? { name: "admin contest problem list", params: { contestID: props.contestID } }
|
||||
: { name: "admin problem list" },
|
||||
)
|
||||
message.error('获取题目失败')
|
||||
router.push(props.contestID ? { name: 'admin contest problem list', params: { contestID: props.contestID } } : { name: 'admin problem list' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,18 +42,6 @@ watch(() => [props.problemID, route.query.type], resolve, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SqlDetail
|
||||
v-if="kind === 'sql'"
|
||||
:key="`sql-${problemID ?? 'new'}`"
|
||||
:problem-i-d="problemID"
|
||||
:contest-i-d="contestID"
|
||||
:initial="loaded"
|
||||
/>
|
||||
<ProblemDetail
|
||||
v-else-if="kind === 'code'"
|
||||
:key="`code-${problemID ?? 'new'}`"
|
||||
:problem-i-d="problemID"
|
||||
:contest-i-d="contestID"
|
||||
:initial="loaded"
|
||||
/>
|
||||
<SqlDetail v-if="kind === 'sql'" :key="`sql-${problemID ?? 'new'}`" :problem-i-d="problemID" :contest-i-d="contestID" :initial="loaded" />
|
||||
<ProblemDetail v-else-if="kind === 'code'" :key="`code-${problemID ?? 'new'}`" :problem-i-d="problemID" :contest-i-d="contestID" :initial="loaded" />
|
||||
</template>
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch, useThemeVars } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminProblemRow } from "utils/types"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { batchSetProblemVisible, getProblemList, toggleProblemVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import AddFromLibraryModal from "./components/AddFromLibraryModal.vue"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import AuthorSelect from "admin/components/AuthorSelect.vue"
|
||||
import type { DataTableRowKey } from "naive-ui"
|
||||
import BatchTagModal from "./components/BatchTagModal.vue"
|
||||
import CreateProblemMenu from "./components/CreateProblemMenu.vue"
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useListLoader } from "shared/composables/listLoader"
|
||||
import { NSwitch, useThemeVars } from 'naive-ui'
|
||||
import Pagination from 'shared/components/Pagination.vue'
|
||||
import { usePagination } from 'shared/composables/pagination'
|
||||
import { parseTime } from 'utils/functions'
|
||||
import type { AdminProblemRow } from 'utils/types'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import { batchSetProblemVisible, getProblemList, toggleProblemVisible } from '../api'
|
||||
import Actions from './components/Actions.vue'
|
||||
import AddFromLibraryModal from './components/AddFromLibraryModal.vue'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import AuthorSelect from 'admin/components/AuthorSelect.vue'
|
||||
import type { DataTableRowKey } from 'naive-ui'
|
||||
import BatchTagModal from './components/BatchTagModal.vue'
|
||||
import CreateProblemMenu from './components/CreateProblemMenu.vue'
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import ProblemTypeTag from 'oj/problem/components/ProblemTypeTag.vue'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useListLoader } from 'shared/composables/listLoader'
|
||||
|
||||
/**
|
||||
* 后台题目列表(设计稿「后台题目列表」PlA / PlB)。一行一道题:题号、题目(点了就是编辑)、
|
||||
@@ -39,11 +39,11 @@ const { isMobile } = useBreakpoints()
|
||||
const title = computed(
|
||||
() =>
|
||||
({
|
||||
"admin problem list": "题目列表",
|
||||
"admin contest problem list": "比赛题目列表",
|
||||
'admin problem list': '题目列表',
|
||||
'admin contest problem list': '比赛题目列表',
|
||||
})[route.name as string],
|
||||
)
|
||||
const isContestProblemList = computed(() => route.name === "admin contest problem list")
|
||||
const isContestProblemList = computed(() => route.name === 'admin contest problem list')
|
||||
|
||||
const [show, toggleShow] = useToggle()
|
||||
const { count, inc } = useCounter(0)
|
||||
@@ -51,7 +51,7 @@ const total = ref(0)
|
||||
const problems = ref<AdminProblemRow[]>([])
|
||||
|
||||
const selectedRowKeys = ref<DataTableRowKey[]>([])
|
||||
const batchTagAction = ref<"add" | "remove">("add")
|
||||
const batchTagAction = ref<'add' | 'remove'>('add')
|
||||
const [showBatchTag, toggleBatchTag] = useToggle(false)
|
||||
|
||||
const selectedProblemIds = computed(() => selectedRowKeys.value.map((key) => Number(key)))
|
||||
@@ -62,7 +62,7 @@ function chooseProblems(rowKeys: DataTableRowKey[]) {
|
||||
selectedRowKeys.value = rowKeys
|
||||
}
|
||||
|
||||
function openBatchTag(action: "add" | "remove") {
|
||||
function openBatchTag(action: 'add' | 'remove') {
|
||||
batchTagAction.value = action
|
||||
toggleBatchTag(true)
|
||||
}
|
||||
@@ -73,38 +73,38 @@ function onBatchTagDone() {
|
||||
}
|
||||
|
||||
const nextDisplayID = computed(() => {
|
||||
if (!isContestProblemList.value) return ""
|
||||
if (problems.value.length === 0) return "1"
|
||||
if (!isContestProblemList.value) return ''
|
||||
if (problems.value.length === 0) return '1'
|
||||
const ids = problems.value.map((p) => p._id)
|
||||
if (ids.every((id) => /^\d+$/.test(id))) {
|
||||
return String(Math.max(...ids.map((id) => parseInt(id))) + 1)
|
||||
}
|
||||
return ""
|
||||
return ''
|
||||
})
|
||||
|
||||
interface ProblemQuery {
|
||||
keyword: string
|
||||
author: string
|
||||
kind: "" | "code" | "sql"
|
||||
kind: '' | 'code' | 'sql'
|
||||
/** 1 = 学生看得到的,0 = 藏着的 */
|
||||
visible: "" | "1" | "0"
|
||||
visible: '' | '1' | '0'
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
author: useRouteQuery("author", "").value,
|
||||
kind: useRouteQuery<ProblemQuery["kind"]>("kind", "").value,
|
||||
visible: useRouteQuery<ProblemQuery["visible"]>("visible", "").value,
|
||||
keyword: useRouteQuery('keyword', '').value,
|
||||
author: useRouteQuery('author', '').value,
|
||||
kind: useRouteQuery<ProblemQuery['kind']>('kind', '').value,
|
||||
visible: useRouteQuery<ProblemQuery['visible']>('visible', '').value,
|
||||
})
|
||||
|
||||
const counts = ref<{ all: number; visible: number; hidden: number } | null>(null)
|
||||
// 手机上三个按钮放不下、「藏着的」被切掉:换小号,再不够就横着滑
|
||||
const narrow = useMediaQuery("(max-width: 640px)")
|
||||
const narrow = useMediaQuery('(max-width: 640px)')
|
||||
const visibleTabs = computed(() => [
|
||||
{ value: "" as const, label: "全部", count: counts.value?.all },
|
||||
{ value: "1" as const, label: "学生看得到", count: counts.value?.visible },
|
||||
{ value: "0" as const, label: "藏着的", count: counts.value?.hidden },
|
||||
{ value: '' as const, label: '全部', count: counts.value?.all },
|
||||
{ value: '1' as const, label: '学生看得到', count: counts.value?.visible },
|
||||
{ value: '0' as const, label: '藏着的', count: counts.value?.hidden },
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -112,27 +112,25 @@ const visibleTabs = computed(() => [
|
||||
* 列宽只放得下两个,摆两个再加 +N 的话 +N 会被截掉
|
||||
*/
|
||||
function renderTags(tags: string[]) {
|
||||
if (!tags.length) return h("span", { class: "muted" }, "—")
|
||||
if (!tags.length) return h('span', { class: 'muted' }, '—')
|
||||
const shown = tags.length > 2 ? tags.slice(0, 1) : tags
|
||||
return h("span", { class: "tags", title: tags.join("、") }, [
|
||||
...shown.map((tag) => h("span", { class: "tag", key: tag }, tag)),
|
||||
tags.length > shown.length
|
||||
? h("span", { class: "muted more" }, `+${tags.length - shown.length}`)
|
||||
: null,
|
||||
return h('span', { class: 'tags', title: tags.join('、') }, [
|
||||
...shown.map((tag) => h('span', { class: 'tag', key: tag }, tag)),
|
||||
tags.length > shown.length ? h('span', { class: 'muted more' }, `+${tags.length - shown.length}`) : null,
|
||||
])
|
||||
}
|
||||
|
||||
function renderTitle(row: AdminProblemRow) {
|
||||
const kinds = [
|
||||
row.isSql ? h(ProblemTypeTag, { kind: "sql" }) : null,
|
||||
row.allowFlowchart ? h(ProblemTypeTag, { kind: "flowchart" }) : null,
|
||||
row.hasAstRules ? h(ProblemTypeTag, { kind: "ast" }) : null,
|
||||
row.isSql ? h(ProblemTypeTag, { kind: 'sql' }) : null,
|
||||
row.allowFlowchart ? h(ProblemTypeTag, { kind: 'flowchart' }) : null,
|
||||
row.hasAstRules ? h(ProblemTypeTag, { kind: 'ast' }) : null,
|
||||
]
|
||||
return h("span", { class: "title-cell" }, [
|
||||
return h('span', { class: 'title-cell' }, [
|
||||
h(
|
||||
"a",
|
||||
'a',
|
||||
{
|
||||
class: "title-link",
|
||||
class: 'title-link',
|
||||
href: router.resolve(editRoute(row.id)).href,
|
||||
onClick: (event: MouseEvent) => {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
@@ -148,56 +146,53 @@ function renderTitle(row: AdminProblemRow) {
|
||||
|
||||
function editRoute(problemID: number) {
|
||||
return {
|
||||
name: isContestProblemList.value ? "admin contest problem edit" : "admin problem edit",
|
||||
name: isContestProblemList.value ? 'admin contest problem edit' : 'admin problem edit',
|
||||
params: isContestProblemList.value ? { contestID: props.contestID, problemID } : { problemID },
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
{
|
||||
title: "题号",
|
||||
key: "_id",
|
||||
title: '题号',
|
||||
key: '_id',
|
||||
width: 70,
|
||||
render: (row) => h("span", { class: "did", title: `内部编号 ${row.id}` }, row._id),
|
||||
render: (row) => h('span', { class: 'did', title: `内部编号 ${row.id}` }, row._id),
|
||||
},
|
||||
// 题目定宽:p90 也就 12 个字加一两个类型标签。原来 minWidth 让它吃掉整行剩余空间(用户嫌空太多),
|
||||
// 剩下的宽度给最后的操作列
|
||||
{ title: "题目", key: "title", width: 196, ellipsis: false, render: renderTitle },
|
||||
{ title: "知识点", key: "tags", width: 158, render: (row) => renderTags(row.tags) },
|
||||
{ title: '题目', key: 'title', width: 196, ellipsis: false, render: renderTitle },
|
||||
{ title: '知识点', key: 'tags', width: 158, render: (row) => renderTags(row.tags) },
|
||||
{
|
||||
title: "做对 / 做过",
|
||||
key: "users",
|
||||
title: '做对 / 做过',
|
||||
key: 'users',
|
||||
width: 96,
|
||||
render: (row) =>
|
||||
row.triedUsers
|
||||
? h("span", { class: "num" }, [
|
||||
String(row.solvedUsers),
|
||||
h("span", { class: "muted" }, ` / ${row.triedUsers}`),
|
||||
])
|
||||
: h("span", { class: "muted" }, "没人做过"),
|
||||
? h('span', { class: 'num' }, [String(row.solvedUsers), h('span', { class: 'muted' }, ` / ${row.triedUsers}`)])
|
||||
: h('span', { class: 'muted' }, '没人做过'),
|
||||
},
|
||||
{ title: "出题人", key: "username", width: 86, ellipsis: { tooltip: true } },
|
||||
{ title: '出题人', key: 'username', width: 86, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: "创建",
|
||||
key: "create_time",
|
||||
title: '创建',
|
||||
key: 'create_time',
|
||||
width: 100,
|
||||
render: (row) => h("span", { class: "num muted" }, parseTime(row.createTime, "YYYY-MM-DD")),
|
||||
render: (row) => h('span', { class: 'num muted' }, parseTime(row.createTime, 'YYYY-MM-DD')),
|
||||
},
|
||||
{
|
||||
title: "学生看得到",
|
||||
key: "visible",
|
||||
title: '学生看得到',
|
||||
key: 'visible',
|
||||
width: 96,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: "small",
|
||||
size: 'small',
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row.id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
title: '',
|
||||
key: 'actions',
|
||||
minWidth: 240,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
@@ -215,24 +210,18 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
*/
|
||||
function renderMobileCard(row: AdminProblemRow) {
|
||||
const meta = [
|
||||
row.tags.length
|
||||
? row.tags.slice(0, 2).join("、") + (row.tags.length > 2 ? ` +${row.tags.length - 2}` : "")
|
||||
: null,
|
||||
isContestProblemList.value
|
||||
? null
|
||||
: row.triedUsers
|
||||
? `做对 ${row.solvedUsers} / ${row.triedUsers}`
|
||||
: "没人做过",
|
||||
row.tags.length ? row.tags.slice(0, 2).join('、') + (row.tags.length > 2 ? ` +${row.tags.length - 2}` : '') : null,
|
||||
isContestProblemList.value ? null : row.triedUsers ? `做对 ${row.solvedUsers} / ${row.triedUsers}` : '没人做过',
|
||||
row.username,
|
||||
parseTime(row.createTime, "YYYY-MM-DD"),
|
||||
parseTime(row.createTime, 'YYYY-MM-DD'),
|
||||
].filter((it): it is string => !!it)
|
||||
return h("div", { class: "m-card" }, [
|
||||
h("div", { class: "m-head" }, [h("span", { class: "did" }, row._id), renderTitle(row)]),
|
||||
return h('div', { class: 'm-card' }, [
|
||||
h('div', { class: 'm-head' }, [h('span', { class: 'did' }, row._id), renderTitle(row)]),
|
||||
// 每一项自己不断开(「做对 8 / 14」折成两半看不懂),放不下整项换行
|
||||
h(
|
||||
"div",
|
||||
{ class: "m-meta muted" },
|
||||
meta.map((text, i) => h("span", { class: "m-item", key: i }, i ? ` · ${text}` : text)),
|
||||
'div',
|
||||
{ class: 'm-meta muted' },
|
||||
meta.map((text, i) => h('span', { class: 'm-item', key: i }, i ? ` · ${text}` : text)),
|
||||
),
|
||||
h(Actions, {
|
||||
problemID: row.id,
|
||||
@@ -244,12 +233,12 @@ function renderMobileCard(row: AdminProblemRow) {
|
||||
}
|
||||
|
||||
const mobileColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
{ title: "题目", key: "title", render: renderMobileCard },
|
||||
{ title: '题目', key: 'title', render: renderMobileCard },
|
||||
{
|
||||
title: "看得到",
|
||||
key: "visible",
|
||||
title: '看得到',
|
||||
key: 'visible',
|
||||
width: 72,
|
||||
align: "center",
|
||||
align: 'center',
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
@@ -261,9 +250,7 @@ const mobileColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
|
||||
const columns = computed<DataTableColumn<AdminProblemRow>[]>(() => {
|
||||
const cols = isMobile.value ? mobileColumns : baseColumns
|
||||
return isContestProblemList.value
|
||||
? cols.filter((it) => !("key" in it) || it.key !== "users")
|
||||
: [{ type: "selection" }, ...cols]
|
||||
return isContestProblemList.value ? cols.filter((it) => !('key' in it) || it.key !== 'users') : [{ type: 'selection' }, ...cols]
|
||||
})
|
||||
|
||||
const { loading, track } = useListLoader()
|
||||
@@ -271,16 +258,7 @@ async function listProblems() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await track(
|
||||
getProblemList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.author,
|
||||
props.contestID,
|
||||
undefined,
|
||||
query.kind,
|
||||
props.contestID ? "" : query.visible,
|
||||
),
|
||||
getProblemList(offset, query.limit, query.keyword, query.author, props.contestID, undefined, query.kind, props.contestID ? '' : query.visible),
|
||||
)
|
||||
if (!res) return
|
||||
total.value = res.total
|
||||
@@ -293,7 +271,7 @@ const message = useMessage()
|
||||
async function batchVisible(visible: boolean) {
|
||||
try {
|
||||
const { problemCount } = await batchSetProblemVisible(selectedProblemIds.value, visible)
|
||||
message.success(`${problemCount} 道题${visible ? "学生看得到了" : "藏起来了"}`)
|
||||
message.success(`${problemCount} 道题${visible ? '学生看得到了' : '藏起来了'}`)
|
||||
selectedRowKeys.value = []
|
||||
listProblems()
|
||||
} catch (error) {
|
||||
@@ -321,16 +299,16 @@ async function toggleVisible(problemID: number) {
|
||||
}
|
||||
|
||||
const kindOptions = [
|
||||
{ label: "全部题型", value: "" },
|
||||
{ label: "编程题", value: "code" },
|
||||
{ label: "SQL 题", value: "sql" },
|
||||
{ label: '全部题型', value: '' },
|
||||
{ label: '编程题', value: 'code' },
|
||||
{ label: 'SQL 题', value: 'sql' },
|
||||
]
|
||||
|
||||
function createProblem(kind: string) {
|
||||
router.push({
|
||||
name: isContestProblemList.value ? "admin contest problem create" : "admin problem create",
|
||||
name: isContestProblemList.value ? 'admin contest problem create' : 'admin problem create',
|
||||
params: isContestProblemList.value ? { contestID: props.contestID } : {},
|
||||
query: kind === "sql" ? { type: "sql" } : {},
|
||||
query: kind === 'sql' ? { type: 'sql' } : {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -352,10 +330,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader
|
||||
:title="title ?? '题目列表'"
|
||||
:back="isContestProblemList ? { name: 'admin contest list' } : undefined"
|
||||
>
|
||||
<PageHeader :title="title ?? '题目列表'" :back="isContestProblemList ? { name: 'admin contest list' } : undefined">
|
||||
<template #actions>
|
||||
<template v-if="!isContestProblemList">
|
||||
<CreateProblemMenu label="新建题目" primary @select="createProblem" />
|
||||
@@ -366,20 +341,8 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
</template>
|
||||
</template>
|
||||
<template #filters>
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="题号或题目名"
|
||||
clearable
|
||||
:style="{ width: isMobile ? '100%' : '220px' }"
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
<n-select
|
||||
v-if="!props.contestID"
|
||||
v-model:value="query.kind"
|
||||
:options="kindOptions"
|
||||
aria-label="题型"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<n-input v-model:value="query.keyword" placeholder="题号或题目名" clearable :style="{ width: isMobile ? '100%' : '220px' }" @clear="clearQuery" />
|
||||
<n-select v-if="!props.contestID" v-model:value="query.kind" :options="kindOptions" aria-label="题型" style="width: 120px" />
|
||||
<n-flex v-if="!props.contestID" align="center" :size="8">
|
||||
<n-text depth="3">出题人</n-text>
|
||||
<AuthorSelect v-model:value="query.author" all />
|
||||
@@ -419,18 +382,8 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
@update:checked-row-keys="chooseProblems"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
<AddFromLibraryModal
|
||||
v-model:show="show"
|
||||
:count="count"
|
||||
:next-display-id="nextDisplayID"
|
||||
@change="listProblems"
|
||||
/>
|
||||
<BatchTagModal
|
||||
v-model:show="showBatchTag"
|
||||
:problem-ids="selectedProblemIds"
|
||||
:action="batchTagAction"
|
||||
@done="onBatchTagDone"
|
||||
/>
|
||||
<AddFromLibraryModal v-model:show="show" :count="count" :next-display-id="nextDisplayID" @change="listProblems" />
|
||||
<BatchTagModal v-model:show="showBatchTag" :problem-ids="selectedProblemIds" :action="batchTagAction" @done="onBatchTagDone" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -441,8 +394,8 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid v-bind("theme.successColorSuppl");
|
||||
background: v-bind("theme.hoverColor");
|
||||
border: 1px solid v-bind('theme.successColorSuppl');
|
||||
background: v-bind('theme.hoverColor');
|
||||
white-space: nowrap;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -481,7 +434,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
}
|
||||
|
||||
.problem-table :deep(.title-link) {
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -504,12 +457,12 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 3px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.problem-table :deep(.muted) {
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.problem-table :deep(.more) {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "admin/components/PageHeader.vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import type { AdminTag, TagCategory } from "utils/types"
|
||||
import { deleteTag, getTagAdminList, renameTag, setTagCategory } from "../api"
|
||||
import TagProblemsModal from "./components/TagProblemsModal.vue"
|
||||
import PageHeader from 'admin/components/PageHeader.vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { AdminTag, TagCategory } from 'utils/types'
|
||||
import { deleteTag, getTagAdminList, renameTag, setTagCategory } from '../api'
|
||||
import TagProblemsModal from './components/TagProblemsModal.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const tags = ref<AdminTag[]>([])
|
||||
const keyword = ref("")
|
||||
const keyword = ref('')
|
||||
|
||||
// 搜索在后端做(getTagAdminList 带 keyword),这里只是一份引用,两栏和待归类都从它分
|
||||
const visibleTags = computed(() => tags.value)
|
||||
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingName = ref("")
|
||||
const editingName = ref('')
|
||||
|
||||
const activeTag = ref<AdminTag | null>(null)
|
||||
const [showTagProblems, toggleTagProblems] = useToggle(false)
|
||||
@@ -36,11 +36,11 @@ const columnsOf = computed(() => {
|
||||
const confirmed = visibleTags.value.filter((t) => t.categoryConfirmed)
|
||||
return [
|
||||
{
|
||||
key: "knowledge" as const,
|
||||
title: "知识点",
|
||||
tags: confirmed.filter((t) => t.category === "knowledge"),
|
||||
key: 'knowledge' as const,
|
||||
title: '知识点',
|
||||
tags: confirmed.filter((t) => t.category === 'knowledge'),
|
||||
},
|
||||
{ key: "theme" as const, title: "主题", tags: confirmed.filter((t) => t.category === "theme") },
|
||||
{ key: 'theme' as const, title: '主题', tags: confirmed.filter((t) => t.category === 'theme') },
|
||||
].map((col) => ({
|
||||
...col,
|
||||
tags: [...col.tags].sort((a, b) => b.problemCount - a.problemCount),
|
||||
@@ -61,13 +61,13 @@ function startEdit(tag: AdminTag) {
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
editingName.value = ""
|
||||
editingName.value = ''
|
||||
}
|
||||
|
||||
async function saveTag(tag: AdminTag) {
|
||||
const name = editingName.value.trim()
|
||||
if (!name) {
|
||||
message.error("标签名不能为空")
|
||||
message.error('标签名不能为空')
|
||||
return
|
||||
}
|
||||
if (name === tag.name) {
|
||||
@@ -78,7 +78,7 @@ async function saveTag(tag: AdminTag) {
|
||||
if (res.merged) {
|
||||
message.success(`已合并到「${res.name}」,影响 ${res.affectedCount} 道题`)
|
||||
} else {
|
||||
message.success("已重命名")
|
||||
message.success('已重命名')
|
||||
}
|
||||
cancelEdit()
|
||||
listTags()
|
||||
@@ -88,20 +88,18 @@ async function changeCategory(tag: AdminTag, category: TagCategory) {
|
||||
await setTagCategory(tag.id, category)
|
||||
tag.category = category
|
||||
tag.categoryConfirmed = true
|
||||
message.success(`「${tag.name}」已归到${category === "theme" ? "主题" : "知识点"}`)
|
||||
message.success(`「${tag.name}」已归到${category === 'theme' ? '主题' : '知识点'}`)
|
||||
}
|
||||
|
||||
function confirmDelete(tag: AdminTag) {
|
||||
dialog.warning({
|
||||
title: `删除「${tag.name}」?`,
|
||||
content: tag.problemCount
|
||||
? `${tag.problemCount} 道题会摘掉这个标签,题目本身不动。`
|
||||
: "没有题目挂着它。",
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
content: tag.problemCount ? `${tag.problemCount} 道题会摘掉这个标签,题目本身不动。` : '没有题目挂着它。',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await deleteTag(tag.id)
|
||||
message.success("已删除")
|
||||
message.success('已删除')
|
||||
listTags()
|
||||
},
|
||||
})
|
||||
@@ -139,14 +137,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
<n-empty v-if="!col.tags.length" size="small" description="没有" class="empty" />
|
||||
<div v-for="tag in col.tags" :key="tag.id" class="row">
|
||||
<template v-if="editingId === tag.id">
|
||||
<n-input
|
||||
v-model:value="editingName"
|
||||
size="small"
|
||||
autofocus
|
||||
class="rename"
|
||||
@keyup.enter="saveTag(tag)"
|
||||
@keyup.esc="cancelEdit"
|
||||
/>
|
||||
<n-input v-model:value="editingName" size="small" autofocus class="rename" @keyup.enter="saveTag(tag)" @keyup.esc="cancelEdit" />
|
||||
<n-button size="small" type="primary" @click="saveTag(tag)">保存</n-button>
|
||||
<n-button size="small" @click="cancelEdit">取消</n-button>
|
||||
</template>
|
||||
@@ -154,33 +145,20 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
<button type="button" class="name" :title="tag.name" @click="openTagProblems(tag)">
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
<span class="bar"
|
||||
><i :style="{ width: `${(tag.problemCount / col.max) * 100}%` }"></i
|
||||
></span>
|
||||
<span class="bar"><i :style="{ width: `${(tag.problemCount / col.max) * 100}%` }"></i></span>
|
||||
<span class="count">{{ tag.problemCount }} 道</span>
|
||||
<n-button size="tiny" quaternary @click="startEdit(tag)">改名</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
@click="changeCategory(tag, tag.category === 'theme' ? 'knowledge' : 'theme')"
|
||||
>
|
||||
{{ tag.category === "theme" ? "改成知识点" : "改成主题" }}
|
||||
<n-button size="tiny" quaternary @click="changeCategory(tag, tag.category === 'theme' ? 'knowledge' : 'theme')">
|
||||
{{ tag.category === 'theme' ? '改成知识点' : '改成主题' }}
|
||||
</n-button>
|
||||
<n-button size="tiny" quaternary type="error" @click="confirmDelete(tag)"
|
||||
>删除</n-button
|
||||
>
|
||||
<n-button size="tiny" quaternary type="error" @click="confirmDelete(tag)">删除</n-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<TagProblemsModal
|
||||
v-model:show="showTagProblems"
|
||||
:tag-id="activeTag?.id ?? 0"
|
||||
:tag-name="activeTag?.name ?? ''"
|
||||
@changed="listTags"
|
||||
/>
|
||||
<TagProblemsModal v-model:show="showTagProblems" :tag-id="activeTag?.id ?? 0" :tag-name="activeTag?.name ?? ''" @changed="listTags" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -222,7 +200,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
}
|
||||
|
||||
.list {
|
||||
border: 1px solid v-bind("theme.dividerColor");
|
||||
border: 1px solid v-bind('theme.dividerColor');
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
@@ -233,7 +211,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
gap: 10px;
|
||||
height: 40px;
|
||||
padding: 0 4px 0 8px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
border-bottom: 1px solid v-bind('theme.dividerColor');
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
@@ -242,7 +220,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
/* 斑马底纹,和后台其它列表一样(用户要的) */
|
||||
.row:nth-child(even) {
|
||||
background: v-bind("theme.tableColorStriped");
|
||||
background: v-bind('theme.tableColorStriped');
|
||||
}
|
||||
|
||||
.name {
|
||||
@@ -257,7 +235,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
color: v-bind("theme.primaryColor");
|
||||
color: v-bind('theme.primaryColor');
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -269,7 +247,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
flex: 1 1 auto;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: v-bind("theme.actionColor");
|
||||
background: v-bind('theme.actionColor');
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -277,7 +255,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
display: block;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: v-bind("theme.successColorSuppl");
|
||||
background: v-bind('theme.successColorSuppl');
|
||||
}
|
||||
|
||||
.count {
|
||||
@@ -285,7 +263,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.rename {
|
||||
@@ -294,7 +272,7 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
.muted {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
color: v-bind('theme.textColor3');
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
Reference in new issue
Block a user