feat: 实现减脂体重管理App完整功能
- 实现拍照识别食物功能(集成大语言模型视觉能力) - 实现智能对话功能(集成大语言模型流式输出) - 实现食物记录和卡路里管理功能 - 实现体重记录和统计功能 - 实现健康数据管理页面 - 配置数据库表结构(用户、食物记录、体重记录) - 实现Express后端API路由 - 配置Tab导航和前端页面 - 采用健康运动配色方案
This commit is contained in:
314
client/components/Screen.tsx
Normal file
314
client/components/Screen.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Platform,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
View,
|
||||
TouchableWithoutFeedback,
|
||||
Keyboard,
|
||||
ViewStyle,
|
||||
FlatList,
|
||||
SectionList,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets, Edge } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
// 引入 KeyboardAware 系列组件
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
KeyboardAwareFlatList,
|
||||
KeyboardAwareSectionList
|
||||
} from 'react-native-keyboard-aware-scroll-view';
|
||||
|
||||
/**
|
||||
* # Screen 组件使用指南
|
||||
*
|
||||
* 核心原则:统一使用手动安全区管理 (padding),支持沉浸式布局,解决 iOS/Android 状态栏一致性问题。
|
||||
*
|
||||
* ## 1. 普通页面 (默认)
|
||||
* - 场景:标准的白底或纯色背景页面,Header 在安全区下方。
|
||||
* - 用法:`<Screen>{children}</Screen>`
|
||||
* - 行为:自动处理上下左右安全区,状态栏文字黑色。
|
||||
*
|
||||
* ## 2. 沉浸式 Header (推荐)
|
||||
* - 场景:Header 背景色/图片需要延伸到状态栏 (如首页、个人中心)。
|
||||
* - 用法:`<Screen safeAreaEdges={['left', 'right', 'bottom']}>` (❌ 去掉 'top')
|
||||
* - 配合:页面内部 Header 组件必须手动添加 paddingTop:
|
||||
* ```tsx
|
||||
* const insets = useSafeAreaInsets();
|
||||
* <View style={{ paddingTop: insets.top + 12, backgroundColor: '...' }}>
|
||||
* ```
|
||||
*
|
||||
* ## 3. 底部有 TabBar 或 悬浮按钮
|
||||
* - 场景:页面底部有固定导航栏,或者需要精细控制底部留白。
|
||||
* - 用法:`<Screen safeAreaEdges={['top', 'left', 'right']}>` (❌ 去掉 'bottom')
|
||||
* - 配合:
|
||||
* - 若是滚动页:`<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}>`
|
||||
* - 若是固定页:`<View style={{ paddingBottom: insets.bottom + 60 }}>`
|
||||
*
|
||||
* ## 4. 滚动列表/表单
|
||||
* - 场景:长内容,需要键盘避让。
|
||||
* - 用法:`<Screen>{children}</Screen>`
|
||||
* - 行为:若子树不包含 ScrollView/FlatList/SectionList,则外层自动使用 ScrollView,
|
||||
* 自动处理键盘遮挡,底部安全区会自动加在内容末尾。
|
||||
*/
|
||||
interface ScreenProps {
|
||||
children: React.ReactNode;
|
||||
/** 背景色,默认 #fff */
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
* 状态栏样式
|
||||
* - 'dark': 黑色文字 (默认)
|
||||
* - 'light': 白色文字 (深色背景时用)
|
||||
*/
|
||||
statusBarStyle?: 'auto' | 'inverted' | 'light' | 'dark';
|
||||
/**
|
||||
* 状态栏背景色
|
||||
* - 默认 'transparent' 以支持沉浸式
|
||||
* - Android 下如果需要不透明,可传入具体颜色
|
||||
*/
|
||||
statusBarColor?: string;
|
||||
/**
|
||||
* 安全区控制 (关键属性)
|
||||
* - 默认: ['top', 'left', 'right', 'bottom'] (全避让)
|
||||
* - 沉浸式 Header: 去掉 'top'
|
||||
* - 自定义底部: 去掉 'bottom'
|
||||
*/
|
||||
safeAreaEdges?: Edge[];
|
||||
/** 自定义容器样式 */
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
type KeyboardAwareProps = {
|
||||
element: React.ReactElement<any, any>;
|
||||
extraPadding: number;
|
||||
contentInsetBehaviorIOS: 'automatic' | 'never';
|
||||
};
|
||||
|
||||
const KeyboardAwareScrollable = ({
|
||||
element,
|
||||
extraPadding,
|
||||
contentInsetBehaviorIOS,
|
||||
}: KeyboardAwareProps) => {
|
||||
// 获取原始组件的 props
|
||||
const childAttrs: any = (element as any).props || {};
|
||||
const originStyle = childAttrs['contentContainerStyle'];
|
||||
const styleArray = Array.isArray(originStyle) ? originStyle : originStyle ? [originStyle] : [];
|
||||
const merged = Object.assign({}, ...styleArray);
|
||||
const currentPB = typeof merged.paddingBottom === 'number' ? merged.paddingBottom : 0;
|
||||
|
||||
// 合并 paddingBottom (安全区 + 额外留白)
|
||||
const enhancedContentStyle = [{ ...merged, paddingBottom: currentPB + extraPadding }];
|
||||
|
||||
// 基础配置 props,用于传递给 KeyboardAware 组件
|
||||
const commonProps = {
|
||||
...childAttrs,
|
||||
contentContainerStyle: enhancedContentStyle,
|
||||
keyboardShouldPersistTaps: childAttrs['keyboardShouldPersistTaps'] ?? 'handled',
|
||||
keyboardDismissMode: childAttrs['keyboardDismissMode'] ?? 'on-drag',
|
||||
enableOnAndroid: true,
|
||||
// 类似于原代码中的 setTimeout/scrollToEnd 逻辑,这里设置额外的滚动高度确保输入框可见
|
||||
extraHeight: 100,
|
||||
// 禁用自带的 ScrollView 自动 inset,由外部 padding 控制
|
||||
enableAutomaticScroll: true,
|
||||
...(Platform.OS === 'ios'
|
||||
? { contentInsetAdjustmentBehavior: childAttrs['contentInsetAdjustmentBehavior'] ?? contentInsetBehaviorIOS }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const t = (element as any).type;
|
||||
|
||||
// 根据组件类型返回对应的 KeyboardAware 版本
|
||||
// 注意:不再使用 KeyboardAvoidingView,直接替换为增强版 ScrollView
|
||||
if (t === ScrollView) {
|
||||
return <KeyboardAwareScrollView {...commonProps} />;
|
||||
}
|
||||
|
||||
if (t === FlatList) {
|
||||
return <KeyboardAwareFlatList {...commonProps} />;
|
||||
}
|
||||
|
||||
if (t === SectionList) {
|
||||
return <KeyboardAwareSectionList {...commonProps} />;
|
||||
}
|
||||
|
||||
// 理论上不应运行到这里,如果是非标准组件则原样返回,仅修改样式
|
||||
return React.cloneElement(element, {
|
||||
contentContainerStyle: enhancedContentStyle,
|
||||
keyboardShouldPersistTaps: childAttrs['keyboardShouldPersistTaps'] ?? 'handled',
|
||||
keyboardDismissMode: childAttrs['keyboardDismissMode'] ?? 'on-drag',
|
||||
});
|
||||
};
|
||||
|
||||
export const Screen = ({
|
||||
children,
|
||||
backgroundColor = '#fff',
|
||||
statusBarStyle = 'dark',
|
||||
statusBarColor = 'transparent',
|
||||
safeAreaEdges = ['top', 'left', 'right', 'bottom'],
|
||||
style,
|
||||
}: ScreenProps) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [keyboardShown, setKeyboardShown] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
const s1 = Keyboard.addListener(showEvent, () => setKeyboardShown(true));
|
||||
const s2 = Keyboard.addListener(hideEvent, () => setKeyboardShown(false));
|
||||
return () => { s1.remove(); s2.remove(); };
|
||||
}, []);
|
||||
|
||||
// 自动检测:若子树中包含 ScrollView/FlatList/SectionList,则认为页面自身处理滚动
|
||||
const isNodeScrollable = (node: React.ReactNode): boolean => {
|
||||
const isScrollableElement = (el: unknown): boolean => {
|
||||
if (!React.isValidElement(el)) return false;
|
||||
const element = el as React.ReactElement<any, any>;
|
||||
const t = element.type;
|
||||
// 不递归检查 Modal 内容,避免将弹窗内的 ScrollView 误判为页面已具备垂直滚动
|
||||
if (t === Modal) return false;
|
||||
const props = element.props as Record<string, unknown> | undefined;
|
||||
// 仅识别“垂直”滚动容器;横向滚动不视为页面已处理垂直滚动
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const isHorizontal = !!(props && (props as any).horizontal === true);
|
||||
if ((t === ScrollView || t === FlatList || t === SectionList) && !isHorizontal) return true;
|
||||
const c: React.ReactNode | undefined = props && 'children' in props
|
||||
? (props.children as React.ReactNode)
|
||||
: undefined;
|
||||
if (Array.isArray(c)) return c.some(isScrollableElement);
|
||||
return c ? isScrollableElement(c) : false;
|
||||
};
|
||||
if (Array.isArray(node)) return node.some(isScrollableElement);
|
||||
return isScrollableElement(node);
|
||||
};
|
||||
|
||||
const childIsNativeScrollable = isNodeScrollable(children);
|
||||
|
||||
// 说明:避免双重补白
|
||||
// KeyboardAwareScrollView 内部会自动处理键盘高度。
|
||||
// 我们主要关注非键盘状态下的 Safe Area 管理。
|
||||
|
||||
// 解析安全区设置
|
||||
const hasTop = safeAreaEdges.includes('top');
|
||||
const hasBottom = safeAreaEdges.includes('bottom');
|
||||
const hasLeft = safeAreaEdges.includes('left');
|
||||
const hasRight = safeAreaEdges.includes('right');
|
||||
|
||||
// 强制禁用 iOS 自动调整内容区域,完全由手动 padding 控制,消除系统自动计算带来的多余空白
|
||||
const contentInsetBehaviorIOS = 'never';
|
||||
|
||||
const wrapperStyle: ViewStyle = {
|
||||
flex: 1,
|
||||
backgroundColor,
|
||||
paddingTop: hasTop ? insets.top : 0,
|
||||
paddingLeft: hasLeft ? insets.left : 0,
|
||||
paddingRight: hasRight ? insets.right : 0,
|
||||
// 当页面不使用外层 ScrollView 时(子树本身可滚动),由外层 View 负责底部安全区
|
||||
paddingBottom: (childIsNativeScrollable && hasBottom)
|
||||
? (keyboardShown ? 0 : insets.bottom)
|
||||
: 0,
|
||||
};
|
||||
|
||||
// 若子树不可滚动,则外层使用 KeyboardAwareScrollView 提供“全局页面滑动”能力
|
||||
const useScrollContainer = !childIsNativeScrollable;
|
||||
|
||||
// 2. 滚动容器配置
|
||||
// 如果使用滚动容器,则使用 KeyboardAwareScrollView 替代原有的 ScrollView
|
||||
const Container = useScrollContainer ? KeyboardAwareScrollView : View;
|
||||
|
||||
const containerProps = useScrollContainer ? {
|
||||
contentContainerStyle: {
|
||||
flexGrow: 1,
|
||||
// 滚动模式下,Bottom 安全区由内容容器处理,保证内容能完整显示且不被 Home Indicator 遮挡,同时背景色能延伸到底部
|
||||
paddingBottom: hasBottom ? (keyboardShown ? 0 : insets.bottom) : 0,
|
||||
},
|
||||
keyboardShouldPersistTaps: 'handled' as const,
|
||||
showsVerticalScrollIndicator: false,
|
||||
keyboardDismissMode: 'on-drag' as const,
|
||||
enableOnAndroid: true,
|
||||
extraHeight: 100, // 替代原代码手动计算的 offset
|
||||
// iOS 顶部白条修复:强制不自动添加顶部安全区
|
||||
...(Platform.OS === 'ios'
|
||||
? { contentInsetAdjustmentBehavior: contentInsetBehaviorIOS }
|
||||
: {}),
|
||||
} : {};
|
||||
|
||||
// 3. 若子元素自身包含滚动容器,给该滚动容器单独添加键盘避让,不影响其余固定元素(如底部栏)
|
||||
const wrapScrollableWithKeyboardAvoid = (nodes: React.ReactNode): React.ReactNode => {
|
||||
const isVerticalScrollable = (el: React.ReactElement<any, any>): boolean => {
|
||||
const t = el.type;
|
||||
const elementProps = (el as any).props || {};
|
||||
const isHorizontal = !!(elementProps as any).horizontal;
|
||||
return (t === ScrollView || t === FlatList || t === SectionList) && !isHorizontal;
|
||||
};
|
||||
|
||||
const wrapIfNeeded = (el: React.ReactElement<any, any>, idx?: number): React.ReactElement => {
|
||||
if (isVerticalScrollable(el)) {
|
||||
return (
|
||||
<KeyboardAwareScrollable
|
||||
key={el.key ?? idx}
|
||||
element={el}
|
||||
extraPadding={keyboardShown ? 0 : (hasBottom ? insets.bottom : 0)}
|
||||
contentInsetBehaviorIOS={contentInsetBehaviorIOS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
if (Array.isArray(nodes)) {
|
||||
return nodes.map((n, idx) => {
|
||||
if (React.isValidElement(n)) {
|
||||
return wrapIfNeeded(n as React.ReactElement<any, any>, idx);
|
||||
}
|
||||
return n;
|
||||
});
|
||||
}
|
||||
if (React.isValidElement(nodes)) {
|
||||
return wrapIfNeeded(nodes as React.ReactElement<any, any>, 0);
|
||||
}
|
||||
return nodes;
|
||||
};
|
||||
|
||||
return (
|
||||
// 核心原则:严禁使用 SafeAreaView,统一使用 View + padding 手动管理
|
||||
<View style={wrapperStyle}>
|
||||
{/* 状态栏配置:强制透明背景 + 沉浸式,以支持背景图延伸 */}
|
||||
<StatusBar
|
||||
style={statusBarStyle}
|
||||
backgroundColor={statusBarColor}
|
||||
translucent
|
||||
/>
|
||||
|
||||
{/* 键盘避让:仅当外层使用 ScrollView 时启用,避免固定底部栏随键盘上移 */}
|
||||
{useScrollContainer ? (
|
||||
// 替换为 KeyboardAwareScrollView,移除原先的 KeyboardAvoidingView 包裹
|
||||
// 因为 KeyboardAwareScrollView 已经内置了处理逻辑
|
||||
<Container style={[styles.innerContainer, style]} {...containerProps}>
|
||||
{children}
|
||||
</Container>
|
||||
) : (
|
||||
// 页面自身已处理滚动,不启用全局键盘避让,保证固定底部栏不随键盘上移
|
||||
childIsNativeScrollable ? (
|
||||
<View style={[styles.innerContainer, style]}>
|
||||
{wrapScrollableWithKeyboardAvoid(children)}
|
||||
</View>
|
||||
) : (
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss} disabled={Platform.OS === 'web'}>
|
||||
<View style={[styles.innerContainer, style]}>
|
||||
{children}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
innerContainer: {
|
||||
flex: 1,
|
||||
// 确保内部容器透明,避免背景色遮挡
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
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',
|
||||
}
|
||||
});
|
||||
33
client/components/ThemedText.tsx
Normal file
33
client/components/ThemedText.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Text, TextProps, TextStyle } from 'react-native';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Typography } from '@/constants/theme';
|
||||
|
||||
type TypographyVariant = keyof typeof Typography;
|
||||
|
||||
interface ThemedTextProps extends TextProps {
|
||||
variant?: TypographyVariant;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function ThemedText({
|
||||
variant = 'body',
|
||||
color,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: ThemedTextProps) {
|
||||
const { theme } = useTheme();
|
||||
const typographyStyle = Typography[variant];
|
||||
|
||||
const textStyle: TextStyle = {
|
||||
...typographyStyle,
|
||||
color: color ?? theme.textPrimary,
|
||||
};
|
||||
|
||||
return (
|
||||
<Text style={[textStyle, style]} {...props}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
37
client/components/ThemedView.tsx
Normal file
37
client/components/ThemedView.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { View, ViewProps, ViewStyle } from 'react-native';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
type BackgroundLevel = 'root' | 'default' | 'tertiary';
|
||||
|
||||
interface ThemedViewProps extends ViewProps {
|
||||
level?: BackgroundLevel;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
const backgroundMap: Record<BackgroundLevel, string> = {
|
||||
root: 'backgroundRoot',
|
||||
default: 'backgroundDefault',
|
||||
tertiary: 'backgroundTertiary',
|
||||
};
|
||||
|
||||
export function ThemedView({
|
||||
level = 'root',
|
||||
backgroundColor,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: ThemedViewProps) {
|
||||
const { theme } = useTheme();
|
||||
const bgColor = backgroundColor ?? (theme as any)[backgroundMap[level]];
|
||||
|
||||
const viewStyle: ViewStyle = {
|
||||
backgroundColor: bgColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[viewStyle, style]} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user