feat(电子表格): 电子表格编辑缓存

This commit is contained in:
wisonic-s
2026-08-25 22:55:23 +08:00
committed by wisonic-s
parent d8ed41f17a
commit 80aa75f437
12 changed files with 320 additions and 38 deletions

View File

@@ -8,8 +8,7 @@ ws = "ws"
guid = "guid"
yoy = "yoy"
Ein = "Ein"
cpy1 = "cpy1"
cpy2 = "cpy2"
getCellDatas = "getCellDatas"
cpy = "cpy"
Datas = "Datas"
[files]
extend-exclude = ["mapFiles/**", "docs"]

View File

@@ -5045,6 +5045,10 @@ export default {
'After deletion, all resources under this folder will be deleted, please proceed with caution.',
save_before_leave: 'Do you want to save before leaving?',
unsaved_changes: 'There are unsaved changes',
confirm_exit_without_save: 'Current changes have not been saved. Are you sure you want to exit?',
save_current_before_create_dataset: 'Please save the current spreadsheet first',
cache_use_tips:
'An unsaved spreadsheet from the previous session was found. Do you want to restore it?',
preview_select_tips: 'Please select a spreadsheet on the left',
loading: 'Loading...',
no_data: 'No data',

View File

@@ -4867,6 +4867,9 @@ export default {
delete_folder_confirm: '刪除後,此文件夾下的所有資源都會被刪除,請謹慎操作。',
save_before_leave: '離開前是否保存?',
unsaved_changes: '有未保存的更改',
confirm_exit_without_save: '目前的變更尚未儲存,確定退出嗎?',
save_current_before_create_dataset: '請先儲存目前電子表格',
cache_use_tips: '檢查到上次有電子表格未能正常儲存,是否使用上次未儲存的電子表格?',
preview_select_tips: '請在左側選擇電子表格',
loading: '加載中...',
no_data: '暫無資料',

View File

@@ -4891,6 +4891,9 @@ export default {
delete_folder_confirm: '删除后,此文件夹下的所有资源都会被删除,请谨慎操作。',
save_before_leave: '离开前是否保存?',
unsaved_changes: '有未保存的更改',
confirm_exit_without_save: '当前的更改尚未保存,确定退出吗?',
save_current_before_create_dataset: '请先保存当前电子表格',
cache_use_tips: '检查到上次有电子表格未能正常保存,是否使用上次未保存的电子表格?',
preview_select_tips: '请在左侧选择电子表格',
loading: '加载中...',
no_data: '暂无数据',

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus-secondary'
import { ElMessage } from 'element-plus-secondary'
import Icon from '@/components/icon-custom/src/Icon.vue'
import { useI18n } from '@/hooks/web/useI18n'
import icon_left_outlined from '@/assets/svg/icon_left_outlined.svg'
@@ -14,7 +14,6 @@ const { t } = useI18n()
const props = defineProps<{
name: string
hasChanges?: boolean
saving?: boolean
publishing?: boolean
recovering?: boolean
@@ -71,20 +70,7 @@ const closeEditCanvasName = () => {
}
const handleBack = () => {
if (props.hasChanges) {
ElMessageBox.confirm(t('components.sure_to_exit'), {
confirmButtonText: t('commons.confirm'),
cancelButtonText: t('commons.cancel'),
confirmButtonType: 'primary',
type: 'warning',
autofocus: false,
showClose: false
}).then(() => {
emit('back')
})
} else {
emit('back')
}
emit('back')
}
const handleSave = () => {

View File

@@ -6,7 +6,7 @@ import {
serializeSheetData
} from '../utils/univerConfig'
import type { UniverInstance } from '../utils/univerConfig'
import type { IWorkbookData } from '@univerjs/core'
import { ICommandService, type IWorkbookData } from '@univerjs/core'
import { IRenderManagerService } from '@univerjs/engine-render'
import type { SpreadsheetMode } from '../types/mode'
@@ -24,7 +24,7 @@ const props = withDefaults(
const emit = defineEmits<{
(e: 'update:modelValue', value: Partial<IWorkbookData>): void
(e: 'change', value: Partial<IWorkbookData>): void
(e: 'change'): void
(e: 'ready'): void
}>()
@@ -33,6 +33,7 @@ let univerInstance: UniverInstance | null
const isReady = ref(false)
const isInitializing = ref(false)
let rafId: number | null = null
let commandExecutedDisposable: { dispose: () => void } | undefined
// Internal data tracking
let workbookData: Partial<IWorkbookData> = props.modelValue || createDefaultWorkbookData()
@@ -69,6 +70,9 @@ const initUniver = async () => {
workbookData,
{ mode: props.mode }
)
const commandService = univerInstance.univer.__getInjector().get(ICommandService)
// Univer 的原生编辑、插件命令最终都会经过命令服务,由上层统一比较持久化快照。
commandExecutedDisposable = commandService.onCommandExecuted(() => emit('change'))
isReady.value = true
emit('ready')
} catch (error) {
@@ -132,6 +136,8 @@ const resize = () => {
* Dispose instance
*/
const disposeInstance = () => {
commandExecutedDisposable?.dispose()
commandExecutedDisposable = undefined
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null

View File

@@ -33,6 +33,9 @@ const emit = defineEmits<{
// 注入插件配置
const pluginConfig = inject('pluginConfig') as any
const datasetFields = inject<Ref<{ dimensions: FieldItemData[]; quotas: FieldItemData[] }>>('datasetFields')
const beforeCreateDataset = inject<() => boolean | Promise<boolean>>(
'beforeCreateSpreadsheetDataset'
)
const datasetSelectRef = ref<InstanceType<typeof DatasetSelect>>()
const collapsed = ref(false)
const curDatasetWeight = ref(0)
@@ -422,7 +425,11 @@ const openEmbeddedDatasetEditor = (datasetId?: string | number) => {
useEmitt().emitter.emit('changeCurrentComponent', 'DatasetEditor')
}
const handleAddDataset = () => {
const handleAddDataset = async () => {
if (beforeCreateDataset && !(await beforeCreateDataset())) {
return
}
if (isDataEaseBi.value) {
openEmbeddedDatasetEditor()
return

View File

@@ -5,6 +5,7 @@ import DatasetPanel from '../dataset-panel/index.vue'
import type { FieldItemData, PluginConfig, TablePluginConfig } from '../../types/plugin'
import { ElMessage } from 'element-plus-secondary'
import { pluginRuntimeRegistry } from '../../services/plugin-runtime.service'
import { dispatchSpreadsheetContentChanged } from '../../utils/events'
const univerApi = inject('univerApi') as any
@@ -87,6 +88,7 @@ const updateConfig = async (key: string, value: any) => {
}
updatePluginConfig(key, value)
dispatchSpreadsheetContentChanged()
if (key.startsWith('style.')) {
await runtime?.applyStyle?.({ univerApi: api, config })
}

View File

@@ -1,5 +1,15 @@
<script lang="ts" setup>
import { computed, ref, shallowRef, provide, type Component } from 'vue'
import {
computed,
ref,
shallowRef,
provide,
nextTick,
onBeforeMount,
onMounted,
onBeforeUnmount,
type Component
} from 'vue'
import router from '@/router'
import { ElMessage, ElMessageBox } from 'element-plus-secondary'
import { useI18n } from '@/hooks/web/useI18n'
@@ -20,7 +30,6 @@ import PluginRenderIndicator from './components/PluginRenderIndicator.vue'
import DatasetReplacementDialog from './components/dataset-replacement/DatasetReplacementDialog.vue'
import SpreadsheetPreviewOverlay from './components/SpreadsheetPreviewOverlay.vue'
import { useLocaleStoreWithOut } from '@/store/modules/locale'
import { nextTick, onBeforeMount } from 'vue'
import type { IWorkbookData } from '@univerjs/core'
import { ISidebarService } from '@univerjs/ui'
import { createDefaultWorkbookData, parseSheetData, serializeSheetData } from './utils/univerConfig'
@@ -34,6 +43,12 @@ import { pluginSnapshotCleaningService } from './services/plugin-snapshot-cleani
import { pluginRuntimeRegistry } from './services/plugin-runtime.service'
import { PluginRenderStatusService } from './plugins/DataEaseRuntimePlugin/services/table'
import { PluginAdapterManager } from './types/adapter'
import {
deleteSpreadsheetDraftCache,
getSpreadsheetDraftCache,
setSpreadsheetDraftCache,
type SpreadsheetDraftCache
} from './utils/draft-cache'
const { t } = useI18n()
const localeStore = useLocaleStoreWithOut()
@@ -74,7 +89,138 @@ const editorComponent = shallowRef<Component | null>(null)
const createLocalizedDefaultWorkbookData = () =>
createDefaultWorkbookData(t('spreadsheet.default_sheet_name'))
let baselineSheetData = ''
let baselineSpreadsheetName = ''
let draftCacheTimer: ReturnType<typeof setTimeout> | undefined
let draftCheckRevision = 0
let draftCheckPending = false
let discardAndLeave = false
const getCurrentRawSheetData = () =>
univerSheetRef.value?.getSheetData() ||
serializeSheetData(workbookData.value || createLocalizedDefaultWorkbookData())
const cleanSheetData = async (sheetData: string): Promise<string> => {
const snapshot = parseSheetData(sheetData) ?? createLocalizedDefaultWorkbookData()
const cleanedSnapshot = await pluginSnapshotCleaningService.clean(snapshot)
return serializeSheetData(cleanedSnapshot)
}
const updateSavedBaseline = (sheetData: string, name: string) => {
baselineSheetData = sheetData
baselineSpreadsheetName = name
}
const buildDraftCache = (sheetData: string): SpreadsheetDraftCache | undefined => {
if (routeMode.value !== 'edit' || !routeSheetId.value) {
return undefined
}
return {
sheetId: routeSheetId.value,
sourceVersion: spreadsheetVersion.value,
name: spreadsheetName.value,
remark: spreadsheetRemark.value,
sheetData,
cachedAt: Date.now()
}
}
const cacheCurrentDraft = (sheetData: string) => {
const cache = buildDraftCache(sheetData)
if (!cache) {
return
}
try {
setSpreadsheetDraftCache(cache)
} catch (error) {
// 本地存储空间不足不能影响正常编辑和保存。
console.warn('[Spreadsheet] Failed to cache spreadsheet draft:', error)
}
}
const syncDraftCache = async () => {
if (routeMode.value !== 'edit' || !routeSheetId.value || !baselineSheetData) {
return
}
const currentRevision = draftCheckRevision
try {
const currentSheetData = await cleanSheetData(getCurrentRawSheetData())
if (currentRevision !== draftCheckRevision) {
return
}
draftCheckPending = false
const contentChanged = currentSheetData !== baselineSheetData
const nameChanged = spreadsheetName.value !== baselineSpreadsheetName
hasChanges.value = contentChanged || nameChanged
if (hasChanges.value) {
cacheCurrentDraft(currentSheetData)
} else {
deleteSpreadsheetDraftCache(routeSheetId.value)
}
} catch (error) {
draftCheckPending = false
hasChanges.value = true
cacheCurrentDraft(getCurrentRawSheetData())
console.warn('[Spreadsheet] Failed to compare spreadsheet draft:', error)
}
}
const markSpreadsheetChanged = () => {
if (routeMode.value !== 'edit' || !routeSheetId.value) {
hasChanges.value = true
return
}
draftCheckRevision++
draftCheckPending = true
if (draftCacheTimer) {
clearTimeout(draftCacheTimer)
}
// 合并连续输入和样式操作,避免每条 Univer 命令都序列化整个工作簿。
draftCacheTimer = setTimeout(() => {
draftCacheTimer = undefined
void syncDraftCache()
}, 1000)
}
const flushDraftBeforeLeaving = async () => {
if (draftCacheTimer) {
clearTimeout(draftCacheTimer)
draftCacheTimer = undefined
}
while (draftCheckPending) {
await syncDraftCache()
}
}
const cacheRawDraftBeforeUnload = () => {
if (!hasChanges.value && !draftCheckPending) {
return
}
if (
!draftCheckPending &&
routeSheetId.value &&
getSpreadsheetDraftCache(routeSheetId.value)
) {
return
}
// 页面卸载阶段不能等待异步清理;恢复时会再次经过快照清理器。
cacheCurrentDraft(getCurrentRawSheetData())
}
const handleBeforeCreateDataset = async (): Promise<boolean> => {
if (routeMode.value !== 'edit' || !routeSheetId.value) {
ElMessage.warning(t('spreadsheet.save_current_before_create_dataset'))
return false
}
await flushDraftBeforeLeaving()
return true
}
provide('pluginConfig', currentPluginConfig)
provide('beforeCreateSpreadsheetDataset', handleBeforeCreateDataset)
const closeNativeSidebar = () => {
const injector = univerInstanceRef.value?.univer?.__getInjector?.()
@@ -102,6 +248,11 @@ useEmitt({
}
})
useEmitt({
name: SPREADSHEET_EVENTS.CONTENT_CHANGED,
callback: markSpreadsheetChanged
})
useEmitt({
name: SPREADSHEET_EVENTS.OPEN_PLUGIN_EDITOR,
callback: (payload: PluginEditPayload) => {
@@ -185,7 +336,7 @@ useEmitt({
if (nextConfig) {
currentPluginConfig.value = nextConfig
}
hasChanges.value = true
markSpreadsheetChanged()
}
})
@@ -205,13 +356,14 @@ const persistSpreadsheet = async (
saving.value = true
try {
await flushDraftBeforeLeaving()
const rawSheetDataStr =
univerSheetRef.value?.getSheetData() ||
JSON.stringify(workbookData.value || createLocalizedDefaultWorkbookData())
const cleanedSheetData = await pluginSnapshotCleaningService.clean(
JSON.parse(rawSheetDataStr)
)
const currentSheetDataStr = JSON.stringify(cleanedSheetData)
const currentSheetDataStr = serializeSheetData(cleanedSheetData)
const editor: SpreadsheetEditor = {
name: destination ? destination.name : spreadsheetName.value,
@@ -242,6 +394,12 @@ const persistSpreadsheet = async (
resourceGroupOptShow.value = false
spreadsheetName.value = destination.name
}
updateSavedBaseline(currentSheetDataStr, editor.name || spreadsheetName.value)
deleteSpreadsheetDraftCache(result.id)
// 保存请求期间工作簿仍可编辑,保存完成后重新比较,避免把后续输入误判为已保存。
draftCheckRevision++
draftCheckPending = true
await syncDraftCache()
return result
} catch (e) {
console.error(e)
@@ -334,10 +492,14 @@ const handleRecoverPublished = async () => {
spreadsheetVersion.value = result.version ?? spreadsheetVersion.value
loading.value = true
await nextTick()
workbookData.value = clearUniverProtectionResources(
const recoveredWorkbookData = clearUniverProtectionResources(
parseSheetData(result.sheetData) ?? createLocalizedDefaultWorkbookData()
)
const recoveredSheetData = await cleanSheetData(serializeSheetData(recoveredWorkbookData))
workbookData.value = parseSheetData(recoveredSheetData) ?? createLocalizedDefaultWorkbookData()
updateSavedBaseline(recoveredSheetData, spreadsheetName.value)
hasChanges.value = false
deleteSpreadsheetDraftCache(routeSheetId.value)
loading.value = false
ElMessage.success(t('spreadsheet.recover_publish_success'))
} catch (error) {
@@ -364,6 +526,7 @@ const handleCancelPublish = async () => {
status: SpreadsheetPublishStatus.Unpublished
})
spreadsheetStatus.value = result.status ?? SpreadsheetPublishStatus.Unpublished
spreadsheetVersion.value = result.version ?? spreadsheetVersion.value
ElMessage.success(t('spreadsheet.cancel_publish_success'))
} catch (error) {
console.error(error)
@@ -374,23 +537,29 @@ const handleCancelPublish = async () => {
}
const handleBack = async () => {
if (hasChanges.value) {
await flushDraftBeforeLeaving()
const isCreateMode = routeMode.value !== 'edit' || !routeSheetId.value
if (isCreateMode || hasChanges.value) {
try {
await ElMessageBox.confirm(
t('spreadsheet.save_before_leave'),
t('spreadsheet.confirm_exit_without_save'),
t('spreadsheet.unsaved_changes'),
{
confirmButtonText: t('spreadsheet.save'),
cancelButtonText: t('spreadsheet.discard'),
distinguishCancelAndClose: true
confirmButtonText: t('commons.confirm'),
cancelButtonText: t('commons.cancel'),
confirmButtonType: 'primary',
type: 'warning',
autofocus: false,
showClose: false
}
)
await handleSave()
} catch (e) {
// User chose to discard or closed dialog
} catch {
return
}
}
router.push('/spreadsheet/index')
discardAndLeave = true
deleteSpreadsheetDraftCache(routeSheetId.value)
await router.push('/spreadsheet/index')
}
const handleDatasetBinding = async (binding: DatasetBindDTO) => {
@@ -404,6 +573,7 @@ const handleDatasetUnbind = async (datasetId: number) => {
const handleRename = (name: string) => {
if (spreadsheetName.value === name) return
spreadsheetName.value = name
markSpreadsheetChanged()
}
const handleExport = () => {
@@ -481,7 +651,48 @@ onBeforeMount(async () => {
spreadsheetInfo.status ?? SpreadsheetPublishStatus.Unpublished
spreadsheetRemark.value = spreadsheetInfo.remark ?? ''
spreadsheetVersion.value = spreadsheetInfo.version ?? 1
workbookData.value = cleanedWorkbookData
const serverSheetData = serializeSheetData(cleanedWorkbookData)
const serverWorkbookData = cleanedWorkbookData
updateSavedBaseline(serverSheetData, spreadsheetInfo.name)
let nextWorkbookData = serverWorkbookData
const cachedDraft = getSpreadsheetDraftCache(routeSheetId.value)
if (cachedDraft && cachedDraft.sourceVersion === spreadsheetVersion.value) {
try {
const cachedWorkbookData = clearUniverProtectionResources(
parseSheetData(cachedDraft.sheetData) ?? createLocalizedDefaultWorkbookData()
)
const cachedSheetData = serializeSheetData(cachedWorkbookData)
const cacheHasChanges =
cachedSheetData !== serverSheetData || cachedDraft.name !== spreadsheetInfo.name
if (cacheHasChanges) {
await ElMessageBox.confirm(t('spreadsheet.cache_use_tips'), {
confirmButtonText: t('visualization.yes'),
cancelButtonText: t('visualization.no'),
confirmButtonType: 'primary',
type: 'warning',
autofocus: false,
showClose: false
})
nextWorkbookData = cachedWorkbookData
spreadsheetName.value = cachedDraft.name
spreadsheetRemark.value = cachedDraft.remark
hasChanges.value = true
// 工作簿完成恢复后,命令监听会重新生成清理后的缓存快照。
cacheCurrentDraft(cachedSheetData)
} else {
deleteSpreadsheetDraftCache(routeSheetId.value)
}
} catch {
// 用户拒绝恢复或缓存内容损坏时均回退到服务端版本。
deleteSpreadsheetDraftCache(routeSheetId.value)
}
} else if (cachedDraft) {
// 服务端版本已经变化时,旧缓存不能覆盖较新的数据。
deleteSpreadsheetDraftCache(routeSheetId.value)
}
workbookData.value = nextWorkbookData
loading.value = false
}
} catch (e) {
@@ -495,13 +706,26 @@ onBeforeMount(async () => {
}
})
onMounted(() => {
window.addEventListener('pagehide', cacheRawDraftBeforeUnload)
})
onBeforeUnmount(() => {
window.removeEventListener('pagehide', cacheRawDraftBeforeUnload)
if (draftCacheTimer) {
clearTimeout(draftCacheTimer)
}
if (!discardAndLeave) {
cacheRawDraftBeforeUnload()
}
})
</script>
<template>
<div ref="editorRootRef" class="spreadsheet-editor" v-loading="loading">
<SpreadsheetToolbar
:name="spreadsheetName"
:has-changes="hasChanges"
:saving="saving"
:publishing="publishing"
:recovering="recovering"
@@ -525,6 +749,7 @@ onBeforeMount(async () => {
ref="univerSheetRef"
:model-value="workbookData || undefined"
mode="edit"
@change="markSpreadsheetChanged"
@ready="handleUniverReady"
/>

View File

@@ -1,5 +1,6 @@
import { useEmitt } from '@/hooks/web/useEmitt'
import type { SpreadsheetFilterConfig } from '../../../types/plugin'
import { dispatchSpreadsheetContentChanged } from '../../../utils/events'
export interface SpreadsheetFilterQueryPayload {
unitId?: string
@@ -81,6 +82,7 @@ export const dispatchSpreadsheetFilterConfigChange = (config: SpreadsheetFilterC
currentConfig = config
currentVisible = config.visible
emitter.emit(SPREADSHEET_FILTER_EVENTS.CONFIG_CHANGE, config)
dispatchSpreadsheetContentChanged()
}
export const dispatchSpreadsheetFilterRequestConfigContext = (

View File

@@ -0,0 +1,38 @@
import { useCache } from '@/hooks/web/useCache'
const SPREADSHEET_DRAFT_CACHE_PREFIX = 'DE-SPREADSHEET-CATCH-'
export interface SpreadsheetDraftCache {
sheetId: string
sourceVersion: number
name: string
remark: string
sheetData: string
cachedAt: number
}
const { wsCache } = useCache('localStorage')
const getCacheKey = (sheetId: string | number) =>
`${SPREADSHEET_DRAFT_CACHE_PREFIX}${sheetId}`
export const getSpreadsheetDraftCache = (
sheetId: string | number
): SpreadsheetDraftCache | undefined => {
const cache = wsCache.get(getCacheKey(sheetId)) as SpreadsheetDraftCache | undefined
if (!cache || String(cache.sheetId) !== String(sheetId) || !cache.sheetData) {
return undefined
}
return cache
}
export const setSpreadsheetDraftCache = (cache: SpreadsheetDraftCache): void => {
wsCache.set(getCacheKey(cache.sheetId), cache)
}
export const deleteSpreadsheetDraftCache = (sheetId?: string | number): void => {
if (sheetId === undefined || sheetId === null) {
return
}
wsCache.delete(getCacheKey(sheetId))
}

View File

@@ -1,3 +1,5 @@
import { useEmitt } from '@/hooks/web/useEmitt'
export const SPREADSHEET_EVENTS = {
OPEN_PLUGIN_EDITOR: 'OPEN_PLUGIN_EDITOR',
CLOSE_PLUGIN_EDITOR: 'CLOSE_PLUGIN_EDITOR',
@@ -9,7 +11,12 @@ export const SPREADSHEET_EVENTS = {
REFRESH_PLUGIN_DATA: 'REFRESH_PLUGIN_DATA',
OPEN_COMPONENT_DATASET_REPLACEMENT: 'OPEN_COMPONENT_DATASET_REPLACEMENT',
DATASET_REPLACEMENT_COMPLETED: 'DATASET_REPLACEMENT_COMPLETED',
CONTENT_CHANGED: 'SPREADSHEET_CONTENT_CHANGED',
CELL_SELECTED: 'CELL_SELECTED',
PLACEHOLDER_TOOLTIP_SHOW: 'PLACEHOLDER_TOOLTIP_SHOW',
PLACEHOLDER_TOOLTIP_HIDE: 'PLACEHOLDER_TOOLTIP_HIDE'
} as const
export const dispatchSpreadsheetContentChanged = () => {
useEmitt().emitter.emit(SPREADSHEET_EVENTS.CONTENT_CHANGED)
}