fix: 收紧知识库页面交互状态
This commit is contained in:
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user