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,至少状态是诚实的