18 — 项目组件详解
本章按”用途”对项目所有组件做汇总介绍。
前 12 章你应该已经能读懂代码了,本章给你一个速查表。
1. 组件总览
按功能分类:
1.1 布局组件
| 组件 | 文件 | 作用 |
|---|
App | App.tsx | 根组件,协调所有面板,启动初始化 |
Topbar | Topbar/Topbar.tsx | 🆕 顶部栏(豆豆 logo + 标题 + 主题按钮) |
Sidebar | Sidebar/Sidebar.tsx | 侧边栏(桌面 flex 子项 / 移动 fixed 抽屉) |
ChatItem | Sidebar/ChatItem.tsx | 侧边栏中单个会话项 |
1.2 消息相关
| 组件 | 文件 | 作用 |
|---|
MessageList | MessageList/MessageList.tsx | 消息列表容器 |
EmptyState | MessageList/EmptyState.tsx | 空状态欢迎页(渐变大书法「豆豆」) |
Message | Message/Message.tsx | 单条消息(金色左竖线) |
Markdown | Message/Markdown.tsx | Markdown 渲染器 |
Timeline | Message/Timeline.tsx | AI 思考时间线(折叠) |
ReferenceList | Message/ReferenceList.tsx | 参考来源列表(折叠) |
RecommendQuestions | Message/RecommendQuestions.tsx | 推荐问题(点击自动发送) |
PptDownload | Message/PptDownload.tsx | PPT 下载按钮 |
1.3 输入相关
| 组件 | 文件 | 作用 |
|---|
InputArea | InputArea/InputArea.tsx | 底部输入区域(案台式) |
AgentSelector | InputArea/AgentSelector.tsx | Agent 选择器(横向滚动) |
FilePreview | InputArea/FilePreview.tsx | 上传文件的预览 |
1.4 全局浮层
| 组件 | 文件 | 作用 |
|---|
ThemeSwitcher | ThemeSwitcher/ThemeSwitcher.tsx | 🆕 主题切换面板(6 主题) |
ConfirmDialog | ConfirmDialog/ConfirmDialog.tsx | 确认对话框 |
ConnectionError | ConnectionError/ConnectionError.tsx | 连接错误提示条 |
总计 18 个组件。
2. 布局组件详解
2.1 App
位置:src/App.tsx(约 125 行)
职责:
- 整体布局
- 应用启动初始化
- 协调 Topbar / Sidebar / ThemeSwitcher 状态
关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| useEffect(() => { (async () => { await testConnection(); await loadChats(); createNewChat(); })(); }, [...]);
useEffect(() => { const handler = () => setThemeOpen(true); window.addEventListener('dodo:open-theme-switcher', handler); return () => window.removeEventListener('dodo:open-theme-switcher', handler); }, []);
|
布局结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <> <div className="bg-blob bg-blob-1" /> {} <div className="bg-blob bg-blob-2" /> <div className={styles.app}> <Sidebar mobileOpen={mobileMenuOpen} onMobileClose={...} onOpenSettings={...} onToggleSidebar={toggleSidebar} sidebarOpen={sidebarOpen} /> <div className={styles.main}> <Topbar chatTitle={chatTitle} themeName={themeName} sidebarOpen={sidebarOpen} onToggleSidebar={toggleSidebar} onOpenMobileMenu={...} /> <div className={styles.body}> <MessageList /> <InputArea /> </div> </div> </div> <ConfirmDialog /> <ConnectionError /> <ThemeSwitcher open={themeOpen} onClose={...} /> </>
|
读时关注:
- 整体布局(背景 + sidebar + main + 浮层)
- 两个 useState:
sidebarOpen(持久化)、themeOpen - 跨组件通信:监听
dodo:open-theme-switcher 事件
2.2 Topbar(🆕 新增)
位置:src/components/Topbar/Topbar.tsx(约 90 行)
职责:
- 顶部栏(豆豆 logo + 当前聊天主题 + 主题切换)
- 桌面端 sidebar 展开时:logo 隐藏在 Topbar(因为已经在 Sidebar 头部)
- 桌面端 sidebar 收起时:logo 显示在 Topbar 头部(屏幕最左上)
- 移动端:始终显示 logo + 菜单按钮
Props:
1 2 3 4 5 6 7
| interface Props { chatTitle: string; themeName: string; sidebarOpen: boolean; onToggleSidebar: () => void; onOpenMobileMenu: () => void; }
|
关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| const isMobile = useMediaQuery('(max-width: 768px)'); const showTopbarLogo = isMobile || !sidebarOpen; const showTopbarControls = !isMobile && !sidebarOpen;
return ( <header className={styles.topbar}> {/* 胶囊:仅在 sidebar 收起时(桌面)或 移动端 显示 */} {showTopbarControls && ( <div className={styles.controls}> <button onClick={onToggleSidebar}> <PanelLeftOpen size={18} /> <span>展开</span> </button> <button className={styles.primary} onClick={createNewChat}> <Plus size={18} /> <span>新对话</span> </button> </div> )}
{/* 豆豆 logo */} {showTopbarLogo && <div className={styles.brand}>...<span>豆</span>...</div>}
{/* 当前聊天主题 */} <div className={styles.title}>{chatTitle}</div>
{/* 主题按钮:点击打开主题面板 */} <button onClick={dispatchOpenTheme}> <Palette size={12} /> <span>{themeName}</span> </button> </header> );
|
读时关注:
useMediaQuery 自定义 Hook 判断移动端- 跨组件通信:调
dispatchOpenTheme() 派发 dodo:open-theme-switcher 事件 - 条件渲染:根据
isMobile 和 sidebarOpen 显示不同 UI
位置:src/components/Sidebar/Sidebar.tsx(约 125 行)
职责:
- 显示会话列表
- 切换/删除会话
- 桌面端:作为 flex 子项永久参与布局
- 移动端:fixed 浮层抽屉(默认隐藏)
- 打开主题设置(弹 ThemeSwitcher)
Props:
1 2 3 4 5 6 7
| interface Props { mobileOpen: boolean; onMobileClose: () => void; onOpenSettings: () => void; onToggleSidebar: () => void; sidebarOpen: boolean; }
|
关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| const isMobile = useMediaQuery('(max-width: 768px)'); const visible = isMobile ? mobileOpen : sidebarOpen;
return ( <> {isMobile && mobileOpen && ( <div className={styles.backdrop} onClick={onMobileClose} /> )} <aside className={`${styles.sidebar} ${visible ? styles.open : styles.closed} ${isMobile ? styles.mobile : styles.desktop}`}> {/* 头部:豆豆 logo + 按钮 */} {!isMobile ? ( <div className={styles.brandRow}> <div className={styles.brand}> <span>豆</span> <span>豆豆</span> </div> <button onClick={onToggleSidebar}><PanelLeftClose size={16} /></button> <button onClick={createNewChat} className={styles.newChatPill}> <Plus size={14} /> <span>新对话</span> </button> </div> ) : ( // 移动端:只显示 logo + 新对话 + 关闭 <div className={styles.brandRow}> <div className={styles.brand}><span>豆</span></div> <button onClick={createNewChat} className={styles.newChatPill}><Plus size={14} /></button> <button onClick={onMobileClose}><X size={14} /></button> </div> )}
<div className={styles.list}> {chatList.map((c) => ( <ChatItem key={c.id} chat={c} active={c.id === currentChatId} onSelect={(id) => { selectChat(id); if (isMobile) onMobileClose(); // 移动端选中后关闭抽屉 }} onDelete={deleteChat} /> ))} </div>
<div className={styles.footer}> <button onClick={onOpenSettings}><Settings size={14} />主题设置</button> <div className={styles.backend}><Link2 size={12} />{API_BASE}</div> </div> </aside> </> );
|
读时关注:
useMediaQuery 判断桌面/移动- 移动端会话选中后自动关闭抽屉(
onMobileClose) - 桌面端/移动端两套不同的头部布局
2.4 ChatItem
位置:src/components/Sidebar/ChatItem.tsx(约 33 行)
职责:
- 显示单个会话
- 选中态高亮
- 删除按钮(仅已存在的会话显示)
Props:
1 2 3 4 5 6
| interface Props { chat: Chat; active: boolean; onSelect: (id: string) => void; onDelete: (id: string) => void; }
|
关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <div className={`${styles.item} ${active ? styles.active : ''}`} onClick={() => onSelect(chat.id)} > <span className={styles.title}>{chat.title}</span> {!chat.isNew && ( <span className={styles.deleteBtn} onClick={(e) => { e.stopPropagation(); // 阻止冒泡(点击删除不触发选中) onDelete(chat.id); }} > <Trash2 size={12} /> </span> )} </div>
|
读时关注:
- 简单的 Props 接收
e.stopPropagation() 阻止冒泡
3. 消息组件详解
3.1 MessageList
位置:src/components/MessageList/MessageList.tsx(约 50 行)
职责:
- 找当前会话的消息
- 条件渲染(无会话 / 空消息 / 有消息)
- 自动滚动
关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| const chat = useMemo( () => chatList.find((c) => c.id === currentChatId), [chatList, currentChatId], ); const messages = chat?.messages ?? []; const ref = useAutoScroll<HTMLDivElement>([messages.length, isSending, chat?.id]);
if (!chat) return <div className={styles.container} ref={ref} />; if (messages.length === 0) { return ( <div className={styles.container} ref={ref}> <div className={styles.inner}> <EmptyState onQuickPrompt={quickPrompt} /> </div> </div> ); }
return ( <div className={styles.container} ref={ref}> <div className={styles.inner}> {messages.map((m, i) => ( <Message key={m.id} message={m} isLast={i === messages.length - 1} isSending={isSending} /> ))} </div> </div> );
|
读时关注:
useMemo 缓存 find 结果useAutoScroll 自定义 Hook- 三种状态分支:早 return
3.2 EmptyState
位置:src/components/MessageList/EmptyState.tsx(约 40 行)
职责:
- 空状态欢迎页
- 4 个快捷问题按钮(动画延迟依次进入)
Props:
1 2 3
| interface Props { onQuickPrompt: (text: string) => void; }
|
关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| const QUICK_ACTIONS = [ { icon: User, text: '介绍一下你自己', desc: '了解我的能力' }, { icon: Code, text: '帮我写一个 Python 示例', desc: '快速生成代码' }, { icon: FileText, text: '分析一段文本', desc: '深度内容分析' }, { icon: Sparkles, text: '给我一些创意灵感', desc: '激发新想法' }, ];
return ( <div className={styles.empty}> <div className={styles.calligraphy}>豆豆</div> {/* 渐变大书法 */} <div className={styles.subline}> <span className={styles.line} /> <span className={styles.subtitle}>知音 · 启思 · 共笔</span> <span className={styles.line} /> </div> <p className={styles.hint}>问一段古诗、解一道方程、聊一个项目,或任何事。</p> <div className={styles.quick}> {QUICK_ACTIONS.map((a, i) => ( <button key={a.text} style={{ animationDelay: `${i * 80}ms` }} {/* 错落入场 */} onClick={() => onQuickPrompt(a.text)} > <a.icon size={14} /> <span>{a.text}</span> <span>{a.desc}</span> </button> ))} </div> </div> );
|
读时关注:
- 静态数据
QUICK_ACTIONS 放在组件外部 - 错落入场动画(
animationDelay: ${i * 80}ms)
3.3 Message
位置:src/components/Message/Message.tsx(约 99 行)
职责:
Props:
1 2 3 4 5
| interface Props { message: ChatMessage; isLast: boolean; isSending: boolean; }
|
结构:
1 2 3 4 5
| <Message> ├─ 头像(User / Bot 图标) └─ 内容 ├─ if 用户: 气泡 + 文件标识 + 复制 └─ if AI: 思考时间线 + Markdown + loading + 参考 + 推荐 + PPT + 复制
|
关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| const isUser = message.role === 'user';
return ( <div className={`${styles.message} ${isUser ? styles.user : styles.assistant}`}> <div className={styles.avatar}> {isUser ? <User size={16} /> : <Bot size={16} />} </div> <div className={styles.content}> {isUser ? ( // 用户消息气泡 <div className={styles.userBubble}> {message.file && <span className={styles.fileAttachment}>...</span>} <div>{message.content}</div> <button onClick={() => copyMessage(message.id)}>{copy icon}</button> </div> ) : ( // AI 消息(金色左竖线) <div className={styles.aiBlock}> {message.timeline?.length > 0 && <Timeline ... />} <Markdown content={message.content} /> {isSending && isLast && message.content === '' && <LoadingDots />} {message.reference?.length > 0 && <ReferenceList ... />} {isLast && message.recommend?.length > 0 && <RecommendQuestions ... />} {message.pptFile && <PptDownload url={message.pptFile} />} <button onClick={() => copyMessage(message.id)}>{copy icon}</button> </div> )} </div> </div> );
|
读时关注:
- 三元 +
&& 组合条件渲染 - 多种条件组合(
isSending && isLast && message.content === '')
3.4 Markdown
位置:src/components/Message/Markdown.tsx(约 47 行)
职责:
- 把 AI 返回的 Markdown 文本渲染为 HTML
核心库:
react-markdown — 核心渲染器remark-gfm — GitHub 风格 Markdown(表格、删除线等)rehype-highlight — 代码高亮rehype-sanitize — 安全过滤(防 XSS)
特殊点:
- 自定义 sanitize schema 允许
className(否则 highlight.js 无法工作) - 转换转义换行符
\\n → \n
3.5 Timeline
位置:src/components/Message/Timeline.tsx(约 58 行)
职责:
数据:
1 2 3 4
| type TimelineItem = | { type: 'thinking'; content: string } | { type: 'tool'; toolName: string; toolCallId: string; status: 'running' | 'completed' } | { type: 'error'; message: string; detail?: string };
|
读时关注:
- 联合类型 + 类型守卫
- 状态切换(running → completed)
3.6 ReferenceList
位置:src/components/Message/ReferenceList.tsx(约 48 行)
职责:
关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| {items.map((ref) => ref.url ? ( <a key={ref.url} href={ref.url} target="_blank" // 新窗口打开 rel="noreferrer" // 安全 > <ExternalLink size={12} /> <div> <div>{ref.title || '无标题'}</div> <div>{ref.url}</div> </div> </a> ) : null, )}
|
3.7 RecommendQuestions
位置:src/components/Message/RecommendQuestions.tsx(约 33 行)
职责:
1 2 3 4 5
| <button onClick={() => onPick(q)}> <Lightbulb size={14} /> <span>{q}</span> <ArrowRight size={14} /> </button>
|
3.8 PptDownload
位置:src/components/Message/PptDownload.tsx(约 17 行)
职责:
极简组件。
4. 输入组件详解
位置:src/components/InputArea/InputArea.tsx(约 90 行)
职责:
- 文件上传按钮(hidden input + ref 控制)
- 文字输入(自动调整高度)
- Agent 选择
- 发送/停止按钮
结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <div className={styles.area}> {} <div className={styles.composer}> {} <AgentSelector /> {} <FilePreview /> {} <div className={styles.inputRow}> {} {showFileBtn && <button onClick={() => fileInputRef.current?.click()}><Paperclip /></button>} <input ref={fileInputRef} type="file" style={{ display: 'none' }} /> {selectedFile && <span><FileText /></span>} <textarea ref={textareaRef} value={inputMessage} onChange={...} onKeyDown={onKeyDown} /> <button onClick={() => isSending ? stopMessage() : sendMessage()}> {isSending ? <StopCircle /> : <Send />} </button> </div> </div> </div>
|
关键点:
useRef<HTMLInputElement>(null) 控制隐藏的 file inputuseTextareaAutosize 自定义 Hook- Enter 键发送(
onKeyDown + e.preventDefault) - 三元切换发送/停止按钮
移动端关键 CSS:
textarea { font-size: 16px; } 防止 iOS 自动缩放flex: 1 1 0; min-width: 0; 防止撑破容器
4.2 AgentSelector
位置:src/components/InputArea/AgentSelector.tsx(约 25 行)
职责:
数据:src/constants/agents.ts
1 2 3 4 5 6 7
| export const AGENTS = [ { id: 'chat', name: '对话助手', icon: '💬' }, { id: 'file', name: '文件问答', icon: '📁' }, { id: 'ppt', name: 'PPT生成', icon: '📊' }, { id: 'deep', name: '深度研究', icon: '🔬' }, { id: 'skills', name: '技能助手', icon: '🛠' }, ];
|
移动端:横向滚动 overflow-x: auto,避免溢出。
4.3 FilePreview
位置:src/components/InputArea/FilePreview.tsx(约 44 行)
职责:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| if (!file) return null;
return ( <div className={styles.preview}> <div className={styles.item}> <div className={styles.fileIcon}><FileText size={16} /></div> <div> <div>{file.name}</div> <div>{formatFileSize(file.size)}</div> {isUploading && <div><Loader2 spin />解析中...</div>} </div> {!isUploading && ( <button onClick={removeFile}><Trash2 size={12} /></button> )} </div> </div> );
|
5. 全局浮层组件
5.1 ThemeSwitcher(🆕 新增)
位置:src/components/ThemeSwitcher/ThemeSwitcher.tsx(约 80 行)
职责:
- 6 主题切换面板
- 显示主题色块预览
- 当前主题高亮 + 对勾
Props:
1 2 3 4
| interface Props { open: boolean; onClose: () => void; }
|
关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]);
if (!open) return null;
return ( <div className={styles.overlay} onClick={onClose}> <div className={styles.panel} onClick={(e) => e.stopPropagation()}> <div className={styles.header}> <Palette size={16} /> <span>主题风格</span> <button onClick={onClose}><X size={16} /></button> </div> <p>选择一种主题,点击即可切换</p> <div className={styles.grid}> {THEMES.map((t) => ( <button key={t.id} className={`${styles.card} ${activeId === t.id ? styles.active : ''}`} onClick={() => setTheme(t.id)} > <div className={styles.swatch} style={{ background: t.swatch }}> <span style={{ color: t.mode === 'dark' ? '#d4cab8' : '#2a241a' }}>豆</span> {activeId === t.id && <div className={styles.checkMark}><Check size={14} /></div>} </div> <div className={styles.name}>{t.name}</div> <div className={styles.desc}>{t.description}</div> </button> ))} </div> </div> </div> );
|
读时关注:
- ESC 关闭
- 点击外部关闭(
e.stopPropagation) - 主题预览色块
详见 16-主题系统与ThemeSwitcher.md。
5.2 ConfirmDialog
位置:src/components/ConfirmDialog/ConfirmDialog.tsx(约 36 行)
职责:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| if (!dialog || !dialog.show) return null;
return ( <div className={styles.overlay}> <div className={styles.dialog}> <div className={styles.icon}><AlertCircle size={28} /></div> <h3>{dialog.title}</h3> <p>{dialog.message}</p> <div> <button onClick={closeConfirm}>取消</button> <button onClick={() => dialog.onOk()}>确认删除</button> </div> </div> </div> );
|
5.3 ConnectionError
位置:src/components/ConnectionError/ConnectionError.tsx(约 20 行)
职责:
1 2 3 4 5 6 7 8
| if (!error) return null; return ( <div className={styles.error}> <AlertTriangle size={16} /> <span>{error}</span> <button onClick={testConnection}>重试</button> </div> );
|
6. 完整组件通信图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| ┌────────────────────────────────────────────────────────────┐ │ App.tsx │ ├────────────────────────────────────────────────────────────┤ │ │ │ bg-blob (背景水墨装饰) │ │ │ │ ┌─ Sidebar ───┐ ┌─ Main ──────────────────────────┐ │ │ │ │ │ Topbar │ │ │ │ 桌面端: │ │ - 豆豆 logo (sidebar 收起时) │ │ │ │ flex 子项 │ │ - 当前聊天标题 │ │ │ │ │ │ - 主题按钮 (打开 ThemeSwitcher)│ │ │ │ 移动端: │ ├──────────────────────────────────┤ │ │ │ fixed 抽屉 │ │ │ │ │ │ + backdrop │ │ MessageList (消息列表) │ │ │ │ │ │ - EmptyState (空状态) │ │ │ │ 头部: │ │ - Message × N │ │ │ │ 豆豆+按钮 │ │ ├ Timeline / Markdown │ │ │ │ │ │ ├ Reference / Recommend │ │ │ │ 列表: │ │ └ PptDownload │ │ │ │ ChatItem │ │ │ │ │ │ │ ├──────────────────────────────────┤ │ │ │ 底部: │ │ InputArea (案台式输入) │ │ │ │ 主题设置 │ │ - AgentSelector │ │ │ │ + API 地址 │ │ - FilePreview │ │ │ │ │ │ - textarea + sendBtn │ │ │ └─────────────┘ └──────────────────────────────────┘ │ │ │ ├────────────────────────────────────────────────────────────┤ │ <ConfirmDialog /> <ConnectionError /> │ │ <ThemeSwitcher /> (全局浮层,按 z-index 分层) │ └────────────────────────────────────────────────────────────┘
|
7. 跨组件通信:CustomEvent
项目里 18 个组件不是孤立存在的——它们通过 3 种方式通信:
7.1 父子 Props(最常用)
1 2
| <MessageList /> {} <Message message={m} isLast={...} /> {}
|
7.2 Zustand Store(业务状态共享)
1 2 3
| const chatList = useChatStore((s) => s.chatList); const currentTheme = useThemeStore((s) => s.current);
|
7.3 CustomEvent(兄弟组件通信)
详见 17-移动端适配实战.md 第 8 节。
| 事件 | 发送 | 接收 |
|---|
dodo:sidebar-toggle | Topbar / Sidebar 内部按钮 | 对面那个 |
dodo:open-theme-switcher | Topbar 的主题按钮 | App |
8. 怎么找一个功能在哪实现的
| 你想… | 找这里 |
|---|
| 找某个按钮点击后的行为 | Store 的 action(chatStore.ts / themeStore.ts) |
| 改某个页面的样式 | 对应的 .module.css |
| 看某个事件的处理 | 组件的 onXxx 回调 |
| 改主题色 / 加新主题 | tokens.css([data-theme='xxx'] 块) |
| 改主题元数据 | themeStore.ts 的 THEMES 数组 |
| 加新 Agent | constants/agents.ts |
| 加新事件类型 | constants/streamTypes.ts + applyEvent |
| 改 API 地址 | api/client.ts 或 .env |
| 改后端接口 | api/*.ts + types/api.ts |
| 找跨组件通信 | themeStore.ts 的 dispatchXxx 函数 |
9. 常见修改场景
场景 1:加新主题(最简单)
3 步:① themeStore.ts ② tokens.css ③ ThemeSwitcher.tsx
详见 16-主题系统与ThemeSwitcher.md 第 7 节。
场景 2:给消息加「点赞/点踩」
- 扩展 ChatMessage 类型
- chatStore.ts 加 rateMessage action
- Message.tsx 加 UI 按钮
场景 3:改输入框高度
1 2 3 4
| .area textarea { max-height: 180px; }
|
场景 4:让 Topbar 显示更多内容
直接在 Topbar.tsx 里加新元素,CSS 写到 Topbar.module.css。
10. 一段话总结
项目有 18 个组件,按职责清晰分层:
- 布局层:App / Topbar / Sidebar(4 个)
- 消息层:MessageList / EmptyState / Message + 5 个子组件(8 个)
- 输入层:InputArea / AgentSelector / FilePreview(3 个)
- 浮层:ThemeSwitcher / ConfirmDialog / ConnectionError(3 个)
通信方式:
- 父子用 Props
- 跨组件用 Zustand Store(chatStore + themeStore)
- 兄弟组件用 CustomEvent(
dodo:sidebar-toggle / dodo:open-theme-switcher)
修改功能时:先 Store 加 action → 组件加 UI → 调 action。
接下来
继续阅读 19-API与后端交互.md — 了解后端接口格式、错误处理。