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 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[] children: CategoryTreeNode[]
} }

View File

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

View File

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

View File

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

View File

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

View File

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