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 } } }