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)}`} + /> + ) : ( +