fix: 收紧知识库页面交互状态

This commit is contained in:
zxr
2026-07-22 04:01:25 +08:00
parent 98e1baf1bb
commit f5c7ebc59c
6 changed files with 400 additions and 93 deletions

View File

@@ -27,8 +27,21 @@ export interface Category {
remarks: string
}
/** 公共分类树节点。 */
export interface CategoryTreeNode extends Category {
/** 公共分类树节点;详情字段需通过详情接口获取。 */
export interface CategoryTreeNode {
id: number
name: string
description: string
type: 'general'
icon: string
color: string
parent_id: number
level: 1 | 2 | 3
path: string
sort_order: number
status: CategoryStatus
doc_count: number
faq_count: number
children: CategoryTreeNode[]
}

View File

@@ -5,11 +5,11 @@
<a-card class="tree-card" title="分类树" :bordered="false">
<template #extra>
<a-space>
<a-button v-if="canManage" type="primary" size="small" @click="openCreate(0)">新增一级分类</a-button>
<a-button size="small" :loading="loading" @click="loadTree">刷新</a-button>
<a-button v-if="canManage" type="primary" size="small" :disabled="writeBusy" @click="openCreate(0)">新增一级分类</a-button>
<a-button size="small" :loading="treeLoading" :disabled="writeBusy" @click="loadTree">刷新</a-button>
</a-space>
</template>
<a-spin :loading="loading" class="tree-loading">
<a-spin :loading="treeLoading" class="tree-loading">
<a-tree
v-if="treeData.length"
v-model:selected-keys="selectedKeys"
@@ -31,41 +31,60 @@
<template #title>{{ selectedCategory?.name || '分类详情' }}</template>
<template #extra>
<a-space v-if="selectedCategory && canManage">
<a-button v-if="selectedCategory.level < 3" type="primary" size="small" @click="openCreate(selectedCategory.id)">
<a-button
v-if="selectedCategory.level < 3"
type="primary"
size="small"
:disabled="writeBusy"
@click="openCreate(selectedCategory.id)"
>
新增子分类
</a-button>
<a-button size="small" @click="openEdit">编辑</a-button>
<a-button size="small" status="danger" @click="confirmDelete">删除</a-button>
<a-button size="small" :disabled="writeBusy" @click="openEdit">编辑</a-button>
<a-button
size="small"
status="danger"
:loading="deletingID === selectedCategory.id"
:disabled="writeBusy"
@click="confirmDelete"
>
删除
</a-button>
</a-space>
</template>
<a-descriptions v-if="selectedCategory" :column="2" bordered>
<a-descriptions-item label="分类名称">{{ selectedCategory.name }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="selectedCategory.status === 'active' ? 'green' : 'gray'">
{{ selectedCategory.status === 'active' ? '启用' : '停用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="层级"> {{ selectedCategory.level }} </a-descriptions-item>
<a-descriptions-item label="排序">{{ selectedCategory.sort_order }}</a-descriptions-item>
<a-descriptions-item label="文档数">{{ selectedCategory.doc_count }}</a-descriptions-item>
<a-descriptions-item label="FAQ 数">{{ selectedCategory.faq_count }}</a-descriptions-item>
<a-descriptions-item label="创建人">{{ selectedCategory.creator_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatTime(selectedCategory.updated_at) }}</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">{{ selectedCategory.description || '-' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ selectedCategory.remarks || '-' }}</a-descriptions-item>
</a-descriptions>
<a-empty v-else description="请从左侧选择分类" />
<a-spin :loading="detailLoading" class="detail-loading">
<a-descriptions v-if="selectedCategory" :column="2" bordered>
<a-descriptions-item label="分类名称">{{ selectedCategory.name }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="selectedCategory.status === 'active' ? 'green' : 'gray'">
{{ selectedCategory.status === 'active' ? '启用' : '停用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="层级"> {{ selectedCategory.level }} </a-descriptions-item>
<a-descriptions-item label="排序">{{ selectedCategory.sort_order }}</a-descriptions-item>
<a-descriptions-item label="文档数">{{ selectedCategory.doc_count }}</a-descriptions-item>
<a-descriptions-item label="FAQ 数">{{ selectedCategory.faq_count }}</a-descriptions-item>
<a-descriptions-item label="创建人">{{ selectedCategory.creator_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatTime(selectedCategory.updated_at) }}</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">{{ selectedCategory.description || '-' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ selectedCategory.remarks || '-' }}</a-descriptions-item>
</a-descriptions>
<a-empty v-else description="请从左侧选择分类" />
</a-spin>
</a-card>
</div>
<a-modal
v-model:visible="formVisible"
:visible="formVisible"
:title="editingCategory ? '编辑分类' : '新增分类'"
:ok-loading="submitting"
:closable="!submitting"
:mask-closable="!submitting"
:esc-to-close="!submitting"
:on-before-cancel="canCloseForm"
width="640px"
@ok="submitForm"
@cancel="closeForm"
@update:visible="handleFormVisibleChange"
>
<a-form ref="formRef" :model="form" layout="vertical">
<a-form-item label="分类名称" field="name" :rules="[{ required: true, message: '请输入分类名称' }]">
@@ -103,6 +122,12 @@
<a-textarea v-model="form.remarks" :max-length="500" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button :disabled="submitting" @click="closeForm">取消</a-button>
<a-button type="primary" :loading="submitting" :disabled="submitting" @click="submitForm">保存</a-button>
</a-space>
</template>
</a-modal>
</div>
</template>
@@ -116,6 +141,7 @@ import type { FormInstance } from '@arco-design/web-vue'
import {
createCategory,
deleteCategory,
fetchCategoryDetail,
fetchCategoryTree,
updateCategory,
type Category,
@@ -127,7 +153,7 @@ import usePermissionCodes from '@/hooks/usePermissionCodes'
interface TreeViewNode {
key: string
title: string
category: Category
category: CategoryTreeNode
children: TreeViewNode[]
}
@@ -140,16 +166,22 @@ interface ParentOption {
const { hasPermission } = usePermissionCodes()
const canManage = computed(() => hasPermission('kb:content:manage'))
const loading = ref(false)
const treeLoading = ref(false)
const detailLoading = ref(false)
const tree = ref<CategoryTreeNode[]>([])
const selectedKeys = ref<string[]>([])
const selectedCategory = ref<Category | null>(null)
let requestSequence = 0
const selectedTreeID = ref(0)
let treeSequence = 0
let detailSequence = 0
const treeData = computed<TreeViewNode[]>(() => tree.value.map((node) => toViewNode(node)))
const formVisible = ref(false)
const submitting = ref(false)
const deletingID = ref(0)
const deletePending = ref(false)
const writeBusy = computed(() => submitting.value || deletePending.value)
const formRef = ref<FormInstance>()
const editingCategory = ref<Category | null>(null)
const form = reactive({
@@ -214,42 +246,71 @@ function categorySubtreeHeight(id: number): number {
/** 加载最新分类树,并保留仍存在的选中项。 */
async function loadTree() {
const sequence = ++requestSequence
loading.value = true
const sequence = ++treeSequence
++detailSequence
detailLoading.value = false
selectedCategory.value = null
treeLoading.value = true
try {
const reply = await fetchCategoryTree()
if (sequence !== requestSequence) return
if (sequence !== treeSequence) return
if (reply.code !== 0) throw new Error(reply.message || '获取分类树失败')
tree.value = reply.details || []
const selectedID = selectedCategory.value?.id
const selectedID = selectedTreeID.value
const selected = selectedID ? findCategory(tree.value, selectedID) : tree.value[0]
selectedCategory.value = selected || null
selectedTreeID.value = selected?.id || 0
selectedKeys.value = selected ? [String(selected.id)] : []
if (selected) await loadCategoryDetail(selected.id)
} catch (error) {
if (sequence === requestSequence) {
if (sequence === treeSequence) {
tree.value = []
selectedCategory.value = null
selectedTreeID.value = 0
selectedKeys.value = []
showRequestError(error, '获取分类树失败')
}
} finally {
if (sequence === requestSequence) loading.value = false
if (sequence === treeSequence) treeLoading.value = false
}
}
function handleSelect(keys: Array<string | number>) {
const id = Number(keys[0])
selectedCategory.value = Number.isFinite(id) ? findCategory(tree.value, id) || null : null
if (!Number.isFinite(id) || !findCategory(tree.value, id)) {
selectedTreeID.value = 0
selectedCategory.value = null
return
}
selectedTreeID.value = id
loadCategoryDetail(id)
}
/** 分类树只负责导航,完整详情始终由详情接口读取。 */
async function loadCategoryDetail(id: number) {
const sequence = ++detailSequence
selectedCategory.value = null
detailLoading.value = true
try {
const reply = await fetchCategoryDetail(id)
if (sequence !== detailSequence || selectedTreeID.value !== id) return
if (reply.code !== 0) throw new Error(reply.message || '获取分类详情失败')
selectedCategory.value = reply.details
} catch (error) {
if (sequence === detailSequence && selectedTreeID.value === id) showRequestError(error, '获取分类详情失败')
} finally {
if (sequence === detailSequence) detailLoading.value = false
}
}
function openCreate(parentID: number) {
if (writeBusy.value) return
editingCategory.value = null
Object.assign(form, { name: '', description: '', parent_id: parentID, sort_order: 0, status: 'active', remarks: '' })
formVisible.value = true
}
function openEdit() {
if (!selectedCategory.value) return
if (!selectedCategory.value || writeBusy.value) return
editingCategory.value = selectedCategory.value
Object.assign(form, {
name: selectedCategory.value.name,
@@ -263,15 +324,17 @@ function openEdit() {
}
function closeForm() {
if (submitting.value) return
formVisible.value = false
editingCategory.value = null
formRef.value?.clearValidate()
}
async function submitForm() {
if (await formRef.value?.validate()) return false
if (submitting.value) return
submitting.value = true
try {
if (await formRef.value?.validate()) return
const payload = {
name: form.name.trim(),
description: form.description.trim(),
@@ -285,20 +348,22 @@ async function submitForm() {
const reply = editingCategory.value ? await updateCategory({ ...payload, id: editingCategory.value.id }) : await createCategory(payload)
if (reply.code !== 0) throw new Error(reply.message || '保存分类失败')
Message.success(editingCategory.value ? '分类已更新' : '分类已创建')
closeForm()
formVisible.value = false
editingCategory.value = null
formRef.value?.clearValidate()
await loadTree()
return true
} catch (error) {
showRequestError(error, '保存分类失败')
return false
} finally {
submitting.value = false
}
}
function confirmDelete() {
if (!selectedCategory.value) return
if (!selectedCategory.value || writeBusy.value) return
const category = selectedCategory.value
deletePending.value = true
deletingID.value = category.id
Modal.confirm({
title: '确认删除分类',
content: `确认删除「${category.name}」吗?存在子分类或内容时服务端将拒绝删除。`,
@@ -307,15 +372,33 @@ function confirmDelete() {
const reply = await deleteCategory(category.id)
if (reply.code !== 0) throw new Error(reply.message || '删除分类失败')
Message.success('分类已删除')
selectedTreeID.value = 0
selectedCategory.value = null
await loadTree()
} catch (error) {
showRequestError(error, '删除分类失败')
} finally {
deletePending.value = false
deletingID.value = 0
}
},
onCancel: finishDelete,
})
}
function finishDelete() {
deletePending.value = false
deletingID.value = 0
}
function canCloseForm() {
return !submitting.value
}
function handleFormVisibleChange(visible: boolean) {
if (!visible) closeForm()
}
function showRequestError(error: unknown, fallback: string) {
if (axios.isAxiosError(error)) {
const status = error.response?.status
@@ -347,7 +430,8 @@ onMounted(loadTree)
.detail-card {
min-height: 560px;
}
.tree-loading {
.tree-loading,
.detail-loading {
width: 100%;
}
:deep(.arco-tree-node-title) {

View File

@@ -3,7 +3,7 @@
<Breadcrumb :items="['知识管理', '我的收藏']" />
<a-card class="general-card" :bordered="false">
<template #title>我的收藏</template>
<template #extra><a-button :loading="loading" @click="loadFavorites">刷新</a-button></template>
<template #extra><a-button :loading="loading" :disabled="actionBusy" @click="loadFavorites">刷新</a-button></template>
<a-form :model="filters" layout="inline" class="filters" @submit-success="search">
<a-form-item label="资源类型">
@@ -12,7 +12,7 @@
<a-option value="faq">FAQ</a-option>
</a-select>
</a-form-item>
<a-form-item><a-button type="primary" html-type="submit">查询</a-button></a-form-item>
<a-form-item><a-button type="primary" html-type="submit" :disabled="actionBusy">查询</a-button></a-form-item>
</a-form>
<a-table
@@ -35,8 +35,15 @@
<template #created_at="{ record }">{{ formatTime(record.created_at) }}</template>
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" :disabled="record.is_deleted" @click="openDetail(record)">查看</a-button>
<a-button type="text" size="small" status="danger" :loading="actionID === record.id" @click="confirmUncollect(record)">
<a-button type="text" size="small" :disabled="record.is_deleted || actionBusy" @click="openDetail(record)">查看</a-button>
<a-button
type="text"
size="small"
status="danger"
:loading="actionBusy && actionID === record.id"
:disabled="actionBusy"
@click="confirmUncollect(record)"
>
取消收藏
</a-button>
</a-space>
@@ -100,6 +107,7 @@ const loading = ref(false)
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showTotal: true, showPageSize: true })
let listSequence = 0
const actionID = ref(0)
const actionBusy = ref(false)
const detailVisible = ref(false)
const detailLoading = ref(false)
@@ -198,11 +206,13 @@ function asFaq(record: Favorite): Faq | null {
}
function confirmUncollect(record: Favorite) {
if (actionBusy.value) return
actionBusy.value = true
actionID.value = record.id
Modal.confirm({
title: '确认取消收藏',
content: `确认取消收藏「${record.resource_name}」吗?`,
onOk: async () => {
actionID.value = record.id
try {
const reply = await uncollectResource({ resource_type: record.resource_type, resource_id: record.resource_id })
if (reply.code !== 0) throw new Error(reply.message || '取消收藏失败')
@@ -211,12 +221,18 @@ function confirmUncollect(record: Favorite) {
} catch (error) {
showRequestError(error, '取消收藏失败')
} finally {
actionID.value = 0
finishAction()
}
},
onCancel: finishAction,
})
}
function finishAction() {
actionBusy.value = false
actionID.value = 0
}
function showRequestError(error: unknown, fallback: string) {
if (axios.isAxiosError(error)) {
const status = error.response?.status

View File

@@ -114,12 +114,16 @@
</a-card>
<a-drawer
v-model:visible="formVisible"
:visible="formVisible"
:title="editing ? '编辑文档' : '新建文档'"
:width="720"
:ok-loading="saving"
@ok="saveDocument"
:closable="!saving"
:mask-closable="!saving"
:esc-to-close="!saving"
:on-before-cancel="canCloseForm"
unmount-on-close
@cancel="closeForm"
@update:visible="handleFormVisibleChange"
>
<a-form ref="formRef" :model="form" layout="vertical">
<a-form-item label="标题" field="title" :rules="[{ required: true, message: '请输入标题' }]">
@@ -134,7 +138,7 @@
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="公共分类" field="category_id">
<a-form-item label="公共分类" field="category_id" :rules="[{ required: true, message: '请选择启用分类' }]">
<a-select v-model="form.category_id" placeholder="请选择启用分类" allow-clear allow-search>
<a-option v-for="category in activeCategoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
@@ -143,6 +147,9 @@
</a-form-item>
</a-col>
</a-row>
<a-alert v-if="originalCategoryUnavailable" type="warning" class="category-warning">
原分类已停用或不可用请重新选择启用分类
</a-alert>
<a-form-item label="描述" field="description">
<a-textarea v-model="form.description" :max-length="1000" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
@@ -162,12 +169,18 @@
<a-textarea v-model="form.remarks" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button :disabled="saving" @click="closeForm">取消</a-button>
<a-button type="primary" :loading="saving" :disabled="saving" @click="saveDocument">保存</a-button>
</a-space>
</template>
</a-drawer>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import axios from 'axios'
import dayjs from 'dayjs'
import { Message, Modal } from '@arco-design/web-vue'
@@ -227,6 +240,7 @@ const documents = ref<Document[]>([])
const loading = ref(false)
const pagination = reactive({ current: 1, pageSize: 20, total: 0 })
let listSequence = 0
let readyListSequence = 0
const categories = ref<CategoryTreeNode[]>([])
const categoryOptions = computed<CategoryOption[]>(() => flattenCategories(categories.value))
@@ -242,12 +256,24 @@ const formVisible = ref(false)
const formRef = ref<FormInstance>()
const editing = ref<Document | null>(null)
const saving = ref(false)
const form = reactive({
const originalCategoryUnavailable = ref(false)
const form = reactive<{
title: string
description: string
content: string
type: DocumentType
category_id?: number
sub_category: string
keywords: string
tags: string
detection_point_ids: string
remarks: string
}>({
title: '',
description: '',
content: '',
type: 'common' as DocumentType,
category_id: 0,
category_id: undefined,
sub_category: '',
keywords: '',
tags: '',
@@ -300,6 +326,10 @@ async function loadCategories() {
/** 加载当前范围文档,忽略过期请求。 */
async function loadDocuments() {
const sequence = ++listSequence
readyListSequence = 0
++detailSequence
selected.value = null
detailLoading.value = false
loading.value = true
try {
const reply = await fetchDocumentList({
@@ -314,7 +344,7 @@ async function loadDocuments() {
if (reply.code !== 0) throw new Error(reply.message || '获取文档列表失败')
documents.value = reply.details?.data || []
pagination.total = reply.details?.total || 0
if (selected.value && !documents.value.some((item) => item.id === selected.value?.id)) selected.value = null
readyListSequence = sequence
} catch (error) {
if (sequence === listSequence) {
documents.value = []
@@ -327,11 +357,19 @@ async function loadDocuments() {
}
async function selectDocument(document: Document) {
const listContext = listSequence
if (loading.value || readyListSequence !== listContext || !documents.value.some((item) => item.id === document.id)) return
const sequence = ++detailSequence
detailLoading.value = true
try {
const reply = await fetchDocumentDetail(document.id)
if (sequence !== detailSequence) return
if (
sequence !== detailSequence ||
listContext !== listSequence ||
readyListSequence !== listContext ||
!documents.value.some((item) => item.id === document.id)
)
return
if (reply.code !== 0) throw new Error(reply.message || '获取文档详情失败')
selected.value = reply.details
} catch (error) {
@@ -370,7 +408,7 @@ function resetForm() {
description: '',
content: '',
type: 'common',
category_id: 0,
category_id: undefined,
sub_category: '',
keywords: '',
tags: '',
@@ -380,20 +418,24 @@ function resetForm() {
}
function openCreate() {
if (saving.value) return
editing.value = null
originalCategoryUnavailable.value = false
resetForm()
formVisible.value = true
}
function openEdit() {
if (!selected.value || !canEdit(selected.value)) return
if (!selected.value || !canEdit(selected.value) || saving.value) return
editing.value = selected.value
const categoryAvailable = activeCategoryOptions.value.some((category) => category.id === selected.value?.category_id)
originalCategoryUnavailable.value = !categoryAvailable
Object.assign(form, {
title: selected.value.title,
description: selected.value.description,
content: selected.value.content,
type: selected.value.type,
category_id: selected.value.category_id,
category_id: categoryAvailable ? selected.value.category_id : undefined,
sub_category: selected.value.sub_category,
keywords: selected.value.keywords,
tags: selected.value.tags,
@@ -404,31 +446,57 @@ function openEdit() {
}
function closeForm() {
if (saving.value) return
formVisible.value = false
editing.value = null
originalCategoryUnavailable.value = false
formRef.value?.clearValidate()
}
async function saveDocument() {
if (await formRef.value?.validate()) return false
if (saving.value) return
saving.value = true
try {
const payload = { ...form, title: form.title.trim(), content: form.content.trim() }
if (await formRef.value?.validate()) return
const categoryID = form.category_id
if (!categoryID || !activeCategoryOptions.value.some((category) => category.id === categoryID)) {
originalCategoryUnavailable.value = Boolean(editing.value)
Message.warning('请选择启用分类')
return
}
const wasCreate = !editing.value
const payload = { ...form, category_id: categoryID, title: form.title.trim(), content: form.content.trim() }
const reply = editing.value ? await updateDocument({ ...payload, id: editing.value.id }) : await createDocument(payload)
if (reply.code !== 0) throw new Error(reply.message || '保存文档失败')
Message.success(editing.value ? '文档已更新' : '文档已创建')
selected.value = reply.details
closeForm()
const savedID = reply.details.id
formVisible.value = false
editing.value = null
originalCategoryUnavailable.value = false
formRef.value?.clearValidate()
if (wasCreate && scope.value !== 'my') {
scope.value = 'my'
filters.status = undefined
pagination.current = 1
}
await loadDocuments()
return true
const current = documents.value.find((item) => item.id === savedID)
if (current) await selectDocument(current)
} catch (error) {
showRequestError(error, '保存文档失败')
return false
} finally {
saving.value = false
}
}
function canCloseForm() {
return !saving.value
}
function handleFormVisibleChange(visible: boolean) {
if (!visible) closeForm()
}
function confirmPublish() {
if (!selected.value) return
const document = selected.value
@@ -441,8 +509,8 @@ function confirmPublish() {
if (reply.code !== 0) throw new Error(reply.message || '提交审核失败')
Message.success(reply.message || '已提交审核')
await loadDocuments()
if (documents.value.some((item) => item.id === document.id)) await selectDocument(document)
else selected.value = null
const current = documents.value.find((item) => item.id === document.id)
if (current) await selectDocument(current)
} catch (error) {
showRequestError(error, '提交审核失败')
}
@@ -511,6 +579,28 @@ function formatTime(value: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
}
watch(
activeCategoryOptions,
(options) => {
const document = editing.value
if (!formVisible.value || !document || !originalCategoryUnavailable.value) return
if (options.some((category) => category.id === document.category_id)) {
form.category_id = document.category_id
originalCategoryUnavailable.value = false
}
},
{ deep: true }
)
watch(
() => form.category_id,
(categoryID) => {
if (categoryID && activeCategoryOptions.value.some((category) => category.id === categoryID)) {
originalCategoryUnavailable.value = false
}
}
)
onMounted(async () => {
await Promise.all([loadCategories(), loadDocuments()])
})
@@ -591,6 +681,9 @@ onMounted(async () => {
.metadata {
margin-bottom: 20px;
}
.category-warning {
margin-bottom: 16px;
}
.document-content {
white-space: pre-wrap;
word-break: break-word;

View File

@@ -3,7 +3,9 @@
<Breadcrumb :items="['知识管理', '回收站']" />
<a-card class="general-card" :bordered="false">
<template #title>回收站</template>
<template #extra><a-button :loading="loading" :disabled="!canManage" @click="loadTrash">刷新</a-button></template>
<template #extra>
<a-button :loading="loading" :disabled="!canManage || actionBusy" @click="loadTrash">刷新</a-button>
</template>
<a-alert v-if="!canManage" type="warning">当前账号没有知识内容管理权限</a-alert>
<template v-else>
@@ -15,7 +17,7 @@
</a-option>
</a-select>
</a-form-item>
<a-form-item><a-button type="primary" html-type="submit">查询</a-button></a-form-item>
<a-form-item><a-button type="primary" html-type="submit" :disabled="actionBusy">查询</a-button></a-form-item>
</a-form>
<a-table
@@ -34,9 +36,24 @@
<template #deleted_time="{ record }">{{ formatTime(record.deleted_time) }}</template>
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" @click="openDetail(record)">查看</a-button>
<a-button type="text" size="small" :loading="actionID === record.id" @click="confirmRestore(record)">恢复</a-button>
<a-button type="text" size="small" status="danger" :loading="actionID === record.id" @click="confirmDelete(record)">
<a-button type="text" size="small" :disabled="actionBusy" @click="openDetail(record)">查看</a-button>
<a-button
type="text"
size="small"
:loading="actionBusy && actionID === record.id"
:disabled="actionBusy"
@click="confirmRestore(record)"
>
恢复
</a-button>
<a-button
type="text"
size="small"
status="danger"
:loading="actionBusy && actionID === record.id"
:disabled="actionBusy"
@click="confirmDelete(record)"
>
彻底删除
</a-button>
</a-space>
@@ -116,6 +133,7 @@ const loading = ref(false)
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showTotal: true, showPageSize: true })
let listSequence = 0
const actionID = ref(0)
const actionBusy = ref(false)
const detailVisible = ref(false)
const detailRecord = ref<TrashRecord | null>(null)
@@ -188,26 +206,33 @@ function openDetail(record: TrashRecord) {
function confirmRestore(record: TrashRecord) {
if (!canManage.value) return Message.error('无操作权限')
if (actionBusy.value) return
actionBusy.value = true
actionID.value = record.id
Modal.confirm({
title: '确认恢复',
content: `确认恢复${getResourceTypeText(record.resource_type)}${record.resource_name}」吗?`,
onOk: () => runAction(record, 'restore'),
onCancel: finishAction,
})
}
function confirmDelete(record: TrashRecord) {
if (!canManage.value) return Message.error('无操作权限')
if (actionBusy.value) return
actionBusy.value = true
actionID.value = record.id
Modal.confirm({
title: '确认彻底删除',
content: `彻底删除「${record.resource_name}」后不可恢复,是否继续?`,
okText: '彻底删除',
okButtonProps: { status: 'danger' },
onOk: () => runAction(record, 'delete'),
onCancel: finishAction,
})
}
async function runAction(record: TrashRecord, action: 'restore' | 'delete') {
actionID.value = record.id
try {
const reply = action === 'restore' ? await restoreTrash(record.id) : await deleteTrash(record.id)
if (reply.code !== 0) throw new Error(reply.message || (action === 'restore' ? '恢复失败' : '彻底删除失败'))
@@ -217,10 +242,15 @@ async function runAction(record: TrashRecord, action: 'restore' | 'delete') {
} catch (error) {
showRequestError(error, action === 'restore' ? '恢复失败' : '彻底删除失败')
} finally {
actionID.value = 0
finishAction()
}
}
function finishAction() {
actionBusy.value = false
actionID.value = 0
}
function showRequestError(error: unknown, fallback: string) {
if (axios.isAxiosError(error)) {
const status = error.response?.status

View File

@@ -4,7 +4,7 @@
<a-card class="general-card" :bordered="false">
<template #title>待审核内容</template>
<template #extra>
<a-button :loading="loading" :disabled="!canReview" @click="loadReviews">刷新</a-button>
<a-button :loading="loading" :disabled="!canReview || reviewLocked" @click="loadReviews">刷新</a-button>
</template>
<a-alert v-if="!canReview" type="warning">当前账号没有内容审核权限</a-alert>
@@ -18,7 +18,7 @@
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">查询</a-button>
<a-button type="primary" html-type="submit" :disabled="reviewLocked">查询</a-button>
</a-form-item>
</a-form>
@@ -42,11 +42,25 @@
<template #created_at="{ record }">{{ formatTime(record.resource.created_at) }}</template>
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" @click="openDetail(record)">查看</a-button>
<a-button type="text" size="small" status="success" :loading="isActionLoading(record)" @click="confirmApprove(record)">
<a-button type="text" size="small" :disabled="reviewLocked" @click="openDetail(record)">查看</a-button>
<a-button
type="text"
size="small"
status="success"
:loading="isActionLoading(record)"
:disabled="reviewLocked"
@click="confirmApprove(record)"
>
通过
</a-button>
<a-button type="text" size="small" status="danger" :loading="isActionLoading(record)" @click="openReject(record)">
<a-button
type="text"
size="small"
status="danger"
:loading="isActionLoading(record)"
:disabled="reviewLocked"
@click="openReject(record)"
>
拒绝
</a-button>
</a-space>
@@ -88,7 +102,16 @@
</a-spin>
</a-drawer>
<a-modal v-model:visible="rejectVisible" title="拒绝审核" :ok-loading="actionLoading" @ok="confirmReject" @cancel="closeReject">
<a-modal
:visible="rejectVisible"
title="拒绝审核"
:closable="!actionLoading"
:mask-closable="!actionLoading"
:esc-to-close="!actionLoading"
:on-before-cancel="canCloseReject"
@cancel="closeReject"
@update:visible="handleRejectVisibleChange"
>
<a-alert v-if="rejectRecord && isSelfReview(rejectRecord)" type="warning" class="self-review-alert">
这是你创建的内容提交后还会再次确认并记录自审日志
</a-alert>
@@ -103,6 +126,20 @@
/>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button :disabled="actionLoading" @click="closeReject">取消</a-button>
<a-button
type="primary"
status="danger"
:loading="actionLoading"
:disabled="actionLoading || confirmationOpen"
@click="confirmReject"
>
确认拒绝
</a-button>
</a-space>
</template>
</a-modal>
</div>
</template>
@@ -163,6 +200,8 @@ let detailSequence = 0
const actionLoading = ref(false)
const actionKey = ref('')
const reviewLocked = ref(false)
const confirmationOpen = ref(false)
const rejectVisible = ref(false)
const rejectRecord = ref<ReviewListItem | null>(null)
const rejectForm = reactive({ reason: '' })
@@ -251,13 +290,16 @@ async function openDetail(record: ReviewListItem) {
}
function confirmApprove(record: ReviewListItem) {
if (reviewLocked.value) return
if (!canOperate(record)) return Message.error('无操作权限')
reviewLocked.value = true
const openFinalConfirm = () =>
Modal.confirm({
title: '确认审核通过',
content: `确认通过${getResourceTypeText(record.type)}${resourceTitle(record)}」吗?`,
okText: '确认通过',
onOk: () => runApprove(record),
onCancel: releaseReviewLock,
})
if (isSelfReview(record)) {
@@ -266,6 +308,7 @@ function confirmApprove(record: ReviewListItem) {
content: '这是你创建的内容。继续操作将记录自审日志,是否进入最终确认?',
okText: '继续',
onOk: openFinalConfirm,
onCancel: releaseReviewLock,
})
return
}
@@ -285,58 +328,86 @@ async function runApprove(record: ReviewListItem) {
} finally {
actionLoading.value = false
actionKey.value = ''
releaseReviewLock()
}
}
function openReject(record: ReviewListItem) {
if (reviewLocked.value) return
if (!canOperate(record)) return Message.error('无操作权限')
reviewLocked.value = true
rejectRecord.value = record
rejectForm.reason = ''
rejectVisible.value = true
}
function confirmReject() {
if (!rejectRecord.value) return false
if (!rejectRecord.value || actionLoading.value || confirmationOpen.value) return
if (!rejectForm.reason.trim()) {
Message.warning('请输入拒绝原因')
return false
return
}
const record = rejectRecord.value
if (isSelfReview(record)) {
confirmationOpen.value = true
Modal.confirm({
title: '再次确认自审拒绝',
content: '确认拒绝自己创建的内容吗?本次操作将记录自审日志。',
okText: '确认拒绝',
onOk: () => runReject(record, rejectForm.reason.trim()),
onOk: () => {
confirmationOpen.value = false
return runReject(record, rejectForm.reason.trim())
},
onCancel: () => {
confirmationOpen.value = false
},
})
return false
return
}
return runReject(record, rejectForm.reason.trim())
runReject(record, rejectForm.reason.trim())
}
async function runReject(record: ReviewListItem, reason: string) {
let succeeded = false
actionLoading.value = true
actionKey.value = rowKey(record)
try {
const reply = await rejectReview({ resource_type: record.type, id: record.resource.id, reason })
if (reply.code !== 0) throw new Error(reply.message || '拒绝审核失败')
Message.success('审核已拒绝')
closeReject()
rejectVisible.value = false
rejectRecord.value = null
rejectForm.reason = ''
await loadReviews()
return true
succeeded = true
} catch (error) {
showRequestError(error, '拒绝审核失败')
return false
} finally {
actionLoading.value = false
actionKey.value = ''
if (succeeded) releaseReviewLock()
}
}
function closeReject() {
if (actionLoading.value) return
rejectVisible.value = false
rejectRecord.value = null
rejectForm.reason = ''
confirmationOpen.value = false
releaseReviewLock()
}
function canCloseReject() {
return !actionLoading.value
}
function handleRejectVisibleChange(visible: boolean) {
if (!visible) closeReject()
}
function releaseReviewLock() {
reviewLocked.value = false
}
function isActionLoading(record: ReviewListItem) {