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