Compare commits
3 Commits
52ac8d205d
...
a191bd78d4
| Author | SHA1 | Date | |
|---|---|---|---|
| a191bd78d4 | |||
| 842c4796c0 | |||
| 15aaec4384 |
@@ -2,6 +2,7 @@ import http from "utils/http"
|
||||
import { toProblemListItem } from "admin/transforms"
|
||||
import type {
|
||||
AdminProblem,
|
||||
AdminTag,
|
||||
Announcement,
|
||||
AnnouncementEdit,
|
||||
BlankContest,
|
||||
@@ -84,6 +85,35 @@ export function getContestProblem(id: number) {
|
||||
return http.get("admin/contest/problem", { params: { id } })
|
||||
}
|
||||
|
||||
// 标签管理
|
||||
export function getTagAdminList(keyword = "") {
|
||||
return http.get<AdminTag[]>("admin/problem/tag", { params: { keyword } })
|
||||
}
|
||||
|
||||
export function renameTag(id: number, name: string) {
|
||||
return http.put<{
|
||||
merged: boolean
|
||||
id: number
|
||||
name: string
|
||||
affected_count: number
|
||||
}>("admin/problem/tag", { id, name })
|
||||
}
|
||||
|
||||
export function deleteTag(id: number) {
|
||||
return http.delete("admin/problem/tag", { params: { id } })
|
||||
}
|
||||
|
||||
export function batchTagProblems(
|
||||
problemIds: number[],
|
||||
tagNames: string[],
|
||||
action: "add" | "remove",
|
||||
) {
|
||||
return http.post<{ problem_count: number; tag_count: number }>(
|
||||
"admin/problem/batch_tag",
|
||||
{ problem_ids: problemIds, tag_names: tagNames, action },
|
||||
)
|
||||
}
|
||||
|
||||
// 用户列表
|
||||
export function getUserList(
|
||||
offset = 0,
|
||||
|
||||
109
src/admin/problem/components/BatchTagModal.vue
Normal file
109
src/admin/problem/components/BatchTagModal.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTag } from "utils/types"
|
||||
import { batchTagProblems, getTagAdminList } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
problemIds: number[]
|
||||
action: "add" | "remove"
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [value: boolean]
|
||||
done: []
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const tags = ref<AdminTag[]>([])
|
||||
const selected = ref<string[]>([])
|
||||
const newTags = ref<string[]>([])
|
||||
|
||||
const title = computed(() =>
|
||||
props.action === "add" ? "批量添加标签" : "批量移除标签",
|
||||
)
|
||||
|
||||
const selectedSet = computed(() => new Set(selected.value))
|
||||
|
||||
const names = computed(() =>
|
||||
props.action === "add"
|
||||
? Array.from(new Set([...selected.value, ...newTags.value]))
|
||||
: selected.value,
|
||||
)
|
||||
|
||||
function toggleTag(name: string) {
|
||||
const set = new Set(selected.value)
|
||||
if (set.has(name)) set.delete(name)
|
||||
else set.add(name)
|
||||
selected.value = Array.from(set)
|
||||
}
|
||||
|
||||
async function listTags() {
|
||||
const res = await getTagAdminList()
|
||||
tags.value = res.data
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!names.value.length) {
|
||||
message.error("请先选择标签")
|
||||
return
|
||||
}
|
||||
const res = await batchTagProblems(
|
||||
props.problemIds,
|
||||
names.value,
|
||||
props.action,
|
||||
)
|
||||
const verb = props.action === "add" ? "添加" : "移除"
|
||||
message.success(
|
||||
`已为 ${res.data.problem_count} 道题${verb} ${res.data.tag_count} 个标签`,
|
||||
)
|
||||
close()
|
||||
emit("done")
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
selected.value = []
|
||||
newTags.value = []
|
||||
listTags()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="title"
|
||||
style="width: 600px"
|
||||
:mask-closable="false"
|
||||
@close="close"
|
||||
>
|
||||
<n-flex vertical size="large">
|
||||
<div>已选中 {{ problemIds.length }} 道题目</div>
|
||||
<n-flex size="small">
|
||||
<n-tag
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
checkable
|
||||
:checked="selectedSet.has(tag.name)"
|
||||
@update:checked="toggleTag(tag.name)"
|
||||
>
|
||||
{{ tag.name }}({{ tag.problem_count }})
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-dynamic-tags v-if="action === 'add'" v-model:value="newTags" />
|
||||
<n-flex justify="end">
|
||||
<n-button @click="close">取消</n-button>
|
||||
<n-button type="primary" @click="submit">确定</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -11,6 +11,8 @@ import Actions from "./components/Actions.vue"
|
||||
import Modal from "./components/Modal.vue"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||
import type { DataTableRowKey } from "naive-ui"
|
||||
import BatchTagModal from "./components/BatchTagModal.vue"
|
||||
|
||||
interface Props {
|
||||
contestID?: string
|
||||
@@ -36,6 +38,30 @@ const { count, inc } = useCounter(0)
|
||||
const total = ref(0)
|
||||
const problems = ref<AdminProblemFiltered[]>([])
|
||||
|
||||
const selectedRowKeys = ref<DataTableRowKey[]>([])
|
||||
const batchTagAction = ref<"add" | "remove">("add")
|
||||
const [showBatchTag, toggleBatchTag] = useToggle(false)
|
||||
|
||||
const selectedProblemIds = computed(() =>
|
||||
selectedRowKeys.value.map((key) => Number(key)),
|
||||
)
|
||||
|
||||
const rowKey = (row: AdminProblemFiltered) => row.id
|
||||
|
||||
function chooseProblems(rowKeys: DataTableRowKey[]) {
|
||||
selectedRowKeys.value = rowKeys
|
||||
}
|
||||
|
||||
function openBatchTag(action: "add" | "remove") {
|
||||
batchTagAction.value = action
|
||||
toggleBatchTag(true)
|
||||
}
|
||||
|
||||
function onBatchTagDone() {
|
||||
selectedRowKeys.value = []
|
||||
listProblems()
|
||||
}
|
||||
|
||||
const nextDisplayID = computed(() => {
|
||||
if (!isContestProblemList.value) return ""
|
||||
if (problems.value.length === 0) return "1"
|
||||
@@ -57,7 +83,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
author: useRouteQuery("author", "").value,
|
||||
})
|
||||
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{ title: "ID", key: "id", width: 100 },
|
||||
{ title: "显示编号", key: "_id", width: 100 },
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
@@ -141,6 +167,12 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() =>
|
||||
isContestProblemList.value
|
||||
? baseColumns
|
||||
: [{ type: "selection" }, ...baseColumns],
|
||||
)
|
||||
|
||||
async function listProblems() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
@@ -212,8 +244,20 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
>
|
||||
年度趋势
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
@click="$router.push({ name: 'admin tag list' })"
|
||||
>
|
||||
标签管理
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex>
|
||||
<template v-if="!isContestProblemList && selectedProblemIds.length">
|
||||
<n-button type="primary" @click="openBatchTag('add')">
|
||||
添加标签({{ selectedProblemIds.length }})
|
||||
</n-button>
|
||||
<n-button @click="openBatchTag('remove')">移除标签</n-button>
|
||||
</template>
|
||||
<n-button v-if="isContestProblemList" @click="createContestProblem">
|
||||
新建比赛题目
|
||||
</n-button>
|
||||
@@ -238,7 +282,13 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
</div>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="problems" />
|
||||
<n-data-table
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="problems"
|
||||
:row-key="rowKey"
|
||||
@update:checked-row-keys="chooseProblems"
|
||||
/>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
@@ -250,6 +300,12 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
:next-display-id="nextDisplayID"
|
||||
@change="listProblems"
|
||||
/>
|
||||
<BatchTagModal
|
||||
v-model:show="showBatchTag"
|
||||
:problem-ids="selectedProblemIds"
|
||||
:action="batchTagAction"
|
||||
@done="onBatchTagDone"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
153
src/admin/problem/tags.vue
Normal file
153
src/admin/problem/tags.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex, NInput } from "naive-ui"
|
||||
import type { AdminTag } from "utils/types"
|
||||
import { deleteTag, getTagAdminList, renameTag } from "../api"
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const tags = ref<AdminTag[]>([])
|
||||
const keyword = ref("")
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingName = ref("")
|
||||
|
||||
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()
|
||||
},
|
||||
})
|
||||
: row.name,
|
||||
},
|
||||
{ title: "题目数", key: "problem_count", width: 100 },
|
||||
{
|
||||
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),
|
||||
},
|
||||
() => "删除",
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
async function listTags() {
|
||||
const res = await getTagAdminList(keyword.value)
|
||||
tags.value = res.data
|
||||
}
|
||||
|
||||
function startEdit(tag: AdminTag) {
|
||||
editingId.value = tag.id
|
||||
editingName.value = tag.name
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
editingName.value = ""
|
||||
}
|
||||
|
||||
async function saveTag(tag: AdminTag) {
|
||||
const name = editingName.value.trim()
|
||||
if (!name) {
|
||||
message.error("标签名不能为空")
|
||||
return
|
||||
}
|
||||
if (name === tag.name) {
|
||||
cancelEdit()
|
||||
return
|
||||
}
|
||||
const res = await renameTag(tag.id, name)
|
||||
if (res.data.merged) {
|
||||
message.success(
|
||||
`已合并到「${res.data.name}」,影响 ${res.data.affected_count} 道题`,
|
||||
)
|
||||
} else {
|
||||
message.success("已重命名")
|
||||
}
|
||||
cancelEdit()
|
||||
listTags()
|
||||
}
|
||||
|
||||
function confirmDelete(tag: AdminTag) {
|
||||
dialog.warning({
|
||||
title: "删除标签",
|
||||
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problem_count} 道题在使用它,删除后这些题目会失去该标签。`,
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await deleteTag(tag.id)
|
||||
message.success("已删除")
|
||||
listTags()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(listTags)
|
||||
|
||||
watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="titleWrapper" justify="space-between">
|
||||
<n-flex align="center">
|
||||
<h2 class="title">标签管理</h2>
|
||||
<n-button @click="$router.push({ name: 'admin problem list' })">
|
||||
返回题目列表
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
style="width: 200px"
|
||||
placeholder="搜索标签"
|
||||
clearable
|
||||
/>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="tags" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titleWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -171,6 +171,12 @@ export const admins: RouteRecordRaw = {
|
||||
component: () => import("admin/problem/list.vue"),
|
||||
meta: { requiresProblemPermission: true },
|
||||
},
|
||||
{
|
||||
path: "problem/tags",
|
||||
name: "admin tag list",
|
||||
component: () => import("admin/problem/tags.vue"),
|
||||
meta: { requiresProblemPermission: true },
|
||||
},
|
||||
{
|
||||
path: "problem/create",
|
||||
name: "admin problem create",
|
||||
|
||||
@@ -173,6 +173,7 @@ const active = computed(() => {
|
||||
if (path.startsWith("/admin/config")) return "admin config"
|
||||
if (path.startsWith("/admin/problemset")) return "admin problemset list"
|
||||
if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems"
|
||||
if (path.startsWith("/admin/problem/tags")) return "admin problem list"
|
||||
if (path.startsWith("/admin/problem")) return "admin problem list"
|
||||
if (path.startsWith("/admin/contest")) return "admin contest list"
|
||||
if (path.startsWith("/admin/user")) return "admin user list"
|
||||
|
||||
@@ -104,6 +104,12 @@ export interface Tag {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AdminTag {
|
||||
id: number
|
||||
name: string
|
||||
problem_count: number
|
||||
}
|
||||
|
||||
export interface TestcaseUploadedReturns {
|
||||
id: string
|
||||
info: Testcase[]
|
||||
|
||||
Reference in New Issue
Block a user