- 实现拍照识别食物功能(集成大语言模型视觉能力) - 实现智能对话功能(集成大语言模型流式输出) - 实现食物记录和卡路里管理功能 - 实现体重记录和统计功能 - 实现健康数据管理页面 - 配置数据库表结构(用户、食物记录、体重记录) - 实现Express后端API路由 - 配置Tab导航和前端页面 - 采用健康运动配色方案
238 lines
6.9 KiB
TypeScript
238 lines
6.9 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
||
import {
|
||
View,
|
||
Text,
|
||
TouchableOpacity,
|
||
StyleSheet,
|
||
Keyboard,
|
||
Platform,
|
||
useColorScheme,
|
||
ViewStyle,
|
||
TextStyle
|
||
} from 'react-native';
|
||
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||
import dayjs from 'dayjs';
|
||
import { FontAwesome6 } from '@expo/vector-icons';
|
||
|
||
// --------------------------------------------------------
|
||
// 1. 配置 Dayjs
|
||
// --------------------------------------------------------
|
||
// 即使服务端返回 '2023-10-20T10:00:00Z' (UTC),
|
||
// dayjs(utcString).format() 会自动转为手机当前的本地时区显示。
|
||
// 如果需要传回给后端,我们再转回 ISO 格式。
|
||
|
||
interface SmartDateInputProps {
|
||
label?: string; // 表单标题 (可选)
|
||
value?: string | null; // 服务端返回的时间字符串 (ISO 8601, 带 T)
|
||
onChange: (isoDate: string) => void; // 回调给父组件的值,依然是标准 ISO 字符串
|
||
placeholder?: string;
|
||
mode?: 'date' | 'time' | 'datetime'; // 支持日期、时间、或两者
|
||
displayFormat?: string; // UI展示的格式,默认 YYYY-MM-DD
|
||
error?: string; // 错误信息
|
||
|
||
// 样式自定义(可选)
|
||
containerStyle?: ViewStyle; // 外层容器样式
|
||
inputStyle?: ViewStyle; // 输入框样式
|
||
textStyle?: TextStyle; // 文字样式
|
||
labelStyle?: TextStyle; // 标签样式
|
||
placeholderTextStyle?: TextStyle; // 占位符文字样式
|
||
errorTextStyle?: TextStyle; // 错误信息文字样式
|
||
iconColor?: string; // 图标颜色
|
||
iconSize?: number; // 图标大小
|
||
}
|
||
|
||
export const SmartDateInput = ({
|
||
label,
|
||
value,
|
||
onChange,
|
||
placeholder = '请选择',
|
||
mode = 'date',
|
||
displayFormat,
|
||
error,
|
||
containerStyle,
|
||
inputStyle,
|
||
textStyle,
|
||
labelStyle,
|
||
placeholderTextStyle,
|
||
errorTextStyle,
|
||
iconColor,
|
||
iconSize = 18
|
||
}: SmartDateInputProps) => {
|
||
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
|
||
const colorScheme = useColorScheme();
|
||
const isDark = colorScheme === 'dark';
|
||
|
||
// 默认展示格式
|
||
const format = displayFormat || (mode === 'time' ? 'HH:mm' : 'YYYY-MM-DD');
|
||
|
||
// --------------------------------------------------------
|
||
// 2. 核心:数据转换逻辑
|
||
// --------------------------------------------------------
|
||
|
||
// 解析服务端值,确保无效值不传给控件;time 模式兼容仅时间字符串
|
||
const parsedValue = useMemo(() => {
|
||
if (!value) return null;
|
||
|
||
const direct = dayjs(value);
|
||
if (direct.isValid()) return direct;
|
||
|
||
if (mode === 'time') {
|
||
const timeOnly = dayjs(`1970-01-01T${value}`);
|
||
if (timeOnly.isValid()) return timeOnly;
|
||
}
|
||
|
||
return null;
|
||
}, [value, mode]);
|
||
|
||
// A. 将字符串转为 JS Date 对象给控件使用
|
||
// 如果 value 是空或无效,回退到当前时间
|
||
const dateObjectForPicker = useMemo(() => {
|
||
return parsedValue ? parsedValue.toDate() : new Date();
|
||
}, [parsedValue]);
|
||
|
||
// B. 将 Date 对象转为展示字符串
|
||
const displayString = useMemo(() => {
|
||
if (!parsedValue) return '';
|
||
return parsedValue.format(format);
|
||
}, [parsedValue, format]);
|
||
|
||
// --------------------------------------------------------
|
||
// 3. 核心:交互逻辑 (解决键盘遮挡/无法收起)
|
||
// --------------------------------------------------------
|
||
|
||
const showDatePicker = () => {
|
||
// 【关键点】打开日期控件前,必须强制收起键盘!
|
||
// 否则键盘会遮挡 iOS 的底部滚轮,或者导致 Android 焦点混乱
|
||
Keyboard.dismiss();
|
||
setDatePickerVisibility(true);
|
||
};
|
||
|
||
const hideDatePicker = () => {
|
||
setDatePickerVisibility(false);
|
||
};
|
||
|
||
const handleConfirm = (date: Date) => {
|
||
hideDatePicker();
|
||
// 采用带本地偏移的 ISO 字符串,避免 date 模式在非 UTC 时区出现跨天
|
||
const serverString = dayjs(date).format(format);
|
||
onChange(serverString);
|
||
};
|
||
|
||
// 根据 mode 选择图标
|
||
const iconName = mode === 'time' ? 'clock' : 'calendar';
|
||
|
||
return (
|
||
<View style={[styles.container, containerStyle]}>
|
||
{/* 标题 */}
|
||
{label && <Text style={[styles.label, labelStyle]}>{label}</Text>}
|
||
|
||
{/*
|
||
这里用 TouchableOpacity 模拟 Input。
|
||
模拟组件永远不会唤起键盘。
|
||
*/}
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.inputBox,
|
||
error ? styles.inputBoxError : null,
|
||
inputStyle
|
||
]}
|
||
onPress={showDatePicker}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Text
|
||
style={[
|
||
styles.text,
|
||
textStyle,
|
||
!value && styles.placeholder,
|
||
!value && placeholderTextStyle
|
||
]}
|
||
numberOfLines={1}
|
||
>
|
||
{displayString || placeholder}
|
||
</Text>
|
||
|
||
<FontAwesome6
|
||
name={iconName}
|
||
size={iconSize}
|
||
color={iconColor || (value ? '#4B5563' : '#9CA3AF')}
|
||
style={styles.icon}
|
||
/>
|
||
</TouchableOpacity>
|
||
|
||
{error && <Text style={[styles.errorText, errorTextStyle]}>{error}</Text>}
|
||
|
||
{/*
|
||
DateTimePickerModal 是 React Native Modal。
|
||
它会覆盖在所有 View 之上。
|
||
*/}
|
||
<DateTimePickerModal
|
||
isVisible={isDatePickerVisible}
|
||
mode={mode}
|
||
date={dateObjectForPicker} // 传入 Date 对象
|
||
onConfirm={handleConfirm}
|
||
onCancel={hideDatePicker}
|
||
// iOS 只有用这个 display 样式才最稳,避免乱七八糟的 inline 样式
|
||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||
// 自动适配系统深色模式,或者根据 isDark 变量控制
|
||
isDarkModeEnabled={isDark}
|
||
// 强制使用中文环境
|
||
locale="zh-CN"
|
||
confirmTextIOS="确定"
|
||
cancelTextIOS="取消"
|
||
/>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
// 设计样式
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
marginBottom: 20,
|
||
},
|
||
label: {
|
||
fontSize: 14,
|
||
fontWeight: '600',
|
||
color: '#374151', // Gray 700
|
||
marginBottom: 8,
|
||
marginLeft: 2,
|
||
},
|
||
inputBox: {
|
||
height: 52, // 增加高度提升触控体验
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 12, // 更圆润的角
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 16,
|
||
borderWidth: 1,
|
||
borderColor: '#E5E7EB', // Gray 200
|
||
// 增加轻微阴影提升层次感 (iOS)
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 1 },
|
||
shadowOpacity: 0.05,
|
||
shadowRadius: 2,
|
||
// Android
|
||
elevation: 1,
|
||
},
|
||
inputBoxError: {
|
||
borderColor: '#EF4444', // Red 500
|
||
backgroundColor: '#FEF2F2', // Red 50
|
||
},
|
||
text: {
|
||
fontSize: 16,
|
||
color: '#111827', // Gray 900
|
||
flex: 1,
|
||
},
|
||
placeholder: {
|
||
color: '#9CA3AF', // Gray 400 - 标准占位符颜色
|
||
},
|
||
icon: {
|
||
marginLeft: 12,
|
||
},
|
||
errorText: {
|
||
marginTop: 4,
|
||
marginLeft: 2,
|
||
fontSize: 12,
|
||
color: '#EF4444',
|
||
}
|
||
}); |