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.
 
 
 
 
 
 

12 KiB

06-AI对话页面(原型)

目标

实现 APP 端 AI 健康问诊对话功能,使用模拟数据模拟多轮对话效果。


UI 设计参考

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


前置要求

  • 导航配置完成
  • 模拟数据服务已创建(src/mock/chat.ts

实施步骤

步骤 1:创建对话状态 Store

创建 src/stores/useChatStore.ts

import { create } from 'zustand'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { Conversation, Message } from '../types'

interface ChatState {
  conversations: Conversation[]
  addConversation: (conv: Conversation) => void
  deleteConversation: (id: string) => void
  addMessage: (convId: string, message: Message) => void
}

export const useChatStore = create<ChatState>((set, get) => ({
  conversations: [],
  
  addConversation: (conv) => {
    const updated = [conv, ...get().conversations]
    AsyncStorage.setItem('conversations', JSON.stringify(updated))
    set({ conversations: updated })
  },
  
  deleteConversation: (id) => {
    const updated = get().conversations.filter((c) => c.id !== id)
    AsyncStorage.setItem('conversations', JSON.stringify(updated))
    set({ conversations: updated })
  },
  
  addMessage: (convId, message) => {
    const updated = get().conversations.map((c) => {
      if (c.id === convId) {
        return {
          ...c,
          messages: [...c.messages, message],
          updatedAt: new Date().toISOString(),
        }
      }
      return c
    })
    AsyncStorage.setItem('conversations', JSON.stringify(updated))
    set({ conversations: updated })
  },
}))

步骤 2:对话列表页面

创建 src/screens/chat/ChatListScreen.tsx

import React from 'react'
import { View, FlatList, StyleSheet, TouchableOpacity, Alert } from 'react-native'
import { Text, FAB, Card, IconButton } from 'react-native-paper'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { useNavigation } from '@react-navigation/native'
import dayjs from 'dayjs'
import { useChatStore } from '../../stores/useChatStore'
import type { ChatNavigationProp } from '../../navigation/types'

const ChatListScreen = () => {
  const navigation = useNavigation<ChatNavigationProp>()
  const { conversations, addConversation, deleteConversation } = useChatStore()

  const handleCreate = () => {
    const newConv = {
      id: Date.now().toString(),
      title: '新对话',
      messages: [],
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    }
    addConversation(newConv)
    navigation.navigate('ChatDetail', { id: newConv.id })
  }

  const handleDelete = (id: string) => {
    Alert.alert('确认删除', '确定要删除这个对话吗?', [
      { text: '取消', style: 'cancel' },
      { text: '删除', style: 'destructive', onPress: () => deleteConversation(id) },
    ])
  }

  const renderItem = ({ item }: { item: typeof conversations[0] }) => (
    <TouchableOpacity onPress={() => navigation.navigate('ChatDetail', { id: item.id })}>
      <Card style={styles.card}>
        <Card.Content style={styles.cardContent}>
          <View style={styles.cardIcon}>
            <Icon name="chat-processing" size={24} color="#10B981" />
          </View>
          <View style={styles.cardInfo}>
            <Text style={styles.cardTitle}>{item.title}</Text>
            <Text style={styles.cardTime}>
              {dayjs(item.updatedAt).format('MM-DD HH:mm')}
            </Text>
          </View>
          <IconButton
            icon="delete-outline"
            size={20}
            onPress={() => handleDelete(item.id)}
          />
        </Card.Content>
      </Card>
    </TouchableOpacity>
  )

  return (
    <View style={styles.container}>
      {conversations.length === 0 ? (
        <View style={styles.emptyContainer}>
          <Icon name="chat-outline" size={64} color="#D1D5DB" />
          <Text style={styles.emptyText}>暂无对话记录</Text>
          <Text style={styles.emptySubtext}>点击下方按钮开始咨询</Text>
        </View>
      ) : (
        <FlatList
          data={conversations}
          renderItem={renderItem}
          keyExtractor={(item) => item.id}
          contentContainerStyle={styles.list}
        />
      )}

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

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#F3F4F6' },
  list: { padding: 16 },
  card: { marginBottom: 12, borderRadius: 12 },
  cardContent: { flexDirection: 'row', alignItems: 'center' },
  cardIcon: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#ECFDF5', justifyContent: 'center', alignItems: 'center' },
  cardInfo: { flex: 1, marginLeft: 12 },
  cardTitle: { fontSize: 16, fontWeight: '500', color: '#1F2937' },
  cardTime: { fontSize: 12, color: '#9CA3AF', marginTop: 4 },
  emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  emptyText: { fontSize: 16, color: '#6B7280', marginTop: 16 },
  emptySubtext: { fontSize: 14, color: '#9CA3AF', marginTop: 4 },
  fab: { position: 'absolute', right: 16, bottom: 16, backgroundColor: '#10B981' },
})

export default ChatListScreen

步骤 3:对话详情页面

创建 src/screens/chat/ChatDetailScreen.tsx

import React, { useState, 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 { useChatStore } from '../../stores/useChatStore'
import { useAuthStore } from '../../stores/useAuthStore'
import { mockAIReply } from '../../mock/chat'
import type { ChatDetailRouteProp } from '../../navigation/types'
import type { Message } from '../../types'

const ChatDetailScreen = () => {
  const route = useRoute<ChatDetailRouteProp>()
  const { id } = route.params
  const { conversations, addMessage } = useChatStore()
  const { user } = useAuthStore()
  const flatListRef = useRef<FlatList>(null)

  const conversation = conversations.find((c) => c.id === id)
  const messages = conversation?.messages || []

  const [inputText, setInputText] = useState('')
  const [sending, setSending] = useState(false)

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

    // 添加用户消息
    const userMessage: Message = {
      id: Date.now().toString(),
      role: 'user',
      content,
      createdAt: new Date().toISOString(),
    }
    addMessage(id, userMessage)
    setInputText('')

    // 模拟AI回复
    setSending(true)
    try {
      const reply = await mockAIReply(content)
      const assistantMessage: Message = {
        id: (Date.now() + 1).toString(),
        role: 'assistant',
        content: reply,
        createdAt: new Date().toISOString(),
      }
      addMessage(id, assistantMessage)
    } finally {
      setSending(false)
    }
  }

  const renderMessage = ({ item }: { item: Message }) => {
    const isUser = item.role === 'user'
    
    return (
      <View style={[styles.messageRow, isUser && styles.messageRowUser]}>
        {!isUser && (
          <Avatar.Icon size={36} icon="robot" style={styles.avatarAI} />
        )}
        <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.avatarUser}
          />
        )}
      </View>
    )
  }

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : undefined}
      keyboardVerticalOffset={90}
    >
      {/* 欢迎消息 */}
      {messages.length === 0 && (
        <View style={styles.welcomeContainer}>
          <Avatar.Icon size={64} icon="robot" style={styles.welcomeAvatar} />
          <Text style={styles.welcomeTitle}>AI健康助手</Text>
          <Text style={styles.welcomeText}>
            您好!我是AI健康助手,可以为您提供健康咨询和建议。
            {'\n'}请描述您的症状或健康问题。
          </Text>
        </View>
      )}

      {/* 消息列表 */}
      <FlatList
        ref={flatListRef}
        data={messages}
        renderItem={renderMessage}
        keyExtractor={(item) => item.id}
        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
          maxLength={500}
          disabled={sending}
        />
        <IconButton
          icon="send"
          size={24}
          iconColor="#fff"
          style={styles.sendButton}
          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: '#F3F4F6' },
  welcomeContainer: { alignItems: 'center', padding: 32 },
  welcomeAvatar: { backgroundColor: '#3B82F6' },
  welcomeTitle: { fontSize: 20, fontWeight: '600', marginTop: 16, color: '#1F2937' },
  welcomeText: { fontSize: 14, color: '#6B7280', textAlign: 'center', marginTop: 8, lineHeight: 22 },
  messageList: { padding: 16 },
  messageRow: { flexDirection: 'row', marginBottom: 16, alignItems: 'flex-end' },
  messageRowUser: { flexDirection: 'row-reverse' },
  avatarAI: { backgroundColor: '#3B82F6', marginRight: 8 },
  avatarUser: { backgroundColor: '#10B981', marginLeft: 8 },
  messageBubble: { maxWidth: '70%', padding: 12, borderRadius: 16 },
  userBubble: { backgroundColor: '#10B981', borderBottomRightRadius: 4 },
  assistantBubble: { backgroundColor: '#fff', borderBottomLeftRadius: 4 },
  userText: { color: '#fff', fontSize: 14, lineHeight: 20 },
  assistantText: { color: '#1F2937', fontSize: 14, lineHeight: 20 },
  typingIndicator: { paddingHorizontal: 16, paddingVertical: 8 },
  typingText: { color: '#9CA3AF', fontSize: 13 },
  inputContainer: { flexDirection: 'row', alignItems: 'center', padding: 8, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#E5E7EB' },
  input: { flex: 1, maxHeight: 100, backgroundColor: '#F3F4F6', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8 },
  sendButton: { backgroundColor: '#10B981', marginLeft: 8 },
  disclaimer: { padding: 8, backgroundColor: '#FEF3C7', alignItems: 'center' },
  disclaimerText: { fontSize: 11, color: '#92400E' },
})

export default ChatDetailScreen

模拟数据说明

使用 src/mock/chat.ts 中的 mockAIReply 函数:

  • 根据关键词匹配预设回答
  • 支持:疲劳、失眠、关节痛等常见问题
  • 回答格式包含:情况分析、建议、用药参考、产品推荐

验收标准

  • 对话列表正常显示
  • 新建对话正常
  • 删除对话正常
  • 消息发送和模拟回复正常
  • 消息气泡样式正确
  • 免责声明显示

预计耗时

35-45 分钟


下一步

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