From 5ce25f6fdb7bf61e755a7dce1493816c91253519 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 12:26:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E4=BF=AE=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E6=97=A0=E6=B3=95=E7=BF=BB=E9=A1=B5=20+=20?= =?UTF-8?q?=E5=9B=9E=E4=BF=A1=20UI=20=E7=BB=9F=E4=B8=80=E6=88=90=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户两句:「侧边导航栏完全不可用,点击无法翻页,而且窄屏显示不全」、 「按顺序做吧」(= 做回信 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 条)。 --- client/electron/src/components/Composer.tsx | 210 ++++++++++++++++++ client/electron/src/components/MailView.tsx | 155 ++++++------- client/electron/src/components/Sidebar.tsx | 7 +- .../electron/test/components/Sidebar.test.tsx | 62 ++++++ 4 files changed, 351 insertions(+), 83 deletions(-) create mode 100644 client/electron/src/components/Composer.tsx create mode 100644 client/electron/test/components/Sidebar.test.tsx diff --git a/client/electron/src/components/Composer.tsx b/client/electron/src/components/Composer.tsx new file mode 100644 index 0000000..458aea4 --- /dev/null +++ b/client/electron/src/components/Composer.tsx @@ -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 = { + 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 ( +
+ {/* 条件渲染的差异部分:选项胶囊 / 收件人摘要 */} + {header} + + {singleLine ? ( + onChange(e.target.value)} + placeholder={placeholder} + disabled={busy} + autoFocus={autoFocus} + className={`${header ? 'mt-2 ' : ''}${fieldClass(density, false)}`} + /> + ) : ( +