feat(canvas): localStorage document cache for instant open

- paint from cv:cache:<id> before the network fetch (works offline too),
  then reconcile with the server copy in the background; identical content
  skips the re-apply so undo history and in-flight interaction survive
- cache is written on every successful autosave and cleared on delete;
  quota errors are swallowed (best-effort by design)
This commit is contained in:
dokploy-bot
2026-09-19 23:10:10 +08:00
parent e83da82f3d
commit 38367c36ab
+71 -9
View File
@@ -58,9 +58,75 @@ async function loadList() {
canvases.value = r.data?.data || [] canvases.value = r.data?.data || []
} }
// ---- 本地缓存(localStorage):打开画布先渲染缓存秒开,再后台对齐服务器 ----
const CV_CACHE_PREFIX = 'cv:cache:'
function readCache(id) {
try {
const raw = localStorage.getItem(CV_CACHE_PREFIX + id)
if (!raw) return null
const c = JSON.parse(raw)
if (typeof c.data !== 'string') return null
return { title: typeof c.title === 'string' ? c.title : '未命名画布', data: c.data }
} catch {
return null
}
}
function writeCache(id) {
if (!id) return
try {
localStorage.setItem(
CV_CACHE_PREFIX + id,
JSON.stringify({ title: title.value, data: serializeDoc(doc) }),
)
} catch { /* 配额满等异常:缓存是尽力而为,失败不影响功能 */ }
}
function clearCache(id) {
try { localStorage.removeItem(CV_CACHE_PREFIX + id) } catch { /* 同上 */ }
}
function applyDoc(id, docTitle, d) {
currentId.value = id
title.value = docTitle || '未命名画布'
doc.viewport = d.viewport
doc.nodes = d.nodes
doc.edges = d.edges
selectionIds.value = []
resetHistory()
saveState.value = 'saved'
}
async function loadCanvas(id) { async function loadCanvas(id) {
// 1) 本地缓存先行:立即渲染、立即可交互(离线也可用)
let appliedFromCache = false
const cached = readCache(id)
if (cached) {
const cd = normalizeDoc(safeParse(cached.data))
for (const n of cd.nodes) {
if (n.type === 'gen' && n.status === 'running') {
n.status = 'failed'
n.error = '页面已刷新,任务中断'
}
}
applyDoc(id, cached.title, cd)
appliedFromCache = true
loading.value = false
await nextTick()
stage.value?.setViewport(cd.viewport)
stage.value?.syncZTop()
}
// 2) 服务器版本对齐
const r = await api(`/canvas/${id}`) const r = await api(`/canvas/${id}`)
if (!r.ok) throw new Error(r.data?.detail || '加载画布失败') if (!r.ok) {
if (appliedFromCache) return // 拉取失败但缓存可用:继续用缓存
throw new Error(r.data?.detail || '加载画布失败')
}
const serverData = typeof r.data?.data === 'string' ? r.data.data : serializeDoc(normalizeDoc(safeParse(r.data?.data)))
if (appliedFromCache && cached && cached.data === serverData && cached.title === (r.data.title || '未命名画布')) {
writeCache(id) // 刷新缓存时间戳意义不大,但保持一致路径
return // 内容一致:不重刷,保留撤销历史与当前交互
}
const d = normalizeDoc(safeParse(r.data?.data)) const d = normalizeDoc(safeParse(r.data?.data))
// 页面刷新会丢掉进行中的生成任务结果(无轮询),如实标记中断。 // 页面刷新会丢掉进行中的生成任务结果(无轮询),如实标记中断。
for (const n of d.nodes) { for (const n of d.nodes) {
@@ -69,17 +135,11 @@ async function loadCanvas(id) {
n.error = '页面已刷新,任务中断' n.error = '页面已刷新,任务中断'
} }
} }
currentId.value = r.data.id applyDoc(r.data.id, r.data.title, d)
title.value = r.data.title || '未命名画布'
doc.viewport = d.viewport
doc.nodes = d.nodes
doc.edges = d.edges
selectionIds.value = []
resetHistory()
saveState.value = 'saved'
await nextTick() await nextTick()
stage.value?.setViewport(d.viewport) stage.value?.setViewport(d.viewport)
stage.value?.syncZTop() stage.value?.syncZTop()
writeCache(id)
} }
function safeParse(s) { function safeParse(s) {
@@ -130,6 +190,7 @@ async function saveNow() {
) )
if (r.ok) { if (r.ok) {
saveState.value = 'saved' saveState.value = 'saved'
writeCache(currentId.value)
const row = canvases.value.find((c) => c.id === currentId.value) const row = canvases.value.find((c) => c.id === currentId.value)
if (row) row.title = title.value if (row) row.title = title.value
} else { } else {
@@ -304,6 +365,7 @@ async function deleteCanvas() {
if (!window.confirm(`删除画布「${title.value}」?此操作不可撤销。`)) return if (!window.confirm(`删除画布「${title.value}」?此操作不可撤销。`)) return
const r = await api(`/canvas/${currentId.value}`, { method: 'DELETE' }) const r = await api(`/canvas/${currentId.value}`, { method: 'DELETE' })
if (!r.ok) return if (!r.ok) return
clearCache(currentId.value)
await loadList() await loadList()
if (canvases.value.length) { if (canvases.value.length) {
await loadCanvas(canvases.value[0].id) await loadCanvas(canvases.value[0].id)