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

@ -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
{/* 输入区走共用的 Composer(roomy 档:回信要写作空间 + 等宽字体)。
发送动作也并入它的动作行,附属内容(附件、预算、回复全部)走 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>
);