import { computed, nextTick, ref } from 'vue' import { fetchAgentAssetSpreadsheetBlob, fetchAgentAssetSpreadsheetChangeRecords, fetchAgentAssetSpreadsheetOnlyOfficeConfig, importAgentAssetSpreadsheetContent } from '../../services/agentAssets.js' import { loadOnlyOfficeApi } from '../../services/onlyoffice.js' import { buildOnlyOfficeEditorConfig } from './onlyOfficePreviewConfig.js' import { buildSpreadsheetChangeRecordKey } from './auditViewRuntimeModel.js' import { formatDateTime, formatSpreadsheetChangeSummary, normalizeText, resolveDiffChangeMeta } from './auditViewModel.js' export function useAuditSpreadsheetEditor({ selectedSkill, selectedSkillUsesSpreadsheet, canEditSpreadsheetInline, canUploadSpreadsheet, canDownloadSpreadsheet, selectedSpreadsheetFileName, actionState, refreshCurrentAssets, loadSelectedAssetDetail, resolveActor, toast }) { const spreadsheetUploadInput = ref(null) const spreadsheetOnlyOfficeLoading = ref(false) const spreadsheetOnlyOfficeError = ref('') const spreadsheetOnlyOfficeEditor = ref(null) const spreadsheetOnlyOfficeReady = ref(false) const spreadsheetOnlyOfficeHostId = ref('audit-rule-onlyoffice') const spreadsheetChangeRecordsByAsset = ref({}) const spreadsheetChangeDetailOpen = ref(false) const selectedSpreadsheetChangeRecord = ref(null) let spreadsheetOnlyOfficeMountSeq = 0 let spreadsheetOnlyOfficeLoadTimer = null let spreadsheetOnlyOfficeHadLocalEdits = false let spreadsheetOnlyOfficeSyncSeq = 0 let spreadsheetOnlyOfficeChangePollTimer = null const selectedSpreadsheetChangeRecords = computed(() => { if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id) { return [] } return (spreadsheetChangeRecordsByAsset.value[selectedSkill.value.id] || []) .filter((item) => item?.changed_at) .map((item) => { const sheetNames = [ ...(Array.isArray(item.sheet_changes) ? item.sheet_changes.map((change) => normalizeText(change.sheet_name)) : []), ...(Array.isArray(item.cell_changes) ? item.cell_changes.map((change) => normalizeText(change.sheet_name)) : []) ].filter(Boolean) const changedSheetNames = [...new Set(sheetNames)] const previewChanges = Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : [] return { ...item, time: formatDateTime(item.changed_at), summary: formatSpreadsheetChangeSummary(item.summary), changeCountLabel: item.changed_cell_count ? `${item.changed_cell_count} 处改动` : `${item.changed_sheet_count || changedSheetNames.length || 0} 个工作表`, changedSheetNames, sheetPreview: changedSheetNames.slice(0, 4), remainingSheetCount: Math.max(changedSheetNames.length - 4, 0), previewChanges, remainingChangeCount: Math.max((item.changed_cell_count || 0) - previewChanges.length, 0) } }) }) const selectedSpreadsheetChangeSheetRows = computed(() => Array.isArray(selectedSpreadsheetChangeRecord.value?.sheet_changes) ? selectedSpreadsheetChangeRecord.value.sheet_changes.map((item) => ({ ...item, meta: resolveDiffChangeMeta(item.change_type) })) : [] ) const selectedSpreadsheetChangeCellRows = computed(() => Array.isArray(selectedSpreadsheetChangeRecord.value?.cell_changes) ? selectedSpreadsheetChangeRecord.value.cell_changes.map((item) => ({ ...item, meta: resolveDiffChangeMeta(item.change_type) })) : [] ) function stopSpreadsheetOnlyOfficeChangeSync() { if (spreadsheetOnlyOfficeChangePollTimer) { window.clearTimeout(spreadsheetOnlyOfficeChangePollTimer) spreadsheetOnlyOfficeChangePollTimer = null } } function destroySpreadsheetOnlyOfficeEditor() { if (spreadsheetOnlyOfficeLoadTimer) { window.clearTimeout(spreadsheetOnlyOfficeLoadTimer) spreadsheetOnlyOfficeLoadTimer = null } stopSpreadsheetOnlyOfficeChangeSync() spreadsheetOnlyOfficeHadLocalEdits = false spreadsheetOnlyOfficeSyncSeq += 1 if (spreadsheetOnlyOfficeEditor.value?.destroyEditor) { spreadsheetOnlyOfficeEditor.value.destroyEditor() } spreadsheetOnlyOfficeEditor.value = null spreadsheetOnlyOfficeReady.value = false } function getLatestSpreadsheetChangeKey(assetId) { return buildSpreadsheetChangeRecordKey(spreadsheetChangeRecordsByAsset.value[assetId] || []) } async function loadSpreadsheetChangeRecords(assetId) { if (!assetId) { return } const payload = await fetchAgentAssetSpreadsheetChangeRecords(assetId, 30) spreadsheetChangeRecordsByAsset.value = { ...spreadsheetChangeRecordsByAsset.value, [assetId]: Array.isArray(payload) ? payload : [] } } async function refreshSpreadsheetChangeRecordsAfterSave(assetId, previousLatestKey = '', attempt = 0) { const normalizedAssetId = normalizeText(assetId) if (!normalizedAssetId || selectedSkill.value?.id !== normalizedAssetId) { return false } await loadSpreadsheetChangeRecords(normalizedAssetId) const nextLatestKey = getLatestSpreadsheetChangeKey(normalizedAssetId) if (nextLatestKey && nextLatestKey !== previousLatestKey) { return true } if (attempt >= 9) { return false } await new Promise((resolve) => window.setTimeout(resolve, 800)) return refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestKey, attempt + 1) } function scheduleSpreadsheetOnlyOfficeChangeSync(assetId, attempt = 0) { const normalizedAssetId = normalizeText(assetId) if (!normalizedAssetId) { return } const syncSeq = ++spreadsheetOnlyOfficeSyncSeq stopSpreadsheetOnlyOfficeChangeSync() const previousLatestChangeKey = getLatestSpreadsheetChangeKey(normalizedAssetId) const runSync = async () => { if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) { return } try { const changeRecordRefreshed = await refreshSpreadsheetChangeRecordsAfterSave( normalizedAssetId, previousLatestChangeKey ) if (changeRecordRefreshed) { await refreshCurrentAssets() stopSpreadsheetOnlyOfficeChangeSync() return } } catch { // 临时轮询失败不打断编辑器,继续在窗口期内重试。 } if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) { return } if (attempt >= 29) { return } spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => { scheduleSpreadsheetOnlyOfficeChangeSync(normalizedAssetId, attempt + 1) }, 2000) } spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => { runSync().catch(() => {}) }, attempt === 0 ? 800 : 2000) } function isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId) { return ( mountSeq !== spreadsheetOnlyOfficeMountSeq || !selectedSkillUsesSpreadsheet.value || selectedSkill.value?.id !== assetId || selectedSkill.value?.loading ) } async function mountSpreadsheetOnlyOfficeEditor(retryAttempt = 0) { if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id || selectedSkill.value?.loading) { destroySpreadsheetOnlyOfficeEditor() return } const mountSeq = ++spreadsheetOnlyOfficeMountSeq const assetId = selectedSkill.value.id const editable = canEditSpreadsheetInline.value spreadsheetOnlyOfficeLoading.value = true spreadsheetOnlyOfficeError.value = '' spreadsheetOnlyOfficeReady.value = false destroySpreadsheetOnlyOfficeEditor() try { const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId) if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) { return } await loadOnlyOfficeApi(payload.documentServerUrl) if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) { return } if (!window.DocsAPI?.DocEditor) { throw new Error('表格编辑器未正确加载。') } // ONLYOFFICE 会改写宿主节点;每次挂载使用新 id 避免复用脏容器。 spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${assetId}-${mountSeq}` await nextTick() if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) { return } const config = buildOnlyOfficeEditorConfig(payload.config, { viewportHeight: window.innerHeight, editable, fillContainer: true }) const upstreamEvents = config.events || {} spreadsheetOnlyOfficeLoadTimer = window.setTimeout(() => { if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) { return } if (retryAttempt < 1) { destroySpreadsheetOnlyOfficeEditor() spreadsheetOnlyOfficeLoading.value = true window.setTimeout(() => { mountSpreadsheetOnlyOfficeEditor(retryAttempt + 1).catch(() => {}) }, 600) return } spreadsheetOnlyOfficeError.value = '表格加载超时,请退出详情后重试。' spreadsheetOnlyOfficeLoading.value = false destroySpreadsheetOnlyOfficeEditor() }, 15000) config.events = { ...upstreamEvents, onAppReady(event) { if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) { return } if (spreadsheetOnlyOfficeLoadTimer) { window.clearTimeout(spreadsheetOnlyOfficeLoadTimer) spreadsheetOnlyOfficeLoadTimer = null } spreadsheetOnlyOfficeReady.value = true spreadsheetOnlyOfficeLoading.value = false upstreamEvents.onAppReady?.(event) }, onError(event) { if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) { return } if (spreadsheetOnlyOfficeLoadTimer) { window.clearTimeout(spreadsheetOnlyOfficeLoadTimer) spreadsheetOnlyOfficeLoadTimer = null } const errorCode = event?.data?.errorCode const errorDescription = event?.data?.errorDescription spreadsheetOnlyOfficeError.value = errorDescription ? `表格加载失败:${errorDescription}` : `表格加载失败${errorCode ? `(错误码 ${errorCode})` : '。'}` spreadsheetOnlyOfficeLoading.value = false upstreamEvents.onError?.(event) }, onDocumentStateChange(event) { const hasChanges = Boolean(event?.data) if (hasChanges) { spreadsheetOnlyOfficeHadLocalEdits = true if (!spreadsheetOnlyOfficeChangePollTimer) { scheduleSpreadsheetOnlyOfficeChangeSync(assetId) } } else if ( spreadsheetOnlyOfficeHadLocalEdits && editable && !isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId) ) { spreadsheetOnlyOfficeHadLocalEdits = false scheduleSpreadsheetOnlyOfficeChangeSync(assetId) } upstreamEvents.onDocumentStateChange?.(event) } } spreadsheetOnlyOfficeEditor.value = new window.DocsAPI.DocEditor( spreadsheetOnlyOfficeHostId.value, config ) if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) { destroySpreadsheetOnlyOfficeEditor() } } catch (error) { if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) { return } spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。' spreadsheetOnlyOfficeLoading.value = false toast(spreadsheetOnlyOfficeError.value) } } function triggerSpreadsheetUpload() { if (!canUploadSpreadsheet.value) { return } spreadsheetUploadInput.value?.click?.() } async function downloadSpreadsheetFile() { if (!canDownloadSpreadsheet.value || !selectedSkill.value?.id) { return } actionState.value = 'download-spreadsheet' try { const blob = await fetchAgentAssetSpreadsheetBlob( selectedSkill.value.id, 'attachment' ) const objectUrl = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = objectUrl anchor.download = selectedSpreadsheetFileName.value || '规则表.xlsx' document.body.appendChild(anchor) anchor.click() anchor.remove() URL.revokeObjectURL(objectUrl) } catch (error) { toast(error?.message || '规则表下载失败,请稍后重试。') } finally { actionState.value = '' } } async function uploadSpreadsheetFile(file) { if (!file || !selectedSkill.value?.id || !canUploadSpreadsheet.value) { return } actionState.value = 'upload-spreadsheet' try { await importAgentAssetSpreadsheetContent(selectedSkill.value.id, file, { actor: resolveActor() }) await refreshCurrentAssets() await loadSelectedAssetDetail(selectedSkill.value.id) await loadSpreadsheetChangeRecords(selectedSkill.value.id) toast(`已导入 ${file.name} 的表格内容,右侧会记录本次修改。`) } catch (error) { toast(error?.message || '规则表内容导入失败,请稍后重试。') } finally { actionState.value = '' spreadsheetUploadInput.value?.reset?.() } } async function handleSpreadsheetFileInput(event) { await uploadSpreadsheetFile(event?.target?.files?.[0] || null) } function openSpreadsheetChangeDetail(item) { if (!item?.changed_at) { return } selectedSpreadsheetChangeRecord.value = item spreadsheetChangeDetailOpen.value = true } function closeSpreadsheetChangeDetail() { spreadsheetChangeDetailOpen.value = false } return { spreadsheetUploadInput, spreadsheetOnlyOfficeLoading, spreadsheetOnlyOfficeError, spreadsheetOnlyOfficeReady, spreadsheetOnlyOfficeHostId, spreadsheetChangeDetailOpen, selectedSpreadsheetChangeRecord, selectedSpreadsheetChangeRecords, selectedSpreadsheetChangeSheetRows, selectedSpreadsheetChangeCellRows, destroySpreadsheetOnlyOfficeEditor, mountSpreadsheetOnlyOfficeEditor, triggerSpreadsheetUpload, downloadSpreadsheetFile, uploadSpreadsheetFile, handleSpreadsheetFileInput, loadSpreadsheetChangeRecords, openSpreadsheetChangeDetail, closeSpreadsheetChangeDetail } }