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:
@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user