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