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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user