fix(desktop): 状态镜像到宿主侧,修复随机端口导致重启丢数据

This commit is contained in:
2026-11-02 18:20:16 +08:00
parent f98246ef0c
commit a870f74875
9 changed files with 523 additions and 113 deletions
+72 -8
View File
@@ -12,6 +12,7 @@ import { previewFromEvents, previewFromNodes, readSessionPreview, presetApiOf, m
import { photoStore, kindOf } from './photoStore'
import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg'
import { WAVE_BG_B64 } from './waveBg'
import { loadHostState, queueHostSave, markHydrated, flushHostSave } from './hostState'
/**
* dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。
@@ -124,6 +125,8 @@ type ProjectsState = {
const PERSIST_KEY = 'dsh.worktable.view.v1'
const PROJECTS_KEY = 'dsh.worktable.projects.v1'
/** 本地最后写入时间(毫秒):与宿主镜像的 updatedAt 比较,决定启动时以哪边为准 */
const SAVED_AT_KEY = 'dsh.worktable.savedAt.v1'
const MIN_TOP = 56
const SNAP_PX = 32
/** 插件市场外链(GitHub 仓库,已核实可访问;PRD 提及的 dshfind.com 未验证,不用死链)。 */
@@ -308,11 +311,11 @@ function normHsl(v: any): { h: number; s: number; l: number } | undefined {
}
}
function loadView(): ViewState {
/** 视图状态归一化(本地存档与宿主镜像共用同一套字段校验) */
function normalizeView(raw: any): ViewState {
try {
const raw = localStorage.getItem(PERSIST_KEY)
if (!raw) return { ...DEFAULT_VIEW }
const p = JSON.parse(raw)
if (!raw || typeof raw !== 'object') return { ...DEFAULT_VIEW }
const p = raw
// 显式挑字段:旧版遗留的 groupBy 等未知字段直接忽略。
// 一次性迁移:v2 起默认「手动」排序(最近排序点击即置顶、体验差),旧存「最近」回落为「手动」;
// 之后用户手动选择「最近」会写入 sortMigratedV2 标记并被尊重。
@@ -356,11 +359,21 @@ function loadView(): ViewState {
}
}
function loadProjects(): ProjectsState {
function loadView(): ViewState {
try {
const raw = localStorage.getItem(PROJECTS_KEY)
if (!raw) return { ...DEFAULT_PROJECTS }
const p = JSON.parse(raw)
const raw = localStorage.getItem(PERSIST_KEY)
if (!raw) return { ...DEFAULT_VIEW }
return normalizeView(JSON.parse(raw))
} catch {
return { ...DEFAULT_VIEW }
}
}
/** 项目状态归一化(本地存档与宿主镜像共用同一套字段校验) */
function normalizeProjects(raw: any): ProjectsState {
try {
if (!raw || typeof raw !== 'object') return { ...DEFAULT_PROJECTS }
const p = raw
return {
order: Array.isArray(p.order) ? p.order.filter((x: unknown): x is string => typeof x === 'string') : [],
lastUsed: p.lastUsed && typeof p.lastUsed === 'object' ? p.lastUsed : {},
@@ -383,6 +396,21 @@ function loadProjects(): ProjectsState {
}
}
function loadProjects(): ProjectsState {
try {
const raw = localStorage.getItem(PROJECTS_KEY)
if (!raw) return { ...DEFAULT_PROJECTS }
return normalizeProjects(JSON.parse(raw))
} catch {
return { ...DEFAULT_PROJECTS }
}
}
/** 记下本地写入时间,供启动时与宿主镜像比较新鲜度 */
function markSaved(): void {
try { localStorage.setItem(SAVED_AT_KEY, String(Date.now())) } catch {}
}
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
type FloatRect = { top: number }
@@ -1380,6 +1408,8 @@ function WorktableSection(props: any) {
setView((prev) => {
const next = { ...prev, ...patch }
try { localStorage.setItem(PERSIST_KEY, JSON.stringify({ ...next, sortMigratedV2: true })) } catch {}
markSaved()
queueHostSave({ view: next })
return next
})
}
@@ -1388,10 +1418,44 @@ function WorktableSection(props: any) {
setProjects((prev) => {
const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch }
try { localStorage.setItem(PROJECTS_KEY, JSON.stringify(next)) } catch {}
markSaved()
queueHostSave({ projects: next })
return next
})
}
/**
* 宿主侧状态恢复(本地补丁):桌面端每次启动端口随机,localStorage 按来源隔离,
* 重启后本地必然是空的。这里比对「本地最后写入时间」与「宿主镜像 updatedAt」,
* 只在宿主更新时回填,避免用旧镜像盖掉当前页面刚改的内容。
* 恢复判定结束前不写宿主(markHydrated),防止首屏默认值先把镜像冲掉。
*/
useEffect(() => {
let cancelled = false
void (async () => {
const host = await loadHostState()
if (cancelled) return
let localAt = 0
try { localAt = Number(localStorage.getItem(SAVED_AT_KEY) ?? '0') || 0 } catch {}
const hostAt = typeof host?.updatedAt === 'number' ? host.updatedAt : 0
if (host && hostAt > localAt) {
if (host.projects) persistProjects(normalizeProjects(host.projects))
if (host.view) persistView(normalizeView(host.view))
}
markHydrated()
if (hostAt <= localAt) flushHostSave()
})()
const flush = () => flushHostSave()
window.addEventListener('pagehide', flush)
window.addEventListener('beforeunload', flush)
return () => {
cancelled = true
window.removeEventListener('pagehide', flush)
window.removeEventListener('beforeunload', flush)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 自愈:启动时若控制室存档是坏布局(旧版关掉控制室标签造成窗格退化成选择器),立即重建默认面板
useEffect(() => {
const saved = projects.views[CONSOLE_ID]