feat: 实现减脂体重管理App完整功能

- 实现拍照识别食物功能(集成大语言模型视觉能力)
- 实现智能对话功能(集成大语言模型流式输出)
- 实现食物记录和卡路里管理功能
- 实现体重记录和统计功能
- 实现健康数据管理页面
- 配置数据库表结构(用户、食物记录、体重记录)
- 实现Express后端API路由
- 配置Tab导航和前端页面
- 采用健康运动配色方案
This commit is contained in:
jaystar
2026-02-02 15:17:50 +08:00
commit 28c4d7b3b4
82 changed files with 21891 additions and 0 deletions

6
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

76
client/app.config.ts Normal file
View File

@@ -0,0 +1,76 @@
import { ExpoConfig, ConfigContext } from 'expo/config';
const appName = process.env.COZE_PROJECT_NAME || process.env.EXPO_PUBLIC_COZE_PROJECT_NAME || '应用';
const projectId = process.env.COZE_PROJECT_ID || process.env.EXPO_PUBLIC_COZE_PROJECT_ID;
const slugAppName = projectId ? `app${projectId}` : 'myapp';
export default ({ config }: ConfigContext): ExpoConfig => {
return {
...config,
"name": appName,
"slug": slugAppName,
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": `com.anonymous.x${projectId || '0'}`
},
"web": {
"bundler": "metro",
"output": "single",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
process.env.EXPO_PUBLIC_BACKEND_BASE_URL ? [
"expo-router",
{
"origin": process.env.EXPO_PUBLIC_BACKEND_BASE_URL
}
] : 'expo-router',
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
[
"expo-image-picker",
{
"photosPermission": `允许FoodWeight智能体App访问您的相册以便您上传或保存图片。`,
"cameraPermission": `允许FoodWeight智能体App使用您的相机以便您直接拍摄照片上传。`,
"microphonePermission": `允许FoodWeight智能体App访问您的麦克风以便您拍摄带有声音的视频。`
}
],
[
"expo-location",
{
"locationWhenInUsePermission": `FoodWeight智能体App需要访问您的位置以提供周边服务及导航功能。`
}
],
[
"expo-camera",
{
"cameraPermission": `FoodWeight智能体App需要访问相机以拍摄照片和视频。`,
"microphonePermission": `FoodWeight智能体App需要访问麦克风以录制视频声音。`,
"recordAudioAndroid": true
}
]
],
"experiments": {
"typedRoutes": true
}
}
}

View File

@@ -0,0 +1,58 @@
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { FontAwesome6 } from '@expo/vector-icons';
import { useTheme } from '@/hooks/useTheme';
export default function TabLayout() {
const { theme, isDark } = useTheme();
const insets = useSafeAreaInsets();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: theme.backgroundDefault,
borderTopColor: theme.border,
height: Platform.OS === 'web' ? 60 : 50 + insets.bottom,
paddingBottom: Platform.OS === 'web' ? 0 : insets.bottom,
},
tabBarActiveTintColor: theme.primary,
tabBarInactiveTintColor: theme.textMuted,
tabBarItemStyle: {
height: Platform.OS === 'web' ? 60 : undefined,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: '首页',
tabBarIcon: ({ color }) => <FontAwesome6 name="house" size={20} color={color} />,
}}
/>
<Tabs.Screen
name="record"
options={{
title: '记录',
tabBarIcon: ({ color }) => <FontAwesome6 name="camera" size={20} color={color} />,
}}
/>
<Tabs.Screen
name="stats"
options={{
title: '统计',
tabBarIcon: ({ color }) => <FontAwesome6 name="chart-line" size={20} color={color} />,
}}
/>
<Tabs.Screen
name="chat"
options={{
title: 'AI助手',
tabBarIcon: ({ color }) => <FontAwesome6 name="robot" size={20} color={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1 @@
export { default } from "@/screens/chat";

View File

@@ -0,0 +1 @@
export { default } from "@/screens/home";

View File

@@ -0,0 +1 @@
export { default } from "@/screens/record";

View File

@@ -0,0 +1 @@
export { default } from "@/screens/stats";

30
client/app/+not-found.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { View, Text, StyleSheet } from 'react-native';
import { Link } from 'expo-router';
import { useTheme } from '@/hooks/useTheme';
import { Spacing } from '@/constants/theme';
export default function NotFoundScreen() {
const { theme } = useTheme();
return (
<View style={[styles.container, { backgroundColor: theme.backgroundRoot }]}>
<Text>
</Text>
<Link href="/" style={[styles.gohome]}>
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
gohome: {
marginTop: Spacing['2xl'],
},
});

9
client/app/_layout.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
</Stack>
);
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View 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',
},
});

View 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',
}
});

View 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>
);
}

View 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>
);
}

177
client/constants/theme.ts Normal file
View File

@@ -0,0 +1,177 @@
export const Colors = {
light: {
textPrimary: "#1F2937",
textSecondary: "#6B7280",
textMuted: "#9CA3AF",
primary: "#059669", // Emerald-600 - 健康运动主色
accent: "#10B981", // Emerald-500 - 辅助色
success: "#10B981",
error: "#EF4444",
backgroundRoot: "#F8FAFC", // 冷瓷白
backgroundDefault: "#FFFFFF",
backgroundTertiary: "#F1F5F9",
buttonPrimaryText: "#FFFFFF",
tabIconSelected: "#059669",
border: "#E2E8F0",
borderLight: "#F1F5F9",
},
dark: {
textPrimary: "#F9FAFB",
textSecondary: "#9CA3AF",
textMuted: "#6B7280",
primary: "#34D399", // Emerald-400 - 暗色模式主色
accent: "#10B981",
success: "#34D399",
error: "#F87171",
backgroundRoot: "#111827", // 更深的背景色
backgroundDefault: "#1F2937",
backgroundTertiary: "#374151",
buttonPrimaryText: "#111827",
tabIconSelected: "#34D399",
border: "#374151",
borderLight: "#4B5563",
},
};
export const Spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
"2xl": 24,
"3xl": 32,
"4xl": 40,
"5xl": 48,
"6xl": 64,
};
export const BorderRadius = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
"2xl": 24,
"3xl": 28,
"4xl": 32,
full: 9999,
};
export const Typography = {
display: {
fontSize: 112,
lineHeight: 112,
fontWeight: "200" as const,
letterSpacing: -4,
},
displayLarge: {
fontSize: 112,
lineHeight: 112,
fontWeight: "200" as const,
letterSpacing: -2,
},
displayMedium: {
fontSize: 48,
lineHeight: 56,
fontWeight: "200" as const,
},
h1: {
fontSize: 32,
lineHeight: 40,
fontWeight: "700" as const,
},
h2: {
fontSize: 28,
lineHeight: 36,
fontWeight: "700" as const,
},
h3: {
fontSize: 24,
lineHeight: 32,
fontWeight: "300" as const,
},
h4: {
fontSize: 20,
lineHeight: 28,
fontWeight: "600" as const,
},
title: {
fontSize: 18,
lineHeight: 24,
fontWeight: "700" as const,
},
body: {
fontSize: 16,
lineHeight: 24,
fontWeight: "400" as const,
},
bodyMedium: {
fontSize: 16,
lineHeight: 24,
fontWeight: "500" as const,
},
small: {
fontSize: 14,
lineHeight: 20,
fontWeight: "400" as const,
},
smallMedium: {
fontSize: 14,
lineHeight: 20,
fontWeight: "500" as const,
},
caption: {
fontSize: 12,
lineHeight: 16,
fontWeight: "400" as const,
},
captionMedium: {
fontSize: 12,
lineHeight: 16,
fontWeight: "500" as const,
},
label: {
fontSize: 14,
lineHeight: 20,
fontWeight: "500" as const,
letterSpacing: 2,
textTransform: "uppercase" as const,
},
labelSmall: {
fontSize: 12,
lineHeight: 16,
fontWeight: "500" as const,
letterSpacing: 1,
textTransform: "uppercase" as const,
},
labelTitle: {
fontSize: 14,
lineHeight: 20,
fontWeight: "700" as const,
letterSpacing: 2,
textTransform: "uppercase" as const,
},
link: {
fontSize: 16,
lineHeight: 24,
fontWeight: "400" as const,
},
stat: {
fontSize: 30,
lineHeight: 36,
fontWeight: "300" as const,
},
tiny: {
fontSize: 10,
lineHeight: 14,
fontWeight: "400" as const,
},
navLabel: {
fontSize: 10,
lineHeight: 14,
fontWeight: "500" as const,
},
};
export type Theme = typeof Colors.light;

