718 lines
24 KiB
Vue
718 lines
24 KiB
Vue
<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>
|