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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user