View File

@@ -0,0 +1,49 @@
// @ts-nocheck
/**
* 通用认证上下文
*
* 基于固定的 API 接口实现,可复用到其他项目
* 其他项目使用时,只需修改 @api 的导入路径指向项目的 api 模块
*
* 注意:
* - 如果需要登录/鉴权场景,请扩展本文件,完善 login/logout、token 管理、用户信息获取与刷新等逻辑
* - 将示例中的占位实现替换为项目实际的接口调用与状态管理
*/
import React, { createContext, useContext, ReactNode } from "react";
interface UserOut {
}
interface AuthContextType {
user: UserOut | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (token: string) => Promise<void>;
logout: () => Promise<void>;
updateUser: (userData: Partial<UserOut>) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const value: AuthContextType = {
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
login: async (token: string) => {},
logout: async () => {},
updateUser: () => {},
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

5
client/declarations.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
// declarations.d.ts
declare module 'expo-file-system/legacy' {
export * from 'expo-file-system';
}

View File

@@ -0,0 +1,49 @@
export default function (results) {
return results
.flatMap(file =>
file.messages.map(m => {
// split into lines
const lines = m.message.split('\n');
// 第一行(句子):直接用
const first = lines[0];
// 附加解释:过滤掉所有 codeframe/箭头/行号/重复路径
const details = lines
.slice(1)
.filter(l => {
// 移除空行
if (!l.trim()) return false;
// 移除 "58 | xxx" 这样的行
if (/^\s*\d+\s*\|/.test(l)) return false;
// 移除 "> 60 | ..." 这样的箭头行
if (/^\s*>/.test(l)) return false;
// 移除只有箭头提示的行,如 "| ^^^^^"
if (/^\s*\|/.test(l)) return false;
// 移除 "…" 省略号行
if (/^\s*…/.test(l)) return false;
// 移除重复路径行eslint message 有时夹带 file:line
if (/\.tsx:\d+:\d+/.test(l)) return false;
return true;
})
.join('\n')
.trim();
let output = `${file.filePath}:${m.line}:${m.column} ${
m.severity === 2 ? 'error' : 'warn'
} ${first}`;
if (details) output += `\n${details}\n`;
return output;
})
)
.join('\n');
};

121
client/eslint.config.mjs Normal file
View File

@@ -0,0 +1,121 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import pluginReact from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import regexp from 'eslint-plugin-regexp';
import pluginImport from 'eslint-plugin-import';
import fontawesome6 from '../eslint-plugins/fontawesome6/index.js';
import reanimated from '../eslint-plugins/reanimated/index.js';
import reactnative from '../eslint-plugins/react-native/index.js';
export default [
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'api/**', // 排除自动生成的 API 代码
'src/api/**', // 排除 src 下的自动生成 API
'.expo/**', // 排除 Expo 自动生成的文件
'tailwind.config.js', // 排除 Tailwind 配置文件
'**/*.d.ts',
'eslint.config.*',
],
},
regexp.configs["flat/recommended"],
js.configs.recommended,
...tseslint.configs.recommended,
// React 的推荐配置
pluginReact.configs.flat.recommended,
pluginReact.configs.flat['jsx-runtime'],
reactHooks.configs.flat.recommended,
{
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
// 语言选项:设置全局变量
languageOptions: {
globals: {
...globals.browser,
...globals.es2021,
'__DEV__': 'readonly',
},
},
// React 版本自动检测
settings: {
react: {
version: 'detect',
},
'import/resolver': {
typescript: {
project: ['./tsconfig.json'],
alwaysTryTypes: true,
},
},
},
plugins: {
import: pluginImport,
fontawesome6,
reanimated,
reactnative,
},
rules: {
// 关闭代码风格规则
'semi': 'off',
'quotes': 'off',
'indent': 'off',
"no-empty": ["error", { "allowEmptyCatch": true }],
"no-unused-expressions": "warn",
"no-useless-escape": "warn",
'import/no-unresolved': 'error',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'no-prototype-builtins': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-uses-react': 'off',
'fontawesome6/valid-name': 'error',
'reanimated/ban-mix-use': 'error',
// 禁止使用 via.placeholder.com 服务
'no-restricted-syntax': [
'error',
{
'selector': 'Literal[value=/via\\.placeholder\\.com/]',
'message': 'via.placeholder.com 服务不可用,禁止在代码中使用',
},
{
'selector': 'TemplateLiteral > TemplateElement[value.raw=/via\\.placeholder\\.com/]',
'message': 'via.placeholder.com 服务不可用,禁止在代码中使用',
},
],
'reactnative/wrap-horizontal-scrollview-inside-view': ['error'],
},
},
{
files: [
"metro.config.js",
"scripts/**/*.js",
"expo/scripts/**/*.js",
"eslint.config.js",
"babel.config.js",
"server/**/*.js"
],
languageOptions: {
globals: {
...globals.node,
},
},
rules: {
// 在 .js 文件中关闭 TS 规则
'@typescript-eslint/no-require-imports': 'off',
// 在 Node.js 文件中允许 require
'@typescript-eslint/no-var-requires': 'off',
'no-undef': 'off',
},
},
];

View File

@@ -0,0 +1,48 @@
import { createContext, Dispatch, ReactNode, SetStateAction, useContext, useEffect, useState } from 'react';
import { ColorSchemeName, useColorScheme as useReactNativeColorScheme, Platform } from 'react-native';
const ColorSchemeContext = createContext<'light' | 'dark' | null | undefined>(null);
const ColorSchemeProvider = function ({ children }: { children?: ReactNode }) {
const systemColorScheme = useReactNativeColorScheme();
const [colorScheme, setColorScheme] = useState(systemColorScheme);
useEffect(() => {
setColorScheme(systemColorScheme);
}, [systemColorScheme]);
useEffect(() => {
function handleMessage(e: MessageEvent<{ event: string; colorScheme: ColorSchemeName; } | undefined>) {
if (e.data?.event === 'coze.workbench.colorScheme') {
const cs = e.data.colorScheme;
if (typeof cs === 'string' && typeof setColorScheme === 'function') {
setColorScheme(cs);
}
}
}
if (Platform.OS === 'web') {
window.addEventListener('message', handleMessage, false);
}
return () => {
if (Platform.OS === 'web') {
window.removeEventListener('message', handleMessage, false);
}
}
}, [setColorScheme]);
return <ColorSchemeContext.Provider value={colorScheme}>
{children}
</ColorSchemeContext.Provider>
};
function useColorScheme() {
const colorScheme = useContext(ColorSchemeContext);
return colorScheme;
}
export {
ColorSchemeProvider,
useColorScheme,
}

View File

