feat(后台标签): 标签页改成知识点、主题左右两栏,一眼看出每个标签挂了几道题;改名、换分类、删除收进 ⋯,没归类的单独放最上面
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5561e0bc3a
commit
c59e31eeb2
2 files changed
+224
-146
No files matched your search
@@ -26,29 +26,28 @@ const limit = ref(10)
|
|||||||
const keyword = ref("")
|
const keyword = ref("")
|
||||||
|
|
||||||
const columns: DataTableColumn<AdminProblemRow>[] = [
|
const columns: DataTableColumn<AdminProblemRow>[] = [
|
||||||
{ title: "显示编号", key: "_id", width: 100 },
|
{ title: "题号", key: "_id", width: 90 },
|
||||||
{
|
{
|
||||||
title: "标题",
|
title: "题目",
|
||||||
key: "title",
|
key: "title",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
render: (row) =>
|
render: (row) => h(NButton, { text: true, onClick: () => goEdit(row) }, () => row.title),
|
||||||
h(NButton, { text: true, type: "primary", onClick: () => goEdit(row) }, () => row.title),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "可见",
|
title: "学生看得到",
|
||||||
key: "visible",
|
key: "visible",
|
||||||
width: 80,
|
width: 100,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(NTag, { size: "small", type: row.visible ? "success" : "default" }, () =>
|
h(NTag, { size: "small", bordered: false, type: row.visible ? "success" : "default" }, () =>
|
||||||
row.visible ? "公开" : "隐藏",
|
row.visible ? "看得到" : "藏着",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "选项",
|
title: "",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 110,
|
width: 90,
|
||||||
render: (row) =>
|
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
|
<n-input
|
||||||
v-model:value="keyword"
|
v-model:value="keyword"
|
||||||
style="width: 220px"
|
style="width: 220px"
|
||||||
placeholder="输入标题关键字"
|
placeholder="题号或题目名"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|||||||
+213
-134
@@ -1,36 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
import PageHeader from "admin/components/PageHeader.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 type { AdminTag, TagCategory } from "utils/types"
|
||||||
import { deleteTag, getTagAdminList, renameTag, setTagCategory } from "../api"
|
import { deleteTag, getTagAdminList, renameTag, setTagCategory } from "../api"
|
||||||
import TagProblemsModal from "./components/TagProblemsModal.vue"
|
import TagProblemsModal from "./components/TagProblemsModal.vue"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
const tags = ref<AdminTag[]>([])
|
const tags = ref<AdminTag[]>([])
|
||||||
const keyword = ref("")
|
const keyword = ref("")
|
||||||
|
|
||||||
type CategoryFilter = "" | "pending" | TagCategory
|
// 搜索在后端做(getTagAdminList 带 keyword),这里只是一份引用,两栏和待归类都从它分
|
||||||
const categoryFilter = ref<CategoryFilter>("")
|
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 editingId = ref<number | null>(null)
|
||||||
const editingName = ref("")
|
const editingName = ref("")
|
||||||
|
|
||||||
@@ -42,113 +27,46 @@ function openTagProblems(tag: AdminTag) {
|
|||||||
toggleTagProblems(true)
|
toggleTagProblems(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<AdminTag>[] = [
|
/**
|
||||||
{ title: "ID", key: "id", width: 80 },
|
* 两栏:知识点 / 主题(设计稿「后台标签」TagA)。原来是一张宽表,每行一组单选按钮切分类、
|
||||||
|
* 两颗按钮,ID 一列、页头一段说明。现在一行一个标签 + 挂了几道题(条的长短看出哪个用得多),
|
||||||
|
* 点名字看这个标签下的题,改名 / 换分类 / 删除收进 ⋯。还没归类的单独一条放最上面
|
||||||
|
*/
|
||||||
|
const columnsOf = computed(() => {
|
||||||
|
const confirmed = visibleTags.value.filter((t) => t.categoryConfirmed)
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
title: "标签名",
|
key: "knowledge" as const,
|
||||||
key: "name",
|
title: "知识点",
|
||||||
minWidth: 200,
|
tags: confirmed.filter((t) => t.category === "knowledge"),
|
||||||
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()
|
|
||||||
},
|
},
|
||||||
})
|
{ key: "theme" as const, title: "主题", tags: confirmed.filter((t) => t.category === "theme") },
|
||||||
: h(NFlex, { size: 8, align: "center" }, () => [
|
].map((col) => ({
|
||||||
h(
|
...col,
|
||||||
NButton,
|
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" },
|
||||||
{
|
{
|
||||||
text: true,
|
label: tag.problemCount ? `删除(${tag.problemCount} 道题会摘掉它)` : "删除",
|
||||||
type: "primary",
|
key: "delete",
|
||||||
onClick: () => openTagProblems(row),
|
props: { style: `color: ${theme.value.errorColor}` },
|
||||||
},
|
},
|
||||||
() => 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(
|
function onMenu(tag: AdminTag, key: string) {
|
||||||
NButton,
|
if (key === "rename") startEdit(tag)
|
||||||
{
|
else if (key === "move") changeCategory(tag, tag.category === "theme" ? "knowledge" : "theme")
|
||||||
size: "small",
|
else if (key === "delete") confirmDelete(tag)
|
||||||
type: "error",
|
}
|
||||||
onClick: () => confirmDelete(row),
|
|
||||||
},
|
|
||||||
() => "删除",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
async function listTags() {
|
async function listTags() {
|
||||||
const res = await getTagAdminList(keyword.value)
|
const res = await getTagAdminList(keyword.value)
|
||||||
@@ -194,8 +112,10 @@ async function changeCategory(tag: AdminTag, category: TagCategory) {
|
|||||||
|
|
||||||
function confirmDelete(tag: AdminTag) {
|
function confirmDelete(tag: AdminTag) {
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: "删除标签",
|
title: `删除「${tag.name}」?`,
|
||||||
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problemCount} 道题在使用它,删除后这些题目会失去该标签。`,
|
content: tag.problemCount
|
||||||
|
? `${tag.problemCount} 道题会摘掉这个标签,题目本身不动。`
|
||||||
|
: "没有题目挂着它。",
|
||||||
positiveText: "删除",
|
positiveText: "删除",
|
||||||
negativeText: "取消",
|
negativeText: "取消",
|
||||||
onPositiveClick: async () => {
|
onPositiveClick: async () => {
|
||||||
@@ -212,17 +132,67 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader title="标签管理">
|
<PageHeader title="标签">
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<n-input v-model:value="keyword" style="width: 220px" placeholder="搜索标签" clearable />
|
<n-input v-model:value="keyword" style="width: 220px" placeholder="搜标签" clearable />
|
||||||
<n-select v-model:value="categoryFilter" :options="categoryOptions" style="width: 160px" />
|
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<n-alert v-if="pendingCount > 0" type="warning" :bordered="false" class="pending-alert">
|
|
||||||
有 {{ pendingCount }} 个新标签还没归类(出题时新建的标签默认算「知识点」)。
|
<div v-if="pendingTags.length" class="pending">
|
||||||
前台题目列表按分类把标签分成「知识点」「主题」两组,选好分类或点「确认」即可。
|
<b>还没归类</b>
|
||||||
</n-alert>
|
<div v-for="tag in pendingTags" :key="tag.id" class="pending-item">
|
||||||
<n-data-table striped :columns="columns" :data="visibleTags" />
|
<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
|
<TagProblemsModal
|
||||||
v-model:show="showTagProblems"
|
v-model:show="showTagProblems"
|
||||||
:tag-id="activeTag?.id ?? 0"
|
:tag-id="activeTag?.id ?? 0"
|
||||||
@@ -232,7 +202,116 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pending-alert {
|
.pending {
|
||||||
margin-bottom: 12px;
|
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>
|
</style>
|
||||||
Reference in new issue
Block a user