feat(后台题目): 题目列表重排——1366 屏上不用横滚就看得到「学生看得到」开关;一行只剩「编辑」和 ⋯,有人交过的题删除直接灰掉;勾几道一次放出、藏起、加减知识点

去掉内部 ID、难度、看不懂的「功能」图标和反馈表情几列;题目名点了就是编辑。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 13:27:39 +08:00
1 parent 952edbf86f
commit 97049d562b
5 files changed
+280 -120

No files matched your search

+20 -1
View File
@@ -72,17 +72,36 @@ export async function getProblemList(
tagId?: number, tagId?: number,
/** 题型:sql = SQL 题,code = 编程题;不传是全部(比赛题列表不认) */ /** 题型:sql = SQL 题,code = 编程题;不传是全部(比赛题列表不认) */
kind?: "sql" | "code" | "", kind?: "sql" | "code" | "",
/** 1 = 学生看得到的,0 = 藏着的;不传是全部(比赛题列表不认) */
visible?: "1" | "0" | "",
) { ) {
const endpoint = contestID ? `admin/contests/${contestID}/problems` : "admin/problems" const endpoint = contestID ? `admin/contests/${contestID}/problems` : "admin/problems"
const res = await api.get<AdminProblemList>(endpoint, { const res = await api.get<AdminProblemList>(endpoint, {
params: { offset, limit, keyword, author, tagId, kind: kind || undefined }, params: {
offset,
limit,
keyword,
author,
tagId,
kind: kind || undefined,
visible: visible || undefined,
},
}) })
return { return {
results: res.results.map(toAdminProblemRow), results: res.results.map(toAdminProblemRow),
total: res.total, total: res.total,
counts: res.counts ?? null,
} }
} }
/** 勾了几道,一次让学生看得到 / 藏起来 */
export function batchSetProblemVisible(problemIds: number[], visible: boolean) {
return api.post<{ problemCount: number }>("admin/problems/batch-visible", {
problemIds,
visible,
})
}
// 比赛「从题库中选择」用:列所有可见的公开题,不只是自己创建的 // 比赛「从题库中选择」用:列所有可见的公开题,不只是自己创建的
export async function getLibraryProblemList(offset = 0, limit = 10, keyword: string) { export async function getLibraryProblemList(offset = 0, limit = 10, keyword: string) {
const res = await api.get<AdminProblemList>("admin/problems", { const res = await api.get<AdminProblemList>("admin/problems", {
@@ -1,13 +1,22 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { errorCode, errorMessage } from "utils/api" import { errorCode, errorMessage } from "utils/api"
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api" import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
import download from "utils/download" import download from "utils/download"
/**
* 题目列表一行的操作(设计稿「后台题目列表」PlA):常用的「编辑」常驻,其余收进 ⋯ ——
* 原来是四颗彩色按钮(编辑 / 查看 / 删除 / 下载)并排,1366 宽的屏上把「可见」开关挤出了屏幕。
* 有人交过的题删不掉(服务端也拦),「删除」直接灰掉并写明为什么,不用点了才知道
*/
interface Props { interface Props {
problemID: number problemID: number
problemDisplayID: string problemDisplayID: string
/** 做过这道题的人数,> 0 就删不掉 */
triedUsers?: number
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const dialog = useDialog()
const emit = defineEmits(["updated"]) const emit = defineEmits(["updated"])
const route = useRoute() const route = useRoute()
@@ -60,6 +69,31 @@ function goCheck() {
window.open(data.href, "_blank") window.open(data.href, "_blank")
} }
const menuOptions = computed<DropdownOption[]>(() => [
{ label: "在前台看", key: "view" },
{ label: "下载测试数据", key: "download" },
...(isContestProblem.value ? [{ label: "转成公开题", key: "public" }] : []),
{ type: "divider", key: "divider" },
props.triedUsers
? { label: `删除(有 ${props.triedUsers} 人交过)`, key: "delete", disabled: true }
: { label: "删除", key: "delete" },
])
function onMenu(key: string) {
if (key === "view") goCheck()
else if (key === "download") downloads()
else if (key === "public") openMakePublicModal()
else if (key === "delete") {
dialog.warning({
title: `删除 ${props.problemDisplayID}?`,
content: "删掉就找不回来了。",
positiveText: "删除",
negativeText: "不删",
onPositiveClick: handleDeleteProblem,
})
}
}
function openMakePublicModal() { function openMakePublicModal() {
newDisplayID.value = "" newDisplayID.value = ""
showMakePublicModal.value = true showMakePublicModal.value = true
@@ -88,29 +122,13 @@ async function handleMakePublic() {
} }
</script> </script>
<template> <template>
<n-flex> <n-flex :size="4" justify="end" :wrap="false">
<n-button size="small" secondary type="primary" @click="goEdit"> 编辑 </n-button> <n-button size="small" @click="goEdit">编辑</n-button>
<n-button size="small" secondary type="info" @click="goCheck"> 查看 </n-button> <n-dropdown trigger="click" placement="bottom-end" :options="menuOptions" @select="onMenu">
<n-tooltip v-if="isContestProblem"> <n-button size="small" quaternary aria-label="更多">
<template #trigger> <template #icon><Icon icon="ph:dots-three-bold" /></template>
<n-button size="small" secondary type="warning" @click="openMakePublicModal"> </n-button>
公开 </n-dropdown>
</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-flex>
<n-modal <n-modal
+214 -96
View File
@@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { NFlex, NSwitch, NTag, NTooltip } from "naive-ui" import { NSwitch, useThemeVars } from "naive-ui"
import { Icon } from "@iconify/vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { getTagColor, parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { AdminProblemRow } from "utils/types" import type { AdminProblemRow } from "utils/types"
import { DIFFICULTY, REACTIONS } from "utils/constants" import { errorMessage } from "utils/api"
import { getProblemList, toggleProblemVisible } from "../api" import { batchSetProblemVisible, getProblemList, toggleProblemVisible } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
import AddFromLibraryModal from "./components/AddFromLibraryModal.vue" import AddFromLibraryModal from "./components/AddFromLibraryModal.vue"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
@@ -17,6 +16,14 @@ import CreateProblemMenu from "./components/CreateProblemMenu.vue"
import PageHeader from "admin/components/PageHeader.vue" import PageHeader from "admin/components/PageHeader.vue"
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue" import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
/**
* 后台题目列表(设计稿「后台题目列表」PlA / PlB)。一行一道题:题号、题目(点了就是编辑)、
* 知识点、做对 / 做过的人数、出题人、创建日期、「学生看得到」开关,最右「编辑」+ ⋯。
*
* 原来 1366 宽的屏上「可见」开关和创建时间要横滚才看得到,出题人被截成三个字;
* 内部 ID 和显示编号两列、难度、看不懂的「功能」图标列、反馈表情列都拿掉了。
* 比赛题目列表(contestID)也用这一页,没有勾选、人数和「全部 / 看得到 / 藏着的」
*/
interface Props { interface Props {
contestID?: string contestID?: string
} }
@@ -24,6 +31,7 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const theme = useThemeVars()
const title = computed( const title = computed(
() => () =>
@@ -75,6 +83,8 @@ interface ProblemQuery {
keyword: string keyword: string
author: string author: string
kind: "" | "code" | "sql" kind: "" | "code" | "sql"
/** 1 = 学生看得到的,0 = 藏着的 */
visible: "" | "1" | "0"
} }
// 使用分页 composable // 使用分页 composable
@@ -82,95 +92,88 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
keyword: useRouteQuery("keyword", "").value, keyword: useRouteQuery("keyword", "").value,
author: useRouteQuery("author", "").value, author: useRouteQuery("author", "").value,
kind: useRouteQuery<ProblemQuery["kind"]>("kind", "").value, kind: useRouteQuery<ProblemQuery["kind"]>("kind", "").value,
visible: useRouteQuery<ProblemQuery["visible"]>("visible", "").value,
}) })
const counts = ref<{ all: number; visible: number; hidden: number } | null>(null)
const visibleTabs = computed(() => [
{ value: "" as const, label: "全部", count: counts.value?.all },
{ value: "1" as const, label: "学生看得到", count: counts.value?.visible },
{ value: "0" as const, label: "藏着的", count: counts.value?.hidden },
])
/** 知识点只摆前两个,多的写 +N,鼠标放上去看全部(P073 这类题挂了六个) */
function renderTags(tags: string[]) {
if (!tags.length) return h("span", { class: "muted" }, "—")
return h("span", { class: "tags", title: tags.join("、") }, [
...tags.slice(0, 2).map((tag) => h("span", { class: "tag", key: tag }, tag)),
tags.length > 2 ? h("span", { class: "muted more" }, `+${tags.length - 2}`) : null,
])
}
function renderTitle(row: AdminProblemRow) {
const kinds = [
row.isSql ? h(ProblemTypeTag, { kind: "sql" }) : null,
row.allowFlowchart ? h(ProblemTypeTag, { kind: "flowchart" }) : null,
row.hasAstRules ? h(ProblemTypeTag, { kind: "ast" }) : null,
]
return h("span", { class: "title-cell" }, [
h(
"a",
{
class: "title-link",
href: router.resolve(editRoute(row.id)).href,
onClick: (event: MouseEvent) => {
if (event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
router.push(editRoute(row.id))
},
},
row.title,
),
...kinds,
])
}
function editRoute(problemID: number) {
return {
name: isContestProblemList.value ? "admin contest problem edit" : "admin problem edit",
params: isContestProblemList.value ? { contestID: props.contestID, problemID } : { problemID },
}
}
const baseColumns: DataTableColumn<AdminProblemRow>[] = [ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
{ title: "ID", key: "id", width: 100 },
{ title: "显示编号", key: "_id", width: 100 },
{ {
title: "标题", title: "题号",
key: "title", key: "_id",
minWidth: 200, width: 84,
render: (row) => h("span", { class: "did", title: `内部编号 ${row.id}` }, row._id),
},
{ title: "题目", key: "title", minWidth: 220, ellipsis: false, render: renderTitle },
{ title: "知识点", key: "tags", width: 190, render: (row) => renderTags(row.tags) },
{
title: "做对 / 做过",
key: "users",
width: 100,
render: (row) => render: (row) =>
row.isSql row.triedUsers
? h(NFlex, { size: 6, align: "center", wrap: false }, () => [ ? h("span", { class: "num" }, [
h("span", row.title), String(row.solvedUsers),
h(ProblemTypeTag, { kind: "sql" }), h("span", { class: "muted" }, ` / ${row.triedUsers}`),
]) ])
: row.title, : h("span", { class: "muted" }, "没人做过"),
}, },
{ title: "出题人", key: "username", width: 100, ellipsis: { tooltip: true } },
{ {
title: "难度", 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.allowFlowchart
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-drawio",
title: "绘图",
})
: row.showFlowchart
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-graphql",
title: "流程图",
})
: null,
row.hasAstRules
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-light-todo",
title: "AST",
})
: null,
]),
},
{
title: "反馈",
key: "topReaction",
width: 60,
render: (row) => {
const top = row.topReaction
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", key: "create_time",
width: 200, width: 96,
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"), render: (row) => h("span", { class: "num muted" }, parseTime(row.createTime, "YYYY-MM-DD")),
}, },
{ {
title: "可见", title: "学生看得到",
key: "visible", key: "visible",
minWidth: 100, width: 96,
render: (row) => render: (row) =>
h(NSwitch, { h(NSwitch, {
value: row.visible, value: row.visible,
@@ -180,23 +183,22 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
}), }),
}, },
{ {
title: "选项", title: "",
key: "actions", key: "actions",
fixed: "right", width: 104,
width: 300,
render: (row) => render: (row) =>
h(Actions, { h(Actions, {
problemID: row.id, problemID: row.id,
problemDisplayID: row._id, problemDisplayID: row._id,
triedUsers: row.triedUsers,
onUpdated: listProblems, onUpdated: listProblems,
}), }),
}, },
] ]
// 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示
const columns = computed<DataTableColumn<AdminProblemRow>[]>(() => const columns = computed<DataTableColumn<AdminProblemRow>[]>(() =>
isContestProblemList.value isContestProblemList.value
? baseColumns.filter((it) => !("key" in it) || it.key !== "topReaction") ? baseColumns.filter((it) => !("key" in it) || it.key !== "users")
: [{ type: "selection" }, ...baseColumns], : [{ type: "selection" }, ...baseColumns],
) )
@@ -211,9 +213,24 @@ async function listProblems() {
props.contestID, props.contestID,
undefined, undefined,
query.kind, query.kind,
props.contestID ? "" : query.visible,
) )
total.value = res.total total.value = res.total
problems.value = res.results problems.value = res.results
counts.value = res.counts
}
/** 勾了几道,一次让学生看得到 / 藏起来(lwp0201 一批 P0xx 题出完一起放出来就是这么用) */
const message = useMessage()
async function batchVisible(visible: boolean) {
try {
const { problemCount } = await batchSetProblemVisible(selectedProblemIds.value, visible)
message.success(`${problemCount} 道题${visible ? "学生看得到了" : "藏起来了"}`)
selectedRowKeys.value = []
listProblems()
} catch (error) {
message.error(errorMessage(error))
}
} }
async function toggleVisible(problemID: number) { async function toggleVisible(problemID: number) {
@@ -224,6 +241,15 @@ async function toggleVisible(problemID: number) {
} }
return it return it
}) })
// 三个按钮上的数跟着变;停在「藏着的」时这道题也该从列表里走掉,但当场消失会让人以为没点上,下次刷新再说
if (counts.value) {
const now = problems.value.find((it) => it.id === problemID)?.visible
counts.value = {
...counts.value,
visible: counts.value.visible + (now ? 1 : -1),
hidden: counts.value.hidden + (now ? -1 : 1),
}
}
} }
const kindOptions = [ const kindOptions = [
@@ -254,7 +280,7 @@ watchDebounced(() => query.keyword, listProblems, {
}) })
// 监听其他查询条件变化 // 监听其他查询条件变化
watch(() => [query.page, query.limit, query.author, query.kind], listProblems) watch(() => [query.page, query.limit, query.author, query.kind, query.visible], listProblems)
</script> </script>
<template> <template>
@@ -264,12 +290,6 @@ watch(() => [query.page, query.limit, query.author, query.kind], listProblems)
> >
<template #actions> <template #actions>
<template v-if="!isContestProblemList"> <template v-if="!isContestProblemList">
<template v-if="selectedProblemIds.length">
<n-button @click="openBatchTag('remove')">移除标签</n-button>
<n-button secondary type="primary" @click="openBatchTag('add')">
添加标签({{ selectedProblemIds.length }})
</n-button>
</template>
<CreateProblemMenu label="新建题目" primary @select="createProblem" /> <CreateProblemMenu label="新建题目" primary @select="createProblem" />
</template> </template>
<template v-else> <template v-else>
@@ -280,7 +300,7 @@ watch(() => [query.page, query.limit, query.author, query.kind], listProblems)
<template #filters> <template #filters>
<n-input <n-input
v-model:value="query.keyword" v-model:value="query.keyword"
placeholder="输入标题关键字" placeholder="题号或题目名"
clearable clearable
style="width: 220px" style="width: 220px"
@clear="clearQuery" @clear="clearQuery"
@@ -296,13 +316,35 @@ watch(() => [query.page, query.limit, query.author, query.kind], listProblems)
<n-text depth="3">出题人</n-text> <n-text depth="3">出题人</n-text>
<AuthorSelect v-model:value="query.author" all /> <AuthorSelect v-model:value="query.author" all />
</n-flex> </n-flex>
<n-button-group v-if="!props.contestID">
<n-button
v-for="tab in visibleTabs"
:key="tab.value"
:type="query.visible === tab.value ? 'primary' : 'default'"
:secondary="query.visible === tab.value"
@click="query.visible = tab.value"
>
{{ tab.label }}
<span v-if="tab.count !== undefined" class="tab-count">{{ tab.count }}</span>
</n-button>
</n-button-group>
</template> </template>
</PageHeader> </PageHeader>
<div v-if="selectedProblemIds.length" class="batch-bar">
<b>已选 {{ selectedProblemIds.length }} 道</b>
<n-button size="small" @click="batchVisible(true)">让学生看得到</n-button>
<n-button size="small" @click="batchVisible(false)">藏起来</n-button>
<n-button size="small" @click="openBatchTag('add')">加知识点</n-button>
<n-button size="small" @click="openBatchTag('remove')">去掉知识点</n-button>
<div class="spacer"></div>
<n-button size="small" quaternary @click="selectedRowKeys = []">取消选择</n-button>
</div>
<n-data-table <n-data-table
striped class="problem-table"
:columns="columns" :columns="columns"
:data="problems" :data="problems"
:row-key="rowKey" :row-key="rowKey"
:checked-row-keys="selectedRowKeys"
@update:checked-row-keys="chooseProblems" @update:checked-row-keys="chooseProblems"
/> />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" /> <Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
@@ -319,3 +361,79 @@ watch(() => [query.page, query.limit, query.author, query.kind], listProblems)
@done="onBatchTagDone" @done="onBatchTagDone"
/> />
</template> </template>
<style scoped>
.batch-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid v-bind("theme.successColorSuppl");
background: v-bind("theme.hoverColor");
white-space: nowrap;
}
.spacer {
flex: 1 1 auto;
}
.tab-count {
margin-left: 6px;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.problem-table :deep(.did) {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.problem-table :deep(.title-cell) {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.problem-table :deep(.title-link) {
color: inherit;
font-weight: 500;
text-decoration: none;
}
.problem-table :deep(.title-link:hover) {
color: v-bind("theme.primaryColor");
text-decoration: underline;
}
.problem-table :deep(.tags) {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.problem-table :deep(.tag) {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 7px;
border-radius: 3px;
background: v-bind("theme.actionColor");
font-size: 12px;
}
.problem-table :deep(.muted) {
color: v-bind("theme.textColor3");
}
.problem-table :deep(.more) {
font-size: 12px;
}
.problem-table :deep(.num) {
font-variant-numeric: tabular-nums;
}
</style>
+2
View File
@@ -16,5 +16,7 @@ export function toAdminProblemRow(result: AdminProblemListItem): AdminProblemRow
allowFlowchart: result.allowFlowchart, allowFlowchart: result.allowFlowchart,
showFlowchart: result.showFlowchart, showFlowchart: result.showFlowchart,
topReaction: result.topReaction, topReaction: result.topReaction,
solvedUsers: result.solvedUsers,
triedUsers: result.triedUsers,
} }
} }
+3
View File
@@ -189,6 +189,9 @@ export interface AdminProblemRow {
showFlowchart: boolean showFlowchart: boolean
// 比赛题目列表恒为 null —— 只有公开题列表下发最高票评价 // 比赛题目列表恒为 null —— 只有公开题列表下发最高票评价
topReaction: { type: ReactionKey; count: number } | null topReaction: { type: ReactionKey; count: number } | null
/** 做对 / 做过的人数(题库里的代码提交);比赛题列表恒为 0 */
solvedUsers: number
triedUsers: number
} }
// 题单相关类型 // 题单相关类型