@@ -0,0 +1,152 @@
/**
* 安全路由 Hook - 完全代替原生的 useRouter 和 useLocalSearchParams
*
* 提供的 Hook
* - useSafeRouter: 代替 useRouter包含所有路由方法并对 push/replace/navigate/setParams 进行安全编码
* - useSafeSearchParams: 代替 useLocalSearchParams获取路由参数
*
* 解决的问题:
* 1. URI 编解码不对称 - useLocalSearchParams 会自动解码,但 router.push 不会自动编码,
* 当参数包含 % 等特殊字符时会拿到错误的值
* 2. 类型丢失 - URL 参数全是 stringNumber/Boolean 类型会丢失
* 3. 嵌套对象无法传递 - URL search params 不支持嵌套结构
*
* 解决方案:
* 采用 Payload 模式,将所有参数打包成 JSON 并 Base64 编码后传递,
* 接收时再解码还原,确保数据完整性和类型安全。
*
* 优点:
* 1. 自动处理所有特殊字符(如 %、&、=、中文、Emoji 等)
* 2. 保留数据类型Number、Boolean 不会变成 String
* 3. 支持嵌套对象和数组传递
* 4. 三端兼容iOS、Android、Web
*
* 使用方式:
* ```tsx
* // 发送端 - 使用 useSafeRouter 代替 useRouter
* const router = useSafeRouter();
* router.push('/detail', { id: 123, uri: 'file:///path/%40test.mp3' });
* router.replace('/home', { tab: 'settings' });
* router.navigate('/profile', { userId: 456 });
* router.back();
* if (router.canGoBack()) { ... }
* router.setParams({ updated: true });
*
* // 接收端 - 使用 useSafeSearchParams 代替 useLocalSearchParams
* const { id, uri } = useSafeSearchParams<{ id: number; uri: string }>();
* ```
*/
import { useMemo } from 'react';
import { useRouter as useExpoRouter, useLocalSearchParams as useExpoParams } from 'expo-router';
import { Base64 } from 'js-base64';
const PAYLOAD_KEY = '__safeRouterPayload__';
const LOG_PREFIX = '[SafeRouter]';
const getCurrentParams = (rawParams: Record<string, string | string[]>): Record<string, unknown> => {
const payload = rawParams[PAYLOAD_KEY];
if (payload && typeof payload === 'string') {
const decoded = deserializeParams<Record<string, unknown>>(payload);
if (decoded && Object.keys(decoded).length > 0) {
return decoded;
}
}
const { [PAYLOAD_KEY]: _, ...rest } = rawParams;
return rest as Record<string, unknown>;
};
const serializeParams = (params: Record<string, unknown>): string => {
try {
const jsonStr = JSON.stringify(params);
return Base64.encode(jsonStr);
} catch (error) {
console.error(LOG_PREFIX, 'serialize failed:', error instanceof Error ? error.message : 'Unknown error');
return '';
}
};
const deserializeParams = <T = Record<string, unknown>>(
payload: string | string[] | undefined
): T | null => {
if (!payload || typeof payload !== 'string') {
return null;
}
try {
const jsonStr = Base64.decode(payload);
return JSON.parse(jsonStr) as T;
} catch (error) {
console.error(LOG_PREFIX, 'deserialize failed:', error instanceof Error ? error.message : 'Unknown error');
return null;
}
};
/**
* 安全路由 Hook用于页面跳转代替 useRouter
* @returns 路由方法(继承 useRouter 所有方法,并对以下方法进行安全编码)
* - push(pathname, params) - 入栈新页面
* - replace(pathname, params) - 替换当前页面
* - navigate(pathname, params) - 智能跳转(已存在则返回,否则 push
* - setParams(params) - 更新当前页面参数(合并现有参数)
*/
export function useSafeRouter() {
const router = useExpoRouter();
const rawParams = useExpoParams<Record<string, string | string[]>>();
const push = (pathname: string, params: Record<string, unknown> = {}) => {
const encodedPayload = serializeParams(params);
router.push({
pathname: pathname as `/${string}`,
params: { [PAYLOAD_KEY]: encodedPayload },
});
};
const replace = (pathname: string, params: Record<string, unknown> = {}) => {
const encodedPayload = serializeParams(params);
router.replace({
pathname: pathname as `/${string}`,
params: { [PAYLOAD_KEY]: encodedPayload },
});
};
const navigate = (pathname: string, params: Record<string, unknown> = {}) => {
const encodedPayload = serializeParams(params);
router.navigate({
pathname: pathname as `/${string}`,
params: { [PAYLOAD_KEY]: encodedPayload },
});
};
const setParams = (params: Record<string, unknown>) => {
const currentParams = getCurrentParams(rawParams);
const mergedParams = { ...currentParams, ...params };
const encodedPayload = serializeParams(mergedParams);
router.setParams({ [PAYLOAD_KEY]: encodedPayload });
};
return {
...router,
push,
replace,
navigate,
setParams,
};
}
/**
* 安全获取路由参数 Hook用于接收方代替 useLocalSearchParams
* 兼容两种跳转方式:
* 1. useSafeRouter 跳转 - 自动解码 Payload
* 2. 外部跳转(深链接、浏览器直接访问等)- 回退到原始参数
* @returns 解码后的参数对象,类型安全
*/
export function useSafeSearchParams<T = Record<string, unknown>>(): T {
const rawParams = useExpoParams<Record<string, string | string[]>>();
const decodedParams = useMemo(() => {
return getCurrentParams(rawParams) as T;
}, [rawParams]);
return decodedParams;
}

33
client/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/useColorScheme';
enum COLOR_SCHEME_CHOICE {
FOLLOW_SYSTEM = 'follow-system', // 跟随系统自动变化
DARK = 'dark', // 固定为 dark 主题,不随系统变化
LIGHT = 'light', // 固定为 light 主题,不随系统变化
};
const userPreferColorScheme: COLOR_SCHEME_CHOICE = COLOR_SCHEME_CHOICE.FOLLOW_SYSTEM;
function getTheme(colorScheme?: 'dark' | 'light' | null) {
const isDark = colorScheme === 'dark';
const theme = Colors[colorScheme ?? 'light'];
return {
theme,
isDark,
};
}
function useTheme() {
const systemColorScheme = useColorScheme()
const colorScheme = userPreferColorScheme === COLOR_SCHEME_CHOICE.FOLLOW_SYSTEM ?
systemColorScheme :
userPreferColorScheme;
return getTheme(colorScheme);
}
export {
useTheme,
}

121
client/metro.config.js Normal file
View File

