diff --git a/client/electron/src/components/Sidebar.tsx b/client/electron/src/components/Sidebar.tsx
index a27b412..c0ae965 100644
--- a/client/electron/src/components/Sidebar.tsx
+++ b/client/electron/src/components/Sidebar.tsx
@@ -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() {
+ {/*
+ 品牌标记放在侧栏顶端。
+ 在放到这里之前,应用内**没有任何一处**品牌标识 —— 登录页与首启页各有一个
+ BrandMarkIcon,但两者都是登录前才看得到的界面,日常使用(登录后)从头到尾
+ 见不到产品图标,只有浏览器页签的 favicon。
+ 点它回收件箱,与邮箱类应用的一致习惯相符(点左上角 logo 回家)。
+ */}
+
+
{navItems
.filter(n => !n.adminOnly || isAdmin)
.map(({ short, title, mode, Icon }) => {
diff --git a/client/electron/src/stores/backgroundStore.ts b/client/electron/src/stores/backgroundStore.ts
index 44d42ea..87a453e 100644
--- a/client/electron/src/stores/backgroundStore.ts
+++ b/client/electron/src/stores/backgroundStore.ts
@@ -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;
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((set, get) => {
/** 统一的提交口:先落 DOM,再持久化,最后更新 state。 */
const commit = (patch: Partial) => {
- const next: BackgroundState = normalizeBackground({ ...get(), ...patch });
+ // keepEmptyImage:交互过程中的瞬态要保留(见 normalizeBackground 的说明)。
+ // 落盘后若仍是空图片,下次启动由 readStored 的严格归一化退回 none —— 静止态
+ // 的不变量没有放松,放松的只是"正在选图"这一瞬间。
+ const next: BackgroundState = normalizeBackground({ ...get(), ...patch }, { keepEmptyImage: true });
applyBackground(next);
persist(next);
set(next);
diff --git a/client/electron/test/components/BackgroundPicker.test.tsx b/client/electron/test/components/BackgroundPicker.test.tsx
new file mode 100644
index 0000000..da33e9e
--- /dev/null
+++ b/client/electron/test/components/BackgroundPicker.test.tsx
@@ -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();
+ // 初始是「无」:没有文件输入
+ 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();
+ 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();
+ 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();
+ await userEvent.click(screen.getByRole('button', { name: '无' }));
+ expect(useBackgroundStore.getState().kind).toBe('none');
+ expect(document.documentElement.dataset.bg).toBe('off');
+ });
+});
diff --git a/client/electron/test/stores/background.test.ts b/client/electron/test/stores/background.test.ts
index 8433355..de6b189 100644
--- a/client/electron/test/stores/background.test.ts
+++ b/client/electron/test/stores/background.test.ts
@@ -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,至少状态是诚实的