feat(admin): 题目列表支持批量添加/移除标签
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
|
||||
Reference in New Issue
Block a user