@@ -0,0 +1,121 @@
const { getDefaultConfig } = require('expo/metro-config');
const { createProxyMiddleware } = require('http-proxy-middleware');
const connect = require('connect');
const config = getDefaultConfig(__dirname);
// 安全地获取 Expo 的默认排除列表
const existingBlockList = [].concat(config.resolver.blockList || []);
config.resolver.blockList = [
...existingBlockList,
/.*\/\.expo\/.*/, // Expo 的缓存和构建产物目录
// 1. 原生代码 (Java/C++/Objective-C)
/.*\/react-native\/ReactAndroid\/.*/,
/.*\/react-native\/ReactCommon\/.*/,
// 2. 纯开发和调试工具
// 这些工具只在开发电脑上运行,不会被打包到应用中
/.*\/@typescript-eslint\/eslint-plugin\/.*/,
// 3. 构建时数据
// 这个数据库只在打包过程中使用,应用运行时不需要
/.*\/caniuse-lite\/data\/.*/,
// 4. 通用规则
/.*\/__tests__\/.*/, // 排除所有测试目录
/.*\.git\/.*/, // 排除 Git 目录
];
const BACKEND_TARGET = 'http://localhost:9091';
const apiProxy = createProxyMiddleware({
target: BACKEND_TARGET,
changeOrigin: true,
logLevel: 'debug',
proxyTimeout: 86400000,
onProxyReq: (proxyReq, req) => {
const accept = req.headers.accept || '';
if (accept.includes('text/event-stream')) {
proxyReq.setHeader('accept-encoding', 'identity');
}
},
onProxyRes: (proxyRes, req, res) => {
const contentType = proxyRes.headers['content-type'] || '';
if (contentType.includes('text/event-stream') || contentType.includes('application/stream')) {
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof res.flushHeaders === 'function') {
try { res.flushHeaders(); } catch {}
}
}
},
});
const streamProxy = createProxyMiddleware({
target: BACKEND_TARGET,
changeOrigin: true,
logLevel: 'debug',
ws: true,
proxyTimeout: 86400000,
onProxyReq: (proxyReq, req) => {
const upgrade = req.headers.upgrade;
const accept = req.headers.accept || '';
if (upgrade && upgrade.toLowerCase() === 'websocket') {
proxyReq.setHeader('Connection', 'upgrade');
proxyReq.setHeader('Upgrade', req.headers.upgrade);
} else if (accept.includes('text/event-stream')) {
proxyReq.setHeader('accept-encoding', 'identity');
proxyReq.setHeader('Connection', 'keep-alive');
}
},
onProxyRes: (proxyRes, req, res) => {
const contentType = proxyRes.headers['content-type'] || '';
if (contentType.includes('text/event-stream') || contentType.includes('application/stream')) {
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof res.flushHeaders === 'function') {
try { res.flushHeaders(); } catch {}
}
}
},
});
const shouldProxyToBackend = (url) => {
if (!url) return false;
if (/^\/api\/v\d+\//.test(url)) {
return true;
}
return false;
};
const isWebSocketRequest = (req) =>
!!(req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket');
const isSSERequest = (req) => {
const accept = req.headers.accept || '';
return accept.includes('text/event-stream');
};
config.server = {
...config.server,
enhanceMiddleware: (metroMiddleware, metroServer) => {
return connect()
.use((req, res, next) => {
if (shouldProxyToBackend(req.url)) {
console.log(`[Metro Proxy] Forwarding ${req.method} ${req.url}`);
if (isWebSocketRequest(req) || isSSERequest(req)) {
return streamProxy(req, res, next);
}
return apiProxy(req, res, next);
}
next();
})
.use(metroMiddleware);
},
};
module.exports = config;

96
client/package.json Normal file
View File

@@ -0,0 +1,96 @@
{
"name": "expo-app",
"description": "my-expo-app",
"main": "expo-router/entry",
"private": true,
"scripts": {
"check-deps": "npx depcheck",
"postinstall": "npm run install-missing",
"install-missing": "node ./scripts/install-missing-deps.js",
"lint": "expo lint",
"start": "expo start --web --clear",
"test": "jest --watchAll"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"@expo/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/datetimepicker": "^8.5.0",
"@react-native-community/slider": "^5.0.1",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14",
"dayjs": "^1.11.19",
"expo": "54.0.32",
"expo-auth-session": "^7.0.9",
"expo-av": "~16.0.6",
"expo-blur": "~15.0.6",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.8",
"expo-crypto": "^15.0.7",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.7",
"expo-haptics": "~15.0.6",
"expo-image": "^3.0.11",
"expo-image-picker": "~17.0.10",
"expo-linear-gradient": "~15.0.6",
"expo-linking": "~8.0.7",
"expo-location": "~19.0.7",
"expo-router": "~6.0.0",
"expo-splash-screen": "~31.0.8",
"expo-status-bar": "~3.0.7",
"expo-symbols": "~1.0.6",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"js-base64": "^3.7.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-aware-scroll-view": "^0.9.5",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-reanimated": "~4.1.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-sse": "^1.2.1",
"react-native-svg": "15.15.0",
"react-native-toast-message": "^2.3.3",
"react-native-web": "^0.21.2",
"react-native-webview": "~13.15.0",
"react-native-worklets": "0.5.1",
"zod": "^4.2.1"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@eslint/js": "^9.27.0",
"@types/jest": "^29.5.12",
"@types/react": "~19.1.0",
"@types/react-test-renderer": "19.1.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-preset-expo": "^54.0.9",
"chalk": "^4.1.2",
"connect": "^3.7.0",
"depcheck": "^1.4.7",
"esbuild": "0.27.2",
"eslint": "^9.39.2",
"eslint-formatter-compact": "^9.0.1",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-regexp": "^2.10.0",
"globals": "^16.1.0",
"http-proxy-middleware": "^3.0.5",
"jest": "^29.2.1",
"jest-expo": "~54.0.10",
"react-test-renderer": "19.1.0",
"tsx": "^4.21.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1"
}
}

View File

@@ -0,0 +1,239 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { View, ScrollView, TextInput, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
import RNSSE from 'react-native-sse';
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 { createStyles } from './styles';
interface Message {
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
export default function ChatScreen() {
const { theme, isDark } = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const [messages, setMessages] = useState<Message[]>(() => [{
role: 'assistant',
content: '你好!我是你的健康饮食和减脂顾问助手。我可以帮助你:\n\n• 制定科学的饮食计划\n• 分析食物营养成分\n• 提供减脂建议\n• 解答健康问题\n\n请问有什么可以帮助你的吗',
timestamp: Date.now(),
}]);
const [inputText, setInputText] = useState('');
const [loading, setLoading] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const sseRef = useRef<RNSSE | null>(null);
useEffect(() => {
return () => {
if (sseRef.current) {
sseRef.current.close();
}
};
}, []);
const sendMessage = async () => {
if (!inputText.trim() || loading) return;
const userMessage: Message = {
role: 'user',
content: inputText.trim(),
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMessage]);
setInputText('');
setLoading(true);
// 构建对话历史
const conversationHistory = messages
.slice(1) // 跳过欢迎消息
.map((msg) => ({ role: msg.role, content: msg.content }));
try {
/**
* 服务端文件server/src/routes/ai-chat.ts
* 接口POST /api/v1/ai-chat/chat
* Body 参数message: string, conversationHistory?: Array<{role: string, content: string}>
*/
const url = `${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/ai-chat/chat`;
const sse = new RNSSE(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: inputText.trim(),
conversationHistory,
}),
});
sseRef.current = sse;
let assistantContent = '';
const assistantMessage: Message = {
role: 'assistant',
content: '',
timestamp: Date.now(),
};
// 添加空的助手消息
setMessages((prev) => [...prev, assistantMessage]);
sse.addEventListener('message', (event: any) => {
if (event.data === '[DONE]') {
sse.close();
setLoading(false);
return;
}
try {
const data = JSON.parse(event.data);
if (data.content) {
assistantContent += data.content;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
content: assistantContent,
};
return updated;
});
}
} catch (e) {
console.error('Parse error:', e);
}
});
sse.addEventListener('error', (error: any) => {
console.error('SSE error:', error);
sse.close();
setLoading(false);
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
content: assistantContent || '抱歉,我遇到了一些问题,请稍后再试。',
};
return updated;
});
});
} catch (error) {
console.error('Send message error:', error);
setLoading(false);
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: '抱歉,发送消息失败,请检查网络连接。',
timestamp: Date.now(),
},
]);
}
};
return (
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
<ThemedView level="root" style={styles.header}>
<ThemedText variant="h4" color={theme.textPrimary}>
AI
</ThemedText>
<View style={styles.headerStatus}>
<View style={[styles.statusDot, { backgroundColor: loading ? '#10B981' : '#9CA3AF' }]} />
<ThemedText variant="caption" color={theme.textMuted}>
{loading ? '正在回复...' : '在线'}
</ThemedText>
</View>
</ThemedView>
<ScrollView
ref={scrollViewRef}
style={styles.messagesContainer}
contentContainerStyle={styles.messagesContent}
onContentSizeChange={() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}}
>
{messages.map((message, index) => (
<View
key={index}
style={[
styles.messageWrapper,
message.role === 'user' ? styles.userMessage : styles.assistantMessage,
]}
>
<ThemedView
level="default"
style={[
styles.messageBubble,
message.role === 'user'
? { backgroundColor: theme.primary }
: { backgroundColor: theme.backgroundTertiary },
]}
>
<ThemedText
variant="body"
color={message.role === 'user' ? theme.buttonPrimaryText : theme.textPrimary}
style={styles.messageText}
>
{message.content}
</ThemedText>
</ThemedView>
<ThemedText variant="tiny" color={theme.textMuted} style={styles.messageTime}>
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</ThemedText>
</View>
))}
{loading && messages[messages.length - 1]?.role === 'assistant' && (
<View style={[styles.messageWrapper, styles.assistantMessage]}>
<ThemedView level="default" style={[styles.messageBubble, { backgroundColor: theme.backgroundTertiary }]}>
<View style={styles.typingIndicator}>
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
</View>
</ThemedView>
</View>
)}
</ScrollView>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<ThemedView level="root" style={styles.inputContainer}>
<TextInput
style={styles.input}
placeholder="输入你的问题..."
placeholderTextColor={theme.textMuted}
value={inputText}
onChangeText={setInputText}
multiline
maxLength={500}
editable={!loading}
/>
<TouchableOpacity
style={[styles.sendButton, { opacity: !inputText.trim() || loading ? 0.5 : 1 }]}
onPress={sendMessage}
disabled={!inputText.trim() || loading}
>
<FontAwesome6
name="paper-plane"
size={20}
color={theme.buttonPrimaryText}
/>
</TouchableOpacity>
</ThemedView>
</KeyboardAvoidingView>
</Screen>
);
}

