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,
|
||||
},
|
||||
});
|
||||
};
|
||||
25
client/screens/demo/index.tsx
Normal file
25
client/screens/demo/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { View, Text } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { styles } from './styles';
|
||||
|
||||
export default function DemoPage() {
|
||||
const { theme, isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
|
||||
<View
|
||||
style={styles.container}
|
||||
>
|
||||
<Image
|
||||
style={styles.logo}
|
||||
source="https://lf-coze-web-cdn.coze.cn/obj/eden-cn/lm-lgvj/ljhwZthlaukjlkulzlp/coze-coding/expo/coze-loading.gif"
|
||||
></Image>
|
||||
<Text style={{...styles.title, color: theme.textPrimary}}>APP 开发中</Text>
|
||||
<Text style={{...styles.description, color: theme.textSecondary}}>即将为您呈现应用界面</Text>
|
||||
</View>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
28
client/screens/demo/styles.ts
Normal file
28
client/screens/demo/styles.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Spacing } from '@/constants/theme';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logo: {
|
||||
width: 130,
|
||||
height: 109,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
marginTop: Spacing.sm,
|
||||
},
|
||||
});
|
||||
206
client/screens/home/index.tsx
Normal file
206
client/screens/home/index.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { View, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
|
||||
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 { useSafeRouter } from '@/hooks/useSafeRouter';
|
||||
import { createStyles } from './styles';
|
||||
|
||||
// 模拟用户ID(实际应用中应该从用户认证系统获取)
|
||||
const MOCK_USER_ID = 'mock-user-001';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { theme, isDark } = useTheme();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
const router = useSafeRouter();
|
||||
|
||||
const [totalCalories, setTotalCalories] = useState(0);
|
||||
const [targetCalories] = useState(2000);
|
||||
const [currentWeight, setCurrentWeight] = useState<number | null>(null);
|
||||
const [targetWeight, setTargetWeight] = useState(65);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 获取今日热量和体重数据
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 获取今日总热量
|
||||
/**
|
||||
* 服务端文件:server/src/routes/food-records.ts
|
||||
* 接口:GET /api/v1/food-records/total-calories
|
||||
* Query 参数:userId: string, date?: string
|
||||
*/
|
||||
const caloriesRes = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/food-records/total-calories?userId=${MOCK_USER_ID}`
|
||||
);
|
||||
const caloriesData = await caloriesRes.json();
|
||||
if (caloriesData.success) {
|
||||
setTotalCalories(caloriesData.data.totalCalories);
|
||||
}
|
||||
|
||||
// 获取体重统计
|
||||
/**
|
||||
* 服务端文件:server/src/routes/weight-records.ts
|
||||
* 接口:GET /api/v1/weight-records/stats
|
||||
* Query 参数:userId: string
|
||||
*/
|
||||
const weightRes = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records/stats?userId=${MOCK_USER_ID}`
|
||||
);
|
||||
const weightData = await weightRes.json();
|
||||
if (weightData.success) {
|
||||
setCurrentWeight(weightData.data.currentWeight);
|
||||
if (weightData.data.targetWeight) {
|
||||
setTargetWeight(weightData.data.targetWeight);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const caloriePercentage = Math.min((totalCalories / targetCalories) * 100, 100);
|
||||
|
||||
return (
|
||||
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={fetchData} tintColor={theme.primary} />
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<ThemedView level="root" style={styles.header}>
|
||||
<ThemedText variant="h2" color={theme.textPrimary}>
|
||||
今日概览
|
||||
</ThemedText>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
坚持就是胜利 💪
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
{/* 热量卡片 */}
|
||||
<ThemedView level="default" style={styles.calorieCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.iconContainer}>
|
||||
<FontAwesome6 name="fire-flame-curved" size={24} color={theme.primary} />
|
||||
</View>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
今日热量
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.calorieContent}>
|
||||
<ThemedText variant="displayLarge" color={theme.primary}>
|
||||
{totalCalories}
|
||||
</ThemedText>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
/ {targetCalories} kcal
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressFill, { width: `${caloriePercentage}%` }]} />
|
||||
</View>
|
||||
|
||||
<ThemedText variant="small" color={theme.textMuted} style={styles.remainingText}>
|
||||
还可摄入 {Math.max(0, targetCalories - totalCalories)} kcal
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
{/* 体重卡片 */}
|
||||
<ThemedView level="default" style={styles.weightCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.iconContainer}>
|
||||
<FontAwesome6 name="weight-scale" size={24} color={theme.primary} />
|
||||
</View>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
当前体重
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.weightContent}>
|
||||
<ThemedText variant="displayLarge" color={theme.primary}>
|
||||
{currentWeight || '--'}
|
||||
</ThemedText>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
kg
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{currentWeight && (
|
||||
<ThemedText variant="small" color={theme.textSecondary}>
|
||||
目标体重:{targetWeight} kg
|
||||
{currentWeight > targetWeight ? ` (还需减 ${(currentWeight - targetWeight).toFixed(1)} kg)` : ' ✨'}
|
||||
</ThemedText>
|
||||
)}
|
||||
</ThemedView>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<View style={styles.quickActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.cameraButton]}
|
||||
onPress={() => router.push('/record')}
|
||||
>
|
||||
<View style={styles.actionIconContainer}>
|
||||
<FontAwesome6 name="camera" size={28} color={theme.buttonPrimaryText} />
|
||||
</View>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
拍照识别
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.chartButton]}
|
||||
onPress={() => router.push('/stats')}
|
||||
>
|
||||
<View style={styles.actionIconContainer}>
|
||||
<FontAwesome6 name="chart-line" size={28} color={theme.buttonPrimaryText} />
|
||||
</View>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
数据统计
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.aiButton]}
|
||||
onPress={() => router.push('/chat')}
|
||||
>
|
||||
<View style={styles.actionIconContainer}>
|
||||
<FontAwesome6 name="robot" size={28} color={theme.buttonPrimaryText} />
|
||||
</View>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
AI 助手
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 最近记录 */}
|
||||
<ThemedView level="root" style={styles.recentSection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
最近记录
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => router.push('/record')}>
|
||||
<ThemedText variant="smallMedium" color={theme.primary}>
|
||||
查看全部
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ThemedText variant="small" color={theme.textMuted} style={styles.emptyText}>
|
||||
暂无记录,快去拍照识别吧!
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
117
client/screens/home/styles.ts
Normal file
117
client/screens/home/styles.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
|
||||
|
||||
export const createStyles = (theme: Theme) => {
|
||||
return StyleSheet.create({
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingTop: Spacing.xl,
|
||||
paddingBottom: Spacing["5xl"],
|
||||
},
|
||||
header: {
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
calorieCard: {
|
||||
padding: Spacing.xl,
|
||||
marginBottom: Spacing.lg,
|
||||
borderRadius: BorderRadius.xl,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
weightCard: {
|
||||
padding: Spacing.xl,
|
||||
marginBottom: Spacing.xl,
|
||||
borderRadius: BorderRadius.xl,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: BorderRadius.lg,
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: Spacing.md,
|
||||
},
|
||||
calorieContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
weightContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
progressBar: {
|
||||
height: 8,
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.full,
|
||||
overflow: 'hidden',
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: theme.primary,
|
||||
borderRadius: BorderRadius.full,
|
||||
},
|
||||
remainingText: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
quickActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: Spacing.xl,
|
||||
gap: Spacing.md,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
padding: Spacing.lg,
|
||||
borderRadius: BorderRadius.xl,
|
||||
alignItems: 'center',
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
cameraButton: {
|
||||
backgroundColor: theme.primary,
|
||||
},
|
||||
chartButton: {
|
||||
backgroundColor: '#10B981',
|
||||
},
|
||||
aiButton: {
|
||||
backgroundColor: '#059669',
|
||||
},
|
||||
actionIconContainer: {
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
recentSection: {
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
paddingVertical: Spacing["2xl"],
|
||||
},
|
||||
});
|
||||
};
|
||||
401
client/screens/record/index.tsx
Normal file
401
client/screens/record/index.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { View, ScrollView, TouchableOpacity, Modal, TextInput, Alert, Image } from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
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 { createFormDataFile } from '@/utils';
|
||||
import { createStyles } from './styles';
|
||||
|
||||
const MOCK_USER_ID = 'mock-user-001';
|
||||
|
||||
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
interface RecognizedFood {
|
||||
foodName: string;
|
||||
weight: number;
|
||||
calories: number;
|
||||
imageUrl: string;
|
||||
imageKey: string;
|
||||
}
|
||||
|
||||
export default function RecordScreen() {
|
||||
const { theme, isDark } = useTheme();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [recognizedFood, setRecognizedFood] = useState<RecognizedFood | null>(null);
|
||||
const [manualFood, setManualFood] = useState({
|
||||
name: '',
|
||||
calories: '',
|
||||
weight: '',
|
||||
mealType: 'breakfast' as MealType,
|
||||
});
|
||||
const [recognizing, setRecognizing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||
|
||||
// 请求相机权限
|
||||
const requestCameraPermission = async () => {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限提示', '需要相机权限才能拍照识别食物');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 拍照
|
||||
const takePicture = async () => {
|
||||
const hasPermission = await requestCameraPermission();
|
||||
if (!hasPermission) return;
|
||||
|
||||
try {
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: false,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImageUri(result.assets[0].uri);
|
||||
await recognizeFood(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Camera error:', error);
|
||||
Alert.alert('错误', '拍照失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 从相册选择
|
||||
const pickImage = async () => {
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: false,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImageUri(result.assets[0].uri);
|
||||
await recognizeFood(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Image picker error:', error);
|
||||
Alert.alert('错误', '选择图片失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 识别食物
|
||||
const recognizeFood = async (uri: string) => {
|
||||
setRecognizing(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const file = await createFormDataFile(uri, 'food_photo.jpg', 'image/jpeg');
|
||||
formData.append('image', file as any);
|
||||
|
||||
/**
|
||||
* 服务端文件:server/src/routes/food-records.ts
|
||||
* 接口:POST /api/v1/food-records/recognize
|
||||
* Body 参数:image: File (FormData)
|
||||
*/
|
||||
const response = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/food-records/recognize`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRecognizedFood(data.data);
|
||||
setManualFood({
|
||||
name: data.data.foodName,
|
||||
calories: data.data.calories.toString(),
|
||||
weight: data.data.weight.toString(),
|
||||
mealType: 'breakfast',
|
||||
});
|
||||
setModalVisible(true);
|
||||
} else {
|
||||
Alert.alert('识别失败', data.error || '无法识别食物,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Recognition error:', error);
|
||||
Alert.alert('错误', '识别失败,请检查网络连接');
|
||||
} finally {
|
||||
setRecognizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存记录
|
||||
const saveRecord = async () => {
|
||||
if (!manualFood.name || !manualFood.calories || !manualFood.weight) {
|
||||
Alert.alert('提示', '请填写完整的食物信息');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const recordData = {
|
||||
userId: MOCK_USER_ID,
|
||||
foodName: manualFood.name,
|
||||
calories: parseInt(manualFood.calories),
|
||||
weight: parseFloat(manualFood.weight),
|
||||
mealType: manualFood.mealType,
|
||||
recordedAt: new Date().toISOString(),
|
||||
imageUrl: recognizedFood?.imageUrl,
|
||||
};
|
||||
|
||||
/**
|
||||
* 服务端文件:server/src/routes/food-records.ts
|
||||
* 接口:POST /api/v1/food-records
|
||||
* Body 参数:userId: string, foodName: string, calories: number, weight: number, mealType: string, recordedAt: string, imageUrl?: string
|
||||
*/
|
||||
const response = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/food-records`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(recordData),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
Alert.alert('成功', '记录已保存', [
|
||||
{ text: '确定', onPress: () => resetForm() },
|
||||
]);
|
||||
} else {
|
||||
Alert.alert('失败', data.error || '保存失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
Alert.alert('错误', '保存失败,请检查网络连接');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setModalVisible(false);
|
||||
setRecognizedFood(null);
|
||||
setImageUri(null);
|
||||
setManualFood({
|
||||
name: '',
|
||||
calories: '',
|
||||
weight: '',
|
||||
mealType: 'breakfast',
|
||||
});
|
||||
};
|
||||
|
||||
const mealTypes: { key: MealType; label: string }[] = [
|
||||
{ key: 'breakfast', label: '早餐' },
|
||||
{ key: 'lunch', label: '午餐' },
|
||||
{ key: 'dinner', label: '晚餐' },
|
||||
{ key: 'snack', label: '加餐' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<ThemedText variant="h2" color={theme.textPrimary} style={styles.title}>
|
||||
记录食物
|
||||
</ThemedText>
|
||||
|
||||
{/* 识别方式 */}
|
||||
<View style={styles.methodButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.methodButton, styles.cameraButton]}
|
||||
onPress={takePicture}
|
||||
disabled={recognizing}
|
||||
>
|
||||
<FontAwesome6 name="camera" size={32} color={theme.buttonPrimaryText} />
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText} style={styles.buttonText}>
|
||||
{recognizing ? '识别中...' : '拍照识别'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.methodButton, styles.galleryButton]}
|
||||
onPress={pickImage}
|
||||
disabled={recognizing}
|
||||
>
|
||||
<FontAwesome6 name="image" size={32} color={theme.buttonPrimaryText} />
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText} style={styles.buttonText}>
|
||||
{recognizing ? '识别中...' : '相册选择'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 手动添加 */}
|
||||
<ThemedView level="root" style={styles.manualSection}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary} style={styles.sectionTitle}>
|
||||
手动添加
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
食物名称
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="输入食物名称"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={manualFood.name}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, name: text })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<View style={[styles.inputGroup, styles.halfInput]}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
热量 (kcal)
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="0"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={manualFood.calories}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, calories: text })}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.inputGroup, styles.halfInput]}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
重量 (g)
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="0"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={manualFood.weight}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, weight: text })}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
餐次
|
||||
</ThemedText>
|
||||
<View style={styles.mealTypes}>
|
||||
{mealTypes.map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type.key}
|
||||
style={[
|
||||
styles.mealTypeButton,
|
||||
manualFood.mealType === type.key && styles.mealTypeButtonActive,
|
||||
]}
|
||||
onPress={() => setManualFood({ ...manualFood, mealType: type.key })}
|
||||
>
|
||||
<ThemedText
|
||||
variant="smallMedium"
|
||||
color={manualFood.mealType === type.key ? theme.buttonPrimaryText : theme.textPrimary}
|
||||
>
|
||||
{type.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { opacity: saving ? 0.6 : 1 }]}
|
||||
onPress={saveRecord}
|
||||
disabled={saving}
|
||||
>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
{saving ? '保存中...' : '保存记录'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
|
||||
{/* 识别结果 Modal */}
|
||||
<Modal visible={modalVisible} transparent animationType="slide">
|
||||
<View style={styles.modalContainer}>
|
||||
<ThemedView level="default" style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
{recognizedFood ? '识别结果' : '确认信息'}
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={resetForm}>
|
||||
<FontAwesome6 name="xmark" size={24} color={theme.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.modalBody}>
|
||||
{imageUri && (
|
||||
<Image source={{ uri: imageUri }} style={styles.previewImage} />
|
||||
)}
|
||||
|
||||
<View style={styles.resultItem}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
食物名称
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.resultInput}
|
||||
value={manualFood.name}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, name: text })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<View style={[styles.resultItem, styles.halfInput]}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
热量
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.resultInput}
|
||||
value={manualFood.calories}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, calories: text })}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.resultItem, styles.halfInput]}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
重量 (g)
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.resultInput}
|
||||
value={manualFood.weight}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, weight: text })}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.cancelButton]}
|
||||
onPress={resetForm}
|
||||
>
|
||||
<ThemedText variant="smallMedium" color={theme.textSecondary}>
|
||||
取消
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.confirmButton]}
|
||||
onPress={saveRecord}
|
||||
>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
{saving ? '保存中...' : '确认保存'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</View>
|
||||
</Modal>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
163
client/screens/record/styles.ts
Normal file
163
client/screens/record/styles.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { StyleSheet, Image } from 'react-native';
|
||||
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
|
||||
|
||||
export const createStyles = (theme: Theme) => {
|
||||
return StyleSheet.create({
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingTop: Spacing.xl,
|
||||
paddingBottom: Spacing["5xl"],
|
||||
},
|
||||
title: {
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
methodButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: Spacing.md,
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
methodButton: {
|
||||
flex: 1,
|
||||
padding: Spacing.xl,
|
||||
borderRadius: BorderRadius.xl,
|
||||
alignItems: 'center',
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
cameraButton: {
|
||||
backgroundColor: theme.primary,
|
||||
},
|
||||
galleryButton: {
|
||||
backgroundColor: '#10B981',
|
||||
},
|
||||
buttonText: {
|
||||
marginTop: Spacing.sm,
|
||||
},
|
||||
manualSection: {
|
||||
padding: Spacing.xl,
|
||||
borderRadius: BorderRadius.xl,
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.md,
|
||||
},
|
||||
halfInput: {
|
||||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.lg,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
},
|
||||
mealTypes: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
mealTypeButton: {
|
||||
flex: 1,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.lg,
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
mealTypeButtonActive: {
|
||||
backgroundColor: theme.primary,
|
||||
borderColor: theme.primary,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: theme.primary,
|
||||
paddingVertical: Spacing.lg,
|
||||
borderRadius: BorderRadius.lg,
|
||||
alignItems: 'center',
|
||||
marginTop: Spacing.md,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
borderTopLeftRadius: BorderRadius["2xl"],
|
||||
borderTopRightRadius: BorderRadius["2xl"],
|
||||
maxHeight: '80%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.xl,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.border,
|
||||
},
|
||||
modalBody: {
|
||||
padding: Spacing.xl,
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: 200,
|
||||
borderRadius: BorderRadius.lg,
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
resultItem: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
resultInput: {
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.lg,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
marginTop: Spacing.sm,
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.md,
|
||||
padding: Spacing.xl,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.border,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
paddingVertical: Spacing.lg,
|
||||
borderRadius: BorderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: theme.primary,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
});
|
||||
};
|
||||
346
client/screens/stats/index.tsx
Normal file
346
client/screens/stats/index.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { View, ScrollView, TouchableOpacity, TextInput, Modal, Alert } from 'react-native';
|
||||
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';
|
||||
|
||||
const MOCK_USER_ID = 'mock-user-001';
|
||||
|
||||
interface WeightRecord {
|
||||
id: string;
|
||||
weight: number;
|
||||
note: string;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export default function StatsScreen() {
|
||||
const { theme, isDark } = useTheme();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
|
||||
const [weightRecords, setWeightRecords] = useState<WeightRecord[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [newWeight, setNewWeight] = useState('');
|
||||
const [newNote, setNewNote] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 获取体重记录
|
||||
const fetchWeightRecords = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
/**
|
||||
* 服务端文件:server/src/routes/weight-records.ts
|
||||
* 接口:GET /api/v1/weight-records
|
||||
* Query 参数:userId: string
|
||||
*/
|
||||
const response = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records?userId=${MOCK_USER_ID}`
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setWeightRecords(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch weight records:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchWeightRecords();
|
||||
}, []);
|
||||
|
||||
// 添加体重记录
|
||||
const addWeightRecord = async () => {
|
||||
if (!newWeight) {
|
||||
Alert.alert('提示', '请输入体重');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const recordData = {
|
||||
userId: MOCK_USER_ID,
|
||||
weight: parseFloat(newWeight),
|
||||
note: newNote,
|
||||
recordedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/**
|
||||
* 服务端文件:server/src/routes/weight-records.ts
|
||||
* 接口:POST /api/v1/weight-records
|
||||
* Body 参数:userId: string, weight: number, note?: string, recordedAt: string
|
||||
*/
|
||||
const response = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(recordData),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
Alert.alert('成功', '记录已保存', [
|
||||
{ text: '确定', onPress: () => resetModal() },
|
||||
]);
|
||||
fetchWeightRecords();
|
||||
} else {
|
||||
Alert.alert('失败', data.error || '保存失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
Alert.alert('错误', '保存失败,请检查网络连接');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setModalVisible(false);
|
||||
setNewWeight('');
|
||||
setNewNote('');
|
||||
};
|
||||
|
||||
// 删除记录
|
||||
const deleteRecord = async (id: string) => {
|
||||
Alert.alert('确认删除', '确定要删除这条记录吗?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
/**
|
||||
* 服务端文件:server/src/routes/weight-records.ts
|
||||
* 接口:DELETE /api/v1/weight-records/:id
|
||||
* Path 参数:id: string
|
||||
*/
|
||||
const response = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
fetchWeightRecords();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.header}>
|
||||
<ThemedText variant="h2" color={theme.textPrimary}>
|
||||
数据统计
|
||||
</ThemedText>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => setModalVisible(true)}
|
||||
>
|
||||
<FontAwesome6 name="plus" size={18} color={theme.buttonPrimaryText} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 体重趋势 */}
|
||||
<ThemedView level="root" style={styles.chartSection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
体重记录
|
||||
</ThemedText>
|
||||
{weightRecords.length > 0 && (
|
||||
<ThemedText variant="small" color={theme.primary}>
|
||||
{weightRecords.length} 条记录
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{weightRecords.length === 0 ? (
|
||||
<ThemedText variant="small" color={theme.textMuted} style={styles.emptyText}>
|
||||
暂无记录,点击右上角添加
|
||||
</ThemedText>
|
||||
) : (
|
||||
<View style={styles.recordsList}>
|
||||
{weightRecords.map((record, index) => (
|
||||
<View key={record.id} style={styles.recordItem}>
|
||||
<View style={styles.recordInfo}>
|
||||
<View style={styles.recordWeight}>
|
||||
<ThemedText variant="h3" color={theme.primary}>
|
||||
{record.weight}
|
||||
</ThemedText>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
kg
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View>
|
||||
<ThemedText variant="small" color={theme.textSecondary}>
|
||||
{new Date(record.recordedAt).toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</ThemedText>
|
||||
{record.note && (
|
||||
<ThemedText variant="caption" color={theme.textMuted}>
|
||||
{record.note}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{index > 0 && (
|
||||
<View style={styles.changeBadge}>
|
||||
<FontAwesome6
|
||||
name={record.weight < weightRecords[index - 1].weight ? 'arrow-down' : 'arrow-up'}
|
||||
size={12}
|
||||
color={record.weight < weightRecords[index - 1].weight ? '#10B981' : '#EF4444'}
|
||||
/>
|
||||
<ThemedText
|
||||
variant="caption"
|
||||
color={record.weight < weightRecords[index - 1].weight ? '#10B981' : '#EF4444'}
|
||||
>
|
||||
{Math.abs(record.weight - weightRecords[index - 1].weight).toFixed(1)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity onPress={() => deleteRecord(record.id)}>
|
||||
<FontAwesome6 name="trash" size={16} color={theme.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{weightRecords.length >= 2 && (
|
||||
<ThemedView level="root" style={styles.statsSection}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary} style={styles.sectionTitle}>
|
||||
统计信息
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.statRow}>
|
||||
<View style={styles.statItem}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
初始体重
|
||||
</ThemedText>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
{weightRecords[weightRecords.length - 1].weight} kg
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statItem, styles.statBorder]}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
当前体重
|
||||
</ThemedText>
|
||||
<ThemedText variant="h4" color={theme.primary}>
|
||||
{weightRecords[0].weight} kg
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
累计变化
|
||||
</ThemedText>
|
||||
<ThemedText
|
||||
variant="h4"
|
||||
color={
|
||||
weightRecords[0].weight < weightRecords[weightRecords.length - 1].weight
|
||||
? '#10B981'
|
||||
: '#EF4444'
|
||||
}
|
||||
>
|
||||
{weightRecords[0].weight < weightRecords[weightRecords.length - 1].weight ? '-' : '+'}
|
||||
{Math.abs(
|
||||
weightRecords[0].weight - weightRecords[weightRecords.length - 1].weight
|
||||
).toFixed(1)}{' '}
|
||||
kg
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</ThemedView>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 添加记录 Modal */}
|
||||
<Modal visible={modalVisible} transparent animationType="fade">
|
||||
<View style={styles.modalContainer}>
|
||||
<ThemedView level="default" style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
添加体重记录
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={resetModal}>
|
||||
<FontAwesome6 name="xmark" size={24} color={theme.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalBody}>
|
||||
<View style={styles.inputGroup}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
体重 (kg)
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="输入体重"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={newWeight}
|
||||
onChangeText={setNewWeight}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
备注 (可选)
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
placeholder="添加备注"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={newNote}
|
||||
onChangeText={setNewNote}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.cancelButton]}
|
||||
onPress={resetModal}
|
||||
>
|
||||
<ThemedText variant="smallMedium" color={theme.textSecondary}>
|
||||
取消
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.confirmButton]}
|
||||
onPress={addWeightRecord}
|
||||
>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</View>
|
||||
</Modal>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
161
client/screens/stats/styles.ts
Normal file
161
client/screens/stats/styles.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
|
||||
|
||||
export const createStyles = (theme: Theme) => {
|
||||
return StyleSheet.create({
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingTop: Spacing.xl,
|
||||
paddingBottom: Spacing["5xl"],
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
addButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: BorderRadius.lg,
|
||||
backgroundColor: theme.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
chartSection: {
|
||||
padding: Spacing.xl,
|
||||
borderRadius: BorderRadius.xl,
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
paddingVertical: Spacing["2xl"],
|
||||
},
|
||||
recordsList: {
|
||||
gap: Spacing.md,
|
||||
},
|
||||
recordItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.lg,
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.lg,
|
||||
},
|
||||
recordInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
recordWeight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: Spacing.xs,
|
||||
},
|
||||
changeBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.sm,
|
||||
paddingVertical: Spacing.xs,
|
||||
backgroundColor: theme.backgroundDefault,
|
||||
borderRadius: BorderRadius.sm,
|
||||
marginRight: Spacing.md,
|
||||
},
|
||||
statsSection: {
|
||||
padding: Spacing.xl,
|
||||
borderRadius: BorderRadius.xl,
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
statRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statBorder: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.lg,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
borderRadius: BorderRadius["2xl"],
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.xl,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.border,
|
||||
},
|
||||
modalBody: {
|
||||
padding: Spacing.xl,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
label: {
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.lg,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.md,
|
||||
padding: Spacing.xl,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.border,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
paddingVertical: Spacing.lg,
|
||||
borderRadius: BorderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: theme.primary,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user