feat: 实现减脂体重管理App完整功能
- 实现拍照识别食物功能(集成大语言模型视觉能力) - 实现智能对话功能(集成大语言模型流式输出) - 实现食物记录和卡路里管理功能 - 实现体重记录和统计功能 - 实现健康数据管理页面 - 配置数据库表结构(用户、食物记录、体重记录) - 实现Express后端API路由 - 配置Tab导航和前端页面 - 采用健康运动配色方案
This commit is contained in:
239
client/screens/chat/index.tsx
Normal file
239
client/screens/chat/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { View, ScrollView, TextInput, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
|
||||
import RNSSE from 'react-native-sse';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { FontAwesome6 } from '@expo/vector-icons';
|
||||
import { createStyles } from './styles';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { theme, isDark } = useTheme();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>(() => [{
|
||||
role: 'assistant',
|
||||
content: '你好!我是你的健康饮食和减脂顾问助手。我可以帮助你:\n\n• 制定科学的饮食计划\n• 分析食物营养成分\n• 提供减脂建议\n• 解答健康问题\n\n请问有什么可以帮助你的吗?',
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const sseRef = useRef<RNSSE | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sseRef.current) {
|
||||
sseRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputText.trim() || loading) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content: inputText.trim(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputText('');
|
||||
setLoading(true);
|
||||
|
||||
// 构建对话历史
|
||||
const conversationHistory = messages
|
||||
.slice(1) // 跳过欢迎消息
|
||||
.map((msg) => ({ role: msg.role, content: msg.content }));
|
||||
|
||||
try {
|
||||
/**
|
||||
* 服务端文件:server/src/routes/ai-chat.ts
|
||||
* 接口:POST /api/v1/ai-chat/chat
|
||||
* Body 参数:message: string, conversationHistory?: Array<{role: string, content: string}>
|
||||
*/
|
||||
const url = `${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/ai-chat/chat`;
|
||||
|
||||
const sse = new RNSSE(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: inputText.trim(),
|
||||
conversationHistory,
|
||||
}),
|
||||
});
|
||||
|
||||
sseRef.current = sse;
|
||||
|
||||
let assistantContent = '';
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 添加空的助手消息
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
|
||||
sse.addEventListener('message', (event: any) => {
|
||||
if (event.data === '[DONE]') {
|
||||
sse.close();
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.content) {
|
||||
assistantContent += data.content;
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = {
|
||||
...updated[updated.length - 1],
|
||||
content: assistantContent,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Parse error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
sse.addEventListener('error', (error: any) => {
|
||||
console.error('SSE error:', error);
|
||||
sse.close();
|
||||
setLoading(false);
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = {
|
||||
...updated[updated.length - 1],
|
||||
content: assistantContent || '抱歉,我遇到了一些问题,请稍后再试。',
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Send message error:', error);
|
||||
setLoading(false);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '抱歉,发送消息失败,请检查网络连接。',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
|
||||
<ThemedView level="root" style={styles.header}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
AI 健康助手
|
||||
</ThemedText>
|
||||
<View style={styles.headerStatus}>
|
||||
<View style={[styles.statusDot, { backgroundColor: loading ? '#10B981' : '#9CA3AF' }]} />
|
||||
<ThemedText variant="caption" color={theme.textMuted}>
|
||||
{loading ? '正在回复...' : '在线'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.messagesContainer}
|
||||
contentContainerStyle={styles.messagesContent}
|
||||
onContentSizeChange={() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}}
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.messageWrapper,
|
||||
message.role === 'user' ? styles.userMessage : styles.assistantMessage,
|
||||
]}
|
||||
>
|
||||
<ThemedView
|
||||
level="default"
|
||||
style={[
|
||||
styles.messageBubble,
|
||||
message.role === 'user'
|
||||
? { backgroundColor: theme.primary }
|
||||
: { backgroundColor: theme.backgroundTertiary },
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
variant="body"
|
||||
color={message.role === 'user' ? theme.buttonPrimaryText : theme.textPrimary}
|
||||
style={styles.messageText}
|
||||
>
|
||||
{message.content}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText variant="tiny" color={theme.textMuted} style={styles.messageTime}>
|
||||
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</ThemedText>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{loading && messages[messages.length - 1]?.role === 'assistant' && (
|
||||
<View style={[styles.messageWrapper, styles.assistantMessage]}>
|
||||
<ThemedView level="default" style={[styles.messageBubble, { backgroundColor: theme.backgroundTertiary }]}>
|
||||
<View style={styles.typingIndicator}>
|
||||
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
|
||||
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
|
||||
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
|
||||
</View>
|
||||
</ThemedView>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
||||
>
|
||||
<ThemedView level="root" style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="输入你的问题..."
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
multiline
|
||||
maxLength={500}
|
||||
editable={!loading}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, { opacity: !inputText.trim() || loading ? 0.5 : 1 }]}
|
||||
onPress={sendMessage}
|
||||
disabled={!inputText.trim() || loading}
|
||||
>
|
||||
<FontAwesome6
|
||||
name="paper-plane"
|
||||
size={20}
|
||||
color={theme.buttonPrimaryText}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
</KeyboardAvoidingView>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
102
client/screens/chat/styles.ts
Normal file
102
client/screens/chat/styles.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
|
||||
|
||||
export const createStyles = (theme: Theme) => {
|
||||
return StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.border,
|
||||
},
|
||||
headerStatus: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
messagesContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
messagesContent: {
|
||||
padding: Spacing.lg,
|
||||
gap: Spacing.md,
|
||||
paddingBottom: Spacing["2xl"],
|
||||
},
|
||||
messageWrapper: {
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
userMessage: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
assistantMessage: {
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
messageBubble: {
|
||||
maxWidth: '80%',
|
||||
padding: Spacing.md,
|
||||
borderRadius: BorderRadius.lg,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
messageText: {
|
||||
lineHeight: 22,
|
||||
},
|
||||
messageTime: {
|
||||
paddingHorizontal: Spacing.sm,
|
||||
},
|
||||
typingIndicator: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.xs,
|
||||
paddingVertical: Spacing.xs,
|
||||
},
|
||||
typingDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: Spacing.md,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.border,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.lg,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
maxHeight: 120,
|
||||
},
|
||||
sendButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: BorderRadius.lg,
|
||||
backgroundColor: theme.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user