Files
X-Financial/web/src/views/scripts/PoliciesView.js

684 lines
21 KiB
JavaScript
Raw Normal View History

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