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

402 lines
14 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, 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>
);
}