fix: 状态镜像防冲掉——服务端按 key 合并,客户端读不到宿主就不写
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
* 做法:把「项目 + 视图」这份 JSON 镜像到宿主侧文件(/api/worktable/state),
|
||||
* 启动时若宿主侧比本地新,就用宿主侧恢复。localStorage 仍是第一落点(同步、快),
|
||||
* 宿主文件是跨重启的兜底。
|
||||
*
|
||||
* 两条安全约束(都来自一次真实事故:空状态把宿主文件冲掉):
|
||||
* 1. 读不到宿主状态就**不写**——宁可这次会话只在本地,也不拿空值覆盖镜像。
|
||||
* 2. POST 始终带上完整的 parts(projects 与 view 都在内存里各留一份),
|
||||
* 服务端再做一次按 key 合并,半截写入不会丢另一半。
|
||||
*/
|
||||
|
||||
const ENDPOINT = '/api/worktable/state'
|
||||
@@ -17,16 +22,35 @@ 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
|
||||
/** reachable=false 表示宿主这次根本没读到(连接没就绪/路由不可用),不能据此覆盖镜像 */
|
||||
export type HostReadResult =
|
||||
| { reachable: true; state: HostState | null }
|
||||
| { reachable: false }
|
||||
|
||||
const RETRY_DELAYS = [0, 300, 700, 1200, 2000, 3000]
|
||||
|
||||
/**
|
||||
* 拉取宿主侧镜像;不抛错,不影响工作台本身。
|
||||
* 页面刚挂载时宿主连接可能还没就绪,所以这里带退避重试。
|
||||
* - reachable: true, state: null → 宿主可达但还没有镜像(首次使用),可以写
|
||||
* - reachable: false → 读不到,本次会话禁止写
|
||||
*/
|
||||
export async function loadHostState(): Promise<HostReadResult> {
|
||||
for (const delay of RETRY_DELAYS) {
|
||||
if (delay) await new Promise((r) => setTimeout(r, delay))
|
||||
try {
|
||||
const r = await fetch(ENDPOINT, { cache: 'no-store', headers: { accept: 'application/json' } })
|
||||
if (!r.ok) continue
|
||||
const j: any = await r.json()
|
||||
if (j && typeof j === 'object' && j.state && typeof j.state === 'object') {
|
||||
return { reachable: true, state: j.state as HostState }
|
||||
}
|
||||
return { reachable: true, state: null }
|
||||
} catch {
|
||||
// 继续重试
|
||||
}
|
||||
}
|
||||
return { reachable: false }
|
||||
}
|
||||
|
||||
/** 累积中的镜像内容:POST 只带最新值,避免每次编辑都发整包 */
|
||||
@@ -34,7 +58,7 @@ let parts: HostStateParts = {}
|
||||
let timer: any = null
|
||||
let inflight = false
|
||||
let dirty = false
|
||||
/** 恢复完成前不写宿主,否则首屏的默认值会把宿主里的旧数据覆盖掉 */
|
||||
/** 读完宿主之前不写宿主:首屏默认值不能覆盖镜像,读失败就整场不写 */
|
||||
let hydrated = false
|
||||
|
||||
function schedule() {
|
||||
@@ -77,11 +101,20 @@ export function queueHostSave(patch: HostStateParts): void {
|
||||
schedule()
|
||||
}
|
||||
|
||||
/** 首次恢复判定结束:之后再放行宿主写入 */
|
||||
export function markHydrated(): void {
|
||||
/**
|
||||
* 宿主镜像已成功读到,放行写入。
|
||||
* 传入读到的内容,让之后的半截写入也能带上另一半。
|
||||
*/
|
||||
export function markHydrated(seed?: HostStateParts): void {
|
||||
if (seed) parts = { ...seed, ...parts }
|
||||
hydrated = true
|
||||
}
|
||||
|
||||
/** 本次会话没有读到宿主:禁止写入,避免空状态覆盖镜像 */
|
||||
export function staysReadOnly(): void {
|
||||
hydrated = false
|
||||
}
|
||||
|
||||
/** 立即冲刷(页面隐藏/关闭前调用,避免丢掉最后一个 400ms 内的改动) */
|
||||
export function flushHostSave(): void {
|
||||
if (timer) {
|
||||
|
||||
Reference in New Issue
Block a user