feat: 实现减脂体重管理App完整功能

- 实现拍照识别食物功能(集成大语言模型视觉能力)
- 实现智能对话功能(集成大语言模型流式输出)
- 实现食物记录和卡路里管理功能
- 实现体重记录和统计功能
- 实现健康数据管理页面
- 配置数据库表结构(用户、食物记录、体重记录)
- 实现Express后端API路由
- 配置Tab导航和前端页面
- 采用健康运动配色方案
This commit is contained in:
jaystar
2026-02-02 15:17:50 +08:00
commit 28c4d7b3b4
82 changed files with 21891 additions and 0 deletions

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

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