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)。
354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
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。一张手机直出照片 4–8MB,而 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;
|
||
/** 压暗强度 0–80(百分比)。 */
|
||
dim: number;
|
||
/** 模糊强度 0–24(px)。 */
|
||
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 };
|
||
}
|