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,210 @@
import React from 'react';
import { CheckIcon, CloseIcon, SpinnerIcon } from './icons';
/*
* 回信/批注/写信的**共用 UI**。
*
* 用户2026-09-14「现在多个页面各自独立实现了一套回信 ui是不是可以统一一下
* 通过条件渲染的方式,使用同一个 ui」。
*
* 当时现场有三套各写各的:
* ① MailView 的 ReplyBar回信多行 textarea + 发送)
* ② MailView 的授权表单(提问型:选项胶囊 + textarea + 提交;审批型:按钮 + 单行备注)
* ③ ComposePage 的正文输入(写信)
* 三处的边框/圆角/聚焦环/禁用态/忙碌态/提示文案都是**分别写的**,于是很快就漂移了
* (同一个输入框有三种 padding同一类提交按钮有两种禁用透明度
*
* 这个组件的做法是:把**形状**(输入区 + 动作行 + 提示)固定下来,把**差异**
* 用 `header` / `footerExtra` / `submit.tone` 条件渲染。差异是数据,不是又一套 UI。
*/
export type ComposerTone = 'primary' | 'approve' | 'danger';
export interface ComposerSubmit {
label: string;
onClick: () => void;
disabled?: boolean;
busy?: boolean;
tone?: ComposerTone;
}
export interface ComposerProps {
value: string;
onChange: (v: string) => void;
placeholder: string;
/** 多行textarea还是单行input——审批型的"备注"就是单行的 */
singleLine?: boolean;
rows?: number;
/**
* 输入区的**具名档位**。
*
* compact默认批注、备注这类"随手写一句"。
* roomy回信要写正文需要更大面积 + 等宽字体Markdown 对齐)。
* 两种都在这里定义 —— 让"大小差异"成为一处具名档位,而不是散落的写死值。
*/
density?: 'compact' | 'roomy';
/** 输入区**上方**的条件内容:选项胶囊、收件人摘要等 */
header?: React.ReactNode;
/** 动作行左侧的额外内容:附件、预算等 */
footerExtra?: React.ReactNode;
/**
* 提交动作。**可以不给** —— 审批型就是"点选项即提交",多一个提交按钮反而危险
* (我第一版给它加了一个默认提交 `options[0]`,那等于点一下"提交备注"就批准 ✗)。
*/
submit?: ComposerSubmit;
/** 提交按钮旁边的说明(例如"请先选择或填写回答" */
hint?: React.ReactNode;
/** 错误信息(发送失败等) */
error?: string | null;
/** 自动聚焦(窄屏从悬浮球展开时用:少一次点击) */
autoFocus?: boolean;
className?: string;
}
const TONE: Record<ComposerTone, string> = {
primary: 'bg-blue-700 text-white hover:bg-blue-800',
approve: 'bg-green-700 text-white hover:bg-green-800',
danger: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
};
// 输入区**只有这一处**样式定义:三套 UI 漂移的根源就是它被抄了三遍
const FIELD_BASE =
'w-full text-gray-900 border border-gray-300 rounded-md ' +
'placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-100 ' +
'focus:border-blue-400 disabled:opacity-40';
const FIELD_DENSITY: Record<'compact' | 'roomy', string> = {
compact: 'text-xs px-2.5 py-1.5',
roomy: 'text-sm font-mono p-3 h-20 resize-none'
};
const fieldClass = (density: 'compact' | 'roomy', multi: boolean) =>
`${FIELD_BASE} ${FIELD_DENSITY[density]}${multi && density === 'compact' ? ' resize-y' : ''}`;
export function Composer({
value,
onChange,
placeholder,
singleLine = false,
rows = 2,
density = 'compact',
header,
footerExtra,
submit,
hint,
error,
autoFocus = false,
className = ''
}: ComposerProps) {
const busy = submit?.busy === true;
const tone = submit?.tone ?? 'primary';
return (
<div className={className}>
{/* 条件渲染的差异部分:选项胶囊 / 收件人摘要 */}
{header}
{singleLine ? (
<input
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
disabled={busy}
autoFocus={autoFocus}
className={`${header ? 'mt-2 ' : ''}${fieldClass(density, false)}`}
/>
) : (
<textarea
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
disabled={busy}
autoFocus={autoFocus}
className={`${header ? 'mt-2 ' : ''}${fieldClass(density, true)}`}
/>
)}
{(submit || footerExtra || hint) && (
<div className="mt-2 flex items-center gap-2 flex-wrap">
{submit && (
<button
type="button"
onClick={submit.onClick}
disabled={submit.disabled === true || busy}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${TONE[tone]}`}
>
{busy ? <SpinnerIcon className="w-3.5 h-3.5 animate-spin" /> : null}
{submit.label}
</button>
)}
{footerExtra}
{hint && <span className="text-2xs text-gray-400">{hint}</span>}
</div>
)}
{error && <p className="mt-1.5 text-2xs text-red-600">{error}</p>}
</div>
);
}
/**
* 选项胶囊(授权提问型与审批型共用)。
*
* 单独导出是因为它在**同一个 Composer 的 header 里**要能多选/单选、还要按语义
* 选颜色(同意=绿、拒绝=红、其它=灰)。这三种状态本来就属于"选项"这个概念,
* 不属于某一个页面。
*/
export function ComposerChip({
label,
active,
disabled,
onSelect,
tone = 'neutral',
variant = 'toggle'
}: {
label: string;
active?: boolean;
disabled?: boolean;
onSelect: () => void;
tone?: 'neutral' | 'approve' | 'danger';
/**
* toggle提问型的多选/单选胶囊 —— 选中才填色,未选中是淡淡的。
* action审批型的"同意/拒绝"—— 它本身就是动作按钮,按语义**一直**填色
* (同意=实心绿、拒绝=浅红),与重构前的观感一致。
*/
variant?: 'toggle' | 'action';
}) {
const activeCls =
tone === 'approve'
? 'bg-green-700 text-white border-green-700 hover:bg-green-800'
: tone === 'danger'
? 'bg-red-700 text-white border-red-700 hover:bg-red-800'
: 'bg-blue-700 text-white border-blue-700 hover:bg-blue-800';
const idleCls =
tone === 'approve'
? 'bg-green-50 text-green-800 border-green-200 hover:bg-green-100'
: tone === 'danger'
? 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100'
: 'glass-control text-gray-700 border-gray-300 hover:bg-gray-50';
const actionCls =
tone === 'approve'
? 'bg-green-700 text-white border-green-700 hover:bg-green-800'
: 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100';
const isAction = variant === 'action';
return (
<button
type="button"
onClick={onSelect}
disabled={disabled}
aria-pressed={isAction ? undefined : active}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-40 ${
isAction ? actionCls : active ? activeCls : idleCls
}`}
>
{(isAction || active) && tone === 'approve' ? <CheckIcon className="w-3.5 h-3.5" /> : null}
{(isAction || active) && tone === 'danger' ? <CloseIcon className="w-3.5 h-3.5" /> : null}
{label}
</button>
);
}

View File

@ -14,7 +14,8 @@ import {
} from '../lib/replyTarget';
import * as api from '../api/client';
import type { Mail } from '../types';
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon, ChevronRightIcon, ChatBubbleIcon } from './icons';
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon, ChevronRightIcon, ChatBubbleIcon } from './icons';
import { Composer, ComposerChip } from './Composer';
import { useIsNarrow } from '../hooks/useIsNarrow';
import AddressInput from './AddressInput';
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
@ -754,60 +755,49 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
};
// 回答必须非空:空提交会让模型拿到一个什么都没说的结果继续跑。
const blank = picked.length === 0 && note.trim().length === 0;
const hint =
options.length === 0
? '这题没有预设选项,请直接填写回答:'
: multi
? '可多选,也可补充说明:'
: '请选择一项,也可补充说明:';
// 形状(输入区/动作行/提示)与另两处回信完全同一套;差异只有"选项胶囊"
// 这一段,走 Composer 的 header 条件渲染(见 Composer 顶部的说明)。
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
<div className="text-2xs text-gray-500 mb-2">
{options.length === 0
? '这题没有预设选项,请直接填写回答:'
: multi ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'}
</div>
{options.length > 0 && (
<div className="flex flex-wrap gap-2">
{options.map(opt => {
const on = picked.includes(opt);
return (
<button
key={opt}
onClick={() => toggle(opt)}
disabled={busy}
aria-pressed={on}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-40 ${
on
? 'bg-blue-700 text-white border-blue-700 hover:bg-blue-800'
: 'glass-control text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
{on && <CheckIcon className="w-3.5 h-3.5" />}
{opt}
</button>
);
})}
</div>
)}
<textarea
<Composer
value={note}
onChange={e => setNote(e.target.value)}
onChange={setNote}
rows={options.length === 0 ? 3 : 2}
placeholder={options.length === 0 ? '你的回答(必填)' : '补充说明(可选)'}
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 resize-y focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
header={
<div>
<div className="text-2xs text-gray-500 mb-2">{hint}</div>
{options.length > 0 && (
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<ComposerChip
key={opt}
label={opt}
active={picked.includes(opt)}
disabled={busy}
onSelect={() => toggle(opt)}
/>
))}
</div>
)}
</div>
}
submit={{
label: '提交回答',
busy,
disabled: blank,
onClick: () => submit(picked.join('\n'), note.trim())
}}
hint={blank ? '请先选择或填写回答' : undefined}
/>
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => submit(picked.join('\n'), note.trim())}
disabled={busy || blank}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-blue-700 text-white hover:bg-blue-800 disabled:opacity-40"
>
</button>
{blank && (
<span className="text-2xs text-gray-400"></span>
)}
</div>
</div>
);
}
@ -816,35 +806,32 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
// 同样是那个 Composer这里只换三件事 —— 备注是**单行**、选项胶囊按语义上色、
// 点胶囊即提交(审批只有"批准/拒绝"两个动作,不需要再按一次提交)。
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<button
key={opt}
onClick={() => submit(opt, note)}
disabled={busy}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
isApprove(opt)
? 'bg-green-700 text-white hover:bg-green-800'
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
}`}
>
{isApprove(opt) ? (
<CheckIcon className="w-3.5 h-3.5" />
) : (
<CloseIcon className="w-3.5 h-3.5" />
)}
{opt}
</button>
))}
</div>
<input
<Composer
value={note}
onChange={e => setNote(e.target.value)}
onChange={setNote}
singleLine
placeholder="备注(可选)"
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
header={
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<ComposerChip
key={opt}
label={opt}
variant="action"
tone={isApprove(opt) ? 'approve' : 'danger'}
disabled={busy}
onSelect={() => submit(opt, note)}
/>
))}
</div>
}
// 审批型**不给 submit**:点选项即提交(多一个提交口会以 options[0] 兜底,
// 等于点一下就批准)。备注走上面的输入框。
/>
</div>
);
@ -993,13 +980,22 @@ function ReplyBar({
/>
</div>
)}
<textarea
{/* 输入区走共用的 Composerroomy 档:回信要写作空间 + 等宽字体)。
发送动作也并入它的动作行,附属内容(附件、预算、回复全部)走 footerExtra ——
形状只有一处定义,差异是数据。 */}
<Composer
value={body}
onChange={e => setBody(e.target.value)}
onChange={setBody}
placeholder="回复内容Markdown"
// 窄屏是「点球展开」进来的,焦点直接落到输入框,少一次点击
density="roomy"
autoFocus={narrow}
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
error={error}
submit={{
label: '发送',
busy,
disabled: !body.trim(),
onClick: send
}}
/>
<div className="mt-2">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
@ -1068,13 +1064,8 @@ function ReplyBar({
>
</button>
<button
onClick={send}
disabled={busy || !body.trim()}
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? '发送中' : '发送'}
</button>
{/* 发送按钮已并入上面 Composer 的动作行:同一个 UI不再各写一个。
这里只留「清空」这类不属于"提交"的动作。 */}
</div>
</div>
);

View File

@ -96,7 +96,12 @@ export default function Sidebar() {
return (
<button
key={short}
onClick={() => setViewMode(target || commTab || modes[0])}
onClick={() =>
// ★ 只有「通信」是回上次的子页签;日历/联系人必须去**自己**那一项。
// 原先写成 `target || commTab || modes[0]`,而日历/联系人没有 target
// ⇒ 点它们会跳到 commTab收件箱—— 用户报的"点击无法翻页"就是这个。
setViewMode(target ?? (isComm ? commTab : modes[0]))
}
title={title}
data-active={active}
className="nav-item relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5"

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');
});
});