healthapp
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

11 KiB

06-AI对话页面

目标

实现 APP 端 AI 健康问诊对话功能。


UI 设计参考

参考设计稿:files/ui/问答页.pngfiles/ui/问答对话.png

对话首页

区域 设计要点
导航栏 "AI健康助手" + 绿色 "在线" 状态
AI 欢迎语 机器人图标(蓝色 #3B82F6)+ 灰色气泡
常见问题 灰色标签 + 白色圆角按钮(多个快捷问题)
输入区 麦克风 + 输入框 + 绿色发送按钮

消息气泡

类型 样式
AI 消息 左对齐,机器人图标蓝色 #3B82F6,气泡白色 #FFFFFF
用户消息 右对齐,用户图标绿色 #10B981,气泡绿色 #10B981,文字白色
时间 灰色 #9CA3AF,位于气泡下方

样式常量

const chatStyles = {
  // 气泡
  userBubble: {
    backgroundColor: '#10B981',
    borderRadius: 16,
    borderBottomRightRadius: 4,
  },
  assistantBubble: {
    backgroundColor: '#FFFFFF',
    borderRadius: 16,
    borderBottomLeftRadius: 4,
  },
  // 头像
  userAvatar: {
    backgroundColor: '#10B981',
  },
  aiAvatar: {
    backgroundColor: '#3B82F6',
  },
  // 输入区
  inputContainer: {
    backgroundColor: '#FFFFFF',
    borderTopColor: '#E5E7EB',
  },
  sendButton: {
    backgroundColor: '#10B981',
    borderRadius: 20,
  },
}

实现要点

  1. 消息列表:使用 FlatList 渲染消息,支持自动滚动到底部
  2. 输入框:固定在底部,支持多行输入
  3. 键盘适配:使用 KeyboardAvoidingView 处理键盘弹出
  4. 消息气泡:区分用户和 AI 消息样式

关键代码示例

对话列表页面

// src/screens/chat/ChatListScreen.tsx
import React, { useState, useEffect } from 'react'
import { View, FlatList, StyleSheet, TouchableOpacity } from 'react-native'
import { Text, FAB, Card, IconButton } from 'react-native-paper'
import { useNavigation } from '@react-navigation/native'
import dayjs from 'dayjs'
import { getConversations, createConversation, deleteConversation } from '../../api/conversation'
import type { Conversation } from '../../types'
import type { ChatNavigationProp } from '../../navigation/types'

const ChatListScreen = () => {
  const navigation = useNavigation<ChatNavigationProp>()
  const [conversations, setConversations] = useState<Conversation[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    loadConversations()
  }, [])

  const loadConversations = async () => {
    try {
      const data = await getConversations()
      setConversations(data)
    } finally {
      setLoading(false)
    }
  }

  const handleCreate = async () => {
    const conv = await createConversation()
    navigation.navigate('ChatDetail', { id: conv.id })
  }

  const handleDelete = async (id: number) => {
    await deleteConversation(id)
    setConversations(conversations.filter((c) => c.id !== id))
  }

  const renderItem = ({ item }: { item: Conversation }) => (
    <TouchableOpacity
      onPress={() => navigation.navigate('ChatDetail', { id: item.id })}
    >
      <Card style={styles.card}>
        <Card.Content style={styles.cardContent}>
          <View style={styles.cardInfo}>
            <Text style={styles.cardTitle}>{item.title}</Text>
            <Text style={styles.cardTime}>
              {dayjs(item.updated_at).format('MM-DD HH:mm')}
            </Text>
          </View>
          <IconButton
            icon="delete"
            size={20}
            onPress={() => handleDelete(item.id)}
          />
        </Card.Content>
      </Card>
    </TouchableOpacity>
  )

  return (
    <View style={styles.container}>
      {conversations.length === 0 ? (
        <View style={styles.emptyContainer}>
          <Text style={styles.emptyText}>暂无对话记录</Text>
          <Text style={styles.emptySubtext}>点击下方按钮开始第一次对话</Text>
        </View>
      ) : (
        <FlatList
          data={conversations}
          renderItem={renderItem}
          keyExtractor={(item) => item.id.toString()}
          contentContainerStyle={styles.list}
        />
      )}

      <FAB
        icon="plus"
        style={styles.fab}
        onPress={handleCreate}
        label="新建对话"
      />
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  list: {
    padding: 16,
  },
  card: {
    marginBottom: 12,
  },
  cardContent: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  cardInfo: {
    flex: 1,
  },
  cardTitle: {
    fontSize: 16,
    fontWeight: '500',
  },
  cardTime: {
    fontSize: 12,
    color: '#999',
    marginTop: 4,
  },
  emptyContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  emptyText: {
    fontSize: 16,
    color: '#666',
  },
  emptySubtext: {
    fontSize: 14,
    color: '#999',
    marginTop: 8,
  },
  fab: {
    position: 'absolute',
    right: 16,
    bottom: 16,
  },
})

export default ChatListScreen

对话详情页面

// src/screens/chat/ChatDetailScreen.tsx
import React, { useState, useEffect, useRef } from 'react'
import {
  View,
  FlatList,
  StyleSheet,
  KeyboardAvoidingView,
  Platform,
} from 'react-native'
import { Text, TextInput, IconButton, Avatar } from 'react-native-paper'
import { useRoute } from '@react-navigation/native'
import dayjs from 'dayjs'
import { getConversation, sendMessage } from '../../api/conversation'
import { useUserStore } from '../../stores/userStore'
import type { Message } from '../../types'
import type { ChatDetailRouteProp } from '../../navigation/types'

