跨端: 接手 pi 的两个 WebUI 开项——默认值统一到服务端契约 12/4;缓存键按账号(含一次性迁移)

pi 问"这两个开项谁执行",我接了(他那边无 shell,我这边改过 WebUI)。两件都是他读源码读出来的实缺陷。

## 1 默认值:不是审美,是**服务端契约**(pi 更正了自己上一封)

`server/internal/models/models.go` 的 `DefaultAppearance()` 明写 `BgDim: 12, BgBlur: 4`,
且注释宣称"与客户端 backgroundStore / themeStore 的默认值一致"——而 WebUI 的
`backgroundStore.ts` 是 `dim: 24, blur: 8`,**那句注释是假的**;`lib/appearance.ts`
的 `clamp(..., 12, 4)` 又是另一套。**同一份代码里两个"默认值"**,走哪条路就落哪个数。

后果不是"两处代码不一样"这么轻:服务端"没有记录"时客户端以本地为准推上去,
于是**新账号的初始外观由第一个同步它的客户端决定**(先 WebUI 登录存 24/8,
先鸿蒙登录存 12/4)——同一个账号,压暗强度取决于谁先到。

改法:新增 `src/lib/appearanceDefaults.ts` 作为**唯一来源**(DEFAULT_DIM/DEFAULT_BLUR/
上限),`backgroundStore` 与 `lib/appearance` 都引用它,字面量全部消失。

## 2 缓存键按账号(含旧全局键的一次性迁移)

`STORAGE_KEY = 'agentmail.background'` → `storageKey(accountId)` = 前缀 + 账号;
写盘只走 `storageKey()`;旧全局键**只作为迁移源**:当前账号首次读到它时接管并存进自己的键,
然后**立刻删除**(否则下一个账号继续从它"继承",等于把刚修的缺陷留在原地);
未登录时不迁移(旧值不能送给一个还不知道是谁的账号)。

配套顺序:`appearanceSync` 在账号切换时**先 `reloadForAccount()` 再 `pull()`** ——
服务端"没有记录"时 `pull()` 会"以本地为准推上去",那时"本地"必须已经是本账号的值。

## 3 判据(新增第 13 个判据文件 appearance-defaults)

`test/appearance-defaults.test.mjs`:**去 Go 源码里读** `DefaultAppearance()` 的四个字段,
再比对三处(WebUI 常量、store 的 DEFAULT_BACKGROUND 不许有字面量、鸿蒙 Appearance 的字段默认值);
另两条钉"键按账号、不许退回全局键、旧键必须被删除"与"重读在 pull 之前"。
这样服务端那句注释是**可核对**的,不是承诺。

顺带更正:`Wallpaper.ts` 里"WebUI 默认 24"的注释已过时 → 改 12 并写明缘由;
`harmony-appearance` 里"WebUI 是全局键"的前提失效 → 改为断言两端都按账号分键。

## 验证

`npm test` 退出码 0(13 个判据文件全绿 + vitest 263 passed,原 258 + 新增 5 条行为测试:
键隔离、迁移一次并删除、未登录不迁移、默认值=12/4)。
This commit is contained in:
2026-09-14 15:17:01 +08:00
parent 2d8f5424b5
commit e94313f66a
10 changed files with 366 additions and 25 deletions

View File

