Files
height_manager/client/screens/stats/index.tsx
jaystar 28c4d7b3b4 feat: 实现减脂体重管理App完整功能
- 实现拍照识别食物功能(集成大语言模型视觉能力)
- 实现智能对话功能(集成大语言模型流式输出)
- 实现食物记录和卡路里管理功能
- 实现体重记录和统计功能
- 实现健康数据管理页面
- 配置数据库表结构(用户、食物记录、体重记录)
- 实现Express后端API路由
- 配置Tab导航和前端页面
- 采用健康运动配色方案
2026-02-02 15:17:50 +08:00

347 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}