unnamed-ui
输入控件

Sender

组合示例:数据适配、附件限制、模式策略、发送校验一体化
引用:请帮我总结这段需求,并输出待办列表。
design-reference.png
requirements.docx240KB
"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(输入、附件、模式与发送按钮),同时允许通过适配器与插槽扩展业务逻辑。

安装

pnpm dlx shadcn@latest add http://localhost:3000/r/wuhan/sender.json

概述

  • 定位:对话输入器(输入 + 附件 + 模式 + 发送)
  • 默认样式:开箱即用,包含发送按钮与操作栏
  • 扩展能力:支持数据适配、发送校验、插槽重写

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);
  }}
/>;

快速开始

最小可用配置:valueonChangeonSend 三个核心入口即可工作。

"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>
  );
}

代码演示

功能总览

完整能力演示:数据适配、附件、模式、提交校验与反馈。

组合示例:数据适配、附件限制、模式策略、发送校验一体化
引用:请帮我总结这段需求,并输出待办列表。
design-reference.png
requirements.docx240KB
"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>
  );
}

自定义输入框与操作栏

通过 renderInputrenderActionBar 进行 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)

为便于快速理解,下面按"输入/附件/模式/发送/布局"分组,每项都解释它解决什么问题以及常见用法

输入

属性类型默认值说明
valuestring-受控输入值。你写什么它就显示什么,必须和 onChange 一起使用。
onChange(value) => void-输入变化回调。用户打字时更新你的状态
placeholderstring-空输入时的提示文字。
inputDisabledbooleanfalse禁用输入框,常用于"生成中/无权限"场景。
submitOnEnterbooleanfalse是否按 Enter 发送(自动处理输入法组合态与 Shift+Enter)。
renderInput(ctx) => ReactNode-覆盖输入区域 UI。用于替换为富文本/Markdown/自定义输入组件

附件

属性类型默认值说明
attachmentsT[][]你的业务附件数组,结构不限。
attachmentAdapter(item) => AttachmentItem-把业务数据转换为 UI 所需结构(id/name/thumbnail/size 等)。
onAttachmentRemove(id) => void-删除附件回调。UI 会传回 AttachmentItem.id
onAttachmentClick(item) => void-点击附件卡片时触发,常用于预览/打开。
maxAttachmentsnumber-最大附件数量,超过会触发 onAttachLimitExceed
acceptstring-允许类型(用于你自己的上传控件或提示)。
sizeLimitnumber-单个附件的大小上限(用于你自己的校验)。
onAttachRequest(ctx) => void-点击"添加附件"时触发。通常在这里打开文件选择器。
onAttachLimitExceed(ctx) => void-超过 maxAttachments 时触发,适合展示提示。

模式

属性类型默认值说明
modesT[][]模式数据数组,结构不限(例如"深度思考/联网搜索")。
modeAdapter(mode) => {id,label,icon}-把模式数据映射到 UI 所需字段。
modeSelection"multiple" | "single" | "exclusive""multiple"模式选择策略:多选/单选/互斥。
selectedModesstring[][]当前选中的模式 id 列表。
onModeChange(next, ctx) => void-模式变化回调,推荐用它做状态更新。

发送

属性类型默认值说明
onSend() => void-真正"发送"动作。只有当 getCanSend 通过时才会触发。
sendDisabledboolean-只控制按钮禁用视觉状态,不改变校验逻辑。
generatingbooleanfalse发送中/生成中状态(按钮会显示 loading)。
getCanSend(ctx) => boolean-自定义发送规则(比如"有附件即可发送")。
onSubmit(ctx) => void-点击发送或回车触发,能拿到 reason。用于提示"为什么不能发"。
getSendDisabledReason(ctx) => reason-自定义 reason 文案来源。

布局与插槽

属性类型默认值说明
renderActionBar(ctx) => ReactNode-覆盖整个操作栏。你要自己放发送按钮
renderActionsLeft(ctx) => ReactNode-覆盖左侧动作区(附件按钮/模式按钮所在区域)。
renderActionsRight(ctx) => ReactNode-覆盖右侧动作区(发送按钮所在区域)。
classNamestring-外层容器样式。
maxWidthstring"max-w-2xl"最大宽度,默认中等聊天宽度。

结构示意(插槽位置)

下面是一个图形化结构示意,用框图标出插槽位置(非真实 DOM,仅帮助理解):

SenderContainerQuoteContent(可选)AttachmentList(可选)InputRegion(renderInput 可覆盖)SenderTextareaActionBar(renderActionBar 可覆盖)LeftActions(renderActionsLeft)RightActions(renderActionsRight)

Behavior Notes

  • renderActionsLeft/Right 会完全替换默认区域,需要自行加入附件/模式/发送按钮。
  • sendDisabled 只控制按钮禁用,实际提交还受 getCanSend 影响。