diff --git a/src/api/kb/category.ts b/src/api/kb/category.ts
index cabeee7..f12def6 100644
--- a/src/api/kb/category.ts
+++ b/src/api/kb/category.ts
@@ -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[]
}
diff --git a/src/views/ops/pages/kb/categories/index.vue b/src/views/ops/pages/kb/categories/index.vue
index f9323b9..564370a 100644
--- a/src/views/ops/pages/kb/categories/index.vue
+++ b/src/views/ops/pages/kb/categories/index.vue
@@ -5,11 +5,11 @@
- 新增一级分类
- 刷新
+ 新增一级分类
+ 刷新
-
+
{{ selectedCategory?.name || '分类详情' }}
-
+
新增子分类
- 编辑
- 删除
+ 编辑
+
+ 删除
+
-
- {{ selectedCategory.name }}
-
-
- {{ selectedCategory.status === 'active' ? '启用' : '停用' }}
-
-
- 第 {{ selectedCategory.level }} 级
- {{ selectedCategory.sort_order }}
- {{ selectedCategory.doc_count }}
- {{ selectedCategory.faq_count }}
- {{ selectedCategory.creator_name || '-' }}
- {{ formatTime(selectedCategory.updated_at) }}
- {{ selectedCategory.description || '-' }}
- {{ selectedCategory.remarks || '-' }}
-
-
+
+
+ {{ selectedCategory.name }}
+
+
+ {{ selectedCategory.status === 'active' ? '启用' : '停用' }}
+
+
+ 第 {{ selectedCategory.level }} 级
+ {{ selectedCategory.sort_order }}
+ {{ selectedCategory.doc_count }}
+ {{ selectedCategory.faq_count }}
+ {{ selectedCategory.creator_name || '-' }}
+ {{ formatTime(selectedCategory.updated_at) }}
+ {{ selectedCategory.description || '-' }}
+ {{ selectedCategory.remarks || '-' }}
+
+
+
@@ -103,6 +122,12 @@
+
+
+ 取消
+ 保存
+
+
@@ -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([])
const selectedKeys = ref([])
const selectedCategory = ref(null)
-let requestSequence = 0
+const selectedTreeID = ref(0)
+let treeSequence = 0
+let detailSequence = 0
const treeData = computed(() => 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()
const editingCategory = ref(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) {
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) {
diff --git a/src/views/ops/pages/kb/favorite/index.vue b/src/views/ops/pages/kb/favorite/index.vue
index e55bdf2..6e80dc7 100644
--- a/src/views/ops/pages/kb/favorite/index.vue
+++ b/src/views/ops/pages/kb/favorite/index.vue
@@ -3,7 +3,7 @@
我的收藏
- 刷新
+ 刷新
@@ -12,7 +12,7 @@
FAQ
- 查询
+ 查询
{{ formatTime(record.created_at) }}
- 查看
-
+ 查看
+
取消收藏
@@ -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
diff --git a/src/views/ops/pages/kb/items/index.vue b/src/views/ops/pages/kb/items/index.vue
index e8f985f..f282149 100644
--- a/src/views/ops/pages/kb/items/index.vue
+++ b/src/views/ops/pages/kb/items/index.vue
@@ -114,12 +114,16 @@
@@ -134,7 +138,7 @@
-
+
{{ category.label }}
@@ -143,6 +147,9 @@
+
+ 原分类已停用或不可用,请重新选择启用分类
+
@@ -162,12 +169,18 @@
+
+
+ 取消
+ 保存
+
+