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