import { create } from 'zustand'; import { activeAuth } from '../api/config'; import { payloadFromLocal, pullAppearance, pushAppearance, uploadAppearanceImage, type AppearanceAuth } from '../lib/appearance'; import { useAccountStore, isAggregate } from './accountStore'; import { useBackgroundStore } from './backgroundStore'; import { useThemeStore } from './themeStore'; /** * 外观同步:服务端是权威,本地是缓存。 * * 三条不变量(都有判据): * 1. **登录后以服务端为准**(换设备/换浏览器能拿回来); * 2. **本地改动会推上去**(换设备再看还是这个外观); * 3. **拉下来的东西不会再推回去**(否则就是自己触发自己的回环 —— 一次拉取 * 会连带 PUT 一次,服务端 updated_at 被无意义地刷新,还会掩盖真实的本地改动)。 * * 服务端不可达时状态是 `local-only`,界面上**必须看得见** —— 静默失败会让用户 * 以为"已经同步了",然后在另一台机器上发现没有,正是这次的缺陷。 */ export type SyncStatus = 'idle' | 'synced' | 'pending' | 'local-only'; interface AppearanceSyncState { status: SyncStatus; /** 最近一次成功同步的时刻(ms)。0 = 从没成功过。 */ lastSyncedAt: number; /** 正在把服务端的值灌进 store —— 此时不推回服务端。 */ applyingRemote: boolean; pull: () => Promise; push: () => Promise; } /** * 上一次成功上传的壁纸(data URL),**按账号分桶**。 * * ★ 为什么必须带账号维度:这是本模块唯一没做账号隔离的状态,而它恰好是最不能 * 漏的那一个。A 账号设壁纸 X(上传成功 ⇒ 记下 "A"→X)→ 切到 B 账号,而 B 的 * 服务端记录是 `saved:false` ⇒ `pull()` 走"把本地这份推上去"的分支 ⇒ 但 * `push()` 的跳过判据被 **A** 留下的标记满足 ⇒ **B 的壁纸永远传不上去**, * 而状态仍被置为 `'synced'`。现象是"B 的壁纸在其它所有设备上都缺, * 界面上却显示已同步"。 * * 本来这个文件把 localStorage 键按账号隔离了(见 `lib/appearance.ts`), * 漏的只有这个内存变量。用 `Map` 而不是单串,是因为它的生命周期就等于 * 一次运行期,而账号数很小,不需要额外持久化。 */ const lastUploadedImage = new Map(); /** 当前身份的稳定键。聚合视图下用固定值(聚合本来就不该单独上传)。 */ function currentAccountKey(): string { const s = useAccountStore.getState(); return isAggregate(s) ? '__aggregate__' : s.activeId; } let pushTimer: ReturnType | null = null; function currentAuth(): AppearanceAuth | null { const a = activeAuth(); if (!a?.base) return null; // 令牌可以为空:**浏览器密码登录走 cookie 会话**(桌面端粘贴用户密钥才设 Bearer)。 // 以前这里要求 token 非空,于是 WebUI 里这套同步从来不跑 —— 用户看到的就是 // "服务端存了外观、界面却毫无变化"。 return { base: a.base, token: a.token ?? '' }; } export const useAppearanceSync = create((set) => ({ status: 'idle', lastSyncedAt: 0, applyingRemote: false, pull: async () => { const auth = currentAuth(); if (!auth) { // 没登录(或还没选账号):本地就是全部,且要让用户知道。 set({ status: 'local-only' }); return; } const remote = await pullAppearance(auth); if (!remote) { set({ status: 'local-only' }); return; } if (!remote.saved) { // 服务端还没这份记录(第一次用这套同步,或换了新账号): // **不能拿默认值覆盖本地** —— 反过来把本地这份推上去,作为账号的初始外观。 // 漏了这一条,每个老用户升级后第一次登录都会发现主题/壁纸被重置。 set({ status: 'pending' }); await useAppearanceSync.getState().push(); return; } set({ applyingRemote: true }); try { useThemeStore.getState().setPref(remote.snapshot.theme); const bg = useBackgroundStore.getState(); // 背景档与参数以服务端为准;图片只在服务端有图时才覆盖本地 // (服务端没图而本地有 = 本地还没推上去,别把用户的图擦掉)。 if (remote.snapshot.bgKind === 'preset') bg.setPreset(remote.snapshot.bgPresetId); else if (remote.snapshot.bgKind === 'none') bg.setKind('none'); else if (remote.snapshot.bgKind === 'image' && remote.imageDataUrl) bg.setImage(remote.imageDataUrl); if (remote.imageDataUrl) lastUploadedImage.set(currentAccountKey(), remote.imageDataUrl); bg.setDim(remote.snapshot.bgDim); bg.setBlur(remote.snapshot.bgBlur); } finally { set({ applyingRemote: false }); } set({ status: 'synced', lastSyncedAt: Date.now() }); }, push: async () => { const auth = currentAuth(); if (!auth) { set({ status: 'local-only' }); return; } const theme = useThemeStore.getState().pref; const bg = useBackgroundStore.getState(); const accountKey = currentAccountKey(); const payload = payloadFromLocal({ theme, kind: bg.kind, presetId: bg.presetId, imageDataUrl: bg.imageDataUrl, dim: bg.dim, blur: bg.blur }); let ok = await pushAppearance(auth, payload); // 壁纸本体单独传:几 MB 的图不该每次都跟着 PUT 走,只在换图时传一次。 if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage.get(accountKey)) { const uploaded = await uploadAppearanceImage(auth, bg.imageDataUrl); if (uploaded) lastUploadedImage.set(accountKey, bg.imageDataUrl); else ok = false; } set(ok ? { status: 'synced', lastSyncedAt: Date.now() } : { status: 'pending' }); } })); /** 本地一改就推(去抖),但**拉取期间不推**(见文件头第 3 条)。 */ function schedulePush(): void { if (useAppearanceSync.getState().applyingRemote) return; if (pushTimer) clearTimeout(pushTimer); pushTimer = setTimeout(() => { pushTimer = null; void useAppearanceSync.getState().push(); }, 600); } /** * 接线:订阅两个 store 的改动,并在账号切换时重新拉取。 * * 账号切换必须重新拉 —— 外观是**账号级**的,而 localStorage 里的缓存键是全局的 * (这正是"放本地"时最直接的缺陷:同一台机器换账号背景不跟着走)。 * * @returns 取消订阅(React 的 effect 清理用) */ export function initAppearanceSync(): () => void { const unsubBg = useBackgroundStore.subscribe(schedulePush); const unsubTheme = useThemeStore.subscribe(schedulePush); const unsubAccount = useAccountStore.subscribe((state, prev) => { if (state.activeId !== prev.activeId) { /* * 顺序:**先按新账号重读本地缓存,再拉服务端**。 * * 为什么不能省这一步:服务端"没有记录"(`saved === false`)时 `pull()` 会 * 走"以本地为准推上去"——那时"本地"必须已经是**这个账号**的值。 * 全局键时代这里是上一个账号的值,于是新账号凭空继承了别人的外观, * 而且被写进了服务端(pi 2026-09-14 复核时点出的那条缺陷)。 */ useBackgroundStore.getState().reloadForAccount(); void useAppearanceSync.getState().pull(); } }); return () => { unsubBg(); unsubTheme(); unsubAccount(); if (pushTimer) clearTimeout(pushTimer); }; }