feat(后台标签): 标签页改成知识点、主题左右两栏,一眼看出每个标签挂了几道题;改名、换分类、删除收进 ⋯,没归类的单独放最上面
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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
@@ -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>
|
||||
Reference in new issue
Block a user