跨端: 接手 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:
@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
35
client/electron/src/lib/appearanceDefaults.ts
Normal file
35
client/electron/src/lib/appearanceDefaults.ts
Normal 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;
|
||||
@ -143,7 +143,18 @@ 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) void useAppearanceSync.getState().pull();
|
||||
if (state.activeId !== prev.activeId) {
|
||||
/*
|
||||
* 顺序:**先按新账号重读本地缓存,再拉服务端**。
|
||||
*
|
||||
* 为什么不能省这一步:服务端"没有记录"(`saved === false`)时 `pull()` 会
|
||||
* 走"以本地为准推上去"——那时"本地"必须已经是**这个账号**的值。
|
||||
* 全局键时代这里是上一个账号的值,于是新账号凭空继承了别人的外观,
|
||||
* 而且被写进了服务端(pi 2026-09-14 复核时点出的那条缺陷)。
|
||||
*/
|
||||
useBackgroundStore.getState().reloadForAccount();
|
||||
void useAppearanceSync.getState().pull();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubBg();
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { AGGREGATE_ID, isUsableAccount, useAccountStore } from './accountStore';
|
||||
import { DEFAULT_BLUR, DEFAULT_DIM } from '../lib/appearanceDefaults';
|
||||
|
||||
/**
|
||||
* 自定义背景。
|
||||
*
|
||||
@ -38,7 +41,43 @@ export interface BackgroundState {
|
||||
blur: number;
|
||||
}
|
||||
|
||||
export const STORAGE_KEY = 'agentmail.background';
|
||||
/**
|
||||
* 旧版本用的**全局**键。现在只用于**一次性迁移**(见 `readStored`)。
|
||||
*
|
||||
* ⚠️ 不要再往它写东西:全局键的后果是"切到服务端没有记录的账号"时,
|
||||
* `saved=false` 分支会把**上一个账号的外观**推上去(新账号"继承"了外观,
|
||||
* 而且写进了服务端)。鸿蒙侧一直是按账号分键的,这是 WebUI 侧的缺陷
|
||||
* (pi 2026-09-14 复核时点名:"鸿蒙是对的,别为对齐退回全局键")。
|
||||
*/
|
||||
export const LEGACY_STORAGE_KEY = 'agentmail.background';
|
||||
|
||||
/** 按账号的键前缀:实际键是 `<前缀><accountId>` */
|
||||
export const STORAGE_KEY_PREFIX = 'agentmail.background.';
|
||||
|
||||
/**
|
||||
* 本机当前**用于同步的那个账号**。
|
||||
*
|
||||
* 与 `accountStore.syncAuth()` 取同一个目标:聚合模式用第一个可用账号
|
||||
* (发信要有身份),否则用当前选中的账号。取不到(未登录/首屏)时返回空串,
|
||||
* 此时用一个匿名兜底键 —— 不能退回全局键,那正是要修的东西。
|
||||
*/
|
||||
export function activeAppearanceAccountId(): string {
|
||||
try {
|
||||
const st = useAccountStore.getState();
|
||||
const target =
|
||||
st.activeId === AGGREGATE_ID
|
||||
? st.accounts.find(a => isUsableAccount(a))
|
||||
: st.accounts.find(a => a.id === st.activeId);
|
||||
return target?.id ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 缓存键:**按账号**。传 accountId 便于判据与迁移直接验证 */
|
||||
export function storageKey(accountId: string = activeAppearanceAccountId()): string {
|
||||
return accountId ? `${STORAGE_KEY_PREFIX}${accountId}` : `${STORAGE_KEY_PREFIX}anonymous`;
|
||||
}
|
||||
|
||||
/** 预设清单。**渐变的实际色值定义在 index.css**,这里只有 id 与显示名。 */
|
||||
export const PRESETS: { id: string; label: string }[] = [
|
||||
@ -50,12 +89,18 @@ export const PRESETS: { id: string; label: string }[] = [
|
||||
{ id: 'mesh', label: '网格' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 默认背景。**数值来自服务端契约**(`server/internal/models/models.go` 的
|
||||
* `DefaultAppearance()`:`BgDim: 12, BgBlur: 4`),而不再是这里自己写一套 ——
|
||||
* 原来这里是 `24 / 8`,与 `lib/appearance.ts` 的兜底 `12 / 4` 不一致,
|
||||
* 于是"新账号的初始外观由第一个同步它的客户端决定"(详见 `lib/appearanceDefaults.ts`)。
|
||||
*/
|
||||
export const DEFAULT_BACKGROUND: BackgroundState = {
|
||||
kind: 'none',
|
||||
presetId: 'aurora',
|
||||
imageDataUrl: '',
|
||||
dim: 24,
|
||||
blur: 8
|
||||
dim: DEFAULT_DIM,
|
||||
blur: DEFAULT_BLUR
|
||||
};
|
||||
|
||||
/** 图片最长边。超过就等比缩小 —— 背景是满屏铺开的,再大也看不出来。 */
|
||||
@ -105,9 +150,25 @@ export function normalizeBackground(
|
||||
};
|
||||
}
|
||||
|
||||
export function readStored(): BackgroundState {
|
||||
/**
|
||||
* 读本机缓存。**按账号**读,并做一次旧全局键的迁移。
|
||||
*
|
||||
* 迁移只做一次、只给**当前账号**:旧值属于"这台机器上当时那个账号",
|
||||
* 把它送给当前账号是合理的(用户的观感不会凭空消失),然后**立刻删掉旧键** ——
|
||||
* 否则下一个账号又会从它那里"继承",等于把刚修掉的缺陷留在原地。
|
||||
*/
|
||||
export function readStored(accountId: string = activeAppearanceAccountId()): BackgroundState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const key = storageKey(accountId);
|
||||
let raw = localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
const legacy = accountId ? localStorage.getItem(LEGACY_STORAGE_KEY) : null;
|
||||
if (legacy) {
|
||||
localStorage.setItem(key, legacy);
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
raw = legacy;
|
||||
}
|
||||
}
|
||||
if (!raw) return DEFAULT_BACKGROUND;
|
||||
return normalizeBackground(JSON.parse(raw));
|
||||
} catch {
|
||||
@ -155,11 +216,14 @@ interface BackgroundStore extends BackgroundState {
|
||||
setDim: (v: number) => void;
|
||||
setBlur: (v: number) => void;
|
||||
reset: () => void;
|
||||
/** 切换账号后按新账号重读本地缓存(必须在 pull() 之前) */
|
||||
reloadForAccount: () => void;
|
||||
}
|
||||
|
||||
function persist(state: BackgroundState) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
// 按账号写:换账号后落到各自的键,互不串味
|
||||
localStorage.setItem(storageKey(), JSON.stringify(state));
|
||||
} catch {
|
||||
// 配额满:本次会话仍生效,只是下次打开会退回默认值。
|
||||
// 不抛给调用方 —— 背景是装饰,不该让「换背景失败」打断任何操作。
|
||||
@ -185,7 +249,21 @@ export const useBackgroundStore = create<BackgroundStore>((set, get) => {
|
||||
setImage: imageDataUrl => commit({ imageDataUrl, kind: 'image' }),
|
||||
setDim: dim => commit({ dim: clampDim(dim) }),
|
||||
setBlur: blur => commit({ blur: clampBlur(blur) }),
|
||||
reset: () => commit({ ...DEFAULT_BACKGROUND })
|
||||
reset: () => commit({ ...DEFAULT_BACKGROUND }),
|
||||
|
||||
/**
|
||||
* 切换账号后**重读本账号的本地缓存**。
|
||||
*
|
||||
* 顺序很重要:重读必须发生在 `pull()` **之前** —— 服务端"没有记录"时
|
||||
* 调用方会"以本地为准推上去",而那时"本地"必须已经是**这个账号**的值,
|
||||
* 不能还是上一个账号的(那正是全局键时代的缺陷:新账号继承上一个人的外观)。
|
||||
*/
|
||||
reloadForAccount: () => {
|
||||
const next: BackgroundState = readStored();
|
||||
// 不 persist:这只是"把本账号已有的缓存读回来",不是用户的改动
|
||||
applyBackground(next);
|
||||
set(next);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user