- 迁移 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:
1 parent
e1b6a9e9fa
commit
d07459928d
12 files changed
+4226
-30
No files matched your search
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -104,6 +104,22 @@ async function listTags() {
|
||||
}))
|
||||
}
|
||||
|
||||
// 知识点在前、主题在后;组内按题目数从多到少,最常用的「输入输出」「选择结构」
|
||||
// 排在最前面,不再和一堆游戏名按拼音混排
|
||||
const TAG_GROUPS = [
|
||||
{ category: "knowledge", label: "知识点" },
|
||||
{ category: "theme", label: "主题" },
|
||||
] as const
|
||||
|
||||
const tagGroups = computed(() =>
|
||||
TAG_GROUPS.map((group) => ({
|
||||
...group,
|
||||
tags: tags.value
|
||||
.filter((t) => t.category === group.category)
|
||||
.sort((a, b) => b.problemCount - a.problemCount),
|
||||
})).filter((group) => group.tags.length > 0),
|
||||
)
|
||||
|
||||
function chooseTag(tag: Tag) {
|
||||
query.tag = tag.checked ? "" : tag.name
|
||||
tags.value = tags.value.map((t) => {
|
||||
@@ -293,18 +309,24 @@ function rowProps(row: ProblemRow) {
|
||||
<Hitokoto v-if="isDesktop" class="problem-list-hitokoto" />
|
||||
</div>
|
||||
<n-collapse-transition :show="showTag">
|
||||
<n-flex>
|
||||
<n-tag
|
||||
v-for="tag in tags"
|
||||
:closable="tag.checked"
|
||||
@close="chooseTag(tag)"
|
||||
@click="chooseTag(tag)"
|
||||
:key="tag.id"
|
||||
:type="tag.checked ? 'success' : 'default'"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<div class="tag-groups">
|
||||
<div v-for="group in tagGroups" :key="group.category" class="tag-group">
|
||||
<n-text depth="3" class="tag-group-label">{{ group.label }}</n-text>
|
||||
<n-flex>
|
||||
<n-tag
|
||||
v-for="tag in group.tags"
|
||||
:closable="tag.checked"
|
||||
@close="chooseTag(tag)"
|
||||
@click="chooseTag(tag)"
|
||||
:key="tag.id"
|
||||
:type="tag.checked ? 'success' : 'default'"
|
||||
class="tag-chip"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-transition>
|
||||
<n-data-table :bordered="false" :data="problems" :columns="columns" :row-props="rowProps" />
|
||||
</n-flex>
|
||||
@@ -330,6 +352,28 @@ function rowProps(row: ProblemRow) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tag-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tag-group {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag-group-label {
|
||||
font-size: 13px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.problem-list-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@@ -80,7 +80,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test"
|
||||
* 题目标签。用契约的 —— 它比手抄那份多一个 `problemCount`,
|
||||
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
|
||||
*/
|
||||
export type { Tag } from "@oj2/contract"
|
||||
export type { Tag, TagCategory } from "@oj2/contract"
|
||||
|
||||
export type {
|
||||
AdminTag,
|
||||
|
||||
Reference in new issue
Block a user