const ChatDetailScreen = () => {
  const route = useRoute<ChatDetailRouteProp>()
  const { id } = route.params
  const { user } = useUserStore()
  const flatListRef = useRef<FlatList>(null)

  const [messages, setMessages] = useState<Message[]>([])
  const [inputText, setInputText] = useState('')
  const [sending, setSending] = useState(false)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    loadMessages()
  }, [])

  const loadMessages = async () => {
    try {
      const data = await getConversation(id)
      setMessages(data.messages || [])
    } finally {
      setLoading(false)
    }
  }

  const handleSend = async () => {
    const content = inputText.trim()
    if (!content || sending) return

    // 添加用户消息
    const userMessage: Message = {
      id: Date.now(),
      role: 'user',
      content,
      created_at: new Date().toISOString(),
    }
    setMessages((prev) => [...prev, userMessage])
    setInputText('')

    setSending(true)
    try {
      const res = await sendMessage(id, content)
      
      const assistantMessage: Message = {
        id: Date.now() + 1,
        role: 'assistant',
        content: res.reply,
        created_at: new Date().toISOString(),
      }
      setMessages((prev) => [...prev, assistantMessage])
    } catch (error) {
      // 移除用户消息
      setMessages((prev) => prev.slice(0, -1))
      setInputText(content)
    } finally {
      setSending(false)
    }
  }

  const renderMessage = ({ item }: { item: Message }) => {
    const isUser = item.role === 'user'
    
    return (
      <View style={[styles.messageRow, isUser && styles.messageRowUser]}>
        {!isUser && (
          <Avatar.Text size={36} label="AI" style={styles.avatar} />
        )}
        <View
          style={[
            styles.messageBubble,
            isUser ? styles.userBubble : styles.assistantBubble,
          ]}
        >
          <Text style={isUser ? styles.userText : styles.assistantText}>
            {item.content}
          </Text>
        </View>
        {isUser && (
          <Avatar.Text
            size={36}
            label={user?.nickname?.charAt(0) || 'U'}
            style={styles.avatar}
          />
        )}
      </View>
    )
  }

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : undefined}
      keyboardVerticalOffset={90}
    >
      <FlatList
        ref={flatListRef}
        data={messages}
        renderItem={renderMessage}
        keyExtractor={(item) => item.id.toString()}
        contentContainerStyle={styles.messageList}
        onContentSizeChange={() => flatListRef.current?.scrollToEnd()}
      />

      {sending && (
        <View style={styles.typingIndicator}>
          <Text style={styles.typingText}>AI 正在输入...</Text>
        </View>
      )}

      <View style={styles.inputContainer}>
        <TextInput
          style={styles.input}
          value={inputText}
          onChangeText={setInputText}
          placeholder="请描述您的健康问题..."
          multiline
          disabled={sending}
        />
        <IconButton
          icon="send"
          size={24}
          disabled={!inputText.trim() || sending}
          onPress={handleSend}
        />
      </View>

      <View style={styles.disclaimer}>
        <Text style={styles.disclaimerText}>
          AI 建议仅供参考,不构成医疗诊断
        </Text>
      </View>
    </KeyboardAvoidingView>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  messageList: {
    padding: 16,
  },
  messageRow: {
    flexDirection: 'row',
    marginBottom: 16,
    alignItems: 'flex-end',
  },
  messageRowUser: {
    flexDirection: 'row-reverse',
  },
  avatar: {
    marginHorizontal: 8,
  },
  messageBubble: {
    maxWidth: '70%',
    padding: 12,
    borderRadius: 16,
  },
  userBubble: {
    backgroundColor: '#667eea',
    borderBottomRightRadius: 4,
  },
  assistantBubble: {
    backgroundColor: '#fff',
    borderBottomLeftRadius: 4,
  },
  userText: {
    color: '#fff',
  },
  assistantText: {
    color: '#333',
  },
  typingIndicator: {
    paddingHorizontal: 16,
    paddingVertical: 8,
  },
  typingText: {
    color: '#999',
    fontSize: 13,
  },
  inputContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    padding: 8,
    backgroundColor: '#fff',
    borderTopWidth: 1,
    borderTopColor: '#eee',
  },
  input: {
    flex: 1,
    maxHeight: 100,
    backgroundColor: '#f5f5f5',
    borderRadius: 20,
    paddingHorizontal: 16,
  },
  disclaimer: {
    padding: 8,
    backgroundColor: '#fef0f0',
    alignItems: 'center',
  },
  disclaimerText: {
    fontSize: 12,
    color: '#f56c6c',
  },
})

export default ChatDetailScreen

需要创建的文件

文件路径 说明
src/api/conversation.ts 对话 API
src/screens/chat/ChatListScreen.tsx 对话列表
src/screens/chat/ChatDetailScreen.tsx 对话详情

验收标准

  • 对话列表正常显示
  • 新建和删除对话正常
  • 消息发送和接收正常
  • 键盘弹出时布局正常
  • 免责声明显示

预计耗时

30-40 分钟


下一步

完成后进入 04-APP开发/07-个人中心页面.md