Files
X-Financial/web/src/views/scripts/PoliciesView.js
caoxiaozhu 88ff04bef8 feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
2026-05-22 16:00:19 +08:00

684 lines
21 KiB
JavaScript
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.
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
deleteKnowledgeDocument,
fetchKnowledgeDocument,
fetchKnowledgeDocumentBlob,
fetchKnowledgeLibrary,
fetchKnowledgeOnlyOfficeConfig,
syncKnowledgeLibrary,
uploadKnowledgeDocument
} from '../../services/knowledge.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
import { isManagerUser } from '../../utils/accessControl.js'
import {
buildExcelPreviewTable,
buildPreviewMetaLine,
buildPreviewSecondaryMetaLine
} from './policiesPreviewFormatters.js'
import {
canUseOnlyOfficePreview,
resolveKnowledgePreviewMode,
shouldRenderOnlyOfficeHost,
shouldRenderOnlyOfficePreview
} from './knowledgePreviewMode.js'
import { resolveKnowledgePreviewLayoutState } from './knowledgePreviewLayout.js'
import {
resolveInitialKnowledgeFolder,
resolveKnowledgeFolderIcon
} from './knowledgeFolderSelection.js'
import { buildOnlyOfficePreviewConfig } from './onlyOfficePreviewConfig.js'
const KNOWLEDGE_POLL_INTERVAL_MS = 5000
function triggerFileDownload(blob, filename) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
let bodyOverflowSnapshot = ''
let bodyOverscrollBehaviorSnapshot = ''
let libraryPollTimer = 0
function setBodyScrollLocked(isLocked) {
if (typeof document === 'undefined') {
return
}
const { body } = document
if (!body) {
return
}
if (isLocked) {
if (body.dataset.knowledgePreviewLocked === 'true') {
return
}
bodyOverflowSnapshot = body.style.overflow
bodyOverscrollBehaviorSnapshot = body.style.overscrollBehavior
body.style.overflow = 'hidden'
body.style.overscrollBehavior = 'contain'
body.dataset.knowledgePreviewLocked = 'true'
return
}
if (body.dataset.knowledgePreviewLocked !== 'true') {
return
}
body.style.overflow = bodyOverflowSnapshot
body.style.overscrollBehavior = bodyOverscrollBehaviorSnapshot
delete body.dataset.knowledgePreviewLocked
bodyOverflowSnapshot = ''
bodyOverscrollBehaviorSnapshot = ''
}
export default {
name: 'PoliciesView',
components: {
ConfirmDialog,
TableLoadingState
},
emits: ['summary-change'],
setup(_, { emit }) {
const { currentUser } = useSystemState()
const { toast } = useToast()
const documentSearch = ref('')
const activeFolder = ref('')
const folders = ref([])
const documents = ref([])
const selectedDocument = ref(null)
const pageSizeOpen = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const loading = ref(false)
const uploadInput = ref(null)
const uploading = ref(false)
const deletingId = ref('')
const syncingFolder = ref(false)
const deleteDialogOpen = ref(false)
const deleteTargetDocument = ref(null)
const previewLoading = ref(false)
const previewBlobUrl = ref('')
const previewError = ref('')
const onlyOfficeLoading = ref(false)
const onlyOfficeError = ref('')
const onlyOfficeAvailable = ref(false)
const onlyOfficeEditor = ref(null)
const onlyOfficeHostId = ref('knowledge-onlyoffice-preview')
const onlyOfficeReadyTimeoutId = ref(0)
const currentPreviewPageIndex = ref(0)
const previewDialogPanel = ref(null)
const isAdmin = computed(() => isManagerUser(currentUser.value))
const uploadHint = computed(() =>
isAdmin.value
? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本'
: '当前账号只有查阅权限,上传、删除和修改仅管理员可用'
)
const filteredFolders = computed(() => folders.value)
const filteredDocuments = computed(() => {
const key = documentSearch.value.trim()
return documents.value.filter((doc) => {
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
const matchesSearch = key ? doc.name.includes(key) : true
return inFolder && matchesSearch
})
})
const activeFolderDocuments = computed(() =>
documents.value.filter((doc) => (activeFolder.value ? doc.folder === activeFolder.value : true))
)
const activeFolderIngestStats = computed(() => {
const stats = {
total: activeFolderDocuments.value.length,
pending: 0,
syncing: 0,
ingested: 0,
failed: 0
}
for (const doc of activeFolderDocuments.value) {
const stateCode = Number(doc?.stateCode || 0)
if (stateCode === 2) {
stats.syncing += 1
} else if (stateCode === 3) {
stats.ingested += 1
} else if (stateCode === 4) {
stats.failed += 1
} else {
stats.pending += 1
}
}
return stats
})
const knowledgeSyncButtonLabel = computed(() => {
if (syncingFolder.value || activeFolderIngestStats.value.syncing > 0) {
return '归纳中...'
}
return '知识归纳'
})
const knowledgeSyncHint = computed(() => {
const stats = activeFolderIngestStats.value
if (!activeFolder.value) {
return '请选择一个固定知识目录后再触发归纳。'
}
if (!stats.total) {
return '当前目录暂无文档,上传后即可进行知识归纳。'
}
if (stats.syncing > 0) {
return `当前目录有 ${stats.syncing} 份文档正在归纳,完成后会自动刷新状态。`
}
if (stats.pending > 0 || stats.failed > 0) {
return `当前目录待归纳 ${stats.pending} 份,需重试 ${stats.failed} 份。`
}
return `当前目录 ${stats.ingested} 份文档已归纳,可手动触发一次增量检查。`
})
const canTriggerKnowledgeSync = computed(
() =>
isAdmin.value
&& Boolean(activeFolder.value)
&& activeFolderIngestStats.value.total > 0
&& !syncingFolder.value
&& activeFolderIngestStats.value.syncing === 0
)
const totalCount = computed(() => filteredDocuments.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleDocuments = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredDocuments.value.slice(start, start + pageSize.value)
})
const activePreviewPage = computed(() => {
const pages = selectedDocument.value?.previewPages || []
return pages[currentPreviewPageIndex.value] || pages[0] || null
})
const previewMetaLine = computed(() => buildPreviewMetaLine(selectedDocument.value))
const previewSecondaryMetaLine = computed(() =>
buildPreviewSecondaryMetaLine(selectedDocument.value, activePreviewPage.value)
)
const previewLayoutState = computed(() =>
resolveKnowledgePreviewLayoutState(selectedDocument.value)
)
const previewMode = computed(() =>
resolveKnowledgePreviewMode(selectedDocument.value, {
onlyOfficeAvailable: onlyOfficeAvailable.value
})
)
const shouldRenderOnlyOffice = computed(() =>
shouldRenderOnlyOfficePreview(selectedDocument.value, {
onlyOfficeLoading: onlyOfficeLoading.value,
onlyOfficeAvailable: onlyOfficeAvailable.value,
onlyOfficeError: onlyOfficeError.value
})
)
const shouldRenderOnlyOfficeHostNode = computed(() =>
shouldRenderOnlyOfficeHost(selectedDocument.value, {
onlyOfficeLoading: onlyOfficeLoading.value,
onlyOfficeAvailable: onlyOfficeAvailable.value,
onlyOfficeError: onlyOfficeError.value
})
)
const excelPreviewTable = computed(() =>
selectedDocument.value?.previewKind === 'table'
? buildExcelPreviewTable(activePreviewPage.value)
: { headers: [], rows: [] }
)
function revokePreviewBlob() {
if (previewBlobUrl.value) {
URL.revokeObjectURL(previewBlobUrl.value)
previewBlobUrl.value = ''
}
}
function destroyOnlyOfficeEditor() {
if (onlyOfficeReadyTimeoutId.value) {
window.clearTimeout(onlyOfficeReadyTimeoutId.value)
onlyOfficeReadyTimeoutId.value = 0
}
if (onlyOfficeEditor.value?.destroyEditor) {
onlyOfficeEditor.value.destroyEditor()
}
onlyOfficeEditor.value = null
}
async function mountOnlyOfficeEditor(documentId) {
onlyOfficeLoading.value = true
onlyOfficeError.value = ''
onlyOfficeAvailable.value = false
destroyOnlyOfficeEditor()
try {
const payload = await fetchKnowledgeOnlyOfficeConfig(documentId)
await loadOnlyOfficeApi(payload.documentServerUrl)
await nextTick()
if (!window.DocsAPI?.DocEditor) {
throw new Error('ONLYOFFICE 编辑器未正确加载。')
}
onlyOfficeHostId.value = `knowledge-onlyoffice-preview-${documentId}`
await nextTick()
const config = buildOnlyOfficePreviewConfig(payload.config, {
viewportHeight: window.innerHeight
})
const upstreamEvents = config.events || {}
config.events = {
...upstreamEvents,
onAppReady(event) {
if (onlyOfficeReadyTimeoutId.value) {
window.clearTimeout(onlyOfficeReadyTimeoutId.value)
onlyOfficeReadyTimeoutId.value = 0
}
onlyOfficeAvailable.value = true
onlyOfficeLoading.value = false
upstreamEvents.onAppReady?.(event)
},
onError(event) {
if (onlyOfficeReadyTimeoutId.value) {
window.clearTimeout(onlyOfficeReadyTimeoutId.value)
onlyOfficeReadyTimeoutId.value = 0
}
const errorCode = event?.data?.errorCode
const errorDescription = event?.data?.errorDescription
const message = errorDescription
? `ONLYOFFICE 预览失败:${errorDescription}`
: `ONLYOFFICE 预览失败${errorCode ? `(错误码 ${errorCode}` : '。'}`
onlyOfficeError.value = message
onlyOfficeLoading.value = false
console.error('ONLYOFFICE onError', event)
toast(message)
upstreamEvents.onError?.(event)
}
}
onlyOfficeEditor.value = new window.DocsAPI.DocEditor(onlyOfficeHostId.value, config)
onlyOfficeReadyTimeoutId.value = window.setTimeout(() => {
if (!onlyOfficeAvailable.value && !onlyOfficeError.value) {
onlyOfficeError.value = 'ONLYOFFICE 预览初始化超时。请检查浏览器是否拦截了 iframe 或混合内容。'
onlyOfficeLoading.value = false
toast(onlyOfficeError.value)
}
}, 10000)
return true
} catch (error) {
onlyOfficeError.value = error.message || 'ONLYOFFICE 预览加载失败。'
toast(onlyOfficeError.value)
return false
} finally {
if (onlyOfficeError.value) {
onlyOfficeLoading.value = false
}
}
}
async function loadLibrary(options = {}) {
loading.value = true
try {
const payload = await fetchKnowledgeLibrary()
folders.value = payload.folders || []
documents.value = payload.documents || []
emit('summary-change', { totalDocuments: documents.value.length })
activeFolder.value = resolveInitialKnowledgeFolder(folders.value, activeFolder.value)
if (options.preserveSelection && selectedDocument.value?.id) {
const nextDocument = documents.value.find((doc) => doc.id === selectedDocument.value.id)
const exists = Boolean(nextDocument)
if (!exists) {
closePreview()
} else {
selectedDocument.value = {
...selectedDocument.value,
...nextDocument
}
}
}
syncLibraryPolling()
} catch (error) {
emit('summary-change', { totalDocuments: 0 })
toast(error.message || '知识库加载失败。')
} finally {
loading.value = false
}
}
async function selectDocument(documentId) {
previewLoading.value = true
previewError.value = ''
onlyOfficeError.value = ''
onlyOfficeAvailable.value = false
revokePreviewBlob()
destroyOnlyOfficeEditor()
try {
const payload = await fetchKnowledgeDocument(documentId)
selectedDocument.value = payload
currentPreviewPageIndex.value = 0
if (canUseOnlyOfficePreview(payload)) {
previewLoading.value = false
await mountOnlyOfficeEditor(documentId)
return
}
if (payload.previewKind === 'pdf' || payload.previewKind === 'image') {
const blob = await fetchKnowledgeDocumentBlob(documentId, 'inline')
previewBlobUrl.value = URL.createObjectURL(blob)
}
} catch (error) {
previewError.value = error.message || '预览加载失败。'
toast(previewError.value)
} finally {
previewLoading.value = false
}
}
async function handleDownload(document) {
try {
const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment')
triggerFileDownload(blob, document.name)
} catch (error) {
toast(error.message || '下载失败。')
}
}
function patchDocumentState(documentId, patch) {
documents.value = documents.value.map((doc) =>
doc.id === documentId ? { ...doc, ...patch } : doc
)
if (selectedDocument.value?.id === documentId) {
selectedDocument.value = {
...selectedDocument.value,
...patch
}
}
syncLibraryPolling()
}
function hasSyncingDocuments() {
return documents.value.some((doc) => Number(doc?.stateCode || 0) === 2)
}
function stopLibraryPolling() {
if (libraryPollTimer) {
window.clearInterval(libraryPollTimer)
libraryPollTimer = 0
}
}
function startLibraryPolling() {
stopLibraryPolling()
libraryPollTimer = window.setInterval(() => {
loadLibrary({ preserveSelection: true })
}, KNOWLEDGE_POLL_INTERVAL_MS)
}
function syncLibraryPolling() {
if (hasSyncingDocuments()) {
startLibraryPolling()
return
}
stopLibraryPolling()
}
async function handleKnowledgeSync() {
if (!canTriggerKnowledgeSync.value) {
return
}
syncingFolder.value = true
try {
const payload = await syncKnowledgeLibrary({
folder: activeFolder.value,
documentIds: [],
force: false
})
const queuedIds = Array.isArray(payload?.document_ids) ? payload.document_ids : []
for (const documentId of queuedIds) {
patchDocumentState(documentId, {
stateCode: 2,
state: '\u5f52\u7eb3\u4e2d',
stateTone: 'warning',
ingestTime: ''
})
}
await loadLibrary({ preserveSelection: true })
toast(payload?.summary || '\u77e5\u8bc6\u5f52\u7eb3\u4efb\u52a1\u5df2\u63d0\u4ea4\u3002')
} catch (error) {
await loadLibrary({ preserveSelection: true })
toast(error.message || '\u77e5\u8bc6\u5f52\u7eb3\u89e6\u53d1\u5931\u8d25\u3002')
} finally {
syncingFolder.value = false
}
}
function triggerUpload() {
if (!isAdmin.value || uploading.value) {
return
}
uploadInput.value?.click()
}
async function uploadFiles(fileList) {
const files = Array.from(fileList || []).filter(Boolean)
if (!files.length || !activeFolder.value || !isAdmin.value) {
return
}
uploading.value = true
try {
let latestDocumentId = ''
for (const file of files) {
const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file })
latestDocumentId = payload.id
}
await loadLibrary({ preserveSelection: true })
toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。')
if (latestDocumentId) {
await selectDocument(latestDocumentId)
}
} catch (error) {
toast(error.message || '上传失败。')
} finally {
uploading.value = false
if (uploadInput.value) {
uploadInput.value.value = ''
}
}
}
async function handleFileInput(event) {
await uploadFiles(event.target.files)
}
async function handleDrop(event) {
if (!isAdmin.value) {
return
}
await uploadFiles(event.dataTransfer?.files)
}
async function handleDelete(document) {
if (!isAdmin.value || deletingId.value) {
return
}
deleteTargetDocument.value = document
deleteDialogOpen.value = true
}
function closeDeleteDialog() {
if (deletingId.value) {
return
}
deleteDialogOpen.value = false
deleteTargetDocument.value = null
}
async function confirmDeleteDocument() {
const document = deleteTargetDocument.value
if (!document || !isAdmin.value || deletingId.value) {
return
}
deletingId.value = document.id
try {
await deleteKnowledgeDocument(document.id)
deleteDialogOpen.value = false
deleteTargetDocument.value = null
if (selectedDocument.value?.id === document.id) {
closePreview()
}
await loadLibrary()
toast('知识库文件已删除。')
} catch (error) {
toast(error.message || '删除失败。')
} finally {
deletingId.value = ''
}
}
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
function closePreview() {
selectedDocument.value = null
previewLoading.value = false
previewError.value = ''
currentPreviewPageIndex.value = 0
revokePreviewBlob()
destroyOnlyOfficeEditor()
onlyOfficeLoading.value = false
onlyOfficeError.value = ''
onlyOfficeAvailable.value = false
}
function handleWindowKeydown(event) {
if (event.key === 'Escape' && selectedDocument.value) {
closePreview()
}
}
function selectPreviewPage(index) {
currentPreviewPageIndex.value = index
}
watch(filteredDocuments, () => {
currentPage.value = 1
pageSizeOpen.value = false
if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) {
closePreview()
}
})
watch(activeFolder, () => {
closePreview()
})
watch(
() => previewLayoutState.value.isPreviewModalOpen,
async (isAnyOverlayOpen) => {
setBodyScrollLocked(isAnyOverlayOpen)
if (previewLayoutState.value.isPreviewModalOpen) {
await nextTick()
previewDialogPanel.value?.focus?.()
}
}
)
onMounted(() => {
loadLibrary()
window.addEventListener('keydown', handleWindowKeydown)
})
onBeforeUnmount(() => {
revokePreviewBlob()
destroyOnlyOfficeEditor()
setBodyScrollLocked(false)
stopLibraryPolling()
window.removeEventListener('keydown', handleWindowKeydown)
})
return {
activeFolder,
activeFolderIngestStats,
activePreviewPage,
canTriggerKnowledgeSync,
changePageSize,
closePreview,
closeDeleteDialog,
confirmDeleteDocument,
excelPreviewTable,
currentPage,
currentPreviewPageIndex,
deleteDialogOpen,
deleteTargetDocument,
deletingId,
documentSearch,
filteredFolders,
handleDelete,
handleDownload,
handleDrop,
handleFileInput,
handleKnowledgeSync,
isAdmin,
knowledgeSyncButtonLabel,
knowledgeSyncHint,
loading,
pageSize,
pageSizeOpen,
pageSizes,
onlyOfficeError,
onlyOfficeHostId,
onlyOfficeLoading,
previewDialogPanel,
previewLayoutState,
previewMode,
previewMetaLine,
previewSecondaryMetaLine,
previewBlobUrl,
previewError,
previewLoading,
shouldRenderOnlyOffice,
shouldRenderOnlyOfficeHostNode,
selectDocument,
selectPreviewPage,
selectedDocument,
resolveKnowledgeFolderIcon,
syncingFolder,
totalCount,
totalPages,
triggerUpload,
uploadHint,
uploadInput,
uploading,
visibleDocuments
}
}
}