View File

@@ -0,0 +1,102 @@
import { StyleSheet } from 'react-native';
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
export const createStyles = (theme: Theme) => {
return StyleSheet.create({
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
borderBottomWidth: 1,
borderBottomColor: theme.border,
},
headerStatus: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
messagesContainer: {
flex: 1,
},
messagesContent: {
padding: Spacing.lg,
gap: Spacing.md,
paddingBottom: Spacing["2xl"],
},
messageWrapper: {
gap: Spacing.xs,
},
userMessage: {
alignItems: 'flex-end',
},
assistantMessage: {
alignItems: 'flex-start',
},
messageBubble: {
maxWidth: '80%',
padding: Spacing.md,
borderRadius: BorderRadius.lg,
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
messageText: {
lineHeight: 22,
},
messageTime: {
paddingHorizontal: Spacing.sm,
},
typingIndicator: {
flexDirection: 'row',
gap: Spacing.xs,
paddingVertical: Spacing.xs,
},
typingDot: {
width: 8,
height: 8,
borderRadius: 4,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: Spacing.md,
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
borderTopWidth: 1,
borderTopColor: theme.border,
},
input: {
flex: 1,
backgroundColor: theme.backgroundTertiary,
borderRadius: BorderRadius.lg,
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
color: theme.textPrimary,
fontSize: 16,
maxHeight: 120,
},
sendButton: {
width: 44,
height: 44,
borderRadius: BorderRadius.lg,
backgroundColor: theme.primary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 2,
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 3,
},
});
};

View File

@@ -0,0 +1,25 @@
import { View, Text } from 'react-native';
import { Image } from 'expo-image';
import { useTheme } from '@/hooks/useTheme';
import { Screen } from '@/components/Screen';
import { styles } from './styles';
export default function DemoPage() {
const { theme, isDark } = useTheme();
return (
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
<View
style={styles.container}
>
<Image
style={styles.logo}
source="https://lf-coze-web-cdn.coze.cn/obj/eden-cn/lm-lgvj/ljhwZthlaukjlkulzlp/coze-coding/expo/coze-loading.gif"
></Image>
<Text style={{...styles.title, color: theme.textPrimary}}>APP </Text>
<Text style={{...styles.description, color: theme.textSecondary}}></Text>
</View>
</Screen>
);
}

View File

@@ -0,0 +1,28 @@
import { Spacing } from '@/constants/theme';
import { StyleSheet } from 'react-native';
export const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
logo: {
width: 130,
height: 109,
},
title: {
fontSize: 16,
fontWeight: 'bold',
},
description: {
fontSize: 14,
marginTop: Spacing.sm,
},
});

View File

