import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' import { deleteKnowledgeDocument, fetchKnowledgeDocument, fetchKnowledgeDocumentBlob, fetchLlmWikiDocumentDetail, fetchKnowledgeLibrary, fetchKnowledgeOnlyOfficeConfig, syncKnowledgeDocumentToLlmWiki, updateLlmWikiDocumentSummary, 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 } from './knowledgeFolderSelection.js' import { buildOnlyOfficePreviewConfig } from './onlyOfficePreviewConfig.js' 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 = '' 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 }, 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 ingestingId = ref('') 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 llmWikiDialogOpen = ref(false) const llmWikiDialogPanel = ref(null) const llmWikiLoading = ref(false) const llmWikiSaving = ref(false) const llmWikiError = ref('') const llmWikiDocument = ref(null) const llmWikiSummaryDraft = ref('') 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 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 exists = documents.value.some((doc) => doc.id === selectedDocument.value.id) if (!exists) { closePreview() } } if (options.preserveSelection && llmWikiDocument.value?.document_id) { const exists = documents.value.some((doc) => doc.id === llmWikiDocument.value.document_id) if (!exists) { closeLlmWikiSummary() } } } 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 } } } function resolveIngestActionLabel(document) { if (ingestingId.value === document.id) { return '归纳中' } return Number(document?.stateCode || 0) === 3 ? '重新归纳' : '归纳' } function resolveIngestActionTitle(document) { const action = resolveIngestActionLabel(document) if (action === '归纳中') { return 'Hermes 正在将当前文档归纳到 LLM Wiki' } if (action === '重新归纳') { return '重新使用 Hermes 归纳当前文档到 LLM Wiki' } return '使用 Hermes 归纳当前文档到 LLM Wiki' } function canViewLlmWiki(document) { return isAdmin.value && Number(document?.stateCode || 0) === 3 } function resolveViewLlmWikiTitle(document) { if (!isAdmin.value) { return '仅管理员可查看 LLM Wiki 归纳内容' } if (Number(document?.stateCode || 0) === 2) { return 'Hermes 正在归纳当前文档,完成后可查看 LLM Wiki 知识总结' } if (Number(document?.stateCode || 0) === 4) { return '当前文档上次归纳失败,请重新归纳后再查看' } if (Number(document?.stateCode || 0) !== 3) { return '文档尚未完成归纳,暂无可查看的 LLM Wiki 知识总结' } return '查看并编辑当前文档的 LLM Wiki 归纳内容' } async function handleManualIngest(document) { if (!isAdmin.value || ingestingId.value || !document?.id) { return } ingestingId.value = document.id patchDocumentState(document.id, { stateCode: 2, state: '正归纳', stateTone: 'warning' }) try { const payload = await syncKnowledgeDocumentToLlmWiki({ folder: document.folder, documentId: document.id }) await loadLibrary({ preserveSelection: true }) if (selectedDocument.value?.id === document.id) { await selectDocument(document.id) } toast(payload.summary || 'Hermes 已完成文档归纳。') } catch (error) { patchDocumentState(document.id, { stateCode: 4, state: '归纳失败', stateTone: 'danger' }) toast(error.message || 'Hermes 归纳文档失败。') } finally { ingestingId.value = '' } } async function openLlmWikiSummary(document) { if (!canViewLlmWiki(document) || llmWikiLoading.value || !document?.id) { return } llmWikiDialogOpen.value = true llmWikiLoading.value = true llmWikiError.value = '' llmWikiDocument.value = null llmWikiSummaryDraft.value = '' try { const payload = await fetchLlmWikiDocumentDetail(document.id) llmWikiDocument.value = payload llmWikiSummaryDraft.value = payload.knowledge_summary_markdown || '' await nextTick() llmWikiDialogPanel.value?.focus?.() } catch (error) { llmWikiError.value = error.message || 'LLM Wiki 归纳内容加载失败。' toast(llmWikiError.value) } finally { llmWikiLoading.value = false } } function closeLlmWikiSummary() { if (llmWikiSaving.value) { return } llmWikiDialogOpen.value = false llmWikiLoading.value = false llmWikiError.value = '' llmWikiDocument.value = null llmWikiSummaryDraft.value = '' } async function saveLlmWikiSummary() { if (!isAdmin.value || !llmWikiDocument.value?.document_id || llmWikiSaving.value) { return } const summaryText = String(llmWikiSummaryDraft.value || '').trim() if (!summaryText) { toast('知识总结不能为空。') return } llmWikiSaving.value = true try { const payload = await updateLlmWikiDocumentSummary(llmWikiDocument.value.document_id, { knowledge_summary_markdown: summaryText }) llmWikiDocument.value = payload llmWikiError.value = '' llmWikiSummaryDraft.value = payload.knowledge_summary_markdown || summaryText toast('LLM Wiki 知识总结已保存。') } catch (error) { toast(error.message || 'LLM Wiki 知识总结保存失败。') } finally { llmWikiSaving.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() } if (llmWikiDocument.value?.document_id === document.id) { closeLlmWikiSummary() } 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' && llmWikiDialogOpen.value) { closeLlmWikiSummary() return } 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() closeLlmWikiSummary() }) watch( () => previewLayoutState.value.isPreviewModalOpen || llmWikiDialogOpen.value, async (isAnyOverlayOpen) => { setBodyScrollLocked(isAnyOverlayOpen) if (llmWikiDialogOpen.value) { await nextTick() llmWikiDialogPanel.value?.focus?.() return } if (previewLayoutState.value.isPreviewModalOpen) { await nextTick() previewDialogPanel.value?.focus?.() } } ) onMounted(() => { loadLibrary() window.addEventListener('keydown', handleWindowKeydown) }) onBeforeUnmount(() => { revokePreviewBlob() destroyOnlyOfficeEditor() setBodyScrollLocked(false) window.removeEventListener('keydown', handleWindowKeydown) }) return { activeFolder, activePreviewPage, changePageSize, closePreview, closeDeleteDialog, closeLlmWikiSummary, canViewLlmWiki, confirmDeleteDocument, excelPreviewTable, currentPage, currentPreviewPageIndex, deleteDialogOpen, deleteTargetDocument, deletingId, documentSearch, filteredFolders, handleDelete, handleDownload, handleDrop, handleFileInput, handleManualIngest, ingestingId, isAdmin, llmWikiDialogOpen, llmWikiDialogPanel, llmWikiDocument, llmWikiError, llmWikiLoading, llmWikiSaving, llmWikiSummaryDraft, loading, openLlmWikiSummary, pageSize, pageSizeOpen, pageSizes, onlyOfficeError, onlyOfficeHostId, onlyOfficeLoading, previewDialogPanel, previewLayoutState, previewMode, previewMetaLine, previewSecondaryMetaLine, previewBlobUrl, previewError, previewLoading, shouldRenderOnlyOffice, shouldRenderOnlyOfficeHostNode, selectDocument, selectPreviewPage, selectedDocument, resolveIngestActionLabel, resolveIngestActionTitle, resolveViewLlmWikiTitle, saveLlmWikiSummary, totalCount, totalPages, triggerUpload, uploadHint, uploadInput, uploading, visibleDocuments } } }