fix(desktop): 状态镜像到宿主侧,修复随机端口导致重启丢数据
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 宿主侧状态镜像(本地补丁)。
|
||||
*
|
||||
* 背景:桌面端宿主每次启动都带 `--port 0`(随机端口),页面来源随之变化
|
||||
* (如 127.0.0.1:2080 → 127.0.0.1:8494)。浏览器按来源隔离 localStorage /
|
||||
* IndexedDB,换端口就等于换一个空库,项目/布局/外观重启后全丢。
|
||||
*
|
||||
* 做法:把「项目 + 视图」这份 JSON 镜像到宿主侧文件(/api/worktable/state),
|
||||
* 启动时若宿主侧比本地新,就用宿主侧恢复。localStorage 仍是第一落点(同步、快),
|
||||
* 宿主文件是跨重启的兜底。
|
||||
*/
|
||||
|
||||
const ENDPOINT = '/api/worktable/state'
|
||||
|
||||
export type HostStateParts = { projects?: unknown; view?: unknown }
|
||||
|
||||
/** 宿主镜像里可能带的写入时间戳(毫秒) */
|
||||
export type HostState = HostStateParts & { updatedAt?: number }
|
||||
|
||||
/** 拉取宿主侧镜像;没有/不可用都返回 null(不抛错,不影响工作台本身) */
|
||||
export async function loadHostState(): Promise<HostState | null> {
|
||||
try {
|
||||
const r = await fetch(ENDPOINT, { cache: 'no-store', headers: { accept: 'application/json' } })
|
||||
if (!r.ok) return null
|
||||
const j: any = await r.json()
|
||||
return j && typeof j === 'object' && j.state && typeof j.state === 'object' ? (j.state as HostState) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 累积中的镜像内容:POST 只带最新值,避免每次编辑都发整包 */
|
||||
let parts: HostStateParts = {}
|
||||
let timer: any = null
|
||||
let inflight = false
|
||||
let dirty = false
|
||||
/** 恢复完成前不写宿主,否则首屏的默认值会把宿主里的旧数据覆盖掉 */
|
||||
let hydrated = false
|
||||
|
||||
function schedule() {
|
||||
if (timer) return
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
void flush()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
if (!hydrated || inflight) {
|
||||
if (inflight) dirty = true
|
||||
return
|
||||
}
|
||||
if (!parts.projects && !parts.view) return
|
||||
inflight = true
|
||||
try {
|
||||
await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ state: { ...parts, updatedAt: Date.now() } }),
|
||||
keepalive: true,
|
||||
})
|
||||
} catch {
|
||||
// 宿主不可用时静默:localStorage 仍是本次会话的真值
|
||||
} finally {
|
||||
inflight = false
|
||||
if (dirty) {
|
||||
dirty = false
|
||||
schedule()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 记下要镜像的部分;400ms 内多次调用合并成一次 POST */
|
||||
export function queueHostSave(patch: HostStateParts): void {
|
||||
parts = { ...parts, ...patch }
|
||||
if (!hydrated) return
|
||||
schedule()
|
||||
}
|
||||
|
||||
/** 首次恢复判定结束:之后再放行宿主写入 */
|
||||
export function markHydrated(): void {
|
||||
hydrated = true
|
||||
}
|
||||
|
||||
/** 立即冲刷(页面隐藏/关闭前调用,避免丢掉最后一个 400ms 内的改动) */
|
||||
export function flushHostSave(): void {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
void flush()
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -544,5 +544,48 @@ export function apply(ctx: Context) {
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 工作台状态宿主侧镜像(本地补丁)。
|
||||
*
|
||||
* 桌面端宿主以 `--port 0` 启动,端口每次随机,页面来源跟着变;而 localStorage 按来源隔离,
|
||||
* 于是「新建的项目重启就没了」。这里把项目/视图这份 JSON 落到 DSH_HOME 下的固定文件:
|
||||
* 跨重启、跨端口稳定。客户端启动时比对时间戳决定是否回填(见 client/hostState.ts)。
|
||||
*/
|
||||
const statePath = () => pathResolve(resolveDshHomeSafe(), 'worktable-state.json')
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/state',
|
||||
handler: async (req: any, res: any) => {
|
||||
const file = statePath()
|
||||
if (req.method === 'GET' || req.method === 'HEAD') {
|
||||
try {
|
||||
const raw = await readFile(file, 'utf8')
|
||||
const text = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw
|
||||
json(res, 200, { ok: true, path: file, state: JSON.parse(text) })
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') { json(res, 200, { ok: true, path: file, state: null }); return }
|
||||
json(res, 500, { error: String(err) })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') { json(res, 405, { error: 'method not allowed' }); return }
|
||||
try {
|
||||
const body = await readJsonBody(req)
|
||||
const state = body && typeof body.state === 'object' && body.state !== null ? body.state : null
|
||||
if (!state) { json(res, 400, { error: 'missing state' }); return }
|
||||
const text = JSON.stringify(state)
|
||||
if (text.length > 20 * 1024 * 1024) { json(res, 413, { error: 'state too large' }); return }
|
||||
const fsx = await import('node:fs/promises')
|
||||
await fsx.mkdir(dirname(file), { recursive: true })
|
||||
const tmp = file + '.tmp-' + process.pid + '-' + Date.now()
|
||||
await fsx.writeFile(tmp, text, 'utf8')
|
||||
await fsx.rename(tmp, file)
|
||||
json(res, 200, { ok: true, path: file })
|
||||
} catch (err: any) {
|
||||
json(res, err?.status ?? 500, { error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
setupTerminal(webServer, ctx)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user