fix(webui): 自定义背景完全不可用 —— 「图片」档进不去

现象:点「图片」后背景反而被关掉,上传控件永远不出现 ⇒ 自定义图片在 UI 上
完全不可达(用户看到的正是"自定义背景不正常")。

根因:`normalizeBackground` 把「kind=image 但还没有图片数据」折叠成 `none`
(这条判据本身是对的 —— 读盘时那确实是脏数据),但 store 的 `commit()` 每次
patch 都要过一遍它,于是 `setKind('image')` 这一瞬间就被折叠回去;而上传控件
只在 `kind === 'image'` 下渲染 ⇒ 鸡生蛋问题,用户永远走不到选文件那一步。

修法:给归一化加 `keepEmptyImage`。
  - 读盘(`readStored`)保持严格:空图片状态是脏数据,退回 none。
  - 交互(`commit`)保留瞬态:允许"已选图片档、还没挑文件"这个中间状态存在。
静止态的不变量没有放松,放松的只是正在选图的那一瞬间;`applyBackground` 对
空图片本来就不铺开(不会出现 `url("")`)。

判据(都验过"修复前会红"):
  · 新增 `test/components/BackgroundPicker.test.tsx` —— 点「图片」后选文件控件
    必须出现、选完图背景必须亮、失败必须说原因、选「无」必须能关掉。
    **扰动验证**:把修复撤掉 → 组件 3 红 + store 1 红;恢复 → 22 全绿。
  · `test/stores/background.test.ts` 补 2 条:交互进入图片档要留住 /
    瞬态落盘后重读必须退回 none。

线上验证(真浏览器,部署后):线上 bundle 换成 index-CSFGa8wa.js 后 ——
点「图片」→ `kind=image` 保持 → 上传 16KB 小图与 9MB 大图都成功
(大图压缩到 1790KB)→ 刷新后仍在 → 全程无页面错误。

顺带:应用内**从未出现**过品牌图标。`BrandMarkIcon` 只用在登录页与首启页
(都是登录前界面),登录后的日常界面里一处都没有。侧栏顶端加上品牌标记
(点它回收件箱)。favicon 那条链本来就是好的(3 个 link 都 200、类型正确、
图标内容正确),已在验证中确认。
This commit is contained in:
2026-09-13 08:55:55 +08:00
parent 085e6384a0
commit 5804ba4f63
4 changed files with 144 additions and 6 deletions

View File

