baodan/frontend/src/composables/useAutoSave.ts
2026-08-04 10:44:34 +08:00

148 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 草稿自动保存 composable。
*
* 特性:
* 1. 防抖保存(默认 2 秒)
* 2. 乐观锁冲突检测
* 3. 未保存修改提示
*/
import { ref, onUnmounted } from 'vue'
import api from '@/utils/api'
export interface AutoSaveOptions {
/** 保存端点(字符串或返回字符串的函数,支持动态 endpoint */
endpoint: string | (() => string)
/** 防抖延迟(毫秒) */
debounceMs?: number
}
export function useAutoSave(options: AutoSaveOptions) {
const { debounceMs = 2000 } = options
const getEndpoint: () => string = typeof options.endpoint === 'function'
? options.endpoint
: () => options.endpoint as string
const draftRevision = ref(1)
const generatedRevision = ref(0)
const saving = ref(false)
const lastSaved = ref<Date | null>(null)
const conflictError = ref<string | null>(null)
const hasUnsavedChanges = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
let workspaceGeneration = 0
/**
* 触发防抖保存。
* 调用方在数据变更时调用此方法。
*/
function scheduleSave(data: Record<string, any>) {
if (timer) clearTimeout(timer)
hasUnsavedChanges.value = true
conflictError.value = null
timer = setTimeout(() => {
doSave(data)
}, debounceMs)
}
/**
* 立即保存(用于页面离开前)。
*/
async function saveNow(data: Record<string, any>): Promise<boolean> {
if (timer) {
clearTimeout(timer)
timer = null
}
return await doSave(data)
}
async function doSave(data: Record<string, any>): Promise<boolean> {
if (saving.value) return false
const saveGeneration = workspaceGeneration
saving.value = true
conflictError.value = null
try {
const payload = {
...data,
expected_revision: draftRevision.value,
}
const res: any = await api.post(getEndpoint(), payload)
if (saveGeneration !== workspaceGeneration) return false
// 拦截器已解包 Axios 响应res = {code: 0, data: {...}}
if (res?.code === 0) {
draftRevision.value = res.data?.draft_revision || draftRevision.value + 1
generatedRevision.value = res.data?.generated_revision || 0
hasUnsavedChanges.value = res.data?.has_unsaved_changes ?? false
lastSaved.value = new Date()
return true
} else if (res?.code === 1001 && res?.message?.includes('冲突')) {
syncDraftRevision(Number(res.data?.current_revision))
conflictError.value = res.message
hasUnsavedChanges.value = true
return false
}
} catch (e: any) {
if (saveGeneration !== workspaceGeneration) return false
const response = e?.response?.data
if (response?.code === 1001 && response?.message?.includes('冲突')) {
syncDraftRevision(Number(response.data?.current_revision))
conflictError.value = response.message
hasUnsavedChanges.value = true
return false
}
console.warn('自动保存失败:', e)
} finally {
saving.value = false
}
return false
}
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
/**
* 从服务端状态初始化版本号。
* 在工作区恢复后调用,避免首次保存时版本冲突。
*/
function initRevisions(serverDraftRevision: number, serverGeneratedRevision: number) {
draftRevision.value = serverDraftRevision || 1
generatedRevision.value = serverGeneratedRevision || 0
hasUnsavedChanges.value = draftRevision.value > generatedRevision.value
}
function syncDraftRevision(serverDraftRevision: number) {
if (Number.isInteger(serverDraftRevision) && serverDraftRevision > 0) {
draftRevision.value = serverDraftRevision
}
}
function resetForWorkspace(serverDraftRevision: number = 1, serverGeneratedRevision: number = 0) {
workspaceGeneration += 1
if (timer) {
clearTimeout(timer)
timer = null
}
conflictError.value = null
lastSaved.value = null
initRevisions(serverDraftRevision, serverGeneratedRevision)
}
return {
draftRevision,
generatedRevision,
saving,
lastSaved,
conflictError,
hasUnsavedChanges,
scheduleSave,
saveNow,
initRevisions,
syncDraftRevision,
resetForWorkspace,
}
}