输入控件
Sender
组合示例:数据适配、附件限制、模式策略、发送校验一体化
"use client";
import { useState } from "react";
import type { ComponentType, SVGProps } from "react";
import { ComposedSender } from "@/components/composed/sender/sender";
import { QuoteContentComposed } from "@/components/composed/quote-content/quote-content";
import {
Brain,
Search,
FileText,
Image as ImageIcon,
Trash2,
} from "lucide-react";
export function SenderDemo() {
const [value, setValue] = useState("");
const [selectedModes, setSelectedModes] = useState<string[]>([]);
const [submitHint, setSubmitHint] = useState("");
const [attachments, setAttachments] = useState([
{
key: "att-1",
filename: "design-reference.png",
sizeLabel: "1.8MB",
previewUrl:
"https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=320&auto=format&fit=crop&q=60",
kind: "image" as const,
},
{
key: "att-2",
filename: "requirements.docx",
sizeLabel: "240KB",
kind: "doc" as const,
},
]);
const modes = [
{ key: "deep", name: "深度思考", icon: Brain },
{ key: "web", name: "联网搜索", icon: Search },
];
const canSend = value.trim().length > 0 || attachments.length > 0;
return (
<div className="flex w-full flex-col items-center gap-3">
<div className="w-full max-w-2xl text-sm text-muted-foreground">
组合示例:数据适配、附件限制、模式策略、发送校验一体化
</div>
<ComposedSender
value={value}
onChange={setValue}
placeholder="输入你的需求,支持附件和模式切换"
quoteContent={
<QuoteContentComposed content="引用:请帮我总结这段需求,并输出待办列表。" />
}
attachments={attachments}
attachmentAdapter={(item) => ({
id: item.key,
name: item.filename,
fileSize: item.sizeLabel,
thumbnail: item.previewUrl,
isImage: item.kind === "image",
icon:
item.kind === "image" ? (
<ImageIcon className="size-4" />
) : (
<FileText className="size-4" />
),
})}
onAttachmentRemove={(id) =>
setAttachments((prev) => prev.filter((item) => item.key !== id))
}
onAttachmentClick={(item) =>
setSubmitHint(`已点击附件:${item.name ?? item.id}`)
}
maxAttachments={3}
accept=".pdf,.docx,.png"
sizeLimit={5 * 1024 * 1024}
onAttachRequest={() => {
const nextId = `att-${Date.now()}`;
setAttachments((prev) => [
...prev,
{
key: nextId,
filename: "new-attachment.pdf",
sizeLabel: "88KB",
kind: "doc" as const,
},
]);
}}
onAttachLimitExceed={({ maxAttachments }) =>
setSubmitHint(`最多只能上传 ${maxAttachments ?? 0} 个附件`)
}
modes={modes}
selectedModes={selectedModes}
modeAdapter={(mode) => ({
id: mode.key,
label: mode.name,
icon: mode.icon as ComponentType<SVGProps<SVGSVGElement>>,
})}
modeSelection="exclusive"
allowEmptySelection={false}
onModeChange={(next) => setSelectedModes(next)}
getCanSend={({
value: currentValue,
attachments: currentAttachments,
}) => currentValue.trim().length > 0 || currentAttachments.length > 0}
sendDisabled={!canSend}
submitOnEnter
onSubmit={({ canSend, reason }) => {
if (canSend) {
setSubmitHint("");
return;
}
setSubmitHint(
reason === "empty"
? "请输入内容或添加附件"
: reason === "generating"
? "内容生成中,请稍候"
: reason === "disabled"
? "当前不可发送"
: "未满足发送条件",
);
}}
onSend={() => {
setValue("");
}}
/>
{submitHint && (
<div className="w-full max-w-2xl text-xs text-slate-500">
{submitHint}
</div>
)}
<button
type="button"
onClick={() => {
setValue("");
setAttachments([]);
}}
className="w-full max-w-2xl inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-slate-500 hover:bg-slate-100"
>
<Trash2 className="size-3" />
清空输入与附件
</button>
</div>
);
}
Sender 是一个面向"对话输入"场景的组合组件,默认提供完整 UI(输入、附件、模式与发送按钮),同时允许通过适配器与插槽扩展业务逻辑。
安装
概述
- 定位:对话输入器(输入 + 附件 + 模式 + 发送)
- 默认样式:开箱即用,包含发送按钮与操作栏
- 扩展能力:支持数据适配、发送校验、插槽重写
Usage
基础用法(推荐起点)
import { useState } from "react";
import { ComposedSender } from "@/registry/wuhan/composed/sender/sender";
export function Example() {
const [value, setValue] = useState("");
return (
<ComposedSender
value={value}
onChange={setValue}
onSend={() => console.log("send", value)}
placeholder="输入你的需求..."
/>
);
}数据适配(附件)
const attachments = [
{ key: "1", filename: "design.png", sizeLabel: "1.8MB", kind: "image" },
];
<ComposedSender
value={value}
onChange={setValue}
attachments={attachments}
attachmentAdapter={(item) => ({
id: item.key,
name: item.filename,
fileSize: item.sizeLabel,
isImage: item.kind === "image",
})}
/>;模式策略(单选/互斥)
const modes = [
{ key: "deep", name: "深度思考" },
{ key: "web", name: "联网搜索" },
];
<ComposedSender
value={value}
onChange={setValue}
modes={modes}
selectedModes={selectedModes}
modeAdapter={(mode) => ({ id: mode.key, label: mode.name })}
modeSelection="exclusive"
onModeChange={(next) => setSelectedModes(next)}
/>;发送校验与提示
<ComposedSender
value={value}
onChange={setValue}
getCanSend={({ value }) => value.trim().length > 0}
onSubmit={({ canSend, reason }) => {
if (!canSend) console.log("blocked", reason);
}}
/>;快速开始
最小可用配置:value、onChange、onSend 三个核心入口即可工作。
"use client";
import {
SenderContainer,
SenderTextarea,
SenderActionBar,
SenderButton,
} from "@/components/wuhan/blocks/sender/sender-01";
import { Search, Brain, Send } from "lucide-react";
export function SenderComposedDefault() {
return (
<SenderContainer className="max-w-2xl">
<SenderTextarea placeholder="Type your message..." />
<SenderActionBar className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SenderButton variant="outline" size="sm">
<Search className="size-4" />
联网搜索
</SenderButton>
<SenderButton variant="outline" size="sm">
<Brain className="size-4" />
深度思考
</SenderButton>
</div>
<SenderButton variant="default" size="icon" aria-label="Send">
<Send className="size-4" />
</SenderButton>
</SenderActionBar>
</SenderContainer>
);
}
代码演示
功能总览
完整能力演示:数据适配、附件、模式、提交校验与反馈。
组合示例:数据适配、附件限制、模式策略、发送校验一体化
"use client";
import { useState } from "react";
import type { ComponentType, SVGProps } from "react";
import { ComposedSender } from "@/components/composed/sender/sender";
import { QuoteContentComposed } from "@/components/composed/quote-content/quote-content";
import {
Brain,
Search,
FileText,
Image as ImageIcon,
Trash2,
} from "lucide-react";
export function SenderDemo() {
const [value, setValue] = useState("");
const [selectedModes, setSelectedModes] = useState<string[]>([]);
const [submitHint, setSubmitHint] = useState("");
const [attachments, setAttachments] = useState([
{
key: "att-1",
filename: "design-reference.png",
sizeLabel: "1.8MB",
previewUrl:
"https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=320&auto=format&fit=crop&q=60",
kind: "image" as const,
},
{
key: "att-2",
filename: "requirements.docx",
sizeLabel: "240KB",
kind: "doc" as const,
},
]);
const modes = [
{ key: "deep", name: "深度思考", icon: Brain },
{ key: "web", name: "联网搜索", icon: Search },
];
const canSend = value.trim().length > 0 || attachments.length > 0;
return (
<div className="flex w-full flex-col items-center gap-3">
<div className="w-full max-w-2xl text-sm text-muted-foreground">
组合示例:数据适配、附件限制、模式策略、发送校验一体化
</div>
<ComposedSender
value={value}
onChange={setValue}
placeholder="输入你的需求,支持附件和模式切换"
quoteContent={
<QuoteContentComposed content="引用:请帮我总结这段需求,并输出待办列表。" />
}
attachments={attachments}
attachmentAdapter={(item) => ({
id: item.key,
name: item.filename,
fileSize: item.sizeLabel,
thumbnail: item.previewUrl,
isImage: item.kind === "image",
icon:
item.kind === "image" ? (
<ImageIcon className="size-4" />
) : (
<FileText className="size-4" />
),
})}
onAttachmentRemove={(id) =>
setAttachments((prev) => prev.filter((item) => item.key !== id))
}
onAttachmentClick={(item) =>
setSubmitHint(`已点击附件:${item.name ?? item.id}`)
}
maxAttachments={3}
accept=".pdf,.docx,.png"
sizeLimit={5 * 1024 * 1024}
onAttachRequest={() => {
const nextId = `att-${Date.now()}`;
setAttachments((prev) => [
...prev,
{
key: nextId,
filename: "new-attachment.pdf",
sizeLabel: "88KB",
kind: "doc" as const,
},
]);
}}
onAttachLimitExceed={({ maxAttachments }) =>
setSubmitHint(`最多只能上传 ${maxAttachments ?? 0} 个附件`)
}
modes={modes}
selectedModes={selectedModes}
modeAdapter={(mode) => ({
id: mode.key,
label: mode.name,
icon: mode.icon as ComponentType<SVGProps<SVGSVGElement>>,
})}
modeSelection="exclusive"
allowEmptySelection={false}
onModeChange={(next) => setSelectedModes(next)}
getCanSend={({
value: currentValue,
attachments: currentAttachments,
}) => currentValue.trim().length > 0 || currentAttachments.length > 0}
sendDisabled={!canSend}
submitOnEnter
onSubmit={({ canSend, reason }) => {
if (canSend) {
setSubmitHint("");
return;
}
setSubmitHint(
reason === "empty"
? "请输入内容或添加附件"
: reason === "generating"
? "内容生成中,请稍候"
: reason === "disabled"
? "当前不可发送"
: "未满足发送条件",
);
}}
onSend={() => {
setValue("");
}}
/>
{submitHint && (
<div className="w-full max-w-2xl text-xs text-slate-500">
{submitHint}
</div>
)}
<button
type="button"
onClick={() => {
setValue("");
setAttachments([]);
}}
className="w-full max-w-2xl inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-slate-500 hover:bg-slate-100"
>
<Trash2 className="size-3" />
清空输入与附件
</button>
</div>
);
}
自定义输入框与操作栏
通过 renderInput 和 renderActionBar 进行 UI 结构重写。
高级示例:自定义输入区域与操作栏(renderInput / renderActionBar)
"use client";
import { useState } from "react";
import type { ComponentType, SVGProps } from "react";
import { ComposedSender } from "@/components/composed/sender/sender";
import { SenderTextarea } from "@/components/wuhan/blocks/sender/sender-01";
import { QuoteContentComposed } from "@/components/composed/quote-content/quote-content";
import { Brain, Search, Sparkles } from "lucide-react";
export function SenderComposedDemo() {
const [value, setValue] = useState("");
const [selectedModes, setSelectedModes] = useState<string[]>(["deep"]);
const modes = [
{ key: "deep", name: "深度思考", icon: Brain },
{ key: "web", name: "联网搜索", icon: Search },
];
return (
<div className="flex w-full flex-col items-center gap-4">
<div className="w-full max-w-2xl text-sm text-muted-foreground">
高级示例:自定义输入区域与操作栏(renderInput / renderActionBar)
</div>
<ComposedSender
value={value}
onChange={setValue}
quoteContent={
<QuoteContentComposed content="提示:你可以覆盖输入和操作栏。" />
}
modes={modes}
selectedModes={selectedModes}
modeAdapter={(mode) => ({
id: mode.key,
label: mode.name,
icon: mode.icon as ComponentType<SVGProps<SVGSVGElement>>,
})}
modeSelection="exclusive"
allowEmptySelection={false}
onModeChange={(next) => setSelectedModes(next)}
renderInput={({ placeholder, disabled, onChange, onKeyDown }) => (
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
<SenderTextarea
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder ?? "写下你的需求..."}
disabled={disabled}
onKeyDown={onKeyDown}
className="bg-transparent"
/>
</div>
)}
renderActionBar={({ onAttachRequest, canSend }) => (
<div className="flex items-center justify-between gap-3 pt-2">
<button
type="button"
onClick={onAttachRequest}
className="inline-flex items-center gap-1 rounded-md border border-slate-200 px-2 py-1 text-xs text-slate-600 hover:bg-slate-100"
>
<Sparkles className="size-3" />
插入灵感
</button>
<button
type="submit"
disabled={!canSend}
className="rounded-md bg-slate-900 px-3 py-1 text-xs text-white disabled:opacity-60"
>
发送
</button>
</div>
)}
submitOnEnter
getCanSend={({ value: currentValue }) => currentValue.trim().length > 0}
onSend={() => setValue("")}
/>
</div>
);
}
原语状态
"use client";
import {
SenderContainer,
SenderTextarea,
SenderActionBar,
SenderButton,
} from "@/components/wuhan/blocks/sender/sender-01";
import { Search, Brain, Send } from "lucide-react";
export function SenderComposedDefault() {
return (
<SenderContainer className="max-w-2xl">
<SenderTextarea placeholder="Type your message..." />
<SenderActionBar className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SenderButton variant="outline" size="sm">
<Search className="size-4" />
联网搜索
</SenderButton>
<SenderButton variant="outline" size="sm">
<Brain className="size-4" />
深度思考
</SenderButton>
</div>
<SenderButton variant="default" size="icon" aria-label="Send">
<Send className="size-4" />
</SenderButton>
</SenderActionBar>
</SenderContainer>
);
}
"use client";
import {
SenderContainer,
SenderTextarea,
SenderActionBar,
SenderButton,
} from "@/components/wuhan/blocks/sender/sender-01";
import { Search, Brain, Send } from "lucide-react";
export function SenderComposedActive() {
return (
<SenderContainer className="max-w-2xl border-primary">
<SenderTextarea placeholder="Type your message..." />
<SenderActionBar className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SenderButton variant="default" size="sm">
<Search className="size-4" />
联网搜索
</SenderButton>
<SenderButton variant="outline" size="sm">
<Brain className="size-4" />
深度思考
</SenderButton>
</div>
<SenderButton variant="default" size="icon" aria-label="Send">
<Send className="size-4" />
</SenderButton>
</SenderActionBar>
</SenderContainer>
);
}
"use client";
import {
SenderContainer,
SenderTextarea,
SenderActionBar,
SenderButton,
} from "@/components/wuhan/blocks/sender/sender-01";
import { Search, Send } from "lucide-react";
export function SenderComposedDisabled() {
return (
<SenderContainer className="max-w-2xl opacity-50">
<SenderTextarea placeholder="Type your message..." disabled />
<SenderActionBar className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SenderButton variant="outline" size="sm" disabled>
<Search className="size-4" />
联网搜索
</SenderButton>
</div>
<SenderButton variant="default" size="icon" disabled aria-label="Send">
<Send className="size-4" />
</SenderButton>
</SenderActionBar>
</SenderContainer>
);
}
API (Composed)
为便于快速理解,下面按"输入/附件/模式/发送/布局"分组,每项都解释它解决什么问题以及常见用法。
输入
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | string | - | 受控输入值。你写什么它就显示什么,必须和 onChange 一起使用。 |
onChange | (value) => void | - | 输入变化回调。用户打字时更新你的状态。 |
placeholder | string | - | 空输入时的提示文字。 |
inputDisabled | boolean | false | 禁用输入框,常用于"生成中/无权限"场景。 |
submitOnEnter | boolean | false | 是否按 Enter 发送(自动处理输入法组合态与 Shift+Enter)。 |
renderInput | (ctx) => ReactNode | - | 覆盖输入区域 UI。用于替换为富文本/Markdown/自定义输入组件。 |
附件
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
attachments | T[] | [] | 你的业务附件数组,结构不限。 |
attachmentAdapter | (item) => AttachmentItem | - | 把业务数据转换为 UI 所需结构(id/name/thumbnail/size 等)。 |
onAttachmentRemove | (id) => void | - | 删除附件回调。UI 会传回 AttachmentItem.id。 |
onAttachmentClick | (item) => void | - | 点击附件卡片时触发,常用于预览/打开。 |
maxAttachments | number | - | 最大附件数量,超过会触发 onAttachLimitExceed。 |
accept | string | - | 允许类型(用于你自己的上传控件或提示)。 |
sizeLimit | number | - | 单个附件的大小上限(用于你自己的校验)。 |
onAttachRequest | (ctx) => void | - | 点击"添加附件"时触发。通常在这里打开文件选择器。 |
onAttachLimitExceed | (ctx) => void | - | 超过 maxAttachments 时触发,适合展示提示。 |
模式
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
modes | T[] | [] | 模式数据数组,结构不限(例如"深度思考/联网搜索")。 |
modeAdapter | (mode) => {id,label,icon} | - | 把模式数据映射到 UI 所需字段。 |
modeSelection | "multiple" | "single" | "exclusive" | "multiple" | 模式选择策略:多选/单选/互斥。 |
selectedModes | string[] | [] | 当前选中的模式 id 列表。 |
onModeChange | (next, ctx) => void | - | 模式变化回调,推荐用它做状态更新。 |
发送
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
onSend | () => void | - | 真正"发送"动作。只有当 getCanSend 通过时才会触发。 |
sendDisabled | boolean | - | 只控制按钮禁用视觉状态,不改变校验逻辑。 |
generating | boolean | false | 发送中/生成中状态(按钮会显示 loading)。 |
getCanSend | (ctx) => boolean | - | 自定义发送规则(比如"有附件即可发送")。 |
onSubmit | (ctx) => void | - | 点击发送或回车触发,能拿到 reason。用于提示"为什么不能发"。 |
getSendDisabledReason | (ctx) => reason | - | 自定义 reason 文案来源。 |
布局与插槽
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
renderActionBar | (ctx) => ReactNode | - | 覆盖整个操作栏。你要自己放发送按钮。 |
renderActionsLeft | (ctx) => ReactNode | - | 覆盖左侧动作区(附件按钮/模式按钮所在区域)。 |
renderActionsRight | (ctx) => ReactNode | - | 覆盖右侧动作区(发送按钮所在区域)。 |
className | string | - | 外层容器样式。 |
maxWidth | string | "max-w-2xl" | 最大宽度,默认中等聊天宽度。 |
结构示意(插槽位置)
下面是一个图形化结构示意,用框图标出插槽位置(非真实 DOM,仅帮助理解):
Behavior Notes
renderActionsLeft/Right会完全替换默认区域,需要自行加入附件/模式/发送按钮。sendDisabled只控制按钮禁用,实际提交还受getCanSend影响。