Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a191bd78d4 | ||
|
|
842c4796c0 | ||
|
|
15aaec4384 |
@@ -2,6 +2,7 @@ import http from "utils/http"
|
|||||||
import { toProblemListItem } from "admin/transforms"
|
import { toProblemListItem } from "admin/transforms"
|
||||||
import type {
|
import type {
|
||||||
AdminProblem,
|
AdminProblem,
|
||||||
|
AdminTag,
|
||||||
Announcement,
|
Announcement,
|
||||||
AnnouncementEdit,
|
AnnouncementEdit,
|
||||||
BlankContest,
|
BlankContest,
|
||||||
@@ -84,6 +85,35 @@ export function getContestProblem(id: number) {
|
|||||||
return http.get("admin/contest/problem", { params: { id } })
|
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(
|
export function getUserList(
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
|||||||
@@ -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 Modal from "./components/Modal.vue"
|
||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||||
|
import type { DataTableRowKey } from "naive-ui"
|
||||||
|
import BatchTagModal from "./components/BatchTagModal.vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contestID?: string
|
contestID?: string
|
||||||
@@ -36,6 +38,30 @@ const { count, inc } = useCounter(0)
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const problems = ref<AdminProblemFiltered[]>([])
|
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(() => {
|
const nextDisplayID = computed(() => {
|
||||||
if (!isContestProblemList.value) return ""
|
if (!isContestProblemList.value) return ""
|
||||||
if (problems.value.length === 0) return "1"
|
if (problems.value.length === 0) return "1"
|
||||||
@@ -57,7 +83,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
|
|||||||
author: useRouteQuery("author", "").value,
|
author: useRouteQuery("author", "").value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||||
{ title: "ID", key: "id", width: 100 },
|
{ title: "ID", key: "id", width: 100 },
|
||||||
{ title: "显示编号", key: "_id", width: 100 },
|
{ title: "显示编号", key: "_id", width: 100 },
|
||||||
{ title: "标题", key: "title", minWidth: 200 },
|
{ 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() {
|
async function listProblems() {
|
||||||
if (query.page < 1) query.page = 1
|
if (query.page < 1) query.page = 1
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
@@ -212,8 +244,20 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
|||||||
>
|
>
|
||||||
年度趋势
|
年度趋势
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="!isContestProblemList"
|
||||||
|
@click="$router.push({ name: 'admin tag list' })"
|
||||||
|
>
|
||||||
|
标签管理
|
||||||
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<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 v-if="isContestProblemList" @click="createContestProblem">
|
||||||
新建比赛题目
|
新建比赛题目
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -238,7 +282,13 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
|||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</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
|
<Pagination
|
||||||
:total="total"
|
:total="total"
|
||||||
v-model:limit="query.limit"
|
v-model:limit="query.limit"
|
||||||
@@ -250,6 +300,12 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
|||||||
:next-display-id="nextDisplayID"
|
:next-display-id="nextDisplayID"
|
||||||
@change="listProblems"
|
@change="listProblems"
|
||||||
/>
|
/>
|
||||||
|
<BatchTagModal
|
||||||
|
v-model:show="showBatchTag"
|
||||||
|
:problem-ids="selectedProblemIds"
|
||||||
|
:action="batchTagAction"
|
||||||
|
@done="onBatchTagDone"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -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"),
|
component: () => import("admin/problem/list.vue"),
|
||||||
meta: { requiresProblemPermission: true },
|
meta: { requiresProblemPermission: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "problem/tags",
|
||||||
|
name: "admin tag list",
|
||||||
|
component: () => import("admin/problem/tags.vue"),
|
||||||
|
meta: { requiresProblemPermission: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "problem/create",
|
path: "problem/create",
|
||||||
name: "admin 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/config")) return "admin config"
|
||||||
if (path.startsWith("/admin/problemset")) return "admin problemset list"
|
if (path.startsWith("/admin/problemset")) return "admin problemset list"
|
||||||
if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems"
|
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/problem")) return "admin problem list"
|
||||||
if (path.startsWith("/admin/contest")) return "admin contest list"
|
if (path.startsWith("/admin/contest")) return "admin contest list"
|
||||||
if (path.startsWith("/admin/user")) return "admin user list"
|
if (path.startsWith("/admin/user")) return "admin user list"
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ export interface Tag {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminTag {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
problem_count: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface TestcaseUploadedReturns {
|
export interface TestcaseUploadedReturns {
|
||||||
id: string
|
id: string
|
||||||
info: Testcase[]
|
info: Testcase[]
|
||||||
|
|||||||
Reference in New Issue
Block a user