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
11 KiB
06-AI对话页面
目标
实现 APP 端 AI 健康问诊对话功能。
UI 设计参考
参考设计稿:
files/ui/问答页.png、files/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,
},
}
实现要点
- 消息列表:使用 FlatList 渲染消息,支持自动滚动到底部
- 输入框:固定在底部,支持多行输入
- 键盘适配:使用 KeyboardAvoidingView 处理键盘弹出
- 消息气泡:区分用户和 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