18 — 项目组件详解

本章按”用途”对项目所有组件做汇总介绍。
前 12 章你应该已经能读懂代码了,本章给你一个速查表


1. 组件总览

按功能分类:

1.1 布局组件

组件文件作用
AppApp.tsx根组件,协调所有面板,启动初始化
TopbarTopbar/Topbar.tsx🆕 顶部栏(豆豆 logo + 标题 + 主题按钮)
SidebarSidebar/Sidebar.tsx侧边栏(桌面 flex 子项 / 移动 fixed 抽屉)
ChatItemSidebar/ChatItem.tsx侧边栏中单个会话项

1.2 消息相关

组件文件作用
MessageListMessageList/MessageList.tsx消息列表容器
EmptyStateMessageList/EmptyState.tsx空状态欢迎页(渐变大书法「豆豆」)
MessageMessage/Message.tsx单条消息(金色左竖线)
MarkdownMessage/Markdown.tsxMarkdown 渲染器
TimelineMessage/Timeline.tsxAI 思考时间线(折叠)
ReferenceListMessage/ReferenceList.tsx参考来源列表(折叠)
RecommendQuestionsMessage/RecommendQuestions.tsx推荐问题(点击自动发送)
PptDownloadMessage/PptDownload.tsxPPT 下载按钮

1.3 输入相关

组件文件作用
InputAreaInputArea/InputArea.tsx底部输入区域(案台式)
AgentSelectorInputArea/AgentSelector.tsxAgent 选择器(横向滚动)
FilePreviewInputArea/FilePreview.tsx上传文件的预览

1.4 全局浮层

组件文件作用
ThemeSwitcherThemeSwitcher/ThemeSwitcher.tsx🆕 主题切换面板(6 主题)
ConfirmDialogConfirmDialog/ConfirmDialog.tsx确认对话框
ConnectionErrorConnectionError/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; // 桌面端 sidebar 是否展开
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 事件
  • 条件渲染:根据 isMobilesidebarOpen 显示不同 UI

2.3 Sidebar

位置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 行)

职责

  • 区分用户/AI 消息
  • 组合所有子组件
  • 复制按钮

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 行)

职责

  • 显示 AI 思考过程(可折叠)

数据

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 行)

职责

  • 显示 AI 引用的外部链接(可折叠)

关键代码

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 行)

职责

  • 显示 AI 推荐的追问
  • 点击自动发送
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 行)

职责

  • PPT 下载链接(如果 AI 生成了 PPT)

极简组件。


4. 输入组件详解

4.1 InputArea

位置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 /> {/* Agent 选择行 */}
<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 input
  • useTextareaAutosize 自定义 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 行)

职责

  • 显示 5 个 Agent 选项
  • 点击切换

数据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-toggleTopbar / Sidebar 内部按钮对面那个
dodo:open-theme-switcherTopbar 的主题按钮App

8. 怎么找一个功能在哪实现的

你想…找这里
找某个按钮点击后的行为Store 的 action(chatStore.ts / themeStore.ts
改某个页面的样式对应的 .module.css
看某个事件的处理组件的 onXxx 回调
改主题色 / 加新主题tokens.css[data-theme='xxx'] 块)
改主题元数据themeStore.tsTHEMES 数组
加新 Agentconstants/agents.ts
加新事件类型constants/streamTypes.ts + applyEvent
改 API 地址api/client.ts.env
改后端接口api/*.ts + types/api.ts
找跨组件通信themeStore.tsdispatchXxx 函数

9. 常见修改场景

场景 1:加新主题(最简单)

3 步:① themeStore.tstokens.cssThemeSwitcher.tsx

详见 16-主题系统与ThemeSwitcher.md 第 7 节。

场景 2:给消息加「点赞/点踩」

  1. 扩展 ChatMessage 类型
  2. chatStore.ts 加 rateMessage action
  3. Message.tsx 加 UI 按钮

场景 3:改输入框高度

1
2
3
4
/* InputArea.module.css */
.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 — 了解后端接口格式、错误处理。