feat(标签): 题目列表的标签分成「知识点」「主题」两组,新建的标签在后台标出待归类
Deploy / deploy (push) Canceled after 0s

- 迁移 0022:problem_tag 加 category(knowledge / theme)和 category_confirmed;
  存量 25 个题材、题集类标签归主题,其余归知识点,全部记为已确认
- 前台题目列表按两组显示标签,组内按题目数从多到少
- 后台标签管理:每行切换分类;出题时新建的标签默认知识点、标「待归类」并排在最前,
  页头提示待归类数量,可点「确认」保留默认;加分类筛选
- 新接口 PUT /admin/problem-tags/:id/category,设置分类即视为确认

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 20:38:43 +08:00
1 parent e1b6a9e9fa
commit d07459928d
12 files changed
+4226 -30

No files matched your search

+5
View File
@@ -43,6 +43,7 @@ import type {
AdminProblemSet,
AdminProblemSetProblem,
TutorialListItem,
TagCategory,
} from "utils/types"
export function getBaseInfo() {
@@ -114,6 +115,10 @@ export function renameTag(id: number, name: string) {
return api.put<RenameTagResponse>(`admin/problem-tags/${id}`, { name })
}
export function setTagCategory(id: number, category: TagCategory) {
return api.put<null>(`admin/problem-tags/${id}/category`, { category })
}
export function deleteTag(id: number) {
return api.delete(`admin/problem-tags/${id}`)
}
+91 -13
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import PageHeader from "admin/components/PageHeader.vue"
import { NButton, NFlex, NInput } from "naive-ui"
import type { AdminTag } from "utils/types"
import { deleteTag, getTagAdminList, renameTag } from "../api"
import { NButton, NFlex, NInput, NRadioButton, NRadioGroup, NTag } 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()
@@ -10,6 +10,27 @@ const dialog = useDialog()
const tags = ref<AdminTag[]>([])
const keyword = ref("")
type CategoryFilter = "" | "pending" | TagCategory
const categoryFilter = ref<CategoryFilter>("")
// 出题时新建的标签默认算「知识点」、还没人确认过,数出来挂在页头提醒
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("")
@@ -40,15 +61,54 @@ const columns: DataTableColumn<AdminTag>[] = [
if (e.key === "Escape") cancelEdit()
},
})
: h(
NButton,
{
text: true,
type: "primary",
onClick: () => openTagProblems(row),
},
() => row.name,
),
: 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: "题目数",
@@ -125,6 +185,13 @@ async function saveTag(tag: AdminTag) {
listTags()
}
async function changeCategory(tag: AdminTag, category: TagCategory) {
await setTagCategory(tag.id, category)
tag.category = category
tag.categoryConfirmed = true
message.success(`「${tag.name}」已归到${category === "theme" ? "主题" : "知识点"}`)
}
function confirmDelete(tag: AdminTag) {
dialog.warning({
title: "删除标签",
@@ -148,9 +215,14 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
<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" />
</template>
</PageHeader>
<n-data-table striped :columns="columns" :data="tags" />
<n-alert v-if="pendingCount > 0" type="warning" :bordered="false" class="pending-alert">
有 {{ pendingCount }} 个新标签还没归类(出题时新建的标签默认算「知识点」)。
前台题目列表按分类把标签分成「知识点」「主题」两组,选好分类或点「确认」即可。
</n-alert>
<n-data-table striped :columns="columns" :data="visibleTags" />
<TagProblemsModal
v-model:show="showTagProblems"
:tag-id="activeTag?.id ?? 0"
@@ -158,3 +230,9 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
@changed="listTags"
/>
</template>
<style scoped>
.pending-alert {
margin-bottom: 12px;
}
</style>