@ -5,7 +5,9 @@
*
* 2026-09-13 用户的质问:「为什么背景是保存在本地而不是服务器!」当时的实情是
* 主题与壁纸只写 localStorage:换设备/换浏览器就没了,而且**多账号共用一份**
* (键是全局常量 `agentmail.background`)—— 同一台机器换账号,背景不跟着走。
* (当时键是全局常量 `agentmail.background`)—— 同一台机器换账号,背景不跟着走。
* 2026-09-14 已按账号分键(见 `stores/backgroundStore.ts` 的 `storageKey`),
* 这条缺陷在 WebUI 侧也修掉了。
*
* 现在的分工:
* - **服务端**是权威(账号级,`/api/v1/me/appearance`);
@ -17,6 +19,7 @@
*/
import { fetchWithAuth } from '../api/config';
import { BLUR_MAX, DEFAULT_BLUR, DEFAULT_DIM, DIM_MAX } from './appearanceDefaults';
/** 服务端外观的形状(字段名与 handlers/appearance.go 的 JSON 一致)。 */
export interface AppearanceResponse {
@ -65,8 +68,8 @@ export function snapshotFromResponse(resp: AppearanceResponse | null | undefined
theme: (THEMES.has(String(r.theme)) ? r.theme : 'system') as AppearanceSnapshot['theme'],
bgKind: (KINDS.has(String(r.bg_kind)) ? r.bg_kind : 'none') as AppearanceSnapshot['bgKind'],
bgPresetId: typeof r.bg_preset_id === 'string' && r.bg_preset_id ? r.bg_preset_id : 'aurora',
bgDim: clamp(r.bg_dim, 0, 90, 12),
bgBlur: clamp(r.bg_blur, 0, 40, 4)
bgDim: clamp(r.bg_dim, 0, DIM_MAX, DEFAULT_DIM),
bgBlur: clamp(r.bg_blur, 0, BLUR_MAX, DEFAULT_BLUR)
};
}
@ -84,8 +87,8 @@ export function payloadFromLocal(input: {
theme: THEMES.has(input.theme) ? input.theme : 'system',
bg_kind: kind === 'image' && !input.imageDataUrl ? 'none' : kind,
bg_preset_id: input.presetId || 'aurora',
bg_dim: clamp(input.dim, 0, 90, 12),
bg_blur: clamp(input.blur, 0, 40, 4)
bg_dim: clamp(input.dim, 0, DIM_MAX, DEFAULT_DIM),
bg_blur: clamp(input.blur, 0, BLUR_MAX, DEFAULT_BLUR)
};
}

View File

@ -0,0 +1,35 @@
/**
* 外观的**默认值**:唯一来源,两个客户端与一份服务端契约共用同一个数。
*
* # 为什么单独一个模块
*
* 之前有两个地方各写一份:`stores/backgroundStore.ts` 的 `DEFAULT_BACKGROUND`
* 是 `dim: 24, blur: 8`,而 `lib/appearance.ts` 里 `clamp(..., 12, 4)` 的兜底是 `12 / 4`。
* 同一份代码里两个"默认值",**调用路径不同就落到不同的数**。
*
* 更关键的是:**权威值不在这两个文件里,而在服务端**。
* `server/internal/models/models.go` 的 `DefaultAppearance()` 明写 `BgDim: 12, BgBlur: 4`,
* 并且注释宣称"与客户端 backgroundStore / themeStore 的默认值一致" ——
* 那句注释在 WebUI 用 24/8 时**是假的**(pi 2026-09-14 指出,这是他对自己上一封"数值是审美"
* 的更正:它不是审美问题,是**契约**问题)。
*
* 后果不是"两处代码不一样"这么轻:服务端"没有记录"时客户端走"以本地为准并推上去",
* 于是**新账号的初始外观由第一个同步它的客户端决定** ——
* 先用 WebUI 登录存 24/8,先用鸿蒙登录存 12/4。同一个账号,压暗强度取决于谁先到。
*
* 所以这里定义一次,`lib/appearance.ts` 与 `stores/backgroundStore.ts` 都引用它;
* 判据 `test/appearance-defaults.test.mjs` 会**去 Go 源码里读**这两个数并比对,
* 这样服务端那句注释才是可核对的(不是承诺,是判据)。
*/
/** 压暗强度默认值(%)。与服务端 `DefaultAppearance().BgDim` 必须相等 */
export const DEFAULT_DIM = 12;
/** 模糊强度默认值(px)。与服务端 `DefaultAppearance().BgBlur` 必须相等 */
export const DEFAULT_BLUR = 4;
/** 压暗的合法上限(%)。服务端 `NormalizeAppearance` 用同一区间夹取值 */
export const DIM_MAX = 90;
/** 模糊的合法上限(px) */
export const BLUR_MAX = 40;