feat: 实现减脂体重管理App完整功能
- 实现拍照识别食物功能(集成大语言模型视觉能力) - 实现智能对话功能(集成大语言模型流式输出) - 实现食物记录和卡路里管理功能 - 实现体重记录和统计功能 - 实现健康数据管理页面 - 配置数据库表结构(用户、食物记录、体重记录) - 实现Express后端API路由 - 配置Tab导航和前端页面 - 采用健康运动配色方案
This commit is contained in:
238
client/components/SmartDateInput.tsx
Normal file
238
client/components/SmartDateInput.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
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',
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user