
一、页面设计思路
AI 对话页是应用的"智能交互入口"------用户可以用自然语言提问关于民族文化的问题,AI 助手给出回答。这是一个典型的聊天界面,但又有自己的特色:
- 领域聚焦:只回答民族文化相关问题,不是通用聊天
- 引导式交互:提供快捷问题,降低用户使用门槛
- 轻量设计:界面简洁,聚焦对话内容本身
页面结构
AI对话页
├── 导航栏(返回 + 标题 + 清空)
├── 内容区(空状态 / 消息列表)
├── 快捷问题区(空状态时显示)
└── 输入栏(输入框 + 发送按钮)
二、数据结构
2.1 消息模型
typescript
interface ChatMessage {
id: string; // 消息唯一ID
role: 'user' | 'assistant'; // 角色:用户 / AI助手
content: string; // 消息内容
timestamp: number; // 时间戳
isLoading?: boolean; // 是否是加载中占位消息
}
设计要点:
role字段区分消息方向,决定消息气泡的样式和位置isLoading是可选字段------只有"AI正在思考"的占位消息才有这个标记id用于消息列表的 key 和查找特定消息(如删除加载消息)
2.2 消息状态枚举
typescript
enum MessageStatus {
SENDING = 'sending', // 发送中
SENT = 'sent', // 已发送
FAILED = 'failed', // 发送失败
READ = 'read', // 已读
TYPING = 'typing' // AI正在输入/打字中
}
消息状态是聊天界面的重要组成部分------用户需要知道"我发的消息发出去了吗?"、"AI收到了吗?"。
状态流转:
用户消息:sending → sent → read
AI消息: typing → sent
sending → failed(失败时)
2.3 快捷问题
typescript
interface QuickQuestion {
id: string;
question: string; // 中文问题
questionEn: string; // 英文问题
category: string; // 分类:history / festival / food / costume
}
快捷问题是"引导用户提问"的重要手段------很多用户不知道该问什么,给出几个示例能大幅降低使用门槛。
分类的作用:
history:历史文化类(如"藏族的历史渊源是什么?")festival:传统节日类(如"傣族泼水节的来历?")food:特色美食类(如"新疆大盘鸡的由来?")costume:民族服饰类(如"苗族银饰有什么寓意?")
2.4 FAQ 分类
typescript
interface FaqCategory {
id: string;
name: string; // 分类名称
nameEn: string; // 英文名称
icon: string; // 图标 emoji
questions: QuickQuestion[];
}
FAQ 比快捷问题更系统------按类别组织,用户可以按主题浏览常见问题。
三、AI对话设计模式
3.1 气泡设计的三种范式
聊天界面的核心是"消息气泡",常见的设计模式有三种:
范式一:纯文本气泡(最简)
┌──────────────┐
│ 用户消息 │ ── 右侧,单色背景
└──────────────┘
┌──────────────┐
│ AI回复消息 │ ── 左侧,另一种背景
└──────────────┘
范式二:头像 + 气泡(标准)
┌──────────────┐
│ 用户消息 │ ── 用户:右侧气泡,无头像
└──────────────┘
🤖 ┌──────────────┐
│ AI回复消息 │ ── AI:左侧头像 + 气泡
└──────────────┘
范式三:双头像(对称)
┌──────────────┐ 👤
│ 用户消息 │
└──────────────┘
🤖 ┌──────────────┐
│ AI回复消息 │
└──────────────┘
「民族图鉴」选择范式二------用户消息不显示头像(因为用户知道是自己发的),AI消息显示头像(明确身份)。这样既节省空间,又保持了辨识度。
3.2 消息时间分割线
聊天记录需要时间分割线,帮助用户理解对话的时间脉络:
───── 今天 14:30 ─────
用户:你好
AI:你好!我是民族文化AI助手...
用户:傣族有什么传统节日?
AI:傣族的传统节日有泼水节、关门节...
───── 昨天 ─────
用户:藏族的藏历新年是什么时候?
AI:藏历新年是藏族最重要的传统节日...
分割规则:
- 相邻消息间隔 > 5分钟:显示时间
- 跨天:显示日期
- 当天:显示"今天 HH:mm"
- 昨天:显示"昨天 HH:mm"
- 更早:显示"MM-DD HH:mm"
3.3 消息状态指示
用户消息的右侧需要有状态指示:
消息内容 ✓✓ ── 已读(双勾)
消息内容 ✓ ── 已发送(单勾)
消息内容 ⏳ ── 发送中(沙漏/转圈)
[重试] 消息内容 ✗ ── 失败(叉号 + 重试按钮)
对于「民族图鉴」AI对话来说,因为是"单轮问答"为主,状态指示可以简化------只需要显示"发送中"和"失败重试"两种状态。
四、状态管理
typescript
@State messages: ChatMessage[] = [];
@State inputText: string = '';
@State isLoading: boolean = false;
@StorageLink('currentAppLanguage') currentLanguage: AppLanguage = AppLanguage.ZH_CN;
private aiService: AIService = AIService.getInstance();
private scroller: Scroller = new Scroller();
| 状态 | 作用 |
|---|---|
messages |
消息列表数组 |
inputText |
输入框内容 |
isLoading |
是否正在等待 AI 回复 |
scroller |
Scroll 组件的控制器,用于滚动到底部 |
Scroller 的作用:
ArkUI 中,如果想主动控制 Scroll 组件的滚动位置 (比如发送消息后自动滚到底部),需要创建一个 Scroller 实例,绑定到 Scroll 组件上,然后调用它的方法:
typescript
private scroller: Scroller = new Scroller();
// 滚到底部
this.scroller.scrollEdge(Edge.Bottom);
这是 ArkUI 中"命令式操作"的典型场景------虽然是声明式 UI,但有些操作(滚动、聚焦等)还是需要命令式 API。
四、消息发送逻辑
4.1 发送消息主流程
typescript
private async sendMessage(text: string): Promise<void> {
const trimmedText = text.trim();
if (trimmedText.length === 0 || this.isLoading) {
return;
}
// 第一步:添加用户消息
const userMsg: ChatMessage = {
id: `msg_user_${Date.now()}`,
role: 'user',
content: trimmedText,
timestamp: Date.now()
};
this.messages.push(userMsg);
this.inputText = '';
this.isLoading = true;
// 第二步:添加加载中占位消息
const loadingMsg: ChatMessage = {
id: `msg_loading_${Date.now()}`,
role: 'assistant',
content: '',
timestamp: Date.now(),
isLoading: true
};
this.messages.push(loadingMsg);
this.scrollToBottom();
try {
// 第三步:调用 AI 服务获取回复
const reply = await this.aiService.sendMessage(trimmedText);
// 第四步:替换加载消息为真实回复
const loadingIndex = this.messages.findIndex(m => m.isLoading === true);
if (loadingIndex !== -1) {
this.messages.splice(loadingIndex, 1);
}
this.messages.push(reply);
} catch (e) {
console.error('[AIChatPage] send message failed:', JSON.stringify(e));
// 失败时也要移除加载消息
const loadingIndex = this.messages.findIndex(m => m.isLoading === true);
if (loadingIndex !== -1) {
this.messages.splice(loadingIndex, 1);
}
} finally {
this.isLoading = false;
this.scrollToBottom();
}
}
4.2 流程拆解
整个发送流程分为 5 步:
用户点击发送
│
▼
1. 添加用户消息到列表 ──► 用户立即看到自己发的消息
│
▼
2. 添加加载中占位消息 ──► 用户知道 AI 在"思考"
│
▼
3. 调用 AI 服务(异步)──► 等待回复...
│
▼
4. 用真实回复替换占位消息 ──► 显示 AI 回答
│
▼
5. 滚动到底部 ──► 用户看到最新消息
为什么用"占位消息 + 替换"而不是"等回复了再加"?
因为用户需要即时反馈------发完消息后,如果界面什么都不变化,用户会怀疑"我发出去了吗?"。
添加加载消息的好处:
- 确认消息已发送
- 表明 AI 正在处理
- 三点动画给用户"进度感"
这就是乐观 UI(Optimistic UI)的设计思想------先给反馈,再等结果。
4.3 滚动到底部
typescript
private scrollToBottom(): void {
setTimeout(() => {
this.scroller.scrollEdge(Edge.Bottom);
}, 50);
}
为什么要用 setTimeout?
因为 messages.push() 之后,UI 还没来得及重新渲染,这时候调用 scrollEdge 可能滚不到最底部(因为新消息的 DOM 还没创建)。
加 50ms 的延迟,等 ArkUI 完成一次渲染后再滚动,就能确保滚到底部了。
⚠️ 注意:50ms 是经验值,不是精确值。ArkUI 的渲染帧大约是 16.7ms(60fps),所以 50ms 足够等 2-3 帧,确保新消息已经渲染完成。
五、空状态设计
当消息列表为空时,显示一个欢迎界面:
typescript
@Builder
buildEmptyView(): void {
Column({ space: $r('app.float.spacing_lg') }) {
Text('\u{1F916}') // 🤖 机器人 emoji
.fontSize(64)
Text(this.isChinese() ? '你好!我是民族文化AI助手' : 'Hello! I am Ethnic Culture AI Assistant')
.fontSize($r('app.float.font_size_lg'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(this.isChinese()
? '我可以回答关于中国56个民族的历史文化、风俗习惯、传统节日、特色美食等问题'
: 'I can answer questions about the history, culture, customs, festivals, and food of China\'s 56 ethnic groups')
.fontSize($r('app.float.font_size_md'))
.fontColor($r('app.color.text_hint'))
.textAlign(TextAlign.Center)
.padding({ left: '24vp', right: '24vp' })
}
.layoutWeight(1)
.width('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
空状态的三要素:
- 大图标:吸引注意力,建立第一印象
- 欢迎语:告诉用户"我是谁"
- 能力说明:告诉用户"我能做什么"
好的空状态不是"空无一物",而是"引导用户开始使用"。
六、消息列表设计
6.1 列表结构
typescript
@Builder
buildMessageList(): void {
Scroll(this.scroller) {
Column({ space: $r('app.float.spacing_md') }) {
ForEach(this.messages, (msg: ChatMessage) => {
if (msg.role === 'user') {
this.buildUserMessage(msg)
} else {
this.buildAssistantMessage(msg)
}
})
}
.width('100%')
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
.padding({ top: $r('app.float.spacing_md'), bottom: $r('app.float.spacing_md') })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
消息列表用 Scroll + Column + ForEach 实现。因为消息数量不会特别大(AI 对话一般不会有几千条),所以不用 LazyForEach 也够用。
6.2 用户消息气泡
typescript
@Builder
buildUserMessage(msg: ChatMessage): void {
Row() {
Blank() // 用 Blank 把消息"推"到右边
Text(msg.content)
.fontSize($r('app.float.font_size_md'))
.fontColor($r('app.color.text_primary'))
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.backgroundColor('#E8F0FE')
.borderRadius({ topLeft: 16, topRight: 4, bottomLeft: 16, bottomRight: 16 })
.textAlign(TextAlign.Start)
.constraintSize({ maxWidth: '75%' })
}
.width('100%')
}
用户消息在右侧 ,用 Blank() + Row 实现右对齐。
气泡形状的设计:
- 三个圆角是 16,一个圆角是 4
- 那个"尖角"朝向消息来源的方向
- 用户消息:右上角是尖角(topRight: 4)
- AI 消息:左上角是尖角(topLeft: 4)
这种"不对称圆角"是聊天气泡的经典设计,一眼就能看出消息是谁发的。
最大宽度限制:
typescript
.constraintSize({ maxWidth: '75%' })
消息气泡最宽占屏幕的 75%,避免长文本撑满整个屏幕,影响可读性。
6.3 AI 消息气泡
typescript
@Builder
buildAssistantMessage(msg: ChatMessage): void {
Row({ space: 10 }) {
// AI头像
Column() {
Text('\u{1F916}')
.fontSize(20)
.width(36)
.height(36)
.borderRadius(18)
.backgroundColor('#F0F2F5')
.textAlign(TextAlign.Center)
}
.width(36)
.alignItems(HorizontalAlign.Start)
if (msg.isLoading) {
// 加载中:三点动画
Row({ space: 6 }) {
Text('●')
.fontSize(10)
.fontColor($r('app.color.text_hint'))
Text('●')
.fontSize(10)
.fontColor($r('app.color.text_hint'))
Text('●')
.fontSize(10)
.fontColor($r('app.color.text_hint'))
}
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.backgroundColor($r('app.color.card_background'))
.borderRadius({ topLeft: 4, topRight: 16, bottomLeft: 16, bottomRight: 16 })
.border({ width: 1, color: $r('app.color.border_color') })
} else {
// 正常消息
Text(msg.content)
.fontSize($r('app.float.font_size_md'))
.fontColor($r('app.color.text_primary'))
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.backgroundColor($r('app.color.card_background'))
.borderRadius({ topLeft: 4, topRight: 16, bottomLeft: 16, bottomRight: 16 })
.border({ width: 1, color: $r('app.color.border_color') })
.textAlign(TextAlign.Start)
.lineHeight(22)
.constraintSize({ maxWidth: '70%' })
}
Blank() // 用 Blank 把消息"推"到左边
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
AI 消息比用户消息多了一个头像------36×36 的圆形灰色背景 + 机器人 emoji。
加载状态的实现:
当 msg.isLoading 为 true 时,显示三个小点(● ● ●),模拟"正在输入"的效果。
6.4 打字机效果
三点加载只是"等待"状态,更好的体验是 AI 回复时逐字显示,就像真人在打字一样------这就是"打字机效果"。
效果原理:
完整文本:"傣族的传统节日有泼水节..."
逐字显示:
第 1 帧:"傣"
第 2 帧:"傣族"
第 3 帧:"傣族的"
...
实现代码:
typescript
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
isLoading?: boolean;
isTyping?: boolean; // 是否正在打字
fullContent?: string; // 完整内容(打字时用)
status?: MessageStatus; // 消息状态
}
@State typingTimer: number = -1;
private readonly TYPING_SPEED: number = 30; // 每字30ms,约33字/秒
private startTypingEffect(msgId: string, fullText: string): void {
const msgIndex = this.messages.findIndex(m => m.id === msgId);
if (msgIndex === -1) return;
let currentIndex = 0;
this.messages[msgIndex].isTyping = true;
this.messages[msgIndex].fullContent = fullText;
this.messages[msgIndex].content = '';
const typeNextChar = () => {
if (currentIndex < fullText.length) {
currentIndex++;
this.messages[msgIndex].content = fullText.substring(0, currentIndex);
this.scrollToBottom();
this.typingTimer = setTimeout(typeNextChar, this.TYPING_SPEED);
} else {
this.messages[msgIndex].isTyping = false;
this.typingTimer = -1;
}
};
typeNextChar();
}
private stopTypingEffect(): void {
if (this.typingTimer !== -1) {
clearTimeout(this.typingTimer);
this.typingTimer = -1;
}
}
aboutToDisappear(): void {
this.stopTypingEffect();
}
在发送消息流程中集成打字机效果:
typescript
try {
const replyText = await this.aiService.sendMessage(trimmedText);
const loadingIndex = this.messages.findIndex(m => m.isLoading === true);
if (loadingIndex !== -1) {
this.messages.splice(loadingIndex, 1);
}
const replyMsg: ChatMessage = {
id: `msg_assistant_${Date.now()}`,
role: 'assistant',
content: '',
timestamp: Date.now(),
isTyping: true,
fullContent: replyText,
status: MessageStatus.TYPING
};
this.messages.push(replyMsg);
this.startTypingEffect(replyMsg.id, replyText);
} catch (e) {
// 错误处理...
}
打字机效果的体验优化:
- 首字延迟:AI回复的第一个字等 200-300ms 再出现,模拟"思考"
- 标点停顿:遇到句号、逗号时多停 50-100ms,模拟真人呼吸
- 可跳过:用户点击气泡时直接显示完整内容,不用等打字
- 长文加速:超过 200 字的回复自动加快打字速度
可跳过的实现:
typescript
@Builder
buildAssistantMessage(msg: ChatMessage): void {
// ... 头像 ...
if (msg.isLoading) {
// 加载中...
} else {
Text(msg.content)
// ... 样式 ...
.onClick(() => {
if (msg.isTyping && msg.fullContent) {
this.skipTyping(msg);
}
})
}
}
private skipTyping(msg: ChatMessage): void {
this.stopTypingEffect();
const msgIndex = this.messages.findIndex(m => m.id === msg.id);
if (msgIndex !== -1 && msg.fullContent) {
this.messages[msgIndex].content = msg.fullContent;
this.messages[msgIndex].isTyping = false;
}
}
💡 为什么打字机效果体验更好?
- 感知更快:用户不用等完整回复才能开始读,边打边读
- 更自然:模拟真人对话的节奏感,不像机器一次性丢一大段文字
- 有期待感:看着文字逐字出现,用户会更专注地阅读
- 可中断:如果答案不是用户想要的,看前几个字就知道了,可以提前打断
6.5 时间分割线
消息列表中插入时间分割线,帮助用户理解对话的时间脉络。
时间格式化工具:
typescript
private formatTimeDivider(timestamp: number, prevTimestamp?: number): string | null {
if (!prevTimestamp) {
return this.formatTimeLabel(timestamp);
}
const diffMinutes = (timestamp - prevTimestamp) / (1000 * 60);
if (diffMinutes > 5) {
return this.formatTimeLabel(timestamp);
}
return null;
}
private formatTimeLabel(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const msgDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const timeStr = `${hours}:${minutes}`;
if (msgDate.getTime() === today.getTime()) {
return `${this.isChinese() ? '今天' : 'Today'} ${timeStr}`;
} else if (msgDate.getTime() === yesterday.getTime()) {
return `${this.isChinese() ? '昨天' : 'Yesterday'} ${timeStr}`;
} else {
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${month}-${day} ${timeStr}`;
}
}
在消息列表中渲染分割线:
typescript
@Builder
buildMessageList(): void {
Scroll(this.scroller) {
Column({ space: $r('app.float.spacing_md') }) {
ForEach(this.messages, (msg: ChatMessage, index: number) => {
Column({ space: $r('app.float.spacing_md') }) {
// 时间分割线
if (index === 0 || this.needTimeDivider(index)) {
this.buildTimeDivider(msg.timestamp)
}
if (msg.role === 'user') {
this.buildUserMessage(msg)
} else {
this.buildAssistantMessage(msg)
}
}
})
}
// ... 样式 ...
}
}
private needTimeDivider(index: number): boolean {
if (index <= 0 || index >= this.messages.length) return false;
const curr = this.messages[index].timestamp;
const prev = this.messages[index - 1].timestamp;
return (curr - prev) > 5 * 60 * 1000;
}
@Builder
buildTimeDivider(timestamp: number): void {
Text(this.formatTimeLabel(timestamp))
.fontSize($r('app.float.font_size_xs'))
.fontColor($r('app.color.text_hint'))
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
.backgroundColor($r('app.color.divider_color'))
.borderRadius(12)
}
七、FAQ 快捷入口
7.1 为什么需要 FAQ?
快捷问题(Quick Questions)只有 4-6 个,适合空状态时的"轻量引导"。但用户可能想系统地探索 AI 能回答什么,这时候就需要 FAQ(常见问题) 了。
FAQ 的价值:
- 降低使用门槛:用户不用想问题,点就行了
- 展示能力边界:让用户知道"AI懂什么、不懂什么"
- 提升使用率:好的 FAQ 能显著提高对话功能的使用频率
- 教育用户:通过问题本身传递民族文化知识
7.2 FAQ 分类设计
「民族图鉴」的 FAQ 按民族文化主题分类:
typescript
const FAQ_CATEGORIES: FaqCategory[] = [
{
id: 'history',
name: '历史渊源',
nameEn: 'History',
icon: '\u{1F4DA}', // 📚
questions: [
{ id: 'q1', question: '藏族的历史渊源是什么?', questionEn: 'What is the history of Tibetan people?', category: 'history' },
{ id: 'q2', question: '蒙古族的起源是什么?', questionEn: 'What is the origin of Mongolian people?', category: 'history' },
{ id: 'q3', question: '满族的历史变迁是怎样的?', questionEn: 'How did Manchu people evolve historically?', category: 'history' },
]
},
{
id: 'festival',
name: '传统节日',
nameEn: 'Festivals',
icon: '\u{1F389}', // 🎉
questions: [
{ id: 'q4', question: '傣族泼水节的来历?', questionEn: 'What is the origin of Dai Water Splashing Festival?', category: 'festival' },
{ id: 'q5', question: '彝族火把节有什么习俗?', questionEn: 'What are the customs of Yi Torch Festival?', category: 'festival' },
{ id: 'q6', question: '藏历新年怎么庆祝?', questionEn: 'How is Tibetan New Year celebrated?', category: 'festival' },
]
},
{
id: 'food',
name: '特色美食',
nameEn: 'Food',
icon: '\u{1F35C}', // 🍜
questions: [
{ id: 'q7', question: '新疆大盘鸡的由来?', questionEn: 'What is the origin of Xinjiang Big Plate Chicken?', category: 'food' },
{ id: 'q8', question: '云南过桥米线的传说?', questionEn: 'What is the legend of Yunnan Crossing Bridge Noodles?', category: 'food' },
]
},
{
id: 'costume',
name: '民族服饰',
nameEn: 'Costumes',
icon: '\u{1F458}', // 👘
questions: [
{ id: 'q9', question: '苗族银饰有什么寓意?', questionEn: 'What is the meaning of Miao silver ornaments?', category: 'costume' },
{ id: 'q10', question: '藏族藏袍的设计特点?', questionEn: 'What are the features of Tibetan robes?', category: 'costume' },
]
}
];
7.3 FAQ 面板组件
FAQ 面板在空状态时显示,采用"分类标签 + 问题列表"的两栏布局:
typescript
@State selectedFaqCategory: string = 'history';
@Builder
buildFaqPanel(): void {
if (this.messages.length === 0) {
Column({ space: $r('app.float.spacing_md') }) {
Text(this.isChinese() ? '常见问题' : 'FAQ')
.fontSize($r('app.float.font_size_md'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
.padding({ left: $r('app.float.spacing_lg') })
// 分类标签栏
Scroll() {
Row({ space: $r('app.float.spacing_sm') }) {
ForEach(FAQ_CATEGORIES, (cat: FaqCategory) => {
Text(`${cat.icon} ${this.isChinese() ? cat.name : cat.nameEn}`)
.fontSize($r('app.float.font_size_sm'))
.fontColor(this.selectedFaqCategory === cat.id
? '#FFFFFF'
: $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(this.selectedFaqCategory === cat.id
? $r('app.color.primary_color')
: $r('app.color.card_background'))
.borderRadius(16)
.border({ width: 1, color: $r('app.color.border_color') })
.onClick(() => {
this.selectedFaqCategory = cat.id;
})
})
}
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
// 问题列表
Column({ space: $r('app.float.spacing_xs') }) {
ForEach(this.getCurrentCategoryQuestions(), (q: QuickQuestion) => {
Row() {
Text('\u{2753}') // ❓
.fontSize(16)
.margin({ right: 8 })
Text(this.isChinese() ? q.question : q.questionEn)
.fontSize($r('app.float.font_size_sm'))
.fontColor($r('app.color.text_secondary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('›')
.fontSize($r('app.float.font_size_md'))
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
.padding({ top: 12, bottom: 12 })
.onClick(() => {
this.sendMessage(this.isChinese() ? q.question : q.questionEn);
})
})
}
.width('100%')
}
.width('100%')
.padding({ bottom: $r('app.float.spacing_md') })
}
}
private getCurrentCategoryQuestions(): QuickQuestion[] {
const cat = FAQ_CATEGORIES.find(c => c.id === this.selectedFaqCategory);
return cat ? cat.questions : [];
}
设计要点:
- 分类横向滚动:分类标签多的时候可以左右滑动
- 选中态明确:选中的分类用主色背景 + 白色文字
- 问题带箭头 :右侧
›箭头暗示"点击可查看" - 点击即发送:点击问题直接发起对话,无缝衔接
- 自动隐藏:开始对话后 FAQ 面板消失,聚焦对话内容
八、快捷问题区
7.1 组件结构
typescript
@Builder
buildQuickQuestions(): void {
if (this.messages.length === 0) {
Column({ space: $r('app.float.spacing_sm') }) {
Text(this.isChinese() ? '试试这些问题' : 'Try these questions')
.fontSize($r('app.float.font_size_sm'))
.fontColor($r('app.color.text_hint'))
.width('100%')
.padding({ left: $r('app.float.spacing_lg') })
Scroll() {
Row({ space: $r('app.float.spacing_sm') }) {
ForEach(QUICK_QUESTIONS, (q: QuickQuestion) => {
Text(this.getQuickQuestionText(q))
.fontSize($r('app.float.font_size_sm'))
.fontColor($r('app.color.text_secondary'))
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor($r('app.color.card_background'))
.borderRadius(20)
.border({ width: 1, color: $r('app.color.border_color') })
.onClick(() => {
this.handleQuickQuestion(q);
})
})
}
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%')
.padding({ bottom: $r('app.float.spacing_sm') })
}
}
7.2 设计要点
- 只在空状态时显示 :
if (this.messages.length === 0)------ 用户开始对话后,快捷问题就隐藏了,因为用户已经知道怎么用了 - 横向滚动:快捷问题是横向排列的,节省纵向空间
- 胶囊形状:圆角 20vp,带边框,类似标签样式
- 点击即发送 :点击快捷问题直接调用
sendMessage,和手动输入效果一样
快捷问题的产品价值:
- 降低门槛:新用户不知道问什么,给几个示例就会了
- 引导需求:把最常见的问题放在前面,提高功能使用率
- 展示能力:通过问题展示 AI 的能力范围
八、输入栏设计
typescript
@Builder
buildInputBar(): void {
Row({ space: $r('app.float.spacing_sm') }) {
TextInput({
placeholder: this.isChinese() ? '输入你的问题...' : 'Type your question...',
text: this.inputText
})
.layoutWeight(1)
.height(44)
.padding({ left: $r('app.float.spacing_md'), right: $r('app.float.spacing_md') })
.placeholderColor($r('app.color.text_hint'))
.fontColor($r('app.color.text_primary'))
.fontSize($r('app.float.font_size_md'))
.backgroundColor($r('app.color.card_background'))
.borderRadius(22)
.border({ width: 1, color: $r('app.color.border_color') })
.onChange((value: string) => {
this.inputText = value;
})
.onSubmit(() => {
this.sendMessage(this.inputText);
})
Button(this.isChinese() ? '发送' : 'Send')
.type(ButtonType.Normal)
.width(72)
.height(44)
.borderRadius(22)
.backgroundColor(this.inputText.trim().length > 0 && !this.isLoading
? $r('app.color.primary_color')
: '#CCCCCC')
.fontColor('#FFFFFF')
.fontSize($r('app.float.font_size_md'))
.enabled(this.inputText.trim().length > 0 && !this.isLoading)
.onClick(() => {
this.sendMessage(this.inputText);
})
}
.width('100%')
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
.padding({ top: $r('app.float.spacing_sm'), bottom: $r('app.float.spacing_lg') })
.alignItems(VerticalAlign.Center)
.backgroundColor($r('app.color.card_background'))
}
输入栏的交互细节
发送按钮的状态:
| 条件 | 按钮状态 | 颜色 |
|---|---|---|
| 输入框为空 | 禁用 | 灰色 |
| 正在加载中 | 禁用 | 灰色 |
| 有内容且未加载 | 可用 | 主色 |
用同一个表达式同时控制 backgroundColor 和 enabled,确保状态一致:
typescript
.enabled(this.inputText.trim().length > 0 && !this.isLoading)
键盘提交:
typescript
.onSubmit(() => {
this.sendMessage(this.inputText);
})
onSubmit 是 TextInput 的回调------用户点击软键盘的"发送/完成"按钮时触发。这样用户可以直接用键盘发送,不用抬手点按钮。
为什么用 .trim() 判断?
因为用户可能输入了全是空格的"空内容",trim 后长度为 0,不应该允许发送。
九、导航栏设计
typescript
@Builder
buildNavBar(): void {
Row() {
Text('<')
.fontSize($r('app.float.font_size_xxl'))
.fontColor($r('app.color.text_primary'))
.onClick(() => { router.back(); })
Text(this.isChinese() ? '民族文化AI助手' : 'Ethnic Culture AI Assistant')
.fontSize($r('app.float.font_size_lg'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.textAlign(TextAlign.Center)
Text(this.isChinese() ? '清空' : 'Clear')
.fontSize($r('app.float.font_size_md'))
.fontColor(this.messages.length > 0
? $r('app.color.primary_color')
: $r('app.color.text_hint'))
.onClick(() => {
if (this.messages.length > 0) {
this.clearChat();
}
})
}
.width('100%')
.height(48)
.padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
.alignItems(VerticalAlign.Center)
.backgroundColor($r('app.color.card_background'))
}
清空按钮的交互细节:
- 没有消息时:灰色文字,点击无反应(视觉上不可操作)
- 有消息时:主色文字,点击清空
为什么不用 Dialog 做二次确认?
对于聊天记录来说,"清空"的成本不高------清空了再问就是了。而且对话内容是临时的,不是重要数据。所以直接清空反而更流畅。
当然,如果后续加上"对话历史保存"功能,那就需要二次确认了。
十、对话历史管理与持久化
10.1 为什么需要持久化?
当前的消息列表只存在于内存中------用户退出页面再进来,对话就没了。这对于"随便问问"的场景够用,但如果用户问了一个很有用的问题,想回头再看,那就需要持久化了。
持久化的价值:
- 可回溯:用户可以回顾之前的对话
- 上下文连贯:下次进入可以继续之前的话题
- 节省Token:常见问题不用重复问AI
- 数据积累:可以分析用户最关心什么问题
10.2 存储方案选型
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Preferences | 简单、轻量 | 单条value不能太大 | 几十条以内的消息 |
| 关系型数据库 | 查询灵活、支持分页 | 稍重 | 大量消息、需要搜索 |
| 文件存储(JSON) | 可读、易备份 | 不支持查询 | 导出/导入场景 |
「民族图鉴」AI对话选择 Preferences 方案------对话消息不会太多(一般用户不会存几百条),Preferences 足够用了,而且实现简单。
10.3 ChatHistoryService 实现
typescript
const CHAT_HISTORY_KEY = 'chat_history';
const MAX_HISTORY_MESSAGES = 100; // 最多保存100条消息
export class ChatHistoryService {
private static instance: ChatHistoryService;
private preferences: Preferences | null = null;
private constructor() {}
static getInstance(): ChatHistoryService {
if (!ChatHistoryService.instance) {
ChatHistoryService.instance = new ChatHistoryService();
}
return ChatHistoryService.instance;
}
async init(context: Context): Promise<void> {
try {
this.preferences = await dataPreferences.getPreferences(context, CHAT_HISTORY_KEY);
} catch (e) {
console.error('[ChatHistoryService] init failed:', JSON.stringify(e));
}
}
async saveMessages(messages: ChatMessage[]): Promise<void> {
if (!this.preferences) return;
try {
const messagesToSave = messages.slice(-MAX_HISTORY_MESSAGES);
const jsonStr = JSON.stringify(messagesToSave);
await this.preferences.put(CHAT_HISTORY_KEY, jsonStr);
await this.preferences.flush();
} catch (e) {
console.error('[ChatHistoryService] save failed:', JSON.stringify(e));
}
}
async loadMessages(): Promise<ChatMessage[]> {
if (!this.preferences) return [];
try {
const jsonStr = await this.preferences.get(CHAT_HISTORY_KEY, '') as string;
if (!jsonStr) return [];
return JSON.parse(jsonStr) as ChatMessage[];
} catch (e) {
console.error('[ChatHistoryService] load failed:', JSON.stringify(e));
return [];
}
}
async clearHistory(): Promise<void> {
if (!this.preferences) return;
try {
await this.preferences.delete(CHAT_HISTORY_KEY);
await this.preferences.flush();
} catch (e) {
console.error('[ChatHistoryService] clear failed:', JSON.stringify(e));
}
}
}
设计要点:
- 数量上限:最多保存 100 条,超出丢弃最早的,防止数据无限增长
- 异常兜底:所有操作都 try-catch,失败不影响主流程
- 单例模式:全局唯一实例,避免重复初始化
10.4 在页面中集成
typescript
private chatHistoryService: ChatHistoryService = ChatHistoryService.getInstance();
private saveTimer: number = -1;
private readonly SAVE_DEBOUNCE: number = 1000; // 1秒防抖保存
async aboutToAppear(): Promise<void> {
try {
await this.chatHistoryService.init(getContext());
const savedMessages = await this.chatHistoryService.loadMessages();
if (savedMessages.length > 0) {
this.messages = savedMessages;
this.scrollToBottom();
}
} catch (e) {
console.error('[AIChatPage] load history failed:', JSON.stringify(e));
}
}
private debounceSave(): void {
if (this.saveTimer !== -1) {
clearTimeout(this.saveTimer);
}
this.saveTimer = setTimeout(() => {
this.chatHistoryService.saveMessages(this.messages);
this.saveTimer = -1;
}, this.SAVE_DEBOUNCE);
}
在消息列表变化时调用 debounceSave()------比如发送消息、收到回复、清空对话后。
防抖保存的原因:
- 消息变化很频繁(打字机效果每30ms变一次)
- 每次都存太频繁,浪费性能
- 1秒的延迟对用户来说感知不到,但能大幅减少存储次数
10.5 历史消息的清理策略
保存太多历史消息也有问题------占空间、加载慢。所以需要清理策略:
策略一:数量限制(当前方案)
- 最多保留 100 条,超出自动淘汰最早的
- 简单直接,用户感知弱
策略二:时间限制
- 保留最近 7 天/30 天的消息
- 更符合"历史记录"的直觉
策略三:手动管理
- 提供"历史记录"页面,用户可以手动删除
- 用户可控,但增加了复杂度
「民族图鉴」v1.0 选择策略一(数量限制),简单够用。后续版本可以考虑加入"历史记录页"。
十一、输入框高级设计
11.1 输入框的演进
基础版输入框只有"文字 + 发送按钮",但一个完整的聊天输入框应该有更多能力:
基础版: [输入框] [发送]
进阶版: [🎤😀 输入框 📎] [发送]
完整版: [⌨️🎤📷📍😀 输入框 ↑] [发送]
「民族图鉴」AI对话的输入框定位是"轻量但够用",支持三种输入方式:
- 文字输入:最基础,也是最主要的方式
- 表情输入:增加趣味性(虽然是问问题,但也可以有表情)
- 语音输入:解放双手,适合移动端
11.2 表情输入面板
表情输入不是必要功能,但能增加趣味性。对于「民族图鉴」来说,可以做一个"轻量表情面板":
typescript
@State showEmojiPanel: boolean = false;
private readonly EMOJI_LIST: string[] = [
'\u{1F60A}', '\u{1F604}', '\u{1F60D}', '\u{1F914}', '\u{1F44D}',
'\u{1F389}', '\u{1F3AD}', '\u{1F3A8}', '\u{1F3B5}', '\u{1F4DA}',
'\u{1F30F}', '\u{1F3D4}\uFE0F', '\u{2764}\uFE0F', '\u{1F4AB}', '\u{2728}'
];
@Builder
buildEmojiPanel(): void {
if (this.showEmojiPanel) {
Column() {
Grid() {
ForEach(this.EMOJI_LIST, (emoji: string) => {
GridItem() {
Text(emoji)
.fontSize(28)
.textAlign(TextAlign.Center)
.width('100%')
.height(48)
.onClick(() => {
this.inputText += emoji;
this.showEmojiPanel = false;
})
}
})
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')
.rowsGap(8)
.columnsGap(8)
.padding(12)
.backgroundColor($r('app.color.card_background'))
}
.width('100%')
}
}
设计要点:
- 6-7 列的网格布局,表情大小适中
- 点击表情追加到输入框,面板自动收起
- 表情选择偏"文化相关"的(书籍、音乐、艺术、地球等)
11.3 语音输入设计
语音输入是移动端的重要输入方式------用户不用打字,直接说话就能提问。
技术方案:
- 鸿蒙系统提供了语音识别能力(AVSpeechRecognizer)
- 点击麦克风按钮 → 开始录音 → 实时转文字 → 松开停止 → 文字填入输入框
UI 设计:
正常状态: [🎤 输入你的问题...] [发送]
录音状态: [正在聆听... 🔴] [取消]
识别中: [识别中...] [取消]
核心代码框架:
typescript
@State isRecording: boolean = false;
@State recordingText: string = '';
private async startVoiceInput(): Promise<void> {
try {
this.isRecording = true;
this.recordingText = '';
// 调用系统语音识别能力
// const result = await AVSpeechRecognizer.start();
// this.inputText = result.text;
} catch (e) {
console.error('[AIChatPage] voice input failed:', JSON.stringify(e));
promptAction.showToast({ message: '语音识别失败,请重试' });
} finally {
this.isRecording = false;
}
}
private stopVoiceInput(): void {
// AVSpeechRecognizer.stop();
this.isRecording = false;
}
💡 注意:语音识别需要申请麦克风权限,而且不同设备的支持程度可能不同。在「民族图鉴」v1.0 中,语音输入作为"进阶功能",可以后续版本再加。
十二、加载状态与错误重试
12.1 三种加载状态
AI 对话有三种典型的加载状态,每种状态的展示方式不同:
| 状态 | 触发时机 | UI 表现 |
|---|---|---|
| 思考中 | 用户发送后,AI 还没开始回复 | 三点跳动动画 |
| 打字中 | AI 正在逐字输出回复 | 打字机效果 |
| 加载历史 | 进入页面时加载历史消息 | 顶部 Loading |
思考中的三点动画:
typescript
@State loadingDotIndex: number = 0;
private dotAnimTimer: number = -1;
private startLoadingAnimation(): void {
this.loadingDotIndex = 0;
const animate = () => {
this.loadingDotIndex = (this.loadingDotIndex + 1) % 3;
this.dotAnimTimer = setTimeout(animate, 400);
};
animate();
}
private stopLoadingAnimation(): void {
if (this.dotAnimTimer !== -1) {
clearTimeout(this.dotAnimTimer);
this.dotAnimTimer = -1;
}
}
@Builder
buildLoadingDots(): void {
Row({ space: 4 }) {
ForEach([0, 1, 2], (i: number) => {
Circle({ width: 6, height: 6 })
.fill(this.loadingDotIndex === i
? $r('app.color.primary_color')
: $r('app.color.text_hint'))
})
}
}
三个圆点循环高亮,模拟"跳动"效果。
12.2 错误处理的完整设计
当前的错误处理只是"静默失败"------用户可能不知道出错了。一个好的错误处理应该是:
- 可见:用户明确知道出错了
- 可理解:用通俗的语言说明问题(不是技术术语)
- 可操作:用户知道下一步该做什么(重试、检查网络等)
错误消息模型:
typescript
interface ErrorMessage extends ChatMessage {
role: 'system';
errorType: 'network' | 'timeout' | 'server' | 'unknown';
retryable: boolean;
originalQuestion: string;
}
错误消息气泡:
typescript
@Builder
buildErrorMessage(msg: ErrorMessage): void {
Column({ space: 8 }) {
Row() {
Text('\u{26A0}\uFE0F') // ⚠️
.fontSize(16)
.margin({ right: 8 })
Text(this.getErrorTip(msg.errorType))
.fontSize($r('app.float.font_size_sm'))
.fontColor($r('app.color.danger_color'))
.layoutWeight(1)
}
.width('100%')
if (msg.retryable) {
Button(this.isChinese() ? '重新发送' : 'Retry')
.type(ButtonType.Outline)
.height(32)
.fontSize($r('app.float.font_size_sm'))
.onClick(() => {
this.retrySend(msg.originalQuestion, msg.id);
})
}
}
.width('80%')
.padding(12)
.backgroundColor('#FFF2F0')
.borderRadius(12)
.border({ width: 1, color: '#FFCCC7' })
}
private getErrorTip(errorType: string): string {
if (!this.isChinese()) {
switch (errorType) {
case 'network': return 'Network error, please check your connection';
case 'timeout': return 'Request timeout, please try again';
case 'server': return 'Server error, please try later';
default: return 'Something went wrong';
}
}
switch (errorType) {
case 'network': return '网络连接失败,请检查网络设置';
case 'timeout': return '请求超时,请稍后重试';
case 'server': return '服务器开小差了,请稍后再试';
default: return '出了点小问题,请重试';
}
}
在发送流程中集成错误处理:
typescript
try {
const reply = await this.aiService.sendMessage(trimmedText);
// ... 成功处理 ...
} catch (e) {
const loadingIndex = this.messages.findIndex(m => m.isLoading === true);
if (loadingIndex !== -1) {
this.messages.splice(loadingIndex, 1);
}
const errorType = this.classifyError(e);
const errorMsg: ErrorMessage = {
id: `msg_error_${Date.now()}`,
role: 'system',
content: '',
timestamp: Date.now(),
errorType: errorType,
retryable: errorType !== 'server',
originalQuestion: trimmedText
};
this.messages.push(errorMsg as ChatMessage);
}
private classifyError(error: any): string {
const code = error?.code || '';
if (code === 'NETWORK_ERROR' || code === 'OFFLINE') return 'network';
if (code === 'TIMEOUT') return 'timeout';
if (code >= 500) return 'server';
return 'unknown';
}
private retrySend(question: string, errorMsgId: string): void {
const errorIndex = this.messages.findIndex(m => m.id === errorMsgId);
if (errorIndex !== -1) {
this.messages.splice(errorIndex, 1);
}
this.sendMessage(question);
}
12.3 超时与重试机制
AI 服务可能响应慢,需要设置超时和自动重试:
typescript
class AIService {
private readonly TIMEOUT_MS = 30000; // 30秒超时
private readonly MAX_RETRIES = 1; // 最多重试1次
async sendMessage(text: string, retryCount: number = 0): Promise<string> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT_MS);
// 实际的 API 调用...
// const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return '模拟回复';
} catch (e) {
if (retryCount < this.MAX_RETRIES && this.isRetryableError(e)) {
console.info(`[AIService] retry ${retryCount + 1}/${this.MAX_RETRIES}`);
return this.sendMessage(text, retryCount + 1);
}
throw e;
}
}
private isRetryableError(error: any): boolean {
return error?.code === 'TIMEOUT' || error?.code === 'NETWORK_ERROR';
}
}
重试策略:
- 只对"网络错误"和"超时"自动重试
- 最多重试 1 次(太多次用户等不及)
- 重试对用户透明(但如果最终失败了要告诉用户)
十三、架构思考
10.1 页面与服务的分工
AIChatPage (UI 层)
├── 消息展示、输入框、快捷问题
└── 调用 AIService.sendMessage()
AIService (服务层)
├── 调用 AI 模型 API
├── 对话历史管理
├── 超时、重试、降级
└── 返回格式化的回复
页面层只管"显示"和"交互",AI 调用的具体实现(用哪家 API、怎么鉴权、怎么处理错误)都封装在 AIService 里。
这样做的好处:
- 页面代码简洁,不掺杂业务逻辑
- Service 可以独立测试和优化
- 换 AI 服务商只改 Service,不用改页面
10.2 错误处理策略
当前的错误处理比较"安静"------catch 里只是打印日志,移除加载消息。
可以改进的方向:
- 显示错误消息:在聊天中显示一条"抱歉,网络异常,请稍后再试"的系统消息
- 重试按钮:错误消息旁边加个"重试"按钮
- 降级回复:如果 AI 服务不可用,返回预设的兜底回复
错误处理做得好,用户体验会上一个台阶。
十一、性能与体验优化
11.1 消息列表性能
对于几百条以内的消息,ForEach + Scroll 完全够用。如果消息量很大,可以考虑:
- 使用
LazyForEach实现懒加载 - 分页加载历史消息(向上滚动加载更多)
- 虚拟列表(只渲染可视区域内的消息)
11.2 输入防抖
当前每次输入都直接更新 inputText,没有防抖。对于发送按钮的状态判断来说,这点开销可以忽略。
但如果要做"输入联想"或"实时搜索",就需要加防抖了。
14.3 软键盘适配
在移动端,弹出软键盘时可能会遮挡输入栏。可以用 keyboardAvoidMode 或监听键盘高度来调整布局。
十五、小结
| 技术点 | 关键方法/组件 | 难度 |
|---|---|---|
| 聊天气泡设计 | Row + Blank + 不对称圆角 | ⭐⭐ |
| 消息发送流程 | 乐观 UI + 加载占位 + 异步请求 | ⭐⭐⭐ |
| 滚动控制 | Scroller + scrollEdge + setTimeout |
⭐⭐ |
| 打字机效果 | setTimeout 逐字显示 + 可跳过 | ⭐⭐⭐ |
| 时间分割线 | 时间格式化 + 间隔判断 | ⭐⭐ |
| FAQ 快捷入口 | 分类标签 + 问题列表 + 点击发送 | ⭐⭐ |
| 快捷问题引导 | 横向 Scroll + 胶囊标签 | ⭐⭐ |
| 输入栏交互 | 发送按钮状态联动 + onSubmit | ⭐⭐ |
| 表情输入面板 | Grid 网格 + 点击插入 | ⭐⭐ |
| 对话历史持久化 | Preferences + 防抖保存 | ⭐⭐⭐ |
| 错误重试机制 | 错误分类 + 重试按钮 + 自动重试 | ⭐⭐⭐ |
| 空状态设计 | 大图标 + 欢迎语 + 能力说明 | ⭐⭐ |
| 服务层封装 | AIService 单例模式 | ⭐⭐⭐ |
AI 对话页是一个"麻雀虽小,五脏俱全"的页面------它包含了列表渲染、用户输入、异步加载、状态管理、空状态、错误处理、持久化等常见的交互模式。把这个页面写好了,你就能驾驭大多数内容类应用的页面开发。
从"简单的文字对话"到"完整的AI交互体验",我们经历了这些升级:
- 基础版:发消息、收回复、气泡展示
- 体验版:打字机效果、时间分割线、三点动画
- 引导版:快捷问题、FAQ 分类、空状态设计
- 稳健版:错误重试、超时控制、历史持久化
每一层升级都是为了让用户体验更好、功能更稳。
下一篇我们将进入 音乐页的开发,看看如何实现一个完整的音乐播放器,包括播放列表、迷你播放器、进度控制等功能。