feat(后台标签): 标签页改成知识点、主题左右两栏,一眼看出每个标签挂了几道题;改名、换分类、删除收进 ⋯,没归类的单独放最上面

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 14:04:06 +08:00
1 parent 5561e0bc3a
commit c59e31eeb2
2 files changed
+229 -151

No files matched your search

@@ -26,29 +26,28 @@ const limit = ref(10)
const keyword = ref("")
const columns: DataTableColumn<AdminProblemRow>[] = [
{ title: "显示编号", key: "_id", width: 100 },
{ title: "题号", key: "_id", width: 90 },
{
title: "标题",
title: "题目",
key: "title",
minWidth: 200,
render: (row) =>
h(NButton, { text: true, type: "primary", onClick: () => goEdit(row) }, () => row.title),
render: (row) => h(NButton, { text: true, onClick: () => goEdit(row) }, () => row.title),
},
{
title: "可见",
title: "学生看得到",
key: "visible",
width: 80,
width: 100,
render: (row) =>
h(NTag, { size: "small", type: row.visible ? "success" : "default" }, () =>
row.visible ? "公开" : "隐藏",
h(NTag, { size: "small", bordered: false, type: row.visible ? "success" : "default" }, () =>
row.visible ? "看得到" : "藏着",
),
},
{
title: "选项",
title: "",
key: "actions",
width: 110,
width: 90,
render: (row) =>
h(NButton, { size: "small", type: "error", onClick: () => removeTag(row) }, () => "移除标签"),
h(NButton, { size: "small", quaternary: true, onClick: () => removeTag(row) }, () => "摘掉"),
},
]
@@ -119,7 +118,7 @@ watchDebounced(
<n-input
v-model:value="keyword"
style="width: 220px"
placeholder="输入标题关键字"
placeholder="题号或题目名"
clearable
/>
</n-flex>
+218 -139
View File
@@ -1,36 +1,21 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import PageHeader from "admin/components/PageHeader.vue"
import { NButton, NFlex, NInput, NRadioButton, NRadioGroup, NTag } from "naive-ui"
import { useThemeVars } from "naive-ui"
import type { AdminTag, TagCategory } from "utils/types"
import { deleteTag, getTagAdminList, renameTag, setTagCategory } from "../api"
import TagProblemsModal from "./components/TagProblemsModal.vue"
const message = useMessage()
const dialog = useDialog()
const theme = useThemeVars()
const tags = ref<AdminTag[]>([])
const keyword = ref("")
type CategoryFilter = "" | "pending" | TagCategory
const categoryFilter = ref<CategoryFilter>("")
// 搜索在后端做(getTagAdminList 带 keyword),这里只是一份引用,两栏和待归类都从它分
const visibleTags = computed(() => tags.value)
// 出题时新建的标签默认算「知识点」、还没人确认过,数出来挂在页头提醒
const pendingCount = computed(() => tags.value.filter((t) => !t.categoryConfirmed).length)
// 标签总共几十个,分类筛选在前端做,不必为它再加一个 query 参数
const visibleTags = computed(() => {
const filter = categoryFilter.value
if (!filter) return tags.value
if (filter === "pending") return tags.value.filter((t) => !t.categoryConfirmed)
return tags.value.filter((t) => t.category === filter)
})
const categoryOptions = computed(() => [
{ label: "全部分类", value: "" },
{ label: `待归类(${pendingCount.value})`, value: "pending" },
{ label: "知识点", value: "knowledge" },
{ label: "主题", value: "theme" },
])
const editingId = ref<number | null>(null)
const editingName = ref("")
@@ -42,113 +27,46 @@ function openTagProblems(tag: AdminTag) {
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(NFlex, { size: 8, align: "center" }, () => [
h(
NButton,
{
text: true,
type: "primary",
onClick: () => openTagProblems(row),
},
() => row.name,
),
row.categoryConfirmed
? null
: h(NTag, { size: "small", type: "warning", bordered: false }, () => "待归类"),
]),
},
{
// 前台题目列表按这一列把标签分成「知识点」「主题」两组
title: "分类",
key: "category",
width: 240,
render: (row) =>
h(NFlex, { size: 8, align: "center", wrap: false }, () => [
h(
NRadioGroup,
{
size: "small",
value: row.category,
onUpdateValue: (v: TagCategory) => changeCategory(row, v),
},
() => [
h(NRadioButton, { value: "knowledge" }, () => "知识点"),
h(NRadioButton, { value: "theme" }, () => "主题"),
],
),
// 新标签默认已经是「知识点」,点它不会触发 update,所以单给一个确认按钮
row.categoryConfirmed
? null
: h(
NButton,
{
size: "small",
type: "primary",
secondary: true,
onClick: () => changeCategory(row, row.category),
},
() => "确认",
),
]),
},
{
title: "题目数",
key: "problem_count",
width: 100,
render: (row) =>
h(NButton, { text: true, type: "primary", onClick: () => openTagProblems(row) }, () =>
String(row.problemCount),
),
},
{
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),
},
() => "删除",
),
],
),
},
]
/**
* 两栏:知识点 / 主题(设计稿「后台标签」TagA)。原来是一张宽表,每行一组单选按钮切分类、
* 两颗按钮,ID 一列、页头一段说明。现在一行一个标签 + 挂了几道题(条的长短看出哪个用得多),
* 点名字看这个标签下的题,改名 / 换分类 / 删除收进 ⋯。还没归类的单独一条放最上面
*/
const columnsOf = computed(() => {
const confirmed = visibleTags.value.filter((t) => t.categoryConfirmed)
return [
{
key: "knowledge" as const,
title: "知识点",
tags: confirmed.filter((t) => t.category === "knowledge"),
},
{ key: "theme" as const, title: "主题", tags: confirmed.filter((t) => t.category === "theme") },
].map((col) => ({
...col,
tags: [...col.tags].sort((a, b) => b.problemCount - a.problemCount),
max: Math.max(1, ...col.tags.map((t) => t.problemCount)),
}))
})
const pendingTags = computed(() => visibleTags.value.filter((t) => !t.categoryConfirmed))
function menuOf(tag: AdminTag): DropdownOption[] {
return [
{ label: "改名(同名就合并)", key: "rename" },
{ label: tag.category === "theme" ? "改成知识点" : "改成主题", key: "move" },
{ type: "divider", key: "divider" },
{
label: tag.problemCount ? `删除(${tag.problemCount} 道题会摘掉它)` : "删除",
key: "delete",
props: { style: `color: ${theme.value.errorColor}` },
},
]
}
function onMenu(tag: AdminTag, key: string) {
if (key === "rename") startEdit(tag)
else if (key === "move") changeCategory(tag, tag.category === "theme" ? "knowledge" : "theme")
else if (key === "delete") confirmDelete(tag)
}
async function listTags() {
const res = await getTagAdminList(keyword.value)
@@ -194,8 +112,10 @@ async function changeCategory(tag: AdminTag, category: TagCategory) {
function confirmDelete(tag: AdminTag) {
dialog.warning({
title: "删除标签",
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problemCount} 道题在使用它,删除后这些题目会失去该标签。`,
title: `删除「${tag.name}」?`,
content: tag.problemCount
? `${tag.problemCount} 道题会摘掉这个标签,题目本身不动。`
: "没有题目挂着它。",
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
@@ -212,17 +132,67 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
</script>
<template>
<PageHeader title="标签管理">
<PageHeader title="标签">
<template #filters>
<n-input v-model:value="keyword" style="width: 220px" placeholder="搜索标签" clearable />
<n-select v-model:value="categoryFilter" :options="categoryOptions" style="width: 160px" />
<n-input v-model:value="keyword" style="width: 220px" placeholder="搜标签" clearable />
</template>
</PageHeader>
<n-alert v-if="pendingCount > 0" type="warning" :bordered="false" class="pending-alert">
有 {{ pendingCount }} 个新标签还没归类(出题时新建的标签默认算「知识点」)。
前台题目列表按分类把标签分成「知识点」「主题」两组,选好分类或点「确认」即可。
</n-alert>
<n-data-table striped :columns="columns" :data="visibleTags" />
<div v-if="pendingTags.length" class="pending">
<b>还没归类</b>
<div v-for="tag in pendingTags" :key="tag.id" class="pending-item">
<n-button text @click="openTagProblems(tag)">{{ tag.name }}</n-button>
<span class="muted">{{ tag.problemCount }} 道</span>
<n-button size="small" @click="changeCategory(tag, 'knowledge')">算知识点</n-button>
<n-button size="small" @click="changeCategory(tag, 'theme')">算主题</n-button>
</div>
</div>
<div class="columns">
<section v-for="col in columnsOf" :key="col.key" class="column">
<div class="column-head">
<b>{{ col.title }}</b>
<span class="muted">{{ col.tags.length }} 个</span>
</div>
<div class="list">
<n-empty v-if="!col.tags.length" size="small" description="没有" class="empty" />
<div v-for="tag in col.tags" :key="tag.id" class="row">
<template v-if="editingId === tag.id">
<n-input
v-model:value="editingName"
size="small"
autofocus
class="rename"
@keyup.enter="saveTag(tag)"
@keyup.esc="cancelEdit"
/>
<n-button size="small" type="primary" @click="saveTag(tag)">保存</n-button>
<n-button size="small" @click="cancelEdit">取消</n-button>
</template>
<template v-else>
<button type="button" class="name" :title="tag.name" @click="openTagProblems(tag)">
{{ tag.name }}
</button>
<span class="bar"
><i :style="{ width: `${(tag.problemCount / col.max) * 100}%` }"></i
></span>
<span class="count">{{ tag.problemCount }} 道</span>
<n-dropdown
trigger="click"
placement="bottom-end"
:options="menuOf(tag)"
@select="(key: string) => onMenu(tag, key)"
>
<n-button size="small" quaternary aria-label="更多">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</template>
</div>
</div>
</section>
</div>
<TagProblemsModal
v-model:show="showTagProblems"
:tag-id="activeTag?.id ?? 0"
@@ -232,7 +202,116 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
</template>
<style scoped>
.pending-alert {
margin-bottom: 12px;
.pending {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 18px;
margin-bottom: 14px;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(240, 160, 32, 0.45);
background: rgba(240, 160, 32, 0.08);
}
.pending-item {
display: inline-flex;
align-items: center;
gap: 8px;
}
.columns {
display: flex;
gap: 20px;
align-items: flex-start;
}
.column {
flex: 1 1 0;
min-width: 0;
}
.column-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
font-size: 16px;
}
.list {
border: 1px solid v-bind("theme.dividerColor");
border-radius: 8px;
padding: 2px 6px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 4px 0 8px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.row:last-child {
border-bottom: 0;
}
.name {
flex: 0 0 140px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 15px;
text-align: left;
color: v-bind("theme.textColor1");
cursor: pointer;
}
.name:hover {
color: v-bind("theme.primaryColor");
text-decoration: underline;
}
.bar {
flex: 1 1 auto;
height: 6px;
border-radius: 3px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.bar i {
display: block;
height: 6px;
border-radius: 3px;
background: v-bind("theme.successColorSuppl");
}
.count {
width: 52px;
text-align: right;
font-size: 13px;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor3");
}
.rename {
flex: 1 1 auto;
}
.muted {
font-size: 13px;
color: v-bind("theme.textColor3");
}
.empty {
margin: 16px 0;
}
</style>