fix(webui): 修侧栏点击无法翻页 + 回信 UI 统一成一个组件

用户两句:「侧边导航栏完全不可用,点击无法翻页,而且窄屏显示不全」、
「按顺序做吧」(= 做回信 UI 统一)。

## ① 侧栏点击无法翻页(严重,我的错)

导航合并时我把点击目标写成:

    onClick={() => setViewMode(target || commTab || modes[0])}

只有「通信」有意回上次的子页签,而**日历/联系人没有 target** ⇒ 点它们会跳到
`commTab`(收件箱)⇒ 表现为"点了没反应/不翻页"。

修成 `setViewMode(target ?? (isComm ? commTab : modes[0]))`。

**为什么没被拦住**:我的验证全在看**结构与样式**(导航项数、徽标、圆角、玻璃、
对比度),**一次都没点过**。所以补了 `test/components/Sidebar.test.tsx`:点每一项,
断言落到它自己那一项,并带一条反向对照。真浏览器点击也复验:日历→日历页、
联系→联系人、通信→回通信页。

## ② 回信 UI 统一(用户点名的欠账)

新增 `src/components/Composer.tsx`:**形状**(输入区 / 动作行 / 提示)只有一处定义,
**差异**用 `header`(选项胶囊)、`footerExtra`、`submit.tone`、`density`
(compact=批注、roomy=回信正文)条件渲染 —— 差异是数据,不是又一套 UI。

三处各写一套的地方现在都走它:提问型授权表单、审批型(同意/拒绝)、ReplyBar 回信。
输入框的边框/圆角/聚焦环/禁用态从"抄了三遍"变成一处。

**重构时我引入过一个危险的错**:给审批型加了个"提交备注"按钮,兜底用 `options[0]`
(= 同意)⇒ 点一下就**默认批准**。被既有判据当场抓住(`PermissionPanel` 两条红),
已改成"点选项即提交"(`submit` 现在是可选的),并用 `variant="action"`
把同意/拒绝的颜色语义恢复成原来的实心绿 / 浅红。

## ③ 我自己的流程问题(写下来)

- 结构类改动必须配一条"**点它**"的判据 —— 这次就是缺了它。
- 单测里改 store 后必须 `rerender` 再点:否则闭包里是旧值(我第一版因此误判代码有问题)。
- 窄屏"显示不全"**没能复现**:390×844 与 320×568 都量了 —— 无横向溢出、
  内容面板完整落在悬浮导航之上、最后一行完整可见。需要用户指出具体页面。

判据:vitest **15 文件 / 258 用例全绿**(含新增 Sidebar 点击 4 条)。
This commit is contained in:
2026-09-14 12:26:06 +08:00
parent be0693821b
commit 5ce25f6fdb
4 changed files with 351 additions and 83 deletions

View File

@ -0,0 +1,62 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import Sidebar from '../../src/components/Sidebar';
import { useUIStore } from '../../src/stores/uiStore';
import { useAuthStore } from '../../src/stores/authStore';
import { useMailStore } from '../../src/stores/mailStore';
import { useContactStore } from '../../src/stores/contactStore';
/*
* 侧栏导航**点了要去对地方**。
*
* 这个判据是补写的:用户报「侧边导航栏完全不可用,点击无法翻页」——
* 根因是「通信」合并成一项后我写成 `setViewMode(target || commTab || modes[0])`
* 而日历/联系人没有 `target` ⇒ 点它们会跳到 `commTab`(收件箱)。
*
* 我当时的验证只看了 DOM 结构与样式(导航项数、徽标、圆角、玻璃),
* **一次都没点过**,所以这个 bug 一路到了线上。结构类判据必须配一条"点它"的判据。
*/
describe('侧栏导航点击目标', () => {
let view: ReturnType<typeof render>;
beforeEach(() => {
useUIStore.getState().reset();
useAuthStore.setState({ user: { username: 'gui-lab', role: 'admin' } as never });
useMailStore.setState({ inbox: [] });
useContactStore.setState({ contacts: [] });
view = render(<Sidebar />);
});
// 按**可见文字**找按钮(不用 getByTitletitle 属性在这个环境里查不到,
// 而按钮文字就是用户点的东西 —— 判据要贴住用户实际看到的那一层)
const click = (label: string) => {
const btn = [...document.querySelectorAll('button')].find(b =>
(b.textContent || '').trim() === label
);
if (!btn) throw new Error(`找不到导航项:${label}(现有:${[...document.querySelectorAll('button')].map(b => b.textContent?.trim()).join('/')}`);
fireEvent.click(btn);
return useUIStore.getState().viewMode;
};
it('点「日历」去日历(不是回通信)', () => {
expect(click('日历')).toBe('calendar');
});
it('点「联系」去联系人', () => {
expect(click('联系')).toBe('contacts');
});
it('点「通信」去通信上次用的子页签', () => {
// ★ 改 store 之后必须**重新渲染**再点:否则组件的点击闭包里还是旧的 commTab
// (我第一版没 rerender拿到 'inbox',差点误判成代码有问题)。
// 这里也说明了另一件事:内部页签渲染在 App 里而不是 Sidebar 里,
// 所以 Sidebar 的单测无法通过"点页签"来设置它。
useUIStore.getState().setViewMode('sent');
view.rerender(<Sidebar />);
expect(click('通信')).toBe('sent');
});
it('★ 反向对照:从日历点「通信」不会停在日历', () => {
useUIStore.getState().setViewMode('calendar');
expect(click('通信')).toBe('inbox');
});
});