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>
|
|||
|
|
);
|
|||
|
|
}
|