Files
MailUI4Agents/client/electron/src/stores/backgroundStore.ts
JianFeeeee e94313f66a 跨端: 接手 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)。
2026-09-14 15:17:01 +08:00

354 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { create } from 'zustand';
import { AGGREGATE_ID, isUsableAccount, useAccountStore } from './accountStore';
import { DEFAULT_BLUR, DEFAULT_DIM } from '../lib/appearanceDefaults';
/**
* 自定义背景。
*
* 三件事分开表达,因为它们可以组合:
* - `kind` 背景来源(不设 / 预设渐变 / 自定义图片)
* - `dim` 压暗强度 —— 背景越花,正文越需要一层遮罩才读得动
* - `blur` 模糊强度 —— 图片作背景时通常要虚化,否则细节会跟正文抢注意力
*
* # 为什么背景不放进主题 store
*
* 主题light/dark/system是**必须全局一致**的语义:同一个界面里不能一半深色
* 一半浅色。背景是**纯装饰偏好**,可以随时关掉而不影响任何功能,而且它的取值
* 空间(预设 id / 图片数据 / 两个数值)与主题毫无关系。塞在一起会让主题 store
* 承担两种生命周期的状态,也会让「跟随系统」的实现被背景字段淹没。
*
* # 为什么图片要压缩后再存
*
* 存 localStorage。一张手机直出照片 48MB而 localStorage 配额通常只有 5MB
* 写失败会抛异常,用户看到的是「选了图片但没反应」。所以在**存入之前**先等比
* 缩到 MAX_EDGE 并转 JPEG超限则明确拒绝并告知而不是静默失败。
* (不使用 IndexedDB它的异步/事务模型会把这个纯展示功能复杂化,而压缩后
* 的尺寸已经足够小。)
*/
export type BackgroundKind = 'none' | 'preset' | 'image';
export interface BackgroundState {
kind: BackgroundKind;
/** 预设 id如 'aurora')。仅 kind === 'preset' 时有效。 */
presetId: string;
/** 压缩后的 data URL。仅 kind === 'image' 时有效。 */
imageDataUrl: string;
/** 压暗强度 080百分比。 */
dim: number;
/** 模糊强度 024px。 */
blur: number;
}
/**
* 旧版本用的**全局**键。现在只用于**一次性迁移**(见 `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 }[] = [
{ id: 'aurora', label: '极光' },
{ id: 'dusk', label: '暮色' },
{ id: 'mint', label: '薄荷' },
{ id: 'sand', label: '沙丘' },
{ id: 'ink', label: '墨色' },
{ 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: DEFAULT_DIM,
blur: DEFAULT_BLUR
};
/** 图片最长边。超过就等比缩小 —— 背景是满屏铺开的,再大也看不出来。 */
export const MAX_EDGE = 2560;
/** 压缩后 data URL 的长度上限(约 2.4MB 文本),留足 localStorage 余量。 */
export const MAX_DATA_URL_BYTES = 2_400_000;
export function clampDim(v: number): number {
if (!Number.isFinite(v)) return DEFAULT_BACKGROUND.dim;
return Math.min(80, Math.max(0, Math.round(v)));
}
export function clampBlur(v: number): number {
if (!Number.isFinite(v)) return DEFAULT_BACKGROUND.blur;
return Math.min(24, Math.max(0, Math.round(v)));
}
/**
* 归一化背景状态。
*
* `keepEmptyImage` 区分**两种都合法**的场景:
* - 默认false读磁盘。`kind:'image'` 却没有图片数据 = 脏数据
* (被清理过/写坏),退回 `none`,不留一个"显示已选图片但什么都没有"的空壳。
* - true用户**正在选择**图片的过程中。此时 `kind:'image'` + 空数据是
* 合法瞬态 —— 上传控件只在这一档下渲染,把它折叠回 none 就等于
* "点了「图片」什么都没发生、背景反而被关掉",用户根本走不到选文件那一步。
*/
export function normalizeBackground(
raw: unknown,
opts: { keepEmptyImage?: boolean } = {}
): BackgroundState {
const o = (typeof raw === 'object' && raw !== null ? raw : {}) as Partial<BackgroundState>;
const kind: BackgroundKind =
o.kind === 'preset' || o.kind === 'image' || o.kind === 'none' ? o.kind : 'none';
const presetId = PRESETS.some(p => p.id === o.presetId) ? String(o.presetId) : DEFAULT_BACKGROUND.presetId;
const imageDataUrl = typeof o.imageDataUrl === 'string' && o.imageDataUrl.startsWith('data:image/')
? o.imageDataUrl
: '';
return {
// 选了 image 却没有可用图片:读盘时视为脏数据退回不设;
// 用户正在选图时则保留(否则上传控件永远不会出现)
kind: kind === 'image' && !imageDataUrl && !opts.keepEmptyImage ? 'none' : kind,
presetId,
imageDataUrl,
dim: clampDim(o.dim ?? DEFAULT_BACKGROUND.dim),
blur: clampBlur(o.blur ?? DEFAULT_BACKGROUND.blur)
};
}
/**
* 读本机缓存。**按账号**读,并做一次旧全局键的迁移。
*
* 迁移只做一次、只给**当前账号**:旧值属于"这台机器上当时那个账号"
* 把它送给当前账号是合理的(用户的观感不会凭空消失),然后**立刻删掉旧键** ——
* 否则下一个账号又会从它那里"继承",等于把刚修掉的缺陷留在原地。
*/
export function readStored(accountId: string = activeAppearanceAccountId()): BackgroundState {
try {
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 {
// 隐私模式或脏 JSON退回默认背景页面照常可用
return DEFAULT_BACKGROUND;
}
}
/**
* 把背景写进 DOM。
*
* 用 CSS 变量 + 一个 `data-bg` 标记,而不是给每个组件加 class
* 全站有 27 个组件,逐个改不现实,漏一处就是「一块不透明卡片浮在背景上」。
* 变量定义在 index.css玻璃化处理也集中在那里。
*/
export function applyBackground(state: BackgroundState = readStored()): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const active = state.kind !== 'none' && (state.kind === 'preset' || !!state.imageDataUrl);
root.dataset.bg = active ? 'on' : 'off';
root.style.setProperty('--bg-dim', `${clampDim(state.dim)}%`);
root.style.setProperty('--bg-blur', `${clampBlur(state.blur)}px`);
if (!active) {
root.style.removeProperty('--bg-image');
root.classList.remove(...PRESETS.map(p => `bg-preset-${p.id}`));
return;
}
root.classList.remove(...PRESETS.map(p => `bg-preset-${p.id}`));
if (state.kind === 'image') {
root.style.setProperty('--bg-image', `url("${state.imageDataUrl}")`);
} else {
// 预设的渐变由 class 提供,避免把色值写进 JS写死十六进制就绕过了主题变量
// 深色模式下会原样落下浅色渐变 —— 与组件里写死颜色是同一类错误)。
root.style.removeProperty('--bg-image');
root.classList.add(`bg-preset-${state.presetId}`);
}
}
interface BackgroundStore extends BackgroundState {
setKind: (kind: BackgroundKind) => void;
setPreset: (presetId: string) => void;
setImage: (dataUrl: string) => void;
setDim: (v: number) => void;
setBlur: (v: number) => void;
reset: () => void;
/** 切换账号后按新账号重读本地缓存(必须在 pull() 之前) */
reloadForAccount: () => void;
}
function persist(state: BackgroundState) {
try {
// 按账号写:换账号后落到各自的键,互不串味
localStorage.setItem(storageKey(), JSON.stringify(state));
} catch {
// 配额满:本次会话仍生效,只是下次打开会退回默认值。
// 不抛给调用方 —— 背景是装饰,不该让「换背景失败」打断任何操作。
}
}
export const useBackgroundStore = create<BackgroundStore>((set, get) => {
/** 统一的提交口:先落 DOM再持久化最后更新 state。 */
const commit = (patch: Partial<BackgroundState>) => {
// keepEmptyImage交互过程中的瞬态要保留见 normalizeBackground 的说明)。
// 落盘后若仍是空图片,下次启动由 readStored 的严格归一化退回 none —— 静止态
// 的不变量没有放松,放松的只是"正在选图"这一瞬间。
const next: BackgroundState = normalizeBackground({ ...get(), ...patch }, { keepEmptyImage: true });
applyBackground(next);
persist(next);
set(next);
};
return {
...readStored(),
setKind: kind => commit({ kind }),
setPreset: presetId => commit({ presetId, kind: 'preset' }),
setImage: imageDataUrl => commit({ imageDataUrl, kind: 'image' }),
setDim: dim => commit({ dim: clampDim(dim) }),
setBlur: blur => commit({ blur: clampBlur(blur) }),
reset: () => commit({ ...DEFAULT_BACKGROUND }),
/**
* 切换账号后**重读本账号的本地缓存**。
*
* 顺序很重要:重读必须发生在 `pull()` **之前** —— 服务端"没有记录"时
* 调用方会"以本地为准推上去",而那时"本地"必须已经是**这个账号**的值,
* 不能还是上一个账号的(那正是全局键时代的缺陷:新账号继承上一个人的外观)。
*/
reloadForAccount: () => {
const next: BackgroundState = readStored();
// 不 persist这只是"把本账号已有的缓存读回来",不是用户的改动
applyBackground(next);
set(next);
}
};
});
/**
* 把 disk 上的背景在首屏套用一次。
*
* 不做「内联脚本防闪屏」:背景是装饰层,晚一帧出现只是不够顺滑,
* 不会像主题那样闪出刺眼的白底(主题已有 index.html 的内联脚本)。
*/
export function initBackground(): void {
applyBackground(readStored());
}
/**
* 读取用户选择的图片并压缩成可存储的 data URL。
*
* 失败一律返回带原因的 `{ ok: false }` 而不是抛异常 —— 调用方需要在界面上
* 说明「为什么没换成」,静默失败会让人以为按钮坏了。
*/
export async function prepareImage(
file: File
): Promise<{ ok: true; dataUrl: string } | { ok: false; reason: string }> {
if (!file.type.startsWith('image/')) {
return { ok: false, reason: '请选择图片文件' };
}
// 已压缩过的上限:单张原始文件超过 20MB 就不必读了,解码本身会卡住主线程
if (file.size > 20 * 1024 * 1024) {
return { ok: false, reason: '图片过大(超过 20MB请先裁剪' };
}
if (typeof document === 'undefined') {
return { ok: false, reason: '当前环境不支持图片处理' };
}
try {
const bitmap = await loadImage(file);
const { canvas, width, height } = drawScaled(bitmap, MAX_EDGE);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
if (dataUrl.length > MAX_DATA_URL_BYTES) {
// 缩小一档再试一次。直接拒绝会让「一张 4K 照片」这种完全正常的需求不可用。
const smaller = drawScaled(bitmap, Math.round(MAX_EDGE / 2));
const retry = smaller.canvas.toDataURL('image/jpeg', 0.78);
if (retry.length > MAX_DATA_URL_BYTES) {
return { ok: false, reason: '图片压缩后仍过大,请换一张更小的图片' };
}
return { ok: true, dataUrl: retry };
}
void width;
void height;
return { ok: true, dataUrl };
} catch {
return { ok: false, reason: '图片读取失败,请换一张试试' };
}
}
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('decode failed'));
};
img.src = url;
});
}
/** 等比缩放到最长边不超过 maxEdge并画出到 canvas。 */
function drawScaled(img: HTMLImageElement, maxEdge: number) {
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
const scale = Math.min(1, maxEdge / Math.max(w, h || 1));
const width = Math.max(1, Math.round(w * scale));
const height = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0, width, height);
}
return { canvas, width, height };
}