- 实现拍照识别食物功能(集成大语言模型视觉能力) - 实现智能对话功能(集成大语言模型流式输出) - 实现食物记录和卡路里管理功能 - 实现体重记录和统计功能 - 实现健康数据管理页面 - 配置数据库表结构(用户、食物记录、体重记录) - 实现Express后端API路由 - 配置Tab导航和前端页面 - 采用健康运动配色方案
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|