@ -10,7 +10,8 @@ import {
UsersIcon,
LogoutIcon,
ShieldIcon,
CalendarIcon
CalendarIcon,
BrandMarkIcon
} from './icons';
import { ConnectionIndicator } from './ConnectionIndicator';
import { ThemeToggleButton } from './ThemePicker';
@ -56,6 +57,24 @@ export default function Sidebar() {
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-chrome-900"
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))' }}
>
{/*
品牌标记放在侧栏顶端。
在放到这里之前,应用内**没有任何一处**品牌标识 —— 登录页与首启页各有一个
BrandMarkIcon但两者都是登录前才看得到的界面日常使用登录后从头到尾
见不到产品图标,只有浏览器页签的 favicon。
点它回收件箱,与邮箱类应用的一致习惯相符(点左上角 logo 回家)。
*/}
<button
type="button"
data-testid="brand-mark"
onClick={() => setViewMode('inbox')}
title="AgentMail"
aria-label="AgentMail 首页"
className="w-10 h-10 shrink-0 mb-1 rounded-xl bg-white/10 text-white flex items-center justify-center hover:bg-white/15 active:bg-white/20 transition-colors"
>
<BrandMarkIcon className="w-5 h-5" />
</button>
{navItems
.filter(n => !n.adminOnly || isAdmin)
.map(({ short, title, mode, Icon }) => {

View File

@ -73,8 +73,20 @@ export function clampBlur(v: number): number {
return Math.min(24, Math.max(0, Math.round(v)));
}
/** 归一化磁盘上可能存在的脏数据(旧版本、手改 localStorage、字段缺失。 */
export function normalizeBackground(raw: unknown): BackgroundState {
/**
* 归一化背景状态。
*
* `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';
@ -83,8 +95,9 @@ export function normalizeBackground(raw: unknown): BackgroundState {
? o.imageDataUrl
: '';
return {
// 选了 image 却没有可用图片(被清理/写坏)→ 退回不设,而不是留一个空壳状态
kind: kind === 'image' && !imageDataUrl ? 'none' : kind,
// 选了 image 却没有可用图片:读盘时视为脏数据退回不设;
// 用户正在选图时则保留(否则上传控件永远不会出现)
kind: kind === 'image' && !imageDataUrl && !opts.keepEmptyImage ? 'none' : kind,
presetId,
imageDataUrl,
dim: clampDim(o.dim ?? DEFAULT_BACKGROUND.dim),
@ -156,7 +169,10 @@ function persist(state: BackgroundState) {
export const useBackgroundStore = create<BackgroundStore>((set, get) => {
/** 统一的提交口:先落 DOM再持久化最后更新 state。 */
const commit = (patch: Partial<BackgroundState>) => {
const next: BackgroundState = normalizeBackground({ ...get(), ...patch });
// keepEmptyImage交互过程中的瞬态要保留见 normalizeBackground 的说明)。
// 落盘后若仍是空图片,下次启动由 readStored 的严格归一化退回 none —— 静止态
// 的不变量没有放松,放松的只是"正在选图"这一瞬间。
const next: BackgroundState = normalizeBackground({ ...get(), ...patch }, { keepEmptyImage: true });
applyBackground(next);
persist(next);
set(next);

View File

@ -0,0 +1,79 @@
/**
* 背景选择器的**界面可达性**判据。
*
* 为什么需要这一层:`stores/background.test.ts` 只钉了 store 的状态机,
* 而线上真实缺陷恰好落在"状态 → 控件"这一跳上 ——
* 点「图片」时 kind 被折叠回 `none`,于是上传控件(只在 `kind === 'image'`
* 下渲染)**从不出现**store 的单测全绿,用户却完全无法自定义背景。
*
* 所以这里测的是"用户走得到走不到":点「图片」→ 选文件按钮必须出现 →
* 选完图背景必须真的亮起来。
*/
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BackgroundPicker from '../../src/components/BackgroundPicker';
import { STORAGE_KEY, useBackgroundStore } from '../../src/stores/backgroundStore';
import * as store from '../../src/stores/backgroundStore';
describe('BackgroundPicker', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-bg');
document.documentElement.className = '';
document.documentElement.removeAttribute('style');
useBackgroundStore.setState({ ...store.DEFAULT_BACKGROUND });
vi.restoreAllMocks();
});
it('★ 点「图片」后必须出现选文件控件(否则自定义背景根本走不到)', async () => {
render(<BackgroundPicker />);
// 初始是「无」:没有文件输入
expect(document.querySelector('input[type=file]')).toBeNull();
await userEvent.click(screen.getByRole('button', { name: '图片' }));
expect(useBackgroundStore.getState().kind).toBe('image');
expect(document.querySelector('input[type=file]')).not.toBeNull();
expect(screen.getByRole('button', { name: /选择图片|更换图片/ })).toBeInTheDocument();
});
it('★ 选完图片后背景真的亮起来,并落盘', async () => {
vi.spyOn(store, 'prepareImage').mockResolvedValue({
ok: true,
dataUrl: 'data:image/jpeg;base64,ZZZZ'
});
render(<BackgroundPicker />);
await userEvent.click(screen.getByRole('button', { name: '图片' }));
const input = document.querySelector('input[type=file]') as HTMLInputElement;
const file = new File(['x'], 'bg.jpg', { type: 'image/jpeg' });
await userEvent.upload(input, file);
await waitFor(() => expect(document.documentElement.dataset.bg).toBe('on'));
expect(document.documentElement.style.getPropertyValue('--bg-image')).toContain('ZZZZ');
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).kind).toBe('image');
});
it('图片准备失败时说出原因,并且不假装成功', async () => {
vi.spyOn(store, 'prepareImage').mockResolvedValue({ ok: false, reason: '图片过大(超过 20MB请先裁剪' });
render(<BackgroundPicker />);
await userEvent.click(screen.getByRole('button', { name: '图片' }));
const input = document.querySelector('input[type=file]') as HTMLInputElement;
await userEvent.upload(input, new File(['x'], 'bg.jpg', { type: 'image/jpeg' }));
expect(await screen.findByRole('alert')).toHaveTextContent('图片过大');
// 没换成就不该亮背景
expect(document.documentElement.dataset.bg).toBe('off');
});
it('「无」把背景关掉(对照:图片档不是永久卡住)', async () => {
useBackgroundStore.getState().setImage('data:image/png;base64,YYYY');
render(<BackgroundPicker />);
await userEvent.click(screen.getByRole('button', { name: '无' }));
expect(useBackgroundStore.getState().kind).toBe('none');
expect(document.documentElement.dataset.bg).toBe('off');
});
});

View File

@ -7,6 +7,7 @@ import {
clampBlur,
clampDim,
normalizeBackground,
readStored,
useBackgroundStore
} from '../../src/stores/backgroundStore';
@ -62,6 +63,29 @@ describe('脏数据归一化', () => {
);
});
it('★ 交互进入「图片」档时必须留住(否则上传控件永远不出现)', () => {
// 这是线上真实缺陷:点「图片」→ commit 里那次严格归一化把 kind 折叠回 none
// → 上传控件(只在 kind==='image' 下渲染)从不出现,自定义图片完全不可达,
// 而且背景被顺手关掉(用户看到的正是"点了没反应、背景还没了")。
useBackgroundStore.getState().reset();
useBackgroundStore.getState().setKind('image');
expect(useBackgroundStore.getState().kind).toBe('image');
// 反向对照:没有图片数据时**不该**真的铺开背景(静止态的不变量仍在)
expect(document.documentElement.dataset.bg).toBe('off');
expect(document.documentElement.style.getPropertyValue('--bg-image')).toBe('');
});
it('★ 瞬态里的空图片状态被持久化后,重读必须退回 none静止态不放松', () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ kind: 'image', imageDataUrl: '', dim: 30, blur: 10 }));
const back = readStored();
expect(back.kind).toBe('none');
// 用户接着选图片仍能成功(不是"坏状态卡死"
useBackgroundStore.getState().setImage('data:image/jpeg;base64,EEEE');
expect(useBackgroundStore.getState().kind).toBe('image');
expect(document.documentElement.dataset.bg).toBe('on');
});
it('kind=image 但没有可用图片时退回 none', () => {
// 图片可能被浏览器清理或写坏。留一个空壳 image 状态会让界面显示「已选图片」
// 却什么都没有 —— 不如退回 none至少状态是诚实的