Files
front/src/views/ops/pages/kb/items/index.vue

718 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="document-page">
<Breadcrumb :items="['知识管理', '文档管理']" />
<a-card class="general-card" :bordered="false">
<div class="page-header">
<a-tabs v-model:active-key="scope" @change="handleScopeChange">
<a-tab-pane key="my" title="我的文档" />
<a-tab-pane key="all" title="全部文档" />
</a-tabs>
<a-space>
<a-button v-if="canCreate" type="primary" @click="openCreate">
<template #icon><icon-plus /></template>
新建文档
</a-button>
<a-button :loading="loading" @click="loadDocuments">
<template #icon><icon-refresh /></template>
刷新
</a-button>
</a-space>
</div>
<a-form :model="filters" layout="inline" class="filters" @submit-success="search">
<a-form-item label="关键词">
<a-input v-model="filters.keyword" placeholder="搜索标题、描述、正文或关键词" allow-clear style="width: 260px" />
</a-form-item>
<a-form-item label="公共分类">
<a-select v-model="filters.category_id" placeholder="全部分类" allow-clear allow-search style="width: 220px">
<a-option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}{{ category.status === 'inactive' ? '停用' : '' }}
</a-option>
</a-select>
</a-form-item>
<a-form-item v-if="scope === 'my'" label="状态">
<a-select v-model="filters.status" placeholder="全部状态" allow-clear style="width: 130px">
<a-option v-for="item in statusOptions" :key="item.value" :value="item.value">{{ item.label }}</a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">查询</a-button>
<a-button @click="resetFilters">重置</a-button>
</a-space>
</a-form-item>
</a-form>
<div class="content-layout">
<section class="document-list">
<a-spin :loading="loading" class="list-spin">
<a-empty v-if="!documents.length" :description="scope === 'my' ? '暂无我的文档' : '暂无已审核文档'" />
<button
v-for="document in documents"
:key="document.id"
type="button"
class="document-item"
:class="{ active: selected?.id === document.id }"
@click="selectDocument(document)"
>
<span class="document-title">{{ document.title }}</span>
<span class="document-meta">
<a-tag :color="statusColor(document.status)" size="small">{{ statusText(document.status) }}</a-tag>
<span>{{ categoryName(document.category_id) }}</span>
<span>{{ formatTime(document.updated_at) }}</span>
</span>
</button>
</a-spin>
<a-pagination
v-model:current="pagination.current"
:page-size="pagination.pageSize"
:total="pagination.total"
simple
@change="changePage"
/>
</section>
<section class="document-detail">
<a-spin :loading="detailLoading" class="detail-spin">
<template v-if="selected">
<div class="detail-header">
<div>
<h2>{{ selected.title }}</h2>
<a-space wrap>
<a-tag :color="statusColor(selected.status)">{{ statusText(selected.status) }}</a-tag>
<a-tag>{{ typeText(selected.type) }}</a-tag>
<span>{{ selected.author_name || `用户 ${selected.author_id}` }}</span>
</a-space>
</div>
<a-space wrap>
<a-button :loading="favoriteLoading" @click="toggleFavorite">
<template #icon>
<icon-star-fill v-if="selected.is_favorited" />
<icon-star v-else />
</template>
{{ selected.is_favorited ? '取消收藏' : '收藏' }}
</a-button>
<a-button v-if="canEdit(selected)" type="primary" @click="openEdit">编辑</a-button>
<a-button v-if="canSubmit(selected)" @click="confirmPublish">提交审核</a-button>
<a-button v-if="canDelete(selected)" status="danger" @click="confirmDelete">删除</a-button>
</a-space>
</div>
<a-descriptions :column="3" bordered size="small" class="metadata">
<a-descriptions-item label="文档编号">{{ selected.doc_no }}</a-descriptions-item>
<a-descriptions-item label="公共分类">{{ categoryName(selected.category_id) }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatTime(selected.updated_at) }}</a-descriptions-item>
<a-descriptions-item label="描述" :span="3">{{ selected.description || '-' }}</a-descriptions-item>
<a-descriptions-item label="关键词" :span="3">{{ selected.keywords || '-' }}</a-descriptions-item>
</a-descriptions>
<h3>正文</h3>
<pre class="document-content">{{ selected.content }}</pre>
</template>
<a-empty v-else description="请从左侧选择文档" />
</a-spin>
</section>
</div>
</a-card>
<a-drawer
:visible="formVisible"
:title="editing ? '编辑文档' : '新建文档'"
:width="720"
: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: '请输入标题' }]">
<a-input v-model="form.title" :max-length="200" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="类型" field="type">
<a-select v-model="form.type">
<a-option v-for="item in documentTypeOptions" :key="item.value" :value="item.value">{{ item.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<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 }}
</a-option>
</a-select>
</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>
<a-form-item label="正文" field="content" :rules="[{ required: true, message: '请输入正文' }]">
<a-textarea v-model="form.content" :auto-size="{ minRows: 16, maxRows: 30 }" />
</a-form-item>
<a-form-item label="关键词" field="keywords">
<a-input v-model="form.keywords" :max-length="500" placeholder="多个关键词可用逗号分隔" />
</a-form-item>
<a-form-item label="标签" field="tags">
<a-input v-model="form.tags" placeholder='JSON 数组,例如 ["运维"]' />
</a-form-item>
<a-form-item label="检测点 ID" field="detection_point_ids">
<a-input v-model="form.detection_point_ids" placeholder="JSON 数组,例如 [1,2]" />
</a-form-item>
<a-form-item label="备注" field="remarks">
<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, watch } from 'vue'
import axios from 'axios'
import dayjs from 'dayjs'
import { Message, Modal } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue'
import { IconPlus, IconRefresh, IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
import {
createDocument,
deleteDocument,
documentTypeOptions,
favoriteDocument,
fetchDocumentDetail,
fetchDocumentList,
publishDocument,
unfavoriteDocument,
updateDocument,
type Document,
type DocumentScope,
type DocumentStatus,
type DocumentType,
} from '@/api/kb/document'
import { fetchCategoryTree, type CategoryStatus, type CategoryTreeNode } from '@/api/kb/category'
import usePermissionCodes from '@/hooks/usePermissionCodes'
import { useUserStore } from '@/store'
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
interface CategoryOption {
id: number
label: string
status: CategoryStatus
}
const statusOptions: Array<{ label: string; value: DocumentStatus }> = [
{ label: '草稿', value: 'draft' },
{ label: '待审核', value: 'published' },
{ label: '已审核', value: 'reviewed' },
{ label: '已拒绝', value: 'rejected' },
]
const userStore = useUserStore()
const { hasPermission } = usePermissionCodes()
const canCreate = computed(() => hasPermission('kb:content:create'))
const canManage = computed(() => hasPermission('kb:content:manage'))
const currentUserID = computed(() => {
let source = userStore.$state.userInfo as Record<string, unknown> | null | undefined
if (!source || typeof source !== 'object') source = SafeStorage.get<Record<string, unknown>>(AppStorageKey.USER_INFO)
const value = Number(source?.user_id ?? source?.id)
return Number.isFinite(value) ? value : 0
})
const scope = ref<DocumentScope>('my')
const filters = reactive<{ keyword: string; category_id?: number; status?: DocumentStatus }>({
keyword: '',
category_id: undefined,
status: undefined,
})
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))
const activeCategoryOptions = computed(() => categoryOptions.value.filter((item) => item.status === 'active'))
const categoryLabels = computed(() => new Map(categoryOptions.value.map((item) => [item.id, item.label])))
const selected = ref<Document | null>(null)
const detailLoading = ref(false)
let detailSequence = 0
const favoriteLoading = ref(false)
const formVisible = ref(false)
const formRef = ref<FormInstance>()
const editing = ref<Document | null>(null)
const saving = ref(false)
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: undefined,
sub_category: '',
keywords: '',
tags: '',
detection_point_ids: '',
remarks: '',
})
function flattenCategories(nodes: CategoryTreeNode[], prefix = ''): CategoryOption[] {
return nodes.flatMap((node) => {
const label = prefix ? `${prefix} / ${node.name}` : node.name
return [{ id: node.id, label, status: node.status }, ...flattenCategories(node.children || [], label)]
})
}
function categoryName(id: number) {
return categoryLabels.value.get(id) || (id ? `分类 ${id}` : '未分类')
}
/** 按服务端规则判断文档是否可修改。 */
function canMutate(document: Document) {
if (document.status === 'published') return false
if (document.status === 'reviewed') return canManage.value
if (document.status !== 'draft' && document.status !== 'rejected') return false
return canManage.value || (canCreate.value && currentUserID.value > 0 && document.author_id === currentUserID.value)
}
function canEdit(document: Document) {
return canMutate(document)
}
function canSubmit(document: Document) {
return (document.status === 'draft' || document.status === 'rejected') && canMutate(document)
}
function canDelete(document: Document) {
return canMutate(document)
}
async function loadCategories() {
try {
const reply = await fetchCategoryTree()
if (reply.code !== 0) throw new Error(reply.message || '获取分类失败')
categories.value = reply.details || []
} catch (error) {
categories.value = []
showRequestError(error, '获取分类失败')
}
}
/** 加载当前范围文档,忽略过期请求。 */
async function loadDocuments() {
const sequence = ++listSequence
readyListSequence = 0
++detailSequence
selected.value = null
detailLoading.value = false
loading.value = true
try {
const reply = await fetchDocumentList({
page: pagination.current,
page_size: pagination.pageSize,
scope: scope.value,
keyword: filters.keyword.trim() || undefined,
category_id: filters.category_id,
status: scope.value === 'my' ? filters.status : undefined,
})
if (sequence !== listSequence) return
if (reply.code !== 0) throw new Error(reply.message || '获取文档列表失败')
documents.value = reply.details?.data || []
pagination.total = reply.details?.total || 0
readyListSequence = sequence
} catch (error) {
if (sequence === listSequence) {
documents.value = []
pagination.total = 0
showRequestError(error, '获取文档列表失败')
}
} finally {
if (sequence === listSequence) loading.value = false
}
}
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 ||
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) {
if (sequence === detailSequence) showRequestError(error, '获取文档详情失败')
} finally {
if (sequence === detailSequence) detailLoading.value = false
}
}
function handleScopeChange() {
pagination.current = 1
selected.value = null
if (scope.value === 'all') filters.status = undefined
loadDocuments()
}
function search() {
pagination.current = 1
loadDocuments()
}
function resetFilters() {
Object.assign(filters, { keyword: '', category_id: undefined, status: undefined })
pagination.current = 1
loadDocuments()
}
function changePage(page: number) {
pagination.current = page
loadDocuments()
}
function resetForm() {
Object.assign(form, {
title: '',
description: '',
content: '',
type: 'common',
category_id: undefined,
sub_category: '',
keywords: '',
tags: '',
detection_point_ids: '',
remarks: '',
})
}
function openCreate() {
if (saving.value) return
editing.value = null
originalCategoryUnavailable.value = false
resetForm()
formVisible.value = true
}
function openEdit() {
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: categoryAvailable ? selected.value.category_id : undefined,
sub_category: selected.value.sub_category,
keywords: selected.value.keywords,
tags: selected.value.tags,
detection_point_ids: selected.value.detection_point_ids,
remarks: selected.value.remarks,
})
formVisible.value = true
}
function closeForm() {
if (saving.value) return
formVisible.value = false
editing.value = null
originalCategoryUnavailable.value = false
formRef.value?.clearValidate()
}
async function saveDocument() {
if (saving.value) return
saving.value = true
try {
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 ? '文档已更新' : '文档已创建')
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()
const current = documents.value.find((item) => item.id === savedID)
if (current) await selectDocument(current)
} catch (error) {
showRequestError(error, '保存文档失败')
} 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
Modal.confirm({
title: '提交审核',
content: `确认提交「${document.title}」审核吗?提交后将变为只读。`,
onOk: async () => {
try {
const reply = await publishDocument(document.id)
if (reply.code !== 0) throw new Error(reply.message || '提交审核失败')
Message.success(reply.message || '已提交审核')
await loadDocuments()
const current = documents.value.find((item) => item.id === document.id)
if (current) await selectDocument(current)
} catch (error) {
showRequestError(error, '提交审核失败')
}
},
})
}
function confirmDelete() {
if (!selected.value) return
const document = selected.value
Modal.confirm({
title: '确认删除',
content: `确认删除「${document.title}」吗?删除后可在回收站恢复。`,
onOk: async () => {
try {
const reply = await deleteDocument(document.id)
if (reply.code !== 0) throw new Error(reply.message || '删除文档失败')
Message.success('文档已移入回收站')
selected.value = null
await loadDocuments()
} catch (error) {
showRequestError(error, '删除文档失败')
}
},
})
}
async function toggleFavorite() {
if (!selected.value || favoriteLoading.value) return
const documentID = selected.value.id
const wasFavorited = selected.value.is_favorited
const listContext = listSequence
const readyListContext = readyListSequence
const detailContext = detailSequence
const isCurrentContext = () =>
selected.value?.id === documentID &&
listSequence === listContext &&
readyListSequence === readyListContext &&
detailSequence === detailContext
favoriteLoading.value = true
try {
const reply = wasFavorited ? await unfavoriteDocument(documentID) : await favoriteDocument(documentID)
if (reply.code !== 0) throw new Error(reply.message || '收藏操作失败')
if (!isCurrentContext() || !selected.value) return
selected.value = { ...selected.value, is_favorited: !wasFavorited }
Message.success(wasFavorited ? '已取消收藏' : '已收藏')
} catch (error) {
if (isCurrentContext()) showRequestError(error, '收藏操作失败')
} finally {
favoriteLoading.value = false
}
}
function showRequestError(error: unknown, fallback: string) {
if (axios.isAxiosError(error)) {
const status = error.response?.status
const message = (error.response?.data as { message?: string } | undefined)?.message
if (status === 403) return Message.error('无操作权限')
if (status === 400 || status === 409) return Message.error(message || fallback)
}
Message.error(error instanceof Error && error.message ? error.message : fallback)
}
function statusText(status: DocumentStatus) {
return { draft: '草稿', published: '待审核', reviewed: '已审核', rejected: '已拒绝' }[status]
}
function statusColor(status: DocumentStatus) {
return { draft: 'gray', published: 'orange', reviewed: 'green', rejected: 'red' }[status]
}
function typeText(type: DocumentType) {
return documentTypeOptions.find((item) => item.value === type)?.label || type
}
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()])
})
</script>
<style scoped lang="less">
.document-page {
padding: 0 20px 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.filters {
margin-bottom: 16px;
}
.content-layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 16px;
min-height: 560px;
}
.document-list {
display: flex;
flex-direction: column;
gap: 12px;
border-right: 1px solid var(--color-border);
padding-right: 16px;
}
.list-spin,
.detail-spin {
width: 100%;
}
.document-item {
width: 100%;
border: 1px solid transparent;
background: none;
border-radius: 6px;
padding: 12px;
text-align: left;
cursor: pointer;
color: inherit;
}
.document-item:hover {
background: var(--color-fill-2);
}
.document-item.active {
background: rgb(var(--primary-1));
border-color: rgb(var(--primary-3));
}
.document-title {
display: block;
font-weight: 600;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-meta {
display: flex;
gap: 8px;
align-items: center;
color: var(--color-text-3);
font-size: 12px;
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.detail-header h2 {
margin: 0 0 8px;
}
.metadata {
margin-bottom: 20px;
}
.category-warning {
margin-bottom: 16px;
}
.document-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.8;
font-family: inherit;
background: var(--color-fill-1);
padding: 16px;
border-radius: 6px;
}
@media (max-width: 1000px) {
.content-layout {
grid-template-columns: 1fr;
}
.document-list {
border-right: 0;
border-bottom: 1px solid var(--color-border);
padding: 0 0 16px;
}
}
</style>