@@ -0,0 +1,206 @@
import React, { useState, useEffect, useMemo } from 'react';
import { View, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
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 { useSafeRouter } from '@/hooks/useSafeRouter';
import { createStyles } from './styles';
// 模拟用户ID实际应用中应该从用户认证系统获取
const MOCK_USER_ID = 'mock-user-001';
export default function HomeScreen() {
const { theme, isDark } = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const router = useSafeRouter();
const [totalCalories, setTotalCalories] = useState(0);
const [targetCalories] = useState(2000);
const [currentWeight, setCurrentWeight] = useState<number | null>(null);
const [targetWeight, setTargetWeight] = useState(65);
const [loading, setLoading] = useState(true);
// 获取今日热量和体重数据
const fetchData = async () => {
setLoading(true);
try {
// 获取今日总热量
/**
* 服务端文件server/src/routes/food-records.ts
* 接口GET /api/v1/food-records/total-calories
* Query 参数userId: string, date?: string
*/
const caloriesRes = await fetch(
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/food-records/total-calories?userId=${MOCK_USER_ID}`
);
const caloriesData = await caloriesRes.json();
if (caloriesData.success) {
setTotalCalories(caloriesData.data.totalCalories);
}
// 获取体重统计
/**
* 服务端文件server/src/routes/weight-records.ts
* 接口GET /api/v1/weight-records/stats
* Query 参数userId: string
*/
const weightRes = await fetch(
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records/stats?userId=${MOCK_USER_ID}`
);
const weightData = await weightRes.json();
if (weightData.success) {
setCurrentWeight(weightData.data.currentWeight);
if (weightData.data.targetWeight) {
setTargetWeight(weightData.data.targetWeight);
}
}
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const caloriePercentage = Math.min((totalCalories / targetCalories) * 100, 100);
return (
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
<ScrollView
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={fetchData} tintColor={theme.primary} />
}
>
{/* Header */}
<ThemedView level="root" style={styles.header}>
<ThemedText variant="h2" color={theme.textPrimary}>
</ThemedText>
<ThemedText variant="small" color={theme.textMuted}>
💪
</ThemedText>
</ThemedView>
{/* 热量卡片 */}
<ThemedView level="default" style={styles.calorieCard}>
<View style={styles.cardHeader}>
<View style={styles.iconContainer}>
<FontAwesome6 name="fire-flame-curved" size={24} color={theme.primary} />
</View>
<ThemedText variant="h4" color={theme.textPrimary}>
</ThemedText>
</View>
<View style={styles.calorieContent}>
<ThemedText variant="displayLarge" color={theme.primary}>
{totalCalories}
</ThemedText>
<ThemedText variant="small" color={theme.textMuted}>
/ {targetCalories} kcal
</ThemedText>
</View>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${caloriePercentage}%` }]} />
</View>
<ThemedText variant="small" color={theme.textMuted} style={styles.remainingText}>
{Math.max(0, targetCalories - totalCalories)} kcal
</ThemedText>
</ThemedView>
{/* 体重卡片 */}
<ThemedView level="default" style={styles.weightCard}>
<View style={styles.cardHeader}>
<View style={styles.iconContainer}>
<FontAwesome6 name="weight-scale" size={24} color={theme.primary} />
</View>
<ThemedText variant="h4" color={theme.textPrimary}>
</ThemedText>
</View>
<View style={styles.weightContent}>
<ThemedText variant="displayLarge" color={theme.primary}>
{currentWeight || '--'}
</ThemedText>
<ThemedText variant="small" color={theme.textMuted}>
kg
</ThemedText>
</View>
{currentWeight && (
<ThemedText variant="small" color={theme.textSecondary}>
{targetWeight} kg
{currentWeight > targetWeight ? ` (还需减 ${(currentWeight - targetWeight).toFixed(1)} kg)` : ' ✨'}
</ThemedText>
)}
</ThemedView>
{/* 快捷操作 */}
<View style={styles.quickActions}>
<TouchableOpacity
style={[styles.actionButton, styles.cameraButton]}
onPress={() => router.push('/record')}
>
<View style={styles.actionIconContainer}>
<FontAwesome6 name="camera" size={28} color={theme.buttonPrimaryText} />
</View>
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.chartButton]}
onPress={() => router.push('/stats')}
>
<View style={styles.actionIconContainer}>
<FontAwesome6 name="chart-line" size={28} color={theme.buttonPrimaryText} />
</View>
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.aiButton]}
onPress={() => router.push('/chat')}
>
<View style={styles.actionIconContainer}>
<FontAwesome6 name="robot" size={28} color={theme.buttonPrimaryText} />
</View>
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
AI
</ThemedText>
</TouchableOpacity>
</View>
{/* 最近记录 */}
<ThemedView level="root" style={styles.recentSection}>
<View style={styles.sectionHeader}>
<ThemedText variant="h4" color={theme.textPrimary}>
</ThemedText>
<TouchableOpacity onPress={() => router.push('/record')}>
<ThemedText variant="smallMedium" color={theme.primary}>
</ThemedText>
</TouchableOpacity>
</View>
<ThemedText variant="small" color={theme.textMuted} style={styles.emptyText}>
</ThemedText>
</ThemedView>
</ScrollView>
</Screen>
);
}

View File

@@ -0,0 +1,117 @@
import { StyleSheet } from 'react-native';
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
export const createStyles = (theme: Theme) => {
return StyleSheet.create({
scrollContent: {
flexGrow: 1,
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.xl,
paddingBottom: Spacing["5xl"],
},
header: {
marginBottom: Spacing.xl,
},
calorieCard: {
padding: Spacing.xl,
marginBottom: Spacing.lg,
borderRadius: BorderRadius.xl,
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 4,
},
weightCard: {
padding: Spacing.xl,
marginBottom: Spacing.xl,
borderRadius: BorderRadius.xl,
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 4,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: Spacing.lg,
},
iconContainer: {
width: 48,
height: 48,
borderRadius: BorderRadius.lg,
backgroundColor: theme.backgroundTertiary,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
calorieContent: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: Spacing.md,
},
weightContent: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: Spacing.md,
},
progressBar: {
height: 8,
backgroundColor: theme.backgroundTertiary,
borderRadius: BorderRadius.full,
overflow: 'hidden',
marginBottom: Spacing.sm,
},
progressFill: {
height: '100%',
backgroundColor: theme.primary,
borderRadius: BorderRadius.full,
},
remainingText: {
textAlign: 'right',
},
quickActions: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: Spacing.xl,
gap: Spacing.md,
},
actionButton: {
flex: 1,
padding: Spacing.lg,
borderRadius: BorderRadius.xl,
alignItems: 'center',
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 3,
},
cameraButton: {
backgroundColor: theme.primary,
},
chartButton: {
backgroundColor: '#10B981',
},
aiButton: {
backgroundColor: '#059669',
},
actionIconContainer: {
marginBottom: Spacing.sm,
},
recentSection: {
marginBottom: Spacing.xl,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.lg,
},
emptyText: {
textAlign: 'center',
paddingVertical: Spacing["2xl"],
},
});
};

View File

@@ -0,0 +1,401 @@
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>
);
}

View File

@@ -0,0 +1,163 @@
import { StyleSheet, Image } from 'react-native';
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
export const createStyles = (theme: Theme) => {
return StyleSheet.create({
scrollContent: {
flexGrow: 1,
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.xl,
paddingBottom: Spacing["5xl"],
},
title: {
marginBottom: Spacing.xl,
},
methodButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: Spacing.md,
marginBottom: Spacing.xl,
},
methodButton: {
flex: 1,
padding: Spacing.xl,
borderRadius: BorderRadius.xl,
alignItems: 'center',
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 3,
},
cameraButton: {
backgroundColor: theme.primary,
},
galleryButton: {
backgroundColor: '#10B981',
},
buttonText: {
marginTop: Spacing.sm,
},
manualSection: {
padding: Spacing.xl,
borderRadius: BorderRadius.xl,
marginBottom: Spacing.xl,
},
sectionTitle: {
marginBottom: Spacing.lg,
},
inputGroup: {
marginBottom: Spacing.lg,
},
inputRow: {
flexDirection: 'row',
gap: Spacing.md,
},
halfInput: {
flex: 1,
},
label: {
marginBottom: Spacing.sm,
},
input: {
backgroundColor: theme.backgroundTertiary,
borderRadius: BorderRadius.lg,
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
color: theme.textPrimary,
fontSize: 16,
},
mealTypes: {
flexDirection: 'row',
gap: Spacing.sm,
},
mealTypeButton: {
flex: 1,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
backgroundColor: theme.backgroundTertiary,
alignItems: 'center',
borderWidth: 1,
borderColor: theme.border,
},
mealTypeButtonActive: {
backgroundColor: theme.primary,
borderColor: theme.primary,
},
saveButton: {
backgroundColor: theme.primary,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,
alignItems: 'center',
marginTop: Spacing.md,
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 3,
},
modalContainer: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
borderTopLeftRadius: BorderRadius["2xl"],
borderTopRightRadius: BorderRadius["2xl"],
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: Spacing.xl,
borderBottomWidth: 1,
borderBottomColor: theme.border,
},
modalBody: {
padding: Spacing.xl,
},
previewImage: {
width: '100%',
height: 200,
borderRadius: BorderRadius.lg,
marginBottom: Spacing.lg,
},
resultItem: {
marginBottom: Spacing.lg,
},
resultInput: {
backgroundColor: theme.backgroundTertiary,
borderRadius: BorderRadius.lg,
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
color: theme.textPrimary,
fontSize: 16,
marginTop: Spacing.sm,
},
modalFooter: {
flexDirection: 'row',
gap: Spacing.md,
padding: Spacing.xl,
borderTopWidth: 1,
borderTopColor: theme.border,
},
modalButton: {
flex: 1,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,
alignItems: 'center',
},
cancelButton: {
backgroundColor: theme.backgroundTertiary,
},
confirmButton: {
backgroundColor: theme.primary,
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 3,
},
});
};

View File

@@ -0,0 +1,346 @@
import React, { useState, useEffect, useMemo } from 'react';
import { View, ScrollView, TouchableOpacity, TextInput, Modal, Alert } from 'react-native';
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 { createStyles } from './styles';
const MOCK_USER_ID = 'mock-user-001';
interface WeightRecord {
id: string;
weight: number;
note: string;
recordedAt: string;
}
export default function StatsScreen() {
const { theme, isDark } = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const [weightRecords, setWeightRecords] = useState<WeightRecord[]>([]);
const [modalVisible, setModalVisible] = useState(false);
const [newWeight, setNewWeight] = useState('');
const [newNote, setNewNote] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
// 获取体重记录
const fetchWeightRecords = async () => {
setLoading(true);
try {
/**
* 服务端文件server/src/routes/weight-records.ts
* 接口GET /api/v1/weight-records
* Query 参数userId: string
*/
const response = await fetch(
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records?userId=${MOCK_USER_ID}`
);
const data = await response.json();
if (data.success) {
setWeightRecords(data.data);
}
} catch (error) {
console.error('Failed to fetch weight records:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchWeightRecords();
}, []);
// 添加体重记录
const addWeightRecord = async () => {
if (!newWeight) {
Alert.alert('提示', '请输入体重');
return;
}
setSaving(true);
try {
const recordData = {
userId: MOCK_USER_ID,
weight: parseFloat(newWeight),
note: newNote,
recordedAt: new Date().toISOString(),
};
/**
* 服务端文件server/src/routes/weight-records.ts
* 接口POST /api/v1/weight-records
* Body 参数userId: string, weight: number, note?: string, recordedAt: string
*/
const response = await fetch(
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recordData),
}
);
const data = await response.json();
if (data.success) {
Alert.alert('成功', '记录已保存', [
{ text: '确定', onPress: () => resetModal() },
]);
fetchWeightRecords();
} else {
Alert.alert('失败', data.error || '保存失败,请重试');
}
} catch (error) {
console.error('Save error:', error);
Alert.alert('错误', '保存失败,请检查网络连接');
} finally {
setSaving(false);
}
};
const resetModal = () => {
setModalVisible(false);
setNewWeight('');
setNewNote('');
};
// 删除记录
const deleteRecord = async (id: string) => {
Alert.alert('确认删除', '确定要删除这条记录吗?', [
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
try {
/**
* 服务端文件server/src/routes/weight-records.ts
* 接口DELETE /api/v1/weight-records/:id
* Path 参数id: string
*/
const response = await fetch(
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records/${id}`,
{
method: 'DELETE',
}
);
const data = await response.json();
if (data.success) {
fetchWeightRecords();
}
} catch (error) {
console.error('Delete error:', error);
}
},
},
]);
};
return (
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.header}>
<ThemedText variant="h2" color={theme.textPrimary}>
</ThemedText>
<TouchableOpacity
style={styles.addButton}
onPress={() => setModalVisible(true)}
>
<FontAwesome6 name="plus" size={18} color={theme.buttonPrimaryText} />
</TouchableOpacity>
</View>
{/* 体重趋势 */}
<ThemedView level="root" style={styles.chartSection}>
<View style={styles.sectionHeader}>
<ThemedText variant="h4" color={theme.textPrimary}>
</ThemedText>
{weightRecords.length > 0 && (
<ThemedText variant="small" color={theme.primary}>
{weightRecords.length}
</ThemedText>
)}
</View>
{weightRecords.length === 0 ? (
<ThemedText variant="small" color={theme.textMuted} style={styles.emptyText}>
</ThemedText>
) : (
<View style={styles.recordsList}>
{weightRecords.map((record, index) => (
<View key={record.id} style={styles.recordItem}>
<View style={styles.recordInfo}>
<View style={styles.recordWeight}>
<ThemedText variant="h3" color={theme.primary}>
{record.weight}
</ThemedText>
<ThemedText variant="small" color={theme.textMuted}>
kg
</ThemedText>
</View>
<View>
<ThemedText variant="small" color={theme.textSecondary}>
{new Date(record.recordedAt).toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
})}
</ThemedText>
{record.note && (
<ThemedText variant="caption" color={theme.textMuted}>
{record.note}
</ThemedText>
)}
</View>
</View>
{index > 0 && (
<View style={styles.changeBadge}>
<FontAwesome6
name={record.weight < weightRecords[index - 1].weight ? 'arrow-down' : 'arrow-up'}
size={12}
color={record.weight < weightRecords[index - 1].weight ? '#10B981' : '#EF4444'}
/>
<ThemedText
variant="caption"
color={record.weight < weightRecords[index - 1].weight ? '#10B981' : '#EF4444'}
>
{Math.abs(record.weight - weightRecords[index - 1].weight).toFixed(1)}
</ThemedText>
</View>
)}
<TouchableOpacity onPress={() => deleteRecord(record.id)}>
<FontAwesome6 name="trash" size={16} color={theme.textMuted} />
</TouchableOpacity>
</View>
))}
</View>
)}
</ThemedView>
{/* 统计信息 */}
{weightRecords.length >= 2 && (
<ThemedView level="root" style={styles.statsSection}>
<ThemedText variant="h4" color={theme.textPrimary} style={styles.sectionTitle}>
</ThemedText>
<View style={styles.statRow}>
<View style={styles.statItem}>
<ThemedText variant="small" color={theme.textMuted}>
</ThemedText>
<ThemedText variant="h4" color={theme.textPrimary}>
{weightRecords[weightRecords.length - 1].weight} kg
</ThemedText>
</View>
<View style={[styles.statItem, styles.statBorder]}>
<ThemedText variant="small" color={theme.textMuted}>
</ThemedText>
<ThemedText variant="h4" color={theme.primary}>
{weightRecords[0].weight} kg
</ThemedText>
</View>
<View style={styles.statItem}>
<ThemedText variant="small" color={theme.textMuted}>
</ThemedText>
<ThemedText
variant="h4"
color={
weightRecords[0].weight < weightRecords[weightRecords.length - 1].weight
? '#10B981'
: '#EF4444'
}
>
{weightRecords[0].weight < weightRecords[weightRecords.length - 1].weight ? '-' : '+'}
{Math.abs(
weightRecords[0].weight - weightRecords[weightRecords.length - 1].weight
).toFixed(1)}{' '}
kg
</ThemedText>
</View>
</View>
</ThemedView>
)}
</ScrollView>
{/* 添加记录 Modal */}
<Modal visible={modalVisible} transparent animationType="fade">
<View style={styles.modalContainer}>
<ThemedView level="default" style={styles.modalContent}>
<View style={styles.modalHeader}>
<ThemedText variant="h4" color={theme.textPrimary}>
</ThemedText>
<TouchableOpacity onPress={resetModal}>
<FontAwesome6 name="xmark" size={24} color={theme.textSecondary} />
</TouchableOpacity>
</View>
<View style={styles.modalBody}>
<View style={styles.inputGroup}>
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
(kg)
</ThemedText>
<TextInput
style={styles.input}
placeholder="输入体重"
placeholderTextColor={theme.textMuted}
value={newWeight}
onChangeText={setNewWeight}
keyboardType="decimal-pad"
/>
</View>
<View style={styles.inputGroup}>
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
()
</ThemedText>
<TextInput
style={[styles.input, styles.textArea]}
placeholder="添加备注"
placeholderTextColor={theme.textMuted}
value={newNote}
onChangeText={setNewNote}
multiline
numberOfLines={3}
/>
</View>
</View>
<View style={styles.modalFooter}>
<TouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={resetModal}
>
<ThemedText variant="smallMedium" color={theme.textSecondary}>
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, styles.confirmButton]}
onPress={addWeightRecord}
>
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
{saving ? '保存中...' : '保存'}
</ThemedText>
</TouchableOpacity>
</View>
</ThemedView>
</View>
</Modal>
</Screen>
);
}

View File

@@ -0,0 +1,161 @@
import { StyleSheet } from 'react-native';
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
export const createStyles = (theme: Theme) => {
return StyleSheet.create({
scrollContent: {
flexGrow: 1,
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.xl,
paddingBottom: Spacing["5xl"],
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.xl,
},
addButton: {
width: 40,
height: 40,
borderRadius: BorderRadius.lg,
backgroundColor: theme.primary,
justifyContent: 'center',
alignItems: 'center',
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 3,
},
chartSection: {
padding: Spacing.xl,
borderRadius: BorderRadius.xl,
marginBottom: Spacing.lg,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.lg,
},
sectionTitle: {
marginBottom: Spacing.lg,
},
emptyText: {
textAlign: 'center',
paddingVertical: Spacing["2xl"],
},
recordsList: {
gap: Spacing.md,
},
recordItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: Spacing.lg,
backgroundColor: theme.backgroundTertiary,
borderRadius: BorderRadius.lg,
},
recordInfo: {
flex: 1,
},
recordWeight: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: Spacing.xs,
},
changeBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.sm,
paddingVertical: Spacing.xs,
backgroundColor: theme.backgroundDefault,
borderRadius: BorderRadius.sm,
marginRight: Spacing.md,
},
statsSection: {
padding: Spacing.xl,
borderRadius: BorderRadius.xl,
marginBottom: Spacing.lg,
},
statRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
statItem: {
flex: 1,
alignItems: 'center',
},
statBorder: {
borderLeftWidth: 1,
borderRightWidth: 1,
borderColor: theme.border,
},
modalContainer: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.lg,
},
modalContent: {
width: '100%',
maxWidth: 400,
borderRadius: BorderRadius["2xl"],
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: Spacing.xl,
borderBottomWidth: 1,
borderBottomColor: theme.border,
},
modalBody: {
padding: Spacing.xl,
},
inputGroup: {
marginBottom: Spacing.lg,
},
label: {
marginBottom: Spacing.sm,
},
input: {
backgroundColor: theme.backgroundTertiary,
borderRadius: BorderRadius.lg,
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
color: theme.textPrimary,
fontSize: 16,
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
modalFooter: {
flexDirection: 'row',
gap: Spacing.md,
padding: Spacing.xl,
borderTopWidth: 1,
borderTopColor: theme.border,
},
modalButton: {
flex: 1,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,
alignItems: 'center',
},
cancelButton: {
backgroundColor: theme.backgroundTertiary,
},
confirmButton: {
backgroundColor: theme.primary,
shadowColor: theme.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 3,
},
});
};

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* 自动检测并安装缺失的依赖
* 使用方法: node scripts/install-missing-deps.js
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
console.log('🔍 检测缺失的依赖...\n');
try {
// 运行 depcheck 并获取 JSON 输出
// 注意depcheck 发现问题时会返回非零退出码,但这不是错误
let depcheckOutput;
try {
depcheckOutput = execSync('npx depcheck --json', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (execError) {
// depcheck 返回非零退出码时仍然有输出
if (execError.stdout) {
depcheckOutput = execError.stdout;
} else {
throw execError;
}
}
const result = JSON.parse(depcheckOutput);
// 获取缺失的依赖
const missing = result.missing || {};
// 需要忽略的文件模式
const ignoreFilePatterns = [
/template\.config\.(ts|js)$/, // 模板配置文件
/\.template\./, // 其他模板文件
/declarations\.d\.ts$/, // 项目配置文件
];
// 过滤包:排除内部别名和只被模板文件引用的包
const missingPackages = Object.keys(missing).filter(pkg => {
// 排除内部路径别名
if (pkg.startsWith('@api/') || pkg.startsWith('@/') || pkg === '@api') {
return false;
}
// 获取引用该包的文件列表
const referencingFiles = missing[pkg] || [];
// 过滤掉模板配置文件
const nonTemplateFiles = referencingFiles.filter(file => {
return !ignoreFilePatterns.some(pattern => pattern.test(file));
});
// 只有当存在非模板文件引用时才保留该包
return nonTemplateFiles.length > 0;
});
if (missingPackages.length === 0) {
console.log('✅ 没有发现缺失的依赖!');
process.exit(0);
}
console.log('📦 发现以下缺失的依赖:');
missingPackages.forEach((pkg, index) => {
const files = missing[pkg];
console.log(` ${index + 1}. ${pkg}`);
console.log(
` 被引用于: ${files.slice(0, 2).join(', ')}${files.length > 2 ? ' ...' : ''}`,
);
});
console.log('\n🚀 开始安装...\n');
// 使用 expo install 安装所有缺失的包
const packagesToInstall = missingPackages.join(' ');
try {
execSync(`pnpm expo install ${packagesToInstall}`, {
stdio: 'inherit',
});
console.log('\n✅ 所有缺失的依赖已安装完成!');
} catch (installError) {
console.log('\n⚠ expo install 失败,尝试使用 npm install...\n');
execSync(`npm install ${packagesToInstall}`, {
stdio: 'inherit',
});
console.log('\n✅ 所有缺失的依赖已通过 npm 安装完成!');
}
} catch (error) {
if (error.message.includes('depcheck')) {
console.error('❌ depcheck 未安装或运行失败');
console.log('💡 尝试运行: npm install -g depcheck');
} else {
console.error('❌ 发生错误:', error.message);
}
process.exit(1);
}

24
client/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"skipLibCheck": true,
"jsx": "react-jsx",
"baseUrl": ".",
"strict": true,
"noEmit": true,
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
"exclude": [
"node_modules",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"dist",
"build",
".expo"
]
}

76
client/utils/index.ts Normal file
View File

@@ -0,0 +1,76 @@
import { Platform } from 'react-native';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
const API_BASE = (process.env.EXPO_PUBLIC_API_BASE ?? '').replace(/\/$/, '');
/**
* 创建跨平台兼容的文件对象,用于 FormData.append()
* - Web 端返回 File 对象
* - 移动端返回 { uri, type, name } 对象RN fetch 会自动处理)
* @param fileUri Expo 媒体库(如 expo-image-picker、expo-camera返回的 uri
* @param fileName 上传时的文件名,如 'photo.jpg'
* @param mimeType 文件 MIME 类型,如 'image/jpeg'、'audio/mpeg'
*/
export async function createFormDataFile(
fileUri: string,
fileName: string,
mimeType: string
): Promise<File | { uri: string; type: string; name: string }> {
if (Platform.OS === 'web') {
const response = await fetch(fileUri);
const blob = await response.blob();
return new File([blob], fileName, { type: mimeType });
}
return { uri: fileUri, type: mimeType, name: fileName };
}
/**
* 构建文件或图片完整的URL
* @param url 相对或绝对路径
* @param w 宽度 (px) - 自动向下取整
* @param h 高度 (px)
*/
export const buildAssetUrl = (url?: string | null, w?: number, h?: number): string | undefined => {
if (!url) return undefined;
if (/^https?:\/\//i.test(url)) return url; // 绝对路径直接返回
// 1. 去除 Base 尾部和 Path 头部的斜杠
const base = API_BASE;
const path = url.replace(/^\//, '');
const abs = `${base}/${path}`;
// 2. 无需缩略图则直接返回
if (!w && !h) return abs;
// 3. 构造参数,保留原有 Query (如有)
const separator = abs.includes('?') ? '&' : '?';
const query = [
w ? `w=${Math.floor(w)}` : '',
h ? `h=${Math.floor(h)}` : ''
].filter(Boolean).join('&');
return `${abs}${separator}${query}`;
};
/**
* 将UTC时间字符串转换为本地时间字符串
* @param utcDateStr UTC时间字符串格式如2025-11-26T01:49:48.009573
* @returns 本地时间字符串格式如2025-11-26 08:49:48
*/
export const convertToLocalTimeStr = (utcDateStr: string): string => {
if (!utcDateStr) {
return utcDateStr;
}
const microUtcRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,6}/;
if (!microUtcRegex.test(utcDateStr)) {
console.log('invalid utcDateStr:', utcDateStr);
return utcDateStr;
}
const normalized = utcDateStr.replace(/\.(\d{6})$/, (_, frac) => `.${frac.slice(0, 3)}`);
const d = dayjs.utc(normalized);
if (!d.isValid()) {
return utcDateStr;
}
return d.local().format('YYYY-MM-DD HH:mm:ss');
}