HarmonyOS应用《民族图鉴》开发第28篇:AI对话页——智能问答交互

一、页面设计思路

AI 对话页是应用的"智能交互入口"------用户可以用自然语言提问关于民族文化的问题,AI 助手给出回答。这是一个典型的聊天界面,但又有自己的特色:

  1. 领域聚焦:只回答民族文化相关问题,不是通用聊天
  2. 引导式交互:提供快捷问题,降低用户使用门槛
  3. 轻量设计:界面简洁,聚焦对话内容本身

页面结构

复制代码
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. 滚动到底部 ──► 用户看到最新消息

为什么用"占位消息 + 替换"而不是"等回复了再加"?

因为用户需要即时反馈------发完消息后,如果界面什么都不变化,用户会怀疑"我发出去了吗?"。

添加加载消息的好处:

  1. 确认消息已发送
  2. 表明 AI 正在处理
  3. 三点动画给用户"进度感"

这就是乐观 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)
}

空状态的三要素:

  1. 大图标:吸引注意力,建立第一印象
  2. 欢迎语:告诉用户"我是谁"
  3. 能力说明:告诉用户"我能做什么"

好的空状态不是"空无一物",而是"引导用户开始使用"。

六、消息列表设计

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) {
  // 错误处理...
}

打字机效果的体验优化

  1. 首字延迟:AI回复的第一个字等 200-300ms 再出现,模拟"思考"
  2. 标点停顿:遇到句号、逗号时多停 50-100ms,模拟真人呼吸
  3. 可跳过:用户点击气泡时直接显示完整内容,不用等打字
  4. 长文加速:超过 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;
  }
}

💡 为什么打字机效果体验更好?

  1. 感知更快:用户不用等完整回复才能开始读,边打边读
  2. 更自然:模拟真人对话的节奏感,不像机器一次性丢一大段文字
  3. 有期待感:看着文字逐字出现,用户会更专注地阅读
  4. 可中断:如果答案不是用户想要的,看前几个字就知道了,可以提前打断

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 的价值:

  1. 降低使用门槛:用户不用想问题,点就行了
  2. 展示能力边界:让用户知道"AI懂什么、不懂什么"
  3. 提升使用率:好的 FAQ 能显著提高对话功能的使用频率
  4. 教育用户:通过问题本身传递民族文化知识

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 : [];
}

设计要点

  1. 分类横向滚动:分类标签多的时候可以左右滑动
  2. 选中态明确:选中的分类用主色背景 + 白色文字
  3. 问题带箭头 :右侧 箭头暗示"点击可查看"
  4. 点击即发送:点击问题直接发起对话,无缝衔接
  5. 自动隐藏:开始对话后 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 设计要点

  1. 只在空状态时显示if (this.messages.length === 0) ------ 用户开始对话后,快捷问题就隐藏了,因为用户已经知道怎么用了
  2. 横向滚动:快捷问题是横向排列的,节省纵向空间
  3. 胶囊形状:圆角 20vp,带边框,类似标签样式
  4. 点击即发送 :点击快捷问题直接调用 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'))
}

输入栏的交互细节

发送按钮的状态

条件 按钮状态 颜色
输入框为空 禁用 灰色
正在加载中 禁用 灰色
有内容且未加载 可用 主色

用同一个表达式同时控制 backgroundColorenabled,确保状态一致:

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 为什么需要持久化?

当前的消息列表只存在于内存中------用户退出页面再进来,对话就没了。这对于"随便问问"的场景够用,但如果用户问了一个很有用的问题,想回头再看,那就需要持久化了。

持久化的价值

  1. 可回溯:用户可以回顾之前的对话
  2. 上下文连贯:下次进入可以继续之前的话题
  3. 节省Token:常见问题不用重复问AI
  4. 数据积累:可以分析用户最关心什么问题

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

设计要点

  1. 数量上限:最多保存 100 条,超出丢弃最早的,防止数据无限增长
  2. 异常兜底:所有操作都 try-catch,失败不影响主流程
  3. 单例模式:全局唯一实例,避免重复初始化

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对话的输入框定位是"轻量但够用",支持三种输入方式:

  1. 文字输入:最基础,也是最主要的方式
  2. 表情输入:增加趣味性(虽然是问问题,但也可以有表情)
  3. 语音输入:解放双手,适合移动端

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 错误处理的完整设计

当前的错误处理只是"静默失败"------用户可能不知道出错了。一个好的错误处理应该是:

  1. 可见:用户明确知道出错了
  2. 可理解:用通俗的语言说明问题(不是技术术语)
  3. 可操作:用户知道下一步该做什么(重试、检查网络等)

错误消息模型

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 里只是打印日志,移除加载消息。

可以改进的方向

  1. 显示错误消息:在聊天中显示一条"抱歉,网络异常,请稍后再试"的系统消息
  2. 重试按钮:错误消息旁边加个"重试"按钮
  3. 降级回复:如果 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交互体验",我们经历了这些升级:

  1. 基础版:发消息、收回复、气泡展示
  2. 体验版:打字机效果、时间分割线、三点动画
  3. 引导版:快捷问题、FAQ 分类、空状态设计
  4. 稳健版:错误重试、超时控制、历史持久化

每一层升级都是为了让用户体验更好、功能更稳。

下一篇我们将进入 音乐页的开发,看看如何实现一个完整的音乐播放器,包括播放列表、迷你播放器、进度控制等功能。

相关推荐
心中有国也有家1 小时前
E-Brufen 技术选型全景:Flutter + 鸿蒙 + Hive CE
hive·hadoop·学习·flutter·华为·harmonyos
绝世番茄3 小时前
鸿蒙原生ArkTS布局方式之List+swipeAction侧滑菜单深度解析
华为·list·harmonyos·鸿蒙
Helen_cai4 小时前
OpenHarmony Preferences 本地持久化存储开发(API Version23 + 适配版)
华为·harmonyos
千逐684 小时前
UniApp 鸿蒙实战:音视频与多媒体处理完全指南
华为·uni-app·harmonyos·鸿蒙
国服第二切图仔5 小时前
HarmonyOS APP《画伴梦工厂》开发第51篇-Skill开发入门——VibeCoding与系统级智能入口
华为·harmonyos
哦***75 小时前
最后一块拼图补齐!华为大豆包时代开启✨
华为·音频·harmonyos
在书中成长5 小时前
HarmonyOS 小游戏《对战五子棋》开发第11篇 - AIPlayer设计概览:三档难度架构
华为·harmonyos
最爱老式锅包肉5 小时前
《HarmonyOS技术精讲-ArkGraphics 2D(方舟2D图形服务)》第七篇:动效来袭——与ArkUI结合实现2D动画与交互
华为·交互·harmonyos
HarmonyOS_SDK6 小时前
Account Kit新能力:AI赋能Skill接入“华为账号一键登录”,实现6倍效率飞跃
harmonyos