feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
56
apps/web/src/admin/problem/Stuck.vue
Normal file
56
apps/web/src/admin/problem/Stuck.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { getStuckProblems } from "admin/api"
|
||||
|
||||
interface StuckProblem {
|
||||
problem_id: string
|
||||
problem_title: string
|
||||
total: number
|
||||
failed: number
|
||||
failed_users: number
|
||||
ac_rate: number
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const data = ref<StuckProblem[]>([])
|
||||
|
||||
const columns: DataTableColumn<StuckProblem>[] = [
|
||||
{ title: "题目 ID", key: "problem_id", width: 100 },
|
||||
{ title: "题目名称", key: "problem_title", minWidth: 200 },
|
||||
{ title: "总提交", key: "total", width: 100, sorter: "default" },
|
||||
{ title: "失败次数", key: "failed", width: 100, sorter: "default" },
|
||||
{
|
||||
title: "卡住学生数",
|
||||
key: "failed_users",
|
||||
width: 120,
|
||||
sorter: "default",
|
||||
defaultSortOrder: "descend",
|
||||
},
|
||||
{
|
||||
title: "AC 率",
|
||||
key: "ac_rate",
|
||||
width: 100,
|
||||
sorter: "default",
|
||||
render: (row) => `${row.ac_rate}%`,
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getStuckProblems()
|
||||
data.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 style="margin-top: 0">学生卡点分析(只分析前40道题目)</h2>
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
striped
|
||||
:pagination="{ pageSize: 20 }"
|
||||
/>
|
||||
</template>
|
||||
202
apps/web/src/admin/problem/TopACTrend.vue
Normal file
202
apps/web/src/admin/problem/TopACTrend.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { Line } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { getTopACTrend } from "admin/api"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
)
|
||||
|
||||
interface YearlyEntry {
|
||||
year: number
|
||||
total: number
|
||||
accepted: number
|
||||
ac_rate: number
|
||||
}
|
||||
|
||||
interface ProblemTrend {
|
||||
problem_id: string
|
||||
problem_title: string
|
||||
yearly: YearlyEntry[]
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const yearOptions = Array.from({ length: currentYear - 2022 + 1 }, (_, i) => ({
|
||||
label: String(2022 + i),
|
||||
value: 2022 + i,
|
||||
}))
|
||||
const minPerYearOptions = [
|
||||
{ label: "50", value: 50 },
|
||||
{ label: "100", value: 100 },
|
||||
{ label: "200", value: 200 },
|
||||
]
|
||||
|
||||
const sinceYear = ref(2023)
|
||||
const untilYear = ref(new Date().getFullYear() - 1)
|
||||
const minPerYear = ref(100)
|
||||
const loading = ref(false)
|
||||
const data = ref<ProblemTrend[]>([])
|
||||
|
||||
const acLabelPlugin = {
|
||||
id: "acLabel",
|
||||
afterDatasetsDraw(chart: any) {
|
||||
const ctx = chart.ctx
|
||||
chart.data.datasets.forEach((_: any, i: number) => {
|
||||
const meta = chart.getDatasetMeta(i)
|
||||
meta.data.forEach((point: any, j: number) => {
|
||||
const value = chart.data.datasets[i].data[j]
|
||||
if (value === null || value === undefined) return
|
||||
ctx.save()
|
||||
ctx.font = "bold 11px sans-serif"
|
||||
ctx.fillStyle = "rgba(99, 179, 237, 1)"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "bottom"
|
||||
ctx.fillText(`${value}%`, point.x, point.y - 6)
|
||||
ctx.restore()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
function getChartData(problem: ProblemTrend) {
|
||||
return {
|
||||
labels: problem.yearly.map((y) => String(y.year)),
|
||||
datasets: [
|
||||
{
|
||||
label: "AC 率",
|
||||
data: problem.yearly.map((y) => y.ac_rate),
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
backgroundColor: "rgba(99, 179, 237, 0.2)",
|
||||
borderColor: "rgba(99, 179, 237, 1)",
|
||||
pointBackgroundColor: "rgba(99, 179, 237, 1)",
|
||||
pointRadius: 4,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function getChartOptions(problem: ProblemTrend) {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: `${problem.problem_id} · ${problem.problem_title}`,
|
||||
font: { size: 14 },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: any) => {
|
||||
const entry = problem.yearly[ctx.dataIndex]
|
||||
return `AC 率: ${entry.ac_rate}% (${entry.accepted}/${entry.total})`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: { callback: (v: any) => `${v}%` },
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: "年份" },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTopACTrend({
|
||||
since_year: sinceYear.value,
|
||||
until_year: untilYear.value,
|
||||
min_per_year: minPerYear.value,
|
||||
})
|
||||
data.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 style="margin-top: 0">年度趋势</h2>
|
||||
<n-space align="center" style="margin-bottom: 16px">
|
||||
<span>年份范围</span>
|
||||
<n-select
|
||||
v-model:value="sinceYear"
|
||||
:options="yearOptions"
|
||||
style="width: 100px"
|
||||
@update:value="fetchData"
|
||||
/>
|
||||
<span>—</span>
|
||||
<n-select
|
||||
v-model:value="untilYear"
|
||||
:options="yearOptions"
|
||||
style="width: 100px"
|
||||
@update:value="fetchData"
|
||||
/>
|
||||
<span>年提交下限</span>
|
||||
<n-select
|
||||
v-model:value="minPerYear"
|
||||
:options="minPerYearOptions"
|
||||
style="width: 90px"
|
||||
@update:value="fetchData"
|
||||
/>
|
||||
<n-tag type="info" size="small">共 {{ data.length }} 题</n-tag>
|
||||
</n-space>
|
||||
<n-spin :show="loading">
|
||||
<div
|
||||
v-if="!loading && data.length === 0"
|
||||
style="text-align: center; padding: 40px"
|
||||
>
|
||||
暂无数据
|
||||
</div>
|
||||
<div v-else class="grid">
|
||||
<div v-for="problem in data" :key="problem.problem_id" class="chart-card">
|
||||
<Line
|
||||
:data="getChartData(problem)"
|
||||
:options="getChartOptions(problem)"
|
||||
:plugins="[acLabelPlugin]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
height: 260px;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
160
apps/web/src/admin/problem/components/Actions.vue
Normal file
160
apps/web/src/admin/problem/components/Actions.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
deleteContestProblem,
|
||||
deleteProblem,
|
||||
makeProblemPublic,
|
||||
} from "admin/api"
|
||||
import download from "utils/download"
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
problemDisplayID: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["updated"])
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const isContestProblem = computed(
|
||||
() => route.name === "admin contest problem list",
|
||||
)
|
||||
|
||||
const showMakePublicModal = ref(false)
|
||||
const newDisplayID = ref("")
|
||||
|
||||
async function handleDeleteProblem() {
|
||||
try {
|
||||
if (route.name === "admin contest problem list") {
|
||||
await deleteContestProblem(props.problemID)
|
||||
} else {
|
||||
await deleteProblem(props.problemID)
|
||||
}
|
||||
message.success("删除成功")
|
||||
emit("updated")
|
||||
} catch (err: any) {
|
||||
if (err.data === "Can't delete the problem as it has submissions") {
|
||||
message.error("这道题有提交之后,就不能被删除")
|
||||
} else {
|
||||
message.error("删除失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloads() {
|
||||
download("test_case?problem_id=" + props.problemID)
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
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") {
|
||||
data = router.resolve({
|
||||
name: "contest problem",
|
||||
params: {
|
||||
contestID: route.params.contestID,
|
||||
problemID: props.problemDisplayID,
|
||||
},
|
||||
})
|
||||
}
|
||||
window.open(data.href, "_blank")
|
||||
}
|
||||
|
||||
function openMakePublicModal() {
|
||||
newDisplayID.value = ""
|
||||
showMakePublicModal.value = true
|
||||
}
|
||||
|
||||
async function handleMakePublic() {
|
||||
if (!newDisplayID.value.trim()) {
|
||||
message.error("请输入新的题目编号")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await makeProblemPublic(props.problemID, newDisplayID.value.trim())
|
||||
message.success("已成功转为公开题目(需要手动设置可见)")
|
||||
showMakePublicModal.value = false
|
||||
emit("updated") // 刷新列表
|
||||
} catch (err: any) {
|
||||
if (err.data === "Duplicate display ID") {
|
||||
message.error("该题目编号已存在,请使用其他编号")
|
||||
} else if (err.data === "Already be a public problem") {
|
||||
message.error("该题目已经是公开题目")
|
||||
} else {
|
||||
message.error("转换失败:" + (err.data || "未知错误"))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" secondary type="primary" @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="small" secondary type="info" @click="goCheck">
|
||||
查看
|
||||
</n-button>
|
||||
<n-tooltip v-if="isContestProblem">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
size="small"
|
||||
secondary
|
||||
type="warning"
|
||||
@click="openMakePublicModal"
|
||||
>
|
||||
公开
|
||||
</n-button>
|
||||
</template>
|
||||
将此竞赛题目转为公开题目
|
||||
</n-tooltip>
|
||||
<n-popconfirm @positive-click="handleDeleteProblem">
|
||||
<template #trigger>
|
||||
<n-button secondary size="small" type="error">删除</n-button>
|
||||
</template>
|
||||
确定删除这道题目吗?相关的提交也会被相应删除哦 😯
|
||||
</n-popconfirm>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button size="small" secondary @click="downloads">下载</n-button>
|
||||
</template>
|
||||
下载测试用例
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<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-form-item>
|
||||
</n-form>
|
||||
<n-alert type="info" title="提示:请输入一个未被使用的题目编号">
|
||||
</n-alert>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="showMakePublicModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleMakePublic">确认</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
47
apps/web/src/admin/problem/components/AddButton.vue
Normal file
47
apps/web/src/admin/problem/components/AddButton.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { addProblemForContest } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
problemID: number
|
||||
contestID: string
|
||||
nextDisplayId?: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["added"])
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const displayID = ref(props.nextDisplayId || "")
|
||||
|
||||
async function addProblem() {
|
||||
if (!displayID.value) return
|
||||
try {
|
||||
await addProblemForContest(
|
||||
props.contestID,
|
||||
props.problemID,
|
||||
displayID.value,
|
||||
)
|
||||
emit("added")
|
||||
} catch (err: any) {
|
||||
if (err.data === "Duplicate display id in this contest") {
|
||||
message.error("显示编号重复了,请重新写一个")
|
||||
} else if (err.data === "Contest has ended") {
|
||||
message.error("这场比赛已经结束了,不能添加题目")
|
||||
} else {
|
||||
message.error(err.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-popconfirm :show-icon="false" @positive-click="addProblem">
|
||||
<template #trigger>
|
||||
<n-button secondary size="small" type="primary">添加</n-button>
|
||||
</template>
|
||||
<n-flex vertical>
|
||||
<span>请输入在这场比赛中的显示编号</span>
|
||||
<n-input autofocus v-model:value="displayID" />
|
||||
</n-flex>
|
||||
</n-popconfirm>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
397
apps/web/src/admin/problem/components/AstRulesEditor.vue
Normal file
397
apps/web/src/admin/problem/components/AstRulesEditor.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<script setup lang="ts">
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
|
||||
interface AstRule {
|
||||
engine: string
|
||||
target?: string
|
||||
label?: string
|
||||
exact?: number
|
||||
min?: number
|
||||
max?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: { [key: string]: AstRule[] } | null
|
||||
languages: LANGUAGE[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: { [key: string]: AstRule[] } | null): void
|
||||
}>()
|
||||
|
||||
const activeTab = ref(props.languages[0] || "Python3")
|
||||
|
||||
const ENGINE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
label: "节点检查",
|
||||
type: "group",
|
||||
key: "node_group",
|
||||
children: [
|
||||
{ label: "必须存在", value: "must_exist_node" },
|
||||
{ label: "不能存在", value: "must_not_exist_node" },
|
||||
{ label: "出现次数", value: "count_node" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "函数调用",
|
||||
type: "group",
|
||||
key: "func_group",
|
||||
children: [
|
||||
{ label: "必须调用函数", value: "must_call_function" },
|
||||
{ label: "不能调用函数", value: "must_not_call_function" },
|
||||
{ label: "函数调用次数", value: "count_function_call" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "方法调用",
|
||||
type: "group",
|
||||
key: "method_group",
|
||||
children: [
|
||||
{ label: "必须调用方法", value: "must_call_method" },
|
||||
{ label: "不能调用方法", value: "must_not_call_method" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "运算符",
|
||||
type: "group",
|
||||
key: "op_group",
|
||||
children: [{ label: "必须使用运算符", value: "must_use_operator" }],
|
||||
},
|
||||
]
|
||||
|
||||
const NODE_TARGET_OPTIONS: SelectOption[] = [
|
||||
{ label: "for 循环", value: "for_loop" },
|
||||
{ label: "while 循环", value: "while_loop" },
|
||||
{ label: "if 条件", value: "if_statement" },
|
||||
{ label: "else 子句", value: "else_clause" },
|
||||
{ label: "函数定义", value: "function_definition" },
|
||||
{ label: "return 语句", value: "return" },
|
||||
{ label: "break 语句", value: "break" },
|
||||
{ label: "continue 语句", value: "continue" },
|
||||
{ label: "列表推导式", value: "list_comprehension" },
|
||||
{ label: "列表", value: "list_literal" },
|
||||
{ label: "字典", value: "dict_literal" },
|
||||
{ label: "集合", value: "set_literal" },
|
||||
{ label: "f-string", value: "f_string" },
|
||||
{ label: "try-except", value: "try_except" },
|
||||
{ label: "类定义", value: "class_definition" },
|
||||
]
|
||||
|
||||
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
|
||||
{ label: "+", value: "+" },
|
||||
{ label: "-", value: "-" },
|
||||
{ label: "*", value: "*" },
|
||||
{ label: "/", value: "/" },
|
||||
{ label: "//", value: "//" },
|
||||
{ label: "%", value: "%" },
|
||||
{ label: "**", value: "**" },
|
||||
{ label: "+=", value: "+=" },
|
||||
{ label: "-=", value: "-=" },
|
||||
{ label: "==", value: "==" },
|
||||
{ label: "!=", value: "!=" },
|
||||
{ label: ">", value: ">" },
|
||||
{ label: ">=", value: ">=" },
|
||||
{ label: "<", value: "<" },
|
||||
{ label: "<=", value: "<=" },
|
||||
{ label: "and / &&", value: "and" },
|
||||
{ label: "or / ||", value: "or" },
|
||||
{ label: "not / !", value: "not" },
|
||||
]
|
||||
|
||||
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)
|
||||
}
|
||||
function isFunctionEngine(engine: string) {
|
||||
return FUNCTION_ENGINES.includes(engine)
|
||||
}
|
||||
function isMethodEngine(engine: string) {
|
||||
return METHOD_ENGINES.includes(engine)
|
||||
}
|
||||
function isOperatorEngine(engine: string) {
|
||||
return OPERATOR_ENGINES.includes(engine)
|
||||
}
|
||||
function isCountEngine(engine: string) {
|
||||
return COUNT_ENGINES.includes(engine)
|
||||
}
|
||||
|
||||
const COUNT_MODE_OPTIONS: SelectOption[] = [
|
||||
{ label: "精确", value: "exact" },
|
||||
{ label: "范围", value: "range" },
|
||||
]
|
||||
|
||||
function getCountMode(rule: AstRule): "exact" | "range" {
|
||||
return rule.exact !== undefined ? "exact" : "range"
|
||||
}
|
||||
|
||||
function updateCountMode(lang: string, index: number, mode: "exact" | "range") {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
if (mode === "exact") {
|
||||
rule.exact = rule.min ?? 1
|
||||
delete rule.min
|
||||
delete rule.max
|
||||
} else {
|
||||
delete rule.exact
|
||||
}
|
||||
rules[index] = rule
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function updateExactCount(lang: string, index: number, v: number | null) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
if (v === null) delete rule.exact
|
||||
else rule.exact = v
|
||||
rules[index] = rule
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function needsTargetDropdown(engine: string) {
|
||||
return isNodeEngine(engine)
|
||||
}
|
||||
function needsTargetInput(engine: string) {
|
||||
return isFunctionEngine(engine) || isMethodEngine(engine)
|
||||
}
|
||||
function needsOperatorDropdown(engine: string) {
|
||||
return isOperatorEngine(engine)
|
||||
}
|
||||
|
||||
function getRulesForLang(lang: string): AstRule[] {
|
||||
if (!props.modelValue) return []
|
||||
return props.modelValue[lang] || []
|
||||
}
|
||||
|
||||
function updateRules(lang: string, rules: AstRule[]) {
|
||||
const current = { ...(props.modelValue || {}) }
|
||||
if (rules.length === 0) {
|
||||
delete current[lang]
|
||||
} else {
|
||||
current[lang] = rules
|
||||
}
|
||||
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
|
||||
}
|
||||
|
||||
function getTargetLabel(engine: string, target: string): string | undefined {
|
||||
if (isNodeEngine(engine))
|
||||
return (NODE_TARGET_OPTIONS.find((o) => o.value === target) as any)?.label
|
||||
if (isOperatorEngine(engine))
|
||||
return (OPERATOR_TARGET_OPTIONS.find((o) => o.value === target) as any)
|
||||
?.label
|
||||
return undefined
|
||||
}
|
||||
|
||||
function addRule(lang: string) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
rules.push({
|
||||
engine: "must_exist_node",
|
||||
target: "for_loop",
|
||||
label: "for 循环",
|
||||
message: "",
|
||||
})
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function removeRule(lang: string, index: number) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
rules.splice(index, 1)
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function updateRule(lang: string, index: number, field: string, value: any) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
|
||||
if (field === "engine") {
|
||||
rule.engine = value
|
||||
if (isNodeEngine(value)) {
|
||||
rule.target = "for_loop"
|
||||
rule.label = "for 循环"
|
||||
} else if (isOperatorEngine(value)) {
|
||||
rule.target = "+"
|
||||
rule.label = "+"
|
||||
} else {
|
||||
rule.target = ""
|
||||
delete rule.label
|
||||
}
|
||||
delete rule.min
|
||||
delete rule.max
|
||||
delete rule.exact
|
||||
} else if (field === "target") {
|
||||
rule.target = value
|
||||
const lbl = getTargetLabel(rule.engine, value)
|
||||
if (lbl) rule.label = lbl
|
||||
else delete rule.label
|
||||
} else if (field === "min") {
|
||||
if (value === null || value === undefined) delete rule.min
|
||||
else rule.min = value
|
||||
} else if (field === "max") {
|
||||
if (value === null || value === undefined) delete rule.max
|
||||
else rule.max = value
|
||||
} else if (field === "message") {
|
||||
rule.message = value
|
||||
}
|
||||
|
||||
rules[index] = rule
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.languages,
|
||||
(langs) => {
|
||||
if (langs.length && !langs.includes(activeTab.value as LANGUAGE)) {
|
||||
activeTab.value = langs[0]
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-collapse>
|
||||
<n-collapse-item title="代码规则检查(选填)" name="ast-rules">
|
||||
<n-tabs v-if="languages.length" type="segment" v-model:value="activeTab">
|
||||
<n-tab-pane
|
||||
v-for="lang in languages"
|
||||
:key="lang"
|
||||
:name="lang"
|
||||
:tab="lang"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<div
|
||||
v-for="(rule, index) in getRulesForLang(lang)"
|
||||
:key="index"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<n-flex align="center" :wrap="false">
|
||||
<n-select
|
||||
:options="ENGINE_OPTIONS"
|
||||
:value="rule.engine"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'engine', v)
|
||||
"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
<n-select
|
||||
v-if="needsTargetDropdown(rule.engine)"
|
||||
:options="NODE_TARGET_OPTIONS"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
filterable
|
||||
/>
|
||||
<n-input
|
||||
v-if="needsTargetInput(rule.engine)"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
placeholder="函数/方法名"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
<n-select
|
||||
v-if="needsOperatorDropdown(rule.engine)"
|
||||
:options="OPERATOR_TARGET_OPTIONS"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
<template v-if="isCountEngine(rule.engine)">
|
||||
<n-select
|
||||
:options="COUNT_MODE_OPTIONS"
|
||||
:value="getCountMode(rule)"
|
||||
@update:value="
|
||||
(v: 'exact' | 'range') => updateCountMode(lang, index, v)
|
||||
"
|
||||
style="width: 80px"
|
||||
size="small"
|
||||
/>
|
||||
<n-input-number
|
||||
v-if="getCountMode(rule) === 'exact'"
|
||||
:value="rule.exact ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateExactCount(lang, index, v)
|
||||
"
|
||||
placeholder="次数"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
:min="1"
|
||||
clearable
|
||||
/>
|
||||
<template v-else>
|
||||
<n-input-number
|
||||
:value="rule.min ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateRule(lang, index, 'min', v)
|
||||
"
|
||||
placeholder="最少"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
:min="0"
|
||||
clearable
|
||||
/>
|
||||
<n-input-number
|
||||
:value="rule.max ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateRule(lang, index, 'max', v)
|
||||
"
|
||||
placeholder="最多"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
:min="0"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<n-input
|
||||
:value="rule.message"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'message', v)
|
||||
"
|
||||
placeholder="错误提示(选填)"
|
||||
style="flex: 1"
|
||||
size="small"
|
||||
/>
|
||||
<n-button
|
||||
size="small"
|
||||
tertiary
|
||||
type="error"
|
||||
@click="removeRule(lang, index)"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
<n-button
|
||||
size="small"
|
||||
tertiary
|
||||
type="primary"
|
||||
@click="addRule(lang)"
|
||||
>
|
||||
添加规则
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<n-empty v-else description="请先选择编程语言" />
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</template>
|
||||
109
apps/web/src/admin/problem/components/BatchTagModal.vue
Normal file
109
apps/web/src/admin/problem/components/BatchTagModal.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTag } from "utils/types"
|
||||
import { batchTagProblems, getTagAdminList } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
problemIds: number[]
|
||||
action: "add" | "remove"
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [value: boolean]
|
||||
done: []
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const tags = ref<AdminTag[]>([])
|
||||
const selected = ref<string[]>([])
|
||||
const newTags = ref<string[]>([])
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
function toggleTag(name: string) {
|
||||
const set = new Set(selected.value)
|
||||
if (set.has(name)) set.delete(name)
|
||||
else set.add(name)
|
||||
selected.value = Array.from(set)
|
||||
}
|
||||
|
||||
async function listTags() {
|
||||
const res = await getTagAdminList()
|
||||
tags.value = res.data
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!names.value.length) {
|
||||
message.error("请先选择标签")
|
||||
return
|
||||
}
|
||||
const res = await batchTagProblems(
|
||||
props.problemIds,
|
||||
names.value,
|
||||
props.action,
|
||||
)
|
||||
const verb = props.action === "add" ? "添加" : "移除"
|
||||
message.success(
|
||||
`已为 ${res.data.problem_count} 道题${verb} ${res.data.tag_count} 个标签`,
|
||||
)
|
||||
close()
|
||||
emit("done")
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
selected.value = []
|
||||
newTags.value = []
|
||||
listTags()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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)"
|
||||
>
|
||||
{{ tag.name }}({{ tag.problem_count }})
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-dynamic-tags v-if="action === 'add'" v-model:value="newTags" />
|
||||
<n-flex justify="end">
|
||||
<n-button @click="close">取消</n-button>
|
||||
<n-button type="primary" @click="submit">确定</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
94
apps/web/src/admin/problem/components/Modal.vue
Normal file
94
apps/web/src/admin/problem/components/Modal.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
import { getProblemList } from "admin/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import type { AdminProblemFiltered } from "utils/types"
|
||||
import AddButton from "./AddButton.vue"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
count: number
|
||||
nextDisplayId?: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:show", value: boolean): void
|
||||
(e: "change"): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: "",
|
||||
})
|
||||
const total = ref(0)
|
||||
const problems = shallowRef<AdminProblemFiltered[]>([])
|
||||
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{ title: "编号", key: "_id", width: 80 },
|
||||
{ title: "标题", key: "title" },
|
||||
{
|
||||
title: "选项",
|
||||
key: "add",
|
||||
render: (row) =>
|
||||
h(AddButton, {
|
||||
problemID: row.id,
|
||||
contestID: route.params.contestID as string,
|
||||
nextDisplayId: props.nextDisplayId,
|
||||
onAdded: () => emit("change"),
|
||||
}),
|
||||
width: 60,
|
||||
},
|
||||
]
|
||||
|
||||
async function getList() {
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemList(offset, query.limit, query.keyword, "", "")
|
||||
total.value = res.total
|
||||
problems.value = res.results
|
||||
}
|
||||
watch(
|
||||
() => props.show,
|
||||
(value) => {
|
||||
if (value) getList()
|
||||
},
|
||||
)
|
||||
watch(() => [query.limit, query.page], getList)
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
() => {
|
||||
query.page = 1
|
||||
getList()
|
||||
},
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<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 striped :columns="columns" :data="problems" />
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
</n-modal>
|
||||
</template>
|
||||
<style scoped>
|
||||
.search {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
345
apps/web/src/admin/problem/components/SQLTestcaseEditor.vue
Normal file
345
apps/web/src/admin/problem/components/SQLTestcaseEditor.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<script setup lang="ts">
|
||||
import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types"
|
||||
import { createZipBlob } from "utils/functions"
|
||||
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
|
||||
import {
|
||||
generateSQLTestcase,
|
||||
getSQLTestcaseScripts,
|
||||
previewSQLTestcase,
|
||||
uploadTestcases,
|
||||
} from "../../api"
|
||||
|
||||
interface ScriptEntry {
|
||||
id: number
|
||||
sql: string
|
||||
display: SQLDisplay | null
|
||||
error: string
|
||||
// 标准答案或题型改过之后,旧预览结果作废,需重新预览才能上传
|
||||
stale: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
answers: { language: LANGUAGE; code: string }[]
|
||||
mode: "query" | "modify"
|
||||
problemId?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
let nextId = 0
|
||||
function blankEntry(): ScriptEntry {
|
||||
return { id: nextId++, sql: "", display: null, error: "", stale: false }
|
||||
}
|
||||
|
||||
const scripts = ref<ScriptEntry[]>([blankEntry(), blankEntry(), blankEntry()])
|
||||
|
||||
const refSQL = computed(
|
||||
() =>
|
||||
props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ??
|
||||
"",
|
||||
)
|
||||
|
||||
const isPreviewing = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
|
||||
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
|
||||
const hasBlankScript = computed(() => scripts.value.some((s) => !s.sql.trim()))
|
||||
|
||||
const filledCount = computed(
|
||||
() => scripts.value.filter((s) => s.sql.trim()).length,
|
||||
)
|
||||
|
||||
const canUpload = computed(() => {
|
||||
const filled = scripts.value.filter((s) => s.sql.trim())
|
||||
return (
|
||||
!isPreviewing.value &&
|
||||
// 至少 2 个数据不同的测试点,防止学生对照题目页的期望结果硬编码
|
||||
filled.length >= 2 &&
|
||||
filled.every((s) => s.display && !s.error && !s.stale)
|
||||
)
|
||||
})
|
||||
|
||||
watch([refSQL, () => props.mode], () => {
|
||||
for (const s of scripts.value) {
|
||||
if (s.display || s.error) s.stale = true
|
||||
}
|
||||
})
|
||||
|
||||
// 编辑已有 SQL 题时回显已上传的脚本;新题或旧格式测试点则保持空白
|
||||
onMounted(async () => {
|
||||
if (!props.problemId) return
|
||||
try {
|
||||
const res = await getSQLTestcaseScripts(props.problemId)
|
||||
if (res.data.length) {
|
||||
scripts.value = res.data.map((f) => ({ ...blankEntry(), sql: f.content }))
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
function add() {
|
||||
scripts.value.push(blankEntry())
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
scripts.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
scripts.value = [blankEntry(), blankEntry(), blankEntry()]
|
||||
}
|
||||
|
||||
function expectedQuery(d: SQLDisplay) {
|
||||
return "columns" in d.expected ? d.expected : null
|
||||
}
|
||||
|
||||
function changedTables(d: SQLDisplay) {
|
||||
return "changed_tables" in d.expected ? d.expected.changed_tables : []
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const blanks = scripts.value.filter((s) => !s.sql.trim())
|
||||
if (!blanks.length) return
|
||||
isGenerating.value = true
|
||||
await Promise.all(
|
||||
blanks.map(async (s) => {
|
||||
try {
|
||||
const res = await generateSQLTestcase({
|
||||
ref_sql: refSQL.value,
|
||||
mode: props.mode,
|
||||
})
|
||||
s.sql = res.data.sql
|
||||
} catch (err) {
|
||||
const data = (err as { data?: unknown })?.data
|
||||
message.error(typeof data === "string" ? data : "AI 生成失败")
|
||||
}
|
||||
}),
|
||||
)
|
||||
isGenerating.value = false
|
||||
await preview()
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
// 丢弃空脚本
|
||||
scripts.value = scripts.value.filter((s) => s.sql.trim())
|
||||
if (!scripts.value.length) {
|
||||
scripts.value = [blankEntry()]
|
||||
return
|
||||
}
|
||||
isPreviewing.value = true
|
||||
await Promise.all(
|
||||
scripts.value.map(async (s) => {
|
||||
s.display = null
|
||||
s.error = ""
|
||||
s.stale = false
|
||||
try {
|
||||
const res = await previewSQLTestcase({
|
||||
init_sql: s.sql,
|
||||
ref_sql: refSQL.value,
|
||||
mode: props.mode,
|
||||
})
|
||||
s.display = res.data
|
||||
} catch (err) {
|
||||
const data = (err as { data?: unknown })?.data
|
||||
s.error = typeof data === "string" ? data : "预览失败"
|
||||
}
|
||||
}),
|
||||
)
|
||||
isPreviewing.value = false
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const data = scripts.value
|
||||
.filter((s) => s.sql.trim())
|
||||
.map((s, i) => ({
|
||||
name: `${i + 1}.sql`,
|
||||
content: s.sql,
|
||||
}))
|
||||
const blob = createZipBlob(data)
|
||||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||||
|
||||
const res = await uploadTestcases(file, { sql: true })
|
||||
const testcases: Testcase[] = res.data.info
|
||||
const baseScore = Math.floor(100 / testcases.length)
|
||||
const remainder = 100 - baseScore * testcases.length
|
||||
testcases.forEach((tc, i) => {
|
||||
tc.score = String(
|
||||
i === testcases.length - 1 ? baseScore + remainder : baseScore,
|
||||
)
|
||||
})
|
||||
|
||||
emit("uploaded", res.data.id, testcases)
|
||||
message.success("上传成功")
|
||||
} catch {
|
||||
message.error("上传失败")
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-alert
|
||||
v-if="!refSQL"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
还没有填写 SQL 标准答案,请先在上方"本题参考答案"中填写,再来编写测试点
|
||||
</n-alert>
|
||||
<n-flex align="center" wrap>
|
||||
<n-button :disabled="isPreviewing || isGenerating" @click="reset">
|
||||
清空
|
||||
</n-button>
|
||||
<n-button :disabled="isPreviewing || isGenerating" @click="add">
|
||||
+1
|
||||
</n-button>
|
||||
<n-tooltip :disabled="!!refSQL && hasBlankScript">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<n-button
|
||||
:loading="isGenerating"
|
||||
:disabled="!refSQL || !hasBlankScript || isPreviewing"
|
||||
@click="generate"
|
||||
>
|
||||
AI 生成
|
||||
</n-button>
|
||||
</span>
|
||||
</template>
|
||||
{{ !refSQL ? "请先填写 SQL 标准答案" : "所有脚本都写好了,无需生成" }}
|
||||
</n-tooltip>
|
||||
<n-tooltip :disabled="!!refSQL && hasAnyScript">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<n-button
|
||||
type="success"
|
||||
:loading="isPreviewing"
|
||||
:disabled="!refSQL || !hasAnyScript || isGenerating"
|
||||
@click="preview"
|
||||
>
|
||||
预览验证
|
||||
</n-button>
|
||||
</span>
|
||||
</template>
|
||||
{{ !refSQL ? "请先填写 SQL 标准答案" : "请先填写数据脚本" }}
|
||||
</n-tooltip>
|
||||
<n-tooltip :disabled="canUpload || isPreviewing">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="isUploading"
|
||||
:disabled="!canUpload || isGenerating"
|
||||
@click="upload"
|
||||
>
|
||||
上传
|
||||
</n-button>
|
||||
</span>
|
||||
</template>
|
||||
{{
|
||||
filledCount < 2
|
||||
? "SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果"
|
||||
: "所有脚本预览验证通过后才能上传"
|
||||
}}
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<div v-for="(s, index) in scripts" :key="s.id" class="scriptBox">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<strong>{{ index + 1 }}.sql</strong>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="scripts.length === 1 || isPreviewing || isGenerating"
|
||||
@click="remove(index)"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-input
|
||||
type="textarea"
|
||||
v-model:value="s.sql"
|
||||
:rows="8"
|
||||
placeholder="-- 本测试点的建表 + 插入数据脚本
|
||||
CREATE TABLE ...;
|
||||
INSERT INTO ...;"
|
||||
:status="
|
||||
s.error ? 'error' : s.display && !s.stale ? 'success' : undefined
|
||||
"
|
||||
/>
|
||||
<n-alert v-if="s.error" type="error" :show-icon="false">
|
||||
{{ s.error }}
|
||||
</n-alert>
|
||||
<template v-if="s.display">
|
||||
<n-alert v-if="s.stale" type="warning" :show-icon="false">
|
||||
标准答案或题型已修改,以下预览已过期,请重新预览
|
||||
</n-alert>
|
||||
<div :class="{ stalePreview: s.stale }">
|
||||
<p class="previewTitle">数据表</p>
|
||||
<div v-for="t in s.display.tables" :key="t.name">
|
||||
<p class="sqlTableName">{{ t.name }}</p>
|
||||
<SQLDataTable
|
||||
:columns="t.columns"
|
||||
:rows="t.rows"
|
||||
:total-rows="t.total_rows"
|
||||
:truncated="t.truncated"
|
||||
/>
|
||||
</div>
|
||||
<p class="previewTitle">期望结果</p>
|
||||
<SQLDataTable
|
||||
v-if="expectedQuery(s.display)"
|
||||
:columns="expectedQuery(s.display)!.columns"
|
||||
:rows="expectedQuery(s.display)!.rows"
|
||||
:total-rows="expectedQuery(s.display)!.total_rows"
|
||||
:truncated="expectedQuery(s.display)!.truncated"
|
||||
/>
|
||||
<div v-for="t in changedTables(s.display)" :key="t.name">
|
||||
<p class="sqlTableName">
|
||||
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name} 表` }}
|
||||
</p>
|
||||
<SQLDataTable
|
||||
v-if="!t.dropped"
|
||||
:columns="t.columns"
|
||||
:rows="t.rows"
|
||||
:total-rows="t.total_rows"
|
||||
:truncated="t.truncated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scriptBox {
|
||||
border: 1px solid var(--n-border-color, rgba(128, 128, 128, 0.2));
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
font-weight: bold;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sqlTableName {
|
||||
font-weight: 500;
|
||||
margin: 4px 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.stalePreview {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
147
apps/web/src/admin/problem/components/TagProblemsModal.vue
Normal file
147
apps/web/src/admin/problem/components/TagProblemsModal.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import type { AdminProblemFiltered } from "utils/types"
|
||||
import { batchTagProblems, getProblemList } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
tagId: number
|
||||
tagName: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [value: boolean]
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const problems = ref<AdminProblemFiltered[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const keyword = ref("")
|
||||
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{ title: "显示编号", key: "_id", width: 100 },
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => goEdit(row) },
|
||||
() => row.title,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", type: row.visible ? "success" : "default" },
|
||||
() => (row.visible ? "公开" : "隐藏"),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", type: "error", onClick: () => removeTag(row) },
|
||||
() => "移除标签",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
async function listProblems() {
|
||||
if (page.value < 1) page.value = 1
|
||||
const offset = (page.value - 1) * limit.value
|
||||
const res = await getProblemList(
|
||||
offset,
|
||||
limit.value,
|
||||
keyword.value,
|
||||
"",
|
||||
undefined,
|
||||
props.tagId,
|
||||
)
|
||||
problems.value = res.results
|
||||
total.value = res.total
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
function goEdit(row: AdminProblemFiltered) {
|
||||
close()
|
||||
router.push({ name: "admin problem edit", params: { problemID: row.id } })
|
||||
}
|
||||
|
||||
async function removeTag(row: AdminProblemFiltered) {
|
||||
await batchTagProblems([row.id], [props.tagName], "remove")
|
||||
message.success(`已移除「${row.title}」的标签`)
|
||||
emit("changed")
|
||||
// 移掉本页最后一条时退回上一页,交给下面的 watcher 重新拉取
|
||||
if (problems.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1
|
||||
} else {
|
||||
listProblems()
|
||||
}
|
||||
}
|
||||
|
||||
// 改搜索词就回到第一页
|
||||
watch(keyword, () => (page.value = 1))
|
||||
|
||||
// 每次打开弹窗重置状态,拉取交给下面的 watcher
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
page.value = 1
|
||||
keyword.value = ""
|
||||
},
|
||||
)
|
||||
|
||||
// 打开 / 翻页 / 改每页条数 / 改搜索词都走这里,防抖把同一批变更合并成一次请求
|
||||
watchDebounced(
|
||||
() => [props.show, props.tagId, page.value, limit.value, keyword.value],
|
||||
() => {
|
||||
if (!props.show) return
|
||||
listProblems()
|
||||
},
|
||||
{ debounce: 300, maxWait: 800 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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: 220px"
|
||||
placeholder="输入标题关键字"
|
||||
clearable
|
||||
/>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="problems" />
|
||||
<Pagination :total="total" v-model:limit="limit" v-model:page="page" />
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
263
apps/web/src/admin/problem/components/TestcaseGenerator.vue
Normal file
263
apps/web/src/admin/problem/components/TestcaseGenerator.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import type { LANGUAGE, Testcase } from "utils/types"
|
||||
import { createZipBlob } from "utils/functions"
|
||||
import { createTestSubmission } from "utils/judge"
|
||||
import { uploadTestcases } from "../../api"
|
||||
|
||||
interface FileEntry {
|
||||
id: number
|
||||
in: string
|
||||
out: string
|
||||
error: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
answers: { language: LANGUAGE; code: string }[]
|
||||
samples?: { input: string; output: string }[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
let nextId = 0
|
||||
|
||||
function makeInitialFiles(): FileEntry[] {
|
||||
const fromSamples = (props.samples ?? []).map((s) => ({
|
||||
id: nextId++,
|
||||
in: s.input,
|
||||
out: s.output,
|
||||
error: false,
|
||||
}))
|
||||
const total = Math.ceil(Math.max(fromSamples.length, 1) / 5) * 5
|
||||
const extra = total - fromSamples.length
|
||||
return [
|
||||
...fromSamples,
|
||||
...Array.from({ length: extra }, () => ({
|
||||
id: nextId++,
|
||||
in: "",
|
||||
out: "",
|
||||
error: false,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
const files = ref<FileEntry[]>(makeInitialFiles())
|
||||
|
||||
const selectedLanguage = ref<LANGUAGE>("Python3")
|
||||
|
||||
// 始终显示所有语言,不管有没有答案代码
|
||||
const availableLanguages = computed(() =>
|
||||
props.answers.map((a) => ({ label: a.language, value: a.language })),
|
||||
)
|
||||
|
||||
const hasAnyAnswerCode = computed(() =>
|
||||
props.answers.some((a) => a.code.trim()),
|
||||
)
|
||||
|
||||
// 当前选中语言是否有答案代码(用于控制"先运行"按钮)
|
||||
const hasAnswerCode = computed(() => {
|
||||
const answer = props.answers.find(
|
||||
(a) => a.language === selectedLanguage.value,
|
||||
)
|
||||
return !!answer?.code.trim()
|
||||
})
|
||||
|
||||
// 当语言列表变化时,确保 selectedLanguage 始终指向一个有效值
|
||||
watch(
|
||||
availableLanguages,
|
||||
(langs) => {
|
||||
if (
|
||||
langs.length &&
|
||||
!langs.find((l) => l.value === selectedLanguage.value)
|
||||
) {
|
||||
selectedLanguage.value = langs[0].value
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const isRunning = ref(false)
|
||||
const isUploading = ref(false)
|
||||
|
||||
const hasAnyInput = computed(() => files.value.some((f) => f.in.trim()))
|
||||
|
||||
const canUpload = computed(
|
||||
() =>
|
||||
!isRunning.value &&
|
||||
hasAnyInput.value &&
|
||||
files.value.filter((f) => f.in.trim()).every((f) => f.out && !f.error),
|
||||
)
|
||||
|
||||
function reset() {
|
||||
files.value = Array.from({ length: 5 }, () => ({
|
||||
id: nextId++,
|
||||
in: "",
|
||||
out: "",
|
||||
error: false,
|
||||
}))
|
||||
}
|
||||
|
||||
function add(n: number) {
|
||||
files.value.push(
|
||||
...Array.from({ length: n }, () => ({
|
||||
id: nextId++,
|
||||
in: "",
|
||||
out: "",
|
||||
error: false,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const answer = props.answers.find(
|
||||
(a) => a.language === selectedLanguage.value,
|
||||
)
|
||||
if (!answer?.code.trim()) return
|
||||
|
||||
// 过滤空行,去重(按输入内容)
|
||||
const seen = new Set<string>()
|
||||
files.value = files.value.filter((f) => {
|
||||
if (!f.in.trim()) return false
|
||||
if (seen.has(f.in)) return false
|
||||
seen.add(f.in)
|
||||
return true
|
||||
})
|
||||
|
||||
// 清空旧输出
|
||||
files.value = files.value.map((f) => ({ ...f, out: "", error: false }))
|
||||
|
||||
isRunning.value = true
|
||||
await Promise.all(
|
||||
files.value.map(async (_, i) => {
|
||||
try {
|
||||
const result = await createTestSubmission(
|
||||
{ language: selectedLanguage.value, value: answer.code },
|
||||
files.value[i].in,
|
||||
)
|
||||
files.value[i] = {
|
||||
...files.value[i],
|
||||
out: result.output,
|
||||
error: result.status !== 3,
|
||||
}
|
||||
} catch {
|
||||
files.value[i] = { ...files.value[i], out: "", error: true }
|
||||
}
|
||||
}),
|
||||
)
|
||||
isRunning.value = false
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const data = files.value
|
||||
.filter((f) => f.in.trim() && f.out && !f.error)
|
||||
.flatMap((f, i) => [
|
||||
{ name: `${i + 1}.in`, content: f.in },
|
||||
{ name: `${i + 1}.out`, content: f.out },
|
||||
])
|
||||
|
||||
const blob = createZipBlob(data)
|
||||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||||
|
||||
const res = await uploadTestcases(file)
|
||||
const testcases: Testcase[] = res.data.info
|
||||
const baseScore = Math.floor(100 / testcases.length)
|
||||
const remainder = 100 - baseScore * testcases.length
|
||||
testcases.forEach((tc, i) => {
|
||||
tc.score = String(
|
||||
i === testcases.length - 1 ? baseScore + remainder : baseScore,
|
||||
)
|
||||
})
|
||||
|
||||
emit("uploaded", res.data.id, testcases)
|
||||
message.success("上传成功")
|
||||
} catch {
|
||||
message.error("上传失败")
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-alert
|
||||
v-if="!hasAnyAnswerCode"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
还没有填写答案代码,请先在上方"本题参考答案"中填写至少一种语言的答案,再来生成测试用例
|
||||
</n-alert>
|
||||
<n-flex align="center" wrap>
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
:options="availableLanguages"
|
||||
v-model:value="selectedLanguage"
|
||||
/>
|
||||
<n-button :disabled="isRunning" @click="reset">清空</n-button>
|
||||
<n-button :disabled="isRunning" @click="add(1)">+1</n-button>
|
||||
<n-button :disabled="isRunning" @click="add(5)">+5</n-button>
|
||||
<n-tooltip :disabled="hasAnswerCode && hasAnyInput">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<n-button
|
||||
type="success"
|
||||
:loading="isRunning"
|
||||
:disabled="!hasAnswerCode || !hasAnyInput"
|
||||
@click="run"
|
||||
>
|
||||
先运行
|
||||
</n-button>
|
||||
</span>
|
||||
</template>
|
||||
{{ !hasAnswerCode ? "请先在题目中填写答案代码" : "请先填写输入" }}
|
||||
</n-tooltip>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="isUploading"
|
||||
:disabled="!canUpload"
|
||||
@click="upload"
|
||||
>
|
||||
上传
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-flex
|
||||
v-for="(file, index) in files"
|
||||
:key="file.id"
|
||||
align="start"
|
||||
style="gap: 8px"
|
||||
>
|
||||
<n-flex vertical style="flex: 1">
|
||||
<span>{{ index + 1 }}.in</span>
|
||||
<n-input type="textarea" v-model:value="file.in" :rows="3" />
|
||||
</n-flex>
|
||||
<n-flex vertical style="flex: 1">
|
||||
<span>{{ index + 1 }}.out</span>
|
||||
<n-input
|
||||
type="textarea"
|
||||
v-model:value="file.out"
|
||||
:rows="3"
|
||||
:status="file.out ? (file.error ? 'error' : 'success') : undefined"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-button
|
||||
:disabled="files.length === 1 || isRunning"
|
||||
style="margin-top: 22px"
|
||||
@click="remove(index)"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</template>
|
||||
920
apps/web/src/admin/problem/detail.vue
Normal file
920
apps/web/src/admin/problem/detail.vue
Normal file
@@ -0,0 +1,920 @@
|
||||
<script setup lang="ts">
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
|
||||
import SQLTestcaseEditor from "./components/SQLTestcaseEditor.vue"
|
||||
import AstRulesEditor from "./components/AstRulesEditor.vue"
|
||||
import {
|
||||
CODE_TEMPLATES,
|
||||
LANGUAGE_SHOW_VALUE,
|
||||
STORAGE_KEY,
|
||||
} from "utils/constants"
|
||||
import download from "utils/download"
|
||||
import { unique } from "utils/functions"
|
||||
import type {
|
||||
BlankProblem,
|
||||
LANGUAGE,
|
||||
SQLConfig,
|
||||
Tag,
|
||||
Testcase,
|
||||
} from "utils/types"
|
||||
import {
|
||||
createContestProblem,
|
||||
createProblem,
|
||||
editContestProblem,
|
||||
editProblem,
|
||||
generateFlowchartFromPythonCode,
|
||||
getProblem,
|
||||
uploadTestcases,
|
||||
} from "../api"
|
||||
|
||||
const CodeEditor = defineAsyncComponent(
|
||||
() => import("shared/components/CodeEditor.vue"),
|
||||
)
|
||||
|
||||
const MermaidEditor = defineAsyncComponent(
|
||||
() => import("shared/components/MermaidEditor.vue"),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
problemID?: string
|
||||
contestID?: string
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const title = computed(
|
||||
() =>
|
||||
({
|
||||
"admin problem create": "新建题目",
|
||||
"admin problem edit": "编辑题目",
|
||||
"admin contest problem create": "新建比赛题目",
|
||||
"admin contest problem edit": "编辑比赛题目",
|
||||
})[route.name as string],
|
||||
)
|
||||
|
||||
const isAIGenerating = ref(false)
|
||||
|
||||
const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
||||
_id: "",
|
||||
title: "",
|
||||
description: "",
|
||||
input_description: "",
|
||||
output_description: "",
|
||||
time_limit: 1000,
|
||||
memory_limit: 64,
|
||||
difficulty: "Low" as "Low" | "Mid" | "High",
|
||||
visible: false,
|
||||
share_submission: false,
|
||||
tags: [],
|
||||
languages: ["Python3", "C"] as LANGUAGE[],
|
||||
template: {} as { [key in LANGUAGE]?: string },
|
||||
samples: [
|
||||
{ input: "", output: "" },
|
||||
{ input: "", output: "" },
|
||||
{ input: "", output: "" },
|
||||
],
|
||||
test_case_id: "",
|
||||
test_case_score: [] as Testcase[],
|
||||
hint: "",
|
||||
source: "",
|
||||
prompt: "",
|
||||
answers: [] as { language: LANGUAGE; code: string }[],
|
||||
contest_id: "",
|
||||
allow_flowchart: false,
|
||||
mermaid_code: "",
|
||||
flowchart_data: {},
|
||||
flowchart_hint: "",
|
||||
show_flowchart: false,
|
||||
ast_rules: null as { [key: string]: any[] } | null,
|
||||
sql_config: null as SQLConfig | null,
|
||||
})
|
||||
|
||||
// 从服务器来的tag列表
|
||||
const tagList = shallowRef<Tag[]>([])
|
||||
const tagListLoaded = ref(false)
|
||||
|
||||
const selectedTags = ref<string[]>([])
|
||||
const newTags = ref<string[]>([])
|
||||
const selectedTagSet = computed(() => new Set(selectedTags.value))
|
||||
let syncingTagInputs = false
|
||||
|
||||
function normalizeTagNames(tags: unknown): string[] {
|
||||
if (!Array.isArray(tags)) return []
|
||||
return unique(
|
||||
tags
|
||||
.map((tag) => (typeof tag === "string" ? tag : tag?.name))
|
||||
.filter((tag): tag is string => !!tag),
|
||||
)
|
||||
}
|
||||
|
||||
function syncProblemTags() {
|
||||
problem.value.tags = unique([...selectedTags.value, ...newTags.value])
|
||||
}
|
||||
|
||||
function syncTagInputsFromProblemTags(tags: unknown = problem.value.tags) {
|
||||
const tagNames = normalizeTagNames(tags)
|
||||
const existingTagNames = new Set(tagList.value.map((tag) => tag.name))
|
||||
|
||||
syncingTagInputs = true
|
||||
if (!tagListLoaded.value) {
|
||||
selectedTags.value = tagNames
|
||||
newTags.value = []
|
||||
} else {
|
||||
selectedTags.value = tagNames.filter((tag) => existingTagNames.has(tag))
|
||||
newTags.value = tagNames.filter((tag) => !existingTagNames.has(tag))
|
||||
}
|
||||
syncingTagInputs = false
|
||||
syncProblemTags()
|
||||
}
|
||||
|
||||
function toggleTag(name: string) {
|
||||
const set = new Set(selectedTags.value)
|
||||
if (set.has(name)) set.delete(name)
|
||||
else set.add(name)
|
||||
selectedTags.value = Array.from(set)
|
||||
}
|
||||
|
||||
function validateNewTags(v: string[]) {
|
||||
const existing = new Set(tagList.value.map((t) => t.name))
|
||||
const blanks: string[] = []
|
||||
for (const tag of unique(v)) {
|
||||
if (existing.has(tag)) {
|
||||
message.error("已经存在标签:" + tag)
|
||||
break
|
||||
}
|
||||
blanks.push(tag)
|
||||
}
|
||||
newTags.value = blanks
|
||||
}
|
||||
|
||||
// 这几个用的少,就不缓存本地了
|
||||
const [needTemplate, toggleNeedTemplate] = useToggle(false)
|
||||
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
|
||||
const currentActiveTemplate = ref<LANGUAGE>("Python3")
|
||||
const currentActiveAnswer = ref<LANGUAGE>("Python3")
|
||||
|
||||
// 给 TextEditor 用
|
||||
const [ready, toggleReady] = useToggle(false)
|
||||
|
||||
// Mermaid 渲染状态
|
||||
const mermaidRenderSuccess = ref(false)
|
||||
|
||||
const difficultyOptions: SelectOption[] = [
|
||||
{ label: "简单", value: "Low" },
|
||||
{ label: "中等", value: "Mid" },
|
||||
{ label: "困难", value: "High" },
|
||||
]
|
||||
|
||||
const languageOptions = [
|
||||
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" },
|
||||
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
|
||||
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
|
||||
{ label: LANGUAGE_SHOW_VALUE["SQL"], value: "SQL" },
|
||||
]
|
||||
|
||||
const isSQLProblem = computed(() => !!problem.value?.languages.includes("SQL"))
|
||||
|
||||
// SQL 题联动:SQL 必须是唯一语言(后端强校验),不需要预制代码,自动初始化 sql_config
|
||||
watch(
|
||||
() => problem.value?.languages,
|
||||
(langs) => {
|
||||
if (!langs) return
|
||||
if (langs.includes("SQL")) {
|
||||
if (langs.length > 1) {
|
||||
problem.value.languages = ["SQL"]
|
||||
return
|
||||
}
|
||||
needTemplate.value = false
|
||||
if (!problem.value.sql_config) {
|
||||
problem.value.sql_config = { mode: "query", order_sensitive: false }
|
||||
}
|
||||
currentActiveAnswer.value = "SQL"
|
||||
// 代码规则检查基于 Python/C 的 AST 解析,对 SQL 没有意义,清空避免脏数据
|
||||
if (problem.value.ast_rules) {
|
||||
problem.value.ast_rules = null
|
||||
}
|
||||
// 流程图依赖 Python 答案生成,对 SQL 没有意义
|
||||
problem.value.allow_flowchart = false
|
||||
problem.value.show_flowchart = false
|
||||
} else if (problem.value.sql_config) {
|
||||
problem.value.sql_config = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function getProblemDetail() {
|
||||
if (!props.problemID) {
|
||||
syncTagInputsFromProblemTags()
|
||||
toggleReady(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { data } = await getProblem(props.problemID)
|
||||
problem.value.id = data.id
|
||||
problem.value._id = data._id
|
||||
problem.value.title = data.title
|
||||
problem.value.description = data.description
|
||||
problem.value.input_description = data.input_description
|
||||
problem.value.output_description = data.output_description
|
||||
problem.value.time_limit = data.time_limit
|
||||
problem.value.memory_limit = data.memory_limit
|
||||
problem.value.memory_limit = data.memory_limit
|
||||
problem.value.difficulty = data.difficulty
|
||||
problem.value.visible = data.visible
|
||||
problem.value.share_submission = data.share_submission
|
||||
problem.value.tags = normalizeTagNames(data.tags)
|
||||
problem.value.languages = data.languages
|
||||
problem.value.template = data.template
|
||||
problem.value.samples = data.samples
|
||||
problem.value.samples = data.samples
|
||||
problem.value.test_case_id = data.test_case_id
|
||||
problem.value.test_case_score = data.test_case_score
|
||||
problem.value.hint = data.hint
|
||||
problem.value.source = data.source
|
||||
problem.value.prompt = data.prompt
|
||||
// 流程图相关字段
|
||||
problem.value.allow_flowchart = data.allow_flowchart
|
||||
problem.value.show_flowchart = data.show_flowchart
|
||||
problem.value.mermaid_code = data.mermaid_code ?? ""
|
||||
problem.value.flowchart_hint = data.flowchart_hint ?? ""
|
||||
problem.value.flowchart_data = data.flowchart_data
|
||||
problem.value.ast_rules = data.ast_rules ?? null
|
||||
problem.value.sql_config = data.sql_config ?? null
|
||||
if (data.answers && data.answers.length) {
|
||||
problem.value.answers = data.answers
|
||||
} else {
|
||||
problem.value.answers = data.languages.map((lang: LANGUAGE) => ({
|
||||
language: lang,
|
||||
code: "",
|
||||
}))
|
||||
}
|
||||
if (problem.value.contest_id) {
|
||||
problem.value.contest_id = problem.value.contest_id
|
||||
}
|
||||
|
||||
// 下面是用来显示的:
|
||||
// 代码模板 和 模板开关
|
||||
problem.value.languages.forEach((lang) => {
|
||||
if (data.template[lang]) {
|
||||
template[lang] = data.template[lang]
|
||||
toggleNeedTemplate(true)
|
||||
}
|
||||
})
|
||||
// 标签
|
||||
syncTagInputsFromProblemTags(problem.value.tags)
|
||||
toggleReady(true)
|
||||
} catch (error) {
|
||||
message.error("获取题目失败")
|
||||
router.push({ name: "admin problem list" })
|
||||
}
|
||||
}
|
||||
|
||||
async function getTagList() {
|
||||
const res = await getProblemTagList()
|
||||
tagList.value = res.data
|
||||
tagListLoaded.value = true
|
||||
syncTagInputsFromProblemTags()
|
||||
}
|
||||
|
||||
function addSample() {
|
||||
problem.value.samples.push({ input: "", output: "" })
|
||||
}
|
||||
|
||||
function removeSample(index: number) {
|
||||
problem.value.samples.splice(index, 1)
|
||||
}
|
||||
|
||||
function resetTemplate(language: LANGUAGE) {
|
||||
template[language] = CODE_TEMPLATES[language]
|
||||
}
|
||||
|
||||
async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
|
||||
try {
|
||||
const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value })
|
||||
// @ts-ignore
|
||||
if (res.error) {
|
||||
message.error("上传测试用例失败")
|
||||
return
|
||||
}
|
||||
const testcases = res.data.info
|
||||
for (let file of testcases) {
|
||||
file.score = (100 / testcases.length).toFixed(0)
|
||||
}
|
||||
problem.value.test_case_score = testcases
|
||||
problem.value.test_case_id = res.data.id
|
||||
} catch (err) {
|
||||
message.error("上传测试用例失败")
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTestcases() {
|
||||
download("test_case?problem_id=" + problem.value.id)
|
||||
}
|
||||
|
||||
// Mermaid 渲染事件处理
|
||||
function onMermaidRenderSuccess() {
|
||||
mermaidRenderSuccess.value = true
|
||||
}
|
||||
|
||||
// 题目是否有漏写的
|
||||
async function validateProblem() {
|
||||
let hasErrors = false
|
||||
// 标题
|
||||
if (!problem.value._id || !problem.value.title) {
|
||||
message.error("编号或标题没有填写")
|
||||
hasErrors = true
|
||||
}
|
||||
// 标签
|
||||
else if (selectedTags.value.length === 0 && newTags.value.length === 0) {
|
||||
message.error("标签没有填写")
|
||||
hasErrors = true
|
||||
}
|
||||
// 题目
|
||||
else if (
|
||||
!problem.value.description ||
|
||||
(!isSQLProblem.value &&
|
||||
(!problem.value.input_description || !problem.value.output_description))
|
||||
) {
|
||||
message.error("题目或输入或输出没有填写")
|
||||
hasErrors = true
|
||||
}
|
||||
// 样例
|
||||
else if (!isSQLProblem.value && problem.value.samples.length == 0) {
|
||||
message.error("样例没有填写")
|
||||
hasErrors = true
|
||||
}
|
||||
// 样例是空的
|
||||
else if (
|
||||
!isSQLProblem.value &&
|
||||
problem.value.samples.some(
|
||||
(sample) => sample.output === "" || sample.input === "",
|
||||
)
|
||||
) {
|
||||
message.error("空样例没有删干净")
|
||||
hasErrors = true
|
||||
}
|
||||
// 测试用例
|
||||
else if (problem.value.test_case_score.length === 0) {
|
||||
message.error("测试用例没有上传")
|
||||
hasErrors = true
|
||||
} else if (problem.value.languages.length === 0) {
|
||||
message.error("编程语言没有选择")
|
||||
hasErrors = true
|
||||
}
|
||||
// SQL 题验证
|
||||
else if (isSQLProblem.value && !problem.value.sql_config?.mode) {
|
||||
message.error("SQL 题需要选择题型(查询题/增删改题)")
|
||||
hasErrors = true
|
||||
} else if (
|
||||
isSQLProblem.value &&
|
||||
!problem.value.answers.find(
|
||||
(ans) => ans.language === "SQL" && ans.code.trim() !== "",
|
||||
)
|
||||
) {
|
||||
message.error("SQL 题必须填写标准答案(判题时用它生成期望结果)")
|
||||
hasErrors = true
|
||||
}
|
||||
// 流程图验证
|
||||
else if (problem.value.show_flowchart || problem.value.allow_flowchart) {
|
||||
if (
|
||||
!problem.value.mermaid_code ||
|
||||
problem.value.mermaid_code.trim() === ""
|
||||
) {
|
||||
message.error("启用了流程图功能,但流程图代码为空")
|
||||
hasErrors = true
|
||||
} else if (!mermaidRenderSuccess.value) {
|
||||
message.error("Mermaid 代码尚未成功渲染,请检查代码语法")
|
||||
hasErrors = true
|
||||
}
|
||||
}
|
||||
// 通过了
|
||||
else {
|
||||
hasErrors = false
|
||||
}
|
||||
return hasErrors
|
||||
}
|
||||
|
||||
function getTemplate() {
|
||||
if (!needTemplate.value) {
|
||||
problem.value.template = {}
|
||||
} else {
|
||||
problem.value.languages.forEach((lang) => {
|
||||
if (CODE_TEMPLATES[lang] !== template[lang]) {
|
||||
problem.value.template[lang] = template[lang]
|
||||
} else {
|
||||
delete problem.value.template[lang]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function filterHint() {
|
||||
// 编辑器会自动添加一段 HTML
|
||||
if (problem.value.hint === "<p><br></p>") {
|
||||
problem.value.hint = ""
|
||||
}
|
||||
}
|
||||
|
||||
function filterAnswers() {
|
||||
problem.value.answers = problem.value.answers.filter(
|
||||
(ans) => ans.code.trim() !== "",
|
||||
)
|
||||
}
|
||||
|
||||
function filterSamplesForSQL() {
|
||||
// SQL 题不展示样例;后端 CreateSampleSerializer 也不接受空字符串样例
|
||||
if (isSQLProblem.value) problem.value.samples = []
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const hasValidationErrors = await validateProblem()
|
||||
if (hasValidationErrors) return
|
||||
filterHint()
|
||||
getTemplate()
|
||||
filterAnswers()
|
||||
filterSamplesForSQL()
|
||||
syncProblemTags()
|
||||
const api = {
|
||||
"admin problem create": createProblem,
|
||||
"admin problem edit": editProblem,
|
||||
"admin contest problem create": createContestProblem,
|
||||
"admin contest problem edit": editContestProblem,
|
||||
}[route.name as string]
|
||||
if (
|
||||
route.name === "admin contest problem create" ||
|
||||
route.name === "admin contest problem edit"
|
||||
) {
|
||||
problem.value.contest_id = props.contestID
|
||||
}
|
||||
try {
|
||||
await api!(problem.value)
|
||||
problem.value = null
|
||||
selectedTags.value = []
|
||||
newTags.value = []
|
||||
if (
|
||||
route.name === "admin problem create" ||
|
||||
route.name === "admin contest problem create"
|
||||
) {
|
||||
message.success("恭喜你 💐 出题成功")
|
||||
}
|
||||
if (
|
||||
route.name === "admin problem create" ||
|
||||
route.name === "admin problem edit"
|
||||
) {
|
||||
router.push({ name: "admin problem list" })
|
||||
} else {
|
||||
router.push({
|
||||
name: "admin contest problem list",
|
||||
params: { contestID: props.contestID },
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.data === "Display ID already exists") {
|
||||
message.error("显示编号重复了,请换一个显示编号")
|
||||
} else {
|
||||
message.error(err.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showClear = computed(
|
||||
() =>
|
||||
route.name === "admin problem create" ||
|
||||
route.name === "admin contest problem create",
|
||||
)
|
||||
|
||||
function clear() {
|
||||
problem.value = null
|
||||
selectedTags.value = []
|
||||
newTags.value = []
|
||||
// 为了给所有状态初始化,刷新页面
|
||||
location.reload()
|
||||
}
|
||||
|
||||
async function generateMermaid() {
|
||||
isAIGenerating.value = true
|
||||
const res = await generateFlowchartFromPythonCode(
|
||||
problem.value.answers.filter((a) => a.language === "Python3")[0].code,
|
||||
)
|
||||
isAIGenerating.value = false
|
||||
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
|
||||
problem.value.mermaid_code = res.data.flowchart
|
||||
}
|
||||
|
||||
const showGeneratorModal = ref(false)
|
||||
|
||||
function handleTestcasesGenerated(
|
||||
testCaseId: string,
|
||||
testCaseScore: Testcase[],
|
||||
) {
|
||||
problem.value.test_case_id = testCaseId
|
||||
problem.value.test_case_score = testCaseScore
|
||||
showGeneratorModal.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTagList()
|
||||
getProblemDetail()
|
||||
})
|
||||
|
||||
watch([selectedTags, newTags], ([sel, newT]) => {
|
||||
if (syncingTagInputs) return
|
||||
problem.value.tags = unique([...sel, ...newT])
|
||||
})
|
||||
watch(
|
||||
() => problem.value.languages,
|
||||
(langs) => {
|
||||
const answers = langs.map((lang) => {
|
||||
const existing = problem.value.answers.find(
|
||||
(ans) => ans.language === lang,
|
||||
)
|
||||
return existing || { language: lang, code: "" }
|
||||
})
|
||||
problem.value.answers = answers
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex>
|
||||
<h2 class="title">{{ title }}</h2>
|
||||
<n-button v-if="showClear" @click="clear">清空缓存</n-button>
|
||||
</n-flex>
|
||||
<n-form inline label-placement="left">
|
||||
<n-form-item label="显示编号">
|
||||
<n-input class="w-100" v-model:value="problem._id" />
|
||||
</n-form-item>
|
||||
<n-form-item label="题目">
|
||||
<n-input class="problemTitleInput" v-model:value="problem.title" />
|
||||
</n-form-item>
|
||||
<n-form-item label="难度">
|
||||
<n-select
|
||||
class="w-100"
|
||||
:options="difficultyOptions"
|
||||
v-model:value="problem.difficulty"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="可见">
|
||||
<n-switch v-model:value="problem.visible" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form label-placement="left" :show-feedback="false">
|
||||
<n-form-item label="标签">
|
||||
<n-flex vertical style="width: 100%">
|
||||
<n-flex size="small" style="flex-wrap: wrap">
|
||||
<n-tag
|
||||
v-for="tag in tagList"
|
||||
:key="tag.id"
|
||||
checkable
|
||||
:checked="selectedTagSet.has(tag.name)"
|
||||
@update:checked="toggleTag(tag.name)"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-dynamic-tags
|
||||
v-model:value="newTags"
|
||||
@update:value="validateNewTags"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<TextEditor
|
||||
v-if="ready"
|
||||
v-model:value="problem.description"
|
||||
title="题目的描述"
|
||||
:min-height="300"
|
||||
/>
|
||||
<TextEditor
|
||||
v-if="ready && !isSQLProblem"
|
||||
v-model:value="problem.input_description"
|
||||
title="输入的描述"
|
||||
/>
|
||||
<TextEditor
|
||||
v-if="ready && !isSQLProblem"
|
||||
v-model:value="problem.output_description"
|
||||
title="输出的描述"
|
||||
/>
|
||||
<template v-if="!isSQLProblem">
|
||||
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<strong>测试样例 {{ index + 1 }}</strong>
|
||||
<n-button
|
||||
tertiary
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="removeSample(index)"
|
||||
>
|
||||
删除 {{ index + 1 }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-grid x-gap="20" cols="2">
|
||||
<n-gi span="1">
|
||||
<n-flex vertical>
|
||||
<span>输入样例</span>
|
||||
<n-input type="textarea" v-model:value="sample.input" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi span="1">
|
||||
<n-flex vertical>
|
||||
<span>输出样例</span>
|
||||
<n-input type="textarea" v-model:value="sample.output" />
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</div>
|
||||
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
|
||||
添加用例
|
||||
</n-button>
|
||||
</template>
|
||||
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
|
||||
<n-form>
|
||||
<n-form-item label="题目的来源(选填)">
|
||||
<n-input
|
||||
v-model:value="problem.source"
|
||||
placeholder="比如来自某道题的改编等,或者网上的资料"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="本题的考察知识点(选填,用于 AI 分析)">
|
||||
<n-input
|
||||
v-model:value="problem.prompt"
|
||||
placeholder="比如考察选择、循环、算法等知识点"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<h2 class="title">代码区域</h2>
|
||||
|
||||
<n-form inline label-placement="left">
|
||||
<n-form-item label="编程语言">
|
||||
<n-checkbox-group v-model:value="problem.languages">
|
||||
<n-flex align="center">
|
||||
<n-checkbox
|
||||
v-for="(language, index) in languageOptions"
|
||||
:key="index"
|
||||
:value="language.value"
|
||||
:label="language.label"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="!isSQLProblem">
|
||||
<n-checkbox
|
||||
v-model:checked="needTemplate"
|
||||
label="预制代码(显示在编辑器中,帮助快速上手)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button
|
||||
v-if="needTemplate"
|
||||
size="small"
|
||||
tertiary
|
||||
type="warning"
|
||||
@click="resetTemplate(currentActiveTemplate)"
|
||||
>
|
||||
重置 {{ LANGUAGE_SHOW_VALUE[currentActiveTemplate] }} 的预制代码
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-form
|
||||
v-if="isSQLProblem && problem.sql_config"
|
||||
inline
|
||||
label-placement="left"
|
||||
>
|
||||
<n-form-item label="SQL 题型">
|
||||
<n-radio-group v-model:value="problem.sql_config.mode">
|
||||
<n-radio-button value="query">查询题(比对查询结果)</n-radio-button>
|
||||
<n-radio-button value="modify">
|
||||
增删改题(比对执行后的表数据)
|
||||
</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="严格比对行顺序">
|
||||
<n-switch v-model:value="problem.sql_config.order_sensitive" />
|
||||
<n-text depth="3" style="margin-left: 12px">
|
||||
题目要求 ORDER BY 时开启;关闭则按无序集合比对
|
||||
</n-text>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-grid :cols="2" x-gap="20">
|
||||
<n-gi>
|
||||
<n-form>
|
||||
<n-form-item
|
||||
:label="
|
||||
isSQLProblem
|
||||
? '标准答案(必填,判题依据:每个测试点会运行它生成期望结果)'
|
||||
: '本题参考答案(选填,用于 AI 分析,不会泄露)'
|
||||
"
|
||||
>
|
||||
<n-tabs
|
||||
type="segment"
|
||||
default-value="Python3"
|
||||
v-model:value="currentActiveAnswer"
|
||||
>
|
||||
<n-tab-pane
|
||||
v-for="(answer, index) in problem.answers"
|
||||
:key="index"
|
||||
:name="answer.language"
|
||||
>
|
||||
<CodeEditor
|
||||
v-model:value="answer.code"
|
||||
:language="answer.language"
|
||||
:font-size="16"
|
||||
height="300px"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form v-if="needTemplate">
|
||||
<n-form-item label="编写预制代码">
|
||||
<n-tabs
|
||||
type="segment"
|
||||
default-value="Python3"
|
||||
v-model:value="currentActiveTemplate"
|
||||
>
|
||||
<n-tab-pane
|
||||
v-for="(lang, index) in problem.languages"
|
||||
:key="index"
|
||||
:name="lang"
|
||||
>
|
||||
<CodeEditor
|
||||
v-model:value="template[lang]"
|
||||
:language="lang"
|
||||
:font-size="16"
|
||||
height="300px"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-grid v-if="!isSQLProblem" :cols="2">
|
||||
<n-gi :span="1">
|
||||
<AstRulesEditor
|
||||
v-model="problem.ast_rules!"
|
||||
:languages="problem.languages"
|
||||
/>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<h2 class="title">测试用例区域</h2>
|
||||
|
||||
<n-flex v-if="!isSQLProblem" align="center" style="margin-bottom: 12px">
|
||||
<div>
|
||||
<n-button type="success" @click="showGeneratorModal = true">
|
||||
(新)直接生成
|
||||
</n-button>
|
||||
</div>
|
||||
<div>
|
||||
<n-upload
|
||||
:show-file-list="false"
|
||||
accept=".zip"
|
||||
:custom-request="handleUploadTestcases"
|
||||
>
|
||||
<n-button type="info">(老)手动上传</n-button>
|
||||
</n-upload>
|
||||
</div>
|
||||
<n-tooltip placement="right" style="max-width: 320px; white-space: normal">
|
||||
<template #trigger>
|
||||
<n-button text>温馨提醒</n-button>
|
||||
</template>
|
||||
【测试用例】最好要有10个,要考虑边界情况,且不要跟【测试样例】一模一样
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<SQLTestcaseEditor
|
||||
v-if="isSQLProblem"
|
||||
:answers="problem.answers"
|
||||
:mode="problem.sql_config?.mode ?? 'query'"
|
||||
:problem-id="problem.id"
|
||||
@uploaded="handleTestcasesGenerated"
|
||||
/>
|
||||
|
||||
<n-alert
|
||||
class="box"
|
||||
v-if="problem.test_case_score.length"
|
||||
:show-icon="false"
|
||||
type="info"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
|
||||
{{ problem.test_case_score.length }}
|
||||
条测试用例
|
||||
</div>
|
||||
<n-button
|
||||
v-if="problem.id"
|
||||
tertiary
|
||||
type="info"
|
||||
size="small"
|
||||
@click="downloadTestcases"
|
||||
>
|
||||
下载
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showGeneratorModal"
|
||||
preset="card"
|
||||
title="测试用例生成器"
|
||||
style="width: 80vw; max-width: 900px"
|
||||
:mask-closable="false"
|
||||
display-directive="show"
|
||||
>
|
||||
<TestcaseGenerator
|
||||
:answers="problem.answers"
|
||||
:samples="problem.samples"
|
||||
@uploaded="handleTestcasesGenerated"
|
||||
/>
|
||||
</n-modal>
|
||||
|
||||
<template v-if="!isSQLProblem">
|
||||
<n-divider />
|
||||
|
||||
<h2 class="title">流程图区域</h2>
|
||||
|
||||
<!-- 流程图相关设置 -->
|
||||
<n-form inline label-placement="left" :show-feedback="false">
|
||||
<n-form-item label="根据上面的【Python答案】智能生成 Mermaid 代码">
|
||||
<n-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="
|
||||
!problem.answers.filter((a) => a.language === 'Python3')[0]?.code
|
||||
.length
|
||||
"
|
||||
:loading="isAIGenerating"
|
||||
@click="generateMermaid"
|
||||
>
|
||||
AI 生成
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item label="允许提交流程图">
|
||||
<n-switch v-model:value="problem.allow_flowchart" />
|
||||
</n-form-item>
|
||||
<n-form-item label="显示标准流程图">
|
||||
<n-switch v-model:value="problem.show_flowchart" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-form>
|
||||
<n-form-item>
|
||||
<MermaidEditor
|
||||
v-model="problem.mermaid_code"
|
||||
@render-success="onMermaidRenderSuccess"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="流程图提示信息(选填)">
|
||||
<n-input
|
||||
v-model:value="problem.flowchart_hint"
|
||||
placeholder="请输入流程图相关的提示信息,帮助学生理解题目要求"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</template>
|
||||
<n-flex style="margin: 16px 0 120px" align="center" justify="end">
|
||||
<n-button type="primary" @click="submit">提交</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.box {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.w-100 {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.problemTitleInput {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.addSamples {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
335
apps/web/src/admin/problem/list.vue
Normal file
335
apps/web/src/admin/problem/list.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<script setup lang="ts">
|
||||
import { NFlex, NSwitch, NTag, NTooltip } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { getTagColor, parseTime } from "utils/functions"
|
||||
import type { AdminProblemFiltered } from "utils/types"
|
||||
import { DIFFICULTY, REACTIONS } from "utils/constants"
|
||||
import { getProblemList, toggleProblemVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Modal from "./components/Modal.vue"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||
import type { DataTableRowKey } from "naive-ui"
|
||||
import BatchTagModal from "./components/BatchTagModal.vue"
|
||||
|
||||
interface Props {
|
||||
contestID?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const title = computed(
|
||||
() =>
|
||||
({
|
||||
"admin problem list": "题目列表",
|
||||
"admin contest problem list": "比赛题目列表",
|
||||
})[route.name as string],
|
||||
)
|
||||
const isContestProblemList = computed(
|
||||
() => route.name === "admin contest problem list",
|
||||
)
|
||||
|
||||
const [show, toggleShow] = useToggle()
|
||||
const { count, inc } = useCounter(0)
|
||||
const total = ref(0)
|
||||
const problems = ref<AdminProblemFiltered[]>([])
|
||||
|
||||
const selectedRowKeys = ref<DataTableRowKey[]>([])
|
||||
const batchTagAction = ref<"add" | "remove">("add")
|
||||
const [showBatchTag, toggleBatchTag] = useToggle(false)
|
||||
|
||||
const selectedProblemIds = computed(() =>
|
||||
selectedRowKeys.value.map((key) => Number(key)),
|
||||
)
|
||||
|
||||
const rowKey = (row: AdminProblemFiltered) => row.id
|
||||
|
||||
function chooseProblems(rowKeys: DataTableRowKey[]) {
|
||||
selectedRowKeys.value = rowKeys
|
||||
}
|
||||
|
||||
function openBatchTag(action: "add" | "remove") {
|
||||
batchTagAction.value = action
|
||||
toggleBatchTag(true)
|
||||
}
|
||||
|
||||
function onBatchTagDone() {
|
||||
selectedRowKeys.value = []
|
||||
listProblems()
|
||||
}
|
||||
|
||||
const nextDisplayID = computed(() => {
|
||||
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 ""
|
||||
})
|
||||
|
||||
interface ProblemQuery {
|
||||
keyword: string
|
||||
author: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
author: useRouteQuery("author", "").value,
|
||||
})
|
||||
|
||||
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{ title: "ID", key: "id", width: 100 },
|
||||
{ title: "显示编号", key: "_id", width: 100 },
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
{
|
||||
title: "难度",
|
||||
key: "difficulty",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ type: getTagColor(row.difficulty), size: "small" },
|
||||
() => DIFFICULTY[row.difficulty],
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
key: "tags",
|
||||
minWidth: 120,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 4 }, () =>
|
||||
row.tags.map((t) => h(NTag, { key: t, size: "small" }, () => t)),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "功能",
|
||||
key: "features",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 4, align: "center" }, () => [
|
||||
row.allow_flowchart
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-drawio",
|
||||
title: "绘图",
|
||||
})
|
||||
: row.show_flowchart
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-graphql",
|
||||
title: "流程图",
|
||||
})
|
||||
: null,
|
||||
row.has_ast_rules
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-light-todo",
|
||||
title: "AST",
|
||||
})
|
||||
: null,
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "反馈",
|
||||
key: "top_reaction",
|
||||
width: 60,
|
||||
render: (row) => {
|
||||
const top = row.top_reaction
|
||||
if (!top) return null
|
||||
const reaction = REACTIONS.find((it) => it.key === top.type)
|
||||
if (!reaction) return null
|
||||
return h(NTooltip, null, {
|
||||
trigger: () => h(Icon, { width: 18, icon: reaction.icon }),
|
||||
default: () => `${reaction.label} ${top.count} 人`,
|
||||
})
|
||||
},
|
||||
},
|
||||
{ title: "出题人", key: "username", width: 120 },
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 200,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
minWidth: 100,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
size: "small",
|
||||
rubberBand: false,
|
||||
onUpdateValue: () => toggleVisible(row.id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 320,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
problemID: row.id,
|
||||
problemDisplayID: row._id,
|
||||
onUpdated: listProblems,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
// 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示
|
||||
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() =>
|
||||
isContestProblemList.value
|
||||
? baseColumns.filter((it) => !("key" in it) || it.key !== "top_reaction")
|
||||
: [{ type: "selection" }, ...baseColumns],
|
||||
)
|
||||
|
||||
async function listProblems() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.author,
|
||||
props.contestID,
|
||||
)
|
||||
total.value = res.total
|
||||
problems.value = res.results
|
||||
}
|
||||
|
||||
async function toggleVisible(problemID: number) {
|
||||
await toggleProblemVisible(problemID)
|
||||
problems.value = problems.value.map((it) => {
|
||||
if (it.id === problemID) {
|
||||
it.visible = !it.visible
|
||||
}
|
||||
return it
|
||||
})
|
||||
}
|
||||
|
||||
function createContestProblem() {
|
||||
router.push({
|
||||
name: "admin contest problem create",
|
||||
params: { contestID: props.contestID },
|
||||
})
|
||||
}
|
||||
|
||||
async function selectProblems() {
|
||||
toggleShow(true)
|
||||
inc()
|
||||
}
|
||||
|
||||
onMounted(listProblems)
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(() => query.keyword, listProblems, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="titleWrapper" justify="space-between">
|
||||
<n-flex align="center">
|
||||
<h2 class="title">{{ title }}</h2>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'admin problem create' })"
|
||||
>
|
||||
新建
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
@click="$router.push({ name: 'admin stuck problems' })"
|
||||
>
|
||||
卡点分析
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
@click="$router.push({ name: 'admin top ac trend' })"
|
||||
>
|
||||
年度趋势
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
@click="$router.push({ name: 'admin tag list' })"
|
||||
>
|
||||
标签管理
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex>
|
||||
<template v-if="!isContestProblemList && selectedProblemIds.length">
|
||||
<n-button type="primary" @click="openBatchTag('add')">
|
||||
添加标签({{ selectedProblemIds.length }})
|
||||
</n-button>
|
||||
<n-button @click="openBatchTag('remove')">移除标签</n-button>
|
||||
</template>
|
||||
<n-button v-if="isContestProblemList" @click="createContestProblem">
|
||||
新建比赛题目
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="isContestProblemList"
|
||||
type="primary"
|
||||
@click="selectProblems"
|
||||
>
|
||||
从题目中选择
|
||||
</n-button>
|
||||
<n-flex align="center" v-if="!props.contestID">
|
||||
<span>出题人</span>
|
||||
<AuthorSelect v-model:value="query.author" all />
|
||||
</n-flex>
|
||||
<div>
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="输入标题关键字"
|
||||
clearable
|
||||
@clear="clearQuery"
|
||||
/>
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="problems"
|
||||
:row-key="rowKey"
|
||||
@update:checked-row-keys="chooseProblems"
|
||||
/>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<Modal
|
||||
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>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
186
apps/web/src/admin/problem/tags.vue
Normal file
186
apps/web/src/admin/problem/tags.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex, NInput } from "naive-ui"
|
||||
import type { AdminTag } from "utils/types"
|
||||
import { deleteTag, getTagAdminList, renameTag } from "../api"
|
||||
import TagProblemsModal from "./components/TagProblemsModal.vue"
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const tags = ref<AdminTag[]>([])
|
||||
const keyword = ref("")
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingName = ref("")
|
||||
|
||||
const activeTag = ref<AdminTag | null>(null)
|
||||
const [showTagProblems, toggleTagProblems] = useToggle(false)
|
||||
|
||||
function openTagProblems(tag: AdminTag) {
|
||||
activeTag.value = tag
|
||||
toggleTagProblems(true)
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<AdminTag>[] = [
|
||||
{ title: "ID", key: "id", width: 80 },
|
||||
{
|
||||
title: "标签名",
|
||||
key: "name",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
editingId.value === row.id
|
||||
? h(NInput, {
|
||||
value: editingName.value,
|
||||
autofocus: true,
|
||||
size: "small",
|
||||
style: "max-width: 240px",
|
||||
onUpdateValue: (v: string) => (editingName.value = v),
|
||||
onKeyup: (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") saveTag(row)
|
||||
if (e.key === "Escape") cancelEdit()
|
||||
},
|
||||
})
|
||||
: h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "primary",
|
||||
onClick: () => openTagProblems(row),
|
||||
},
|
||||
() => row.name,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "题目数",
|
||||
key: "problem_count",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => openTagProblems(row) },
|
||||
() => String(row.problem_count),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 200,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 8 }, () =>
|
||||
editingId.value === row.id
|
||||
? [
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", type: "primary", onClick: () => saveTag(row) },
|
||||
() => "保存",
|
||||
),
|
||||
h(NButton, { size: "small", onClick: cancelEdit }, () => "取消"),
|
||||
]
|
||||
: [
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", onClick: () => startEdit(row) },
|
||||
() => "重命名",
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
type: "error",
|
||||
onClick: () => confirmDelete(row),
|
||||
},
|
||||
() => "删除",
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
async function listTags() {
|
||||
const res = await getTagAdminList(keyword.value)
|
||||
tags.value = res.data
|
||||
}
|
||||
|
||||
function startEdit(tag: AdminTag) {
|
||||
editingId.value = tag.id
|
||||
editingName.value = tag.name
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
editingName.value = ""
|
||||
}
|
||||
|
||||
async function saveTag(tag: AdminTag) {
|
||||
const name = editingName.value.trim()
|
||||
if (!name) {
|
||||
message.error("标签名不能为空")
|
||||
return
|
||||
}
|
||||
if (name === tag.name) {
|
||||
cancelEdit()
|
||||
return
|
||||
}
|
||||
const res = await renameTag(tag.id, name)
|
||||
if (res.data.merged) {
|
||||
message.success(
|
||||
`已合并到「${res.data.name}」,影响 ${res.data.affected_count} 道题`,
|
||||
)
|
||||
} else {
|
||||
message.success("已重命名")
|
||||
}
|
||||
cancelEdit()
|
||||
listTags()
|
||||
}
|
||||
|
||||
function confirmDelete(tag: AdminTag) {
|
||||
dialog.warning({
|
||||
title: "删除标签",
|
||||
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problem_count} 道题在使用它,删除后这些题目会失去该标签。`,
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await deleteTag(tag.id)
|
||||
message.success("已删除")
|
||||
listTags()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(listTags)
|
||||
|
||||
watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="titleWrapper" justify="space-between">
|
||||
<n-flex align="center">
|
||||
<h2 class="title">标签管理</h2>
|
||||
<n-button @click="$router.push({ name: 'admin problem list' })">
|
||||
返回题目列表
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
style="width: 200px"
|
||||
placeholder="搜索标签"
|
||||
clearable
|
||||
/>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="tags" />
|
||||
<TagProblemsModal
|
||||
v-model:show="showTagProblems"
|
||||
:tag-id="activeTag?.id ?? 0"
|
||||
:tag-name="activeTag?.name ?? ''"
|
||||
@changed="listTags"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user