commit
7526a9b827
49 changed files with 12520 additions and 0 deletions
@ -0,0 +1,11 @@ |
|||||||
|
# API 配置 |
||||||
|
EXPO_PUBLIC_API_URL=https://api.example.com |
||||||
|
|
||||||
|
# 应用信息 |
||||||
|
EXPO_PUBLIC_APP_NAME=RN Demo |
||||||
|
EXPO_PUBLIC_APP_VERSION=1.0.0 |
||||||
|
|
||||||
|
# 其他配置 |
||||||
|
# EXPO_PUBLIC_SENTRY_DSN= |
||||||
|
# EXPO_PUBLIC_ANALYTICS_ID= |
||||||
|
|
||||||
@ -0,0 +1,83 @@ |
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files |
||||||
|
|
||||||
|
# dependencies |
||||||
|
node_modules/ |
||||||
|
.pnp |
||||||
|
.pnp.js |
||||||
|
|
||||||
|
# Expo |
||||||
|
.expo/ |
||||||
|
dist/ |
||||||
|
web-build/ |
||||||
|
expo-env.d.ts |
||||||
|
|
||||||
|
# Native |
||||||
|
.kotlin/ |
||||||
|
*.orig.* |
||||||
|
*.jks |
||||||
|
*.p8 |
||||||
|
*.p12 |
||||||
|
*.key |
||||||
|
*.mobileprovision |
||||||
|
|
||||||
|
# Metro |
||||||
|
.metro-health-check* |
||||||
|
|
||||||
|
# debug |
||||||
|
npm-debug.* |
||||||
|
yarn-debug.* |
||||||
|
yarn-error.* |
||||||
|
lerna-debug.log* |
||||||
|
.pnpm-debug.log* |
||||||
|
|
||||||
|
# macOS |
||||||
|
.DS_Store |
||||||
|
*.pem |
||||||
|
|
||||||
|
# local env files |
||||||
|
.env |
||||||
|
.env*.local |
||||||
|
.env.local |
||||||
|
.env.development.local |
||||||
|
.env.test.local |
||||||
|
.env.production.local |
||||||
|
|
||||||
|
# typescript |
||||||
|
*.tsbuildinfo |
||||||
|
next-env.d.ts |
||||||
|
|
||||||
|
# testing |
||||||
|
/coverage |
||||||
|
*.lcov |
||||||
|
.nyc_output |
||||||
|
|
||||||
|
# production |
||||||
|
/build |
||||||
|
|
||||||
|
# misc |
||||||
|
.vscode/ |
||||||
|
.idea/ |
||||||
|
*.swp |
||||||
|
*.swo |
||||||
|
*~ |
||||||
|
.project |
||||||
|
.settings |
||||||
|
.classpath |
||||||
|
|
||||||
|
# logs |
||||||
|
logs |
||||||
|
*.log |
||||||
|
|
||||||
|
# OS |
||||||
|
Thumbs.db |
||||||
|
|
||||||
|
# Package manager lock files (keep pnpm-lock.yaml, ignore others) |
||||||
|
package-lock.json |
||||||
|
yarn.lock |
||||||
|
|
||||||
|
# EAS |
||||||
|
.eas/ |
||||||
|
|
||||||
|
# generated native folders |
||||||
|
/ios |
||||||
|
/android |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
{ |
||||||
|
"expo": { |
||||||
|
"name": "rn-demo", |
||||||
|
"slug": "rn-demo", |
||||||
|
"version": "1.0.0", |
||||||
|
"orientation": "portrait", |
||||||
|
"icon": "./assets/images/icon.png", |
||||||
|
"scheme": "rndemo", |
||||||
|
"userInterfaceStyle": "automatic", |
||||||
|
"newArchEnabled": true, |
||||||
|
"splash": { |
||||||
|
"image": "./assets/images/splash-icon.png", |
||||||
|
"resizeMode": "contain", |
||||||
|
"backgroundColor": "#ffffff" |
||||||
|
}, |
||||||
|
"ios": { |
||||||
|
"supportsTablet": true, |
||||||
|
"bundleIdentifier": "com.rndemo.app" |
||||||
|
}, |
||||||
|
"android": { |
||||||
|
"adaptiveIcon": { |
||||||
|
"foregroundImage": "./assets/images/adaptive-icon.png", |
||||||
|
"backgroundColor": "#ffffff" |
||||||
|
}, |
||||||
|
"edgeToEdgeEnabled": true, |
||||||
|
"predictiveBackGestureEnabled": false, |
||||||
|
"package": "com.rndemo.app" |
||||||
|
}, |
||||||
|
"web": { |
||||||
|
"bundler": "metro", |
||||||
|
"output": "static", |
||||||
|
"favicon": "./assets/images/favicon.png" |
||||||
|
}, |
||||||
|
"plugins": [ |
||||||
|
"expo-router", |
||||||
|
"expo-updates" |
||||||
|
], |
||||||
|
"experiments": { |
||||||
|
"typedRoutes": true |
||||||
|
}, |
||||||
|
"updates": { |
||||||
|
"url": "https://u.expo.dev/your-project-id" |
||||||
|
}, |
||||||
|
"runtimeVersion": { |
||||||
|
"policy": "appVersion" |
||||||
|
}, |
||||||
|
"extra": { |
||||||
|
"eas": { |
||||||
|
"projectId": "your-project-id" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import FontAwesome from '@expo/vector-icons/FontAwesome'; |
||||||
|
import { Link, Tabs } from 'expo-router'; |
||||||
|
import { Pressable } from 'react-native'; |
||||||
|
|
||||||
|
import Colors from '@/constants/Colors'; |
||||||
|
import { useColorScheme } from '@/components/useColorScheme'; |
||||||
|
import { useClientOnlyValue } from '@/components/useClientOnlyValue'; |
||||||
|
|
||||||
|
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||||
|
function TabBarIcon(props: { |
||||||
|
name: React.ComponentProps<typeof FontAwesome>['name']; |
||||||
|
color: string; |
||||||
|
}) { |
||||||
|
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />; |
||||||
|
} |
||||||
|
|
||||||
|
export default function TabLayout() { |
||||||
|
const colorScheme = useColorScheme(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Tabs |
||||||
|
screenOptions={{ |
||||||
|
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint, |
||||||
|
// Disable the static render of the header on web
|
||||||
|
// to prevent a hydration error in React Navigation v6.
|
||||||
|
headerShown: useClientOnlyValue(false, true), |
||||||
|
}}> |
||||||
|
<Tabs.Screen |
||||||
|
name="index" |
||||||
|
options={{ |
||||||
|
title: 'Tab One', |
||||||
|
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />, |
||||||
|
headerRight: () => ( |
||||||
|
<Link href="/modal" asChild> |
||||||
|
<Pressable> |
||||||
|
{({ pressed }) => ( |
||||||
|
<FontAwesome |
||||||
|
name="info-circle" |
||||||
|
size={25} |
||||||
|
color={Colors[colorScheme ?? 'light'].text} |
||||||
|
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</Pressable> |
||||||
|
</Link> |
||||||
|
), |
||||||
|
}} |
||||||
|
/> |
||||||
|
<Tabs.Screen |
||||||
|
name="two" |
||||||
|
options={{ |
||||||
|
title: 'Tab Two', |
||||||
|
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />, |
||||||
|
}} |
||||||
|
/> |
||||||
|
<Tabs.Screen |
||||||
|
name="demo" |
||||||
|
options={{ |
||||||
|
title: '完整示例', |
||||||
|
tabBarIcon: ({ color }) => <TabBarIcon name="rocket" color={color} />, |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Tabs> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,192 @@ |
|||||||
|
import { useState } from 'react'; |
||||||
|
import { StyleSheet, TouchableOpacity, Alert, ActivityIndicator } from 'react-native'; |
||||||
|
import * as Updates from 'expo-updates'; |
||||||
|
|
||||||
|
import { Text, View } from '@/components/Themed'; |
||||||
|
|
||||||
|
export default function TabOneScreen() { |
||||||
|
const [isChecking, setIsChecking] = useState(false); |
||||||
|
const [updateInfo, setUpdateInfo] = useState<string>(''); |
||||||
|
|
||||||
|
const checkForUpdates = async () => { |
||||||
|
if (__DEV__) { |
||||||
|
Alert.alert('提示', '开发模式下无法检查更新,请使用生产构建测试热更新功能'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsChecking(true); |
||||||
|
setUpdateInfo('正在检查更新...'); |
||||||
|
|
||||||
|
try { |
||||||
|
const update = await Updates.checkForUpdateAsync(); |
||||||
|
|
||||||
|
if (update.isAvailable) { |
||||||
|
setUpdateInfo('发现新版本,正在下载...'); |
||||||
|
await Updates.fetchUpdateAsync(); |
||||||
|
|
||||||
|
Alert.alert( |
||||||
|
'更新完成', |
||||||
|
'新版本已下载完成,是否立即重启应用?', |
||||||
|
[ |
||||||
|
{ |
||||||
|
text: '稍后', |
||||||
|
style: 'cancel', |
||||||
|
onPress: () => setUpdateInfo('更新已下载,稍后重启应用即可应用'), |
||||||
|
}, |
||||||
|
{ |
||||||
|
text: '立即重启', |
||||||
|
onPress: async () => { |
||||||
|
await Updates.reloadAsync(); |
||||||
|
}, |
||||||
|
}, |
||||||
|
] |
||||||
|
); |
||||||
|
} else { |
||||||
|
setUpdateInfo('当前已是最新版本'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
setUpdateInfo('检查更新失败: ' + (error as Error).message); |
||||||
|
Alert.alert('错误', '检查更新失败,请稍后重试'); |
||||||
|
} finally { |
||||||
|
setIsChecking(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const getUpdateInfo = () => { |
||||||
|
const { |
||||||
|
isEmbeddedLaunch, |
||||||
|
isEmergencyLaunch, |
||||||
|
updateId, |
||||||
|
channel, |
||||||
|
runtimeVersion, |
||||||
|
} = Updates.useUpdates(); |
||||||
|
|
||||||
|
return ` |
||||||
|
运行模式: ${__DEV__ ? '开发模式' : '生产模式'} |
||||||
|
是否为内嵌启动: ${isEmbeddedLaunch ? '是' : '否'} |
||||||
|
是否为紧急启动: ${isEmergencyLaunch ? '是' : '否'} |
||||||
|
更新 ID: ${updateId || '无'} |
||||||
|
更新通道: ${channel || '无'} |
||||||
|
运行时版本: ${runtimeVersion || '无'} |
||||||
|
`.trim();
|
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<View style={styles.container}> |
||||||
|
<Text style={styles.title}>🚀 热更新演示</Text> |
||||||
|
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" /> |
||||||
|
|
||||||
|
<View style={styles.infoContainer}> |
||||||
|
<Text style={styles.infoTitle}>当前版本信息:</Text> |
||||||
|
<Text style={styles.infoText}>{getUpdateInfo()}</Text> |
||||||
|
</View> |
||||||
|
|
||||||
|
<TouchableOpacity |
||||||
|
style={[styles.button, isChecking && styles.buttonDisabled]} |
||||||
|
onPress={checkForUpdates} |
||||||
|
disabled={isChecking} |
||||||
|
> |
||||||
|
{isChecking ? ( |
||||||
|
<ActivityIndicator color="#fff" /> |
||||||
|
) : ( |
||||||
|
<Text style={styles.buttonText}>检查更新</Text> |
||||||
|
)} |
||||||
|
</TouchableOpacity> |
||||||
|
|
||||||
|
{updateInfo ? ( |
||||||
|
<View style={styles.updateInfoContainer}> |
||||||
|
<Text style={styles.updateInfoText}>{updateInfo}</Text> |
||||||
|
</View> |
||||||
|
) : null} |
||||||
|
|
||||||
|
<View style={styles.instructionsContainer}> |
||||||
|
<Text style={styles.instructionsTitle}>📝 使用说明:</Text> |
||||||
|
<Text style={styles.instructionsText}> |
||||||
|
1. 使用 EAS Build 构建生产版本{'\n'} |
||||||
|
2. 修改代码后运行 eas update 发布更新{'\n'} |
||||||
|
3. 打开应用点击"检查更新"按钮{'\n'} |
||||||
|
4. 应用会自动下载并提示重启 |
||||||
|
</Text> |
||||||
|
</View> |
||||||
|
</View> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const styles = StyleSheet.create({ |
||||||
|
container: { |
||||||
|
flex: 1, |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center', |
||||||
|
padding: 20, |
||||||
|
}, |
||||||
|
title: { |
||||||
|
fontSize: 28, |
||||||
|
fontWeight: 'bold', |
||||||
|
marginBottom: 10, |
||||||
|
}, |
||||||
|
separator: { |
||||||
|
marginVertical: 20, |
||||||
|
height: 1, |
||||||
|
width: '80%', |
||||||
|
}, |
||||||
|
infoContainer: { |
||||||
|
backgroundColor: 'rgba(0, 122, 255, 0.1)', |
||||||
|
padding: 15, |
||||||
|
borderRadius: 10, |
||||||
|
marginBottom: 20, |
||||||
|
width: '100%', |
||||||
|
}, |
||||||
|
infoTitle: { |
||||||
|
fontSize: 16, |
||||||
|
fontWeight: 'bold', |
||||||
|
marginBottom: 10, |
||||||
|
}, |
||||||
|
infoText: { |
||||||
|
fontSize: 12, |
||||||
|
fontFamily: 'monospace', |
||||||
|
lineHeight: 18, |
||||||
|
}, |
||||||
|
button: { |
||||||
|
backgroundColor: '#007AFF', |
||||||
|
paddingHorizontal: 30, |
||||||
|
paddingVertical: 15, |
||||||
|
borderRadius: 10, |
||||||
|
marginBottom: 20, |
||||||
|
minWidth: 200, |
||||||
|
alignItems: 'center', |
||||||
|
}, |
||||||
|
buttonDisabled: { |
||||||
|
backgroundColor: '#999', |
||||||
|
}, |
||||||
|
buttonText: { |
||||||
|
color: '#fff', |
||||||
|
fontSize: 16, |
||||||
|
fontWeight: 'bold', |
||||||
|
}, |
||||||
|
updateInfoContainer: { |
||||||
|
backgroundColor: 'rgba(52, 199, 89, 0.1)', |
||||||
|
padding: 15, |
||||||
|
borderRadius: 10, |
||||||
|
marginBottom: 20, |
||||||
|
width: '100%', |
||||||
|
}, |
||||||
|
updateInfoText: { |
||||||
|
fontSize: 14, |
||||||
|
textAlign: 'center', |
||||||
|
}, |
||||||
|
instructionsContainer: { |
||||||
|
backgroundColor: 'rgba(255, 149, 0, 0.1)', |
||||||
|
padding: 15, |
||||||
|
borderRadius: 10, |
||||||
|
width: '100%', |
||||||
|
}, |
||||||
|
instructionsTitle: { |
||||||
|
fontSize: 16, |
||||||
|
fontWeight: 'bold', |
||||||
|
marginBottom: 10, |
||||||
|
}, |
||||||
|
instructionsText: { |
||||||
|
fontSize: 13, |
||||||
|
lineHeight: 20, |
||||||
|
}, |
||||||
|
}); |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
import { StyleSheet } from 'react-native'; |
||||||
|
|
||||||
|
import EditScreenInfo from '@/components/EditScreenInfo'; |
||||||
|
import { Text, View } from '@/components/Themed'; |
||||||
|
|
||||||
|
export default function TabTwoScreen() { |
||||||
|
return ( |
||||||
|
<View style={styles.container}> |
||||||
|
<Text style={styles.title}>Tab Two</Text> |
||||||
|
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" /> |
||||||
|
<EditScreenInfo path="app/(tabs)/two.tsx" /> |
||||||
|
</View> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const styles = StyleSheet.create({ |
||||||
|
container: { |
||||||
|
flex: 1, |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center', |
||||||
|
}, |
||||||
|
title: { |
||||||
|
fontSize: 20, |
||||||
|
fontWeight: 'bold', |
||||||
|
}, |
||||||
|
separator: { |
||||||
|
marginVertical: 30, |
||||||
|
height: 1, |
||||||
|
width: '80%', |
||||||
|
}, |
||||||
|
}); |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
import { ScrollViewStyleReset } from 'expo-router/html'; |
||||||
|
|
||||||
|
// This file is web-only and used to configure the root HTML for every
|
||||||
|
// web page during static rendering.
|
||||||
|
// The contents of this function only run in Node.js environments and
|
||||||
|
// do not have access to the DOM or browser APIs.
|
||||||
|
export default function Root({ children }: { children: React.ReactNode }) { |
||||||
|
return ( |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charSet="utf-8" /> |
||||||
|
<meta httpEquiv="X-UA-Compatible" content="IE=edge" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" /> |
||||||
|
|
||||||
|
{/* |
||||||
|
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||||
|
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. |
||||||
|
*/} |
||||||
|
<ScrollViewStyleReset /> |
||||||
|
|
||||||
|
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} |
||||||
|
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} /> |
||||||
|
{/* Add any additional <head> elements that you want globally available on web... */} |
||||||
|
</head> |
||||||
|
<body>{children}</body> |
||||||
|
</html> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const responsiveBackground = ` |
||||||
|
body { |
||||||
|
background-color: #fff; |
||||||
|
} |
||||||
|
@media (prefers-color-scheme: dark) { |
||||||
|
body { |
||||||
|
background-color: #000; |
||||||
|
} |
||||||
|
}`;
|
||||||
@ -0,0 +1,40 @@ |
|||||||
|
import { Link, Stack } from 'expo-router'; |
||||||
|
import { StyleSheet } from 'react-native'; |
||||||
|
|
||||||
|
import { Text, View } from '@/components/Themed'; |
||||||
|
|
||||||
|
export default function NotFoundScreen() { |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Stack.Screen options={{ title: 'Oops!' }} /> |
||||||
|
<View style={styles.container}> |
||||||
|
<Text style={styles.title}>This screen doesn't exist.</Text> |
||||||
|
|
||||||
|
<Link href="/" style={styles.link}> |
||||||
|
<Text style={styles.linkText}>Go to home screen!</Text> |
||||||
|
</Link> |
||||||
|
</View> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const styles = StyleSheet.create({ |
||||||
|
container: { |
||||||
|
flex: 1, |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center', |
||||||
|
padding: 20, |
||||||
|
}, |
||||||
|
title: { |
||||||
|
fontSize: 20, |
||||||
|
fontWeight: 'bold', |
||||||
|
}, |
||||||
|
link: { |
||||||
|
marginTop: 15, |
||||||
|
paddingVertical: 15, |
||||||
|
}, |
||||||
|
linkText: { |
||||||
|
fontSize: 14, |
||||||
|
color: '#2e78b7', |
||||||
|
}, |
||||||
|
}); |
||||||
@ -0,0 +1,102 @@ |
|||||||
|
import FontAwesome from '@expo/vector-icons/FontAwesome'; |
||||||
|
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; |
||||||
|
import { useFonts } from 'expo-font'; |
||||||
|
import { Stack } from 'expo-router'; |
||||||
|
import * as SplashScreen from 'expo-splash-screen'; |
||||||
|
import * as Updates from 'expo-updates'; |
||||||
|
import { useEffect } from 'react'; |
||||||
|
import { Alert, Platform } from 'react-native'; |
||||||
|
import 'react-native-reanimated'; |
||||||
|
|
||||||
|
import { useColorScheme } from '@/components/useColorScheme'; |
||||||
|
|
||||||
|
export { |
||||||
|
// Catch any errors thrown by the Layout component.
|
||||||
|
ErrorBoundary, |
||||||
|
} from 'expo-router'; |
||||||
|
|
||||||
|
export const unstable_settings = { |
||||||
|
// Ensure that reloading on `/modal` keeps a back button present.
|
||||||
|
initialRouteName: '(tabs)', |
||||||
|
}; |
||||||
|
|
||||||
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
|
SplashScreen.preventAutoHideAsync(); |
||||||
|
|
||||||
|
export default function RootLayout() { |
||||||
|
const [loaded, error] = useFonts({ |
||||||
|
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), |
||||||
|
...FontAwesome.font, |
||||||
|
}); |
||||||
|
|
||||||
|
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||||
|
useEffect(() => { |
||||||
|
if (error) throw error; |
||||||
|
}, [error]); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (loaded) { |
||||||
|
SplashScreen.hideAsync(); |
||||||
|
} |
||||||
|
}, [loaded]); |
||||||
|
|
||||||
|
// 检查热更新
|
||||||
|
useEffect(() => { |
||||||
|
async function checkForUpdates() { |
||||||
|
if (__DEV__) { |
||||||
|
// 开发模式下不检查更新
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const update = await Updates.checkForUpdateAsync(); |
||||||
|
|
||||||
|
if (update.isAvailable) { |
||||||
|
await Updates.fetchUpdateAsync(); |
||||||
|
|
||||||
|
// 提示用户重启应用以应用更新
|
||||||
|
Alert.alert( |
||||||
|
'更新可用', |
||||||
|
'发现新版本,是否立即重启应用?', |
||||||
|
[ |
||||||
|
{ |
||||||
|
text: '稍后', |
||||||
|
style: 'cancel', |
||||||
|
}, |
||||||
|
{ |
||||||
|
text: '立即重启', |
||||||
|
onPress: async () => { |
||||||
|
await Updates.reloadAsync(); |
||||||
|
}, |
||||||
|
}, |
||||||
|
] |
||||||
|
); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
// 处理更新检查错误
|
||||||
|
console.error('检查更新失败:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
checkForUpdates(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
if (!loaded) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return <RootLayoutNav />; |
||||||
|
} |
||||||
|
|
||||||
|
function RootLayoutNav() { |
||||||
|
const colorScheme = useColorScheme(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> |
||||||
|
<Stack> |
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> |
||||||
|
<Stack.Screen name="modal" options={{ presentation: 'modal' }} /> |
||||||
|
</Stack> |
||||||
|
</ThemeProvider> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
import { StatusBar } from 'expo-status-bar'; |
||||||
|
import { Platform, StyleSheet } from 'react-native'; |
||||||
|
|
||||||
|
import EditScreenInfo from '@/components/EditScreenInfo'; |
||||||
|
import { Text, View } from '@/components/Themed'; |
||||||
|
|
||||||
|
export default function ModalScreen() { |
||||||
|
return ( |
||||||
|
<View style={styles.container}> |
||||||
|
<Text style={styles.title}>Modal</Text> |
||||||
|
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" /> |
||||||
|
<EditScreenInfo path="app/modal.tsx" /> |
||||||
|
|
||||||
|
{/* Use a light status bar on iOS to account for the black space above the modal */} |
||||||
|
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} /> |
||||||
|
</View> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const styles = StyleSheet.create({ |
||||||
|
container: { |
||||||
|
flex: 1, |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'center', |
||||||
|
}, |
||||||
|
title: { |
||||||
|
fontSize: 20, |
||||||
|
fontWeight: 'bold', |
||||||
|
}, |
||||||
|
separator: { |
||||||
|
marginVertical: 30, |
||||||
|
height: 1, |
||||||
|
width: '80%', |
||||||
|
}, |
||||||
|
}); |
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,77 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { StyleSheet } from 'react-native'; |
||||||
|
|
||||||
|
import { ExternalLink } from './ExternalLink'; |
||||||
|
import { MonoText } from './StyledText'; |
||||||
|
import { Text, View } from './Themed'; |
||||||
|
|
||||||
|
import Colors from '@/constants/Colors'; |
||||||
|
|
||||||
|
export default function EditScreenInfo({ path }: { path: string }) { |
||||||
|
return ( |
||||||
|
<View> |
||||||
|
<View style={styles.getStartedContainer}> |
||||||
|
<Text |
||||||
|
style={styles.getStartedText} |
||||||
|
lightColor="rgba(0,0,0,0.8)" |
||||||
|
darkColor="rgba(255,255,255,0.8)"> |
||||||
|
Open up the code for this screen: |
||||||
|
</Text> |
||||||
|
|
||||||
|
<View |
||||||
|
style={[styles.codeHighlightContainer, styles.homeScreenFilename]} |
||||||
|
darkColor="rgba(255,255,255,0.05)" |
||||||
|
lightColor="rgba(0,0,0,0.05)"> |
||||||
|
<MonoText>{path}</MonoText> |
||||||
|
</View> |
||||||
|
|
||||||
|
<Text |
||||||
|
style={styles.getStartedText} |
||||||
|
lightColor="rgba(0,0,0,0.8)" |
||||||
|
darkColor="rgba(255,255,255,0.8)"> |
||||||
|
Change any of the text, save the file, and your app will automatically update. |
||||||
|
</Text> |
||||||
|
</View> |
||||||
|
|
||||||
|
<View style={styles.helpContainer}> |
||||||
|
<ExternalLink |
||||||
|
style={styles.helpLink} |
||||||
|
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet"> |
||||||
|
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}> |
||||||
|
Tap here if your app doesn't automatically update after making changes |
||||||
|
</Text> |
||||||
|
</ExternalLink> |
||||||
|
</View> |
||||||
|
</View> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const styles = StyleSheet.create({ |
||||||
|
getStartedContainer: { |
||||||
|
alignItems: 'center', |
||||||
|
marginHorizontal: 50, |
||||||
|
}, |
||||||
|
homeScreenFilename: { |
||||||
|
marginVertical: 7, |
||||||
|
}, |
||||||
|
codeHighlightContainer: { |
||||||
|
borderRadius: 3, |
||||||
|
paddingHorizontal: 4, |
||||||
|
}, |
||||||
|
getStartedText: { |
||||||
|
fontSize: 17, |
||||||
|
lineHeight: 24, |
||||||
|
textAlign: 'center', |
||||||
|
}, |
||||||
|
helpContainer: { |
||||||
|
marginTop: 15, |
||||||
|
marginHorizontal: 20, |
||||||
|
alignItems: 'center', |
||||||
|
}, |
||||||
|
helpLink: { |
||||||
|
paddingVertical: 15, |
||||||
|
}, |
||||||
|
helpLinkText: { |
||||||
|
textAlign: 'center', |
||||||
|
}, |
||||||
|
}); |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import { Link } from 'expo-router'; |
||||||
|
import * as WebBrowser from 'expo-web-browser'; |
||||||
|
import React from 'react'; |
||||||
|
import { Platform } from 'react-native'; |
||||||
|
|
||||||
|
export function ExternalLink( |
||||||
|
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string } |
||||||
|
) { |
||||||
|
return ( |
||||||
|
<Link |
||||||
|
target="_blank" |
||||||
|
{...props} |
||||||
|
// @ts-expect-error: External URLs are not typed.
|
||||||
|
href={props.href} |
||||||
|
onPress={(e) => { |
||||||
|
if (Platform.OS !== 'web') { |
||||||
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
e.preventDefault(); |
||||||
|
// Open the link in an in-app browser.
|
||||||
|
WebBrowser.openBrowserAsync(props.href as string); |
||||||
|
} |
||||||
|
}} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
import { Text, TextProps } from './Themed'; |
||||||
|
|
||||||
|
export function MonoText(props: TextProps) { |
||||||
|
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />; |
||||||
|
} |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
/** |
||||||
|
* Learn more about Light and Dark modes: |
||||||
|
* https://docs.expo.io/guides/color-schemes/
|
||||||
|
*/ |
||||||
|
|
||||||
|
import { Text as DefaultText, View as DefaultView } from 'react-native'; |
||||||
|
|
||||||
|
import Colors from '@/constants/Colors'; |
||||||
|
import { useColorScheme } from './useColorScheme'; |
||||||
|
|
||||||
|
type ThemeProps = { |
||||||
|
lightColor?: string; |
||||||
|
darkColor?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export type TextProps = ThemeProps & DefaultText['props']; |
||||||
|
export type ViewProps = ThemeProps & DefaultView['props']; |
||||||
|
|
||||||
|
export function useThemeColor( |
||||||
|
props: { light?: string; dark?: string }, |
||||||
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark |
||||||
|
) { |
||||||
|
const theme = useColorScheme() ?? 'light'; |
||||||
|
const colorFromProps = props[theme]; |
||||||
|
|
||||||
|
if (colorFromProps) { |
||||||
|
return colorFromProps; |
||||||
|
} else { |
||||||
|
return Colors[theme][colorName]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function Text(props: TextProps) { |
||||||
|
const { style, lightColor, darkColor, ...otherProps } = props; |
||||||
|
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); |
||||||
|
|
||||||
|
return <DefaultText style={[{ color }, style]} {...otherProps} />; |
||||||
|
} |
||||||
|
|
||||||
|
export function View(props: ViewProps) { |
||||||
|
const { style, lightColor, darkColor, ...otherProps } = props; |
||||||
|
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); |
||||||
|
|
||||||
|
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />; |
||||||
|
} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
import * as React from 'react'; |
||||||
|
import renderer from 'react-test-renderer'; |
||||||
|
|
||||||
|
import { MonoText } from '../StyledText'; |
||||||
|
|
||||||
|
it(`renders correctly`, () => { |
||||||
|
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON(); |
||||||
|
|
||||||
|
expect(tree).toMatchSnapshot(); |
||||||
|
}); |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
// This function is web-only as native doesn't currently support server (or build-time) rendering.
|
||||||
|
export function useClientOnlyValue<S, C>(server: S, client: C): S | C { |
||||||
|
return client; |
||||||
|
} |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
// `useEffect` is not invoked during server rendering, meaning
|
||||||
|
// we can use this to determine if we're on the server or not.
|
||||||
|
export function useClientOnlyValue<S, C>(server: S, client: C): S | C { |
||||||
|
const [value, setValue] = React.useState<S | C>(server); |
||||||
|
React.useEffect(() => { |
||||||
|
setValue(client); |
||||||
|
}, [client]); |
||||||
|
|
||||||
|
return value; |
||||||
|
} |
||||||
@ -0,0 +1 @@ |
|||||||
|
export { useColorScheme } from 'react-native'; |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
// NOTE: The default React Native styling doesn't support server rendering.
|
||||||
|
// Server rendered styles should not change between the first render of the HTML
|
||||||
|
// and the first render on the client. Typically, web developers will use CSS media queries
|
||||||
|
// to render different styles on the client and server, these aren't directly supported in React Native
|
||||||
|
// but can be achieved using a styling library like Nativewind.
|
||||||
|
export function useColorScheme() { |
||||||
|
return 'light'; |
||||||
|
} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
const tintColorLight = '#2f95dc'; |
||||||
|
const tintColorDark = '#fff'; |
||||||
|
|
||||||
|
export default { |
||||||
|
light: { |
||||||
|
text: '#000', |
||||||
|
background: '#fff', |
||||||
|
tint: tintColorLight, |
||||||
|
tabIconDefault: '#ccc', |
||||||
|
tabIconSelected: tintColorLight, |
||||||
|
}, |
||||||
|
dark: { |
||||||
|
text: '#fff', |
||||||
|
background: '#000', |
||||||
|
tint: tintColorDark, |
||||||
|
tabIconDefault: '#ccc', |
||||||
|
tabIconSelected: tintColorDark, |
||||||
|
}, |
||||||
|
}; |
||||||
@ -0,0 +1,113 @@ |
|||||||
|
# 已知问题和警告说明 |
||||||
|
|
||||||
|
## ⚠️ Web 平台警告 |
||||||
|
|
||||||
|
### 警告信息 |
||||||
|
``` |
||||||
|
λ WARN props.pointerEvents is deprecated. Use style.pointerEvents |
||||||
|
``` |
||||||
|
|
||||||
|
### 原因 |
||||||
|
这个警告来自 `[email protected]` 库内部,是该库在处理某些 React Native 组件时产生的弃用警告。具体来说: |
||||||
|
|
||||||
|
- React Native Web 正在从使用 `props.pointerEvents` 迁移到 `style.pointerEvents` |
||||||
|
- 这是库内部的实现细节,不是我们应用代码的问题 |
||||||
|
- 警告来自 `View` 组件在 Web 平台上的渲染过程 |
||||||
|
|
||||||
|
### 影响 |
||||||
|
- ✅ **不影响应用功能** - 应用在 Web 平台上完全正常运行 |
||||||
|
- ✅ **不影响性能** - 只是一个弃用提示 |
||||||
|
- ✅ **不影响移动端** - 只在 Web 平台出现 |
||||||
|
|
||||||
|
### 解决方案 |
||||||
|
|
||||||
|
#### 方案 1:忽略警告(推荐) |
||||||
|
这个警告是无害的,可以安全忽略。等待 `react-native-web` 库更新到新版本后会自动解决。 |
||||||
|
|
||||||
|
#### 方案 2:抑制警告 |
||||||
|
如果你想在开发时隐藏这个警告,可以在 `app.json` 中添加配置: |
||||||
|
|
||||||
|
```json |
||||||
|
{ |
||||||
|
"expo": { |
||||||
|
"web": { |
||||||
|
"bundler": "metro", |
||||||
|
"output": "static", |
||||||
|
"favicon": "./assets/images/favicon.png" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
或者在代码中添加警告过滤(不推荐): |
||||||
|
|
||||||
|
```typescript |
||||||
|
// 在 app/_layout.tsx 顶部添加 |
||||||
|
if (typeof window !== 'undefined') { |
||||||
|
const originalWarn = console.warn; |
||||||
|
console.warn = (...args) => { |
||||||
|
if (args[0]?.includes?.('pointerEvents is deprecated')) { |
||||||
|
return; |
||||||
|
} |
||||||
|
originalWarn(...args); |
||||||
|
}; |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
#### 方案 3:等待库更新 |
||||||
|
`react-native-web` 团队正在积极维护这个库,未来版本会解决这个警告。你可以: |
||||||
|
|
||||||
|
1. 关注 [react-native-web 更新日志](https://github.com/necolas/react-native-web/releases) |
||||||
|
2. 定期运行 `pnpm update react-native-web` 更新到最新版本 |
||||||
|
|
||||||
|
### 相关信息 |
||||||
|
|
||||||
|
- **库版本**: [email protected] |
||||||
|
- **Expo 版本**: ~54.0.22 |
||||||
|
- **React Native 版本**: 0.81.5 |
||||||
|
- **问题追踪**: 这是 react-native-web 库的已知问题,正在逐步迁移中 |
||||||
|
|
||||||
|
### 验证 |
||||||
|
你可以通过以下方式验证应用功能正常: |
||||||
|
|
||||||
|
```bash |
||||||
|
# 启动 Web 版本 |
||||||
|
pnpm web |
||||||
|
|
||||||
|
# 在浏览器中打开 http://localhost:8081 |
||||||
|
# 检查所有功能是否正常工作 |
||||||
|
``` |
||||||
|
|
||||||
|
## 📝 其他注意事项 |
||||||
|
|
||||||
|
### 开发模式下的其他常见警告 |
||||||
|
|
||||||
|
#### 1. Metro Bundler 警告 |
||||||
|
某些依赖可能会产生 Metro 打包警告,这些通常可以安全忽略。 |
||||||
|
|
||||||
|
#### 2. React 19 新特性警告 |
||||||
|
由于使用了 React 19.1.0,某些旧的 API 可能会有弃用警告。 |
||||||
|
|
||||||
|
#### 3. Expo Router 类型警告 |
||||||
|
TypeScript 可能会对某些 Expo Router 的动态路由产生类型警告,这是正常的。 |
||||||
|
|
||||||
|
### 生产构建 |
||||||
|
在生产构建中,这些警告不会出现,因为: |
||||||
|
- 生产构建会移除所有开发时的警告 |
||||||
|
- 代码会被优化和压缩 |
||||||
|
- 只有关键错误会被记录 |
||||||
|
|
||||||
|
### 如何报告问题 |
||||||
|
如果你遇到其他问题: |
||||||
|
|
||||||
|
1. 检查是否是已知问题(查看本文档) |
||||||
|
2. 查看 [Expo 文档](https://docs.expo.dev/) |
||||||
|
3. 搜索 [Expo GitHub Issues](https://github.com/expo/expo/issues) |
||||||
|
4. 在项目中创建 issue 或联系开发团队 |
||||||
|
|
||||||
|
## ✅ 总结 |
||||||
|
|
||||||
|
**当前的 `pointerEvents` 警告是安全的,可以忽略。** 应用在所有平台(iOS、Android、Web)上都能正常运行。这只是一个库内部的迁移提示,不影响你的开发和生产使用。 |
||||||
|
|
||||||
|
如果你想要一个完全没有警告的开发体验,可以等待 `react-native-web` 的下一个主要版本更新,或者使用上述方案 2 临时抑制警告。 |
||||||
|
|
||||||
@ -0,0 +1,387 @@ |
|||||||
|
# 📚 常用工具库使用指南 |
||||||
|
|
||||||
|
本文档介绍项目中已安装的常用工具库及其使用方法。 |
||||||
|
|
||||||
|
## 📦 已安装的库列表 |
||||||
|
|
||||||
|
### 工具类库 |
||||||
|
| 库名 | 版本 | 用途 | |
||||||
|
|------|------|------| |
||||||
|
| **lodash-es** | ^4.17.21 | JavaScript 工具函数库(ES modules 版本) | |
||||||
|
| **dayjs** | ^1.11.19 | 轻量级日期处理库 | |
||||||
|
| **axios** | ^1.13.1 | HTTP 请求库 | |
||||||
|
| **zustand** | ^5.0.8 | 轻量级状态管理 | |
||||||
|
| **react-hook-form** | ^7.66.0 | 表单处理库 | |
||||||
|
| **zod** | ^4.1.12 | TypeScript 数据验证库 | |
||||||
|
|
||||||
|
### Expo 原生模块 |
||||||
|
| 库名 | 版本 | 用途 | |
||||||
|
|------|------|------| |
||||||
|
| **@react-native-async-storage/async-storage** | ^2.2.0 | 本地存储 | |
||||||
|
| **expo-image** | ^3.0.10 | 优化的图片组件 | |
||||||
|
| **expo-haptics** | ^15.0.7 | 触觉反馈 | |
||||||
|
|
||||||
|
### 开发工具 |
||||||
|
| 库名 | 版本 | 用途 | |
||||||
|
|------|------|------| |
||||||
|
| **@types/lodash-es** | ^4.17.12 | Lodash-ES TypeScript 类型定义 | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 🚀 快速开始 |
||||||
|
|
||||||
|
### 1. Lodash-ES - JavaScript 工具库 |
||||||
|
|
||||||
|
> **注意**:使用 `lodash-es` 而不是 `lodash`,支持 ES modules 和更好的 tree-shaking。 |
||||||
|
|
||||||
|
```typescript |
||||||
|
// 推荐:按需导入(tree-shaking 友好) |
||||||
|
import { map, filter, uniq, pick, cloneDeep, debounce } from 'lodash-es'; |
||||||
|
|
||||||
|
// 数组操作 |
||||||
|
map([1, 2, 3], n => n * 2); // [2, 4, 6] |
||||||
|
filter([1, 2, 3, 4], n => n % 2 === 0); // [2, 4] |
||||||
|
uniq([1, 2, 2, 3]); // [1, 2, 3] |
||||||
|
|
||||||
|
// 对象操作 |
||||||
|
pick({ a: 1, b: 2, c: 3 }, ['a', 'b']); // { a: 1, b: 2 } |
||||||
|
cloneDeep(obj); // 深拷贝 |
||||||
|
|
||||||
|
// 防抖和节流 |
||||||
|
const handleSearch = debounce((text) => { |
||||||
|
console.log('Searching:', text); |
||||||
|
}, 300); |
||||||
|
|
||||||
|
// 也可以全量导入(不推荐,会增加包体积) |
||||||
|
import _ from 'lodash-es'; |
||||||
|
_.map([1, 2, 3], n => n * 2); |
||||||
|
``` |
||||||
|
|
||||||
|
### 2. Day.js - 日期处理 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import dayjs from 'dayjs'; |
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime'; |
||||||
|
import 'dayjs/locale/zh-cn'; |
||||||
|
|
||||||
|
dayjs.extend(relativeTime); |
||||||
|
dayjs.locale('zh-cn'); |
||||||
|
|
||||||
|
// 格式化 |
||||||
|
dayjs().format('YYYY-MM-DD HH:mm:ss'); |
||||||
|
|
||||||
|
// 相对时间 |
||||||
|
dayjs().fromNow(); // '几秒前' |
||||||
|
dayjs().subtract(1, 'day').fromNow(); // '1天前' |
||||||
|
|
||||||
|
// 日期操作 |
||||||
|
dayjs().add(7, 'day'); // 7天后 |
||||||
|
dayjs().startOf('month'); // 本月第一天 |
||||||
|
``` |
||||||
|
|
||||||
|
### 3. Axios - HTTP 请求 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import axios from 'axios'; |
||||||
|
|
||||||
|
// 创建实例 |
||||||
|
const api = axios.create({ |
||||||
|
baseURL: 'https://api.example.com', |
||||||
|
timeout: 10000, |
||||||
|
}); |
||||||
|
|
||||||
|
// 请求拦截器 |
||||||
|
api.interceptors.request.use(async (config) => { |
||||||
|
const token = await AsyncStorage.getItem('token'); |
||||||
|
if (token) { |
||||||
|
config.headers.Authorization = `Bearer ${token}`; |
||||||
|
} |
||||||
|
return config; |
||||||
|
}); |
||||||
|
|
||||||
|
// 使用 |
||||||
|
const data = await api.get('/users'); |
||||||
|
await api.post('/users', { name: 'John' }); |
||||||
|
``` |
||||||
|
|
||||||
|
### 4. Zustand - 状态管理 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import { create } from 'zustand'; |
||||||
|
|
||||||
|
interface UserState { |
||||||
|
user: User | null; |
||||||
|
setUser: (user: User) => void; |
||||||
|
} |
||||||
|
|
||||||
|
export const useUserStore = create<UserState>((set) => ({ |
||||||
|
user: null, |
||||||
|
setUser: (user) => set({ user }), |
||||||
|
})); |
||||||
|
|
||||||
|
// 在组件中使用 |
||||||
|
const user = useUserStore((state) => state.user); |
||||||
|
const setUser = useUserStore((state) => state.setUser); |
||||||
|
``` |
||||||
|
|
||||||
|
### 5. React Hook Form + Zod - 表单处理 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import { useForm, Controller } from 'react-hook-form'; |
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'; |
||||||
|
import { z } from 'zod'; |
||||||
|
|
||||||
|
const schema = z.object({ |
||||||
|
email: z.string().email('请输入有效的邮箱'), |
||||||
|
password: z.string().min(6, '密码至少6个字符'), |
||||||
|
}); |
||||||
|
|
||||||
|
type FormData = z.infer<typeof schema>; |
||||||
|
|
||||||
|
function LoginForm() { |
||||||
|
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({ |
||||||
|
resolver: zodResolver(schema), |
||||||
|
}); |
||||||
|
|
||||||
|
const onSubmit = (data: FormData) => { |
||||||
|
console.log(data); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<Controller |
||||||
|
control={control} |
||||||
|
name="email" |
||||||
|
render={({ field: { onChange, value } }) => ( |
||||||
|
<TextInput |
||||||
|
value={value} |
||||||
|
onChangeText={onChange} |
||||||
|
placeholder="邮箱" |
||||||
|
/> |
||||||
|
)} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### 6. AsyncStorage - 本地存储 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'; |
||||||
|
|
||||||
|
// 存储 |
||||||
|
await AsyncStorage.setItem('key', JSON.stringify(value)); |
||||||
|
|
||||||
|
// 读取 |
||||||
|
const value = await AsyncStorage.getItem('key'); |
||||||
|
const data = value ? JSON.parse(value) : null; |
||||||
|
|
||||||
|
// 删除 |
||||||
|
await AsyncStorage.removeItem('key'); |
||||||
|
|
||||||
|
// 清空 |
||||||
|
await AsyncStorage.clear(); |
||||||
|
``` |
||||||
|
|
||||||
|
### 7. Expo Image - 优化的图片组件 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import { Image } from 'expo-image'; |
||||||
|
|
||||||
|
<Image |
||||||
|
source={{ uri: 'https://example.com/image.jpg' }} |
||||||
|
placeholder={require('@/assets/placeholder.png')} |
||||||
|
contentFit="cover" |
||||||
|
transition={1000} |
||||||
|
style={{ width: 300, height: 300 }} |
||||||
|
/> |
||||||
|
``` |
||||||
|
|
||||||
|
### 8. Expo Haptics - 触觉反馈 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import * as Haptics from 'expo-haptics'; |
||||||
|
|
||||||
|
// 轻触反馈 |
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); |
||||||
|
|
||||||
|
// 成功反馈 |
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); |
||||||
|
|
||||||
|
// 错误反馈 |
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 💡 实用示例 |
||||||
|
|
||||||
|
### 创建 API 工具类 |
||||||
|
|
||||||
|
```typescript |
||||||
|
// utils/api.ts |
||||||
|
import axios from 'axios'; |
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'; |
||||||
|
|
||||||
|
const api = axios.create({ |
||||||
|
baseURL: 'https://api.example.com', |
||||||
|
timeout: 10000, |
||||||
|
}); |
||||||
|
|
||||||
|
api.interceptors.request.use(async (config) => { |
||||||
|
const token = await AsyncStorage.getItem('token'); |
||||||
|
if (token) { |
||||||
|
config.headers.Authorization = `Bearer ${token}`; |
||||||
|
} |
||||||
|
return config; |
||||||
|
}); |
||||||
|
|
||||||
|
api.interceptors.response.use( |
||||||
|
(response) => response.data, |
||||||
|
(error) => { |
||||||
|
if (error.response?.status === 401) { |
||||||
|
AsyncStorage.removeItem('token'); |
||||||
|
} |
||||||
|
return Promise.reject(error); |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
export default api; |
||||||
|
``` |
||||||
|
|
||||||
|
### 创建 Storage 工具类 |
||||||
|
|
||||||
|
```typescript |
||||||
|
// utils/storage.ts |
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'; |
||||||
|
|
||||||
|
class Storage { |
||||||
|
static async setObject<T>(key: string, value: T): Promise<void> { |
||||||
|
await AsyncStorage.setItem(key, JSON.stringify(value)); |
||||||
|
} |
||||||
|
|
||||||
|
static async getObject<T>(key: string): Promise<T | null> { |
||||||
|
const value = await AsyncStorage.getItem(key); |
||||||
|
return value ? JSON.parse(value) : null; |
||||||
|
} |
||||||
|
|
||||||
|
static async remove(key: string): Promise<void> { |
||||||
|
await AsyncStorage.removeItem(key); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export default Storage; |
||||||
|
``` |
||||||
|
|
||||||
|
### 创建用户状态管理 |
||||||
|
|
||||||
|
```typescript |
||||||
|
// stores/userStore.ts |
||||||
|
import { create } from 'zustand'; |
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware'; |
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'; |
||||||
|
|
||||||
|
interface User { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
email: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface UserState { |
||||||
|
user: User | null; |
||||||
|
setUser: (user: User) => void; |
||||||
|
clearUser: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
export const useUserStore = create<UserState>()( |
||||||
|
persist( |
||||||
|
(set) => ({ |
||||||
|
user: null, |
||||||
|
setUser: (user) => set({ user }), |
||||||
|
clearUser: () => set({ user: null }), |
||||||
|
}), |
||||||
|
{ |
||||||
|
name: 'user-storage', |
||||||
|
storage: createJSONStorage(() => AsyncStorage), |
||||||
|
} |
||||||
|
) |
||||||
|
); |
||||||
|
``` |
||||||
|
|
||||||
|
### 日期格式化工具 |
||||||
|
|
||||||
|
```typescript |
||||||
|
// utils/date.ts |
||||||
|
import dayjs from 'dayjs'; |
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime'; |
||||||
|
import 'dayjs/locale/zh-cn'; |
||||||
|
|
||||||
|
dayjs.extend(relativeTime); |
||||||
|
dayjs.locale('zh-cn'); |
||||||
|
|
||||||
|
export const formatDate = (date: Date | string | number) => { |
||||||
|
return dayjs(date).format('YYYY-MM-DD HH:mm:ss'); |
||||||
|
}; |
||||||
|
|
||||||
|
export const formatRelativeTime = (date: Date | string | number) => { |
||||||
|
return dayjs(date).fromNow(); |
||||||
|
}; |
||||||
|
|
||||||
|
export const formatChatTime = (timestamp: number) => { |
||||||
|
const date = dayjs(timestamp); |
||||||
|
const now = dayjs(); |
||||||
|
|
||||||
|
if (date.isSame(now, 'day')) { |
||||||
|
return date.format('HH:mm'); |
||||||
|
} else if (date.isSame(now.subtract(1, 'day'), 'day')) { |
||||||
|
return '昨天 ' + date.format('HH:mm'); |
||||||
|
} else if (date.isSame(now, 'year')) { |
||||||
|
return date.format('MM-DD HH:mm'); |
||||||
|
} else { |
||||||
|
return date.format('YYYY-MM-DD'); |
||||||
|
} |
||||||
|
}; |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 📝 最佳实践 |
||||||
|
|
||||||
|
1. **代码组织** |
||||||
|
- 将 API 配置放在 `utils/api.ts` |
||||||
|
- 将存储工具放在 `utils/storage.ts` |
||||||
|
- 将状态管理放在 `stores/` 目录 |
||||||
|
- 将验证规则放在 `schemas/` 目录 |
||||||
|
|
||||||
|
2. **性能优化** |
||||||
|
- 使用 `_.debounce` 和 `_.throttle` 优化频繁触发的事件 |
||||||
|
- 使用 Zustand 的选择器避免不必要的重渲染 |
||||||
|
- 使用 Expo Image 的缓存策略 |
||||||
|
|
||||||
|
3. **类型安全** |
||||||
|
- 使用 Zod 定义数据结构并自动生成 TypeScript 类型 |
||||||
|
- 为 Zustand store 定义完整的类型 |
||||||
|
- 使用 `@types/lodash` 获得完整的类型提示 |
||||||
|
|
||||||
|
4. **错误处理** |
||||||
|
- 在 Axios 拦截器中统一处理错误 |
||||||
|
- 使用 try-catch 包裹异步操作 |
||||||
|
- 提供用户友好的错误提示 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 🔗 相关资源 |
||||||
|
|
||||||
|
- [Lodash 文档](https://lodash.com/docs/) |
||||||
|
- [Day.js 文档](https://day.js.org/) |
||||||
|
- [Axios 文档](https://axios-http.com/) |
||||||
|
- [Zustand 文档](https://zustand-demo.pmnd.rs/) |
||||||
|
- [React Hook Form 文档](https://react-hook-form.com/) |
||||||
|
- [Zod 文档](https://zod.dev/) |
||||||
|
- [AsyncStorage 文档](https://react-native-async-storage.github.io/async-storage/) |
||||||
|
- [Expo Image 文档](https://docs.expo.dev/versions/latest/sdk/image/) |
||||||
|
- [Expo Haptics 文档](https://docs.expo.dev/versions/latest/sdk/haptics/) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
**提示**:所有库都已安装并配置好,可以直接在项目中使用!🎉 |
||||||
|
|
||||||
@ -0,0 +1,429 @@ |
|||||||
|
# 💡 使用示例 |
||||||
|
|
||||||
|
本文档提供项目中各个工具和模块的实际使用示例。 |
||||||
|
|
||||||
|
## 📋 目录 |
||||||
|
|
||||||
|
- [登录功能示例](#登录功能示例) |
||||||
|
- [用户资料更新示例](#用户资料更新示例) |
||||||
|
- [搜索功能示例](#搜索功能示例) |
||||||
|
- [设置页面示例](#设置页面示例) |
||||||
|
- [列表加载示例](#列表加载示例) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 登录功能示例 |
||||||
|
|
||||||
|
完整的登录页面实现,包含表单验证、API 调用、状态管理。 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import React, { useState } from 'react'; |
||||||
|
import { View, TextInput, TouchableOpacity, Text, Alert } from 'react-native'; |
||||||
|
import { useForm, Controller } from 'react-hook-form'; |
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'; |
||||||
|
import { useRouter } from 'expo-router'; |
||||||
|
import { |
||||||
|
loginSchema, |
||||||
|
type LoginFormData, |
||||||
|
authService, |
||||||
|
useUserStore, |
||||||
|
useHaptics, |
||||||
|
} from '@/src'; |
||||||
|
|
||||||
|
export default function LoginScreen() { |
||||||
|
const router = useRouter(); |
||||||
|
const haptics = useHaptics(); |
||||||
|
const login = useUserStore((state) => state.login); |
||||||
|
const [loading, setLoading] = useState(false); |
||||||
|
|
||||||
|
// 表单配置 |
||||||
|
const { |
||||||
|
control, |
||||||
|
handleSubmit, |
||||||
|
formState: { errors }, |
||||||
|
} = useForm<LoginFormData>({ |
||||||
|
resolver: zodResolver(loginSchema), |
||||||
|
defaultValues: { |
||||||
|
email: '', |
||||||
|
password: '', |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
// 提交处理 |
||||||
|
const onSubmit = async (data: LoginFormData) => { |
||||||
|
try { |
||||||
|
setLoading(true); |
||||||
|
|
||||||
|
// 调用登录 API |
||||||
|
const { user, token } = await authService.login(data); |
||||||
|
|
||||||
|
// 更新状态 |
||||||
|
login(user, token); |
||||||
|
|
||||||
|
// 触觉反馈 |
||||||
|
haptics.success(); |
||||||
|
|
||||||
|
// 跳转到首页 |
||||||
|
router.replace('/(tabs)'); |
||||||
|
} catch (error: any) { |
||||||
|
haptics.error(); |
||||||
|
Alert.alert('登录失败', error.message || '请检查邮箱和密码'); |
||||||
|
} finally { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<View style={{ padding: 20 }}> |
||||||
|
{/* 邮箱输入 */} |
||||||
|
<Controller |
||||||
|
control={control} |
||||||
|
name="email" |
||||||
|
render={({ field: { onChange, value } }) => ( |
||||||
|
<View> |
||||||
|
<TextInput |
||||||
|
value={value} |
||||||
|
onChangeText={onChange} |
||||||
|
placeholder="邮箱" |
||||||
|
keyboardType="email-address" |
||||||
|
autoCapitalize="none" |
||||||
|
style={{ |
||||||
|
borderWidth: 1, |
||||||
|
borderColor: errors.email ? 'red' : '#ccc', |
||||||
|
padding: 10, |
||||||
|
borderRadius: 5, |
||||||
|
}} |
||||||
|
/> |
||||||
|
{errors.email && ( |
||||||
|
<Text style={{ color: 'red', marginTop: 5 }}> |
||||||
|
{errors.email.message} |
||||||
|
</Text> |
||||||
|
)} |
||||||
|
</View> |
||||||
|
)} |
||||||
|
/> |
||||||
|
|
||||||
|
{/* 密码输入 */} |
||||||
|
<Controller |
||||||
|
control={control} |
||||||
|
name="password" |
||||||
|
render={({ field: { onChange, value } }) => ( |
||||||
|
<View style={{ marginTop: 15 }}> |
||||||
|
<TextInput |
||||||
|
value={value} |
||||||
|
onChangeText={onChange} |
||||||
|
placeholder="密码" |
||||||
|
secureTextEntry |
||||||
|
style={{ |
||||||
|
borderWidth: 1, |
||||||
|
borderColor: errors.password ? 'red' : '#ccc', |
||||||
|
padding: 10, |
||||||
|
borderRadius: 5, |
||||||
|
}} |
||||||
|
/> |
||||||
|
{errors.password && ( |
||||||
|
<Text style={{ color: 'red', marginTop: 5 }}> |
||||||
|
{errors.password.message} |
||||||
|
</Text> |
||||||
|
)} |
||||||
|
</View> |
||||||
|
)} |
||||||
|
/> |
||||||
|
|
||||||
|
{/* 登录按钮 */} |
||||||
|
<TouchableOpacity |
||||||
|
onPress={handleSubmit(onSubmit)} |
||||||
|
disabled={loading} |
||||||
|
style={{ |
||||||
|
backgroundColor: loading ? '#ccc' : '#007AFF', |
||||||
|
padding: 15, |
||||||
|
borderRadius: 5, |
||||||
|
marginTop: 20, |
||||||
|
}} |
||||||
|
> |
||||||
|
<Text style={{ color: 'white', textAlign: 'center', fontWeight: 'bold' }}> |
||||||
|
{loading ? '登录中...' : '登录'} |
||||||
|
</Text> |
||||||
|
</TouchableOpacity> |
||||||
|
</View> |
||||||
|
); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 用户资料更新示例 |
||||||
|
|
||||||
|
使用表单验证和 API 服务更新用户资料。 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import React, { useState } from 'react'; |
||||||
|
import { View, TextInput, TouchableOpacity, Text, Alert } from 'react-native'; |
||||||
|
import { useForm, Controller } from 'react-hook-form'; |
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'; |
||||||
|
import { |
||||||
|
updateProfileSchema, |
||||||
|
type UpdateProfileFormData, |
||||||
|
userService, |
||||||
|
useUserStore, |
||||||
|
useHaptics, |
||||||
|
} from '@/src'; |
||||||
|
|
||||||
|
export default function EditProfileScreen() { |
||||||
|
const haptics = useHaptics(); |
||||||
|
const user = useUserStore((state) => state.user); |
||||||
|
const updateUser = useUserStore((state) => state.updateUser); |
||||||
|
const [loading, setLoading] = useState(false); |
||||||
|
|
||||||
|
const { |
||||||
|
control, |
||||||
|
handleSubmit, |
||||||
|
formState: { errors }, |
||||||
|
} = useForm<UpdateProfileFormData>({ |
||||||
|
resolver: zodResolver(updateProfileSchema), |
||||||
|
defaultValues: { |
||||||
|
nickname: user?.nickname || '', |
||||||
|
phone: user?.phone || '', |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
const onSubmit = async (data: UpdateProfileFormData) => { |
||||||
|
try { |
||||||
|
setLoading(true); |
||||||
|
|
||||||
|
// 调用更新 API |
||||||
|
const updatedUser = await userService.updateProfile(data); |
||||||
|
|
||||||
|
// 更新本地状态 |
||||||
|
updateUser(updatedUser); |
||||||
|
|
||||||
|
haptics.success(); |
||||||
|
Alert.alert('成功', '资料更新成功'); |
||||||
|
} catch (error: any) { |
||||||
|
haptics.error(); |
||||||
|
Alert.alert('失败', error.message || '更新失败'); |
||||||
|
} finally { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<View style={{ padding: 20 }}> |
||||||
|
<Controller |
||||||
|
control={control} |
||||||
|
name="nickname" |
||||||
|
render={({ field: { onChange, value } }) => ( |
||||||
|
<View> |
||||||
|
<Text>昵称</Text> |
||||||
|
<TextInput |
||||||
|
value={value} |
||||||
|
onChangeText={onChange} |
||||||
|
placeholder="请输入昵称" |
||||||
|
style={{ borderWidth: 1, padding: 10, marginTop: 5 }} |
||||||
|
/> |
||||||
|
{errors.nickname && ( |
||||||
|
<Text style={{ color: 'red' }}>{errors.nickname.message}</Text> |
||||||
|
)} |
||||||
|
</View> |
||||||
|
)} |
||||||
|
/> |
||||||
|
|
||||||
|
<TouchableOpacity |
||||||
|
onPress={handleSubmit(onSubmit)} |
||||||
|
disabled={loading} |
||||||
|
style={{ |
||||||
|
backgroundColor: '#007AFF', |
||||||
|
padding: 15, |
||||||
|
marginTop: 20, |
||||||
|
borderRadius: 5, |
||||||
|
}} |
||||||
|
> |
||||||
|
<Text style={{ color: 'white', textAlign: 'center' }}> |
||||||
|
{loading ? '保存中...' : '保存'} |
||||||
|
</Text> |
||||||
|
</TouchableOpacity> |
||||||
|
</View> |
||||||
|
); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 搜索功能示例 |
||||||
|
|
||||||
|
使用防抖优化搜索性能。 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import React, { useState, useEffect } from 'react'; |
||||||
|
import { View, TextInput, FlatList, Text } from 'react-native'; |
||||||
|
import { useDebounce } from '@/src'; |
||||||
|
|
||||||
|
export default function SearchScreen() { |
||||||
|
const [searchText, setSearchText] = useState(''); |
||||||
|
const [results, setResults] = useState<any[]>([]); |
||||||
|
const [loading, setLoading] = useState(false); |
||||||
|
|
||||||
|
// 防抖搜索函数 |
||||||
|
const debouncedSearch = useDebounce(async (text: string) => { |
||||||
|
if (!text.trim()) { |
||||||
|
setResults([]); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
setLoading(true); |
||||||
|
// 调用搜索 API |
||||||
|
const response = await fetch(`/api/search?q=${text}`); |
||||||
|
const data = await response.json(); |
||||||
|
setResults(data.results); |
||||||
|
} catch (error) { |
||||||
|
console.error('Search error:', error); |
||||||
|
} finally { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
}, 500); |
||||||
|
|
||||||
|
// 监听搜索文本变化 |
||||||
|
useEffect(() => { |
||||||
|
debouncedSearch(searchText); |
||||||
|
}, [searchText]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<View style={{ flex: 1, padding: 20 }}> |
||||||
|
<TextInput |
||||||
|
value={searchText} |
||||||
|
onChangeText={setSearchText} |
||||||
|
placeholder="搜索..." |
||||||
|
style={{ |
||||||
|
borderWidth: 1, |
||||||
|
borderColor: '#ccc', |
||||||
|
padding: 10, |
||||||
|
borderRadius: 5, |
||||||
|
}} |
||||||
|
/> |
||||||
|
|
||||||
|
{loading && <Text style={{ marginTop: 10 }}>搜索中...</Text>} |
||||||
|
|
||||||
|
<FlatList |
||||||
|
data={results} |
||||||
|
keyExtractor={(item) => item.id} |
||||||
|
renderItem={({ item }) => ( |
||||||
|
<View style={{ padding: 10, borderBottomWidth: 1 }}> |
||||||
|
<Text>{item.title}</Text> |
||||||
|
</View> |
||||||
|
)} |
||||||
|
style={{ marginTop: 20 }} |
||||||
|
/> |
||||||
|
</View> |
||||||
|
); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 设置页面示例 |
||||||
|
|
||||||
|
使用状态管理和触觉反馈。 |
||||||
|
|
||||||
|
```typescript |
||||||
|
import React from 'react'; |
||||||
|
import { View, Text, Switch, TouchableOpacity } from 'react-native'; |
||||||
|
import { |
||||||
|
useSettingsStore, |
||||||
|
useHaptics, |
||||||
|
type Theme, |
||||||
|
} from '@/src'; |
||||||
|
|
||||||
|
export default function SettingsScreen() { |
||||||
|
const haptics = useHaptics(); |
||||||
|
const theme = useSettingsStore((state) => state.theme); |
||||||
|
const notificationsEnabled = useSettingsStore((state) => state.notificationsEnabled); |
||||||
|
const hapticsEnabled = useSettingsStore((state) => state.hapticsEnabled); |
||||||
|
|
||||||
|
const setTheme = useSettingsStore((state) => state.setTheme); |
||||||
|
const setNotificationsEnabled = useSettingsStore((state) => state.setNotificationsEnabled); |
||||||
|
const setHapticsEnabled = useSettingsStore((state) => state.setHapticsEnabled); |
||||||
|
|
||||||
|
const handleThemeChange = (newTheme: Theme) => { |
||||||
|
haptics.selection(); |
||||||
|
setTheme(newTheme); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleToggle = (setter: (value: boolean) => void, value: boolean) => { |
||||||
|
haptics.light(); |
||||||
|
setter(value); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<View style={{ flex: 1, padding: 20 }}> |
||||||
|
{/* 主题选择 */} |
||||||
|
<View style={{ marginBottom: 20 }}> |
||||||
|
<Text style={{ fontSize: 18, fontWeight: 'bold', marginBottom: 10 }}> |
||||||
|
主题 |
||||||
|
</Text> |
||||||
|
{(['light', 'dark', 'auto'] as Theme[]).map((t) => ( |
||||||
|
<TouchableOpacity |
||||||
|
key={t} |
||||||
|
onPress={() => handleThemeChange(t)} |
||||||
|
style={{ |
||||||
|
padding: 15, |
||||||
|
backgroundColor: theme === t ? '#007AFF' : '#f0f0f0', |
||||||
|
marginBottom: 10, |
||||||
|
borderRadius: 5, |
||||||
|
}} |
||||||
|
> |
||||||
|
<Text style={{ color: theme === t ? 'white' : 'black' }}> |
||||||
|
{t === 'light' ? '亮色' : t === 'dark' ? '暗色' : '自动'} |
||||||
|
</Text> |
||||||
|
</TouchableOpacity> |
||||||
|
))} |
||||||
|
</View> |
||||||
|
|
||||||
|
{/* 通知开关 */} |
||||||
|
<View |
||||||
|
style={{ |
||||||
|
flexDirection: 'row', |
||||||
|
justifyContent: 'space-between', |
||||||
|
alignItems: 'center', |
||||||
|
marginBottom: 15, |
||||||
|
}} |
||||||
|
> |
||||||
|
<Text>通知</Text> |
||||||
|
<Switch |
||||||
|
value={notificationsEnabled} |
||||||
|
onValueChange={(value) => handleToggle(setNotificationsEnabled, value)} |
||||||
|
/> |
||||||
|
</View> |
||||||
|
|
||||||
|
{/* 触觉反馈开关 */} |
||||||
|
<View |
||||||
|
style={{ |
||||||
|
flexDirection: 'row', |
||||||
|
justifyContent: 'space-between', |
||||||
|
alignItems: 'center', |
||||||
|
}} |
||||||
|
> |
||||||
|
<Text>触觉反馈</Text> |
||||||
|
<Switch |
||||||
|
value={hapticsEnabled} |
||||||
|
onValueChange={(value) => handleToggle(setHapticsEnabled, value)} |
||||||
|
/> |
||||||
|
</View> |
||||||
|
</View> |
||||||
|
); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 📚 更多示例 |
||||||
|
|
||||||
|
查看以下文档了解更多: |
||||||
|
|
||||||
|
- [工具库使用指南](./LIBRARIES.md) - 各个工具库的详细用法 |
||||||
|
- [项目结构说明](./PROJECT_STRUCTURE.md) - 项目结构和最佳实践 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
**提示**:这些示例都是可以直接使用的代码,复制到你的项目中即可! |
||||||
|
|
||||||
@ -0,0 +1,23 @@ |
|||||||
|
{ |
||||||
|
"cli": { |
||||||
|
"version": ">= 13.2.0" |
||||||
|
}, |
||||||
|
"build": { |
||||||
|
"development": { |
||||||
|
"developmentClient": true, |
||||||
|
"distribution": "internal", |
||||||
|
"channel": "development" |
||||||
|
}, |
||||||
|
"preview": { |
||||||
|
"distribution": "internal", |
||||||
|
"channel": "preview" |
||||||
|
}, |
||||||
|
"production": { |
||||||
|
"channel": "production" |
||||||
|
} |
||||||
|
}, |
||||||
|
"submit": { |
||||||
|
"production": {} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,49 @@ |
|||||||
|
{ |
||||||
|
"name": "rn-demo", |
||||||
|
"main": "expo-router/entry", |
||||||
|
"version": "1.0.0", |
||||||
|
"scripts": { |
||||||
|
"start": "expo start", |
||||||
|
"android": "expo start --android", |
||||||
|
"ios": "expo start --ios", |
||||||
|
"web": "expo start --web" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@expo/vector-icons": "^15.0.3", |
||||||
|
"@hookform/resolvers": "^5.2.2", |
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0", |
||||||
|
"@react-navigation/native": "^7.1.8", |
||||||
|
"axios": "^1.13.1", |
||||||
|
"dayjs": "^1.11.19", |
||||||
|
"expo": "~54.0.22", |
||||||
|
"expo-constants": "~18.0.10", |
||||||
|
"expo-font": "~14.0.9", |
||||||
|
"expo-haptics": "^15.0.7", |
||||||
|
"expo-image": "^3.0.10", |
||||||
|
"expo-linking": "~8.0.8", |
||||||
|
"expo-router": "~6.0.14", |
||||||
|
"expo-splash-screen": "~31.0.10", |
||||||
|
"expo-status-bar": "~3.0.8", |
||||||
|
"expo-updates": "^29.0.12", |
||||||
|
"expo-web-browser": "~15.0.9", |
||||||
|
"lodash-es": "^4.17.21", |
||||||
|
"react": "19.1.0", |
||||||
|
"react-dom": "19.1.0", |
||||||
|
"react-hook-form": "^7.66.0", |
||||||
|
"react-native": "0.81.5", |
||||||
|
"react-native-reanimated": "~4.1.1", |
||||||
|
"react-native-safe-area-context": "~5.6.0", |
||||||
|
"react-native-screens": "~4.16.0", |
||||||
|
"react-native-web": "~0.21.0", |
||||||
|
"react-native-worklets": "0.5.1", |
||||||
|
"zod": "^4.1.12", |
||||||
|
"zustand": "^5.0.8" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@types/lodash-es": "^4.17.12", |
||||||
|
"@types/react": "~19.1.0", |
||||||
|
"react-test-renderer": "19.1.0", |
||||||
|
"typescript": "~5.9.2" |
||||||
|
}, |
||||||
|
"private": true |
||||||
|
} |
||||||
@ -0,0 +1,84 @@ |
|||||||
|
/** |
||||||
|
* 防抖 Hook |
||||||
|
* 使用 lodash-es 的 debounce 函数 |
||||||
|
*/ |
||||||
|
import React, { useEffect, useMemo, useRef } from 'react'; |
||||||
|
import { debounce } from 'lodash-es'; |
||||||
|
|
||||||
|
/** |
||||||
|
* 防抖值 Hook |
||||||
|
* @param value 需要防抖的值 |
||||||
|
* @param delay 延迟时间(毫秒) |
||||||
|
* @returns 防抖后的值 |
||||||
|
*/ |
||||||
|
export function useDebounceValue<T>(value: T, delay: number = 300): T { |
||||||
|
const [debouncedValue, setDebouncedValue] = React.useState<T>(value); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const handler = setTimeout(() => { |
||||||
|
setDebouncedValue(value); |
||||||
|
}, delay); |
||||||
|
|
||||||
|
return () => { |
||||||
|
clearTimeout(handler); |
||||||
|
}; |
||||||
|
}, [value, delay]); |
||||||
|
|
||||||
|
return debouncedValue; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 防抖函数 Hook |
||||||
|
* @param callback 需要防抖的函数 |
||||||
|
* @param delay 延迟时间(毫秒) |
||||||
|
* @returns 防抖后的函数 |
||||||
|
*/ |
||||||
|
export function useDebounce<T extends (...args: any[]) => any>( |
||||||
|
callback: T, |
||||||
|
delay: number = 300 |
||||||
|
): T { |
||||||
|
const callbackRef = useRef(callback); |
||||||
|
|
||||||
|
// 更新 callback ref
|
||||||
|
useEffect(() => { |
||||||
|
callbackRef.current = callback; |
||||||
|
}, [callback]); |
||||||
|
|
||||||
|
// 创建防抖函数
|
||||||
|
const debouncedCallback = useMemo(() => { |
||||||
|
const func = (...args: Parameters<T>) => { |
||||||
|
callbackRef.current(...args); |
||||||
|
}; |
||||||
|
|
||||||
|
return debounce(func, delay); |
||||||
|
}, [delay]); |
||||||
|
|
||||||
|
// 清理
|
||||||
|
useEffect(() => { |
||||||
|
return () => { |
||||||
|
debouncedCallback.cancel(); |
||||||
|
}; |
||||||
|
}, [debouncedCallback]); |
||||||
|
|
||||||
|
return debouncedCallback as T; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 使用示例: |
||||||
|
* |
||||||
|
* // 防抖值
|
||||||
|
* const [searchText, setSearchText] = useState(''); |
||||||
|
* const debouncedSearchText = useDebounceValue(searchText, 500); |
||||||
|
* |
||||||
|
* useEffect(() => { |
||||||
|
* // 使用防抖后的值进行搜索
|
||||||
|
* search(debouncedSearchText); |
||||||
|
* }, [debouncedSearchText]); |
||||||
|
* |
||||||
|
* // 防抖函数
|
||||||
|
* const handleSearch = useDebounce((text: string) => { |
||||||
|
* console.log('Searching:', text); |
||||||
|
* }, 500); |
||||||
|
*/ |
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,114 @@ |
|||||||
|
/** |
||||||
|
* 触觉反馈 Hook |
||||||
|
* 封装 Expo Haptics 功能 |
||||||
|
*/ |
||||||
|
|
||||||
|
import { useCallback } from 'react'; |
||||||
|
import * as Haptics from 'expo-haptics'; |
||||||
|
import { useHapticsEnabled } from '@/src/stores/settingsStore'; |
||||||
|
|
||||||
|
/** |
||||||
|
* 触觉反馈 Hook |
||||||
|
* 根据用户设置决定是否触发触觉反馈 |
||||||
|
*/ |
||||||
|
export function useHaptics() { |
||||||
|
const hapticsEnabled = useHapticsEnabled(); |
||||||
|
|
||||||
|
/** |
||||||
|
* 轻触反馈 |
||||||
|
*/ |
||||||
|
const light = useCallback(async () => { |
||||||
|
if (hapticsEnabled) { |
||||||
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); |
||||||
|
} |
||||||
|
}, [hapticsEnabled]); |
||||||
|
|
||||||
|
/** |
||||||
|
* 中等反馈 |
||||||
|
*/ |
||||||
|
const medium = useCallback(async () => { |
||||||
|
if (hapticsEnabled) { |
||||||
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); |
||||||
|
} |
||||||
|
}, [hapticsEnabled]); |
||||||
|
|
||||||
|
/** |
||||||
|
* 重触反馈 |
||||||
|
*/ |
||||||
|
const heavy = useCallback(async () => { |
||||||
|
if (hapticsEnabled) { |
||||||
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); |
||||||
|
} |
||||||
|
}, [hapticsEnabled]); |
||||||
|
|
||||||
|
/** |
||||||
|
* 成功反馈 |
||||||
|
*/ |
||||||
|
const success = useCallback(async () => { |
||||||
|
if (hapticsEnabled) { |
||||||
|
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); |
||||||
|
} |
||||||
|
}, [hapticsEnabled]); |
||||||
|
|
||||||
|
/** |
||||||
|
* 警告反馈 |
||||||
|
*/ |
||||||
|
const warning = useCallback(async () => { |
||||||
|
if (hapticsEnabled) { |
||||||
|
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); |
||||||
|
} |
||||||
|
}, [hapticsEnabled]); |
||||||
|
|
||||||
|
/** |
||||||
|
* 错误反馈 |
||||||
|
*/ |
||||||
|
const error = useCallback(async () => { |
||||||
|
if (hapticsEnabled) { |
||||||
|
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); |
||||||
|
} |
||||||
|
}, [hapticsEnabled]); |
||||||
|
|
||||||
|
/** |
||||||
|
* 选择反馈 |
||||||
|
*/ |
||||||
|
const selection = useCallback(async () => { |
||||||
|
if (hapticsEnabled) { |
||||||
|
await Haptics.selectionAsync(); |
||||||
|
} |
||||||
|
}, [hapticsEnabled]); |
||||||
|
|
||||||
|
return { |
||||||
|
light, |
||||||
|
medium, |
||||||
|
heavy, |
||||||
|
success, |
||||||
|
warning, |
||||||
|
error, |
||||||
|
selection, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 使用示例: |
||||||
|
*
|
||||||
|
* function MyComponent() { |
||||||
|
* const haptics = useHaptics(); |
||||||
|
*
|
||||||
|
* const handlePress = () => { |
||||||
|
* haptics.light(); |
||||||
|
* // 执行其他操作
|
||||||
|
* }; |
||||||
|
*
|
||||||
|
* const handleSuccess = () => { |
||||||
|
* haptics.success(); |
||||||
|
* // 显示成功消息
|
||||||
|
* }; |
||||||
|
*
|
||||||
|
* return ( |
||||||
|
* <TouchableOpacity onPress={handlePress}> |
||||||
|
* <Text>Press me</Text> |
||||||
|
* </TouchableOpacity> |
||||||
|
* ); |
||||||
|
* } |
||||||
|
*/ |
||||||
|
|
||||||
@ -0,0 +1,61 @@ |
|||||||
|
/** |
||||||
|
* 节流 Hook |
||||||
|
* 使用 lodash-es 的 throttle 函数 |
||||||
|
*/ |
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef } from 'react'; |
||||||
|
import { throttle } from 'lodash-es'; |
||||||
|
|
||||||
|
/** |
||||||
|
* 节流函数 Hook |
||||||
|
* @param callback 需要节流的函数 |
||||||
|
* @param delay 延迟时间(毫秒) |
||||||
|
* @param options 节流选项 |
||||||
|
* @returns 节流后的函数 |
||||||
|
*/ |
||||||
|
export function useThrottle<T extends (...args: any[]) => any>( |
||||||
|
callback: T, |
||||||
|
delay: number = 300, |
||||||
|
options?: { |
||||||
|
leading?: boolean; |
||||||
|
trailing?: boolean; |
||||||
|
} |
||||||
|
): T { |
||||||
|
const callbackRef = useRef(callback); |
||||||
|
|
||||||
|
// 更新 callback ref
|
||||||
|
useEffect(() => { |
||||||
|
callbackRef.current = callback; |
||||||
|
}, [callback]); |
||||||
|
|
||||||
|
// 创建节流函数
|
||||||
|
const throttledCallback = useMemo(() => { |
||||||
|
const func = (...args: Parameters<T>) => { |
||||||
|
callbackRef.current(...args); |
||||||
|
}; |
||||||
|
|
||||||
|
return throttle(func, delay, options); |
||||||
|
}, [delay, options]); |
||||||
|
|
||||||
|
// 清理
|
||||||
|
useEffect(() => { |
||||||
|
return () => { |
||||||
|
throttledCallback.cancel(); |
||||||
|
}; |
||||||
|
}, [throttledCallback]); |
||||||
|
|
||||||
|
return throttledCallback as T; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 使用示例: |
||||||
|
*
|
||||||
|
* const handleScroll = useThrottle((event) => { |
||||||
|
* console.log('Scrolling:', event); |
||||||
|
* }, 200); |
||||||
|
*
|
||||||
|
* <ScrollView onScroll={handleScroll}> |
||||||
|
* ... |
||||||
|
* </ScrollView> |
||||||
|
*/ |
||||||
|
|
||||||
@ -0,0 +1,29 @@ |
|||||||
|
/** |
||||||
|
* 统一导出所有模块 |
||||||
|
*/ |
||||||
|
|
||||||
|
// Utils
|
||||||
|
export { default as api, request } from './utils/api'; |
||||||
|
export { default as Storage, STORAGE_KEYS } from './utils/storage'; |
||||||
|
export * from './utils/date'; |
||||||
|
|
||||||
|
// Stores
|
||||||
|
export * from './stores/userStore'; |
||||||
|
export * from './stores/settingsStore'; |
||||||
|
|
||||||
|
// Schemas
|
||||||
|
export * from './schemas/auth'; |
||||||
|
export * from './schemas/user'; |
||||||
|
|
||||||
|
// Services
|
||||||
|
export { default as authService } from './services/authService'; |
||||||
|
export { default as userService } from './services/userService'; |
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export * from './hooks/useDebounce'; |
||||||
|
export * from './hooks/useThrottle'; |
||||||
|
export * from './hooks/useHaptics'; |
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './types'; |
||||||
|
|
||||||
@ -0,0 +1,130 @@ |
|||||||
|
/** |
||||||
|
* 认证相关的 Zod 验证 Schema |
||||||
|
*/ |
||||||
|
|
||||||
|
import { z } from 'zod'; |
||||||
|
|
||||||
|
/** |
||||||
|
* 登录表单 Schema |
||||||
|
*/ |
||||||
|
export const loginSchema = z.object({ |
||||||
|
email: z |
||||||
|
.string() |
||||||
|
.min(1, '请输入邮箱') |
||||||
|
.email('请输入有效的邮箱地址'), |
||||||
|
password: z |
||||||
|
.string() |
||||||
|
.min(6, '密码至少6个字符') |
||||||
|
.max(20, '密码最多20个字符'), |
||||||
|
rememberMe: z.boolean().optional(), |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* 注册表单 Schema |
||||||
|
*/ |
||||||
|
export const registerSchema = z |
||||||
|
.object({ |
||||||
|
username: z |
||||||
|
.string() |
||||||
|
.min(3, '用户名至少3个字符') |
||||||
|
.max(20, '用户名最多20个字符') |
||||||
|
.regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线'), |
||||||
|
email: z |
||||||
|
.string() |
||||||
|
.min(1, '请输入邮箱') |
||||||
|
.email('请输入有效的邮箱地址'), |
||||||
|
password: z |
||||||
|
.string() |
||||||
|
.min(6, '密码至少6个字符') |
||||||
|
.max(20, '密码最多20个字符') |
||||||
|
.regex(/[A-Z]/, '密码必须包含至少一个大写字母') |
||||||
|
.regex(/[a-z]/, '密码必须包含至少一个小写字母') |
||||||
|
.regex(/[0-9]/, '密码必须包含至少一个数字'), |
||||||
|
confirmPassword: z.string().min(1, '请确认密码'), |
||||||
|
agreeToTerms: z.boolean().refine((val) => val === true, { |
||||||
|
message: '请同意服务条款', |
||||||
|
}), |
||||||
|
}) |
||||||
|
.refine((data) => data.password === data.confirmPassword, { |
||||||
|
message: '两次输入的密码不一致', |
||||||
|
path: ['confirmPassword'], |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* 忘记密码 Schema |
||||||
|
*/ |
||||||
|
export const forgotPasswordSchema = z.object({ |
||||||
|
email: z |
||||||
|
.string() |
||||||
|
.min(1, '请输入邮箱') |
||||||
|
.email('请输入有效的邮箱地址'), |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* 重置密码 Schema |
||||||
|
*/ |
||||||
|
export const resetPasswordSchema = z |
||||||
|
.object({ |
||||||
|
code: z |
||||||
|
.string() |
||||||
|
.min(6, '验证码为6位') |
||||||
|
.max(6, '验证码为6位') |
||||||
|
.regex(/^\d{6}$/, '验证码必须是6位数字'), |
||||||
|
password: z |
||||||
|
.string() |
||||||
|
.min(6, '密码至少6个字符') |
||||||
|
.max(20, '密码最多20个字符'), |
||||||
|
confirmPassword: z.string().min(1, '请确认密码'), |
||||||
|
}) |
||||||
|
.refine((data) => data.password === data.confirmPassword, { |
||||||
|
message: '两次输入的密码不一致', |
||||||
|
path: ['confirmPassword'], |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* 修改密码 Schema |
||||||
|
*/ |
||||||
|
export const changePasswordSchema = z |
||||||
|
.object({ |
||||||
|
oldPassword: z.string().min(1, '请输入当前密码'), |
||||||
|
newPassword: z |
||||||
|
.string() |
||||||
|
.min(6, '新密码至少6个字符') |
||||||
|
.max(20, '新密码最多20个字符'), |
||||||
|
confirmPassword: z.string().min(1, '请确认新密码'), |
||||||
|
}) |
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, { |
||||||
|
message: '两次输入的密码不一致', |
||||||
|
path: ['confirmPassword'], |
||||||
|
}) |
||||||
|
.refine((data) => data.oldPassword !== data.newPassword, { |
||||||
|
message: '新密码不能与当前密码相同', |
||||||
|
path: ['newPassword'], |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* 手机号登录 Schema |
||||||
|
*/ |
||||||
|
export const phoneLoginSchema = z.object({ |
||||||
|
phone: z |
||||||
|
.string() |
||||||
|
.min(11, '请输入11位手机号') |
||||||
|
.max(11, '请输入11位手机号') |
||||||
|
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'), |
||||||
|
code: z |
||||||
|
.string() |
||||||
|
.min(6, '验证码为6位') |
||||||
|
.max(6, '验证码为6位') |
||||||
|
.regex(/^\d{6}$/, '验证码必须是6位数字'), |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* TypeScript 类型推断 |
||||||
|
*/ |
||||||
|
export type LoginFormData = z.infer<typeof loginSchema>; |
||||||
|
export type RegisterFormData = z.infer<typeof registerSchema>; |
||||||
|
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>; |
||||||
|
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>; |
||||||
|
export type ChangePasswordFormData = z.infer<typeof changePasswordSchema>; |
||||||
|
export type PhoneLoginFormData = z.infer<typeof phoneLoginSchema>; |
||||||
|
|
||||||
@ -0,0 +1,76 @@ |
|||||||
|
/** |
||||||
|
* 用户相关的 Zod 验证 Schema |
||||||
|
*/ |
||||||
|
|
||||||
|
import { z } from 'zod'; |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户信息 Schema |
||||||
|
*/ |
||||||
|
export const userSchema = z.object({ |
||||||
|
id: z.string(), |
||||||
|
username: z.string(), |
||||||
|
email: z.string().email(), |
||||||
|
avatar: z.string().url().optional(), |
||||||
|
nickname: z.string().optional(), |
||||||
|
phone: z.string().optional(), |
||||||
|
createdAt: z.string().optional(), |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* 更新用户资料 Schema |
||||||
|
*/ |
||||||
|
export const updateProfileSchema = z.object({ |
||||||
|
nickname: z |
||||||
|
.string() |
||||||
|
.min(2, '昵称至少2个字符') |
||||||
|
.max(20, '昵称最多20个字符') |
||||||
|
.optional(), |
||||||
|
avatar: z.string().url('请输入有效的头像URL').optional(), |
||||||
|
phone: z |
||||||
|
.string() |
||||||
|
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号') |
||||||
|
.optional() |
||||||
|
.or(z.literal('')), |
||||||
|
bio: z.string().max(200, '个人简介最多200个字符').optional(), |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* 绑定手机号 Schema |
||||||
|
*/ |
||||||
|
export const bindPhoneSchema = z.object({ |
||||||
|
phone: z |
||||||
|
.string() |
||||||
|
.min(11, '请输入11位手机号') |
||||||
|
.max(11, '请输入11位手机号') |
||||||
|
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'), |
||||||
|
code: z |
||||||
|
.string() |
||||||
|
.min(6, '验证码为6位') |
||||||
|
.max(6, '验证码为6位') |
||||||
|
.regex(/^\d{6}$/, '验证码必须是6位数字'), |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* 绑定邮箱 Schema |
||||||
|
*/ |
||||||
|
export const bindEmailSchema = z.object({ |
||||||
|
email: z |
||||||
|
.string() |
||||||
|
.min(1, '请输入邮箱') |
||||||
|
.email('请输入有效的邮箱地址'), |
||||||
|
code: z |
||||||
|
.string() |
||||||
|
.min(6, '验证码为6位') |
||||||
|
.max(6, '验证码为6位') |
||||||
|
.regex(/^\d{6}$/, '验证码必须是6位数字'), |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* TypeScript 类型推断 |
||||||
|
*/ |
||||||
|
export type User = z.infer<typeof userSchema>; |
||||||
|
export type UpdateProfileFormData = z.infer<typeof updateProfileSchema>; |
||||||
|
export type BindPhoneFormData = z.infer<typeof bindPhoneSchema>; |
||||||
|
export type BindEmailFormData = z.infer<typeof bindEmailSchema>; |
||||||
|
|
||||||
@ -0,0 +1,134 @@ |
|||||||
|
/** |
||||||
|
* 认证服务 |
||||||
|
* 处理登录、注册等认证相关的 API 请求 |
||||||
|
*/ |
||||||
|
|
||||||
|
import { request } from '@/src/utils/api'; |
||||||
|
import type { |
||||||
|
LoginFormData, |
||||||
|
RegisterFormData, |
||||||
|
ForgotPasswordFormData, |
||||||
|
ResetPasswordFormData, |
||||||
|
ChangePasswordFormData, |
||||||
|
PhoneLoginFormData, |
||||||
|
} from '@/src/schemas/auth'; |
||||||
|
import type { User } from '@/src/schemas/user'; |
||||||
|
|
||||||
|
/** |
||||||
|
* API 响应接口 |
||||||
|
*/ |
||||||
|
interface ApiResponse<T = any> { |
||||||
|
code: number; |
||||||
|
message: string; |
||||||
|
data: T; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 登录响应 |
||||||
|
*/ |
||||||
|
interface LoginResponse { |
||||||
|
user: User; |
||||||
|
token: string; |
||||||
|
refreshToken?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 认证服务类 |
||||||
|
*/ |
||||||
|
class AuthService { |
||||||
|
/** |
||||||
|
* 邮箱登录 |
||||||
|
*/ |
||||||
|
async login(data: LoginFormData): Promise<LoginResponse> { |
||||||
|
const response = await request.post<ApiResponse<LoginResponse>>( |
||||||
|
'/auth/login', |
||||||
|
data |
||||||
|
); |
||||||
|
return response.data; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 手机号登录 |
||||||
|
*/ |
||||||
|
async phoneLogin(data: PhoneLoginFormData): Promise<LoginResponse> { |
||||||
|
const response = await request.post<ApiResponse<LoginResponse>>( |
||||||
|
'/auth/phone-login', |
||||||
|
data |
||||||
|
); |
||||||
|
return response.data; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 注册 |
||||||
|
*/ |
||||||
|
async register(data: RegisterFormData): Promise<LoginResponse> { |
||||||
|
const response = await request.post<ApiResponse<LoginResponse>>( |
||||||
|
'/auth/register', |
||||||
|
data |
||||||
|
); |
||||||
|
return response.data; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 登出 |
||||||
|
*/ |
||||||
|
async logout(): Promise<void> { |
||||||
|
await request.post('/auth/logout'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 发送忘记密码邮件 |
||||||
|
*/ |
||||||
|
async forgotPassword(data: ForgotPasswordFormData): Promise<void> { |
||||||
|
await request.post('/auth/forgot-password', data); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 重置密码 |
||||||
|
*/ |
||||||
|
async resetPassword(data: ResetPasswordFormData): Promise<void> { |
||||||
|
await request.post('/auth/reset-password', data); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 修改密码 |
||||||
|
*/ |
||||||
|
async changePassword(data: ChangePasswordFormData): Promise<void> { |
||||||
|
await request.post('/auth/change-password', data); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 发送验证码 |
||||||
|
*/ |
||||||
|
async sendVerificationCode(phone: string): Promise<void> { |
||||||
|
await request.post('/auth/send-code', { phone }); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 刷新 token |
||||||
|
*/ |
||||||
|
async refreshToken(refreshToken: string): Promise<{ token: string }> { |
||||||
|
const response = await request.post<ApiResponse<{ token: string }>>( |
||||||
|
'/auth/refresh-token', |
||||||
|
{ refreshToken } |
||||||
|
); |
||||||
|
return response.data; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 验证 token 是否有效 |
||||||
|
*/ |
||||||
|
async verifyToken(): Promise<boolean> { |
||||||
|
try { |
||||||
|
await request.get('/auth/verify-token'); |
||||||
|
return true; |
||||||
|
} catch { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const authService = new AuthService(); |
||||||
|
export default authService; |
||||||
|
|
||||||
@ -0,0 +1,90 @@ |
|||||||
|
/** |
||||||
|
* 用户服务 |
||||||
|
* 处理用户信息相关的 API 请求 |
||||||
|
*/ |
||||||
|
|
||||||
|
import { request } from '@/src/utils/api'; |
||||||
|
import type { User, UpdateProfileFormData } from '@/src/schemas/user'; |
||||||
|
|
||||||
|
/** |
||||||
|
* API 响应接口 |
||||||
|
*/ |
||||||
|
interface ApiResponse<T = any> { |
||||||
|
code: number; |
||||||
|
message: string; |
||||||
|
data: T; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户服务类 |
||||||
|
*/ |
||||||
|
class UserService { |
||||||
|
/** |
||||||
|
* 获取当前用户信息 |
||||||
|
*/ |
||||||
|
async getCurrentUser(): Promise<User> { |
||||||
|
const response = await request.get<ApiResponse<User>>('/user/me'); |
||||||
|
return response.data; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取用户信息(通过 ID) |
||||||
|
*/ |
||||||
|
async getUserById(userId: string): Promise<User> { |
||||||
|
const response = await request.get<ApiResponse<User>>(`/user/${userId}`); |
||||||
|
return response.data; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 更新用户资料 |
||||||
|
*/ |
||||||
|
async updateProfile(data: UpdateProfileFormData): Promise<User> { |
||||||
|
const response = await request.put<ApiResponse<User>>('/user/profile', data); |
||||||
|
return response.data; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 上传头像 |
||||||
|
*/ |
||||||
|
async uploadAvatar(file: File | Blob): Promise<{ url: string }> { |
||||||
|
const formData = new FormData(); |
||||||
|
formData.append('avatar', file); |
||||||
|
|
||||||
|
const response = await request.post<ApiResponse<{ url: string }>>( |
||||||
|
'/user/avatar', |
||||||
|
formData, |
||||||
|
{ |
||||||
|
headers: { |
||||||
|
'Content-Type': 'multipart/form-data', |
||||||
|
}, |
||||||
|
} |
||||||
|
); |
||||||
|
return response.data; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 绑定手机号 |
||||||
|
*/ |
||||||
|
async bindPhone(phone: string, code: string): Promise<void> { |
||||||
|
await request.post('/user/bind-phone', { phone, code }); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 绑定邮箱 |
||||||
|
*/ |
||||||
|
async bindEmail(email: string, code: string): Promise<void> { |
||||||
|
await request.post('/user/bind-email', { email, code }); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 注销账号 |
||||||
|
*/ |
||||||
|
async deleteAccount(): Promise<void> { |
||||||
|
await request.delete('/user/account'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const userService = new UserService(); |
||||||
|
export default userService; |
||||||
|
|
||||||
@ -0,0 +1,147 @@ |
|||||||
|
/** |
||||||
|
* 应用设置状态管理 |
||||||
|
* 使用 Zustand + AsyncStorage 持久化 |
||||||
|
*/ |
||||||
|
|
||||||
|
import { create } from 'zustand'; |
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware'; |
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'; |
||||||
|
|
||||||
|
/** |
||||||
|
* 主题类型 |
||||||
|
*/ |
||||||
|
export type Theme = 'light' | 'dark' | 'auto'; |
||||||
|
|
||||||
|
/** |
||||||
|
* 语言类型 |
||||||
|
*/ |
||||||
|
export type Language = 'zh-CN' | 'en-US'; |
||||||
|
|
||||||
|
/** |
||||||
|
* 设置状态接口 |
||||||
|
*/ |
||||||
|
interface SettingsState { |
||||||
|
// 状态
|
||||||
|
theme: Theme; |
||||||
|
language: Language; |
||||||
|
notificationsEnabled: boolean; |
||||||
|
soundEnabled: boolean; |
||||||
|
hapticsEnabled: boolean; |
||||||
|
|
||||||
|
// 操作
|
||||||
|
setTheme: (theme: Theme) => void; |
||||||
|
setLanguage: (language: Language) => void; |
||||||
|
setNotificationsEnabled: (enabled: boolean) => void; |
||||||
|
setSoundEnabled: (enabled: boolean) => void; |
||||||
|
setHapticsEnabled: (enabled: boolean) => void; |
||||||
|
resetSettings: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 默认设置 |
||||||
|
*/ |
||||||
|
const DEFAULT_SETTINGS = { |
||||||
|
theme: 'auto' as Theme, |
||||||
|
language: 'zh-CN' as Language, |
||||||
|
notificationsEnabled: true, |
||||||
|
soundEnabled: true, |
||||||
|
hapticsEnabled: true, |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 设置状态 Store |
||||||
|
*/ |
||||||
|
export const useSettingsStore = create<SettingsState>()( |
||||||
|
persist( |
||||||
|
(set) => ({ |
||||||
|
// 初始状态
|
||||||
|
...DEFAULT_SETTINGS, |
||||||
|
|
||||||
|
// 设置主题
|
||||||
|
setTheme: (theme) => { |
||||||
|
set({ theme }); |
||||||
|
if (__DEV__) { |
||||||
|
console.log('🎨 Theme changed:', theme); |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
// 设置语言
|
||||||
|
setLanguage: (language) => { |
||||||
|
set({ language }); |
||||||
|
if (__DEV__) { |
||||||
|
console.log('🌐 Language changed:', language); |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
// 设置通知开关
|
||||||
|
setNotificationsEnabled: (enabled) => { |
||||||
|
set({ notificationsEnabled: enabled }); |
||||||
|
if (__DEV__) { |
||||||
|
console.log('🔔 Notifications:', enabled ? 'enabled' : 'disabled'); |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
// 设置声音开关
|
||||||
|
setSoundEnabled: (enabled) => { |
||||||
|
set({ soundEnabled: enabled }); |
||||||
|
if (__DEV__) { |
||||||
|
console.log('🔊 Sound:', enabled ? 'enabled' : 'disabled'); |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
// 设置触觉反馈开关
|
||||||
|
setHapticsEnabled: (enabled) => { |
||||||
|
set({ hapticsEnabled: enabled }); |
||||||
|
if (__DEV__) { |
||||||
|
console.log('📳 Haptics:', enabled ? 'enabled' : 'disabled'); |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
// 重置所有设置
|
||||||
|
resetSettings: () => { |
||||||
|
set(DEFAULT_SETTINGS); |
||||||
|
if (__DEV__) { |
||||||
|
console.log('🔄 Settings reset to default'); |
||||||
|
} |
||||||
|
}, |
||||||
|
}), |
||||||
|
{ |
||||||
|
name: 'settings-storage', |
||||||
|
storage: createJSONStorage(() => AsyncStorage), |
||||||
|
} |
||||||
|
) |
||||||
|
); |
||||||
|
|
||||||
|
/** |
||||||
|
* 选择器 Hooks |
||||||
|
*/ |
||||||
|
|
||||||
|
// 获取主题
|
||||||
|
export const useTheme = () => useSettingsStore((state) => state.theme); |
||||||
|
|
||||||
|
// 获取语言
|
||||||
|
export const useLanguage = () => useSettingsStore((state) => state.language); |
||||||
|
|
||||||
|
// 获取通知状态
|
||||||
|
export const useNotificationsEnabled = () => |
||||||
|
useSettingsStore((state) => state.notificationsEnabled); |
||||||
|
|
||||||
|
// 获取声音状态
|
||||||
|
export const useSoundEnabled = () => |
||||||
|
useSettingsStore((state) => state.soundEnabled); |
||||||
|
|
||||||
|
// 获取触觉反馈状态
|
||||||
|
export const useHapticsEnabled = () => |
||||||
|
useSettingsStore((state) => state.hapticsEnabled); |
||||||
|
|
||||||
|
// 获取设置操作方法
|
||||||
|
export const useSettingsActions = () => |
||||||
|
useSettingsStore((state) => ({ |
||||||
|
setTheme: state.setTheme, |
||||||
|
setLanguage: state.setLanguage, |
||||||
|
setNotificationsEnabled: state.setNotificationsEnabled, |
||||||
|
setSoundEnabled: state.setSoundEnabled, |
||||||
|
setHapticsEnabled: state.setHapticsEnabled, |
||||||
|
resetSettings: state.resetSettings, |
||||||
|
})); |
||||||
|
|
||||||
@ -0,0 +1,142 @@ |
|||||||
|
/** |
||||||
|
* 用户状态管理 |
||||||
|
* 使用 Zustand + AsyncStorage 持久化 |
||||||
|
*/ |
||||||
|
|
||||||
|
import { create } from 'zustand'; |
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware'; |
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'; |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户信息接口 |
||||||
|
*/ |
||||||
|
export interface User { |
||||||
|
id: string; |
||||||
|
username: string; |
||||||
|
email: string; |
||||||
|
avatar?: string; |
||||||
|
nickname?: string; |
||||||
|
phone?: string; |
||||||
|
createdAt?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户状态接口 |
||||||
|
*/ |
||||||
|
interface UserState { |
||||||
|
// 状态
|
||||||
|
user: User | null; |
||||||
|
isLoggedIn: boolean; |
||||||
|
token: string | null; |
||||||
|
|
||||||
|
// 操作
|
||||||
|
setUser: (user: User) => void; |
||||||
|
setToken: (token: string) => void; |
||||||
|
login: (user: User, token: string) => void; |
||||||
|
logout: () => void; |
||||||
|
updateUser: (updates: Partial<User>) => void; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户状态 Store |
||||||
|
*/ |
||||||
|
export const useUserStore = create<UserState>()( |
||||||
|
persist( |
||||||
|
(set, get) => ({ |
||||||
|
// 初始状态
|
||||||
|
user: null, |
||||||
|
isLoggedIn: false, |
||||||
|
token: null, |
||||||
|
|
||||||
|
// 设置用户信息
|
||||||
|
setUser: (user) => { |
||||||
|
set({ user, isLoggedIn: true }); |
||||||
|
}, |
||||||
|
|
||||||
|
// 设置 token
|
||||||
|
setToken: (token) => { |
||||||
|
set({ token }); |
||||||
|
}, |
||||||
|
|
||||||
|
// 登录
|
||||||
|
login: (user, token) => { |
||||||
|
set({ |
||||||
|
user, |
||||||
|
token, |
||||||
|
isLoggedIn: true, |
||||||
|
}); |
||||||
|
|
||||||
|
// 同时保存 token 到 AsyncStorage(用于 API 请求)
|
||||||
|
AsyncStorage.setItem('auth_token', token); |
||||||
|
|
||||||
|
if (__DEV__) { |
||||||
|
console.log('✅ User logged in:', user.username); |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
// 登出
|
||||||
|
logout: () => { |
||||||
|
set({ |
||||||
|
user: null, |
||||||
|
token: null, |
||||||
|
isLoggedIn: false, |
||||||
|
}); |
||||||
|
|
||||||
|
// 清除 AsyncStorage 中的 token
|
||||||
|
AsyncStorage.removeItem('auth_token'); |
||||||
|
|
||||||
|
if (__DEV__) { |
||||||
|
console.log('👋 User logged out'); |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
updateUser: (updates) => { |
||||||
|
const currentUser = get().user; |
||||||
|
if (currentUser) { |
||||||
|
set({ |
||||||
|
user: { ...currentUser, ...updates }, |
||||||
|
}); |
||||||
|
|
||||||
|
if (__DEV__) { |
||||||
|
console.log('📝 User updated:', updates); |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
}), |
||||||
|
{ |
||||||
|
name: 'user-storage', // AsyncStorage 中的键名
|
||||||
|
storage: createJSONStorage(() => AsyncStorage), |
||||||
|
// 可以选择性地持久化某些字段
|
||||||
|
partialize: (state) => ({ |
||||||
|
user: state.user, |
||||||
|
token: state.token, |
||||||
|
isLoggedIn: state.isLoggedIn, |
||||||
|
}), |
||||||
|
} |
||||||
|
) |
||||||
|
); |
||||||
|
|
||||||
|
/** |
||||||
|
* 选择器 Hooks(优化性能,避免不必要的重渲染) |
||||||
|
*/ |
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
export const useUser = () => useUserStore((state) => state.user); |
||||||
|
|
||||||
|
// 获取登录状态
|
||||||
|
export const useIsLoggedIn = () => useUserStore((state) => state.isLoggedIn); |
||||||
|
|
||||||
|
// 获取 token
|
||||||
|
export const useToken = () => useUserStore((state) => state.token); |
||||||
|
|
||||||
|
// 获取用户操作方法
|
||||||
|
export const useUserActions = () => |
||||||
|
useUserStore((state) => ({ |
||||||
|
setUser: state.setUser, |
||||||
|
setToken: state.setToken, |
||||||
|
login: state.login, |
||||||
|
logout: state.logout, |
||||||
|
updateUser: state.updateUser, |
||||||
|
})); |
||||||
|
|
||||||
@ -0,0 +1,81 @@ |
|||||||
|
/** |
||||||
|
* 全局类型定义 |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* API 响应基础接口 |
||||||
|
*/ |
||||||
|
export interface ApiResponse<T = any> { |
||||||
|
code: number; |
||||||
|
message: string; |
||||||
|
data: T; |
||||||
|
timestamp?: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 分页参数 |
||||||
|
*/ |
||||||
|
export interface PaginationParams { |
||||||
|
page: number; |
||||||
|
pageSize: number; |
||||||
|
sortBy?: string; |
||||||
|
sortOrder?: 'asc' | 'desc'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 分页响应 |
||||||
|
*/ |
||||||
|
export interface PaginatedResponse<T> { |
||||||
|
items: T[]; |
||||||
|
total: number; |
||||||
|
page: number; |
||||||
|
pageSize: number; |
||||||
|
totalPages: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 错误响应 |
||||||
|
*/ |
||||||
|
export interface ErrorResponse { |
||||||
|
code: number; |
||||||
|
message: string; |
||||||
|
errors?: Record<string, string[]>; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 上传文件响应 |
||||||
|
*/ |
||||||
|
export interface UploadResponse { |
||||||
|
url: string; |
||||||
|
filename: string; |
||||||
|
size: number; |
||||||
|
mimeType: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 导航参数类型 |
||||||
|
*/ |
||||||
|
export type RootStackParamList = { |
||||||
|
Home: undefined; |
||||||
|
Profile: { userId: string }; |
||||||
|
Settings: undefined; |
||||||
|
Login: undefined; |
||||||
|
Register: undefined; |
||||||
|
// 添加更多路由...
|
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 环境变量类型 |
||||||
|
*/ |
||||||
|
declare global { |
||||||
|
namespace NodeJS { |
||||||
|
interface ProcessEnv { |
||||||
|
EXPO_PUBLIC_API_URL: string; |
||||||
|
EXPO_PUBLIC_APP_NAME: string; |
||||||
|
EXPO_PUBLIC_APP_VERSION: string; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export {}; |
||||||
|
|
||||||
@ -0,0 +1,144 @@ |
|||||||
|
/** |
||||||
|
* Axios API 配置 |
||||||
|
* 统一管理 HTTP 请求 |
||||||
|
*/ |
||||||
|
|
||||||
|
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; |
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'; |
||||||
|
|
||||||
|
// API 基础配置
|
||||||
|
const API_CONFIG = { |
||||||
|
baseURL: process.env.EXPO_PUBLIC_API_URL || 'https://api.example.com', |
||||||
|
timeout: 10000, |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const api = axios.create(API_CONFIG); |
||||||
|
|
||||||
|
/** |
||||||
|
* 请求拦截器 |
||||||
|
* 在请求发送前添加 token 等信息 |
||||||
|
*/ |
||||||
|
api.interceptors.request.use( |
||||||
|
async (config) => { |
||||||
|
try { |
||||||
|
// 从本地存储获取 token
|
||||||
|
const token = await AsyncStorage.getItem('auth_token'); |
||||||
|
|
||||||
|
if (token) { |
||||||
|
config.headers.Authorization = `Bearer ${token}`; |
||||||
|
} |
||||||
|
|
||||||
|
// 打印请求信息(开发环境)
|
||||||
|
if (__DEV__) { |
||||||
|
console.log('📤 API Request:', { |
||||||
|
method: config.method?.toUpperCase(), |
||||||
|
url: config.url, |
||||||
|
data: config.data, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return config; |
||||||
|
} catch (error) { |
||||||
|
console.error('Request interceptor error:', error); |
||||||
|
return config; |
||||||
|
} |
||||||
|
}, |
||||||
|
(error) => { |
||||||
|
console.error('Request error:', error); |
||||||
|
return Promise.reject(error); |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
/** |
||||||
|
* 响应拦截器 |
||||||
|
* 统一处理响应和错误 |
||||||
|
*/ |
||||||
|
api.interceptors.response.use( |
||||||
|
(response: AxiosResponse) => { |
||||||
|
// 打印响应信息(开发环境)
|
||||||
|
if (__DEV__) { |
||||||
|
console.log('📥 API Response:', { |
||||||
|
url: response.config.url, |
||||||
|
status: response.status, |
||||||
|
data: response.data, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// 返回响应数据
|
||||||
|
return response.data; |
||||||
|
}, |
||||||
|
async (error: AxiosError) => { |
||||||
|
// 打印错误信息
|
||||||
|
console.error('❌ API Error:', { |
||||||
|
url: error.config?.url, |
||||||
|
status: error.response?.status, |
||||||
|
message: error.message, |
||||||
|
data: error.response?.data, |
||||||
|
}); |
||||||
|
|
||||||
|
// 处理不同的错误状态码
|
||||||
|
if (error.response) { |
||||||
|
switch (error.response.status) { |
||||||
|
case 401: |
||||||
|
// 未授权,清除 token 并跳转到登录页
|
||||||
|
await AsyncStorage.removeItem('auth_token'); |
||||||
|
// TODO: 导航到登录页
|
||||||
|
// router.replace('/login');
|
||||||
|
break; |
||||||
|
|
||||||
|
case 403: |
||||||
|
// 禁止访问
|
||||||
|
console.error('Access forbidden'); |
||||||
|
break; |
||||||
|
|
||||||
|
case 404: |
||||||
|
// 资源不存在
|
||||||
|
console.error('Resource not found'); |
||||||
|
break; |
||||||
|
|
||||||
|
case 500: |
||||||
|
// 服务器错误
|
||||||
|
console.error('Server error'); |
||||||
|
break; |
||||||
|
|
||||||
|
default: |
||||||
|
console.error('Unknown error'); |
||||||
|
} |
||||||
|
} else if (error.request) { |
||||||
|
// 请求已发送但没有收到响应
|
||||||
|
console.error('No response received'); |
||||||
|
} else { |
||||||
|
// 请求配置出错
|
||||||
|
console.error('Request configuration error'); |
||||||
|
} |
||||||
|
|
||||||
|
return Promise.reject(error); |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
/** |
||||||
|
* 通用请求方法 |
||||||
|
*/ |
||||||
|
export const request = { |
||||||
|
get: <T = any>(url: string, config?: AxiosRequestConfig) =>
|
||||||
|
api.get<T, T>(url, config), |
||||||
|
|
||||||
|
post: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
|
||||||
|
api.post<T, T>(url, data, config), |
||||||
|
|
||||||
|
put: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
|
||||||
|
api.put<T, T>(url, data, config), |
||||||
|
|
||||||
|
delete: <T = any>(url: string, config?: AxiosRequestConfig) =>
|
||||||
|
api.delete<T, T>(url, config), |
||||||
|
|
||||||
|
patch: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
|
||||||
|
api.patch<T, T>(url, data, config), |
||||||
|
}; |
||||||
|
|
||||||
|
export default api; |
||||||
|
|
||||||
@ -0,0 +1,219 @@ |
|||||||
|
/** |
||||||
|
* Day.js 日期工具函数 |
||||||
|
* 统一管理日期格式化和处理 |
||||||
|
*/ |
||||||
|
|
||||||
|
import dayjs from 'dayjs'; |
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime'; |
||||||
|
import calendar from 'dayjs/plugin/calendar'; |
||||||
|
import duration from 'dayjs/plugin/duration'; |
||||||
|
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; |
||||||
|
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; |
||||||
|
import 'dayjs/locale/zh-cn'; |
||||||
|
|
||||||
|
// 扩展插件
|
||||||
|
dayjs.extend(relativeTime); |
||||||
|
dayjs.extend(calendar); |
||||||
|
dayjs.extend(duration); |
||||||
|
dayjs.extend(isSameOrBefore); |
||||||
|
dayjs.extend(isSameOrAfter); |
||||||
|
|
||||||
|
// 设置默认语言为中文
|
||||||
|
dayjs.locale('zh-cn'); |
||||||
|
|
||||||
|
/** |
||||||
|
* 日期格式常量 |
||||||
|
*/ |
||||||
|
export const DATE_FORMATS = { |
||||||
|
FULL: 'YYYY-MM-DD HH:mm:ss', |
||||||
|
DATE: 'YYYY-MM-DD', |
||||||
|
TIME: 'HH:mm:ss', |
||||||
|
DATE_TIME: 'YYYY-MM-DD HH:mm', |
||||||
|
MONTH_DAY: 'MM-DD', |
||||||
|
HOUR_MINUTE: 'HH:mm', |
||||||
|
YEAR_MONTH: 'YYYY-MM', |
||||||
|
CHINESE_DATE: 'YYYY年MM月DD日', |
||||||
|
CHINESE_FULL: 'YYYY年MM月DD日 HH:mm:ss', |
||||||
|
} as const; |
||||||
|
|
||||||
|
/** |
||||||
|
* 格式化日期 |
||||||
|
* @param date 日期(Date、时间戳或字符串) |
||||||
|
* @param format 格式(默认:YYYY-MM-DD HH:mm:ss) |
||||||
|
*/ |
||||||
|
export const formatDate = ( |
||||||
|
date: Date | string | number, |
||||||
|
format: string = DATE_FORMATS.FULL |
||||||
|
): string => { |
||||||
|
return dayjs(date).format(format); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 格式化为相对时间(如:3分钟前、2小时前) |
||||||
|
*/ |
||||||
|
export const formatRelativeTime = (date: Date | string | number): string => { |
||||||
|
return dayjs(date).fromNow(); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 格式化为日历时间(如:今天、昨天、上周) |
||||||
|
*/ |
||||||
|
export const formatCalendarTime = (date: Date | string | number): string => { |
||||||
|
return dayjs(date).calendar(null, { |
||||||
|
sameDay: '[今天] HH:mm', |
||||||
|
lastDay: '[昨天] HH:mm', |
||||||
|
lastWeek: 'MM-DD HH:mm', |
||||||
|
sameElse: 'YYYY-MM-DD HH:mm', |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 格式化聊天时间 |
||||||
|
* 今天:显示时间 |
||||||
|
* 昨天:显示"昨天 + 时间" |
||||||
|
* 本年:显示"月-日 时间" |
||||||
|
* 往年:显示"年-月-日" |
||||||
|
*/ |
||||||
|
export const formatChatTime = (timestamp: number | string | Date): string => { |
||||||
|
const date = dayjs(timestamp); |
||||||
|
const now = dayjs(); |
||||||
|
|
||||||
|
if (date.isSame(now, 'day')) { |
||||||
|
// 今天
|
||||||
|
return date.format('HH:mm'); |
||||||
|
} else if (date.isSame(now.subtract(1, 'day'), 'day')) { |
||||||
|
// 昨天
|
||||||
|
return '昨天 ' + date.format('HH:mm'); |
||||||
|
} else if (date.isSame(now, 'year')) { |
||||||
|
// 本年
|
||||||
|
return date.format('MM-DD HH:mm'); |
||||||
|
} else { |
||||||
|
// 往年
|
||||||
|
return date.format('YYYY-MM-DD'); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取时间差(返回对象) |
||||||
|
*/ |
||||||
|
export const getTimeDiff = ( |
||||||
|
startDate: Date | string | number, |
||||||
|
endDate: Date | string | number = new Date() |
||||||
|
) => { |
||||||
|
const start = dayjs(startDate); |
||||||
|
const end = dayjs(endDate); |
||||||
|
const diff = end.diff(start); |
||||||
|
|
||||||
|
const duration = dayjs.duration(diff); |
||||||
|
|
||||||
|
return { |
||||||
|
years: duration.years(), |
||||||
|
months: duration.months(), |
||||||
|
days: duration.days(), |
||||||
|
hours: duration.hours(), |
||||||
|
minutes: duration.minutes(), |
||||||
|
seconds: duration.seconds(), |
||||||
|
milliseconds: duration.milliseconds(), |
||||||
|
totalDays: Math.floor(duration.asDays()), |
||||||
|
totalHours: Math.floor(duration.asHours()), |
||||||
|
totalMinutes: Math.floor(duration.asMinutes()), |
||||||
|
totalSeconds: Math.floor(duration.asSeconds()), |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 判断是否是今天 |
||||||
|
*/ |
||||||
|
export const isToday = (date: Date | string | number): boolean => { |
||||||
|
return dayjs(date).isSame(dayjs(), 'day'); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 判断是否是昨天 |
||||||
|
*/ |
||||||
|
export const isYesterday = (date: Date | string | number): boolean => { |
||||||
|
return dayjs(date).isSame(dayjs().subtract(1, 'day'), 'day'); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 判断是否是本周 |
||||||
|
*/ |
||||||
|
export const isThisWeek = (date: Date | string | number): boolean => { |
||||||
|
return dayjs(date).isSame(dayjs(), 'week'); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 判断是否是本月 |
||||||
|
*/ |
||||||
|
export const isThisMonth = (date: Date | string | number): boolean => { |
||||||
|
return dayjs(date).isSame(dayjs(), 'month'); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 判断是否是本年 |
||||||
|
*/ |
||||||
|
export const isThisYear = (date: Date | string | number): boolean => { |
||||||
|
return dayjs(date).isSame(dayjs(), 'year'); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取日期范围的开始和结束 |
||||||
|
*/ |
||||||
|
export const getDateRange = (type: 'day' | 'week' | 'month' | 'year') => { |
||||||
|
const now = dayjs(); |
||||||
|
return { |
||||||
|
start: now.startOf(type).toDate(), |
||||||
|
end: now.endOf(type).toDate(), |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 添加时间 |
||||||
|
*/ |
||||||
|
export const addTime = ( |
||||||
|
date: Date | string | number, |
||||||
|
amount: number, |
||||||
|
unit: dayjs.ManipulateType |
||||||
|
): Date => { |
||||||
|
return dayjs(date).add(amount, unit).toDate(); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 减去时间 |
||||||
|
*/ |
||||||
|
export const subtractTime = ( |
||||||
|
date: Date | string | number, |
||||||
|
amount: number, |
||||||
|
unit: dayjs.ManipulateType |
||||||
|
): Date => { |
||||||
|
return dayjs(date).subtract(amount, unit).toDate(); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 判断日期是否在范围内 |
||||||
|
*/ |
||||||
|
export const isBetween = ( |
||||||
|
date: Date | string | number, |
||||||
|
startDate: Date | string | number, |
||||||
|
endDate: Date | string | number |
||||||
|
): boolean => { |
||||||
|
const target = dayjs(date); |
||||||
|
return target.isAfter(startDate) && target.isBefore(endDate); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取当前时间戳(毫秒) |
||||||
|
*/ |
||||||
|
export const now = (): number => { |
||||||
|
return dayjs().valueOf(); |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取当前时间戳(秒) |
||||||
|
*/ |
||||||
|
export const nowInSeconds = (): number => { |
||||||
|
return Math.floor(dayjs().valueOf() / 1000); |
||||||
|
}; |
||||||
|
|
||||||
|
export default dayjs; |
||||||
|
|
||||||
Loading…
Reference in new issue