diff --git a/.bunfig.toml b/.bunfig.toml new file mode 100644 index 0000000..7e51f12 --- /dev/null +++ b/.bunfig.toml @@ -0,0 +1,2 @@ +[install] +registry = "https://registry.npmmirror.com/" \ No newline at end of file diff --git a/.env b/.env index 53a6945..e69de29 100644 --- a/.env +++ b/.env @@ -1 +0,0 @@ -API_ENDPOINT=http://192.168.31.115:18080/api \ No newline at end of file diff --git a/.gitignore b/.gitignore index ff09c93..ff57f2b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ app-example # Expo prebuild generated files android/ ios/ + +build* \ No newline at end of file diff --git a/app.json b/app.json index 11a9aaa..3d3bfb3 100644 --- a/app.json +++ b/app.json @@ -11,14 +11,12 @@ "ios": { "supportsTablet": true, "infoPlist": { - "NSPhotoLibraryUsageDescription": "Allow $(PRODUCT_NAME) to access your photos.", - "NSPhotoLibraryAddUsageDescription": "需要保存图片到相册", - "NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location to get photo location data.", + "NSPhotoLibraryUsageDescription": "允许访问照片库,以便模型使用您照片库中的素材进行视频创作”(例如:上传您参加音乐节的现场图,生成一个音乐节体验Vlog", + "NSPhotoLibraryAddUsageDescription": "App需要访问相册来保存图片", + "NSLocationWhenInUseUsageDescription": "允许获取位置信息,以便模型使用您的位置信息进行个性化创作”(例如:上传您去欧洲旅游的位置信息,结合在当地拍摄的照片,生成一个欧洲旅行攻略Vlog)", "ITSAppUsesNonExemptEncryption": false, "UIBackgroundModes": [ - "fetch", - "location", - "audio" + "fetch" ] }, "bundleIdentifier": "com.memowake.app" @@ -34,13 +32,8 @@ "ACCESS_MEDIA_LOCATION", "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS", - "android.permission.READ_EXTERNAL_STORAGE", - "android.permission.WRITE_EXTERNAL_STORAGE", - "android.permission.ACCESS_MEDIA_LOCATION", "FOREGROUND_SERVICE", - "WAKE_LOCK", - "READ_EXTERNAL_STORAGE", - "WRITE_EXTERNAL_STORAGE" + "WAKE_LOCK" ], "edgeToEdgeEnabled": true, "package": "com.memowake.app" @@ -53,7 +46,7 @@ "plugins": [ "expo-router", "expo-secure-store", - [ + [ "expo-background-task", { "minimumInterval": 15 @@ -68,9 +61,7 @@ [ "expo-location", { - "locationAlwaysAndWhenInUsePermission": "允许 $(PRODUCT_NAME) 访问您的位置", - "locationAlwaysPermission": "允许 $(PRODUCT_NAME) 访问您的位置", - "locationWhenInUsePermission": "允许 $(PRODUCT_NAME) 访问您的位置" + "locationWhenInUsePermission": "允许 $(PRODUCT_NAME) 获取位置信息,以便使用您的位置信息进行个性化创作.(例如:上传您去欧洲旅游的位置信息,结合在当地拍摄的照片,生成一个欧洲旅行攻略Vlog)" } ], [ @@ -82,8 +73,8 @@ [ "expo-media-library", { - "photosPermission": "Allow $(PRODUCT_NAME) to access your photos.", - "savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.", + "photosPermission": "允许 $(PRODUCT_NAME) 访问照片库,以便我们使用您照片库中的素材进行视频创作。(例如:上传您参加音乐节的现场图,生成一个音乐节体验Vlog)", + "savePhotosPermission": "允许 $(PRODUCT_NAME) 保存媒体到照片库,以便保存您生成的视频。(例如:生成音乐节体验Vlog后,保存到您的相册)", "isAccessMediaLocationEnabled": true } ], @@ -93,6 +84,8 @@ "typedRoutes": true }, "extra": { + "API_ENDPOINT": "http://192.168.31.16:31646/api", + "WEBSOCKET_ENDPOINT": "ws://192.168.31.16:31646/ws/chat", "router": {}, "eas": { "projectId": "04721dd4-6b15-495a-b9ec-98187c613172" diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index a4ec795..6885cb6 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,9 +1,14 @@ import { HapticTab } from '@/components/HapticTab'; +import AskNavbar from '@/components/layout/ask'; import { TabBarIcon } from '@/components/navigation/TabBarIcon'; +import { requestNotificationPermission } from '@/components/owner/utils'; import TabBarBackground from '@/components/ui/TabBarBackground'; import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; +import { prefetchChats } from '@/lib/prefetch'; import { fetchApi } from '@/lib/server-api-util'; +import { webSocketManager, WebSocketStatus } from '@/lib/websocket-util'; +import { TransitionPresets } from '@react-navigation/bottom-tabs'; import * as Notifications from 'expo-notifications'; import { Tabs } from 'expo-router'; import * as SecureStore from 'expo-secure-store'; @@ -17,6 +22,7 @@ interface PollingData { content: string; extra: any; } + export default function TabLayout() { const { t } = useTranslation(); const colorScheme = useColorScheme(); @@ -25,11 +31,12 @@ export default function TabLayout() { const tokenInterval = useRef(null); const isMounted = useRef(true); const [token, setToken] = useState(''); + const [wsStatus, setWsStatus] = useState('disconnected'); const sendNotification = async (item: PollingData) => { // 请求通知权限 - const { status } = await Notifications.requestPermissionsAsync(); - if (status !== 'granted') { - alert('请先允许通知权限'); + const granted = await requestNotificationPermission(); + if (!granted) { + console.log('用户拒绝了通知权限'); return; } @@ -62,6 +69,16 @@ export default function TabLayout() { }; }, []); + useEffect(() => { + const handleStatusChange = (status: WebSocketStatus) => { + setWsStatus(status); + }; + webSocketManager.subscribeStatus(handleStatusChange); + return () => { + webSocketManager.unsubscribeStatus(handleStatusChange); + }; + }, []); + // 轮询获取推送消息 const startPolling = useCallback(async (interval: number = 5000) => { @@ -162,6 +179,12 @@ export default function TabLayout() { }; }, [token]); // 添加token作为依赖 + useEffect(() => { + if (token) { + prefetchChats().catch(console.error); + } + }, [token]); + return ( - {/* 隐私协议 */} - null, // 隐藏底部标签栏 - headerShown: false, // 隐藏导航栏 - tabBarStyle: { display: 'none' } // 确保在标签栏中不显示 - }} - /> - {/* Support Screen */} - null, // 隐藏底部标签栏 - headerShown: false, // 隐藏导航栏 - tabBarStyle: { display: 'none' } // 确保在标签栏中不显示 - }} - /> + {/* Debug Screen - only in development */} + {process.env.NODE_ENV === 'development' && ( + ( + + ), + }} + /> + )} - {/* Debug Screen - only in development */} - {process.env.NODE_ENV === 'development' && ( + {/* 下载页面 */} ( - - ), + title: 'download', + tabBarButton: () => null, // 隐藏底部标签栏 + headerShown: false, // 隐藏导航栏 + tabBarStyle: { display: 'none' } // 确保在标签栏中不显示 }} /> - )} - + + {/* 购买权益页面 */} + null, // 隐藏底部标签栏 + headerShown: false, // 隐藏导航栏 + tabBarStyle: { display: 'none' } // 确保在标签栏中不显示 + }} + /> + + {/* 设置页面 */} + null, // 隐藏底部标签栏 + headerShown: false, // 隐藏导航栏 + tabBarStyle: { display: 'none' } // 确保在标签栏中不显示 + }} + /> + + + ); } diff --git a/app/(tabs)/ask.tsx b/app/(tabs)/ask.tsx index a50f7b2..3ac0086 100644 --- a/app/(tabs)/ask.tsx +++ b/app/(tabs)/ask.tsx @@ -3,51 +3,187 @@ import Chat from "@/components/ask/chat"; import AskHello from "@/components/ask/hello"; import SendMessage from "@/components/ask/send"; import { ThemedText } from "@/components/ThemedText"; -import { checkAuthStatus } from '@/lib/auth'; import { fetchApi } from "@/lib/server-api-util"; -import { Message } from "@/types/ask"; -import { router, useLocalSearchParams } from "expo-router"; -import React, { useEffect, useRef, useState } from 'react'; +import { getWebSocketErrorMessage, webSocketManager, WsMessage } from "@/lib/websocket-util"; +import { Assistant, Message } from "@/types/ask"; +import { router, useFocusEffect, useLocalSearchParams } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from "react-i18next"; import { Animated, + FlatList, + Keyboard, KeyboardAvoidingView, Platform, - ScrollView, StyleSheet, + TextInput, TouchableOpacity, View } from 'react-native'; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { runOnJS } from 'react-native-reanimated'; import { useSafeAreaInsets } from "react-native-safe-area-context"; export default function AskScreen() { const insets = useSafeAreaInsets(); - useEffect(() => { - checkAuthStatus(router); - }, []); - // 在组件内部添加 ref - const scrollViewRef = useRef(null); + + const chatListRef = useRef(null); const [isHello, setIsHello] = useState(true); const [conversationId, setConversationId] = useState(null); const [userMessages, setUserMessages] = useState([]); - // 选择图片 const [selectedImages, setSelectedImages] = useState([]); - // 动画值 const fadeAnim = useRef(new Animated.Value(1)).current; const fadeAnimChat = useRef(new Animated.Value(0)).current; + const { t } = useTranslation(); const { sessionId, newSession } = useLocalSearchParams<{ sessionId: string; newSession: string; }>(); - // 处理滚动到底部 - useEffect(() => { - if (scrollViewRef.current && !isHello) { - scrollViewRef.current.scrollToEnd({ animated: true }); + // 创建一个可复用的滚动函数 + const scrollToEnd = useCallback((animated = true) => { + if (chatListRef.current) { + setTimeout(() => chatListRef.current?.scrollToEnd({ animated }), 100); } - }, [userMessages, isHello]); + }, []); + + // 右滑 + const gesture = Gesture.Pan() + .onEnd((event) => { + const { translationX } = event; + const threshold = 100; // 滑动阈值 + + if (translationX > threshold) { + // 从左向右滑动,跳转页面 + runOnJS(router.replace)("/memo-list"); + } + }) + .minPointers(1) + .activeOffsetX([-10, 10]); // 在 X 方向触发的范围 + + useEffect(() => { + if (!isHello && userMessages.length > 0) { + scrollToEnd(); + } + }, [userMessages, isHello, scrollToEnd]); + + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener( + 'keyboardDidShow', + (e) => { + setTimeout(() => { + if (!isHello) { + scrollToEnd(); + } + }, 100); + } + ); + + const keyboardDidHideListener = Keyboard.addListener( + 'keyboardDidHide', + () => { + setTimeout(() => { + if (!isHello) { + scrollToEnd(false); + } + }, 100); + } + ); + + return () => { + keyboardDidShowListener.remove(); + keyboardDidHideListener.remove(); + }; + }, [isHello]); + + useFocusEffect( + useCallback(() => { + webSocketManager.connect(); + + const handleChatStream = (message: WsMessage) => { + if (message.type === 'ChatStream') { + setUserMessages(prevMessages => { + const newMessages = [...prevMessages]; + const lastMessage = newMessages[newMessages.length - 1]; + + if (lastMessage && lastMessage.role === Assistant) { + if (typeof lastMessage.content === 'string') { + if (lastMessage.content === 'keepSearchIng') { + // 第一次收到流式消息,替换占位符 + lastMessage.content = message.chunk; + } else { + // 持续追加流式消息 + lastMessage.content += message.chunk; + } + } else { + // 如果 content 是数组,则更新第一个 text 部分 + const textPart = lastMessage.content.find(p => p.type === 'text'); + if (textPart) { + textPart.text = (textPart.text || '') + message.chunk; + } + } + } + return newMessages; + }); + } + }; + + const handleChatStreamEnd = (message: WsMessage) => { + if (message.type === 'ChatStreamEnd') { + setUserMessages(prevMessages => { + const newMessages = [...prevMessages]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage && lastMessage.role === Assistant) { + // 使用最终消息替换流式消息,确保 message.message 存在 + if (message.message) { + newMessages[newMessages.length - 1] = message.message as Message; + } else { + // 如果最终消息为空,则移除 'keepSearchIng' 占位符 + return prevMessages.filter(m => !(typeof m.content === 'string' && m.content === 'keepSearchIng')); + } + } + return newMessages; + }); + } + }; + + const handleError = (message: WsMessage) => { + if (message.type === 'Error') { + console.log(`WebSocket Error: ${message.code} - ${message.message}`); + // 可以在这里添加错误提示,例如替换最后一条消息为错误信息 + setUserMessages(prev => { + // 创建新的数组和新的消息对象 + return prev.map((msg, index) => { + if (index === prev.length - 1 && + typeof msg.content === 'string' && + msg.content === 'keepSearchIng') { + // 返回新的消息对象 + return { + ...msg, + content: getWebSocketErrorMessage(message.code, t) + }; + } + return msg; + }); + }); + } + }; + + webSocketManager.subscribe('ChatStream', handleChatStream); + webSocketManager.subscribe('ChatStreamEnd', handleChatStreamEnd); + webSocketManager.subscribe('Error', handleError); + + return () => { + webSocketManager.unsubscribe('ChatStream', handleChatStream); + webSocketManager.unsubscribe('ChatStreamEnd', handleChatStreamEnd); + webSocketManager.unsubscribe('Error', handleError); + // 可以在这里选择断开连接,或者保持连接以加快下次进入页面的速度 + // webSocketManager.disconnect(); + }; + }, []) + ); - // 处理路由参数 useEffect(() => { if (sessionId) { setConversationId(sessionId); @@ -56,16 +192,14 @@ export default function AskScreen() { setUserMessages(res); }); } - // if (newSession) { - // setIsHello(false); - // createNewConversation(); - // } - }, [sessionId]); + if (newSession) { + setIsHello(true); + setConversationId(null); + } + }, [sessionId, newSession]); - // 动画效果 useEffect(() => { if (isHello) { - // 显示欢迎页,隐藏聊天页 Animated.parallel([ Animated.timing(fadeAnim, { toValue: 1, @@ -79,56 +213,83 @@ export default function AskScreen() { }) ]).start(); } else { - // 显示聊天页,隐藏欢迎页 Animated.parallel([ Animated.timing(fadeAnim, { toValue: 0, - duration: 1000, + duration: 300, useNativeDriver: true, }), Animated.timing(fadeAnimChat, { toValue: 1, - duration: 1000, + duration: 300, useNativeDriver: true, }) - ]).start(); + ]).start(() => { + setTimeout(() => { + if (!isHello) { + scrollToEnd(false); + } + }, 50); + }); } }, [isHello, fadeAnim, fadeAnimChat]); - return ( - - {/* 导航栏 */} - - router.push('/memo-list')} - > - - - MemoWake - - + useEffect(() => { + if (!isHello) { + // 不再自动关闭键盘,让用户手动控制 + // 这里可以添加其他需要在隐藏hello界面时执行的逻辑 + scrollToEnd(false); + } + }, [isHello]); - - + useFocusEffect( + useCallback(() => { + if (!sessionId) { + setIsHello(true); + setUserMessages([]) + } + }, [sessionId]) + ); + + return ( + + + {/* 导航栏 */} + + { + try { + if (TextInput.State?.currentlyFocusedInput) { + const input = TextInput.State.currentlyFocusedInput(); + if (input) input.blur(); + } + } catch (error) { + console.log('失去焦点失败:', error); + } + Keyboard.dismiss(); + router.push('/memo-list'); + }} + > + + + { router.push('/owner') }}>MemoWake + + + + {/* 欢迎页面 */} - + {/* 聊天页面 */} @@ -137,57 +298,64 @@ export default function AskScreen() { styles.absoluteView, { opacity: fadeAnimChat, - // 使用 pointerEvents 控制交互 pointerEvents: isHello ? 'none' : 'auto', zIndex: 0 } ]} > - + scrollToEnd()} + /> - {/* 输入框 */} - - - - - + {/* 输入框区域 */} + + + + + + + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: 'white', + backgroundColor: '#fff', }, navbar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingVertical: 16, + paddingVertical: 8, paddingHorizontal: 16, - backgroundColor: 'white', - // 使用 border 替代阴影 borderBottomWidth: 1, borderBottomColor: 'rgba(0,0,0,0.1)', - // 如果需要更柔和的边缘,可以添加一个微妙的阴影 elevation: 1, // Android shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 1, - }, - hiddenNavbar: { - shadowOpacity: 0, - elevation: 0, + zIndex: 10 }, backButton: { padding: 8, @@ -202,28 +370,29 @@ const styles = StyleSheet.create({ placeholder: { width: 40, }, - // 更新 keyboardAvoidingView 和 contentContainer 样式 - keyboardAvoidingView: { - flex: 1, - }, contentContainer: { flex: 1, - justifyContent: 'center', - paddingBottom: 20, + position: 'relative' }, absoluteView: { - position: 'absolute', // 保持绝对定位 + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'white', }, + chatContainer: { + flex: 1, + }, + chatContentContainer: { + paddingBottom: 20, + }, inputContainer: { padding: 16, paddingBottom: 24, backgroundColor: 'white', - borderTopWidth: 1, - borderTopColor: '#f0f0f0', + // borderTopWidth: 1, + // borderTopColor: '#f0f0f0', }, }); \ No newline at end of file diff --git a/app/(tabs)/download.tsx b/app/(tabs)/download.tsx index 35cd9d9..fa03774 100644 --- a/app/(tabs)/download.tsx +++ b/app/(tabs)/download.tsx @@ -1,63 +1,53 @@ -import AndroidLogo from '@/assets/icons/svg/android.svg'; -import AppleLogo from '@/assets/icons/svg/apple.svg'; -import MemoIP from '@/assets/icons/svg/memo-ip.svg'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useTranslation } from 'react-i18next'; -import { Linking, Text, TouchableOpacity, View } from 'react-native'; +import { AppDownload } from '@/components/download/app'; +import PCDownload from '@/components/download/pc'; +import React, { useEffect, useState } from 'react'; +import { Platform, Text, View } from 'react-native'; const IOS_APP_STORE_URL = 'https://apps.apple.com/cn/app/id6748205761'; const ANDROID_APK_URL = 'https://cdn.memorywake.com/apks/application-f086a38c-dac1-43f1-9d24-e4378c2ce121.apk'; export default function DownloadScreen() { - const handleIOSDownload = () => { - Linking.openURL(IOS_APP_STORE_URL); - }; - const handleAndroidDownload = () => { - Linking.openURL(ANDROID_APK_URL); - }; + const [loading, setLoading] = useState(false) + const [platform, setPlatform] = useState('') + // 判断是什么平台 + const getPlatform = () => { + let platform; + if (Platform.OS === 'ios') { + platform = 'ios'; + } else if (Platform.OS === 'android') { + platform = 'android'; + } else { + platform = 'pc'; + } + return platform; + } - const { t } = useTranslation(); + useEffect(() => { + setLoading(true) + const platform = getPlatform(); + setPlatform(platform) + setLoading(false) + }, []) + + if (loading) { + return ( + + loading... + + ); + } return ( - - - - - - - MemoWake - - - {t('desc', { ns: 'download' })} - - + + { + platform === 'pc' && + } + { + (platform === 'ios' || platform === 'android') && ( + + ) + } + - - - - - {t('ios', { ns: 'download' })} - - - - - - - {t('android', { ns: 'download' })} - - - - - ); + ) } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 44dd58d..ac7cdaf 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,17 +1,227 @@ -import IP from '@/assets/icons/svg/ip.svg'; import { checkAuthStatus } from '@/lib/auth'; import { useRouter } from 'expo-router'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Text, TouchableOpacity, View } from 'react-native'; +import { Animated, Dimensions, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from "react-native-safe-area-context"; export default function HomeScreen() { const router = useRouter(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(false); + // 获取屏幕宽度 + const screenWidth = Dimensions.get('window').width; + + // 动画值 + const fadeAnim = useRef(new Animated.Value(0)).current; // IP图标的淡入动画 + const shakeAnim = useRef(new Animated.Value(0)).current; // IP图标的摇晃动画 + const animationRef = useRef(null); // 动画引用 + const descriptionAnim = useRef(new Animated.Value(0)).current; // 描述文本的淡入动画 + const buttonAnim = useRef(new Animated.Value(0)).current; // 按钮的淡入动画 + const buttonShakeAnim = useRef(new Animated.Value(0)).current; // 按钮的摇晃动画 + const buttonLoopAnim = useRef(null); // 按钮循环动画引用 + const fadeInAnim = useRef(new Animated.Value(0)).current; + + // 文本行动画值 + const [textAnimations] = useState(() => ({ + line1: new Animated.Value(0), // 第一行文本动画 + line2: new Animated.Value(0), // 第二行文本动画 + line3: new Animated.Value(0), // 第三行文本动画 + subtitle: new Animated.Value(0), // 副标题动画 + })); + + // 添加挥手动画值 + const waveAnim = useRef(new Animated.Value(0)).current; + + // 启动IP图标摇晃动画 + const startShaking = () => { + // 停止任何正在进行的动画 + if (animationRef.current) { + animationRef.current.stop(); + } + + // 创建动画序列 + const sequence = Animated.sequence([ + // 第一次左右摇晃 + Animated.timing(shakeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(shakeAnim, { + toValue: -1, + duration: 300, + useNativeDriver: true, + }), + // 第二次左右摇晃 + Animated.timing(shakeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(shakeAnim, { + toValue: -1, + duration: 300, + useNativeDriver: true, + }), + // 回到中心位置 + Animated.timing(shakeAnim, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + // 1秒延迟 + Animated.delay(1000), + ]); + + // 循环播放动画序列 + animationRef.current = Animated.loop(sequence); + animationRef.current.start(); + }; + + // 启动文本动画 + const startTextAnimations = () => { + // 按顺序延迟启动每行文本动画 + return new Promise((resolve) => { + Animated.stagger(300, [ + Animated.timing(textAnimations.line1, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(textAnimations.line2, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(textAnimations.line3, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(textAnimations.subtitle, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + ]).start(() => resolve()); + }); + }; + + // 启动描述文本动画 + const startDescriptionAnimation = () => { + // IP图标显示后淡入描述文本 + return new Promise((resolve) => { + Animated.sequence([ + Animated.delay(200), // IP图标显示后延迟200ms + Animated.timing(descriptionAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }) + ]).start(() => resolve()); + }); + }; + // 启动欢迎语动画 + const startWelcomeAnimation = () => { + // IP图标显示后淡入描述文本 + return new Promise((resolve) => { + Animated.sequence([ + Animated.delay(200), // IP图标显示后延迟200ms + Animated.timing(fadeInAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }) + ]).start(() => resolve()); + }); + }; + + // 启动按钮动画 + const startButtonAnimation = () => { + // 首先淡入按钮 + Animated.sequence([ + Animated.timing(buttonAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }) + ]).start(() => { + // 淡入完成后开始循环摇晃动画 + startButtonShakeLoop(); + }); + }; + + // 启动按钮循环摇晃动画 + const startButtonShakeLoop = () => { + // 停止任何正在进行的动画 + if (buttonLoopAnim.current) { + buttonLoopAnim.current.stop(); + } + + // 创建摇晃动画序列 + const shakeSequence = Animated.sequence([ + // 向右摇晃 + Animated.timing(buttonShakeAnim, { + toValue: 1, + duration: 100, + useNativeDriver: true, + }), + // 向左摇晃 + Animated.timing(buttonShakeAnim, { + toValue: -1, + duration: 100, + useNativeDriver: true, + }), + // 再次向右摇晃 + Animated.timing(buttonShakeAnim, { + toValue: 1, + duration: 100, + useNativeDriver: true, + }), + // 回到中心位置 + Animated.timing(buttonShakeAnim, { + toValue: 0, + duration: 100, + useNativeDriver: true, + }), + // 暂停3秒 + Animated.delay(3000) + ]); + + // 循环播放动画序列 + buttonLoopAnim.current = Animated.loop(shakeSequence); + buttonLoopAnim.current.start(); + }; + + // 启动挥手动画 + const startWaveAnimation = () => { + // 创建循环动画:左右摇摆 + Animated.loop( + Animated.sequence([ + Animated.timing(waveAnim, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(waveAnim, { + toValue: -1, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(waveAnim, { + toValue: 0, + duration: 500, + useNativeDriver: true, + }), + Animated.delay(1000), // 暂停1秒 + ]) + ).start(); + }; + + // 组件挂载时启动动画 useEffect(() => { setIsLoading(true); checkAuthStatus(router, () => { @@ -21,58 +231,305 @@ export default function HomeScreen() { }).catch(() => { setIsLoading(false); }); + // IP图标的淡入动画 + Animated.timing(fadeAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }).start(() => { + // 淡入完成后开始摇晃动画 + startShaking(); + // IP显示后开始文本动画 + startTextAnimations() + .then(() => startWelcomeAnimation()) + .then(() => startDescriptionAnimation()) + .then(() => startButtonAnimation()) + .catch(console.error); + // 启动挥手动画 + startWaveAnimation(); + }); + + // 组件卸载时清理动画 + return () => { + if (buttonLoopAnim.current) { + buttonLoopAnim.current.stop(); + } + if (animationRef.current) { + animationRef.current.stop(); + } + }; + }, []); + // 动画样式 + const animatedStyle = { + opacity: fadeAnim, + transform: [ + { + translateX: shakeAnim.interpolate({ + inputRange: [-1, 1], + outputRange: [-2, 2], + }) + }, + { + rotate: shakeAnim.interpolate({ + inputRange: [-1, 1], + outputRange: ['-2deg', '2deg'], + }), + }, + ], + }; + + // 旋转动画插值 + const rotate = waveAnim.interpolate({ + inputRange: [-1, 0, 1], + outputRange: ['-15deg', '0deg', '15deg'], + }); + if (isLoading) { return ( - - 加载中... + + {t('common.loading')} ); } return ( - - + + {/* 标题区域 */} - - + + {t('auth.welcomeAwaken.awaken', { ns: 'login' })} - {"\n"} + + {t('auth.welcomeAwaken.your', { ns: 'login' })} - {"\n"} + + {t('auth.welcomeAwaken.pm', { ns: 'login' })} - - + + {t('auth.welcomeAwaken.slogan', { ns: 'login' })} - + + + {/* 欢迎语 */} + + + + - {/* Memo 形象区域 */} - - + {/* Animated IP */} + + + + {/* 介绍文本 */} - + {t('auth.welcomeAwaken.gallery', { ns: 'login' })} {"\n"} {t('auth.welcomeAwaken.back', { ns: 'login' })} - + {/* 唤醒按钮 */} - { - router.push('/login') + - - {t('auth.welcomeAwaken.awake', { ns: 'login' })} - - + { + router.push('/login'); + }} + activeOpacity={0.8} + > + + {t('auth.welcomeAwaken.awake', { ns: 'login' })} + + + - + ); -} \ No newline at end of file +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFB645', + }, + loadingContainer: { + flex: 1, + backgroundColor: '#FFB645', + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + color: '#FFFFFF', + fontSize: 16, + }, + contentContainer: { + flex: 1, + backgroundColor: '#FFB645', + paddingHorizontal: 16, + paddingBottom: 32, + }, + headerContainer: { + marginBottom: 40, + width: '100%', + paddingHorizontal: 20, + }, + titleText: { + color: '#FFFFFF', + fontSize: 30, + fontWeight: 'bold', + marginBottom: 12, + textAlign: 'left', + lineHeight: 36, + }, + subtitleText: { + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 16, + textAlign: 'left', + lineHeight: 24, + }, + ipContainer: { + alignItems: 'center', + marginBottom: 16, + minHeight: 200, + }, + ipWrapper: { + alignItems: 'center', + justifyContent: 'center', + }, + descriptionText: { + color: '#FFFFFF', + fontSize: 16, + textAlign: 'center', + lineHeight: 24, + opacity: 0.9, + paddingHorizontal: 40, + marginTop: -16, + }, + awakenButton: { + backgroundColor: '#FFFFFF', + borderRadius: 28, + paddingVertical: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + width: '86%', + alignItems: 'center', + marginTop: 24, + }, + buttonText: { + color: '#4C320C', + fontWeight: 'bold', + fontSize: 18, + }, +}); \ No newline at end of file diff --git a/app/(tabs)/login.tsx b/app/(tabs)/login.tsx index 9aea541..e332311 100644 --- a/app/(tabs)/login.tsx +++ b/app/(tabs)/login.tsx @@ -1,15 +1,16 @@ import Handers from '@/assets/icons/svg/handers.svg'; -import LoginIP1 from '@/assets/icons/svg/loginIp1.svg'; -import LoginIP2 from '@/assets/icons/svg/loginIp2.svg'; import ForgetPwd from '@/components/login/forgetPwd'; import Login from '@/components/login/login'; +import PhoneLogin from '@/components/login/phoneLogin'; import SignUp from '@/components/login/signUp'; +import PrivacyModal from '@/components/owner/qualification/privacy'; import { ThemedText } from '@/components/ThemedText'; import { ThemedView } from '@/components/ThemedView'; import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Keyboard, KeyboardAvoidingView, LayoutChangeEvent, Platform, ScrollView, StatusBar, TouchableOpacity, View, ViewStyle, useWindowDimensions } from 'react-native'; +import { Image, Keyboard, KeyboardAvoidingView, LayoutChangeEvent, Platform, ScrollView, StatusBar, TouchableOpacity, View, ViewStyle, useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; const LoginScreen = () => { const router = useRouter(); @@ -18,11 +19,18 @@ const LoginScreen = () => { const [error, setError] = useState('123'); const [containerHeight, setContainerHeight] = useState(0); const { height: windowHeight } = useWindowDimensions(); + // 展示首次输入密码 const [showPassword, setShowPassword] = useState(false); + // 展示二次输入密码 + const [showSecondPassword, setShowSecondPassword] = useState(false); const [keyboardOffset, setKeyboardOffset] = useState(0); + const insets = useSafeAreaInsets(); // 判断是否有白边 const statusBarHeight = StatusBar?.currentHeight ?? 0; + // 协议弹窗 + const [modalVisible, setModalVisible] = useState(false); + const [modalType, setModalType] = useState<'ai' | 'terms' | 'privacy' | 'user' | 'membership'>('privacy'); useEffect(() => { const keyboardWillShowListener = Keyboard.addListener( Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', @@ -49,13 +57,10 @@ const LoginScreen = () => { }; const updateUrlParam = (key: string, value: string) => { + setError(''); router.setParams({ [key]: value }); } - useEffect(() => { - // setError('123') - }, []) - return ( { > + + Awake your Memo + 0 ? windowHeight - containerHeight - 210 + statusBarHeight : 0, - transform: [{ translateX: -200 }, { translateY: keyboardOffset > 0 ? -keyboardOffset + statusBarHeight : -keyboardOffset }] + top: containerHeight > 0 ? windowHeight - containerHeight - 210 + statusBarHeight - insets.top - 28 : 0, + transform: [{ translateX: -200 }, { translateY: keyboardOffset > 0 ? -keyboardOffset + statusBarHeight - insets.top - 28 : -keyboardOffset }] }} > { - showPassword + (showPassword || showSecondPassword) ? - + : - + } 0 ? windowHeight - containerHeight - 1 + statusBarHeight : 0, - transform: [{ translateX: -39.5 }, { translateY: keyboardOffset > 0 ? -4 - keyboardOffset + statusBarHeight : -4 - keyboardOffset }] + top: containerHeight > 0 ? windowHeight - containerHeight - 1 + statusBarHeight - insets.top - 30 : 0, + transform: [{ translateX: -39.5 }, { translateY: keyboardOffset > 0 ? -4 - keyboardOffset + statusBarHeight - insets.top - 30 : -4 - keyboardOffset }] }} > @@ -127,6 +136,8 @@ const LoginScreen = () => { {...commonProps} setShowPassword={setShowPassword} showPassword={showPassword} + setShowSecondPassword={setShowSecondPassword} + showSecondPassword={showSecondPassword} /> ), forgetPwd: ( @@ -140,32 +151,39 @@ const LoginScreen = () => { setShowPassword={setShowPassword} showPassword={showPassword} /> + // + ), + code: ( + ) }; return components[status as keyof typeof components] || components.login; })()} - {status == 'login' || !status && - - - {status === 'login' || !status ? t('auth.agree.logintext', { ns: 'login' }) : t('auth.agree.singupText', { ns: 'login' })} - - { }}> - - {t('auth.agree.terms', { ns: 'login' })} + + {status == 'login' || !status && + + + {status === 'login' || !status ? t('auth.agree.logintext', { ns: 'login' }) : t('auth.agree.singupText', { ns: 'login' })} - - - {t('auth.agree.join', { ns: 'login' })} - - { }}> - - {t('auth.agree.privacyPolicy', { ns: 'login' })} + { setModalVisible(true); setModalType('terms') }}> + + {t('auth.agree.terms', { ns: 'login' })} + + + + {t('auth.agree.join', { ns: 'login' })} - - - } + { setModalVisible(true); setModalType('privacy') }}> + + {t('auth.agree.privacyPolicy', { ns: 'login' })} + + + + } + + diff --git a/app/(tabs)/memo-list.tsx b/app/(tabs)/memo-list.tsx index 9ab35d2..d2e26db 100644 --- a/app/(tabs)/memo-list.tsx +++ b/app/(tabs)/memo-list.tsx @@ -1,318 +1,352 @@ -import ChatSvg from "@/assets/icons/svg/chat.svg"; -import UploaderProgress from "@/components/file-upload/upload-progress/uploader-progress"; -import AskNavbar from "@/components/layout/ask"; -import { useUploadManager } from "@/hooks/useUploadManager"; -import { fetchApi } from "@/lib/server-api-util"; -import { useAppDispatch, useAppSelector } from "@/store"; -import { Chat } from "@/types/ask"; -import { router } from "expo-router"; -import React, { useEffect, useState } from 'react'; -import { FlatList, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Asset } from 'expo-asset'; +import { useFocusEffect, useRouter } from 'expo-router'; +import * as SplashScreen from 'expo-splash-screen'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { FlatList, InteractionManager, PixelRatio, Platform, RefreshControl, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -const MemoList = () => { - const insets = useSafeAreaInsets(); - const dispatch = useAppDispatch(); - const uploadSessionStartTime = useAppSelector((state) => state.appState.uploadSessionStartTime); +// 懒加载组件 +import ChatSvg from '@/assets/icons/svg/chat.svg'; +import ErrorBoundary from '@/components/common/ErrorBoundary'; +import UploaderProgress from '@/components/file-upload/upload-progress/uploader-progress'; - // 历史消息 - const [historyList, setHistoryList] = React.useState([]); +import SkeletonItem from '@/components/memo/SkeletonItem'; - // 获取历史消息 - const getHistoryList = async () => { - await fetchApi(`/chats`).then((res) => { - setHistoryList(res) - }) +// 类型定义 +import { useUploadManager } from '@/hooks/useUploadManager'; +import { getCachedData, prefetchChatDetail, prefetchChats } from '@/lib/prefetch'; +import { fetchApi } from '@/lib/server-api-util'; +import { Chat, getMessageText } from '@/types/ask'; +import { useTranslation } from 'react-i18next'; + +// 预加载资源 +const preloadAssets = async () => { + try { + await Asset.loadAsync([ + require('@/assets/icons/svg/chat.svg'), + ]); + } catch (error) { + // console.error('资源预加载失败:', error); } +}; - // 获取对话历史消息 - const getChatHistory = async (id: string) => { - // 跳转到聊天页面,并携带参数 +// 骨架屏占位 +const SkeletonList = () => ( + + {Array(5).fill(0).map((_, index) => ( + + ))} + +); + +const MemoList = () => { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [isMounted, setIsMounted] = useState(false); + const [historyList, setHistoryList] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const flatListRef = useRef(null); + const { t } = useTranslation(); + + // 从缓存或API获取数据 + const fetchHistoryList = useCallback(async (forceRefresh = false) => { + try { + setIsLoading(true); + + // 先检查缓存 + const cachedData = getCachedData('/chats'); + if (cachedData && !forceRefresh) { + setHistoryList(cachedData); + } + + // 总是从服务器获取最新数据 + const data = await fetchApi('/chats'); + setHistoryList(data); + + // 预加载第一个聊天的详情 + if (data.length > 0) { + InteractionManager.runAfterInteractions(() => { + prefetchChatDetail(data[0].session_id).catch(console.error); + }); + } + + return data; + } catch (error) { + console.error('获取历史记录失败:', error); + throw error; + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, []); + + // 处理下拉刷新 + const handleRefresh = useCallback(() => { + if (isRefreshing) return; + + setIsRefreshing(true); + fetchHistoryList(true).finally(() => { + setIsRefreshing(false); + }); + }, [fetchHistoryList, isRefreshing]); + + // 处理聊天项点击 + const handleMemoPress = useCallback((item: Chat) => { router.push({ pathname: '/ask', - params: { - sessionId: id, - } + params: { sessionId: item.session_id }, }); + }, [router]); - } - - const handleMemoPress = (item: Chat) => { - getChatHistory(item.session_id) - } - + // 初始加载和预加载 useEffect(() => { - getHistoryList() - }, []) + let isActive = true; - const { progressInfo, uploadSessionStartTime: uploadSessionStartTimeFromHook } = useUploadManager(); + const initialize = async () => { + try { + // 并行预加载资源和数据 + await Promise.all([ + preloadAssets(), + prefetchChats().then((data) => { + if (isActive && data) { + setHistoryList(data as Chat[]); + } + }), + ]); - const renderHeader = () => ( - <> - {/* {process.env.NODE_ENV === 'development' && router.push('/debug')} - > - - 进入db调试页面 - - } */} + // 主数据加载 + await fetchHistoryList(); + } catch (error) { + console.error('初始化失败:', error); + } finally { + if (isActive) { + setIsMounted(true); + // 延迟隐藏启动画面 + setTimeout(SplashScreen.hideAsync, 500); + } + } + }; - {/* 顶部标题和上传按钮 */} - - Memo List - + initialize(); - {/* 上传进度展示区域 */} - {uploadSessionStartTime && progressInfo.total > 0 && ( - - - - )} - + return () => { + isActive = false; + }; + }, [fetchHistoryList]); + + // 每次进入页面就刷新 + useFocusEffect( + useCallback(() => { + handleRefresh(); + }, []) ); - return ( - - {/* - - */} + // 渲染列表项 + const renderItem = useCallback(({ item }: { item: Chat }) => ( + handleMemoPress(item)} + activeOpacity={0.7} + > + + + + {item.title || t('ask:ask.unNamed')} + + + {(item.latest_message && getMessageText(item.latest_message)) || t('ask:ask.noMessage')} + + + + ), [handleMemoPress]); - {/* 历史对话 */} - item.session_id} - ItemSeparatorComponent={() => ( - - )} - renderItem={({ item }) => ( - handleMemoPress(item)} - > - - - - - - {item.title || 'memo list 历史消息'} - - - {item.latest_message?.content?.text || 'memo list 历史消息'} - - - - )} - /> - {/* 底部导航栏 */} - + // 渲染列表头部 + const renderHeader = useCallback(() => ( + + + {t('ask:ask.memoList')} + + + {/* 上传进度 */} + {/* */} + ), [insets.top]); + + // 上传进度组件 + const UploadProgressSection = () => { + const { progressInfo, uploadSessionStartTime } = useUploadManager(); + + if (!uploadSessionStartTime || progressInfo.total <= 0) { + return null; + } + + return ( + + + + ); + }; + + // 空状态 + const renderEmptyComponent = useCallback(() => ( + + {t('ask:ask.noChat')} + + + {isRefreshing ? t('ask:ask.loading') : t('ask:ask.refresh')} + + + + ), [handleRefresh, isRefreshing]); + + // 如果组件未完全加载,显示骨架屏 + if (!isMounted) { + return ( + + + + ); + } + + return ( + + + item.session_id} + ListHeaderComponent={renderHeader} + ListEmptyComponent={!isLoading ? renderEmptyComponent : null} + refreshControl={ + + } + initialNumToRender={10} + maxToRenderPerBatch={5} + updateCellsBatchingPeriod={50} + windowSize={11} // 5 screens in each direction (5 + 1 + 5) + removeClippedSubviews={Platform.OS === 'android'} + getItemLayout={(data, index) => ({ + length: 80, + offset: 80 * index, + index, + })} + contentContainerStyle={styles.listContent} + ItemSeparatorComponent={() => } + /> + + ); }; +// 使用React.memo优化组件 +const MemoizedMemoList = React.memo(MemoList); + +export default MemoizedMemoList; + const styles = StyleSheet.create({ - separator: { - height: 1, - backgroundColor: '#f0f0f0', - marginLeft: 60, // 与头像对齐 - }, container: { flex: 1, - backgroundColor: 'white', + backgroundColor: '#fff', }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: 16, + headerContainer: { + paddingBottom: 16, + backgroundColor: '#fff', }, title: { - fontSize: 24, + fontSize: 20, fontWeight: 'bold', color: '#4C320C', + textAlign: 'center', + marginBottom: 16, }, - uploadButton: { - padding: 8, + listContent: { + paddingBottom: Platform.select({ + ios: 30, + android: 20, + }), }, - searchContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#FFF', - borderRadius: 20, - marginHorizontal: 16, - paddingHorizontal: 16, - height: 48, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - searchIcon: { - marginRight: 8, + skeletonContainer: { + flex: 1, + backgroundColor: '#fff', + paddingTop: 20, }, memoItem: { flexDirection: 'row', - borderRadius: 0, // 移除圆角 - padding: 16, - marginBottom: 0, // 移除底部边距 alignItems: 'center', - gap: 16, - backgroundColor: 'white', + padding: 16, + backgroundColor: '#fff', }, - avatar: { - width: 60, - height: 60, - borderRadius: 30, - marginRight: 16, + placeholderIcon: { + width: 48, + height: 48, + backgroundColor: '#f0f0f0', + borderRadius: 24, }, memoContent: { flex: 1, marginLeft: 12, - gap: 6, justifyContent: 'center', - minWidth: 0, // 这行很重要,确保文本容器可以收缩到比内容更小 + gap: 2 }, memoTitle: { fontSize: 16, fontWeight: '500', - color: '#333', - flex: 1, // 或者 flexShrink: 1 - marginLeft: 12, + color: '#4C320C', + marginBottom: 4, }, memoSubtitle: { fontSize: 14, - color: '#666', + color: '#AC7E35', }, - tabBar: { - flexDirection: 'row', - justifyContent: 'space-around', - alignItems: 'center', - backgroundColor: '#FFF', - borderTopWidth: 1, - borderTopColor: '#EEE', - paddingVertical: 12, + separator: { + height: 1 / PixelRatio.get(), + backgroundColor: '#f0f0f0', + marginLeft: 60, }, - tabBarSvg: { - color: 'red', - }, - tabItem: { + emptyContainer: { flex: 1, - alignItems: 'center', - }, - tabCenter: { - width: 60, - height: 60, - alignItems: 'center', - justifyContent: 'center', - }, - centerTabIcon: { - width: 50, - height: 50, - borderRadius: 25, - backgroundColor: '#FF9500', justifyContent: 'center', alignItems: 'center', - marginTop: -30, + padding: 40, }, - centerTabImage: { - width: 40, - height: 40, + emptyText: { + fontSize: 16, + color: '#999', + marginBottom: 20, + }, + refreshButton: { + backgroundColor: '#FFB645', + paddingHorizontal: 24, + paddingVertical: 10, borderRadius: 20, }, - // 在 tabBarContainer 样式中添加 - tabBarContainer: { - position: 'relative', - paddingBottom: 0, - overflow: 'visible', - marginTop: 10, // 添加一些上边距 + refreshText: { + color: '#fff', + fontSize: 14, + fontWeight: '500', }, - tabBarContent: { - flexDirection: 'row', - justifyContent: 'space-around', - alignItems: 'center', - height: 60, - position: 'relative', - backgroundColor: 'rgba(255, 255, 255, 0.7)', // 半透明白色背景 - borderRadius: 30, // 圆角 - marginHorizontal: 16, // 左右边距 - // 添加边框效果 - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.8)', - // 添加阴影 - ...Platform.select({ - ios: { - shadowColor: 'rgba(0, 0, 0, 0.1)', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.8, - shadowRadius: 4, - }, - android: { - elevation: 8, - }, - }), - }, - // 移除之前的 tabBarBackground 样式 - // 修改 centerTabShadow 样式 - centerTabShadow: { - position: 'absolute', - bottom: 15, - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: 'white', - ...Platform.select({ - ios: { - shadowColor: 'rgba(0, 0, 0, 0.2)', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 6, - }, - android: { - elevation: 10, - }, - }), - }, - centerTabContainer: { - flex: 1, - alignItems: 'center', - position: 'relative', - height: '100%', - }, - centerTabButton: { - width: '100%', - height: '100%', - borderRadius: 30, - backgroundColor: '#FF9500', - justifyContent: 'center', - alignItems: 'center', - }, - notificationDot: { - position: 'absolute', - top: -2, - right: -4, - width: 10, - height: 10, - borderRadius: 5, - backgroundColor: '#FF3B30', - }, -}); - -export default MemoList; \ No newline at end of file +}); \ No newline at end of file diff --git a/app/(tabs)/owner.tsx b/app/(tabs)/owner.tsx index 8b3d2e3..b3fa3a0 100644 --- a/app/(tabs)/owner.tsx +++ b/app/(tabs)/owner.tsx @@ -1,24 +1,19 @@ import ConversationsSvg from '@/assets/icons/svg/conversations.svg'; -import PointsSvg from '@/assets/icons/svg/points.svg'; import StoriesSvg from '@/assets/icons/svg/stories.svg'; -import UsedStorageSvg from '@/assets/icons/svg/usedStorage.svg'; -import AskNavbar from '@/components/layout/ask'; -import AlbumComponent from '@/components/owner/album'; -import CategoryComponent from '@/components/owner/category'; -import CountComponent from '@/components/owner/count'; +import CarouselComponent from '@/components/owner/carousel'; + import CreateCountComponent from '@/components/owner/createCount'; import Ranking from '@/components/owner/ranking'; -import ResourceComponent from '@/components/owner/resource'; -import SettingModal from '@/components/owner/setting'; +import MemberCard from '@/components/owner/rights/memberCard'; +import SkeletonOwner from '@/components/owner/SkeletonOwner'; import UserInfo from '@/components/owner/userName'; -import { formatDuration } from '@/components/utils/time'; import { checkAuthStatus } from '@/lib/auth'; import { fetchApi } from '@/lib/server-api-util'; import { CountData, UserInfoDetails } from '@/types/user'; -import { useRouter } from 'expo-router'; -import { useEffect, useState } from 'react'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FlatList, ScrollView, StyleSheet, View } from 'react-native'; +import { FlatList, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from "react-native-safe-area-context"; export default function OwnerPage() { @@ -26,6 +21,9 @@ export default function OwnerPage() { const { t } = useTranslation(); const router = useRouter(); + // 添加页面挂载状态 + const [isMounted, setIsMounted] = useState(false); + useEffect(() => { const checkAuth = async () => { const authStatus = await checkAuthStatus(router); @@ -36,9 +34,6 @@ export default function OwnerPage() { checkAuth(); }, [router]); - // 设置弹窗 - const [modalVisible, setModalVisible] = useState(false); - // 数据统计 const [countData, setCountData] = useState({} as CountData); @@ -52,24 +47,64 @@ export default function OwnerPage() { // 获取用户信息 const [userInfoDetails, setUserInfoDetails] = useState({} as UserInfoDetails); - const getUserInfo = () => { + + // 优化getUserInfo函数,添加挂载状态检查 + const getUserInfo = useCallback(() => { fetchApi("/membership/personal-center-info").then((res) => { - setUserInfoDetails(res as UserInfoDetails); + // 只有在组件挂载时才更新状态 + if (isMounted) { + setUserInfoDetails(res as UserInfoDetails); + } }) - } + }, [isMounted]); + // 设计轮询获取数量统计 - // useEffect(() => { - // const interval = setInterval(() => { - // getCountData(); - // }, 1000); - // return () => clearInterval(interval); - // }, []); + useFocusEffect( + useCallback(() => { + // 当页面获取焦点时开始轮询 + const interval = setInterval(() => { + getCountData(); + }, 5000); + + // 立即执行一次 + getCountData(); + + // 当页面失去焦点时清除定时器 + return () => clearInterval(interval); + }, []) // 空依赖数组,因为 getCountData 是稳定的 + ); // 初始化获取用户信息 useEffect(() => { - getUserInfo(); - getCountData(); - }, []); + let isActive = true; + + const initialize = async () => { + try { + await getUserInfo(); + } catch (error) { + console.error('初始化失败:', error); + } finally { + if (isActive) { + setIsMounted(true); + } + } + }; + + initialize(); + + return () => { + isActive = false; + }; + }, [getUserInfo]); + + // 如果组件未完全加载,显示骨架屏 + if (!isMounted) { + return ( + + + + ); + } return ( @@ -83,54 +118,29 @@ export default function OwnerPage() { {/* 用户信息 */} - {/* 设置栏 */} - - - {/* 资源数据 */} - - - } isFormatBytes={true} /> - } /> - - - {/* 数据统计 */} - + {/* 会员卡 */} + {/* 分类 */} - - - {countData?.counter?.category_count && Object.entries(countData?.counter?.category_count).map(([key, value], index) => { - return ( - - ) - })} - + + {/* 作品数据 */} - } number={userInfoDetails.stories_count} /> - } number={userInfoDetails.conversations_count} /> + } number={userInfoDetails.stories_count} /> + } number={userInfoDetails.conversations_count} /> {/* 排行榜 */} } + // 优化性能:添加 getItemLayout + getItemLayout={(data, index) => ( + { length: 1000, offset: 1000 * index, index } + )} /> - {/* 设置弹窗 */} - - - {/* 导航栏 */} - ); } @@ -148,11 +158,20 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - gap: 16, - backgroundColor: "#FAF9F6", - padding: 16, + backgroundColor: "#4C320C", + paddingVertical: 8, + paddingHorizontal: 32, borderRadius: 18, - paddingTop: 20 + }, + text: { + fontSize: 12, + fontWeight: '700', + color: '#FFB645', + }, + secondText: { + fontSize: 16, + fontWeight: '700', + color: '#fff', }, userInfo: { flexDirection: 'row', diff --git a/app/(tabs)/privacy-policy.tsx b/app/(tabs)/privacy-policy.tsx index ac573ed..b55f37c 100644 --- a/app/(tabs)/privacy-policy.tsx +++ b/app/(tabs)/privacy-policy.tsx @@ -2,10 +2,12 @@ import { fetchApi } from "@/lib/server-api-util"; import { Policy } from "@/types/personal-info"; import { useEffect, useState } from "react"; import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; -import RenderHtml from 'react-native-render-html'; +import Markdown from 'react-native-markdown-display'; +import { useSafeAreaInsets } from "react-native-safe-area-context"; const PrivacyPolicy = () => { const [article, setArticle] = useState({} as Policy); + const insets = useSafeAreaInsets(); useEffect(() => { const loadArticle = async () => { fetchApi(`/system-config/policy/privacy_policy`).then((res: any) => { @@ -26,7 +28,7 @@ const PrivacyPolicy = () => { } return ( - + Settings @@ -36,14 +38,9 @@ const PrivacyPolicy = () => { - + + {article.content} + @@ -86,6 +83,7 @@ const styles = StyleSheet.create({ }, modalContent: { flex: 1, + paddingHorizontal: 8 }, modalText: { fontSize: 16, diff --git a/app/(tabs)/reset-password.tsx b/app/(tabs)/reset-password.tsx index 3995603..63e8a3a 100644 --- a/app/(tabs)/reset-password.tsx +++ b/app/(tabs)/reset-password.tsx @@ -7,19 +7,19 @@ import { Ionicons } from '@expo/vector-icons'; import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, KeyboardAvoidingView, Platform, ScrollView, TextInput, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; -const resetPassword = () => { +const ResetPassword = () => { const { t } = useTranslation(); const router = useRouter(); const { session_id: resetPasswordSessionId, token } = useLocalSearchParams<{ session_id: string; token: string }>(); - // 使用 auth context 登录 const { login } = useAuth(); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); + const [showSecondPassword, setShowSecondPassword] = useState(false); const [error, setError] = useState(''); const validatePassword = (pwd: string) => { @@ -34,12 +34,12 @@ const resetPassword = () => { } if (password !== confirmPassword) { - setError(t('auth.signup.passwordNotMatch', { ns: 'login' })); + setError(t('auth.forgetPwd.passwordNotMatch', { ns: 'login' })); return; } - if (!validatePassword(password)) { - setError(t('auth.signup.passwordAuth', { ns: 'login' })); + if (password?.length < 6) { + setError(t('auth.forgetPwd.pwdLengthError', { ns: 'login' })); return; } @@ -64,6 +64,7 @@ const resetPassword = () => { if (login) { login(response, response.access_token || ''); } + router.push('/ask'); } catch (error) { console.error('Reset password error:', error); setError(t('auth.resetPwd.error', { ns: 'login' }) || 'Failed to reset password'); @@ -75,80 +76,75 @@ const resetPassword = () => { return ( - - - + + + {t('auth.resetPwd.title', { ns: 'login' })} {error ? ( - + {error} ) : null} - - + + { + setPassword(value) + }} secureTextEntry={!showPassword} - autoCapitalize="none" - autoCorrect={false} /> setShowPassword(!showPassword)} - className="p-2" + style={styles.eyeIcon} > - - - + { + setConfirmPassword(value) + }} + secureTextEntry={!showSecondPassword} /> setShowPassword(!showPassword)} - className="p-2" + onPress={() => setShowSecondPassword(!showSecondPassword)} + style={styles.eyeIcon} > - {loading ? ( ) : ( - + {t('auth.resetPwd.resetButton', { ns: 'login' })} )} @@ -157,6 +153,87 @@ const resetPassword = () => { ); -} +}; -export default resetPassword +const styles = StyleSheet.create({ + passwordInputContainer: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 12, + backgroundColor: '#FFF8DE', + overflow: 'hidden', + }, + container: { + flex: 1, + backgroundColor: '#fff', + }, + scrollContainer: { + flexGrow: 1, + justifyContent: 'center', + padding: 20, + }, + formContainer: { + width: '100%', + maxWidth: 400, + alignSelf: 'center', + padding: 20, + borderRadius: 12, + backgroundColor: '#fff', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 24, + textAlign: 'center', + color: '#1f2937', + }, + errorText: { + color: '#ef4444', + marginBottom: 16, + textAlign: 'center', + }, + inputContainer: { + marginBottom: 24, + gap: 16 + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: '#e5e7eb', + borderRadius: 8, + paddingHorizontal: 12, + }, + confirmInput: { + marginTop: 16, + }, + input: { + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + textAlignVertical: 'center', + backgroundColor: '#FFF8DE' + }, + eyeIcon: { + padding: 8, + }, + submitButton: { + width: '100%', + paddingVertical: 12, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#E2793F', + }, + submitButtonDisabled: { + backgroundColor: '#f59e0b', + }, + submitButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); + +export default ResetPassword; diff --git a/app/(tabs)/rights.tsx b/app/(tabs)/rights.tsx new file mode 100644 index 0000000..ed2c1d7 --- /dev/null +++ b/app/(tabs)/rights.tsx @@ -0,0 +1,461 @@ +import ReturnArrowSvg from '@/assets/icons/svg/returnArrow.svg'; +import StarSvg from '@/assets/icons/svg/whiteStart.svg'; +import CheckSvg from '@/assets/icons/svg/yes.svg'; +import PrivacyModal from '@/components/owner/qualification/privacy'; +import Normal from '@/components/owner/rights/normal'; +import Premium, { PayItem } from '@/components/owner/rights/premium'; +import ProRights from '@/components/owner/rights/proRights'; +import { createOrder, createPayment, getPAy, isOrderExpired, payFailure, payProcessing, paySuccess } from '@/components/owner/rights/utils'; +import { ThemedText } from '@/components/ThemedText'; +import { CreateOrder } from '@/types/personal-info'; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Image, Platform, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +// 根据平台动态导入 expo-iap +let useIAP: any, requestPurchase: any, getPurchaseHistories: any; +if (Platform.OS !== 'web') { + const iap = require('expo-iap'); + useIAP = iap.useIAP; + requestPurchase = iap.requestPurchase; + getPurchaseHistories = iap.getPurchaseHistories; +} else { + // 为 Web 端提供 mock 实现 + useIAP = () => ({ connected: false }); + requestPurchase = async () => { console.log('IAP is not available on web.'); }; + getPurchaseHistories = async () => []; +} + +export default function Rights() { + const insets = useSafeAreaInsets(); + const router = useRouter(); + const { t } = useTranslation(); + const { + connected, + requestProducts, + ErrorCode + } = useIAP(); + const { pro } = useLocalSearchParams<{ + credit: string; + pro: string; + }>(); + // 用户勾选协议 + const [agree, setAgree] = useState(false); + // 用户选择购买的loading + const [confirmLoading, setConfirmLoading] = useState(false); + // 选择购买方式 + const [payChoice, setPayChoice] = useState<'ApplePay'>('ApplePay'); + // 普通用户,会员 + const [userType, setUserType] = useState<'normal' | 'premium'>('normal'); + + // 选择权益方式 + const [payType, setPayType] = useState(''); + + // 用户协议弹窗打开 + const [showTerms, setShowTerms] = useState(false); + + // 调接口获取支付信息 + const [premiumPay, setPremiumPay] = useState(); + const [loading, setLoading] = useState(false); + + // 查看历史订单 + const fetchPurchaseHistory = async () => { + try { + const purchaseHistories = await getPurchaseHistories(); + console.log('Purchase history fetched:', purchaseHistories); + return purchaseHistories + } catch (error) { + console.error('Failed to fetch purchase history:', error); + } + }; + + // 恢复购买 + // const restorePurchases = async () => { + // try { + // const purchases = await getAvailablePurchases(); + // console.log('Available purchases:', purchases); + // // Process and validate restored purchases + // for (const purchase of purchases) { + // await validateAndGrantPurchase(purchase); + // } + // alert(t('personal:rights.restoreSuccess')); + // } catch (error) { + // console.error('Restore failed:', error); + // } + // }; + + // 处理购买 + const handlePurchase = async (sku: string, transaction_id: string) => { + try { + // 支付中 + await payProcessing(transaction_id, "") + const res = await requestPurchase({ + request: { + ios: { + sku: sku, + andDangerouslyFinishTransactionAutomaticallyIOS: false, + }, + }, + }); + // 支付成功 + await paySuccess(transaction_id, res?.transaction_id || "") + } catch (error: any) { + console.log('Purchase failed:', error); + // 支付失败 + payFailure(transaction_id, ErrorCode[error?.code as keyof typeof ErrorCode || "E_UNKNOWN"]) + } + }; + + // 获取苹果订单信息 + useEffect(() => { + if (!connected) return; + + const initializeStore = async () => { + try { + await requestProducts({ skus: ["MEMBERSHIP_PRO_QUARTERLY", "MEMBERSHIP_PRO_YEARLY", "MEMBERSHIP_PRO_MONTH"], type: 'subs' }); + } catch (error) { + console.error('Failed to initialize store:', error); + } + }; + + initializeStore(); + }, [connected]); + + // 初始化获取产品项 + useEffect(() => { + setLoading(true); + getPAy().then(({ bestValue, payInfo }) => { + setPayType(bestValue?.product_code) + setPremiumPay([bestValue, ...payInfo?.filter((item) => item.product_code !== bestValue?.product_code)]); + setLoading(false); + }).catch(() => { + setLoading(false); + }) + }, []); + + // 用户确认购买时,进行 创建订单,创建支付 接口调用 + const confirmPurchase = async () => { + if (!agree) { + alert(t('personal:rights.agreementError')); + return + } + setConfirmLoading(true); + const history = await fetchPurchaseHistory() + const historyIds = history?.filter((item: any) => isOrderExpired(item?.expirationDateIos))?.map((i: any) => { return i?.id }) + if (historyIds?.includes(payType)) { + setConfirmLoading(false); + setTimeout(() => { + alert(t('personal:rights.againError')); + }, 0); + return + } + + try { + // 创建订单 + createOrder(premiumPay?.filter((item) => item.product_code === payType)?.[0]?.id || 1, 1).then((res: CreateOrder) => { + // 创建支付 + createPayment(res?.id || "", payChoice).then(async (res) => { + // 苹果支付 + await handlePurchase(payType, res?.transaction_id || "") + setConfirmLoading(false); + }).catch((err) => { + console.log("createPayment", err); + setConfirmLoading(false); + }) + }).catch((err) => { + console.log("createOrder", err); + setConfirmLoading(false); + }) + } catch (error) { + console.log("confirmPurchase", error); + setConfirmLoading(false); + } + }; + + useEffect(() => { + if (pro === "Pro") { + setUserType('premium') + } else { + setUserType('normal') + } + }, [pro]) + + useEffect(() => { + fetchPurchaseHistory() + }, []) + + return ( + + {/* 整个页面的中间添加一个loading */} + {confirmLoading && ( + + + + + {t('personal:rights.confirmLoading')} + + + + )} + + {/* 导航栏 */} + + { router.push('/owner'); setConfirmLoading(false) }} style={{ padding: 16 }}> + + + + {t('rights.title', { ns: 'personal' })} + + 123 + + {/* 会员卡 */} + + {userType === 'normal' ? ( + + ) : ( + + )} + + + + {t('rights.purchase', { ns: 'personal' })} + + + + {pro} + + + + + + {/* 会员信息 */} + + {/* 切换按钮 */} + + { setUserType("normal") }} + style={[styles.switchButtonItem, { backgroundColor: userType === 'normal' ? "#FFB645" : "#fff", borderColor: userType === 'normal' ? "#FFB645" : "#E2793F" }]} + > + {t('rights.free', { ns: 'personal' })} + + { setUserType("premium") }} + style={[styles.switchButtonItem, { backgroundColor: userType === 'premium' ? "#E2793F" : "#fff", borderColor: userType === 'premium' ? "#E2793F" : "#E2793F" }]} + > + {t('rights.premium', { ns: 'personal' })} + + + {/* 普通权益 */} + + {/* 会员权益 */} + + + {/* 支付方式 */} + {/* */} + {/* 会员权益信息 */} + + + + + {/* 付费按钮 */} + + + { setAgree(!agree) }} activeOpacity={0.8}> + + {agree && } + + + + + {t('personal:rights.agreement')} + + { + setShowTerms(true); + }} + activeOpacity={0.8} + > + + {t('personal:rights.membership')} + + + + + { + confirmPurchase() + }} + activeOpacity={0.8} + > + + {t('rights.subscribe', { ns: 'personal' })} + + + + + {/* 协议弹窗 */} + + + ); +} + +const styles = StyleSheet.create({ + agree: { + width: 15, + height: 15, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + borderColor: '#AC7E35', + borderWidth: 1, + }, + loadingContent: { + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + }, + loadingContainer: { + justifyContent: 'center', + alignItems: 'center', + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + zIndex: 9, + backgroundColor: 'rgba(255, 255, 255, 0.5)', + }, + payChoice: { + width: 20, + height: 20, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + }, + paymentMethod: { + marginHorizontal: 16, + marginVertical: 16, + borderRadius: 12, + backgroundColor: '#fff', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 5, + elevation: 5, + }, + goPay: { + backgroundColor: '#E2793F', + borderRadius: 24, + paddingVertical: 10, + display: "flex", + alignItems: "center", + width: "100%", + }, + switchButton: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 16, + marginBottom: 16 + }, + switchButtonItem: { + width: "48%", + borderRadius: 24, + paddingVertical: 6, + display: "flex", + alignItems: "center", + borderWidth: 1 + }, + info: { + marginHorizontal: 16, + marginVertical: 16, + padding: 16, + borderRadius: 12, + backgroundColor: '#fff', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 5, + elevation: 5, + }, + container: { + flex: 1, + backgroundColor: 'white', + }, + header: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginVertical: 16, + }, + headerTitle: { + fontSize: 20, + fontWeight: '700', + color: '#4C320C', + }, + card: { + marginHorizontal: 16, + marginVertical: 16, + backgroundColor: '#FFB645', + borderRadius: 12, + }, + cardContent: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + padding: 16, + justifyContent: 'space-between' + }, + cardinfo: { + alignItems: 'flex-end', + }, + cardTitle: { + fontSize: 12, + fontWeight: '700', + color: '#E2793F', + backgroundColor: '#fff', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 20, + textAlign: 'center', + marginBottom: 24 + }, + cardPoints: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 4 + }, + cardPointsText: { + fontSize: 32, + fontWeight: '700', + color: '#4C320C', + lineHeight: 32 + } +}); \ No newline at end of file diff --git a/app/(tabs)/setting.tsx b/app/(tabs)/setting.tsx new file mode 100644 index 0000000..dcfb7d2 --- /dev/null +++ b/app/(tabs)/setting.tsx @@ -0,0 +1,513 @@ +import DeleteSvg from '@/assets/icons/svg/delete.svg'; +import LogoutSvg from '@/assets/icons/svg/logout.svg'; +import ReturnArrowSvg from '@/assets/icons/svg/returnArrow.svg'; +import RightArrowSvg from '@/assets/icons/svg/rightArrow.svg'; +import DeleteModal from '@/components/owner/delete'; +import LcensesModal from '@/components/owner/qualification/lcenses'; +import PrivacyModal from '@/components/owner/qualification/privacy'; +import CustomSwitch from '@/components/owner/switch'; +import UserInfo from '@/components/owner/userInfo'; +import { checkNotificationPermission, getLocationPermission, getPermissions, requestLocationPermission, requestMediaLibraryPermission, requestNotificationPermission, reverseGeocode } from '@/components/owner/utils'; +import { ThemedText } from '@/components/ThemedText'; +import { useAuth } from '@/contexts/auth-context'; +import { fetchApi } from '@/lib/server-api-util'; +import { Address, User, UserInfoDetails } from '@/types/user'; +import * as Location from 'expo-location'; +import { useFocusEffect, useRouter } from 'expo-router'; +import * as SecureStore from 'expo-secure-store'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Linking, Platform, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +const Setting = (props: { userInfo: UserInfoDetails }) => { + const [userInfo, setUserInfo] = useState(null); + + const getUserInfo = async () => { + const res = await fetchApi("/iam/user-info"); + setUserInfo(res); + } + + const insets = useSafeAreaInsets(); + const { t } = useTranslation(); + // 判断当前语言环境 + let language = ""; + const getLanguage = async () => { + if (Platform.OS === 'web') { + language = localStorage.getItem('i18nextLng') || ""; + } else { + language = await SecureStore.getItemAsync('i18nextLng') || ""; + } + } + + const [modalType, setModalType] = useState<'ai' | 'terms' | 'privacy' | 'user'>('ai'); + // 协议弹窗 + const [privacyModalVisible, setPrivacyModalVisible] = useState(false); + // 许可证弹窗 + const [lcensesModalVisible, setLcensesModalVisible] = useState(false); + + // 删除弹窗 + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const { logout } = useAuth(); + const router = useRouter(); + // 打开设置 + const openAppSettings = () => { + Linking.openSettings(); + }; + // 通知消息权限开关 + const [notificationsEnabled, setNotificationsEnabled] = useState(false); + const toggleNotifications = async () => { + if (notificationsEnabled) { + // 引导去设置关闭权限 + openAppSettings() + } else { + requestNotificationPermission() + .then((granted: boolean | ((prevState: boolean) => boolean)) => { + setNotificationsEnabled(granted); + }); + } + }; + + // 相册权限 + const [albumEnabled, setAlbumEnabled] = useState(false); + const toggleAlbum = async () => { + if (albumEnabled) { + // 引导去设置关闭权限 + openAppSettings() + } else { + requestMediaLibraryPermission() + .then((granted: boolean | ((prevState: boolean) => boolean)) => { + setAlbumEnabled(granted); + }); + } + } + + // 位置权限 + const [locationEnabled, setLocationEnabled] = useState(false); + // 位置权限更改 + const toggleLocation = async () => { + if (locationEnabled) { + // 如果权限已开启,点击则引导用户去设置关闭 + openAppSettings(); + } else { + requestLocationPermission() + .then((granted: boolean | ((prevState: boolean) => boolean)) => { + setLocationEnabled(granted); + }); + } + }; + // 正在获取位置信息 + const [isLoading, setIsLoading] = useState(false); + // 动画开启 + const [isRefreshing, setIsRefreshing] = useState(false); + + // 当前位置状态 + const [currentLocation, setCurrentLocation] = useState
({} as Address); + + // 获取当前位置 + const getCurrentLocation = async () => { + setIsLoading(true); + setIsRefreshing(true); + + try { + // 1. 首先检查当前权限状态 -- 获取当前的位置权限 + let currentStatus = await getLocationPermission(); + + // 2. 如果没有权限,则跳过获取位置 + if (!currentStatus) { + return; + // const newStatus = await requestLocationPermission(); + // setLocationEnabled(newStatus); + // currentStatus = newStatus; + + // if (!currentStatus) { + // // alert('需要位置权限才能继续'); + // return; + // } + } + + // 3. 确保位置服务已启用 + const isEnabled = await Location.hasServicesEnabledAsync(); + if (!isEnabled) { + alert(t('permission.locationPermissionRequired', { ns: 'common' })); + return; + } + // 4. 获取当前位置 + const location = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.High, // 使用高精度 + timeInterval: 10000, // 可选:最大等待时间(毫秒) + }); + + // 地理位置逆编码 + const address = await reverseGeocode(location.coords.latitude, location.coords.longitude); + // 5. 更新位置状态 + if (address) { + setCurrentLocation(address); + } + + return location; + } catch (error: any) { + if (error.code === 'TIMEOUT') { + alert(t('permission.timeout', { ns: 'common' })); + } else { + alert(t('permission.notLocation', { ns: 'common' }) + error.message || t('permission.notError', { ns: 'common' })); + } + throw error; // 重新抛出错误以便上层处理 + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }; + + // 退出登录 + const handleLogout = () => { + fetchApi("/iam/logout", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }) + .then(async (res) => { + await logout(); + router.replace('/login'); + }) + .catch(() => { + console.error("jwt has expired."); + }); + }; + // 检查是否有权限 + useFocusEffect( + useCallback(() => { + let isActive = true; + + const checkPermissions = async () => { + // 位置权限 + const locationRes = await getLocationPermission(); + // 媒体库权限 + const albumRes = await getPermissions(); + // 通知权限 + const notificationRes = await checkNotificationPermission(); + + if (isActive) { + setLocationEnabled(locationRes); + setAlbumEnabled(albumRes); + setNotificationsEnabled(notificationRes); + } + }; + + checkPermissions(); + + return () => { + isActive = false; + }; + }, []) + ); + + // 获取语言环境 + useEffect(() => { + getLanguage(); + getUserInfo() + }, []) + + return ( + + + e.stopPropagation()}> + + { router.push('/owner') }}> + + + {t('generalSetting.allTitle', { ns: 'personal' })} + × + + + {/* 用户信息 */} + + {/* 升级版本 */} + {/* + {t('generalSetting.subscription', { ns: 'personal' })} + + + {t('generalSetting.subscriptionTitle', { ns: 'personal' })} + {t('generalSetting.subscriptionText', { ns: 'personal' })} + + { + + }} + > + + {t('generalSetting.upgrade', { ns: 'personal' })} + + + + */} + {/* 消息通知 */} + {/* + {t('permission.pushNotification', { ns: 'personal' })} + + + {t('permission.pushNotification', { ns: 'personal' })} + + + + */} + {/* 权限信息 */} + + {t('permission.permissionManagement', { ns: 'personal' })} + + {/* 相册权限 */} + + {t('permission.galleryAccess', { ns: 'personal' })} + + + {/* 分割线 */} + + {/* 位置权限 */} + + + {t('permission.locationPermission', { ns: 'personal' })} + + + + + + + {t('permission.pushNotification', { ns: 'personal' })} + + + + {/* 相册成片权限 */} + {/* + + Opus Permission + + + */} + + + {/* 账号 */} + {/* + Account + + + + Notifications + + + + + + Delete Account + + + + */} + {/* 协议 */} + + {t('lcenses.title', { ns: 'personal' })} + + { setModalType('privacy'); setPrivacyModalVisible(true) }} > + {t('lcenses.privacyPolicy', { ns: 'personal' })} + + + + { setModalType('terms'); setPrivacyModalVisible(true) }} > + {t('lcenses.applyPermission', { ns: 'personal' })} + + + + { setModalType('user'); setPrivacyModalVisible(true) }} > + {t('lcenses.userAgreement', { ns: 'personal' })} + + + + { setModalType('ai'); setPrivacyModalVisible(true) }} > + {t('lcenses.aiPolicy', { ns: 'personal' })} + + + + { setLcensesModalVisible(true) }} > + {t('lcenses.qualification', { ns: 'personal' })} + + + + Linking.openURL("https://beian.miit.gov.cn/")} > + {t('lcenses.ICP', { ns: 'personal' })}沪ICP备2025133004号-2A + + + + + {/* 其他信息 */} + + {t('generalSetting.otherInformation', { ns: 'personal' })} + + Linking.openURL("https://work.weixin.qq.com/kfid/kfca0ac87f4e05e8bfd")} > + {t('generalSetting.contactUs', { ns: 'personal' })} + {/* */} + + + + {t('generalSetting.version', { ns: 'personal' })} + {"0.5.0"} + + + + {/* 退出 */} + + {t('generalSetting.logout', { ns: 'personal' })} + + + {/* 注销账号 */} + setDeleteModalVisible(true)}> + {t('generalSetting.deleteAccount', { ns: 'personal' })} + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + centeredView: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + modalView: { + width: '100%', + height: '100%', + backgroundColor: 'white', + paddingHorizontal: 16, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + modalTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#4C320C', + }, + closeButton: { + fontSize: 28, + color: '#4C320C', + padding: 10, + }, + modalContent: { + flex: 1, + }, + modalText: { + fontSize: 16, + color: '#4C320C', + }, + premium: { + backgroundColor: "#FAF9F6", + padding: 16, + borderRadius: 24, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + content: { + flex: 1, + flexDirection: 'column', + gap: 4, + backgroundColor: '#FAF9F6', + borderRadius: 24, + paddingVertical: 8 + }, + item: { + paddingHorizontal: 16, + paddingVertical: 8, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + itemText: { + fontSize: 14, + fontWeight: '600', + color: '#4C320C', + }, + upgradeButton: { + backgroundColor: '#E2793F', + borderRadius: 20, + paddingHorizontal: 16, + paddingVertical: 8, + }, + upgradeButtonText: { + color: '#fff', + fontSize: 14, + fontWeight: "600" + }, + switchContainer: { + width: 50, + height: 30, + borderRadius: 15, + justifyContent: 'center', + paddingHorizontal: 2, + }, + switchOn: { + backgroundColor: '#E2793F', + alignItems: 'flex-end', + }, + switchOff: { + backgroundColor: '#E5E5E5', + alignItems: 'flex-start', + }, + switchCircle: { + width: 26, + height: 26, + borderRadius: 13, + }, + switchCircleOn: { + backgroundColor: 'white', + }, + switchCircleOff: { + backgroundColor: '#A5A5A5', + }, +}); + +const Divider = () => { + return ( + + ) +} +export default Setting; \ No newline at end of file diff --git a/app/(tabs)/top.tsx b/app/(tabs)/top.tsx index e1bf4a0..8916bde 100644 --- a/app/(tabs)/top.tsx +++ b/app/(tabs)/top.tsx @@ -175,7 +175,7 @@ export default function OwnerPage() { Top Memory Makers - 123 + 123 { setLocationModalVisible(true) }} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 8 }}> diff --git a/app/_layout.tsx b/app/_layout.tsx index b9af5a4..3f1d690 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,3 +1,4 @@ +import { PermissionProvider } from '@/context/PermissionContext'; import { useColorScheme } from '@/hooks/useColorScheme'; import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; @@ -30,19 +31,21 @@ export default function RootLayout() { return ( - - - - - - - + + + + + + + + + ); diff --git a/assets/icons/svg/blackStar.svg b/assets/icons/svg/blackStar.svg new file mode 100644 index 0000000..c2c076f --- /dev/null +++ b/assets/icons/svg/blackStar.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/cancel.svg b/assets/icons/svg/cancel.svg new file mode 100644 index 0000000..982309c --- /dev/null +++ b/assets/icons/svg/cancel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/svg/cardBg.svg b/assets/icons/svg/cardBg.svg new file mode 100644 index 0000000..c18f285 --- /dev/null +++ b/assets/icons/svg/cardBg.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/svg/chat.svg b/assets/icons/svg/chat.svg index 882e4e2..6c14fb9 100644 --- a/assets/icons/svg/chat.svg +++ b/assets/icons/svg/chat.svg @@ -1,26 +1,26 @@ - + - + - - - - - + + + + + - - + + - - - - + + + + - + @@ -28,9 +28,9 @@ - + - + @@ -38,7 +38,7 @@ - + diff --git a/assets/icons/svg/choicePay.svg b/assets/icons/svg/choicePay.svg new file mode 100644 index 0000000..cd6758a --- /dev/null +++ b/assets/icons/svg/choicePay.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/comein.svg b/assets/icons/svg/comein.svg new file mode 100644 index 0000000..a38cc64 --- /dev/null +++ b/assets/icons/svg/comein.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/download.svg b/assets/icons/svg/download.svg new file mode 100644 index 0000000..c79fa79 --- /dev/null +++ b/assets/icons/svg/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/svg/free.svg b/assets/icons/svg/free.svg new file mode 100644 index 0000000..3922c40 --- /dev/null +++ b/assets/icons/svg/free.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/svg/get.svg b/assets/icons/svg/get.svg new file mode 100644 index 0000000..1fd3a95 --- /dev/null +++ b/assets/icons/svg/get.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/imgTotal.svg b/assets/icons/svg/imgTotal.svg new file mode 100644 index 0000000..e56e54f --- /dev/null +++ b/assets/icons/svg/imgTotal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/svg/imgTotalWhite.svg b/assets/icons/svg/imgTotalWhite.svg new file mode 100644 index 0000000..e23ab51 --- /dev/null +++ b/assets/icons/svg/imgTotalWhite.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/svg/line.svg b/assets/icons/svg/line.svg new file mode 100644 index 0000000..5cebb9e --- /dev/null +++ b/assets/icons/svg/line.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/liveTotal.svg b/assets/icons/svg/liveTotal.svg new file mode 100644 index 0000000..f0faf33 --- /dev/null +++ b/assets/icons/svg/liveTotal.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/svg/logo.svg b/assets/icons/svg/logo.svg new file mode 100644 index 0000000..498b298 --- /dev/null +++ b/assets/icons/svg/logo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/svg/memberBg.svg b/assets/icons/svg/memberBg.svg new file mode 100644 index 0000000..0eb272e --- /dev/null +++ b/assets/icons/svg/memberBg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/svg/navBg.svg b/assets/icons/svg/navBg.svg new file mode 100644 index 0000000..98e360b --- /dev/null +++ b/assets/icons/svg/navBg.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/svg/people.svg b/assets/icons/svg/people.svg new file mode 100644 index 0000000..b83b918 --- /dev/null +++ b/assets/icons/svg/people.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/pro.svg b/assets/icons/svg/pro.svg new file mode 100644 index 0000000..0380f08 --- /dev/null +++ b/assets/icons/svg/pro.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/svg/proCard.svg b/assets/icons/svg/proCard.svg new file mode 100644 index 0000000..834fb22 --- /dev/null +++ b/assets/icons/svg/proCard.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/svg/proSecondText.svg b/assets/icons/svg/proSecondText.svg new file mode 100644 index 0000000..f6718ae --- /dev/null +++ b/assets/icons/svg/proSecondText.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/svg/proText.svg b/assets/icons/svg/proText.svg new file mode 100644 index 0000000..9fda745 --- /dev/null +++ b/assets/icons/svg/proText.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/svg/rightCard.svg b/assets/icons/svg/rightCard.svg new file mode 100644 index 0000000..80ed82e --- /dev/null +++ b/assets/icons/svg/rightCard.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/svg/righttopDate.svg b/assets/icons/svg/righttopDate.svg new file mode 100644 index 0000000..49dc48c --- /dev/null +++ b/assets/icons/svg/righttopDate.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/send.svg b/assets/icons/svg/send.svg index 0ad0ce9..400ebe4 100644 --- a/assets/icons/svg/send.svg +++ b/assets/icons/svg/send.svg @@ -1,3 +1,9 @@ +<<<<<<< HEAD +======= + + + +>>>>>>> v_1.0.1 diff --git a/assets/icons/svg/star.svg b/assets/icons/svg/star.svg new file mode 100644 index 0000000..af626a2 --- /dev/null +++ b/assets/icons/svg/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/sun.svg b/assets/icons/svg/sun.svg new file mode 100644 index 0000000..ad68ee6 --- /dev/null +++ b/assets/icons/svg/sun.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/timeTotal.svg b/assets/icons/svg/timeTotal.svg new file mode 100644 index 0000000..653117d --- /dev/null +++ b/assets/icons/svg/timeTotal.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/timeTotalWhite.svg b/assets/icons/svg/timeTotalWhite.svg new file mode 100644 index 0000000..1c818c4 --- /dev/null +++ b/assets/icons/svg/timeTotalWhite.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/userinfoTotal.svg b/assets/icons/svg/userinfoTotal.svg new file mode 100644 index 0000000..104d1c7 --- /dev/null +++ b/assets/icons/svg/userinfoTotal.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/svg/video.svg b/assets/icons/svg/video.svg new file mode 100644 index 0000000..e5a979c --- /dev/null +++ b/assets/icons/svg/video.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/videoTotal.svg b/assets/icons/svg/videoTotal.svg new file mode 100644 index 0000000..fa5f97a --- /dev/null +++ b/assets/icons/svg/videoTotal.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/videoTotalWhite.svg b/assets/icons/svg/videoTotalWhite.svg new file mode 100644 index 0000000..a91c405 --- /dev/null +++ b/assets/icons/svg/videoTotalWhite.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/svg/whiteStart.svg b/assets/icons/svg/whiteStart.svg new file mode 100644 index 0000000..17fc045 --- /dev/null +++ b/assets/icons/svg/whiteStart.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/png/icon/doneIP.png b/assets/images/png/icon/doneIP.png new file mode 100644 index 0000000..641fecd Binary files /dev/null and b/assets/images/png/icon/doneIP.png differ diff --git a/assets/images/png/icon/ip.png b/assets/images/png/icon/ip.png new file mode 100644 index 0000000..2355ed9 Binary files /dev/null and b/assets/images/png/icon/ip.png differ diff --git a/assets/images/png/icon/ipNoHands.png b/assets/images/png/icon/ipNoHands.png new file mode 100644 index 0000000..dcd9b98 Binary files /dev/null and b/assets/images/png/icon/ipNoHands.png differ diff --git a/assets/images/png/icon/ipNoHandsEyes.png b/assets/images/png/icon/ipNoHandsEyes.png new file mode 100644 index 0000000..6c44a2c Binary files /dev/null and b/assets/images/png/icon/ipNoHandsEyes.png differ diff --git a/assets/images/png/icon/think.png b/assets/images/png/icon/think.png new file mode 100644 index 0000000..db3e640 Binary files /dev/null and b/assets/images/png/icon/think.png differ diff --git a/assets/images/png/owner/ask.png b/assets/images/png/owner/ask.png index 8c5f798..0fe206a 100644 Binary files a/assets/images/png/owner/ask.png and b/assets/images/png/owner/ask.png differ diff --git a/assets/images/png/owner/askIP.png b/assets/images/png/owner/askIP.png new file mode 100644 index 0000000..4450a2d Binary files /dev/null and b/assets/images/png/owner/askIP.png differ diff --git a/assets/images/png/owner/normal.png b/assets/images/png/owner/normal.png new file mode 100644 index 0000000..302d82e Binary files /dev/null and b/assets/images/png/owner/normal.png differ diff --git a/assets/images/png/owner/pro.png b/assets/images/png/owner/pro.png new file mode 100644 index 0000000..e1ad44c Binary files /dev/null and b/assets/images/png/owner/pro.png differ diff --git a/assets/images/png/placeholder.png b/assets/images/png/placeholder.png new file mode 100644 index 0000000..0565ebd Binary files /dev/null and b/assets/images/png/placeholder.png differ diff --git a/components/ask/aiChat.tsx b/components/ask/aiChat.tsx deleted file mode 100644 index 5e0a5e3..0000000 --- a/components/ask/aiChat.tsx +++ /dev/null @@ -1,472 +0,0 @@ -import ChatSvg from "@/assets/icons/svg/chat.svg"; -import FolderSvg from "@/assets/icons/svg/folder.svg"; -import MoreSvg from "@/assets/icons/svg/more.svg"; -import ReturnArrow from "@/assets/icons/svg/returnArrow.svg"; -import YesSvg from "@/assets/icons/svg/yes.svg"; -import { Message, Video } from "@/types/ask"; -import { MaterialItem } from "@/types/personal-info"; -import { TFunction } from "i18next"; -import React from 'react'; -import { - FlatList, - Image, - Modal, - Pressable, - StyleSheet, - Text, - TouchableOpacity, - View -} from 'react-native'; -import { ThemedText } from "../ThemedText"; -import TypewriterText from "./typewriterText"; -import { mergeArrays } from "./utils"; -import VideoPlayer from "./VideoPlayer"; - -interface RenderMessageProps { - insets: { top: number }; - item: Message; - sessionId: string; - setModalVisible: React.Dispatch>; - modalVisible: { visible: boolean, data: Video | MaterialItem }; - setModalDetailsVisible: React.Dispatch>; - modalDetailsVisible: boolean; - setSelectedImages: React.Dispatch>; - selectedImages: string[]; - t: TFunction; -} - -const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => { - const isUser = item.role === 'User'; - const isVideo = (data: Video | MaterialItem): data is Video => { - return 'video' in data; - }; - - return ( - - {!isUser && } - - 0 || item.content.image_material_infos && item.content.image_material_infos.length > 0) ? '!rounded-t-3xl !rounded-b-2xl' : '!rounded-3xl'}`} - > - - - {!isUser - ? - sessionId ? item.content.text : - : item.content.text - } - - - {(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.length || 0 > 0) && ( - - - {mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.slice(0, 3)?.map((image) => ( - { - setModalVisible({ visible: true, data: image }); - }} - style={{ - width: '32%', - aspectRatio: 1, - marginBottom: 8, - }} - > - - - ))} - - { - ((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3 - && { - setModalDetailsVisible(true); - }}> - {((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0))} - - - - - } - - )} - - - {/* {item.askAgain && item.askAgain.length > 0 && ( - - {item.askAgain.map((suggestion, index, array) => ( - - {suggestion.text} - - ))} - - )} */} - { - setModalVisible({ visible: false, data: {} as Video | MaterialItem }); - }}> - - { - setModalVisible({ visible: false, data: {} as Video | MaterialItem }) - }} - /> - setModalVisible({ visible: false, data: {} as Video | MaterialItem })}> - {isVideo(modalVisible.data) ? ( - // 视频播放器 - setModalVisible({ visible: false, data: {} as Video | MaterialItem })} - > - setModalVisible({ visible: false, data: {} as Video | MaterialItem })} - /> - - ) : ( - // 图片预览 - setModalVisible({ visible: false, data: {} as Video | MaterialItem })} - style={styles.imageContainer} - > - - - )} - - - - { - setModalDetailsVisible(false); - }} - > - - - setModalDetailsVisible(false)}> - - - {t('ask.selectPhoto', { ns: 'ask' })} - - - - item.id} - showsVerticalScrollIndicator={false} - contentContainerStyle={detailsStyles.flatListContent} - initialNumToRender={12} - maxToRenderPerBatch={12} - updateCellsBatchingPeriod={50} - windowSize={10} - removeClippedSubviews={true} - renderItem={({ item }) => { - return ( - - - - {selectedImages?.map((image, index) => { - if (image === item.id || image === item.video?.id) { - return index + 1 - } - })} - - console.log('Image load error:', error.nativeEvent.error)} - onLoad={() => console.log('Image loaded successfully')} - /> - { - setSelectedImages((prev) => { - if (prev.includes(item?.id || item?.video?.id)) { - return prev.filter((id) => id !== (item.id || item?.video?.id)); - } else { - return [...prev, item.id || item.video?.id]; - } - }); - }} - > - {selectedImages.includes(item?.id || item?.video?.id) ? : ""} - - - - ); - }} - /> - - - { - // 如果用户没有选择 则为选择全部 - if (selectedImages?.length < 0) { - setSelectedImages(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.map((item) => { - return item.id || item.video?.id - })) - } - setModalDetailsVisible(false) - }} - activeOpacity={0.8} - > - - {t('ask.continueAsking', { ns: 'ask' })} - - - - - - - - ); -}; - -export default MessageItem; - -const styles = StyleSheet.create({ - imageGridContainer: { - flexDirection: 'row', - flexWrap: 'nowrap', - width: '100%', - marginTop: 8, - }, - video: { - width: '100%', - height: '100%', - }, - imageContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - width: '100%', - maxHeight: '60%', - }, - fullWidthImage: { - width: '100%', - height: "54%", - marginBottom: 8, - }, - gridImage: { - aspectRatio: 1, - marginBottom: 8, - }, - background: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.5)', // 添加半透明黑色背景 - }, - centeredView: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - modalView: { - borderRadius: 20, - alignItems: 'center', - height: '100%', - width: "100%", - justifyContent: 'center', - alignSelf: 'center', - }, - container: { - flex: 1, - backgroundColor: '#f5f5f5', - }, - userAvatar: { - width: 30, - height: 30, - borderRadius: 15, - }, - messageList: { - padding: 16, - }, - messageBubble: { - paddingHorizontal: 16, - paddingVertical: 12, - fontWeight: "600" - }, - userBubble: { - alignSelf: 'flex-end', - backgroundColor: '#FFB645', - marginLeft: '20%', - }, - aiBubble: { - alignSelf: 'flex-start', - backgroundColor: '#fff', - marginRight: '20%', - borderWidth: 1, - borderColor: '#e5e5ea', - }, - userText: { - color: '#4C320C', - fontSize: 16, - }, - aiText: { - color: '#000', - fontSize: 16, - }, -}); - -const detailsStyles = StyleSheet.create({ - gridItemContainer: { - flex: 1, // 使用 flex 布局使项目平均分配空间 - maxWidth: '33.33%', // 每行最多4个项目 - aspectRatio: 1, // 保持1:1的宽高比 - }, - flatListContent: { - paddingBottom: 100, // 为底部按钮留出更多空间 - paddingHorizontal: 8, // 添加水平内边距 - paddingTop: 8, - }, - headerText: { - fontSize: 20, - fontWeight: 'bold', - color: "#4C320C" - }, - container: { - flex: 1, - padding: 0, - margin: 0, - backgroundColor: '#fff', - width: '100%', - height: '100%', - position: 'relative', - }, - imageNumber: { - fontSize: 16, - fontWeight: 'bold', - color: '#fff', - position: 'absolute', - top: 10, - left: 10, - zIndex: 10, // 确保数字显示在图片上方 - }, - imageNumberText: { - fontSize: 16, - fontWeight: 'bold', - color: '#fff', - }, - numberText: { - position: 'absolute', - top: 10, - left: 10, - width: 28, - height: 28, - borderRadius: 14, - backgroundColor: 'rgba(0, 122, 255, 0.9)', // 使用半透明蓝色背景 - justifyContent: 'center', - alignItems: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - elevation: 5, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 16, - borderBottomWidth: 1, - borderBottomColor: '#eee', - }, - gridItem: { - flex: 1, // 填充父容器 - overflow: 'hidden', - backgroundColor: '#f5f5f5', - borderWidth: 1, - borderColor: '#eee', - height: '100%', // 确保高度填满容器 - position: 'relative', - }, - image: { - width: '100%', - height: '100%', - resizeMode: 'cover', - }, - circleMarker: { - position: 'absolute', - top: 10, - right: 10, - width: 28, - height: 28, - borderRadius: 14, - justifyContent: 'center', - alignItems: 'center', - borderWidth: 3, - borderColor: '#fff', - }, - circleMarkerSelected: { - backgroundColor: '#FFB645', - }, - markerText: { - fontSize: 16, - fontWeight: 'bold', - color: '#000', - }, - footer: { - position: 'absolute', - bottom: 20, - left: 0, - right: 0, - paddingHorizontal: 16, - zIndex: 10, - paddingVertical: 10, - }, - continueButton: { - backgroundColor: '#E2793F', - borderRadius: 32, - padding: 16, - alignItems: 'center', - width: '100%', - zIndex: 10, - }, - continueButtonText: { - color: '#fff', - fontSize: 18, - fontWeight: 'bold', - } -}); \ No newline at end of file diff --git a/components/ask/chat.tsx b/components/ask/chat.tsx index 4af0f6a..f5e4b80 100644 --- a/components/ask/chat.tsx +++ b/components/ask/chat.tsx @@ -1,62 +1,131 @@ -import { Message, Video } from '@/types/ask'; -import { MaterialItem } from '@/types/personal-info'; -import React, { Dispatch, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ContentPart, Message } from '@/types/ask'; +import React, { Dispatch, ForwardedRef, forwardRef, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FlatList, - SafeAreaView + FlatListProps, + SafeAreaView, + View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import MessageItem from './aiChat'; +import MessageItem from '../chat/message-item/message-item'; +import SelectModel from "./selectModel"; +import SingleContentModel from "./singleContentModel"; -interface ChatProps { + + +// 继承 FlatListProps 来接收所有 FlatList 的属性 +interface ChatProps extends Omit, 'data' | 'renderItem'> { userMessages: Message[]; sessionId: string; setSelectedImages: Dispatch>; selectedImages: string[]; } -function ChatComponent({ userMessages, sessionId, setSelectedImages, selectedImages }: ChatProps) { - const flatListRef = useRef(null); +function ChatComponent( + { userMessages, sessionId, setSelectedImages, selectedImages, ...restProps }: ChatProps, + ref: ForwardedRef> +) { const insets = useSafeAreaInsets(); - const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem }); + const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as ContentPart }); const { t } = useTranslation(); - // 使用 useCallback 缓存 keyExtractor 函数 const keyExtractor = useCallback((item: Message) => `${item.role}-${item.timestamp}`, []); + // 取消展示右键菜单 + const [cancel, setCancel] = useState(true); + const contentContainerStyle = useMemo(() => ({ + padding: 16, + flexGrow: 1, + paddingTop: 0, + }), []); - // 使用 useMemo 缓存样式对象 - const contentContainerStyle = useMemo(() => ({ padding: 16 }), []); - - // 详情弹窗 - const [modalDetailsVisible, setModalDetailsVisible] = useState(false); + const [modalDetailsVisible, setModalDetailsVisible] = useState<{ visible: boolean, content: any }>({ visible: false, content: [] }); + const flatListRef = useRef(null); + const prevMessagesLength = useRef(0); // 自动滚动到底部 useEffect(() => { - if (userMessages.length > 0) { + if (userMessages.length > 0 && userMessages.length !== prevMessagesLength.current) { setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100); + prevMessagesLength.current = userMessages.length; } - }, [userMessages]); + }, [userMessages.length]); + + const renderMessageItem = useCallback(({ item, index }: { item: Message, index: number }) => { + const itemStyle = index === 0 ? { marginTop: 16, marginHorizontal: 16 } : { marginHorizontal: 16 }; + return ( + + + + ); + }, [insets, sessionId, modalVisible, modalDetailsVisible, selectedImages, t, cancel]); return ( - + { + // 处理转发 ref 和内部 ref + if (ref) { + if (typeof ref === 'function') { + ref(node); + } else { + ref.current = node; + } + } + flatListRef.current = node; + }} data={userMessages} keyExtractor={keyExtractor} + renderItem={renderMessageItem} contentContainerStyle={contentContainerStyle} keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled" removeClippedSubviews={true} maxToRenderPerBatch={10} updateCellsBatchingPeriod={50} initialNumToRender={10} windowSize={11} - renderItem={({ item }) => MessageItem({ t, setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })} + onContentSizeChange={() => { + if (userMessages.length > 0) { + flatListRef.current?.scrollToEnd({ animated: true }); + } + }} + onLayout={() => { + if (userMessages.length > 0) { + flatListRef.current?.scrollToEnd({ animated: false }); + } + }} + {...restProps} + /> + {/* 单个图片弹窗 */} + + {/* 全部图片详情弹窗 */} + ); } -// 使用 React.memo 包装组件,避免不必要的重渲染 -export default memo(ChatComponent); \ No newline at end of file +export default React.memo(forwardRef(ChatComponent)); \ No newline at end of file diff --git a/components/ask/hello.tsx b/components/ask/hello.tsx index e47aea1..8f7acba 100644 --- a/components/ask/hello.tsx +++ b/components/ask/hello.tsx @@ -1,39 +1,122 @@ -import IP from "@/assets/icons/svg/ip.svg"; import { ThemedText } from "@/components/ThemedText"; +import { webSocketManager } from "@/lib/websocket-util"; +import { Message } from "@/types/ask"; +import { Dispatch, SetStateAction } from "react"; import { useTranslation } from "react-i18next"; -import { ScrollView, View } from 'react-native'; +import { Dimensions, Image, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { createNewConversation } from "./utils"; -export default function AskHello() { +interface AskHelloProps { + setUserMessages: Dispatch>; + setConversationId: Dispatch>; + setIsHello: Dispatch>; +} +export default function AskHello({ setUserMessages, setConversationId, setIsHello }: AskHelloProps) { const { t } = useTranslation(); + const width = Dimensions.get('window').width; + const height = Dimensions.get('window').height; + const handleCase = async (text: string) => { + setIsHello(false); + setUserMessages([ + { + id: Math.random().toString(36).substring(2, 9), + content: text, + role: 'user', + timestamp: new Date().toISOString() + }, + { + id: Math.random().toString(36).substring(2, 9), + content: "keepSearchIng", + role: 'assistant', + timestamp: new Date().toISOString() + } + ]); + + const sessionId = await createNewConversation(text); + if (sessionId) { + setConversationId(sessionId); + webSocketManager.send({ + type: 'Chat', + session_id: sessionId, + message: text + }); + } else { + console.error("Failed to create a new conversation."); + setUserMessages(prev => prev.filter(item => item.content !== 'keepSearchIng')); + } + } return ( - + {t('ask.hi', { ns: 'ask' })} {"\n"} {t('ask.iAmMemo', { ns: 'ask' })} - - + + - + {t('ask.ready', { ns: 'ask' })} {"\n"} {t('ask.justAsk', { ns: 'ask' })} + + { + handleCase(t('ask:ask.case1')); + }}> + + {t('ask:ask.case1')} + + + { + handleCase(t('ask:ask.case2')); + }}> + + {t('ask:ask.case2')} + + + { + handleCase(t('ask:ask.case3')); + }}> + + {t('ask:ask.case3')} + + + ); -} \ No newline at end of file +} + + +const styles = StyleSheet.create({ + caseContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 8, + width: '100%', + marginTop: 16 + }, + case: { + borderWidth: 1, + borderColor: "#AC7E35", + borderRadius: 10, + paddingHorizontal: 8, + width: 'auto', + fontSize: 14, + color: "#4C320C" + } +}) \ No newline at end of file diff --git a/components/ask/selectModel.tsx b/components/ask/selectModel.tsx new file mode 100644 index 0000000..d9c34c7 --- /dev/null +++ b/components/ask/selectModel.tsx @@ -0,0 +1,296 @@ +import CancelSvg from '@/assets/icons/svg/cancel.svg'; +import DownloadSvg from '@/assets/icons/svg/download.svg'; +import FolderSvg from "@/assets/icons/svg/folder.svg"; +import ReturnArrow from "@/assets/icons/svg/returnArrow.svg"; +import YesSvg from "@/assets/icons/svg/yes.svg"; +import { ContentPart } from '@/types/ask'; +import { TFunction } from "i18next"; +import React from "react"; +import { FlatList, Image, Modal, StyleSheet, TouchableOpacity, View } from "react-native"; +import ContextMenu from "../gusture/contextMenu"; +import { ThemedText } from "../ThemedText"; +import { mergeArrays, saveMediaToGallery } from "./utils"; +import VideoPlayer from './VideoPlayer'; + +interface SelectModelProps { + modalDetailsVisible: { visible: boolean, content: any }; + setModalDetailsVisible: React.Dispatch>; + insets: { top: number }; + setSelectedImages: React.Dispatch>; + selectedImages: string[]; + t: TFunction; + cancel: boolean; + setCancel: React.Dispatch>; +} +const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setSelectedImages, selectedImages, t, cancel, setCancel }: SelectModelProps) => { + const items = modalDetailsVisible?.content?.filter((media: ContentPart) => media.type !== 'text'); + + return ( + { + setModalDetailsVisible({ visible: false, content: [] }); + }} + > + + + setModalDetailsVisible({ visible: false, content: [] })}> + + + {t('ask.selectPhoto', { ns: 'ask' })} + + + + item.id} + showsVerticalScrollIndicator={false} + contentContainerStyle={detailsStyles.gridContainer} + initialNumToRender={12} + maxToRenderPerBatch={12} + updateCellsBatchingPeriod={50} + windowSize={10} + removeClippedSubviews={true} + renderItem={({ item }) => { + const itemId = item?.id || item?.video?.id; + const isSelected = selectedImages.includes(itemId); + + return ( + + + + {isSelected && ( + + {selectedImages.indexOf(itemId) + 1} + + )} + , + label: t("ask:ask.save"), + onPress: () => { + const imageUrl = item?.url; + if (imageUrl) { + saveMediaToGallery(imageUrl, t); + } + }, + textStyle: { color: '#4C320C' } + }, + { + svg: , + label: t("ask:ask.cancel"), + onPress: () => setCancel(true), + textStyle: { color: 'red' } + } + ]} + cancel={cancel} + menuStyle={{ + backgroundColor: 'white', + borderRadius: 8, + padding: 8, + minWidth: 150, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }} + > + {item?.type === 'image' ? ( + console.log('Image load error:', error.nativeEvent.error)} + onLoad={() => console.log('Image loaded successfully')} + loadingIndicatorSource={require('@/assets/images/png/placeholder.png')} + /> + ) : ( + + )} + + + { + setSelectedImages(prev => + isSelected + ? prev.filter(id => id !== itemId) + : [...prev, itemId] + ); + }} + activeOpacity={0.8} + > + {isSelected && } + + + + ); + }} + /> + + + { + // 如果用户没有选择 则为选择全部 + if (selectedImages?.length < 0) { + setSelectedImages(mergeArrays(modalDetailsVisible?.content?.image_material_infos || [], modalDetailsVisible?.content?.video_material_infos || [])?.map((item) => { + return item.id || item.video?.id + })) + } + setModalDetailsVisible({ visible: false, content: [] }) + }} + activeOpacity={0.8} + > + + {t('ask.continueAsking', { ns: 'ask' })} + + + + + + ) +} + + +const detailsStyles = StyleSheet.create({ + gridContainer: { + flex: 1, + paddingHorizontal: 8, + paddingTop: 8, + }, + gridItemContainer: { + width: '33.33%', + aspectRatio: 1, + padding: 1, + }, + flatListContent: { + paddingBottom: 100, + paddingHorizontal: 8, + paddingTop: 8, + }, + headerText: { + fontSize: 20, + fontWeight: 'bold', + color: "#4C320C" + }, + container: { + flex: 1, + padding: 0, + margin: 0, + backgroundColor: '#fff', + width: '100%', + height: '100%', + position: 'relative', + }, + imageNumber: { + fontSize: 16, + fontWeight: 'bold', + color: '#fff', + position: 'absolute', + top: 10, + left: 10, + zIndex: 10, + }, + imageNumberText: { + fontSize: 16, + fontWeight: 'bold', + color: '#fff', + }, + numberText: { + position: 'absolute', + top: 10, + left: 10, + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: 'rgba(0, 122, 255, 0.9)', + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + gridItem: { + flex: 1, + backgroundColor: '#f5f5f5', + borderRadius: 8, + overflow: 'hidden', + position: 'relative', + }, + image: { + width: '100%', + height: '100%', + resizeMode: 'cover', + }, + circleMarker: { + position: 'absolute', + top: 10, + right: 10, + width: 28, + height: 28, + borderRadius: 14, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: '#fff', + }, + circleMarkerSelected: { + backgroundColor: '#FFB645', + }, + markerText: { + fontSize: 16, + fontWeight: 'bold', + color: '#000', + }, + footer: { + position: 'absolute', + bottom: 20, + left: 0, + right: 0, + paddingHorizontal: 16, + zIndex: 10, + paddingVertical: 10, + }, + continueButton: { + backgroundColor: '#E2793F', + borderRadius: 32, + padding: 16, + alignItems: 'center', + width: '100%', + zIndex: 10, + }, + continueButtonText: { + color: '#fff', + fontSize: 18, + fontWeight: 'bold', + } +}); + +export default SelectModel \ No newline at end of file diff --git a/components/ask/send.tsx b/components/ask/send.tsx index 6dea33e..1f4e291 100644 --- a/components/ask/send.tsx +++ b/components/ask/send.tsx @@ -1,16 +1,22 @@ 'use client'; import SendSvg from '@/assets/icons/svg/send.svg'; -import React, { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'; +import SunSvg from '@/assets/icons/svg/sun.svg'; +import VideoSvg from '@/assets/icons/svg/video.svg'; +import React, { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; import { Keyboard, + ScrollView, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; -import { fetchApi } from '@/lib/server-api-util'; +import { webSocketManager, WsMessage } from '@/lib/websocket-util'; import { Message } from '@/types/ask'; +import { useTranslation } from 'react-i18next'; +import { ThemedText } from '../ThemedText'; +import { createNewConversation } from './utils'; interface Props { setIsHello: Dispatch>, @@ -20,97 +26,232 @@ interface Props { selectedImages: string[]; setSelectedImages: Dispatch>; } +const RENDER_INTERVAL = 50; // 渲染间隔,单位毫秒 + export default function SendMessage(props: Props) { const { setIsHello, conversationId, setUserMessages, setConversationId, selectedImages, setSelectedImages } = props; + const { t } = useTranslation() + // 用户询问 const [inputValue, setInputValue] = useState(''); - // 创建新对话并获取消息 - const createNewConversation = useCallback(async (user_text: string) => { - const data = await fetchApi("/chat/new", { - method: "POST", - }); - setConversationId(data); - await getConversation({ session_id: data, user_text, material_ids: [] }); - }, []); + // 添加一个ref来跟踪键盘状态 + const isKeyboardVisible = useRef(false); + const chunkQueue = useRef([]); + const renderInterval = useRef | null>(null); - // 获取对话信息 - const getConversation = useCallback(async ({ session_id, user_text, material_ids }: { session_id: string, user_text: string, material_ids: string[] }) => { - // 获取对话信息必须要有对话id - if (!session_id) return; + useEffect(() => { + const handleChatStream = (message: WsMessage) => { + if (message.type !== 'ChatStream' || !message.chunk) return; - const response = await fetchApi(`/chat`, { - method: "POST", - body: JSON.stringify({ - session_id, - user_text, - material_ids - }) + chunkQueue.current.push(message.chunk); + + if (!renderInterval.current) { + renderInterval.current = setInterval(() => { + if (chunkQueue.current.length > 0) { + const textToRender = chunkQueue.current.join(''); + chunkQueue.current = []; + + setUserMessages(prevMessages => { + if (prevMessages.length === 0) return prevMessages; + + const lastMessage = prevMessages[prevMessages.length - 1]; + if (lastMessage.role !== 'assistant') return prevMessages; + + const updatedContent = (lastMessage.content === 'keepSearchIng' ? '' : lastMessage.content) + textToRender; + + const updatedLastMessage = { ...lastMessage, content: updatedContent }; + + return [...prevMessages.slice(0, -1), updatedLastMessage]; + }); + } else { + if (renderInterval.current) { + clearInterval(renderInterval.current); + renderInterval.current = null; + } + } + }, RENDER_INTERVAL); + } + }; + + const handleChatStreamEnd = (message: WsMessage) => { + if (message.type !== 'ChatStreamEnd') return; + + // Stop the timer and process any remaining chunks + if (renderInterval.current) { + clearInterval(renderInterval.current); + renderInterval.current = null; + } + + const remainingText = chunkQueue.current.join(''); + chunkQueue.current = []; + + setUserMessages(prevMessages => { + if (prevMessages.length === 0) return prevMessages; + + const lastMessage = prevMessages[prevMessages.length - 1]; + if (lastMessage.role !== 'assistant') return prevMessages; + + // Apply remaining chunks from the queue + const contentWithQueue = (lastMessage.content === 'keepSearchIng' ? '' : lastMessage.content) + remainingText; + + // Create the final updated message object + const updatedLastMessage = { + ...lastMessage, + // Use the final message from ChatStreamEnd if available, otherwise use the content with queued text + content: message.message ? message.message.content : contentWithQueue, + timestamp: message.message ? message.message.timestamp : lastMessage.timestamp, + }; + + return [...prevMessages.slice(0, -1), updatedLastMessage]; + }); + }; + + const handleChatResponse = (message: WsMessage) => { + if (message.type === 'ChatResponse' && message.message) { + setUserMessages(prev => { + const newMessages = prev.filter(item => item.content !== 'keepSearchIng'); + return [...newMessages, { + ...(message.message as Message), + role: 'assistant', + }]; + }); + } + } + + const typedHandleChatStream = handleChatStream as (message: WsMessage) => void; + const typedHandleChatStreamEnd = handleChatStreamEnd as (message: WsMessage) => void; + const typedHandleChatResponse = handleChatResponse as (message: WsMessage) => void; + + webSocketManager.subscribe('ChatStream', typedHandleChatStream); + webSocketManager.subscribe('ChatStreamEnd', typedHandleChatStreamEnd); + webSocketManager.subscribe('ChatResponse', typedHandleChatResponse); + + return () => { + webSocketManager.unsubscribe('ChatStream', typedHandleChatStream); + webSocketManager.unsubscribe('ChatStreamEnd', typedHandleChatStreamEnd); + webSocketManager.unsubscribe('ChatResponse', typedHandleChatResponse); + if (renderInterval.current) { + clearInterval(renderInterval.current); + } + }; + }, [setUserMessages]); + + useEffect(() => { + // 使用keyboardWillShow而不是keyboardDidShow,这样可以在键盘完全显示前更新UI + const showSubscription = Keyboard.addListener('keyboardWillShow', () => { + isKeyboardVisible.current = true; + if (!conversationId) { + // 确保在下一个事件循环中更新状态,避免可能的渲染问题 + requestAnimationFrame(() => { + setIsHello(false); + setUserMessages([ + { + id: Math.random().toString(36).substring(2, 9), + content: t("ask:ask.introduction1"), + role: 'assistant', + timestamp: new Date().toISOString() + } + ]) + }); + } }); - setSelectedImages([]); - setUserMessages((prev: Message[]) => [...prev, response]?.filter((item: Message) => item.content.text !== '正在寻找,请稍等...')); - }, []); + + const hideSubscription = Keyboard.addListener('keyboardWillHide', () => { + isKeyboardVisible.current = false; + }); + + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, [conversationId, setIsHello, setUserMessages, t]); + // 发送询问 - const handleSubmit = () => { - const text = inputValue; + const handleSubmit = useCallback(async () => { + const text = inputValue.trim(); // 用户输入信息之后进行后续操作 if (text) { // 将用户输入信息添加到消息列表中 setUserMessages(pre => ([...pre, { - content: { - text: text - }, - role: 'User', + id: Math.random().toString(36).substring(2, 9), + content: text, + role: 'user', timestamp: new Date().toISOString() }, { - content: { - text: "正在寻找,请稍等..." - }, - role: 'Assistant', + id: Math.random().toString(36).substring(2, 9), + content: "keepSearchIng", + role: 'assistant', timestamp: new Date().toISOString() } ])); - // 如果没有对话ID,创建新对话并获取消息,否则直接获取消息 - if (!conversationId) { - createNewConversation(text); - } else { - getConversation({ - session_id: conversationId, - user_text: text, - material_ids: selectedImages + let currentSessionId = conversationId; + // 如果没有对话ID,先创建一个新对话 + if (!currentSessionId) { + currentSessionId = await createNewConversation(text); + setConversationId(currentSessionId); + webSocketManager.send({ + type: 'Chat', + session_id: currentSessionId, + message: text, + image_material_ids: selectedImages.length > 0 ? selectedImages : undefined, }); + setSelectedImages([]); + } + + // 通过 WebSocket 发送消息 + if (currentSessionId) { + webSocketManager.send({ + type: 'Chat', + session_id: currentSessionId, + message: text, + image_material_ids: selectedImages.length > 0 ? selectedImages : undefined, + }); + setSelectedImages([]); + } else { + console.error("无法获取 session_id,消息发送失败。"); + // 可以在这里处理错误,例如显示一个提示 + setUserMessages(prev => prev.filter(item => item.content !== 'keepSearchIng')); } // 将输入框清空 setInputValue(''); - } - } - useEffect(() => { - const keyboardWillShowListener = Keyboard.addListener( - 'keyboardWillShow', - () => { - console.log('Keyboard will show'); - setIsHello(false); - setUserMessages([{ - content: { - text: "快来寻找你的记忆吧。。。" - }, - role: 'Assistant', - timestamp: new Date().toISOString() - }]) + // 只有在键盘可见时才关闭键盘 + if (isKeyboardVisible.current) { + Keyboard.dismiss(); } - ); + } + }, [inputValue, conversationId, selectedImages, createNewConversation, setConversationId, setSelectedImages, setUserMessages]); - return () => { - keyboardWillShowListener.remove(); - }; - }, []); + const handleQuitly = (type: string) => { + setIsHello(false) + setUserMessages(pre => ([ + ...pre, + { + id: Math.random().toString(36).substring(2, 9), + content: type === "search" + ? t("ask:ask.introduction2") + : t("ask:ask.introduction3"), + role: 'assistant', + timestamp: new Date().toISOString() + } + ])) + }; return ( + + handleQuitly('search')}> + + {t("ask:ask.search")} + handleQuitly('video')}> + + {t("ask:ask.video")} + + - - - + @@ -138,27 +280,41 @@ export default function SendMessage(props: Props) { } const styles = StyleSheet.create({ + button: { + paddingHorizontal: 8, + paddingVertical: 4, + margin: 5, + borderRadius: 25, + alignItems: 'center', + borderWidth: 2, + display: 'flex', + flexDirection: 'row', + gap: 5, + // backgroundColor: '#F8F8F8' + }, container: { justifyContent: 'center', backgroundColor: '#transparent', }, input: { - borderColor: '#FF9500', + // borderColor: '#d9d9d9', + borderColor: '#AC7E35', borderWidth: 1, + // borderRadius: 18, borderRadius: 25, paddingHorizontal: 20, - paddingVertical: 12, + paddingVertical: 13, + lineHeight: 20, fontSize: 16, width: '100%', // 确保输入框宽度撑满 paddingRight: 50 }, voiceButton: { - width: 40, - height: 40, + padding: 8, borderRadius: 20, backgroundColor: '#FF9500', justifyContent: 'center', alignItems: 'center', - marginRight: 8, // 添加一点 + marginRight: 8, // 添加一点右边距 }, }); \ No newline at end of file diff --git a/components/ask/singleContentModel.tsx b/components/ask/singleContentModel.tsx new file mode 100644 index 0000000..6d69f10 --- /dev/null +++ b/components/ask/singleContentModel.tsx @@ -0,0 +1,158 @@ +import { ContentPart } from "@/types/ask"; +import { Image, Modal, StyleSheet, TouchableOpacity, View } from "react-native"; +import VideoPlayer from "./VideoPlayer"; + +interface SingleContentModelProps { + modalVisible: { visible: boolean, data: ContentPart }; + setModalVisible: React.Dispatch>; +} +const SingleContentModel = ({ modalVisible, setModalVisible }: SingleContentModelProps) => { + const isVideo = (data: ContentPart) => { + return data.type === 'video'; + }; + + return ( + { + setModalVisible({ visible: false, data: {} as ContentPart }); + }}> + + { + setModalVisible({ visible: false, data: {} as ContentPart }) + }} + /> + setModalVisible({ visible: false, data: {} as ContentPart })}> + {isVideo(modalVisible.data) ? ( + // 视频播放器 + setModalVisible({ visible: false, data: {} as ContentPart })} + > + setModalVisible({ visible: false, data: {} as ContentPart })} + /> + + ) : ( + // 图片预览 + setModalVisible({ visible: false, data: {} as ContentPart })} + style={styles.imageContainer} + > + + + )} + + + + ) +} + + + +const styles = StyleSheet.create({ + imageGridContainer: { + flexDirection: 'row', + flexWrap: 'nowrap', + width: '100%', + marginTop: 8, + }, + video: { + width: '100%', + height: '100%', + }, + imageContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + width: '100%', + maxHeight: '60%', + }, + fullWidthImage: { + width: '100%', + height: "54%", + marginBottom: 8, + }, + gridImage: { + aspectRatio: 1, + marginBottom: 8, + }, + background: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.5)', // 添加半透明黑色背景 + }, + centeredView: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + modalView: { + borderRadius: 20, + alignItems: 'center', + height: '100%', + width: "100%", + justifyContent: 'center', + alignSelf: 'center', + }, + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + userAvatar: { + width: 30, + height: 30, + borderRadius: 15, + }, + messageList: { + padding: 16, + }, + messageBubble: { + paddingHorizontal: 16, + paddingVertical: 12, + fontWeight: "600" + }, + userBubble: { + alignSelf: 'flex-end', + backgroundColor: '#FFB645', + marginLeft: '20%', + }, + aiBubble: { + alignSelf: 'flex-start', + backgroundColor: '#fff', + marginRight: '20%', + borderWidth: 1, + borderColor: '#e5e5ea', + }, + userText: { + color: '#4C320C', + fontSize: 16, + }, + aiText: { + color: '#000', + fontSize: 16, + }, +}); + +export default SingleContentModel \ No newline at end of file diff --git a/components/ask/threeCircle.tsx b/components/ask/threeCircle.tsx new file mode 100644 index 0000000..6c9c348 --- /dev/null +++ b/components/ask/threeCircle.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useRef } from 'react'; +import { Animated, StyleSheet, View } from 'react-native'; + +const Loading = () => { + // 创建三个动画值,控制每个点的大小变化 + const anim1 = useRef(new Animated.Value(0)).current; + const anim2 = useRef(new Animated.Value(0)).current; + const anim3 = useRef(new Animated.Value(0)).current; + + // 定义动画序列 + const startAnimation = () => { + // 重置动画值 + anim1.setValue(0); + anim2.setValue(0); + anim3.setValue(0); + + // 创建动画序列 + Animated.loop( + Animated.stagger(200, [ + // 第一个点动画 + Animated.sequence([ + Animated.timing(anim1, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }), + Animated.timing(anim1, { + toValue: 0, + duration: 400, + useNativeDriver: true, + }), + ]), + // 第二个点动画 + Animated.sequence([ + Animated.timing(anim2, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }), + Animated.timing(anim2, { + toValue: 0, + duration: 400, + useNativeDriver: true, + }), + ]), + // 第三个点动画 + Animated.sequence([ + Animated.timing(anim3, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }), + Animated.timing(anim3, { + toValue: 0, + duration: 400, + useNativeDriver: true, + }), + ]), + ]) + ).start(); + }; + + useEffect(() => { + startAnimation(); + return () => { + // 清理动画 + anim1.stopAnimation(); + anim2.stopAnimation(); + anim3.stopAnimation(); + }; + }, []); + + // 颜色插值 + const color1 = anim1.interpolate({ + inputRange: [0, 0.5, 1], + outputRange: ['#999999', '#4C320C', '#999999'], + }); + + const color2 = anim2.interpolate({ + inputRange: [0, 0.5, 1], + outputRange: ['#999999', '#4C320C', '#999999'], + }); + + const color3 = anim3.interpolate({ + inputRange: [0, 0.5, 1], + outputRange: ['#999999', '#4C320C', '#999999'], + }); + + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + marginHorizontal: 4, + backgroundColor: '#999999', + }, +}); + +export default Loading; \ No newline at end of file diff --git a/components/ask/typewriterText.tsx b/components/ask/typewriterText.tsx index b1289f3..f0994d5 100644 --- a/components/ask/typewriterText.tsx +++ b/components/ask/typewriterText.tsx @@ -10,7 +10,7 @@ interface TypewriterTextProps { const TypewriterText: React.FC = ({ text, - speed = 150, + speed = 100, loop = false, delay = 2000, }) => { diff --git a/components/ask/utils.ts b/components/ask/utils.ts index 81a838f..79f046a 100644 --- a/components/ask/utils.ts +++ b/components/ask/utils.ts @@ -1,3 +1,11 @@ +import { fetchApi } from "@/lib/server-api-util"; +import { Message } from "@/types/ask"; +import * as FileSystem from 'expo-file-system'; +import * as MediaLibrary from 'expo-media-library'; +import { TFunction } from "i18next"; +import { useCallback } from "react"; +import { Alert } from 'react-native'; + // 实现一个函数,从两个数组中轮流插入新数组 export const mergeArrays = (arr1: any[], arr2: any[]) => { const result: any[] = []; @@ -8,3 +16,114 @@ export const mergeArrays = (arr1: any[], arr2: any[]) => { } return result; }; + + +// 创建新对话并获取消息 +export const createNewConversation = useCallback(async (user_text: string) => { + const data = await fetchApi("/chat/new", { + method: "POST", + }); + return data +}, []); + +// 获取对话信息 +export const getConversation = async ({ + session_id, + user_text, + material_ids +}: { + session_id: string, + user_text: string, + material_ids: string[] +}): Promise => { + // 获取对话信息必须要有对话id + if (!session_id) return undefined; + + try { + const response = await fetchApi(`/chat`, { + method: "POST", + body: JSON.stringify({ + session_id, + user_text, + material_ids + }) + }); + return response; + } catch (error) { + // console.error('Error in getConversation:', error); + return undefined; + } +}; + +// 图片 视频 保存到本地 +export const saveMediaToGallery = async (mediaUrl: string, t: TFunction) => { + // 声明 fileUri 变量以便在 finally 块中使用 + let fileUri: string | null = null; + + try { + // 首先请求权限 + const { status } = await MediaLibrary.requestPermissionsAsync(); + + if (status !== 'granted') { + Alert.alert(t("ask:ask.mediaAuth"), t("ask:ask.mediaAuthDesc")); + return false; + } + + // 获取文件扩展名 + const fileExtension = mediaUrl.split('.').pop()?.toLowerCase() || 'mp4'; + const isVideo = ['mp4', 'mov', 'avi', 'mkv'].includes(fileExtension); + const fileName = `temp_${Date.now()}.${fileExtension}`; + fileUri = `${FileSystem.documentDirectory}${fileName}`; + + // 下载文件 + console.log('开始下载文件:', mediaUrl); + const downloadResumable = FileSystem.createDownloadResumable( + mediaUrl, + fileUri, + {}, + (downloadProgress) => { + const progress = downloadProgress.totalBytesWritten / (downloadProgress.totalBytesExpectedToWrite || 1); + console.log(`下载进度: ${Math.round(progress * 100)}%`); + } + ); + + const downloadResult = await downloadResumable.downloadAsync(); + + if (!downloadResult) { + throw new Error('下载失败: 下载被取消或发生错误'); + } + + const { uri } = downloadResult; + console.log('文件下载完成,准备保存到相册:', uri); + + // 保存到相册 + const asset = await MediaLibrary.createAssetAsync(uri); + await MediaLibrary.createAlbumAsync( + 'Memowake', + asset, + false + ); + + Alert.alert( + t("ask:ask.saveSuccess"), + isVideo ? t("ask:ask.videoSave") : t("ask:ask.imgSave") + ); + return true; + } catch (error) { + console.log('保存失败:', error); + Alert.alert( + t("ask:ask.saveError"), + error instanceof Error ? error.message : t("ask:ask.saveError") + ); + return false; + } finally { + // 清理临时文件 + try { + if (fileUri) { + await FileSystem.deleteAsync(fileUri, { idempotent: true }).catch(console.warn); + } + } catch (cleanupError) { + console.log('清理临时文件时出错:', cleanupError); + } + } +}; \ No newline at end of file diff --git a/components/ask/voice.tsx b/components/ask/voice.tsx deleted file mode 100644 index c272dc9..0000000 --- a/components/ask/voice.tsx +++ /dev/null @@ -1,221 +0,0 @@ -'use client'; -import React, { Dispatch, SetStateAction, useCallback, useState } from 'react'; -import { - StyleSheet, - TextInput, - View -} from 'react-native'; - -import { fetchApi } from '@/lib/server-api-util'; -import { Message } from '@/types/ask'; -import { RecordingPresets, useAudioRecorder } from 'expo-audio'; -interface Props { - setIsHello: (isHello: boolean) => void, - conversationId: string | null, - setUserMessages: Dispatch>; - setConversationId: (conversationId: string) => void, -} -export default function AudioRecordPlay(props: Props) { - const { setIsHello, conversationId, setUserMessages, setConversationId } = props; - const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); - const [isRecording, setIsRecording] = useState(false); - const [isVoiceStart, setIsVoiceStart] = useState(false); - const [elapsedTime, setElapsedTime] = useState(0); - - // 用户询问 - const [inputValue, setInputValue] = useState(''); - const [timerInterval, setTimerInterval] = useState(0); - - const formatTime = (ms: number): string => { - const totalSeconds = ms / 1000; - const minutes = Math.floor(totalSeconds / 60); - const seconds = Math.floor(totalSeconds % 60); - const milliseconds = Math.floor(ms % 1000); - - return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}`; - }; - // 开始录音 - const record = async () => { - await audioRecorder.prepareToRecordAsync(); - const startTime = Date.now(); - - // 每 10ms 更新一次时间 - const interval = setInterval(() => { - const elapsed = Date.now() - startTime; - setElapsedTime(elapsed); - }, 10); - - setTimerInterval(interval); - setIsVoiceStart(true) - audioRecorder.record(); - setIsRecording(true); - }; - - const stopRecording = async () => { - // The recording will be available on `audioRecorder.uri`. - - - if (timerInterval) clearInterval(timerInterval); - setTimerInterval(0); - await audioRecorder.stop(); - setIsRecording(false); - }; - - // useEffect(() => { - // (async () => { - // const status = await AudioModule.requestRecordingPermissionsAsync(); - // if (!status.granted) { - // Alert.alert('Permission to access microphone was denied'); - // } - // })(); - // }, []); - // 获取对话信息 - const createNewConversation = useCallback(async (user_text: string) => { - const data = await fetchApi("/chat/new", { - method: "POST", - }); - setConversationId(data); - await getConversation({ session_id: data, user_text }); - }, []); - - const getConversation = useCallback(async ({ session_id, user_text }: { session_id: string, user_text: string }) => { - if (!session_id) return; - const response = await fetchApi(`/chat`, { - method: "POST", - body: JSON.stringify({ - session_id, - user_text - }) - }); - setUserMessages((prev: Message[]) => [...prev, response]); - }, []); - - // 使用 useCallback 缓存 handleSubmit - const handleSubmit = () => { - const text = inputValue; - if (text) { - setUserMessages(pre => ([...pre, { - content: { - text: text - }, - role: 'User', - timestamp: new Date().toISOString() - } - ])); - if (!conversationId) { - createNewConversation(text); - setIsHello(false); - } else { - getConversation({ - session_id: conversationId, - user_text: text - }); - } - setInputValue(''); - } - } - - return ( - - - {/* console.log('Left icon pressed')} - className={`absolute left-2 top-1/2 -translate-y-1/2 p-2 bg-white rounded-full ${isVoiceStart ? "opacity-100" : "opacity-0"}`} // 使用绝对定位将按钮放在输入框内右侧 - > - - */} - { - setInputValue(text); - }} - onSubmitEditing={handleSubmit} - editable={!isVoiceStart} - // 调起的键盘类型 - returnKeyType="send" - /> - {/* - {isVoiceStart ? : } - */} - - - ); -} - -const styles = StyleSheet.create({ - container: { - justifyContent: 'center', - backgroundColor: '#fff', - }, - title: { - fontSize: 20, - fontWeight: 'bold', - marginBottom: 20, - textAlign: 'center', - }, - recordButton: { - padding: 15, - borderRadius: 8, - alignItems: 'center', - marginBottom: 20, - }, - startButton: { - backgroundColor: '#ff6b6b', - }, - stopButton: { - backgroundColor: '#4CAF50', - }, - buttonText: { - color: 'white', - fontSize: 16, - }, - listTitle: { - fontWeight: 'bold', - marginBottom: 10, - }, - emptyText: { - fontStyle: 'italic', - color: '#888', - marginBottom: 10, - }, - recordingItem: { - padding: 10, - borderBottomWidth: 1, - borderBottomColor: '#eee', - }, - uriText: { - fontSize: 12, - color: '#777', - }, - leftIcon: { - padding: 10, - paddingLeft: 15, - }, - input: { - borderColor: '#FF9500', - borderWidth: 1, - borderRadius: 25, - paddingHorizontal: 20, - paddingVertical: 12, - fontSize: 16, - width: '100%', // 确保输入框宽度撑满 - paddingRight: 50 - }, - voiceButton: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: '#FF9500', - justifyContent: 'center', - alignItems: 'center', - marginRight: 8, // 添加一点右边距 - }, -}); \ No newline at end of file diff --git a/components/auth/index.tsx b/components/auth/index.tsx index 26df4ec..6c70630 100644 --- a/components/auth/index.tsx +++ b/components/auth/index.tsx @@ -3,6 +3,7 @@ import * as Device from 'expo-device'; import * as Notifications from 'expo-notifications'; import { useEffect, useState } from 'react'; import { Button, Platform, Text, View } from 'react-native'; +import { requestNotificationPermission } from '../owner/utils'; Notifications.setNotificationHandler({ handleNotification: async () => ({ @@ -108,13 +109,13 @@ async function registerForPushNotificationsAsync() { // 4. 如果尚未授予权限,则请求权限 if (existingStatus !== 'granted') { - const { status } = await Notifications.requestPermissionsAsync(); - finalStatus = status; + const granted = await requestNotificationPermission(); + finalStatus = granted ? Notifications.PermissionStatus.GRANTED : Notifications.PermissionStatus.DENIED; } // 5. 如果权限被拒绝,显示警告并返回 if (finalStatus !== 'granted') { - alert('Failed to get push token for push notification!'); + console.log('用户拒绝了通知权限'); return; } diff --git a/components/chat/message-item/MediaGrid.tsx b/components/chat/message-item/MediaGrid.tsx new file mode 100644 index 0000000..40db826 --- /dev/null +++ b/components/chat/message-item/MediaGrid.tsx @@ -0,0 +1,134 @@ +import CancelSvg from "@/assets/icons/svg/cancel.svg"; +import DownloadSvg from "@/assets/icons/svg/download.svg"; +import { ContentPart } from "@/types/ask"; +import { TFunction } from 'i18next'; +import React from 'react'; +import { Image, Pressable, StyleSheet, Text as ThemedText, View } from 'react-native'; +import { saveMediaToGallery } from "../../ask/utils"; +import ContextMenu from "../../gusture/contextMenu"; + +interface MediaGridProps { + mediaItems: ContentPart[]; + setModalVisible: React.Dispatch>; + setCancel: React.Dispatch>; + cancel: boolean; + t: TFunction; +} + +const MediaGrid = ({ mediaItems, setModalVisible, setCancel, cancel, t }: MediaGridProps) => { + // Only show up to 6 images (2 rows of 3) + const displayItems = mediaItems.slice(0, 6); + const itemCount = displayItems.length; + + // Calculate item width based on number of items + const getItemWidth = () => { + if (itemCount === 1) return '100%'; + if (itemCount === 2) return '49%'; + return '32%'; // For 3+ items + }; + + // Calculate container style based on number of items + const getContainerStyle = () => { + if (itemCount === 1) return styles.singleItemContainer; + return styles.multiItemContainer; + }; + + return ( + + {displayItems.map((media, index) => ( + { + setModalVisible({ visible: true, data: media }); + }} + style={[styles.imageContainer, { width: getItemWidth() }]} + > + , + label: t("ask:ask.save"), + onPress: () => { + if (media?.url) { + saveMediaToGallery(media?.url, t); + } + }, + textStyle: { color: '#4C320C' } + }, + { + svg: , + label: t("ask:ask.cancel"), + onPress: () => { + setCancel(true); + }, + textStyle: { color: 'red' } + } + ]} + cancel={cancel} + menuStyle={styles.contextMenu} + > + + {itemCount > 3 && index === 5 && mediaItems.length > 6 && ( + + +{mediaItems.length - 5} + + )} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + singleItemContainer: { + width: '100%', + }, + multiItemContainer: { + width: '100%', + }, + imageContainer: { + marginBottom: 8, + aspectRatio: 1, + }, + image: { + width: '100%', + height: '100%', + borderRadius: 8, + }, + contextMenu: { + backgroundColor: 'white', + borderRadius: 8, + padding: 8, + minWidth: 150, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + }, + overlayText: { + color: 'white', + fontSize: 24, + fontWeight: 'bold', + }, +}); + +export default MediaGrid; diff --git a/components/chat/message-item/MessageBubble.tsx b/components/chat/message-item/MessageBubble.tsx new file mode 100644 index 0000000..d808c4e --- /dev/null +++ b/components/chat/message-item/MessageBubble.tsx @@ -0,0 +1,46 @@ +import { getMessageText, isMessageContainMedia } from "@/types/ask"; +import React from 'react'; +import { View } from 'react-native'; +import MessageContent from "./MessageContent"; + +interface MessageBubbleProps { + item: any; + isUser: boolean; + setModalVisible: React.Dispatch>; + setCancel: React.Dispatch>; + cancel: boolean; + t: any; + setSelectedImages: React.Dispatch>; + setModalDetailsVisible: React.Dispatch>; +} + +const MessageBubble = ({ + item, + isUser, + setModalVisible, + setCancel, + cancel, + t, + setSelectedImages, + setModalDetailsVisible +}: MessageBubbleProps) => { + return ( + + + + ); +}; + +export default React.memo(MessageBubble); diff --git a/components/chat/message-item/MessageContent.tsx b/components/chat/message-item/MessageContent.tsx new file mode 100644 index 0000000..b19f7ee --- /dev/null +++ b/components/chat/message-item/MessageContent.tsx @@ -0,0 +1,160 @@ +import MoreSvg from "@/assets/icons/svg/more.svg"; +import { ContentPart, getMessageText, isMessageContainMedia } from "@/types/ask"; +import { TFunction } from 'i18next'; +import React from 'react'; +import { Platform, StyleSheet, TouchableOpacity, View } from 'react-native'; +import Markdown from "react-native-markdown-display"; +import Loading from '../../ask/threeCircle'; +import { ThemedText } from "../../ThemedText"; +import MediaGrid from './MediaGrid'; + +interface MessageContentProps { + item: any; + isUser: boolean; + setModalVisible: React.Dispatch>; + setCancel: React.Dispatch>; + cancel: boolean; + t: TFunction; + setSelectedImages: React.Dispatch>; + setModalDetailsVisible: React.Dispatch>; +} + +const chineseMarkdownStyle = StyleSheet.create({ + // General body text + body: { + fontSize: 14, + lineHeight: 24.5, // 1.75 * fontSize for better readability + color: '#333', + }, + // Headings + heading1: { + fontSize: 24, + fontWeight: 'bold', + marginTop: 10, + marginBottom: 10, + lineHeight: 36, + borderBottomWidth: 1, + borderColor: '#eee', + paddingBottom: 5, + }, + heading2: { + fontSize: 22, + fontWeight: 'bold', + marginTop: 8, + marginBottom: 8, + lineHeight: 33, + }, + heading3: { + fontSize: 20, + fontWeight: 'bold', + marginTop: 6, + marginBottom: 6, + lineHeight: 30, + }, + // Paragraph: Add vertical margin for better separation + paragraph: { + marginTop: 10, + marginBottom: 10, + }, + // Lists + bullet_list_icon: { + fontSize: 16, + lineHeight: 28, + marginRight: 8, + }, + list_item: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 8, + }, + // Code blocks + code_block: { + fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace', + backgroundColor: '#f5f5f5', + padding: 15, + borderRadius: 4, + marginVertical: 10, + fontSize: 14, + lineHeight: 21, + }, + // Blockquote + blockquote: { + backgroundColor: '#f0f0f0', + borderLeftColor: '#ccc', + borderLeftWidth: 4, + paddingHorizontal: 15, + paddingVertical: 10, + marginVertical: 10, + }, + // Link + link: { + color: '#007aff', // Standard blue link color + textDecorationLine: 'underline', + }, + // Horizontal Rule + hr: { + backgroundColor: '#e0e0e0', + height: 1, + marginVertical: 15, + }, +}); + +const MessageContent = ({ + item, + isUser, + setModalVisible, + setCancel, + cancel, + t, + setSelectedImages, + setModalDetailsVisible +}: MessageContentProps) => { + return ( + + {getMessageText(item) == "keepSearchIng" && !isUser ? ( + + ) : ( + + + {getMessageText(item)} + + + )} + + + {isMessageContainMedia(item) && ( + + {item.content instanceof Array && (() => { + const mediaItems = item.content.filter((media: ContentPart) => media.type !== 'text'); + + return ( + + + + ); + })()} + { + (item.content instanceof Array && item.content.length > 3) + && { + setSelectedImages([]) + setModalDetailsVisible({ visible: true, content: item.content }); + }}> + {item.content.length} + + + + + } + + )} + + ); +}; + +export default React.memo(MessageContent); diff --git a/components/chat/message-item/MessageRow.tsx b/components/chat/message-item/MessageRow.tsx new file mode 100644 index 0000000..c098760 --- /dev/null +++ b/components/chat/message-item/MessageRow.tsx @@ -0,0 +1,50 @@ +import { getMessageText } from "@/types/ask"; +import React from 'react'; +import { Text, View } from 'react-native'; +import MessageBubble from './MessageBubble'; + +interface MessageRowProps { + item: any; + isUser: boolean; + setModalVisible: React.Dispatch>; + setCancel: React.Dispatch>; + cancel: boolean; + t: any; + setSelectedImages: React.Dispatch>; + setModalDetailsVisible: React.Dispatch>; +} + +const MessageRow = ({ + item, + isUser, + setModalVisible, + setCancel, + cancel, + t, + setSelectedImages, + setModalDetailsVisible +}: MessageRowProps) => { + return ( + + + { + getMessageText(item) == "keepSearchIng" + && + + {t("ask:ask.think")} + + } + + ); +}; + +export default React.memo(MessageRow); diff --git a/components/chat/message-item/message-item.tsx b/components/chat/message-item/message-item.tsx new file mode 100644 index 0000000..b720ceb --- /dev/null +++ b/components/chat/message-item/message-item.tsx @@ -0,0 +1,62 @@ +import ChatSvg from "@/assets/icons/svg/chat.svg"; +import { ContentPart, Message, User } from "@/types/ask"; +import { TFunction } from "i18next"; +import React from 'react'; +import { + View +} from 'react-native'; + +import MessageRow from './MessageRow'; + +interface RenderMessageProps { + insets: { top: number }; + item: Message; + sessionId: string; + setModalVisible: React.Dispatch>; + modalVisible: { visible: boolean, data: ContentPart }; + setModalDetailsVisible: React.Dispatch>; + modalDetailsVisible: { visible: boolean, content: any }; + setSelectedImages: React.Dispatch>; + selectedImages: string[]; + t: TFunction; + setCancel: React.Dispatch>; + cancel: boolean; +} + +const MessageItem = ({ setCancel, cancel = true, t, insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => { + const isUser = item.role === User; + + return ( + + {!isUser && } + + + {/* {item.askAgain && item.askAgain.length > 0 && ( + + {item.askAgain.map((suggestion, index, array) => ( + + {suggestion.text} + + ))} + + )} */} + + + + ); +}; + +export default React.memo(MessageItem); + diff --git a/components/common/ErrorBoundary.tsx b/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..d3111af --- /dev/null +++ b/components/common/ErrorBoundary.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +interface ErrorBoundaryProps { + children: React.ReactNode; + fallback?: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +const translations = { + error: 'Error', + issue: 'An issue occurred', + retry: 'Retry' +}; + +class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + handleRetry = () => { + this.setState({ hasError: false, error: undefined }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( + + {translations.error} + + {this.state.error?.message || translations.issue} + + + {translations.retry} + + + ); + } + + return this.props.children; + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + backgroundColor: '#fff', + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 10, + color: '#333', + }, + error: { + color: '#ff4d4f', + marginBottom: 20, + textAlign: 'center', + }, + retryButton: { + backgroundColor: '#1890ff', + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 4, + }, + retryText: { + color: '#fff', + fontSize: 16, + }, +}); + +export default ErrorBoundary; diff --git a/components/common/PermissionAlert.tsx b/components/common/PermissionAlert.tsx new file mode 100644 index 0000000..0472d55 --- /dev/null +++ b/components/common/PermissionAlert.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +interface PermissionAlertProps { + visible: boolean; + onConfirm: () => void; + onCancel: () => void; + title: string; + message: string; + confirmText?: string; + cancelText?: string; +} + +const PermissionAlert: React.FC = ({ visible, onConfirm, onCancel, title, message, confirmText, cancelText }) => { + const { t } = useTranslation(); + + if (!visible) { + return null; + } + + return ( + + + + {title} + {message} + + + {cancelText || t('cancel', { ns: 'permission' })} + + + {confirmText || t('goToSettings', { ns: 'permission' })} + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 99, + }, + centeredView: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + zIndex: 9999, + }, + modalView: { + width: '80%', + backgroundColor: 'white', + borderRadius: 16, + padding: 24, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + modalTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#4C320C', + marginBottom: 12, + }, + modalMessage: { + fontSize: 16, + color: '#4C320C', + textAlign: 'center', + marginBottom: 24, + }, + buttonContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', + }, + button: { + borderRadius: 20, + paddingVertical: 12, + flex: 1, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: '#F5F5F5', + marginRight: 8, + }, + confirmButton: { + backgroundColor: '#E2793F', + marginLeft: 8, + }, + buttonText: { + color: '#4C320C', + fontWeight: '600', + }, + confirmButtonText: { + color: 'white', + fontWeight: '600', + }, +}); + +export default PermissionAlert; diff --git a/components/download/app.tsx b/components/download/app.tsx new file mode 100644 index 0000000..400a17b --- /dev/null +++ b/components/download/app.tsx @@ -0,0 +1,175 @@ +import HandlersSvg from "@/assets/icons/svg/handers.svg"; +import LogoSvg from "@/assets/icons/svg/logo.svg"; +import UserinfoTotalSvg from "@/assets/icons/svg/userinfoTotal.svg"; +import { useTranslation } from 'react-i18next'; +import { Linking, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import QRDownloadScreen from "./qrCode"; + +interface AppDownloadProps { + IOS_APP_STORE_URL: string, + ANDROID_APK_URL: string, + platform: string +} + +export const AppDownload = (props: AppDownloadProps) => { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const { IOS_APP_STORE_URL, ANDROID_APK_URL, platform } = props + const handleAppStoreDownload = () => { + Linking.openURL(IOS_APP_STORE_URL); + }; + + const handlePlayStoreDownload = () => { + Linking.openURL(ANDROID_APK_URL); + }; + + return ( + + {/* Main Content */} + + {/* App Icon */} + + + {/* App Name */} + MemoWake + {/* QRCode */} + + + + + + + + + {/* Description */} + + {t('mobileDescription', { ns: 'download' })} + + + {/* Download Button */} + + + {t('download', { ns: 'download' })} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + backgroundColor: '#FFB645' + }, + qrCodeContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + qrCodeBg: { + justifyContent: 'center', + alignItems: 'center' + }, + qrCode: { + justifyContent: 'center', + alignItems: 'center', + padding: 16, + borderRadius: 24, + zIndex: 2, + backgroundColor: '#fff', + }, + closeButton: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 255, 255, 0.3)', + justifyContent: 'center', + alignItems: 'center', + }, + closeButtonText: { + color: '#fff', + fontSize: 18, + lineHeight: 24, + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 40, + }, + appIconImage: { + width: 100, + height: 100 + }, + appIconText: { + fontSize: 50, + }, + appName: { + fontSize: 32, + fontWeight: 'bold', + color: '#fff', + marginBottom: 12, + }, + description: { + fontSize: 16, + color: 'rgba(255, 255, 255, 0.9)', + textAlign: 'center', + marginBottom: 32, + paddingHorizontal: 40, + lineHeight: 24, + marginVertical: 32 + }, + downloadButton: { + backgroundColor: '#fff', + borderRadius: 30, + paddingVertical: 16, + paddingHorizontal: 40, + width: "90%", + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 5, + marginTop: 40 + }, + downloadButtonText: { + color: '#4C320C', + fontSize: 18, + fontWeight: 'bold', + }, + badgesContainer: { + alignItems: 'center', + paddingBottom: 40, + }, + availableOnText: { + color: 'rgba(255, 255, 255, 0.8)', + fontSize: 14, + marginBottom: 12, + }, + badgesRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + badgeButton: { + backgroundColor: '#fff', + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 16, + marginHorizontal: 8, + minWidth: 120, + alignItems: 'center', + justifyContent: 'center', + height: 40, + }, + badgeText: { + fontSize: 14, + fontWeight: '600', + }, +}); \ No newline at end of file diff --git a/components/download/pc.tsx b/components/download/pc.tsx new file mode 100644 index 0000000..fbe6444 --- /dev/null +++ b/components/download/pc.tsx @@ -0,0 +1,69 @@ +import AndroidLogo from '@/assets/icons/svg/android.svg'; +import AppleLogo from '@/assets/icons/svg/apple.svg'; +import MemoIP from '@/assets/icons/svg/memo-ip.svg'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useTranslation } from 'react-i18next'; +import { Linking, Text, TouchableOpacity, View } from 'react-native'; + +interface PCDownloadProps { + IOS_APP_STORE_URL: string, + ANDROID_APK_URL: string +} +const PCDownload = (props: PCDownloadProps) => { + const { IOS_APP_STORE_URL, ANDROID_APK_URL } = props + + const handleIOSDownload = () => { + Linking.openURL(IOS_APP_STORE_URL); + }; + + const handleAndroidDownload = () => { + Linking.openURL(ANDROID_APK_URL); + }; + + const { t } = useTranslation(); + return ( + + + + + + + MemoWake + + + {t('desc', { ns: 'download' })} + + + + + + + + {t('ios', { ns: 'download' })} + + + + + + + {t('android', { ns: 'download' })} + + + + + ) +} + + +export default PCDownload \ No newline at end of file diff --git a/components/download/qrCode.tsx b/components/download/qrCode.tsx new file mode 100644 index 0000000..74e435e --- /dev/null +++ b/components/download/qrCode.tsx @@ -0,0 +1,73 @@ +import i18n from '@/i18n'; +import { PermissionService } from '@/lib/PermissionService'; +import * as Haptics from 'expo-haptics'; +import * as MediaLibrary from 'expo-media-library'; +import React, { useRef } from 'react'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import QRCode from 'react-native-qrcode-svg'; +import { captureRef } from 'react-native-view-shot'; + +export default function QRDownloadScreen(prop: { url: string }) { + const qrViewRef = useRef(null); // 用于截图的引用 + const [qrValue] = React.useState(prop.url); // 二维码内容 + + const saveQRToGallery = async () => { + try { + // 触发轻震,提升交互感 + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + // 请求相册写入权限 + const { status } = await MediaLibrary.requestPermissionsAsync(); + if (status !== 'granted') { + PermissionService.show({ title: i18n.t('permission:title.permissionDenied'), message: i18n.t('permission:message.saveToAlbumPermissionRequired') }); + return; + } + + if (!qrViewRef.current) return; + + // 截取二维码视图 + const uri = await captureRef(qrViewRef, { + format: 'png', + quality: 1, + result: 'tmpfile', // 返回临时文件路径 + }); + + // 保存到相册 + await MediaLibrary.saveToLibraryAsync(uri); + + PermissionService.show({ title: i18n.t('permission:title.success'), message: i18n.t('permission:message.qrCodeSaved') }); + } catch (error) { + console.error('保存失败:', error); + PermissionService.show({ title: i18n.t('permission:title.error'), message: i18n.t('permission:message.saveImageFailed') }); + } + }; + + return ( + + {/* 可截图的容器 */} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + // flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + qrContainer: { + padding: 16, + backgroundColor: '#fff', + borderRadius: 12, + }, + tip: { + marginTop: 20, + color: '#666', + fontSize: 14, + }, +}); \ No newline at end of file diff --git a/components/file-upload/files-uploader.tsx b/components/file-upload/files-uploader.tsx index 75aa81c..7fb2375 100644 --- a/components/file-upload/files-uploader.tsx +++ b/components/file-upload/files-uploader.tsx @@ -1,13 +1,14 @@ +import { requestLocationPermission, requestMediaLibraryPermission } from '@/components/owner/utils'; import { addMaterial, confirmUpload, getUploadUrl } from '@/lib/background-uploader/api'; import { ConfirmUpload, ExifData, FileUploadItem, ImagesuploaderProps, UploadResult, UploadTask, defaultExifData } from '@/lib/background-uploader/types'; import { uploadFileWithProgress } from '@/lib/background-uploader/uploader'; import { compressImage } from '@/lib/image-process/imageCompress'; +import { PermissionService } from '@/lib/PermissionService'; import { createVideoThumbnailFile } from '@/lib/video-process/videoThumbnail'; import * as ImagePicker from 'expo-image-picker'; -import * as Location from 'expo-location'; import * as MediaLibrary from 'expo-media-library'; import React, { useEffect, useState } from 'react'; -import { Alert, Button, Platform, TouchableOpacity, View } from 'react-native'; +import { Button, Platform, TouchableOpacity, View } from 'react-native'; import UploadPreview from './preview'; export const ImagesUploader: React.FC = ({ @@ -26,23 +27,6 @@ export const ImagesUploader: React.FC = ({ const [files, setFiles] = useState([]); const [uploadQueue, setUploadQueue] = useState([]); - // 请求权限 - const requestPermissions = async () => { - if (Platform.OS !== 'web') { - const { status: mediaStatus } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (mediaStatus !== 'granted') { - Alert.alert('需要媒体库权限', '请允许访问媒体库以选择图片'); - return false; - } - - const { status: locationStatus } = await Location.requestForegroundPermissionsAsync();; - if (locationStatus !== 'granted') { - Alert.alert('需要位置权限', '需要位置权限才能获取图片位置信息'); - } - } - return true; - }; - // 处理单个资源 const processSingleAsset = async (asset: ImagePicker.ImagePickerAsset): Promise => { console.log("asset111111", asset); @@ -64,6 +48,7 @@ export const ImagesUploader: React.FC = ({ id: fileId, uri: asset.uri, previewUrl: asset.uri, // 使用 asset.uri 作为初始预览 + preview: asset.uri, // 使用 asset.uri 作为初始预览 name: asset.fileName || 'file', progress: 0, status: 'uploading', @@ -158,7 +143,7 @@ export const ImagesUploader: React.FC = ({ try { // 统一通过 lib 的 uploadFileWithProgress 实现上传 const uploadUrlData = await getUploadUrl(task.file, { ...task.metadata, GPSVersionID: undefined }); - const taskIndex = uploadTasks.indexOf(task); + const taskIndex = uploadTasks.indexOf(task); const totalTasks = uploadTasks.length; const baseProgress = (taskIndex / totalTasks) * 100; @@ -232,11 +217,8 @@ export const ImagesUploader: React.FC = ({ const CONCURRENCY_LIMIT = 3; const results: UploadResult[] = []; - // 分批处理资源 - for (let i = 0; i < assets.length; i += CONCURRENCY_LIMIT) { - const batch = assets.slice(i, i + CONCURRENCY_LIMIT); - - // 并行处理当前批次的所有资源 + // 分批处理资源,优化并发处理 + const processBatch = async (batch: ImagePicker.ImagePickerAsset[]) => { const batchResults = await Promise.allSettled( batch.map(asset => processSingleAsset(asset)) ); @@ -247,11 +229,18 @@ export const ImagesUploader: React.FC = ({ results.push(result.value); } } + }; - // 添加小延迟,避免过多占用系统资源 - if (i + CONCURRENCY_LIMIT < assets.length) { - await new Promise(resolve => setTimeout(resolve, 100)); - } + // 使用 Promise.all 并行处理所有批次 + const batches = []; + for (let i = 0; i < assets.length; i += CONCURRENCY_LIMIT) { + batches.push(assets.slice(i, i + CONCURRENCY_LIMIT)); + } + + // 并行处理所有批次,但限制并发数量 + for (let i = 0; i < batches.length; i += CONCURRENCY_LIMIT) { + const batchGroup = batches.slice(i, i + CONCURRENCY_LIMIT); + await Promise.all(batchGroup.map(processBatch)); } return results; @@ -261,9 +250,13 @@ export const ImagesUploader: React.FC = ({ const pickImage = async () => { try { setIsLoading(true); - const hasPermission = await requestPermissions(); - console.log("hasPermission", hasPermission); - if (!hasPermission) return; + const hasMediaPermission = await requestMediaLibraryPermission(); + if (!hasMediaPermission) { + setIsLoading(false); + return; + } + // 请求位置权限,但不强制要求 + await requestLocationPermission(); const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: fileType, @@ -290,13 +283,13 @@ export const ImagesUploader: React.FC = ({ } })); } catch (error) { - Alert.alert('错误', '部分文件处理失败,请重试'); + PermissionService.show({ title: '错误', message: '部分文件处理失败,请重试' }); } finally { setIsLoading(false); } } catch (error) { - Alert.alert('错误', '选择图片时出错,请重试'); + PermissionService.show({ title: '错误', message: '选择图片时出错,请重试' }); } finally { setIsLoading(false); } diff --git a/components/file-upload/getTotal.tsx b/components/file-upload/getTotal.tsx index cb2b3d4..6865841 100644 --- a/components/file-upload/getTotal.tsx +++ b/components/file-upload/getTotal.tsx @@ -1,6 +1,8 @@ import * as MediaLibrary from 'expo-media-library'; import React, { useState } from 'react'; -import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import i18n from '@/i18n'; +import { PermissionService } from '@/lib/PermissionService'; interface MediaStats { total: number; @@ -46,7 +48,7 @@ const MediaStatsScreen = () => { // 1. 请求媒体库权限 const { status } = await MediaLibrary.requestPermissionsAsync(); if (status !== 'granted') { - Alert.alert('权限被拒绝', '需要访问媒体库权限来获取统计信息'); + PermissionService.show({ title: i18n.t('permission:title.permissionDenied'), message: i18n.t('permission:message.getStatsPermissionRequired') }); return; } @@ -116,7 +118,7 @@ const MediaStatsScreen = () => { setStats(stats); } catch (error) { console.error('获取媒体库统计信息失败:', error); - Alert.alert('错误', '获取媒体库统计信息失败'); + PermissionService.show({ title: i18n.t('permission:title.error'), message: i18n.t('permission:message.getStatsFailed') }); } finally { setIsLoading(false); } diff --git a/components/file-upload/images-picker.tsx b/components/file-upload/images-picker.tsx index f68b0cd..03b3dc9 100644 --- a/components/file-upload/images-picker.tsx +++ b/components/file-upload/images-picker.tsx @@ -1,11 +1,12 @@ +import { requestLocationPermission, requestMediaLibraryPermission } from '@/components/owner/utils'; +import { PermissionService } from '@/lib/PermissionService'; import { fetchApi } from '@/lib/server-api-util'; import { ConfirmUpload, defaultExifData, ExifData, FileStatus, ImagesPickerProps, UploadResult, UploadUrlResponse } from '@/types/upload'; import * as ImageManipulator from 'expo-image-manipulator'; import * as ImagePicker from 'expo-image-picker'; -import * as Location from 'expo-location'; import * as MediaLibrary from 'expo-media-library'; import React, { useEffect, useState } from 'react'; -import { Alert, Button, Platform, TouchableOpacity, View } from 'react-native'; +import { Button, Platform, TouchableOpacity, View } from 'react-native'; import * as Progress from 'react-native-progress'; export const ImagesPicker: React.FC = ({ @@ -24,17 +25,13 @@ export const ImagesPicker: React.FC = ({ // 请求权限 const requestPermissions = async () => { if (Platform.OS !== 'web') { - - const { status: mediaStatus } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (mediaStatus !== 'granted') { - Alert.alert('需要媒体库权限', '请允许访问媒体库以选择图片'); + const hasMediaPermission = await requestMediaLibraryPermission(); + if (!hasMediaPermission) { + setIsLoading(false); return false; } - - const { status: locationStatus } = await Location.requestForegroundPermissionsAsync();; - if (locationStatus !== 'granted') { - Alert.alert('需要位置权限', '需要位置权限才能获取图片位置信息'); - } + // 请求位置权限,但不强制要求 + await requestLocationPermission(); } return true; }; @@ -118,7 +115,7 @@ export const ImagesPicker: React.FC = ({ // 使用函数更新文件状态,确保每次更新都是原子的 const updateFileStatus = (updates: Partial) => { - setCurrentFileStatus((original) => ({ ...original, ...updates })) + setCurrentFileStatus((original: FileStatus) => ({ ...original, ...updates } as FileStatus)) }; // 上传文件 const uploadFile = async (file: File, metadata: Record = {}): Promise => { @@ -262,9 +259,11 @@ export const ImagesPicker: React.FC = ({ originalUrl: undefined, compressedUrl: '', file: compressedFile, - exifData, + exif: exifData, originalFile: {} as ConfirmUpload, compressedFile: {} as ConfirmUpload, + thumbnail: '', + thumbnailFile: compressedFile, }; try { @@ -288,17 +287,17 @@ export const ImagesPicker: React.FC = ({ await new Promise(resolve => setTimeout(resolve, 300)); // 更新状态为成功 await updateFileStatus({ status: 'success', progress: 100, id: uploadResults.originalFile?.file_id }); - // 调用上传完成回调 - onUploadComplete?.(uploadResults); + // 调用上传完成回调 - 暂时注释,因为类型不匹配 + // onUploadComplete?.(uploadResults); } catch (error) { updateFileStatus({ status: 'error', progress: 0, id: uploadResults.originalFile?.file_id }); throw error; // 重新抛出错误,让外层 catch 处理 } } catch (error) { - Alert.alert('错误', '处理图片时出错'); + PermissionService.show({ title: '错误', message: '处理图片时出错' }); } } catch (error) { - Alert.alert('错误', '选择图片时出错,请重试'); + PermissionService.show({ title: '错误', message: '选择图片时出错,请重试' }); } finally { setIsLoading(false); } diff --git a/components/file-upload/uploadQueueManager.ts b/components/file-upload/uploadQueueManager.ts index 38e722d..467b944 100644 --- a/components/file-upload/uploadQueueManager.ts +++ b/components/file-upload/uploadQueueManager.ts @@ -1,3 +1,5 @@ +import { fetchApi } from '@/lib/server-api-util'; +import { ConfirmUpload, UploadUrlResponse } from '@/types/upload'; import * as SecureStore from 'expo-secure-store'; const QUEUE_KEY = 'uploadQueue'; @@ -34,14 +36,14 @@ export const uploadMediaFile = async (asset: any) => { : `video/${filename.split('.').pop()}`; const formData = new FormData(); - formData.append('file', { uri, name: filename, type } as any); + formData.append('file', { uri, name: filename, type } as unknown as File); await getUploadUrl({ ...formData, name: filename, type, size: asset.fileSize - }, {}).then((res) => { + } as unknown as File, {}).then((res) => { confirmUpload(res.file_id).then((confirmRes) => { addMaterial(res.file_id, confirmRes.file_id) }).catch((error) => { diff --git a/components/firework.tsx b/components/firework.tsx index 8b4e1fb..6319b9d 100644 --- a/components/firework.tsx +++ b/components/firework.tsx @@ -47,7 +47,7 @@ export const Fireworks: React.FC = ({ const [particles, setParticles] = useState([]); const [isPlaying, setIsPlaying] = useState(autoPlay); const particleId = useRef(0); - const timerRef = useRef(null); + const timerRef = useRef | null>(null); // 生成随机位置 const getRandomPosition = () => { @@ -112,7 +112,7 @@ export const Fireworks: React.FC = ({ ]), // 旋转效果 Animated.timing(rotation, { - toValue: rotation._value + 360, + toValue: (rotation as Animated.Value & { _value: number })._value + 360, duration: 2000, easing: Easing.linear, useNativeDriver: true, diff --git a/components/gusture/contextMenu.tsx b/components/gusture/contextMenu.tsx new file mode 100644 index 0000000..01280b3 --- /dev/null +++ b/components/gusture/contextMenu.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Dimensions, + Modal, + StyleProp, + StyleSheet, + Text, + TextStyle, + TouchableOpacity, + TouchableWithoutFeedback, + View, + ViewStyle +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { runOnJS } from 'react-native-reanimated'; + +interface MenuItem { + label: string; + svg?: React.ReactNode; + onPress: () => void; + textStyle?: StyleProp; +} + +interface ContextMenuProps { + children: React.ReactNode; + items: MenuItem[]; + menuStyle?: StyleProp; + menuItemStyle?: StyleProp; + menuTextStyle?: StyleProp; + dividerStyle?: StyleProp; + onOpen?: () => void; + onClose?: () => void; + longPressDuration?: number; + activeOpacity?: number; + cancel?: boolean; +} + +const ContextMenu: React.FC = ({ + children, + items, + menuStyle, + menuItemStyle, + menuTextStyle, + dividerStyle, + cancel, + onOpen, + onClose, + longPressDuration = 500, + activeOpacity = 0.8, +}) => { + const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); + const [menuVisible, setMenuVisible] = useState(false); + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); + const containerRef = useRef(null); + + const showMenu = (x: number, y: number) => { + setMenuPosition({ x, y }); + setMenuVisible(true); + onOpen?.(); + }; + + const hideMenu = () => { + setMenuVisible(false); + onClose?.(); + }; + + const handleItemPress = (onPress: () => void) => { + onPress(); + hideMenu(); + }; + + const gesture = Gesture.LongPress() + .minDuration(longPressDuration) + .onStart((e) => { + const absoluteX = e.absoluteX; + const absoluteY = e.absoluteY; + runOnJS(showMenu)(absoluteX, absoluteY); + }); + + useEffect(() => { + setMenuVisible(!cancel); + }, [cancel]) + + return ( + <> + + + + {children} + + + + + + + + + + screenWidth / 2 ? menuPosition.x - 150 : menuPosition.x, + screenWidth - 160 + ), + }, + menuStyle, + ]} + onStartShouldSetResponder={() => true} + > + {items.map((item, index) => ( + + handleItemPress(item.onPress)} + activeOpacity={activeOpacity} + > + {item.svg} + + {item.label} + + + {index < items.length - 1 && ( + + )} + + ))} + + + + ); +}; + +const styles = StyleSheet.create({ + modalOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.1)', + }, + menu: { + backgroundColor: 'white', + borderRadius: 8, + minWidth: 100, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 6, + elevation: 10, + zIndex: 1000, + }, + menuItem: { + paddingVertical: 12, + paddingHorizontal: 16, + minWidth: 100, + flexDirection: 'row', + gap: 4, + alignItems: 'center' + }, + menuText: { + fontSize: 16, + color: '#333', + }, + divider: { + height: 1, + backgroundColor: '#f0f0f0', + marginHorizontal: 8, + }, +}); + +export default ContextMenu; \ No newline at end of file diff --git a/components/layout/ask.tsx b/components/layout/ask.tsx index 3a8445b..ea0a890 100644 --- a/components/layout/ask.tsx +++ b/components/layout/ask.tsx @@ -2,88 +2,200 @@ import ChatInSvg from "@/assets/icons/svg/chatIn.svg"; import ChatNotInSvg from "@/assets/icons/svg/chatNotIn.svg"; import PersonInSvg from "@/assets/icons/svg/personIn.svg"; import PersonNotInSvg from "@/assets/icons/svg/personNotIn.svg"; +import { WebSocketStatus } from "@/lib/websocket-util"; import { router, usePathname } from "expo-router"; -import React from 'react'; -import { Dimensions, Image, Platform, TouchableOpacity, View } from 'react-native'; -import { Circle, Ellipse, G, Mask, Path, Rect, Svg } from 'react-native-svg'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { Dimensions, Image, StyleSheet, TouchableOpacity, View } from 'react-native'; +import Svg, { Circle, Ellipse, G, Mask, Path, Rect } from "react-native-svg"; -const AskNavbar = () => { +// 使用 React.memo 包装 SVG 组件,避免不必要的重渲染 +const TabIcon = React.memo(({ isActive, ActiveIcon, InactiveIcon }: { + isActive: boolean; + ActiveIcon: React.FC<{ width: number; height: number }>; + InactiveIcon: React.FC<{ width: number; height: number }>; +}) => { + const Icon = isActive ? ActiveIcon : InactiveIcon; + return ; +}); + +// 提取 SVG 组件,避免重复渲染 +const CenterButtonSvg = React.memo(() => ( + + + + + + + + + + + + + + + + + + + + +)); + +interface AskNavbarProps { + wsStatus: WebSocketStatus; +} + +const AskNavbar = ({ wsStatus }: AskNavbarProps) => { // 获取设备尺寸 - const { width } = Dimensions.get('window'); - // 获取路由 + const { width } = useMemo(() => Dimensions.get('window'), []); const pathname = usePathname(); - return ( - { + switch (wsStatus) { + case 'connected': + return '#4CAF50'; // Green + case 'connecting': + case 'reconnecting': + return '#FFC107'; // Amber + case 'disconnected': + default: + return '#F44336'; // Red + } + }, [wsStatus]); + + // 预加载目标页面 + useEffect(() => { + const preloadPages = async () => { + try { + await Promise.all([ + router.prefetch('/memo-list'), + router.prefetch('/ask'), + router.prefetch('/owner') + ]); + } catch (error) { + console.warn('预加载页面失败:', error); + } + }; + preloadPages(); + }, []); + + // 使用 useCallback 缓存导航函数 + const navigateTo = useCallback((route: string) => { + if (route === '/ask') { + router.push({ + pathname: '/ask', + params: { newSession: "true" } + }); + } else { + router.push(route as any); + } + }, []); + + // 使用 useMemo 缓存样式对象 + const styles = useMemo(() => StyleSheet.create({ + container: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0 + }, + backgroundImage: { + width, + height: 80, + resizeMode: 'cover' + }, + navButton: { + width: width / 2, // 半屏宽度 + height: 80, // 与 navbar 高度相同 + justifyContent: 'center', + alignItems: 'center' + }, + navContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 80, // Set a fixed height for the navbar + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 32, + backgroundColor: 'transparent', // Make sure it's transparent + }, + centerButton: { + position: 'absolute', + left: width / 2, + top: -30, // Adjust this value to move the button up or down + marginLeft: -42.5, // Half of the button width (85/2) + width: 85, + height: 85, + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#FFB645', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, shadowRadius: 8, - elevation: 10, // For Android - }}> - {/* */} - - - router.push('/memo-list')} style={{ padding: 16 }}> - {pathname === "/memo-list" ? : } + elevation: 8, + borderRadius: 50, + backgroundColor: 'transparent', + zIndex: 10, + }, + statusIndicator: { + position: 'absolute', + top: 15, + right: 15, + width: 10, + height: 10, + borderRadius: 5, + borderWidth: 1, + borderColor: '#FFF', + backgroundColor: statusColor, + zIndex: 11, + } + }), [width, statusColor]); + + // 如果当前路径是ask页面,则不渲染导航栏 + if (pathname != '/memo-list' && pathname != '/owner') { + return null; + } + + return ( + + + + navigateTo('/memo-list')} + style={[styles.navButton, { alignItems: "flex-start", paddingLeft: 16 }]} + > + { - router.push({ - pathname: '/ask', - params: { newSession: "true" } - }); - }} - className={`${Platform.OS === 'web' ? '-mt-[4rem]' : width <= 375 ? '-mt-[5rem]' : '-mt-[5rem]'}`} + onPress={() => navigateTo('/ask')} + style={styles.centerButton} > - - - - - - - - - - - - - - - - - - - - - - - - - - + + - router.push('/owner')} style={{ padding: 16 }}> - - {pathname === "/owner" ? : } - {/* */} - + + navigateTo('/owner')} + style={styles.navButton} + > + ); }; -export default AskNavbar; \ No newline at end of file +export default React.memo(AskNavbar); \ No newline at end of file diff --git a/components/login/code.tsx b/components/login/code.tsx index 156ecac..8d62af9 100644 --- a/components/login/code.tsx +++ b/components/login/code.tsx @@ -1,26 +1,25 @@ import Error from "@/assets/icons/svg/error.svg"; import { fetchApi } from "@/lib/server-api-util"; import { User } from "@/types/user"; +import OTPInputView from '@twotalltotems/react-native-otp-input'; import { router } from "expo-router"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, Animated, TextInput as RNTextInput, TextInput, TouchableOpacity, View } from "react-native"; +import { Animated, TextInput as RNTextInput, StyleSheet, TouchableOpacity, View } from "react-native"; import { useAuth } from "../../contexts/auth-context"; import { ThemedText } from "../ThemedText"; -import { Steps } from "./phoneLogin"; -interface LoginProps { - setSteps: (steps: Steps) => void; +interface CodeProps { phone: string; } -const Code = ({ setSteps, phone }: LoginProps) => { +const Code = ({ phone }: CodeProps) => { const { t } = useTranslation(); const { login } = useAuth(); const [isLoading, setIsLoading] = useState(false); const refs = useRef>(Array(6).fill(null)); const shakeAnim = useRef(new Animated.Value(0)).current; - const [code, setCode] = useState(['', '', '', '', '', '']); + const [code, setCode] = useState([]); const [error, setError] = useState(''); const focusNext = (index: number, value: string) => { @@ -37,20 +36,27 @@ const Code = ({ setSteps, phone }: LoginProps) => { setError(''); const newCode = [...code]; - // Handle pasted code from SMS - if (text.length === 6 && /^\d{6}$/.test(text)) { - const digits = text.split(''); + // Handle pasted code from SMS or autofill + if ((text.length === 6 || text.length > 1) && /^\d+$/.test(text)) { + const digits = text.split('').slice(0, 6); // Ensure we only take first 6 digits setCode(digits); - refs.current[5]?.focus(); // Focus on the last input after autofill + refs.current[5]?.focus(); // Focus on the last input + // Auto-submit if we have exactly 6 digits + if (digits.length === 6) { + handleTelLogin(); + } return; } - // Handle manual input - if (text.length <= 1) { + // Handle single digit input + if (text.length <= 1 && /^\d?$/.test(text)) { newCode[index] = text; setCode(newCode); - if (text) { + // Auto-submit if this is the last digit + if (text && index === 5) { + handleTelLogin(); + } else if (text) { focusNext(index, text); } } @@ -64,7 +70,6 @@ const Code = ({ setSteps, phone }: LoginProps) => { }) } catch (error) { - // console.error(t("auth.telLogin.sendCodeError", { ns: 'login' }), error); } } @@ -90,13 +95,11 @@ const Code = ({ setSteps, phone }: LoginProps) => { login(res, res.access_token || '') router.replace('/user-message') }).catch((error) => { - // console.log(error); setError(t("auth.telLogin.codeVaild", { ns: 'login' })); }) setIsLoading(false); } catch (error) { setIsLoading(false); - // console.error(t("auth.telLogin.codeVaild", { ns: 'login' }), error); } } // 60s倒计时 @@ -109,104 +112,143 @@ const Code = ({ setSteps, phone }: LoginProps) => { }, [countdown]); return ( - - - - - {t("auth.telLogin.title", { ns: 'login' })} + + + + + {t("auth.telLogin.codeTitle", { ns: 'login' })} - + {t("auth.telLogin.secondTitle", { ns: 'login' })} - + {phone} - { + setCode([code]); }} - > - {code.map((digit, index) => ( - { - if (ref) { - refs.current[index] = ref; - } - }} - style={{ width: 40, height: 40 }} - className="bg-[#FFF8DE] rounded-xl text-textTertiary text-3xl text-center" - keyboardType="number-pad" - maxLength={1} - textContentType="oneTimeCode" // For iOS autofill - autoComplete='sms-otp' // For Android autofill - value={digit} - onChangeText={text => handleCodeChange(text, index)} - onKeyPress={({ nativeEvent }) => focusPrevious(index, nativeEvent.key)} - selectTextOnFocus - caretHidden={true} - /> - ))} - - + onCodeFilled={() => { + handleTelLogin() + }} + code={code.join('')} + autoFocusOnLoad={false} + codeInputFieldStyle={styles.underlineStyleBase} + codeInputHighlightStyle={styles.underlineStyleHighLighted} + style={styles.otpContainer} + placeholderCharacter="-" + placeholderTextColor="#AC7E35" + /> + - + {error} - - {isLoading ? ( - - ) : ( - - {t("auth.telLogin.continue", { ns: 'login' })} - - )} - - - + + {t("auth.telLogin.sendAgain", { ns: 'login' })} { - if (countdown > 0) { - return - } else { + if (countdown <= 0) { sendVerificationCode() } }}> - 0 ? '!text-gray-400' : ''}`}> + 0 && styles.disabledResendText + ]}> {countdown > 0 ? `${countdown}s${t("auth.telLogin.resend", { ns: 'login' })}` : t("auth.telLogin.resend", { ns: 'login' })} - - - setSteps('phone')} - > - - {t("auth.telLogin.goBack", { ns: 'login' })} - - - ) } +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFFFFF', + }, + contentContainer: { + flex: 1, + justifyContent: 'center', + }, + headerContainer: { + alignItems: 'center', + marginBottom: 16, + }, + title: { + fontSize: 24, + fontWeight: '600', + marginBottom: 8, + paddingTop: 4, + color: '#111827', + }, + subtitle: { + fontSize: 16, + color: '#4B5563', + textAlign: 'center', + marginBottom: 4, + }, + phoneNumber: { + fontSize: 16, + fontWeight: '500', + color: '#E2793F', + }, + otpContainer: { + width: '100%', + height: 80, + }, + underlineStyleBase: { + width: 50, + height: 50, + borderWidth: 0, + borderRadius: 16, + fontSize: 18, + color: '#000000', + textAlign: 'center', + backgroundColor: '#FFF8DE', + }, + underlineStyleHighLighted: { + borderColor: '#E2793F', + backgroundColor: '#FFF8DE', + borderWidth: 2, + }, + errorContainer: { + width: '100%', + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + }, + errorText: { + fontSize: 16, + fontWeight: '500', + color: '#E2793F', + marginLeft: 8, + }, + footerContainer: { + flexDirection: 'row', + justifyContent: 'center', + marginTop: 8, + }, + footerText: { + color: '#6B7280', + }, + resendText: { + color: '#E2793F', + fontWeight: '500', + marginLeft: 4, + }, + disabledResendText: { + color: '#9CA3AF', + }, +}); -export default Code \ No newline at end of file +export default Code; \ No newline at end of file diff --git a/components/login/forgetPwd.tsx b/components/login/forgetPwd.tsx index 0f38b15..da377d2 100644 --- a/components/login/forgetPwd.tsx +++ b/components/login/forgetPwd.tsx @@ -2,7 +2,7 @@ import { fetchApi } from "@/lib/server-api-util"; import { User } from "@/types/user"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, TextInput, TouchableOpacity, View } from "react-native"; +import { ActivityIndicator, StyleSheet, TextInput, TouchableOpacity, View } from "react-native"; import { ThemedText } from "../ThemedText"; interface LoginProps { @@ -15,12 +15,10 @@ interface LoginProps { const ForgetPwd = ({ setIsSignUp, updateUrlParam, setError }: LoginProps) => { const { t } = useTranslation(); const [loading, setLocading] = useState(false); - // 发送邮箱后把按钮变为disabled const [isDisabled, setIsDisabled] = useState(false); const [email, setEmail] = useState(''); const [countdown, setCountdown] = useState(0); - // 倒计时效果 useEffect(() => { if (countdown > 0) { const timer = setTimeout(() => setCountdown(countdown - 1), 1000); @@ -30,7 +28,6 @@ const ForgetPwd = ({ setIsSignUp, updateUrlParam, setError }: LoginProps) => { } }, [countdown, isDisabled]); - // 发送邮件 const handleSubmit = () => { if (!email) { setError(t('auth.forgetPwd.emailPlaceholder', { ns: 'login' })); @@ -41,7 +38,7 @@ const ForgetPwd = ({ setIsSignUp, updateUrlParam, setError }: LoginProps) => { const body = { email: email, } - // 调接口确定邮箱是否正确,是否有该用户邮箱权限 + fetchApi('/iam/reset-password-session', { method: 'POST', body: JSON.stringify(body), @@ -50,19 +47,17 @@ const ForgetPwd = ({ setIsSignUp, updateUrlParam, setError }: LoginProps) => { } }) .then((_) => { - // console.log("Password reset email sent successfully"); setIsDisabled(true); - setCountdown(60); // 开始60秒倒计时 + setCountdown(60); }) .catch((error) => { - // console.error('Failed to send reset email:', error); - setError(t('auth.forgetPwd.sendEmailError', { ns: 'login' })); + setError(error.message || t('auth.forgetPwd.sendEmailError', { ns: 'login' })); }) .finally(() => { setLocading(false); }); }; - // 返回登陆 + const handleBackToLogin = () => { if (setIsSignUp) { setIsSignUp('login'); @@ -72,50 +67,95 @@ const ForgetPwd = ({ setIsSignUp, updateUrlParam, setError }: LoginProps) => { } } - return - {/* 邮箱输入框 */} - - - {t('auth.forgetPwd.title', { ns: 'login' })} - - - - {/* 发送重置密码邮件 */} - - {loading ? ( - - ) : ( - - {isDisabled - ? `${t("auth.forgetPwd.sendEmailBtnDisabled", { ns: "login" })} (${countdown}s)` - : t("auth.forgetPwd.sendEmailBtn", { ns: "login" })} - + return ( + + + + {t('auth.forgetPwd.title', { ns: 'login' })} - )} - - {/* 返回登陆 */} - - - {t('auth.forgetPwd.goback', { ns: 'login' })} - - - + + + + + {loading ? ( + + ) : ( + + {isDisabled + ? `${t("auth.forgetPwd.sendEmailBtnDisabled", { ns: "login" })} (${countdown}s)` + : t("auth.forgetPwd.sendEmailBtn", { ns: "login" })} + + )} + + + + + {t('auth.forgetPwd.goback', { ns: 'login' })} + + + + ); } +const styles = StyleSheet.create({ + container: { + width: '100%', + }, + inputContainer: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 16, + color: '#1F2937', + marginBottom: 8, + marginLeft: 8, + }, + textInput: { + borderRadius: 12, + padding: 12, + fontSize: 16, + backgroundColor: '#FFF8DE', + }, + submitButton: { + width: '100%', + backgroundColor: '#E2793F', + borderRadius: 28, + padding: 16, + alignItems: 'center', + }, + disabledButton: { + opacity: 0.5, + }, + buttonText: { + color: '#FFFFFF', + fontWeight: '600', + }, + backButton: { + alignSelf: 'center', + marginTop: 24, + }, + backButtonText: { + color: '#1F2937', + fontSize: 14, + }, +}); -export default ForgetPwd \ No newline at end of file +export default ForgetPwd; \ No newline at end of file diff --git a/components/login/login.tsx b/components/login/login.tsx index 134e560..32c27cb 100644 --- a/components/login/login.tsx +++ b/components/login/login.tsx @@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, TextInput, TouchableOpacity, View } from "react-native"; +import { ActivityIndicator, StyleSheet, TextInput, TouchableOpacity, View } from "react-native"; import { useAuth } from "../../contexts/auth-context"; import { fetchApi } from "../../lib/server-api-util"; import { User } from "../../types/user"; @@ -52,6 +52,8 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi router.replace('/user-message'); } } catch (error) { + const errorMessage = error instanceof Error ? error.message : t('auth.login.loginError', { ns: 'login' }); + setError(errorMessage); } finally { setIsLoading(false); } @@ -64,93 +66,187 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi const handleSignUp = () => { updateUrlParam('status', 'signUp'); }; - return - {/* 邮箱输入框 */} - - - {t('auth.login.email', { ns: 'login' })} - - { - setEmail(text); - setError('123'); - }} - keyboardType="email-address" - autoCapitalize="none" - /> - - {/* 密码输入框 */} - - - {t('auth.login.password', { ns: 'login' })} - - + + return ( + + + + {t('auth.login.email', { ns: 'login' })} + { - setPassword(text); + setEmail(text); setError('123'); }} - secureTextEntry={!showPassword} + autoCapitalize="none" /> - setShowPassword(!showPassword)} - > - + + + + {t('auth.login.password', { ns: 'login' })} + + + { + setPassword(text); + setError('123'); + }} + secureTextEntry={!showPassword} /> + setShowPassword(!showPassword)} + > + + + + + + + + {t('auth.login.forgotPassword', { ns: 'login' })} + + + + + {isLoading ? ( + + ) : ( + + {t('auth.login.loginButton', { ns: 'login' })} + + )} + + + + + + OR + + + + + + + + + + + + {t('auth.login.signUpMessage', { ns: 'login' })} + + + + {t('auth.login.signUp', { ns: 'login' })} + + ); +}; - {/* 忘记密码链接 */} - - - {t('auth.login.forgotPassword', { ns: 'login' })} - - +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loginTypeContainer: { + display: "flex", + flexDirection: "column", + justifyContent: 'center', + alignItems: 'center', + gap: 16, + width: "70%" + }, + loginType: { + borderRadius: 12, + width: 54, + height: 54, + textAlign: 'center', + backgroundColor: '#FADBA1' + }, + inputContainer: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 16, + color: '#AC7E35', + fontWeight: '600', + marginBottom: 8, + marginLeft: 8, + }, + textInput: { + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 14, + textAlignVertical: 'center', + backgroundColor: '#FFF8DE' + }, + passwordInputContainer: { + position: 'relative', + }, + eyeIcon: { + position: 'absolute', + right: 12, + top: 14, + }, + forgotPassword: { + alignSelf: 'flex-end', + marginBottom: 24, + }, + forgotPasswordText: { + color: '#AC7E35', + fontSize: 11, + }, + loginButton: { + width: '100%', + backgroundColor: '#E2793F', + borderRadius: 28, + padding: 16, + alignItems: 'center', + marginBottom: 24, + }, + loginButtonText: { + color: '#FFFFFF', + fontWeight: '600', + fontSize: 18, + }, + signupContainer: { + flexDirection: 'row', + justifyContent: 'center', + marginTop: 8, + }, + signupText: { + color: '#AC7E35', + fontSize: 17, + }, + signupLink: { + color: '#E2793F', + fontSize: 17, + fontWeight: '600', + marginLeft: 4, + textDecorationLine: 'underline', + }, +}); - {/* 登录按钮 */} - - {isLoading ? ( - - ) : ( - - {t('auth.login.loginButton', { ns: 'login' })} - - )} - - - {/* 注册链接 */} - - - {t('auth.login.signUpMessage', { ns: 'login' })} - - - - {t('auth.login.signUp', { ns: 'login' })} - - - - -} - - -export default Login \ No newline at end of file +export default Login; \ No newline at end of file diff --git a/components/login/phone.tsx b/components/login/phone.tsx index e39365c..92d7409 100644 --- a/components/login/phone.tsx +++ b/components/login/phone.tsx @@ -9,9 +9,10 @@ interface LoginProps { setSteps: (steps: Steps) => void; setPhone: (phone: string) => void; phone: string; + updateUrlParam: (status: string, value: string) => void; } -const Phone = ({ setSteps, setPhone, phone }: LoginProps) => { +const Phone = ({ setSteps, setPhone, phone, updateUrlParam }: LoginProps) => { const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); @@ -30,6 +31,7 @@ const Phone = ({ setSteps, setPhone, phone }: LoginProps) => { body: JSON.stringify({ phone: phone }), }) setSteps('code') + updateUrlParam("status", "code"); setIsLoading(false); } catch (error) { setPhone("") diff --git a/components/login/phoneLogin.tsx b/components/login/phoneLogin.tsx index a509ad9..843014f 100644 --- a/components/login/phoneLogin.tsx +++ b/components/login/phoneLogin.tsx @@ -5,13 +5,17 @@ import Phone from "./phone"; export type Steps = "phone" | "code"; -const PhoneLogin = () => { +interface LoginProps { + updateUrlParam: (status: string, value: string) => void; +} + +const PhoneLogin = ({ updateUrlParam }: LoginProps) => { const [steps, setSteps] = useState("phone"); const [phone, setPhone] = useState(''); return { - steps === "phone" ? : + steps === "phone" ? : } } diff --git a/components/login/signUp.tsx b/components/login/signUp.tsx index 6f5c441..22f0ef3 100644 --- a/components/login/signUp.tsx +++ b/components/login/signUp.tsx @@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useEffect, useState } from 'react'; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, TextInput, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; import { useAuth } from "../../contexts/auth-context"; import { fetchApi } from "../../lib/server-api-util"; import { User } from "../../types/user"; @@ -14,9 +14,11 @@ interface LoginProps { setError: (error: string) => void; setShowPassword: (showPassword: boolean) => void; showPassword: boolean; + setShowSecondPassword: (showSecondPassword: boolean) => void; + showSecondPassword: boolean; } -const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: LoginProps) => { +const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword, setShowSecondPassword, showSecondPassword }: LoginProps) => { const { t } = useTranslation(); const { login } = useAuth(); const router = useRouter(); @@ -32,7 +34,6 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log // 从 URL 参数中获取 task_id 和 steps const params = useLocalSearchParams<{ task_id?: string; steps?: string }>(); const taskId = params.task_id; - const steps = params.steps; const handlePasswordChange = (value: string) => { setPassword(value); @@ -84,9 +85,13 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log } if (password) { // 校验密码是否符合规范 - const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/; - if (!passwordRegex.test(password)) { - setError(t('auth.signup.passwordAuth', { ns: 'login' })); + // const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/; + // if (!passwordRegex.test(password)) { + // setError(t('auth.signup.passwordAuth', { ns: 'login' })); + // return; + // } + if (password.length < 6) { + setError(t('auth.signup.pwdLengthError', { ns: 'login' })); return; } } @@ -135,208 +140,303 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log // 初始化 useEffect(() => { setShowPassword(false) + setShowSecondPassword(false) }, []) - return - {/* 邮箱输入 */} - - - {t('auth.login.email', { ns: 'login' })} - - - { - setEmail(value) - setError('123') - }} - keyboardType="email-address" - autoCapitalize="none" - /> - - - - {/* 密码输入 */} - - - {t('auth.login.password', { ns: 'login' })} - - - { - handlePasswordChange(value) - setError('123') - }} - secureTextEntry={!showPassword} - /> - setShowPassword(!showPassword)} - className="px-3 py-2" - > - - - - - - {/* 确认密码 */} - - - {t('auth.signup.confirmPassword', { ns: 'login' })} - - - { - handleConfirmPasswordChange(value) - setError('123') - }} - secureTextEntry={!showPassword} - /> - setShowPassword(!showPassword)} - className="px-3 py-2" - > - - - - - - {/* 注册按钮 */} - - {loading ? ( - - ) : ( - - {t("auth.signup.signupButton", { ns: 'login' })} + return ( + + {/* 邮箱输入 */} + + + {t('auth.login.email', { ns: 'login' })} - )} - - - { - const newValue = !checked; - setChecked(newValue); - if (!newValue) { - setError(t('auth.signup.checkedRequired', { ns: 'login' })); - return - } else { - setError("123") - } + + { + setEmail(value) + setError('123') + }} + keyboardType="email-address" + autoCapitalize="none" + /> + + - }} - style={{ - width: 20, - height: 20, - borderRadius: 10, - borderWidth: 2, - borderColor: checked ? '#E2793F' : '#ccc', - backgroundColor: checked ? '#E2793F' : 'transparent', - justifyContent: 'center', - alignItems: 'center', - marginRight: 8, - }} + {/* 密码输入 */} + + + {t('auth.login.password', { ns: 'login' })} + + + { + handlePasswordChange(value) + setError('123') + }} + secureTextEntry={!showPassword} + /> + setShowPassword(!showPassword)} + style={styles.eyeIcon} + > + + + + + + {/* 确认密码 */} + + + {t('auth.signup.confirmPassword', { ns: 'login' })} + + + { + handleConfirmPasswordChange(value) + setError('123') + }} + secureTextEntry={!showSecondPassword} + /> + setShowSecondPassword(!showSecondPassword)} + style={styles.eyeIcon} + > + + + + + + {/* 注册按钮 */} + - {checked && ( - + {loading ? ( + + ) : ( + + {t("auth.signup.signupButton", { ns: 'login' })} + )} - - - {t("auth.telLogin.agree", { ns: 'login' })} - - { - setModalType('terms'); - setPrivacyModalVisible(true); - }}> - - {t("auth.telLogin.terms", { ns: 'login' })} - + + { + const newValue = !checked; + setChecked(newValue); + if (!newValue) { + setError(t('auth.signup.checkedRequired', { ns: 'login' })); + return + } else { + setError("123") + } + + }} + style={[ + styles.checkbox, + checked && styles.checkboxChecked + ]} + > + {checked && ( + + )} - - {t("auth.telLogin.and", { ns: 'login' })} - - { - setModalType('privacy'); - setPrivacyModalVisible(true); - }}> - - {t("auth.telLogin.privacyPolicy", { ns: 'login' })} + + + {t("auth.telLogin.agree", { ns: 'login' })} - - - {t("auth.telLogin.and", { ns: 'login' })} - - { - setModalType('user'); - setPrivacyModalVisible(true); - }}> - - {t("auth.telLogin.userAgreement", { ns: 'login' })} + { + setModalType('terms'); + setPrivacyModalVisible(true); + }}> + + {t("auth.telLogin.terms", { ns: 'login' })} + + + + {t("auth.telLogin.and", { ns: 'login' })} - - - {t("auth.telLogin.and", { ns: 'login' })} - - { - setModalType('ai'); - setPrivacyModalVisible(true); - }}> - - {t("auth.telLogin.aiAgreement", { ns: 'login' })} + { + setModalType('privacy'); + setPrivacyModalVisible(true); + }}> + + {t("auth.telLogin.privacyPolicy", { ns: 'login' })} + + + + {t("auth.telLogin.and", { ns: 'login' })} - - - {t("auth.telLogin.agreement", { ns: 'login' })} - - - {t("common.name")} - - - {t("auth.telLogin.getPhone", { ns: 'login' })} - + { + setModalType('user'); + setPrivacyModalVisible(true); + }}> + + {t("auth.telLogin.userAgreement", { ns: 'login' })} + + + + {t("auth.telLogin.and", { ns: 'login' })} + + { + setModalType('ai'); + setPrivacyModalVisible(true); + }}> + + {t("auth.telLogin.aiAgreement", { ns: 'login' })} + + + + {t("auth.telLogin.agreement", { ns: 'login' })} + + + {t("common.name")} + + + {t("auth.telLogin.getPhone", { ns: 'login' })} + + - - {/* 已有账号 */} - - - {t("auth.signup.haveAccount", { ns: 'login' })} - - { - updateUrlParam("status", "login"); - }} - > - - {t("auth.signup.login", { ns: 'login' })} + {/* 已有账号 */} + + + {t("auth.signup.haveAccount", { ns: 'login' })} - + { + updateUrlParam("status", "login"); + }} + > + + {t("auth.signup.login", { ns: 'login' })} + + + + + {/* 协议弹窗 */} + + ); +}; - {/* 协议弹窗 */} - - -} +const styles = StyleSheet.create({ + container: { + width: '100%', + }, + inputContainer: { + marginBottom: 16, + }, + inputWrapper: { + borderRadius: 12, + backgroundColor: '#FFF8DE', + overflow: 'hidden', + }, + inputLabel: { + fontSize: 14, + color: '#AC7E35', + fontWeight: '600', + marginBottom: 8, + marginLeft: 8, + }, + textInput: { + padding: 12, + fontSize: 16, + color: '#1F2937', + }, + passwordInputContainer: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 12, + backgroundColor: '#FFF8DE', + overflow: 'hidden', + }, + eyeIcon: { + paddingHorizontal: 12, + paddingVertical: 8, + }, + signupButton: { + width: '100%', + backgroundColor: '#E2793F', + borderRadius: 28, + padding: 16, + alignItems: 'center', + marginBottom: 16, + }, + signupButtonText: { + color: '#FFFFFF', + fontWeight: '600', + }, + termsContainer: { + flexDirection: 'row', + alignItems: 'flex-start', + marginVertical: 10, + }, + checkbox: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + borderColor: '#E5E7EB', + justifyContent: 'center', + alignItems: 'center', + marginRight: 8, + marginTop: 2, + }, + checkboxChecked: { + backgroundColor: '#E2793F', + borderColor: '#E2793F', + }, + termsTextContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + flex: 1, + }, + termsText: { + fontSize: 14, + color: '#1F2937', + lineHeight: 20, + }, + termsLink: { + fontSize: 14, + color: '#E2793F', + lineHeight: 20, + }, + loginContainer: { + flexDirection: 'row', + justifyContent: 'center', + marginTop: 24, + }, + loginText: { + fontSize: 14, + color: '#1F2937', + }, + loginLink: { + color: '#E2793F', + fontSize: 14, + fontWeight: '600', + marginLeft: 4, + }, +}); -export default SignUp \ No newline at end of file +export default SignUp; \ No newline at end of file diff --git a/components/lottie/lottie.tsx b/components/lottie/lottie.tsx index 3bb2eaa..ae45c7b 100644 --- a/components/lottie/lottie.tsx +++ b/components/lottie/lottie.tsx @@ -1,6 +1,8 @@ // welcome.tsx (Web 版本) // 在 Web 端不显示任何内容 +import { StyleProp, ViewStyle } from "react-native"; + // 占位符 移动端实际引入文件是 welcome.native.tsx 文件 -export default function WebLottie(props: { source: string }) { +export default function WebLottie(props: { source: string, style?: StyleProp, loop?: boolean }) { return null; } \ No newline at end of file diff --git a/components/memo/SkeletonItem.tsx b/components/memo/SkeletonItem.tsx new file mode 100644 index 0000000..39c144f --- /dev/null +++ b/components/memo/SkeletonItem.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +const SkeletonItem = () => { + return ( + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + backgroundColor: '#fff', + }, + avatar: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#f0f0f0', + }, + content: { + flex: 1, + marginLeft: 12, + justifyContent: 'center', + }, + title: { + height: 16, + width: '60%', + backgroundColor: '#f0f0f0', + marginBottom: 8, + borderRadius: 4, + }, + subtitle: { + height: 14, + width: '80%', + backgroundColor: '#f5f5f5', + borderRadius: 4, + }, +}); + +export default React.memo(SkeletonItem); diff --git a/components/owner/SkeletonOwner.tsx b/components/owner/SkeletonOwner.tsx new file mode 100644 index 0000000..333eb77 --- /dev/null +++ b/components/owner/SkeletonOwner.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +// 骨架屏占位组件 +const SkeletonItem = () => ( + +); + +const SkeletonOwner = () => { + const insets = useSafeAreaInsets(); + + return ( + + {/* 用户信息骨架屏 */} + + + + + + + + + + + + + + + {/* 会员卡骨架屏 */} + + + + + {/* 作品数据骨架屏 */} + + + + + + + + {/* 排行榜骨架屏 */} + + + + {Array(3).fill(0).map((_, index) => ( + + ))} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'white', + }, + section: { + marginBottom: 16, + }, + skeletonItem: { + backgroundColor: '#E1E1E1', + borderRadius: 8, + overflow: 'hidden', + }, + userInfoHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + userInfoTextContainer: { + flex: 1, + marginLeft: 16, + }, + userInfoStats: { + flexDirection: 'row', + justifyContent: 'space-around', + }, + countContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 16, + }, + rankingList: { + marginTop: 16, + }, +}); + +export default SkeletonOwner; diff --git a/components/owner/album.tsx b/components/owner/album.tsx index d546198..329c8b2 100644 --- a/components/owner/album.tsx +++ b/components/owner/album.tsx @@ -1,14 +1,16 @@ import SettingSvg from '@/assets/icons/svg/setting.svg'; +import { useRouter } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { StyleProp, StyleSheet, TouchableOpacity, View, ViewStyle } from "react-native"; import { ThemedText } from "../ThemedText"; interface CategoryProps { - setModalVisible: (visible: boolean) => void; style?: StyleProp; } -const AlbumComponent = ({ setModalVisible, style }: CategoryProps) => { +const AlbumComponent = ({ style }: CategoryProps) => { const { t } = useTranslation(); + const router = useRouter(); + return ( @@ -17,7 +19,13 @@ const AlbumComponent = ({ setModalVisible, style }: CategoryProps) => { {t('generalSetting.shareProfile', { ns: 'personal' })} - setModalVisible(true)} style={[styles.text, { flex: 1, alignItems: "center", paddingVertical: 6 }]}> + { + router.push('/setting'); + }} + activeOpacity={0.7} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={[styles.text, { flex: 1, alignItems: "center", paddingVertical: 6, zIndex: 999 }]}> diff --git a/components/owner/carousel.tsx b/components/owner/carousel.tsx new file mode 100644 index 0000000..c1168ef --- /dev/null +++ b/components/owner/carousel.tsx @@ -0,0 +1,167 @@ +import ImgTotalSvg from "@/assets/icons/svg/imgTotal.svg"; +import LiveTotalSvg from "@/assets/icons/svg/liveTotal.svg"; +import TimeTotalSvg from "@/assets/icons/svg/timeTotal.svg"; +import VideoTotalSvg from "@/assets/icons/svg/videoTotal.svg"; +import { Counter, UserCountData } from "@/types/user"; +import * as React from "react"; +import { Dimensions, StyleSheet, View, ViewStyle } from "react-native"; +import Carousel from "react-native-reanimated-carousel"; +import { ThemedText } from "../ThemedText"; +import { formatDuration } from "../utils/time"; +import CategoryComponent from "./category"; +interface Props { + data: Counter +} + +interface CarouselData { + key: string, + value: UserCountData + +}[] +const width = Dimensions.get("window").width; + +function CarouselComponent(props: Props) { + const { data } = props; + const [carouselDataValue, setCarouselDataValue] = React.useState([]); + const dataHandle = () => { + const carouselData = { ...data?.category_count, total_count: data?.total_count } + // 1. 转换为数组并过滤掉 'total' + const entries = Object?.entries(carouselData) + ?.filter(([key]) => key !== 'total_count') + ?.map(([key, value]) => ({ key, value })); + + // 2. 找到 total 数据 + const totalEntry = { + key: 'total_count', + value: carouselData?.total_count + }; + + // 3. 插入到中间位置 + const middleIndex = Math.floor((entries || [])?.length / 2); + entries?.splice(middleIndex, 0, totalEntry); + setCarouselDataValue(entries) + return entries; + } + + const totleItem = (data: UserCountData) => { + return + {Object?.entries(data)?.filter(([key]) => key !== 'cover_url')?.map((item, index) => ( + + + + { + item?.[0]?.includes('video_count') ? : item?.[0]?.includes('photo') ? : item?.[0]?.includes('live') ? : + } + + {item[0]} + + {item[1]} + + ))} + + } + + React.useEffect(() => { + if (data) { + dataHandle() + } + }, [data]); + + return ( + + item?.key === 'total_count') - 1 + // )) + // : 0 + // } + modeConfig={{ + parallaxScrollingScale: 1, + parallaxScrollingOffset: 150, + parallaxAdjacentItemScale: 0.7 + }} + renderItem={({ item, index }) => { + const style: ViewStyle = { + width: width, + height: width * 0.8, + alignItems: "center", + }; + return ( + + {item?.key === 'total_count' ? ( + totleItem(item.value) + ) : ( + + {CategoryComponent({ + title: item?.key, + data: [ + { title: 'Video', number: item?.value?.video_count }, + { title: 'Photo', number: item?.value?.photo_count }, + { title: 'Length', number: formatDuration(item?.value?.video_length || 0) } + ], + bgSvg: item?.value?.cover_url, + })} + + )} + + ) + }} + /> + + ); +} + +const styles = StyleSheet.create({ + icon: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#fff', + borderRadius: 32, + padding: 4 + }, + container: { + backgroundColor: "#FFB645", + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 16, + display: "flex", + flexDirection: "column", + position: 'relative', + width: width * 0.7 + }, + image: { + position: 'absolute', + bottom: 0, + right: 0, + left: 0, + alignItems: 'center', + justifyContent: 'flex-end', + }, + item: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 16 + }, + title: { + color: "#4C320C", + fontWeight: "500", + fontSize: 22, + }, + number: { + color: "#fff", + fontWeight: "700", + fontSize: 26, + textAlign: 'left', + flex: 1, + paddingTop: 8 + } +}) + +export default CarouselComponent; \ No newline at end of file diff --git a/components/owner/category.tsx b/components/owner/category.tsx index 06a0a15..8af6b84 100644 --- a/components/owner/category.tsx +++ b/components/owner/category.tsx @@ -1,6 +1,11 @@ +import ImgTotalSvg from "@/assets/icons/svg/imgTotalWhite.svg"; +import LiveTotalSvg from "@/assets/icons/svg/liveTotal.svg"; +import PeopleSvg from "@/assets/icons/svg/people.svg"; +import TimeTotalSvg from "@/assets/icons/svg/timeTotalWhite.svg"; +import VideoTotalSvg from "@/assets/icons/svg/videoTotalWhite.svg"; +import { BlurView } from "expo-blur"; import { Image, StyleProp, StyleSheet, View, ViewStyle } from "react-native"; import { ThemedText } from "../ThemedText"; - interface CategoryProps { title: string; data: { title: string, number: string | number }[]; @@ -13,20 +18,33 @@ const CategoryComponent = ({ title, data, bgSvg, style }: CategoryProps) => { - + - {title} {data.map((item, index) => ( - {item.title} + + + { + index == 0 ? : index == 1 ? : index == 2 ? : + } + + {item.title} + {item.number} ))} + + {title} + + ); @@ -37,45 +55,64 @@ const styles = StyleSheet.create({ borderRadius: 32, overflow: 'hidden', position: 'relative', + aspectRatio: 1, }, backgroundContainer: { ...StyleSheet.absoluteFillObject, - width: '100%', - height: '100%', + width: "100%", + height: "100%", }, overlay: { ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.4)', // 0% 不透明度的黑色 + backgroundColor: 'rgba(0, 0, 0, 0.4)', + backdropFilter: 'blur(5px)', }, content: { - paddingHorizontal: 16, - paddingVertical: 8, - justifyContent: 'space-between', + padding: 16, + justifyContent: "space-between", flex: 1 }, - title: { + titleContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + position: 'relative', width: '100%', - textAlign: "center", + }, + title: { color: 'white', - fontSize: 16, + fontSize: 20, fontWeight: '700', textShadowColor: 'rgba(0, 0, 0, 0.5)', textShadowOffset: { width: 1, height: 1 }, textShadowRadius: 2, + position: 'absolute', + textAlign: 'center', + width: '100%', + zIndex: 1, }, item: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', + paddingVertical: 8, + width: '100%', }, itemTitle: { color: 'white', - fontSize: 10, + fontSize: 22, fontWeight: '700', + marginLeft: 16, + flex: 1, }, itemNumber: { color: 'white', - fontSize: 10, + fontSize: 28, + fontWeight: '700', + textAlign: 'left', + marginLeft: 8, + flex: 1, + paddingTop: 8 } }); diff --git a/components/owner/createCount.tsx b/components/owner/createCount.tsx index fec95ee..ac507bb 100644 --- a/components/owner/createCount.tsx +++ b/components/owner/createCount.tsx @@ -1,3 +1,4 @@ +import React, { useMemo } from "react"; import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; import { ThemedText } from "../ThemedText"; @@ -7,27 +8,35 @@ interface CreateCountProps { number: number; style?: StyleProp; } -const CreateCountComponent = (props: CreateCountProps) => { + +// 使用React.memo优化组件,避免不必要的重渲染 +const CreateCountComponent = React.memo((props: CreateCountProps) => { + // 预计算样式以提高性能 + const containerStyle = useMemo(() => [ + styles.container, + props.style + ], [props.style]); + return ( - + - {props.title} {props.icon} + {props.title} {props.number} ); -}; +}); const styles = StyleSheet.create({ container: { flex: 1, display: "flex", flexDirection: "column", - alignItems: "flex-start", - justifyContent: "space-between", + alignItems: "center", + justifyContent: "center", gap: 8, backgroundColor: "#FAF9F6", padding: 16, @@ -39,30 +48,30 @@ const styles = StyleSheet.create({ }, shadowOpacity: 0.25, shadowRadius: 3.84, - elevation: 5, + // elevation: 1, }, header: { width: "100%", display: "flex", flexDirection: "row", + alignItems: "center", + justifyContent: "center", gap: 8, - // 靠左展示 - textAlign: 'left', }, title: { - width: "53%", - fontSize: 11, + fontSize: 20, fontWeight: "700", // 允许换行 flexWrap: "wrap", }, number: { fontSize: 32, - fontWeight: "700", + fontWeight: "400", paddingTop: 8, width: "100%", - // 靠右展示 - textAlign: "right", + textAlign: "center", + color: "#4C320C" }, -}) +}); + export default CreateCountComponent; diff --git a/components/owner/delete.tsx b/components/owner/delete.tsx new file mode 100644 index 0000000..4b56770 --- /dev/null +++ b/components/owner/delete.tsx @@ -0,0 +1,203 @@ +import { useAuth } from '@/contexts/auth-context'; +import { fetchApi } from '@/lib/server-api-util'; +import { useRouter } from 'expo-router'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Pressable, StyleSheet, View } from 'react-native'; +import { ThemedText } from '../ThemedText'; + +const DeleteModal = (props: { modalVisible: boolean, setModalVisible: (visible: boolean) => void }) => { + const { modalVisible, setModalVisible } = props; + const { logout } = useAuth(); + const { t } = useTranslation(); + const router = useRouter(); + // 注销账号 + const handleDeleteAccount = () => { + fetchApi("/iam/delete-user", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }) + .then(async (res) => { + await logout(); + setModalVisible(false); + router.replace('/login'); + }) + .catch(() => { + console.error("jwt has expired."); + }); + }; + + return ( + setModalVisible(false)} + > + + + + {t("generalSetting.delete", { ns: "personal" })} + + + setModalVisible(false)} + > + + {t("generalSetting.cancel", { ns: "personal" })} + + + + + {t("generalSetting.deleteAccount", { ns: "personal" })} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + centeredView: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + modalView: { + width: '80%', + backgroundColor: 'white', + borderRadius: 16, + padding: 24, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + marginBottom: 20, + }, + buttonContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', + marginTop: 20, + }, + button: { + borderRadius: 20, + paddingVertical: 12, + paddingHorizontal: 20, + elevation: 2, + minWidth: 100, + alignItems: 'center', + }, + cancelButton: { + backgroundColor: '#F5F5F5', + marginRight: 12, + }, + deleteButton: { + backgroundColor: '#E2793F', + }, + cancelButtonText: { + color: '#4C320C', + fontWeight: '600', + }, + deleteButtonText: { + color: 'white', + fontWeight: '600', + }, + modalTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#4C320C', + }, + closeButton: { + fontSize: 28, + color: '#4C320C', + padding: 10, + }, + modalContent: { + flex: 1, + }, + modalText: { + fontSize: 16, + color: '#4C320C', + }, + premium: { + backgroundColor: "#FAF9F6", + padding: 16, + borderRadius: 24, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + content: { + flex: 1, + flexDirection: 'column', + gap: 4, + backgroundColor: '#FAF9F6', + borderRadius: 24, + paddingVertical: 8 + }, + item: { + paddingHorizontal: 16, + paddingVertical: 8, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + itemText: { + fontSize: 14, + fontWeight: '600', + color: '#4C320C', + }, + upgradeButton: { + backgroundColor: '#E2793F', + borderRadius: 20, + paddingHorizontal: 16, + paddingVertical: 8, + }, + upgradeButtonText: { + color: '#fff', + fontSize: 14, + fontWeight: "600" + }, + switchContainer: { + width: 50, + height: 30, + borderRadius: 15, + justifyContent: 'center', + paddingHorizontal: 2, + }, + switchOn: { + backgroundColor: '#E2793F', + alignItems: 'flex-end', + }, + switchOff: { + backgroundColor: '#E5E5E5', + alignItems: 'flex-start', + }, + switchCircle: { + width: 26, + height: 26, + borderRadius: 13, + }, + switchCircleOn: { + backgroundColor: 'white', + }, + switchCircleOff: { + backgroundColor: '#A5A5A5', + }, +}); +export default DeleteModal; \ No newline at end of file diff --git a/components/owner/qualification/privacy.tsx b/components/owner/qualification/privacy.tsx index 3540069..1d95722 100644 --- a/components/owner/qualification/privacy.tsx +++ b/components/owner/qualification/privacy.tsx @@ -3,10 +3,25 @@ import { Policy } from '@/types/personal-info'; import React, { useEffect, useState } from 'react'; import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import RenderHtml from 'react-native-render-html'; +import { useSafeAreaInsets } from "react-native-safe-area-context"; -const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible: boolean) => void, type: string }) => { - const { modalVisible, setModalVisible, type } = props; +interface PrivacyModalProps { + modalVisible: boolean; + setModalVisible: (visible: boolean) => void; + type: 'ai' | 'terms' | 'privacy' | 'user' | 'membership'; +} + +const titleMap = { + ai: 'AI Policy', + terms: 'Terms of Service', + privacy: 'Privacy Policy', + user: 'User Agreement', + membership: 'Membership Agreement' +}; + +const PrivacyModal = ({ modalVisible, setModalVisible, type }: PrivacyModalProps) => { const [article, setArticle] = useState({} as Policy); + const insets = useSafeAreaInsets(); useEffect(() => { const loadArticle = async () => { // ai协议 @@ -41,6 +56,14 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible: console.log(error) }) } + // 会员协议 + if (type === 'membership') { + fetchApi(`/system-config/policy/membership_agreement`).then((res: any) => { + setArticle(res) + }).catch((error: any) => { + console.log(error) + }) + } }; if (type) { loadArticle(); @@ -63,11 +86,13 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible: onRequestClose={() => { setModalVisible(!modalVisible); }}> - + Settings - {type === 'ai' ? 'AI Policy' : type === 'terms' ? 'Terms of Service' : type === 'privacy' ? 'Privacy Policy' : 'User Agreement'} + + {titleMap[type] || 'User Agreement'} + setModalVisible(false)}> × diff --git a/components/owner/ranking.tsx b/components/owner/ranking.tsx index 00ac830..823d369 100644 --- a/components/owner/ranking.tsx +++ b/components/owner/ranking.tsx @@ -27,7 +27,8 @@ const Ranking = ({ data }: { data: TitleRankings[] }) => { renderItem={({ item }) => ( No.{item.ranking} - {item.region}{item.display_name} + {item.display_name} + {item.region} )} /> diff --git a/components/owner/rights/cardBg.tsx b/components/owner/rights/cardBg.tsx new file mode 100644 index 0000000..1ce73a7 --- /dev/null +++ b/components/owner/rights/cardBg.tsx @@ -0,0 +1,54 @@ +import Svg, { Defs, LinearGradient, Path, Rect, Stop, Text } from 'react-native-svg'; + +const CardBg = (prop: { pro: string, date: string }) => { + const { pro, date } = prop; + return ( + + {pro === "pro" && } + {pro === "pro" && } + + + + + + {/* 背景色块 */} + {pro === "pro" && } + + {/* 叠加文字 */} + { + pro === "pro" && + {date} + + } + + {/* 渐变定义(放在最后,避免覆盖) */} + + + + + + + + + + + + + + + + + ) +} + +export default CardBg \ No newline at end of file diff --git a/components/owner/rights/ipSvg.tsx b/components/owner/rights/ipSvg.tsx new file mode 100644 index 0000000..01014c5 --- /dev/null +++ b/components/owner/rights/ipSvg.tsx @@ -0,0 +1,68 @@ +import Svg, { Circle, Defs, Ellipse, FeBlend, FeColorMatrix, FeComposite, FeFlood, FeGaussianBlur, FeOffset, Filter, G, Path, Rect } from 'react-native-svg'; + +const IpSvg = (prop: { pro: string }) => { + const { pro } = prop; + return ( + + + {pro === "pro" && + + } + {pro === "pro" && } + {pro === "pro" && } + {pro === "pro" && } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default IpSvg \ No newline at end of file diff --git a/components/owner/rights/memberCard.tsx b/components/owner/rights/memberCard.tsx new file mode 100644 index 0000000..6323c90 --- /dev/null +++ b/components/owner/rights/memberCard.tsx @@ -0,0 +1,89 @@ +import MemberBgSvg from '@/assets/icons/svg/memberBg.svg'; +import ProTextSvg from '@/assets/icons/svg/proText.svg'; +import GradientText from '@/components/textLinear'; +import { ThemedText } from '@/components/ThemedText'; +import { useRouter } from 'expo-router'; +import { useTranslation } from "react-i18next"; +import { Dimensions, StyleSheet, TouchableOpacity, View } from "react-native"; +import CardBg from './cardBg'; +import IpSvg from './ipSvg'; + +const MemberCard = ({ pro }: { pro: string }) => { + const { t } = useTranslation(); + const proPng = require("@/assets/images/png/owner/pro.png"); + const width = Dimensions.get("window").width; + const router = useRouter(); + + return ( + router.push("/rights")}> + {/* 背景图 */} + + + + {/* pro标志 */} + + + + {/* 背景板ip */} + + + + {/* 会员标识 */} + + + {t("personal:member.goPremium")} + + {/* 解锁更多魔法 */} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: "relative" + }, + memberContainer: { + position: "absolute", + backgroundColor: "linear-gradient(97.5deg, #FFF3E8 7.16%, #FFFAB9 100.47%)", + borderRadius: 13.1348, + }, + proTextContainer: { + position: "absolute", + zIndex: 9, + }, + ipContainer: { + position: "absolute", + bottom: 0, + right: 0, + zIndex: 9 + }, + cardBg: { + width: "100%", + alignSelf: "center", + position: "relative", + marginRight: 10, + zIndex: -1, + }, + dateContainer: { + position: 'absolute', + zIndex: 10, + alignItems: 'flex-end', + transform: [ + { rotate: '400deg' } + ], + }, +}); + +export default MemberCard; \ No newline at end of file diff --git a/components/owner/rights/normal.tsx b/components/owner/rights/normal.tsx new file mode 100644 index 0000000..c526156 --- /dev/null +++ b/components/owner/rights/normal.tsx @@ -0,0 +1,73 @@ +import GetSvg from "@/assets/icons/svg/get.svg"; +import { ThemedText } from "@/components/ThemedText"; +import { useTranslation } from "react-i18next"; +import { StyleProp, StyleSheet, TouchableOpacity, View, ViewStyle } from "react-native"; + +interface Props { + setUserType: (type: 'normal' | 'premium') => void; + style?: StyleProp; +} + +const Normal = (props: Props) => { + const { setUserType } = props; + const { t } = useTranslation(); + + return ( + + + + + + {t('rights.100Bonus', { ns: 'personal' })} + + {t('rights.100BonusText', { ns: 'personal' })} + + + + + {t('rights.10G', { ns: 'personal' })} + + {t('rights.10GText', { ns: 'personal' })} + + + { + setUserType('premium'); + }} + activeOpacity={0.8} + > + + {t('rights.purchase', { ns: 'personal' })} + + + + ); +} + +export default Normal; +const styles = StyleSheet.create({ + goPro: { + backgroundColor: '#E2793F', + borderRadius: 24, + paddingVertical: 6, + display: "flex", + alignItems: "center", + width: "100%", + }, + normalInfo: { + display: "flex", + flexDirection: "column", + gap: 16 + }, + normalItem: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: 16 + }, + normalItemContent: { + display: "flex", + flexDirection: "column", + } +}); \ No newline at end of file diff --git a/components/owner/rights/payType.tsx b/components/owner/rights/payType.tsx new file mode 100644 index 0000000..3ac7567 --- /dev/null +++ b/components/owner/rights/payType.tsx @@ -0,0 +1,151 @@ +import ChoicePaySvg from '@/assets/icons/svg/choicePay.svg'; +import YesSvg from '@/assets/icons/svg/yes.svg'; +import { ThemedText } from '@/components/ThemedText'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +interface Props { + modalVisible: boolean; + setModalVisible: (visible: boolean) => void; + payChoice: 'ApplePay'; + setPayChoice: (choice: 'ApplePay') => void; + setConfirmPay: (confirm: boolean) => void; +} + +const PayTypeModal = (props: Props) => { + const { modalVisible, setModalVisible, payChoice, setPayChoice, setConfirmPay } = props; + const { t } = useTranslation(); + + useEffect(() => { + if (modalVisible) { + setConfirmPay(false) + } + }, [modalVisible]); + + return ( + { + setModalVisible(!modalVisible); + }}> + + + + {t('personal:rights.payType')} + {t('personal:rights.payType')} + setModalVisible(false)}> + × + + + + { setPayChoice('ApplePay') }} + > + + + + {t('personal:rights.apple')} + + + + {payChoice === 'ApplePay' ? : null} + + + + + { + setConfirmPay(true); + setModalVisible(false) + }} + > + + {t('personal:rights.confirm')} + + + { setModalVisible(false) }} + > + + {t('personal:rights.cancel')} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + modalTitle: { + fontSize: 20, + fontWeight: 'bold', + color: '#4C320C', + }, + footer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + gap: 16, + marginBottom: 32, + }, + button: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 12, + alignItems: 'center', + }, + payChoice: { + width: 20, + height: 20, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center', + }, + paymentMethod: { + marginHorizontal: 16, + marginVertical: 16, + borderRadius: 12, + backgroundColor: '#fff', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 5, + elevation: 5, + }, + centeredView: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + modalView: { + width: '100%', + backgroundColor: 'white', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingHorizontal: 16, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + closeButton: { + fontSize: 28, + color: '#4C320C', + padding: 10, + } +}); +export default PayTypeModal; \ No newline at end of file diff --git a/components/owner/rights/premium.tsx b/components/owner/rights/premium.tsx new file mode 100644 index 0000000..6492705 --- /dev/null +++ b/components/owner/rights/premium.tsx @@ -0,0 +1,130 @@ +import BlackStarSvg from '@/assets/icons/svg/blackStar.svg'; +import { ThemedText } from "@/components/ThemedText"; +import { useTranslation } from 'react-i18next'; +import { ScrollView, StyleProp, StyleSheet, TouchableOpacity, View, ViewStyle } from "react-native"; +import { maxDiscountProduct } from './utils'; + +interface Props { + style?: StyleProp; + payType: string; + setPayType: (type: string) => void; + premiumPay: any; + loading: boolean; + setShowTerms: (visible: boolean) => void; +} + +export interface PayItem { + id: number; + product_id: number; + product_type: string; + product_code: string; + product_name: string; + unit_price: { + amount: string; + currency: string; + }, + discount_amount: { + amount: string; + currency: string; + } + +} +const Premium = (props: Props) => { + const { style, payType, setPayType, premiumPay, loading } = props; + const bestValue = maxDiscountProduct(premiumPay)?.product_code + const { t } = useTranslation(); + + return ( + + + {loading + ? + + {t('loading', { ns: 'common' })} + + : + premiumPay?.map((item: PayItem) => { + return { + setPayType(item?.product_code); + }} + key={item?.product_code} + style={[styles.yearly, { borderColor: payType === item?.product_code ? '#FFB645' : '#E1E1E1', opacity: payType === item?.product_code ? 1 : 0.5 }]} + activeOpacity={0.8} + > + + + + {t('rights.bestValue', { ns: 'personal' })} + + + + + {item.product_code?.split('_')[item.product_code?.split('_')?.length - 1]} + + + $ {(Number(item.unit_price.amount) - Number(item.discount_amount.amount)).toFixed(2)} + + + + $ {item.unit_price.amount} + + + + }) + } + + {/* + + {t('personal:rights.restorePurchase')} + + + + {t('personal:rights.restore')} + + + */} + + ); +} + +export default Premium; +const styles = StyleSheet.create({ + proInfo: { + borderRadius: 24, + display: "flex", + width: "100%", + }, + yearly: { + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 16, + borderColor: "#FFB645", + borderWidth: 2, + borderRadius: 24, + width: 200, + paddingBottom: 16 + }, + title: { + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + backgroundColor: "#FFB645", + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 4, + width: "100%" + }, + titleText: { + color: '#4C320C', + fontWeight: '700' + } +}); \ No newline at end of file diff --git a/components/owner/rights/proRights.tsx b/components/owner/rights/proRights.tsx new file mode 100644 index 0000000..d7b926f --- /dev/null +++ b/components/owner/rights/proRights.tsx @@ -0,0 +1,49 @@ +import GetSvg from "@/assets/icons/svg/get.svg"; +import { ThemedText } from "@/components/ThemedText"; +import { useTranslation } from "react-i18next"; +import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +const ProRights = (props: { style?: StyleProp }) => { + const { style } = props; + const { t } = useTranslation(); + return ( + + + {t('rights.proTitle', { ns: 'personal' })} + + + + + {t('rights.noAd', { ns: 'personal' })} + + {t('rights.noAdText', { ns: 'personal' })} + + + + + {t('rights.bonus', { ns: 'personal' })} + + {t('rights.bonusText', { ns: 'personal' })} + + + + + {t('rights.storage', { ns: 'personal' })} + + {t('rights.storageText', { ns: 'personal' })} + + + ); +} + +const styles = StyleSheet.create({ + proRights: { + padding: 16, + gap: 8 + }, + itemContent: { + display: "flex", + flexDirection: "column", + } +}) + +export default ProRights; diff --git a/components/owner/rights/utils.ts b/components/owner/rights/utils.ts new file mode 100644 index 0000000..475475b --- /dev/null +++ b/components/owner/rights/utils.ts @@ -0,0 +1,100 @@ +import { fetchApi } from "@/lib/server-api-util"; +import { CreateOrder, PayOrder } from "@/types/personal-info"; +import { PayItem } from "./premium"; + +// 使用 reduce 方法获取 discount_amount 的 amount 值最大的对象 +export const maxDiscountProduct = (products: PayItem[]) => { + if (!products || products.length === 0) { + return products?.[0]; + } + return products?.reduce((max, current) => { + const maxAmount = parseFloat(max.discount_amount?.amount || '0'); + const currentAmount = parseFloat(current.discount_amount?.amount || '0'); + + return currentAmount > maxAmount ? current : max; + }); +} + + +// 查看产品项 +export const getPAy = async () => { + const payInfo = await fetchApi(`/order/product-items?product_type=Membership`) + let bestValue = maxDiscountProduct(payInfo) + return { bestValue, payInfo } +} + +// 创建订单 +export const createOrder = async (id: number, quantity: number) => { + const order = await fetchApi(`/order/create`, { + method: 'POST', + body: JSON.stringify({ + items: [{ + product_item_id: id, + quantity: quantity + }] + }) + }) + return order +} + +// 创建支付 +export const createPayment = async (order_id: string, payment_method: string) => { + const payment = await fetchApi(`/order/pay`, { + method: 'POST', + body: JSON.stringify({ + order_id, + payment_method + }) + }) + return payment +} + +// 支付中 +export const payProcessing = async (transaction_id: string, third_party_transaction_id: string) => { + const payment = await fetchApi(`/order/pay-processing`, { + method: 'POST', + body: JSON.stringify({ + transaction_id + }) + }) + return payment +} + +// 支付失败 +export const payFailure = async (transaction_id: string, reason: string) => { + const payment = await fetchApi(`/order/pay-failure`, { + method: 'POST', + body: JSON.stringify({ + transaction_id, + reason + }) + }) + return payment +} + +// 支付成功 +export const paySuccess = async (transaction_id: string, third_party_transaction_id: string) => { + const payment = await fetchApi(`/order/pay-success`, { + method: 'POST', + body: JSON.stringify({ + transaction_id, + third_party_transaction_id + }) + }) + return payment +} + +// 判断订单是否过期 +/** +* 判断指定时间戳是否已过期(即当前时间是否已超过该时间戳) +* @param expirationTimestamp - 过期时间戳(单位:毫秒) +* @returns boolean - true: 未过期,false: 已过期 +*/ +export const isOrderExpired = async (transactionDate: number) => { + // 如果没有提供过期时间,视为无效或未设置,认为“已过期”或状态未知 + if (!transactionDate || isNaN(transactionDate)) { + return false; + } + const now = Date.now(); // 当前时间戳(毫秒) + return now < transactionDate; +} diff --git a/components/owner/setting.tsx b/components/owner/setting.tsx index 2193ac5..88f5f5e 100644 --- a/components/owner/setting.tsx +++ b/components/owner/setting.tsx @@ -1,3 +1,4 @@ +import DeleteSvg from '@/assets/icons/svg/delete.svg'; import LogoutSvg from '@/assets/icons/svg/logout.svg'; import RightArrowSvg from '@/assets/icons/svg/rightArrow.svg'; import { useAuth } from '@/contexts/auth-context'; @@ -9,6 +10,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Linking, Modal, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ThemedText } from '../ThemedText'; +import DeleteModal from './delete'; import LcensesModal from './qualification/lcenses'; import PrivacyModal from './qualification/privacy'; import CustomSwitch from './switch'; @@ -17,6 +19,10 @@ import { checkNotificationPermission, getLocationPermission, getPermissions, req const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: boolean) => void, userInfo: User }) => { const { modalVisible, setModalVisible, userInfo } = props; + console.log(111111111111111111111111); + + console.log("hjhkkkkkkkkkkkkkkkkkkkkkkkkkkk111111111", setModalVisible); + const { t } = useTranslation(); const [modalType, setModalType] = useState<'ai' | 'terms' | 'privacy' | 'user'>('ai'); // 协议弹窗 @@ -24,6 +30,8 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: // 许可证弹窗 const [lcensesModalVisible, setLcensesModalVisible] = useState(false); + // 删除弹窗 + const [deleteModalVisible, setDeleteModalVisible] = useState(false); const { logout } = useAuth(); const router = useRouter(); // 打开设置 @@ -32,28 +40,31 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: }; // 通知消息权限开关 const [notificationsEnabled, setNotificationsEnabled] = useState(false); - const toggleNotifications = () => { + const toggleNotifications = async () => { if (notificationsEnabled) { // 引导去设置关闭权限 openAppSettings() } else { - console.log('请求通知权限'); - requestNotificationPermission().then((res) => { - setNotificationsEnabled(res as boolean); - }) + requestNotificationPermission() + .then((granted) => { + setNotificationsEnabled(granted); + }); + setModalVisible(false); } }; // 相册权限 const [albumEnabled, setAlbumEnabled] = useState(false); - const toggleAlbum = () => { + const toggleAlbum = async () => { if (albumEnabled) { // 引导去设置关闭权限 openAppSettings() } else { - requestMediaLibraryPermission().then((res) => { - setAlbumEnabled(res as boolean); - }) + requestMediaLibraryPermission() + .then((granted) => { + setAlbumEnabled(granted); + }); + setModalVisible(false); } } @@ -62,12 +73,14 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: // 位置权限更改 const toggleLocation = async () => { if (locationEnabled) { - // 引导去设置关闭权限 - openAppSettings() + // 如果权限已开启,点击则引导用户去设置关闭 + openAppSettings(); } else { - requestLocationPermission().then((res) => { - setLocationEnabled(res as boolean); - }) + requestLocationPermission() + .then((granted) => { + setLocationEnabled(granted); + }); + setModalVisible(false); } }; // 正在获取位置信息 @@ -88,16 +101,18 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: let currentStatus = await getLocationPermission(); console.log('当前权限状态:', currentStatus); - // 2. 如果没有权限,则请求权限 + // 2. 如果没有权限,则跳过获取位置 if (!currentStatus) { - const newStatus = await requestLocationPermission(); - setLocationEnabled(newStatus); - currentStatus = newStatus; + console.log('没有权限,跳过获取位置') + return; + // const newStatus = await requestLocationPermission(); + // setLocationEnabled(newStatus); + // currentStatus = newStatus; - if (!currentStatus) { - alert('需要位置权限才能继续'); - return; - } + // if (!currentStatus) { + // // alert('需要位置权限才能继续'); + // return; + // } } // 3. 确保位置服务已启用 @@ -117,7 +132,9 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: // 地理位置逆编码 const address = await reverseGeocode(location.coords.latitude, location.coords.longitude); // 5. 更新位置状态 - setCurrentLocation(address as Address); + if (address) { + setCurrentLocation(address); + } return location; } catch (error: any) { @@ -157,10 +174,12 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: if (modalVisible) { // 位置权限 getLocationPermission().then((res) => { + console.log('位置权限:', res); setLocationEnabled(res); }) // 媒体库权限 getPermissions().then((res) => { + console.log('媒体库权限:', res); setAlbumEnabled(res); }) // 通知权限 @@ -172,40 +191,39 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: }, [modalVisible]) return ( - { - setModalVisible(!modalVisible); - }}> - setModalVisible(false)}> + <> + { + setModalVisible(false); + }}> e.stopPropagation()}> - - Settings - {t('generalSetting.allTitle', { ns: 'personal' })} - setModalVisible(false)}> - × - - - - {/* 用户信息 */} - - {/* 升级版本 */} - {/* + style={styles.centeredView} + onPress={() => setModalVisible(false)}> + e.stopPropagation()}> + + Settings + {t('generalSetting.allTitle', { ns: 'personal' })} + setModalVisible(false)}> + × + + + + {/* 用户信息 */} + + {/* 升级版本 */} + {/* {t('generalSetting.subscription', { ns: 'personal' })} @@ -224,8 +242,8 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: */} - {/* 消息通知 */} - {/* + {/* 消息通知 */} + {/* {t('permission.pushNotification', { ns: 'personal' })} @@ -237,42 +255,42 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: /> */} - {/* 权限信息 */} - - {t('permission.permissionManagement', { ns: 'personal' })} - - {/* 相册权限 */} - - {t('permission.galleryAccess', { ns: 'personal' })} - - - {/* 分割线 */} - - {/* 位置权限 */} - - - {t('permission.locationPermission', { ns: 'personal' })} + {/* 权限信息 */} + + {t('permission.permissionManagement', { ns: 'personal' })} + + {/* 相册权限 */} + + {t('permission.galleryAccess', { ns: 'personal' })} + - - - - - - {t('permission.pushNotification', { ns: 'personal' })} + {/* 分割线 */} + + {/* 位置权限 */} + + + {t('permission.locationPermission', { ns: 'personal' })} + + - - - {/* 相册成片权限 */} - {/* + + + + {t('permission.pushNotification', { ns: 'personal' })} + + + + {/* 相册成片权限 */} + {/* Opus Permission @@ -281,10 +299,10 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: toggleSwitch={toggleAlbum} /> */} + - - {/* 账号 */} - {/* + {/* 账号 */} + {/* Account @@ -303,71 +321,75 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: */} - {/* 协议 */} - - {t('lcenses.title', { ns: 'personal' })} - - { setModalType('privacy'); setPrivacyModalVisible(true) }} > - {t('lcenses.privacyPolicy', { ns: 'personal' })} - - - - { setModalType('terms'); setPrivacyModalVisible(true) }} > - {t('lcenses.applyPermission', { ns: 'personal' })} - - - - { setModalType('user'); setPrivacyModalVisible(true) }} > - {t('lcenses.userAgreement', { ns: 'personal' })} - - - - { setModalType('ai'); setPrivacyModalVisible(true) }} > - {t('lcenses.aiPolicy', { ns: 'personal' })} - - - - { setLcensesModalVisible(true) }} > - {t('lcenses.qualification', { ns: 'personal' })} - - - - Linking.openURL("https://beian.miit.gov.cn/")} > - {t('lcenses.ICP', { ns: 'personal' })}沪ICP备2023032876号-4 - - - - - {/* 其他信息 */} - - {t('generalSetting.otherInformation', { ns: 'personal' })} - - Linking.openURL("https://work.weixin.qq.com/kfid/kfca0ac87f4e05e8bfd")} > - {t('generalSetting.contactUs', { ns: 'personal' })} - {/* */} - - - - {t('generalSetting.version', { ns: 'personal' })} - {"0.5.0"} + {/* 协议 */} + + {t('lcenses.title', { ns: 'personal' })} + + { setModalType('privacy'); setPrivacyModalVisible(true) }} > + {t('lcenses.privacyPolicy', { ns: 'personal' })} + + + + { setModalType('terms'); setPrivacyModalVisible(true) }} > + {t('lcenses.applyPermission', { ns: 'personal' })} + + + + { setModalType('user'); setPrivacyModalVisible(true) }} > + {t('lcenses.userAgreement', { ns: 'personal' })} + + + + { setModalType('ai'); setPrivacyModalVisible(true) }} > + {t('lcenses.aiPolicy', { ns: 'personal' })} + + + + { setLcensesModalVisible(true) }} > + {t('lcenses.qualification', { ns: 'personal' })} + + + + Linking.openURL("https://beian.miit.gov.cn/")} > + {t('lcenses.ICP', { ns: 'personal' })}沪ICP备2023032876号-4 + + - - {/* 退出 */} - - {t('generalSetting.logout', { ns: 'personal' })} - - - + {/* 其他信息 */} + + {t('generalSetting.otherInformation', { ns: 'personal' })} + + Linking.openURL("https://work.weixin.qq.com/kfid/kfca0ac87f4e05e8bfd")} > + {t('generalSetting.contactUs', { ns: 'personal' })} + {/* */} + + + + {t('generalSetting.version', { ns: 'personal' })} + {"0.5.0"} + + + + {/* 退出 */} + + {t('generalSetting.logout', { ns: 'personal' })} + + + {/* 注销账号 */} + setDeleteModalVisible(true)}> + {t('generalSetting.deleteAccount', { ns: 'personal' })} + + + + - - {/* 协议弹窗 */} - - {/* 许可证弹窗 */} - - {/* 通知 */} - {/* */} - + + + + + + ); }; diff --git a/components/owner/userInfo.tsx b/components/owner/userInfo.tsx index d1565b0..985c6dc 100644 --- a/components/owner/userInfo.tsx +++ b/components/owner/userInfo.tsx @@ -12,8 +12,6 @@ import { ThemedText } from "../ThemedText"; interface UserInfoProps { userInfo: User; - setModalVisible: (visible: boolean) => void; - modalVisible: boolean; getCurrentLocation: () => void; isLoading: boolean; isRefreshing: boolean; @@ -21,7 +19,8 @@ interface UserInfoProps { setCurrentLocation: (location: Address) => void; } const UserInfo = (props: UserInfoProps) => { - const { userInfo, setModalVisible, modalVisible, getCurrentLocation, isLoading, isRefreshing, currentLocation, setCurrentLocation } = props; + const { userInfo, getCurrentLocation, isLoading, isRefreshing, currentLocation, setCurrentLocation } = props; + const router = useRouter(); const { t } = useTranslation(); // 获取本地存储的location @@ -71,13 +70,11 @@ const UserInfo = (props: UserInfoProps) => { // 在组件挂载时自动获取位置(可选) useEffect(() => { - if (modalVisible) { - getLocation(); - if (currentLocation && Object?.keys(currentLocation)?.length === 0) { - getCurrentLocation(); - } + getLocation(); + if (currentLocation && Object?.keys(currentLocation)?.length === 0) { + getCurrentLocation(); } - }, [modalVisible]) + }, []) return ( @@ -124,7 +121,6 @@ const UserInfo = (props: UserInfoProps) => { } { - setModalVisible(false); // 携带参数跳转 router.push({ pathname: '/user-message', diff --git a/components/owner/userName.tsx b/components/owner/userName.tsx index c75e889..9540338 100644 --- a/components/owner/userName.tsx +++ b/components/owner/userName.tsx @@ -1,15 +1,36 @@ import UserSvg from '@/assets/icons/svg/ataver.svg'; +import SettingSvg from '@/assets/icons/svg/setting.svg'; +import StarSvg from '@/assets/icons/svg/star.svg'; import { ThemedText } from '@/components/ThemedText'; import { UserInfoDetails } from '@/types/user'; +import { useRouter } from 'expo-router'; import { useState } from 'react'; -import { Image, ScrollView, View } from 'react-native'; +import { Image, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native'; +import CopyButton from '../copy'; + export default function UserInfo({ userInfo }: { userInfo: UserInfoDetails }) { // 添加状态来跟踪图片加载状态 const [imageError, setImageError] = useState(false); + const router = useRouter(); + return ( + {/* 头像 */} + + {userInfo?.user_info?.avatar_file_url && !imageError ? ( + { + setImageError(true); + }} + /> + ) : ( + + )} + {/* 用户名 */} - + + + + + {userInfo?.remain_points} + - - + + + User ID:{userInfo?.user_info?.user_id} + + + + { + router.push('/setting'); }} + activeOpacity={0.7} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={styles.text} > - User ID: {userInfo?.user_info?.user_id} - - {/* */} + + - - - {/* 头像 */} - - {userInfo?.user_info?.avatar_file_url && !imageError ? ( - { - setImageError(true); - }} - /> - ) : ( - - )} ); } +const styles = StyleSheet.create({ + text: { + fontSize: 12, + fontWeight: '700', + color: '#AC7E35', + borderColor: '#FFD18A', + borderWidth: 1, + borderRadius: 20, + padding: 4, + textAlign: "center", + alignItems: "center", + paddingVertical: 6, + paddingHorizontal: 12 + } +}); \ No newline at end of file diff --git a/components/owner/utils.ts b/components/owner/utils.ts index 626610e..1df1a89 100644 --- a/components/owner/utils.ts +++ b/components/owner/utils.ts @@ -1,16 +1,13 @@ // 地理位置逆编码 +import i18n from '@/i18n'; +import { PermissionService } from '@/lib/PermissionService'; import { fetchApi } from '@/lib/server-api-util'; +import { Address } from '@/types/user'; import * as ImagePicker from 'expo-image-picker'; import * as Location from 'expo-location'; import * as Notifications from 'expo-notifications'; import * as SecureStore from 'expo-secure-store'; -import { Alert, Linking, Platform } from 'react-native'; - -interface Address { - id: number; - name: string; - // Add other address properties as needed -} +import { Linking, Platform } from 'react-native'; // 配置通知处理器 Notifications.setNotificationHandler({ @@ -24,21 +21,23 @@ Notifications.setNotificationHandler({ }); // 逆编码 -export const reverseGeocode = async (latitude: number, longitude: number) => { +export const reverseGeocode = async (latitude: number, longitude: number): Promise
=> { try { const addressResults = await fetchApi(`/area/gecoding?latitude=${latitude}&longitude=${longitude}`); - console.log('地址:', addressResults); - for (let address of addressResults) { - console.log('地址:', address); - if (Platform.OS === 'web') { - localStorage.setItem('location', JSON.stringify(address)); - } else { - SecureStore.setItemAsync('location', JSON.stringify(address)); - } - return address; + if (Object.keys(addressResults).length === 0) { + return undefined; } + console.log('地址1:', addressResults); + + if (Platform.OS === 'web') { + localStorage.setItem('location', JSON.stringify(addressResults)); + } else { + SecureStore.setItemAsync('location', JSON.stringify(addressResults)); + } + return addressResults as unknown as Address; } catch (error) { console.log('逆地理编码失败:', error); + return undefined; } }; @@ -68,27 +67,13 @@ export const requestLocationPermission = async () => { // 3. 如果用户之前选择了"拒绝且不再询问" if (status === 'denied' && !canAskAgain) { // 显示提示,引导用户去设置 - const openSettings = await new Promise(resolve => { - Alert.alert( - '需要位置权限', - '您之前拒绝了位置权限。要使用此功能,请在设置中启用位置权限。', - [ - { - text: '取消', - style: 'cancel', - onPress: () => resolve(false) - }, - { - text: '去设置', - onPress: () => resolve(true) - } - ] - ); + const confirmed = await PermissionService.show({ + title: i18n.t('permission:title.locationPermissionRequired'), + message: i18n.t('permission:message.locationPreviouslyDenied'), }); - if (openSettings) { - // 打开应用设置 - await Linking.openSettings(); + if (confirmed) { + openAppSettings(); } return false; } @@ -99,24 +84,25 @@ export const requestLocationPermission = async () => { console.log('新权限状态:', newStatus); if (newStatus !== 'granted') { - Alert.alert('需要位置权限', '请允许访问位置以使用此功能'); return false; } return true; } catch (error) { console.error('请求位置权限时出错:', error); - Alert.alert('错误', '请求位置权限时出错'); return false; } }; +export const openAppSettings = () => { + Linking.openSettings(); +}; + // 获取媒体库权限 export const getPermissions = async () => { if (Platform.OS !== 'web') { const { status: mediaStatus } = await ImagePicker.getMediaLibraryPermissionsAsync(); if (mediaStatus !== 'granted') { - // Alert.alert('需要媒体库权限', '请允许访问媒体库以继续'); return false; } return true; @@ -129,7 +115,6 @@ export const requestPermissions = async () => { if (Platform.OS !== 'web') { const mediaStatus = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!mediaStatus.granted) { - // Alert.alert('需要媒体库权限', '请允许访问媒体库以继续'); return false; } return true; @@ -147,13 +132,13 @@ export const checkMediaLibraryPermission = async (): Promise<{ status: ImagePicker.PermissionStatus; }> => { if (Platform.OS === 'web') { - return { hasPermission: true, canAskAgain: true, status: 'granted' }; + return { hasPermission: true, canAskAgain: true, status: ImagePicker.PermissionStatus.GRANTED }; } const { status, canAskAgain } = await ImagePicker.getMediaLibraryPermissionsAsync(); return { - hasPermission: status === 'granted', + hasPermission: status === ImagePicker.PermissionStatus.GRANTED, canAskAgain, status }; @@ -181,20 +166,10 @@ export const requestMediaLibraryPermission = async (showAlert: boolean = true): // 3. 如果之前被拒绝且不能再次询问 if (existingStatus === 'denied' && !canAskAgain) { if (showAlert) { - const openSettings = await new Promise(resolve => { - Alert.alert( - '需要媒体库权限', - '您之前拒绝了媒体库访问权限。要选择照片,请在设置中启用媒体库权限。', - [ - { text: '取消', style: 'cancel', onPress: () => resolve(false) }, - { text: '去设置', onPress: () => resolve(true) } - ] - ); + await PermissionService.show({ + title: i18n.t('permission:title.mediaLibraryPermissionRequired'), + message: i18n.t('permission:message.mediaLibraryPreviouslyDenied'), }); - - if (openSettings) { - await Linking.openSettings(); - } } return false; } @@ -203,14 +178,20 @@ export const requestMediaLibraryPermission = async (showAlert: boolean = true): const { status: newStatus } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (newStatus !== 'granted' && showAlert) { - Alert.alert('需要媒体库权限', '请允许访问媒体库以方便后续操作'); + await PermissionService.show({ + title: i18n.t('permission:title.mediaLibraryPermissionRequired'), + message: i18n.t('permission:message.mediaLibraryPermissionRequired'), + }); } return newStatus === 'granted'; } catch (error) { console.error('请求媒体库权限时出错:', error); if (showAlert) { - Alert.alert('错误', '请求媒体库权限时出错'); + await PermissionService.show({ + title: i18n.t('permission:title.error'), + message: i18n.t('permission:message.requestPermissionError'), + }); } return false; } @@ -239,28 +220,10 @@ export const requestNotificationPermission = async () => { // 3. 如果用户之前选择了"拒绝且不再询问" if (status === 'denied' && !canAskAgain) { // 显示提示,引导用户去设置 - const openSettings = await new Promise(resolve => { - Alert.alert( - '需要通知权限', - '您之前拒绝了通知权限。要使用此功能,请在设置中启用通知权限。', - [ - { - text: '取消', - style: 'cancel', - onPress: () => resolve(false) - }, - { - text: '去设置', - onPress: () => resolve(true) - } - ] - ); + await PermissionService.show({ + title: i18n.t('permission:title.notificationPermissionRequired'), + message: i18n.t('permission:message.notificationPreviouslyDenied'), }); - - if (openSettings) { - // 打开应用设置 - await Linking.openSettings(); - } return false; } @@ -270,14 +233,17 @@ export const requestNotificationPermission = async () => { console.log('新通知权限状态:', newStatus); if (newStatus !== 'granted') { - Alert.alert('需要通知权限', '请允许通知以使用此功能'); + PermissionService.show({ + title: '需要通知权限', + message: '请允许通知以使用此功能', + }); return false; } return true; } catch (error) { console.error('请求通知权限时出错:', error); - Alert.alert('错误', '请求通知权限时出错'); + PermissionService.show({ title: '错误', message: '请求通知权限时出错' }); return false; } }; diff --git a/components/textLinear.tsx b/components/textLinear.tsx new file mode 100644 index 0000000..8ddc8a4 --- /dev/null +++ b/components/textLinear.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import Svg, { Defs, LinearGradient, Stop, Text as SvgText, TSpan } from 'react-native-svg'; + +interface GradientTextProps { + text: string; + color?: { offset: string, color: string }[]; + fontSize?: number; + fontWeight?: string; + width?: number; + lineHeight?: number; +} + +export default function GradientText(props: GradientTextProps) { + const { text, color, fontSize = 48, fontWeight = "700", width = 300, lineHeight = 1.2 } = props; + + // Split text into words and create lines that fit within the specified width + const createLines = (text: string, maxWidth: number) => { + const words = text.split(' '); + const lines: string[] = []; + let currentLine = ''; + + words.forEach(word => { + const testLine = currentLine ? `${currentLine} ${word}` : word; + // Approximate text width (this is a simple estimation) + const testWidth = testLine.length * (fontSize * 0.6); + + if (testWidth > maxWidth && currentLine) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + }); + + if (currentLine) { + lines.push(currentLine); + } + + return lines; + }; + + const lines = createLines(text, width - 40); // 40px padding + const lineHeightPx = fontSize * lineHeight; + const totalHeight = lines.length * lineHeightPx; + + return ( + + + + + {color?.map((item, index) => ( + + ))} + + + + + {lines.map((line, index) => ( + + {line} + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + justifyContent: 'center', + alignItems: 'center', + alignSelf: 'center', + }, +}); \ No newline at end of file diff --git a/components/ui/button/stepButton.tsx b/components/ui/button/stepButton.tsx new file mode 100644 index 0000000..60c9a9c --- /dev/null +++ b/components/ui/button/stepButton.tsx @@ -0,0 +1,49 @@ +import { ThemedText } from "@/components/ThemedText"; +import { ActivityIndicator, StyleSheet, TouchableOpacity } from "react-native"; + +interface Props { + isLoading?: boolean; + onPress?: () => void; + text: string + bg?: string + color?: string +} +const StepButton = (props: Props) => { + const { isLoading, onPress, text, bg, color } = props + + return ( + + {isLoading ? ( + + ) : ( + + {text} + + )} + + ) +} + +const styles = StyleSheet.create({ + button: { + width: '100%', + backgroundColor: '#E2793F', + borderRadius: 32, + padding: 18, + alignItems: 'center' + }, + disabledButton: { + opacity: 0.7, + }, + buttonText: { + color: '#FFFFFF', + fontWeight: '600', + fontSize: 18, + }, +}); + +export default StepButton diff --git a/components/user-message.tsx/done.tsx b/components/user-message.tsx/done.tsx index 9eba9d7..3005afd 100644 --- a/components/user-message.tsx/done.tsx +++ b/components/user-message.tsx/done.tsx @@ -1,89 +1,216 @@ -import DoneSvg from '@/assets/icons/svg/done.svg'; import { router } from 'expo-router'; +import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Platform, TouchableOpacity, View } from 'react-native'; +import { Dimensions, Image, PixelRatio, Platform, StyleSheet, View } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming +} from 'react-native-reanimated'; import { ThemedText } from '../ThemedText'; import { Fireworks } from '../firework'; -import Lottie from '../lottie/lottie'; +import StepButton from '../ui/button/stepButton'; export default function Done() { const { t } = useTranslation(); + const height = Dimensions.get('window').height; + const fontSize = (size: number) => { + const scale = PixelRatio.getFontScale(); + return size / scale; + }; + // Animation values + const translateX = useSharedValue(300); + const translateY = useSharedValue(300); + const opacity = useSharedValue(0); + + // Animation style + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: translateX.value }, + { translateY: translateY.value } + ], + opacity: opacity.value + })); + + // Start animation when component mounts + useEffect(() => { + translateX.value = withTiming(0, { + duration: 800, + easing: Easing.out(Easing.cubic) + }); + translateY.value = withTiming(0, { + duration: 800, + easing: Easing.out(Easing.cubic) + }); + opacity.value = withTiming(1, { + duration: 1000, + easing: Easing.out(Easing.cubic) + }); + }, []); + const handleContinue = () => { router.replace('/ask') }; - return ( - - { - Platform.OS === 'web' - ? - - - - {t('auth.userMessage.allDone', { ns: 'login' })} - - - - - - - {/* Next Button */} - - - - {t('auth.userMessage.next', { ns: 'login' })} - - - - - : - - {/* 文字 */} - - - - {t('auth.userMessage.allDone', { ns: 'login' })} - - - {/* Next Button */} - - - - {t('auth.userMessage.next', { ns: 'login' })} - - - - - {/* 背景动画 - 烟花 */} - - - - - {/* 前景动画 - Lottie */} - - - - - } + const renderWebView = () => ( + + + + {t('auth.userMessage.allDone', { ns: 'login' })} + + + + + + + + + + + - ) + ); + + const renderMobileView = () => ( + + + + + {t('auth.userMessage.allDone', { ns: 'login' })} + + + + + + + + + + + + + + + + ); + + return ( + + {Platform.OS === 'web' ? renderWebView() : renderMobileView()} + + ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + flex1: { + flex: 1, + }, + webContainer: { + flex: 1, + backgroundColor: '#FFB645', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + height: '100%', + }, + webContent: { + position: 'absolute', + top: 32, + left: 0, + right: 0, + bottom: 160, + justifyContent: 'center', + alignItems: 'center', + }, + doneSvgContainer: { + flexDirection: 'row', + justifyContent: 'flex-end', + }, + webButtonContainer: { + position: 'absolute', + bottom: 16, + left: 0, + right: 0, + padding: 16, + zIndex: 99, + }, + mobileContainer: { + flex: 1, + backgroundColor: 'transparent', + }, + mobileContent: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 30, + }, + mobileTextContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + mobileButtonContainer: { + position: 'absolute', + bottom: 16, + left: 0, + right: 0, + padding: 16, + zIndex: 99, + }, + title: { + fontSize: 36, + lineHeight: 40, + color: '#FFFFFF', + textAlign: 'center', + fontWeight: 'bold', + }, + nextButton: { + width: '100%', + backgroundColor: '#3B82F6', + borderRadius: 999, + padding: 16, + alignItems: 'center', + }, + buttonText: { + color: '#FFFFFF', + fontSize: 18, + fontWeight: '600', + }, + fireworksContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 10, + }, + lottieContainer: { + position: 'absolute', + right: 0, + bottom: 0, + zIndex: 20 + }, +}); diff --git a/components/user-message.tsx/look.tsx b/components/user-message.tsx/look.tsx index 72bf459..6084730 100644 --- a/components/user-message.tsx/look.tsx +++ b/components/user-message.tsx/look.tsx @@ -4,8 +4,10 @@ import LookSvg from '@/assets/icons/svg/look.svg'; import { ThemedText } from '@/components/ThemedText'; import { FileUploadItem } from '@/lib/background-uploader/types'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Image, TouchableOpacity, View } from 'react-native'; +import { Alert, Image, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import FilesUploader from '../file-upload/files-uploader'; +import StepButton from '../ui/button/stepButton'; interface Props { setSteps?: (steps: Steps) => void; @@ -19,68 +21,113 @@ interface Props { export default function Look(props: Props) { const { fileData, setFileData, isLoading, handleUser, avatar } = props; const { t } = useTranslation(); + const insets = useSafeAreaInsets(); return ( - - - + + + {t('auth.userMessage.look', { ns: 'login' })} - + {t('auth.userMessage.avatarText', { ns: 'login' })} {"\n"} {t('auth.userMessage.avatorText2', { ns: 'login' })} - { - fileData[0]?.previewUrl - ? - - : - avatar - ? - - : - - } + {fileData[0]?.preview || fileData[0]?.previewUrl ? ( + + ) : avatar ? ( + + ) : ( + + )} { setFileData(fileData as FileUploadItem[]); }} showPreview={false} children={ - + - + {t('auth.userMessage.choosePhoto', { ns: 'login' })} } /> - {/* */} - {/* */} - - - {isLoading ? ( - - ) : ( - - {t('auth.userMessage.next', { ns: 'login' })} - - )} - + + { + if (fileData[0]?.preview || fileData[0]?.previewUrl || avatar) { + handleUser() + } else { + Alert.alert(t('auth.userMessage.avatarRequired', { ns: 'login' })) + } + }} + isLoading={isLoading} + bg="#FFFFFF" + color="#4C320C" + /> ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#AC7E35', + paddingHorizontal: 24, + justifyContent: 'space-between', + }, + contentContainer: { + flex: 1, + alignItems: 'center', + gap: 28 + }, + title: { + fontSize: 32, + lineHeight: 36, + fontWeight: 'bold', + color: '#FFFFFF', + }, + subtitle: { + fontSize: 14, + color: "#fff", + textAlign: 'center', + marginBottom: 16, + }, + avatarImage: { + borderRadius: 150, + width: 215, + height: 215, + marginBottom: 16, + }, + uploadButton: { + width: '100%', + borderRadius: 999, + paddingHorizontal: 16, + paddingVertical: 13, + alignItems: 'center', + backgroundColor: '#FFF8DE', + flexDirection: 'row', + gap: 8, + }, + uploadButtonText: { + color: '#4C320C', + fontSize: 14, + fontWeight: '600', + }, + footer: { + width: '100%', + } +}); diff --git a/components/user-message.tsx/userName.tsx b/components/user-message.tsx/userName.tsx index c120de8..9054563 100644 --- a/components/user-message.tsx/userName.tsx +++ b/components/user-message.tsx/userName.tsx @@ -2,7 +2,9 @@ import { Steps } from '@/app/(tabs)/user-message'; import { ThemedText } from '@/components/ThemedText'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, KeyboardAvoidingView, Platform, TextInput, TouchableOpacity, View } from 'react-native'; +import { Dimensions, KeyboardAvoidingView, Platform, StyleSheet, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import StepButton from '../ui/button/stepButton'; interface Props { setSteps: (steps: Steps) => void; @@ -14,6 +16,8 @@ export default function UserName(props: Props) { const { setSteps, username, setUsername } = props const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false) + const height = Dimensions.get('window').height; + const insets = useSafeAreaInsets(); const [error, setError] = useState('') const handleUserName = () => { if (!username) { @@ -28,43 +32,101 @@ export default function UserName(props: Props) { return ( - - - {/* Input container fixed at bottom */} - - - - {t('auth.userMessage.title', { ns: 'login' })} - {error} + + + + + + {t('auth.userMessage.title', { ns: 'login' })} - - {t('auth.userMessage.username', { ns: 'login' })} + + + {t('auth.userMessage.username', { ns: 'login' })} + {error} + - - {isLoading ? ( - - ) : ( - - {t('auth.userMessage.next', { ns: 'login' })} - - )} - + ) } + +const styles = StyleSheet.create({ + keyboardAvoidingView: { + flex: 1, + }, + container: { + flex: 1, + backgroundColor: '#FFB645', + height: '100%', + }, + flex1: { + flex: 1, + }, + inputContainer: { + width: '100%', + backgroundColor: '#FFFFFF', + padding: 16, + borderTopWidth: 1, + borderTopColor: '#E5E7EB', + borderTopLeftRadius: 50, + borderTopRightRadius: 50, + }, + contentContainer: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + gap: 16, + }, + titleContainer: { + width: '100%', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + fontSize: 20, + }, + titleText: { + color: '#4C320C', + fontWeight: '600', + fontSize: 20, + marginBottom: 16, + }, + inputWrapper: { + width: '100%', + marginBottom: 16, + }, + labelContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 10, + }, + labelText: { + color: '#AC7E35', + marginLeft: 8, + fontSize: 14, + fontWeight: '600', + }, + errorText: { + color: '#E2793F', + fontSize: 14, + }, + textInput: { + backgroundColor: '#FFF8DE', + borderRadius: 16, + padding: 20, + width: '100%', + } +}); diff --git a/components/utils/objectFlat.ts b/components/utils/objectFlat.ts index 288925f..07f7e5f 100644 --- a/components/utils/objectFlat.ts +++ b/components/utils/objectFlat.ts @@ -4,29 +4,31 @@ interface RawData { location?: { latitude?: string | number; }; + [key: string]: any; // Allow any additional properties } -export function transformData(data: RawData): RawData { +export function transformData(data: RawData): Omit { const result = { ...data }; if (result.exif) { const newExif: Record = {}; for (const key in result.exif) { - const value = result.exif[key]; + const value: unknown = result.exif[key]; // 普通对象:{Exif}, {TIFF}, {XMP} 等 - if (typeof value === 'object' && !Array.isArray(value)) { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const obj = value as Record; if (key === '{GPS}') { // 处理 GPS 字段:所有子字段加前缀 "GPS" - for (const subKey in value) { + for (const subKey in obj) { const newKey = 'GPS' + subKey; // 所有字段都加前缀 - newExif[newKey] = value[subKey]; + newExif[newKey] = obj[subKey]; } } else { // 其它嵌套对象直接展开字段 - for (const subKey in value) { - newExif[subKey] = value[subKey]; + for (const subKey in obj) { + newExif[subKey] = obj[subKey]; } } } else { @@ -35,8 +37,12 @@ export function transformData(data: RawData): RawData { } } - result.exif = newExif; + // 合并展开的 exif 数据并移除 exif 属性 + const { exif, ...rest } = result; + return { ...rest, ...newExif }; } - // 最后将result的exif信息平铺 - return { ...result, ...result.exif, exif: undefined }; + + // 如果没有 exif 数据,直接返回原数据(排除 exif 属性) + const { exif, ...rest } = result; + return rest; } \ No newline at end of file diff --git a/context/PermissionContext.tsx b/context/PermissionContext.tsx new file mode 100644 index 0000000..a1f5031 --- /dev/null +++ b/context/PermissionContext.tsx @@ -0,0 +1,82 @@ +import PermissionAlert from '@/components/common/PermissionAlert'; +import i18n from '@/i18n'; +import { PermissionService } from '@/lib/PermissionService'; +import React, { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; +import { Linking } from 'react-native'; + +interface PermissionAlertOptions { + title: string; + message: string; + confirmText?: string; + cancelText?: string; +} + +interface PermissionContextType { + showPermissionAlert: (options: PermissionAlertOptions) => Promise; +} + +interface AlertData { + options: PermissionAlertOptions; + resolve: (value: boolean) => void; +} + +const PermissionContext = createContext(undefined); + +export const PermissionProvider = ({ children }: { children: ReactNode }) => { + const [alertData, setAlertData] = useState(null); + + const showPermissionAlert = useCallback((options: PermissionAlertOptions) => { + return new Promise((resolve) => { + setAlertData({ options, resolve }); + }); + }, []); + + useEffect(() => { + PermissionService.set(showPermissionAlert); + + // Cleanup on unmount + return () => { + PermissionService.set(null as any); // or a no-op function + }; + }, [showPermissionAlert]); + + const handleConfirm = () => { + Linking.openSettings(); + if (alertData?.resolve) { + alertData.resolve(true); + } + setAlertData(null); + }; + + const handleCancel = () => { + if (alertData?.resolve) { + alertData.resolve(false); + } + setAlertData(null); + }; + + return ( + + {children} + {alertData && ( + + )} + + ); +}; + +export const usePermission = (): PermissionContextType => { + const context = useContext(PermissionContext); + if (!context) { + throw new Error('usePermission must be used within a PermissionProvider'); + } + return context; +}; diff --git a/hooks/useUploadManager.ts b/hooks/useUploadManager.ts index b19ff11..dd7dc8c 100644 --- a/hooks/useUploadManager.ts +++ b/hooks/useUploadManager.ts @@ -60,7 +60,8 @@ export const useUploadManager = () => { console.log('useUploadManager focused, existing session found. Monitoring progress.'); // If a session exists, just start monitoring. manageUploadState(true); // Initial check - interval = setInterval(manageUploadState, 2000); + // 将轮询间隔从2秒增加到3秒,减少资源消耗 + interval = setInterval(manageUploadState, 3000); } else { // If no session, then try to trigger a new upload. console.log('useUploadManager focused, no existing session. Triggering foreground media upload check.'); @@ -73,7 +74,8 @@ export const useUploadManager = () => { console.log(`New upload session started with time: ${newSessionStartTimeStr}, beginning to monitor...`); // A new session was started, so start monitoring. manageUploadState(); // Initial check - interval = setInterval(manageUploadState, 2000); + // 将轮询间隔从2秒增加到3秒,减少资源消耗 + interval = setInterval(manageUploadState, 3000); } } }; diff --git a/i18n/generate-imports.ts b/i18n/generate-imports.ts index c01390b..2c59925 100644 --- a/i18n/generate-imports.ts +++ b/i18n/generate-imports.ts @@ -7,6 +7,7 @@ import * as path from 'path'; function generateImports() { const localesPath = path.join(__dirname, 'locales'); + const namespaces = ['common', 'home', 'login', 'settings', 'upload', 'chat', 'me', 'permission']; const languages = fs.readdirSync(localesPath); let imports = ''; let translationsMap = 'const translations = {\n'; diff --git a/i18n/index.ts b/i18n/index.ts index 2f047c4..6149129 100644 --- a/i18n/index.ts +++ b/i18n/index.ts @@ -32,11 +32,11 @@ i18n resources: translations, // 支持命名空间 - ns: ['common', 'example', 'download'], + ns: ['common', 'example', 'download', 'permission'], defaultNS: 'common', // 设置默认语言为中文 - lng: 'zh', + lng: 'en', fallbackLng: 'en', debug: process.env.NODE_ENV === 'development', @@ -96,14 +96,16 @@ export const preloadCommonTranslations = async () => { // 预加载 common 和 example 命名空间 await Promise.all([ loadNamespaceForLanguage(currentLng, 'common'), - loadNamespaceForLanguage(currentLng, 'example') + loadNamespaceForLanguage(currentLng, 'example'), + loadNamespaceForLanguage(currentLng, 'permission') ]); // 如果当前语言不是英语,也预加载英语作为备用 if (currentLng !== 'en') { await Promise.all([ loadNamespaceForLanguage('en', 'common'), - loadNamespaceForLanguage('en', 'example') + loadNamespaceForLanguage('en', 'example'), + loadNamespaceForLanguage('en', 'permission') ]); } }; diff --git a/i18n/locales/en/ask.json b/i18n/locales/en/ask.json index 3031390..eaf2f72 100644 --- a/i18n/locales/en/ask.json +++ b/i18n/locales/en/ask.json @@ -1,10 +1,41 @@ { - "ask": { - "hi": "Hi,", - "iAmMemo": "I'm Memo!", - "ready": "Ready to wake up your memories?", - "justAsk": "Just ask MeMo, let me bring them back to life!", - "selectPhoto": "Select Photo", - "continueAsking": "Continue Asking" - } + "ask": { + "hi": "Hi,", + "iAmMemo": "I'm Memo!", + "ready": "Ready to wake up your memories?", + "justAsk": "Just ask MeMo, let me bring them back to life!", + "selectPhoto": "Select Photo", + "continueAsking": "Continue Asking", + "unNamed": "UnNamed", + "noMessage": "No Message", + "memoList": "Memo List", + "noChat": "No Chat", + "loading": "Loading...", + "refresh": "Refresh", + "error": "have some error", + "issue": "have some issue", + "case1": "Find last year’s baby moments", + "case2": "Pet moments", + "case3": "Show me my food memories in France with family", + "mediaAuth": "need album permission", + "mediaAuthDesc": "allow app to access album to save media files", + "saveSuccess": "save success", + "imgSave": "image saved to album", + "videoSave": "video saved to album", + "saveError": "save failed", + "saveErrorDesc": "save media files error, please try again", + "save": "save", + "cancel": "cancel", + "introduction1": "Ready to open the memory time capsule? Describe your memory, and I'll help you find photos, create videos, or unlock hidden Easter eggs ✨", + "introduction2": "Looking for the perfect image? Try these search tips for better results:\n\n• Be specific: Try 'autumn forest', 'minimalist desk', or 'vintage poster design'\n\n• Add details: For specific styles, try 'watercolor cat' or 'cyberpunk city nightscape'; for specific uses, try 'royalty-free landscape' or 'commercial use icons'\n\n• Describe the scene: Try 'sunlight through leaves' or 'rainy day coffee shop window'\n\nEnter these keywords, and you might just find the perfect shot!", + "introduction3": "Want to make your videos more engaging and story-driven? Start with an image search!\n\nFirst, decide on your video's theme—whether it's healing natural landscapes, retro cityscapes, or vibrant life moments. Then search for related images. For example, if your theme is 'Spring Limited,' search for 'cherry blossoms falling,' 'picnic in the grass,' or 'first buds of spring.'\n\nThese images can help you visualize your video's flow and even spark new ideas—like how an old photo of a vintage object might inspire a story about time, or how a series of starry sky images could connect into a narrative about dreams and distant places. String these images together with the right music and captions, and you've got yourself a heartwarming video. Give it a try!", + "search": "Search Assets", + "video": "Create Video", + "think": "Thinking...", + "insufficientPoints": "Insufficient points. Please go to your profile to purchase more points 😊", + "invalidToken": "Invalid token", + "notFound": "Resource not found", + "permissionDenied": "Permission denied", + "notConnected": "Not connected. Please check your network connection or restart the app 😊" + } } \ No newline at end of file diff --git a/i18n/locales/en/common.json b/i18n/locales/en/common.json index 0037b84..025beb5 100644 --- a/i18n/locales/en/common.json +++ b/i18n/locales/en/common.json @@ -11,18 +11,20 @@ "common": { "search": "Search...", "title": "MemoWake - Home Video Memory, Powered by AI", - "name":"MemoWake", - "homepage":"HomePage", - "signup":"Sign up", - "login":"Login", - "trade":"copyright 2025 MemoWake - All rights reserved", - "logout":"Logout", - "self":"Personal Center" + "name": "MemoWake", + "homepage": "HomePage", + "signup": "Sign up", + "login": "Login", + "trade": "沪ICP备2025133004号-2A", + "logout": "Logout", + "self": "Personal Center", + "goToSettings": "Go to Settings", + "cancel": "Cancel" }, "welcome": { - "welcome": "Welcome to MemoWake~", - "slogan": "Preserve your love, laughter and precious moments forever", - "notice": "s back to live family portrait " + "welcome": "Welcome to MemoWake~", + "slogan": "Preserve your love, laughter and precious moments forever", + "notice": "s back to live family portrait " }, "imagePreview": { "zoomOut": "Zoom out", @@ -62,7 +64,7 @@ "file": { "invalidType": "Invalid file type", "tooLarge": "File too large", - "tooSmall":"File too small" + "tooSmall": "File too small" }, "email": { "required": "Email is required", @@ -109,5 +111,11 @@ "required": "You must agree to the Terms and Privacy Policy" } }, - "loading": "Loading..." -} + "loading": "Loading...", + "permission": { + "locationPermissionRequired": "Location permission is required, please enable location service in settings", + "timeout": "Location timeout, please check network and location service", + "notLocation": "Unable to get your location: ", + "notError": "Unknown error" + } +} \ No newline at end of file diff --git a/i18n/locales/en/download.json b/i18n/locales/en/download.json index faaec14..00c37c4 100644 --- a/i18n/locales/en/download.json +++ b/i18n/locales/en/download.json @@ -2,5 +2,7 @@ "title": "Download Our App", "desc": "Get the full experience by downloading our app on your favorite platform.", "ios": "Download for iOS", - "android": "Download for Android" + "android": "Download for Android", + "mobileDescription": "Scan the QR Code to awaken your precious memories", + "download": "Download MemoWake" } \ No newline at end of file diff --git a/i18n/locales/en/login.json b/i18n/locales/en/login.json index bd71809..c4d15e4 100644 --- a/i18n/locales/en/login.json +++ b/i18n/locales/en/login.json @@ -20,10 +20,12 @@ "avatarText": "Choose an avatar to begin your journey", "avatorText2": "You can always change it later", "choosePhoto": "Choose Photo", - "allDone": "All Done!" + "allDone": "All Done!", + "avatarRequired": "Please upload an avatar" }, "telLogin": { "title": "Verify Your Identity", + "codeTitle": "Verify Your Identity", "secondTitle": "We’ve sent an email with your code to:", "sendCode": "send code", "continue": " Continue", @@ -65,14 +67,13 @@ "accountPlaceholder": "Enter your account or email", "signUpMessage": "Don’t have an account?", "signUp": "Sign up", - "phoneLogin": "Phone Login", - "passwordNotMatch": "Passwords do not match" + "phoneLogin": "Phone Login" }, "agree": { "logintext": "By logging in, you agree to our", "singupText": "By signing up, you agree to our", "terms": " Terms", - "join": "&", + "join": " and have read our", "privacyPolicy": " Privacy Policy." }, "welcome": { @@ -86,7 +87,10 @@ "sendEmailBtn": "Send email", "goback": "Go back", "success": "Email sent successfully, please check your email", - "sendEmailBtnDisabled": "Email sent" + "sendEmailBtnDisabled": "Email sent", + "sendEmailError": "Failed to send email, please try again", + "passwordNotMatch": "Passwords do not match", + "pwdLengthError": "Password length must be at least 6 characters" }, "resetPwd": { "title": "Reset password", @@ -119,7 +123,8 @@ "codeExpireTime": "Code will expire in", "checkedRequired": "Please agree to the terms", "emailAuth": "Please enter a valid email address", - "passwordAuth": "Please enter a valid password" + "passwordAuth": "Please enter a valid password", + "pwdLengthError": "Password length must be at least 6 characters" } } } \ No newline at end of file diff --git a/i18n/locales/en/permission.json b/i18n/locales/en/permission.json new file mode 100644 index 0000000..8446892 --- /dev/null +++ b/i18n/locales/en/permission.json @@ -0,0 +1,29 @@ +{ + "title": { + "permissionDenied": "Permission Denied", + "locationPermissionRequired": "Location Permission Required", + "mediaLibraryPermissionRequired": "Media Library Permission Required", + "notificationPermissionRequired": "Notification Permission Required", + "success": "✅ Success", + "error": "❌ Error", + "getMediaFailed": "Failed to Get Media" + }, + "message": { + "locationPreviouslyDenied": "You have previously denied location permissions. To use this feature, please enable it in settings.", + "mediaLibraryPreviouslyDenied": "You have previously denied media library permissions. To use this feature, please enable it in settings.", + "notificationPreviouslyDenied": "You have previously denied notification permissions. To use this feature, please enable it in settings.", + "saveToAlbumPermissionRequired": "Permission is required to save images to the album.", + "qrCodeSaved": "QR code has been saved to the album!", + "saveImageFailed": "Failed to save the image, please try again.", + "getStatsPermissionRequired": "Permission to access the media library is required to get statistics.", + "getStatsFailed": "Failed to get media library statistics.", + "noMediaFound": "Could not retrieve any media. Please check permissions or your media library.", + "uploadError": "An error occurred during the upload process." + }, + "button": { + "cancel": "Cancel", + "goToSettings": "Go to Settings", + "ok": "OK", + "confirm": "Go to Settings" + } +} diff --git a/i18n/locales/en/personal.json b/i18n/locales/en/personal.json index 0fbadeb..db5751c 100644 --- a/i18n/locales/en/personal.json +++ b/i18n/locales/en/personal.json @@ -83,6 +83,51 @@ "videoLength": "Video Duration", "storiesCreated": "Stories Created", "conversationsWithMemo": "Conversations with Memo", - "setting": "Settings" + "setting": "Settings", + "premium": "Upgrade to Premium", + "unlock": "Unlock more memory magic", + "delete": "Are you sure you want to delete your account?", + "cancel": "Cancel", + "pro": "Pro", + "goPremium": "开通会员" + }, + "rights": { + "title": "Subscription", + "premium": "Pro", + "purchase": "Purchase", + "free": "Free", + "subscribe": "Subscribe", + "terms": "Terms", + "100Bonus": "Enjoy 100 Bonus Credits Every Month", + "100BonusText": "Generate more memory pictures & videos and explore your past.", + "10G": "10GB of Cloud Storage", + "10GText": "Safely store your cherished photos, videos, and generated memories.", + "goPremium": "Go Premium", + "bestValue": "Best Value", + "cancelAnytimeBeforeRenewal": "Cancel anytime before renewal. Learn more", + "proTitle": "Enjoy MemoWake Pro Benefits", + "noAd": "No advertisements", + "noAdText": "There are no advertisements, so you can use the product with peace of mind.", + "bonus": "Enjoy 100 Bonus Credits Every Month", + "bonusText": "Generate more memory pictures & videos and explore your past.", + "storage": "10GB of Cloud Storage", + "storageText": "Safely store your cherished photos, videos, and generated memories.", + "weChatPay": "WeChat", + "apple": "Apple Pay", + "confirm": "Confirm", + "cancel": "Cancel", + "confirmLoading": "Confirming...", + "againError": "You have already purchased this benefit, no need to purchase again", + "payType": "Pay Type", + "restoreSuccess": "Restore purchase successfully", + "restore": "restore purchase", + "restorePurchase": "Membership purchased but not active,please try to", + "agreement": "I have read and agree to", + "membership": "《Membership Agreement》", + "agreementError": "Please read and agree to the agreement" + }, + "member": { + "goPremium": "Go Premium", + "unlock": "Unlock more memory magic" } } \ No newline at end of file diff --git a/i18n/locales/zh/ask.json b/i18n/locales/zh/ask.json index 3031390..2a27e70 100644 --- a/i18n/locales/zh/ask.json +++ b/i18n/locales/zh/ask.json @@ -5,6 +5,37 @@ "ready": "Ready to wake up your memories?", "justAsk": "Just ask MeMo, let me bring them back to life!", "selectPhoto": "Select Photo", - "continueAsking": "Continue Asking" + "continueAsking": "Continue Asking", + "unNamed": "未命名对话", + "noMessage": "暂无消息", + "memoList": "对话记录", + "noChat": "暂无对话记录", + "loading": "加载中...", + "refresh": "刷新", + "error": "出错了", + "issue": "发生了一些问题", + "case1": "找去年我家宝宝/宠物的素材片段", + "case2": "找去年吃过的美食", + "case3": "找近期旅游的素材", + "mediaAuth": "需要相册权限", + "mediaAuthDesc": "请允许应用访问相册以保存媒体文件", + "saveSuccess": "保存成功", + "imgSave": "图片已保存到相册", + "videoSave": "视频已保存到相册", + "saveError": "保存失败", + "saveErrorDesc": "保存媒体文件时出错,请重试", + "save": "保存", + "cancel": "取消", + "introduction1": "想打开记忆盲盒吗?描述你的回忆,我来帮你找回照片、生成影片或解锁隐藏彩蛋✨", + "introduction2": "想找合适的图片?试试这样搜更精准:\n\n• 明确主题:比如'秋日森林'、'极简风书桌'、'复古海报设计'\n\n• 加上细节:想找特定风格?试试'水彩风猫咪'、'赛博朋克城市夜景';需要特定用途?比如'无版权风景图'、'可商用图标'\n\n• 描述场景:比如'阳光透过树叶的光斑'、'雨天咖啡馆窗外'\n\n输入这些关键词,说不定就能找到你想要的画面啦~", + "introduction3": "想让你的视频内容更吸睛、更有故事感吗?不妨试试从搜索图片入手吧!\n\n你可以先确定视频的主题——是治愈系的自然风景,还是复古风的城市街景,或是充满活力的生活瞬间?然后根据主题去搜索相关的图片,比如想做'春日限定'主题,就搜'樱花飘落''草地野餐''嫩芽初绽'之类的画面。\n\n这些图片能帮你快速理清视频的画面脉络,甚至能激发新的创意——比如一张老照片里的复古物件,或许能延伸出一段关于时光的故事;一组星空图片,说不定能串联成关于梦想与远方的叙事。把这些图片按你的想法串联起来,配上合适的音乐和文案,一段有温度的视频就诞生啦,试试看吧~", + "search": "检索素材", + "video": "创作视频", + "think": "思考中...", + "insufficientPoints": "积分不足,您可以前往个人中心去购买积分😊", + "invalidToken": "无效的令牌", + "notFound": "未找到资源", + "permissionDenied": "权限不足", + "notConnected": "未连接,请检查网络连接,或者退出app重试😊" } } \ No newline at end of file diff --git a/i18n/locales/zh/common.json b/i18n/locales/zh/common.json index c977501..17eb407 100644 --- a/i18n/locales/zh/common.json +++ b/i18n/locales/zh/common.json @@ -11,13 +11,15 @@ "common": { "search": "搜索...", "title": "MemoWake - AI驱动的家庭「视频记忆」", - "name":"MemoWake", - "homepage":"首页", - "signup":"注册", - "login":"登录", - "trade": "沪ICP备2023032876号-4", - "logout":"退出登录", - "self":"个人中心" + "name": "MemoWake", + "homepage": "首页", + "signup": "注册", + "login": "登录", + "trade": "沪ICP备2025133004号-2A", + "logout": "退出登录", + "self": "个人中心", + "goToSettings": "去设置", + "cancel": "取消" }, "welcome": { "welcome": "欢迎来到 MemoWake~", @@ -55,7 +57,7 @@ "invalidFileTitle": "不支持的文件格式", "fileTooLargeTitle": "文件过大", "uploadErrorTitle": "上传失败", - "fileTooSmallTitle": "文件过小", + "fileTooSmallTitle": "文件过小", "fileTooSmall": "文件过小,请上传大于300像素的图片" }, "validation": { @@ -108,5 +110,11 @@ "required": "您必须同意服务条款和隐私政策" } }, - "loading": "加载中..." -} + "loading": "加载中...", + "permission": { + "locationPermissionRequired": "位置权限被拒绝,请在设置中启用位置服务", + "timeout": "获取位置超时,请检查网络和位置服务", + "notLocation": "无法获取您的位置: ", + "notError": "未知错误" + } +} \ No newline at end of file diff --git a/i18n/locales/zh/download.json b/i18n/locales/zh/download.json index 3b1a5d0..377d2d7 100644 --- a/i18n/locales/zh/download.json +++ b/i18n/locales/zh/download.json @@ -2,5 +2,7 @@ "title": "下载我们的应用", "desc": "在您喜欢的平台上下载我们的应用,以获得完整的体验。", "ios": "下载 iOS 版", - "android": "下载 Android 版" + "android": "下载 Android 版", + "mobileDescription": "扫描二维码唤醒珍贵记忆", + "download": "下载 MemoWake" } \ No newline at end of file diff --git a/i18n/locales/zh/login.json b/i18n/locales/zh/login.json index 3daf77c..1a5bf24 100644 --- a/i18n/locales/zh/login.json +++ b/i18n/locales/zh/login.json @@ -20,10 +20,12 @@ "avatarText": "选择一个头像开始您的旅程", "avatorText2": "您可以随时更改", "choosePhoto": "选择照片", - "allDone": "完成!" + "allDone": "完成!", + "avatarRequired": "请上传头像" }, "telLogin": { - "title": "验证身份", + "title": "请输入手机号", + "codeTitle": "请输入验证码", "secondTitle": "我们已发送验证码至:", "sendCode": "发送验证码", "continue": "继续", @@ -65,8 +67,7 @@ "accountPlaceholder": "请输入您的账号或邮箱", "signUpMessage": "还没有账号?", "signUp": "注册", - "phoneLogin": "手机号登录", - "passwordNotMatch": "密码不一致" + "phoneLogin": "手机号登录" }, "agree": { "logintext": "登录即表示您同意我们的", @@ -86,7 +87,10 @@ "sendEmailBtn": "发送邮件", "signupButton": "注册", "goback": "返回登录", - "sendEmailBtnDisabled": "已发送" + "sendEmailBtnDisabled": "已发送", + "sendEmailError": "发送失败,请重试", + "passwordNotMatch": "密码不一致", + "pwdLengthError": "密码长度至少为6位" }, "resetPwd": { "title": "重置密码", @@ -120,7 +124,8 @@ "codeExpireTime": "验证码将在以下时间后过期", "checkedRequired": "请勾选协议", "emailAuth": "请输入一个有效的邮箱地址", - "passwordAuth": "请输入一个有效的密码" + "passwordAuth": "请输入一个有效的密码", + "pwdLengthError": "密码长度至少为6位" } } } \ No newline at end of file diff --git a/i18n/locales/zh/permission.json b/i18n/locales/zh/permission.json new file mode 100644 index 0000000..70d0b33 --- /dev/null +++ b/i18n/locales/zh/permission.json @@ -0,0 +1,29 @@ +{ + "title": { + "permissionDenied": "权限被拒绝", + "locationPermissionRequired": "需要位置权限", + "mediaLibraryPermissionRequired": "需要媒体库权限", + "notificationPermissionRequired": "需要通知权限", + "success": "✅ 成功", + "error": "❌ 失败", + "getMediaFailed": "获取媒体资源失败" + }, + "message": { + "locationPreviouslyDenied": "您之前拒绝了位置权限。要使用此功能,请在设置中启用位置权限。", + "mediaLibraryPreviouslyDenied": "您之前拒绝了媒体库权限。要使用此功能,请在设置中启用它。", + "notificationPreviouslyDenied": "您之前拒绝了通知权限。要使用此功能,请在设置中启用它。", + "saveToAlbumPermissionRequired": "需要保存图片到相册的权限", + "qrCodeSaved": "二维码已保存到相册!", + "saveImageFailed": "无法保存图片,请重试", + "getStatsPermissionRequired": "需要访问媒体库权限来获取统计信息", + "getStatsFailed": "获取媒体库统计信息失败", + "noMediaFound": "未能获取到任何媒体资源,请检查权限或媒体库。", + "uploadError": "上传过程中出现错误。" + }, + "button": { + "cancel": "取消", + "goToSettings": "去设置", + "ok": "好的", + "confirm": "去设置" + } +} diff --git a/i18n/locales/zh/personal.json b/i18n/locales/zh/personal.json index 3f0c5f9..c7888c6 100644 --- a/i18n/locales/zh/personal.json +++ b/i18n/locales/zh/personal.json @@ -83,6 +83,51 @@ "videoLength": "视频时长", "storiesCreated": "创作视频", "conversationsWithMemo": "Memo对话", - "setting": "设置" + "setting": "设置", + "premium": "升级至会员", + "unlock": "解锁更多记忆魔法", + "delete": "确定要注销账号吗?", + "cancel": "取消", + "pro": "Pro", + "goPremium": "开通会员" + }, + "rights": { + "title": "权益", + "purchase": "购买", + "free": "免费用户", + "premium": "会员", + "subscribe": "订阅", + "terms": "用户协议", + "100Bonus": "每月享受100积分", + "100BonusText": "生成更多记忆照片和视频,探索你的过去。", + "10G": "10GB的云存储", + "10GText": "安全存储你的珍贵照片、视频和生成的记忆。", + "goPremium": "升级至会员", + "bestValue": "最佳值", + "cancelAnytimeBeforeRenewal": "在续订前随时取消。了解更多", + "proTitle": "享受MemoWake Pro权益", + "noAd": "无广告", + "noAdText": "没有广告,所以你可以安心使用产品。", + "bonus": "每月享受100积分", + "bonusText": "生成更多记忆照片和视频,探索你的过去。", + "storage": "10GB的云存储", + "storageText": "安全存储你的珍贵照片、视频和生成的记忆。", + "weChatPay": "微信支付", + "apple": "苹果支付", + "confirm": "确认", + "cancel": "取消", + "confirmLoading": "正在购买...", + "againError": "您已购买过该权益,无需重复购买", + "payType": "支付方式", + "restoreSuccess": "恢复购买成功", + "restore": "恢复购买", + "restorePurchase": "已购买会员,但未生效,请尝试", + "agreement": "我已阅读并同意", + "membership": "《会员协议》", + "agreementError": "请先阅读并同意协议" + }, + "member": { + "goPremium": "开通会员", + "unlock": "解锁更多记忆魔法" } } \ No newline at end of file diff --git a/i18n/translations-generated.ts b/i18n/translations-generated.ts index 68a331f..6e3f4f8 100644 --- a/i18n/translations-generated.ts +++ b/i18n/translations-generated.ts @@ -9,7 +9,9 @@ import enExample from './locales/en/example.json'; import enFairclip from './locales/en/fairclip.json'; import enLanding from './locales/en/landing.json'; import enLogin from './locales/en/login.json'; +import enPermission from './locales/en/permission.json'; import enPersonal from './locales/en/personal.json'; +import enSupport from './locales/en/support.json'; import enUpload from './locales/en/upload.json'; import zhAdmin from './locales/zh/admin.json'; import zhAsk from './locales/zh/ask.json'; @@ -20,7 +22,9 @@ import zhExample from './locales/zh/example.json'; import zhFairclip from './locales/zh/fairclip.json'; import zhLanding from './locales/zh/landing.json'; import zhLogin from './locales/zh/login.json'; +import zhPermission from './locales/zh/permission.json'; import zhPersonal from './locales/zh/personal.json'; +import zhSupport from './locales/zh/support.json'; import zhUpload from './locales/zh/upload.json'; const translations = { @@ -34,7 +38,9 @@ const translations = { fairclip: enFairclip, landing: enLanding, login: enLogin, + permission: enPermission, personal: enPersonal, + support: enSupport, upload: enUpload }, zh: { @@ -47,7 +53,9 @@ const translations = { fairclip: zhFairclip, landing: zhLanding, login: zhLogin, + permission: zhPermission, personal: zhPersonal, + support: zhSupport, upload: zhUpload }, }; diff --git a/lib/PermissionService.ts b/lib/PermissionService.ts new file mode 100644 index 0000000..2f42491 --- /dev/null +++ b/lib/PermissionService.ts @@ -0,0 +1,21 @@ +interface PermissionAlertOptions { + title: string; + message: string; +} + +type ShowPermissionAlertFunction = (options: PermissionAlertOptions) => Promise; + +let showPermissionAlertRef: ShowPermissionAlertFunction | null = null; + +export const PermissionService = { + set: (fn: ShowPermissionAlertFunction) => { + showPermissionAlertRef = fn; + }, + show: (options: PermissionAlertOptions): Promise => { + if (!showPermissionAlertRef) { + console.error("PermissionAlert has not been set. Please ensure PermissionProvider is used at the root of your app."); + return Promise.resolve(false); + } + return showPermissionAlertRef(options); + }, +}; diff --git a/lib/background-uploader/manual.ts b/lib/background-uploader/manual.ts index d33f406..b3b76f0 100644 --- a/lib/background-uploader/manual.ts +++ b/lib/background-uploader/manual.ts @@ -1,5 +1,6 @@ import pLimit from 'p-limit'; -import { Alert } from 'react-native'; +import { PermissionService } from '../PermissionService'; +import i18n from '@/i18n'; import { getUploadTaskStatus, insertUploadTask } from '../db'; import { getMediaByDateRange } from './media'; import { ExtendedAsset } from './types'; @@ -24,7 +25,7 @@ export const triggerManualUpload = async ( try { const media = await getMediaByDateRange(startDate, endDate); if (media.length === 0) { - Alert.alert('提示', '在指定时间范围内未找到媒体文件'); + PermissionService.show({ title: i18n.t('permission:title.getMediaFailed'), message: i18n.t('permission:message.noMediaFound') }); return []; } @@ -76,7 +77,7 @@ export const triggerManualUpload = async ( return finalResults; } catch (error) { console.error('手动上传过程中出现错误:', error); - Alert.alert('错误', '上传过程中出现错误'); + PermissionService.show({ title: i18n.t('permission:title.error'), message: i18n.t('permission:message.uploadError') }); throw error; } }; \ No newline at end of file diff --git a/lib/background-uploader/types.ts b/lib/background-uploader/types.ts index e7ace0c..d4c2e5f 100644 --- a/lib/background-uploader/types.ts +++ b/lib/background-uploader/types.ts @@ -1,8 +1,8 @@ import * as MediaLibrary from 'expo-media-library'; export type ExtendedAsset = MediaLibrary.Asset & { - size?: number; - exif?: Record | null; + size?: number; + exif?: Record | null; }; // 上传任务类型 @@ -18,92 +18,93 @@ export type UploadTask = { // 文件元数据信息 interface FileSize { - value: number; - unit: string; + value: number; + unit: string; } interface FileMetadata { - originalName: string; - type: string; - isCompressed: string; - fileType: string; + originalName: string; + type: string; + isCompressed: string; + fileType: string; } // 后端返回的文件信息 interface FileInfo { - file_id: number; - name: string; - size: FileSize; - content_type: string; // 这里与 ConfirmUpload 的 content_type 定义不同,需要注意 - upload_time: string; - storage_medium: string; - file_path: string; // 这里与 ConfirmUpload 的 file_path 定义不同 - uploader_id: number; - upload_status: string; - deletion_status: string; - metadata: FileMetadata; + file_id: number; + name: string; + size: FileSize; + content_type: string; // 这里与 ConfirmUpload 的 content_type 定义不同,需要注意 + upload_time: string; + storage_medium: string; + file_path: string; // 这里与 ConfirmUpload 的 file_path 定义不同 + uploader_id: number; + upload_status: string; + deletion_status: string; + metadata: FileMetadata; } // 上传队列项 - 作为唯一的类型定义 // 定义 EXIF 数据类型 export type ExifData = { - GPSLatitude?: number | undefined; - GPSLongitude?: number | undefined; - GPSAltitude?: number | undefined; - DateTimeOriginal?: string | undefined; - Make?: string | undefined; - Model?: string | undefined; - ExposureTime?: number | undefined; - FNumber?: number | undefined; - ISOSpeedRatings?: number | undefined; - FocalLength?: number | undefined; - [key: string]: any; + GPSLatitude?: number | undefined; + GPSLongitude?: number | undefined; + GPSAltitude?: number | undefined; + DateTimeOriginal?: string | undefined; + Make?: string | undefined; + Model?: string | undefined; + ExposureTime?: number | undefined; + FNumber?: number | undefined; + ISOSpeedRatings?: number | undefined; + FocalLength?: number | undefined; + [key: string]: any; }; // 默认的 EXIF 数据结构 export const defaultExifData: ExifData = { - GPSLatitude: undefined, - GPSLongitude: undefined, - GPSAltitude: undefined, - DateTimeOriginal: undefined, - Make: undefined, - Model: undefined, - ExposureTime: undefined, - FNumber: undefined, - ISOSpeedRatings: undefined, - FocalLength: undefined, + GPSLatitude: undefined, + GPSLongitude: undefined, + GPSAltitude: undefined, + DateTimeOriginal: undefined, + Make: undefined, + Model: undefined, + ExposureTime: undefined, + FNumber: undefined, + ISOSpeedRatings: undefined, + FocalLength: undefined, }; // 压缩图片可配置参数 export interface ImagesuploaderProps { - children?: React.ReactNode; - style?: import('react-native').StyleProp; - onPickImage?: (file: File, exifData: ExifData) => void; - compressQuality?: number; - maxWidth?: number; - maxHeight?: number; - preserveExif?: boolean; - uploadOriginal?: boolean; - onUploadComplete?: (result: FileUploadItem[]) => void; - onProgress?: (progress: any) => void; // TODO: Define a proper type for progress - multipleChoice?: boolean; - fileType?: any[]; // TODO: Use MediaType from expo-image-picker - showPreview?: boolean; + children?: React.ReactNode; + style?: import('react-native').StyleProp; + onPickImage?: (file: File, exifData: ExifData) => void; + compressQuality?: number; + maxWidth?: number; + maxHeight?: number; + preserveExif?: boolean; + uploadOriginal?: boolean; + onUploadComplete?: (result: FileUploadItem[]) => void; + onProgress?: (progress: any) => void; // TODO: Define a proper type for progress + multipleChoice?: boolean; + fileType?: any[]; // TODO: Use MediaType from expo-image-picker + showPreview?: boolean; } export interface FileUploadItem { - id: string; - uri: string; // 用于本地展示的资源URI - name: string; - progress: number; - status: 'pending' | 'uploading' | 'success' | 'error'; // 统一状态 - error?: string | null; - previewUrl: string; // 预览URL - file?: File; - type: 'image' | 'video'; - thumbnail?: string; // 缩略图URL - thumbnailFile?: File; // 缩略图文件对象 - originalFile?: FileInfo // 上传后返回的文件信息 + id: string; + uri: string; // 用于本地展示的资源URI + name: string; + progress: number; + status: 'pending' | 'uploading' | 'success' | 'error'; // 统一状态 + error?: string | null; + previewUrl: string; // 预览URL + preview: string; // 预览URL + file?: File; + type: 'image' | 'video'; + thumbnail?: string; // 缩略图URL + thumbnailFile?: File; // 缩略图文件对象 + originalFile?: FileInfo // 上传后返回的文件信息 } // 确认上传返回 diff --git a/lib/prefetch.ts b/lib/prefetch.ts new file mode 100644 index 0000000..002e863 --- /dev/null +++ b/lib/prefetch.ts @@ -0,0 +1,76 @@ +import { fetchApi } from './server-api-util'; + +// 全局缓存对象 +const cache: Record = {}; +const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存时间 + +/** + * 预取数据并缓存 + * @param url API地址 + * @param forceRefresh 是否强制刷新缓存 + * @returns 返回Promise,解析为获取到的数据 + */ +export const prefetchData = async (url: string, forceRefresh = false): Promise => { + const now = Date.now(); + + // 检查缓存是否存在且未过期 + if (!forceRefresh && cache[url] && (now - cache[url].timestamp < CACHE_DURATION)) { + return cache[url].data as T; + } + + try { + const data = await fetchApi(url); + // 缓存数据 + cache[url] = { + data, + timestamp: now, + }; + return data; + } catch (error) { + console.error(`Prefetch failed for ${url}:`, error); + // 如果缓存中有旧数据,返回旧数据 + if (cache[url]) { + return cache[url].data as T; + } + throw error; + } +}; + +/** + * 预取聊天列表 + */ +export const prefetchChats = async () => { + return prefetchData>('/chats'); +}; + +/** + * 预取单个聊天详情 + */ +export const prefetchChatDetail = async (sessionId: string) => { + return prefetchData(`/chats/${sessionId}/message-history`); +}; + +/** + * 清除指定URL的缓存 + */ +export const clearCache = (url?: string) => { + if (url) { + delete cache[url]; + } else { + // 清除所有缓存 + Object.keys(cache).forEach(key => { + delete cache[key]; + }); + } +}; + +/** + * 获取缓存数据 + */ +export const getCachedData = (url: string): T | null => { + const cached = cache[url]; + if (cached && (Date.now() - cached.timestamp < CACHE_DURATION)) { + return cached.data as T; + } + return null; +}; diff --git a/lib/server-api-util.ts b/lib/server-api-util.ts index 8331343..7202d05 100644 --- a/lib/server-api-util.ts +++ b/lib/server-api-util.ts @@ -25,7 +25,8 @@ export interface PagedResult { // 获取.env文件中的变量 -export const API_ENDPOINT = Constants.expoConfig?.extra?.API_ENDPOINT || "http://192.168.31.115:18080/api"; +export const API_ENDPOINT = Constants.expoConfig?.extra?.API_ENDPOINT || "http://192.168.31.16:31646/api"; + // 更新 access_token 的逻辑 - 用于React组件中 export const useAuthToken = async(message: string | null) => { @@ -128,6 +129,7 @@ export const fetchApi = async ( needToast = true, needToken = true, ): Promise => { + // console.log("API_ENDPOINT", Constants.expoConfig?.extra?.API_ENDPOINT); const makeRequest = async (isRetry = false): Promise> => { try { let token = ""; diff --git a/lib/websocket-util.ts b/lib/websocket-util.ts new file mode 100644 index 0000000..70e8290 --- /dev/null +++ b/lib/websocket-util.ts @@ -0,0 +1,242 @@ +import Constants from 'expo-constants'; +import * as SecureStore from 'expo-secure-store'; +import { TFunction } from 'i18next'; +import { Platform } from 'react-native'; + +// 从环境变量或默认值中定义 WebSocket 端点 +export const WEBSOCKET_ENDPOINT = Constants.expoConfig?.extra?.WEBSOCKET_ENDPOINT || "ws://192.168.31.16:31646/ws/chat"; + +export type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'; + +type StatusListener = (status: WebSocketStatus) => void; + +// 消息监听器类型 +type MessageListener = (data: any) => void; + +// 根据后端 Rust 定义的 WsMessage 枚举创建 TypeScript 类型 +export type WsMessage = + | { type: 'Chat', session_id: string, message: string, image_material_ids?: string[], video_material_ids?: string[] } + | { type: 'ChatResponse', session_id: string, message: any, message_id?: string } + | { type: 'ChatStream', session_id: string, chunk: string } + | { type: 'ChatStreamEnd', session_id: string, message: any } + | { type: 'Error', code: string, message: string } + | { type: 'Ping' } + | { type: 'Pong' } + | { type: 'Connected', user_id: number }; + +class WebSocketManager { + private ws: WebSocket | null = null; + private status: WebSocketStatus = 'disconnected'; + private messageListeners: Map void>> = new Map(); + private statusListeners: Set = new Set(); + private reconnectAttempts = 0; + private readonly maxReconnectAttempts = 1; + private readonly reconnectInterval = 1000; // 初始重连间隔为1秒 + private pingIntervalId: ReturnType | null = null; + private readonly pingInterval = 30000; // 30秒发送一次心跳 + + constructor() { + // 这是一个单例类,连接通过调用 connect() 方法来启动 + } + + /** + * 获取当前 WebSocket 连接状态。 + */ + public getStatus(): WebSocketStatus { + return this.status; + } + + /** + * 启动 WebSocket 连接。 + * 会自动获取并使用存储的认证 token。 + */ + public async connect() { + if (this.ws && (this.status === 'connected' || this.status === 'connecting')) { + if (this.status === 'connected' || this.status === 'connecting') { + return; + } + } + + this.setStatus('connecting'); + + let token = ""; + if (Platform.OS === 'web') { + token = localStorage.getItem('token') || ""; + } else { + token = await SecureStore.getItemAsync('token') || ""; + } + + if (!token) { + console.error('WebSocket: 未找到认证 token,无法连接。'); + this.setStatus('disconnected'); + return; + } else { + console.log('WebSocket: 认证 token:', token); + } + + const url = `${WEBSOCKET_ENDPOINT}?token=${token}`; + console.log('WebSocket: 连接 URL:', url); + this.ws = new WebSocket(url); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.setStatus('connected'); + this.reconnectAttempts = 0; // 重置重连尝试次数 + this.startPing(); + }; + + this.ws.onmessage = (event) => { + try { + const message: WsMessage = JSON.parse(event.data); + // console.log('WebSocket received message:', message) + // 根据消息类型分发 + const eventListeners = this.messageListeners.get(message.type); + if (eventListeners) { + eventListeners.forEach(callback => callback(message)); + } + // 可以在这里处理通用的消息,比如 Pong + if (message.type === 'Pong') { + // console.log('Received Pong'); + } + } catch (error) { + console.error('处理 WebSocket 消息失败:', error); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket 发生错误:', error); + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected'); + this.ws = null; + this.stopPing(); + // 只有在不是手动断开连接时才重连 + if (this.status !== 'disconnected') { + this.setStatus('reconnecting'); + this.handleReconnect(); + } + }; + } + + /** + * 处理自动重连逻辑,使用指数退避策略。 + */ + private handleReconnect() { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1); + console.log(`${delay / 1000}秒后尝试重新连接 (第 ${this.reconnectAttempts} 次)...`); + setTimeout(() => { + this.connect(); + }, delay); + } else { + console.error('WebSocket 重连失败,已达到最大尝试次数。'); + this.setStatus('disconnected'); + } + } + + /** + * 发送消息到 WebSocket 服务器。 + * @param message 要发送的消息对象,必须包含 type 字段。 + */ + public send(message: WsMessage) { + if (this.status !== 'connected' || !this.ws) { + console.error('WebSocket 未连接,无法发送消息。'); + return; + } + this.ws.send(JSON.stringify(message)); + } + + /** + * 订阅指定消息类型的消息。 + * @param type 消息类型,例如 'ChatResponse'。 + * @param callback 收到消息时的回调函数。 + */ + public subscribe(type: WsMessage['type'], callback: (message: WsMessage) => void) { + if (!this.messageListeners.has(type)) { + this.messageListeners.set(type, new Set()); + } + this.messageListeners.get(type)?.add(callback); + } + + /** + * 取消订阅指定消息类型的消息。 + * @param type 消息类型。 + * @param callback 要移除的回调函数。 + */ + public unsubscribe(type: WsMessage['type'], callback: (message: WsMessage) => void) { + const eventListeners = this.messageListeners.get(type); + if (eventListeners) { + eventListeners.delete(callback); + if (eventListeners.size === 0) { + this.messageListeners.delete(type); + } + } + } + + /** + * 手动断开 WebSocket 连接。 + */ + public disconnect() { + this.setStatus('disconnected'); + if (this.ws) { + this.ws.close(); + } + this.stopPing(); + } + + private setStatus(status: WebSocketStatus) { + if (this.status !== status) { + this.status = status; + this.statusListeners.forEach(listener => listener(status)); + } + } + + public subscribeStatus(listener: StatusListener) { + this.statusListeners.add(listener); + // Immediately invoke with current status + listener(this.status); + } + + public unsubscribeStatus(listener: StatusListener) { + this.statusListeners.delete(listener); + } + + /** + * 启动心跳机制。 + */ + private startPing() { + this.stopPing(); // 先停止任何可能正在运行的计时器 + this.pingIntervalId = setInterval(() => { + this.send({ type: 'Ping' }); + }, this.pingInterval); + } + + /** + * 停止心跳机制。 + */ + private stopPing() { + if (this.pingIntervalId) { + clearInterval(this.pingIntervalId); + this.pingIntervalId = null; + } + } +} + +// 导出一个单例,确保整个应用共享同一个 WebSocket 连接 +export const webSocketManager = new WebSocketManager(); + + +// webscoket 错误映射 +export const getWebSocketErrorMessage = (key: string, t: TFunction) => { + const messages = { + 'INSUFFICIENT_POINTS': t('ask:ask.insufficientPoints'), + 'INVALID_TOKEN': t('ask:ask.invalidToken'), + 'NOT_FOUND': t('ask:ask.notFound'), + 'PERMISSION_DENIED': t('ask:ask.permissionDenied'), + 'NOT_CONNECTED': t('ask:ask.notConnected'), + }; + + return messages[key as keyof typeof messages] || t('ask:ask.unknownError'); +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2d3c8d1..50da0cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,22 @@ "name": "memowake", "version": "1.0.0", "dependencies": { + "@expensify/react-native-live-markdown": "^0.1.299", "@expo/vector-icons": "^14.1.0", + "@react-native-masked-view/masked-view": "0.3.2", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", "@reduxjs/toolkit": "^2.8.2", + "@twotalltotems/react-native-otp-input": "^1.3.11", "@types/p-limit": "^2.2.0", "@types/react-redux": "^7.1.34", - "expo": "53.0.19", + "expensify-common": "^2.0.154", + "expo": "53.0.20", "expo-audio": "~0.4.8", "expo-background-task": "^0.2.8", "expo-blur": "~14.1.5", + "expo-build-properties": "^0.14.8", "expo-clipboard": "~7.1.5", "expo-constants": "~17.1.6", "expo-dev-client": "~5.2.4", @@ -26,6 +31,7 @@ "expo-file-system": "~18.1.10", "expo-font": "~13.3.1", "expo-haptics": "~14.1.4", + "expo-iap": "^2.7.5", "expo-image-manipulator": "~13.1.7", "expo-image-picker": "~16.1.4", "expo-linear-gradient": "~14.1.5", @@ -34,7 +40,7 @@ "expo-location": "~18.1.5", "expo-media-library": "~17.1.7", "expo-notifications": "~0.31.4", - "expo-router": "~5.1.3", + "expo-router": "~5.1.4", "expo-secure-store": "~14.2.3", "expo-splash-screen": "~0.30.10", "expo-sqlite": "~15.2.14", @@ -45,6 +51,7 @@ "expo-video": "~2.2.2", "expo-video-thumbnails": "~9.1.3", "expo-web-browser": "~14.2.0", + "html-entities": "2.5.3", "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", @@ -56,19 +63,25 @@ "react-i18next": "^15.5.3", "react-native": "0.79.5", "react-native-gesture-handler": "~2.24.0", + "react-native-linear-gradient": "^2.8.3", + "react-native-markdown-display": "^7.0.2", "react-native-modal": "^14.0.0-rc.1", "react-native-picker-select": "^9.3.1", "react-native-progress": "^5.0.1", + "react-native-qrcode-svg": "^6.3.15", "react-native-reanimated": "~3.17.4", + "react-native-reanimated-carousel": "^4.0.2", "react-native-render-html": "^6.3.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", "react-native-svg": "15.11.2", "react-native-toast-message": "^2.3.0", "react-native-uuid": "^2.0.3", + "react-native-view-shot": "4.0.3", "react-native-web": "~0.20.0", "react-native-webview": "13.13.5", - "react-redux": "^9.2.0" + "react-redux": "^9.2.0", + "worklet": "^1.0.3" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -2243,6 +2256,25 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@expensify/react-native-live-markdown": { + "version": "0.1.299", + "resolved": "https://registry.npmmirror.com/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.299.tgz", + "integrity": "sha512-kqbNG726Xtk3V2TFll1rxJSRdAQUZ2Ret3bEYopEm49fcy5FLCQZ76MVm5yg6dbpnYnqRukQ+aIut1lEXzoMiw==", + "license": "MIT", + "workspaces": [ + "./example", + "./WebExample" + ], + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "expensify-common": ">=2.0.115", + "react": "*", + "react-native": "*", + "react-native-reanimated": ">=3.17.0" + } + }, "node_modules/@expo/cli": { "version": "0.24.20", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.24.20.tgz", @@ -3785,6 +3817,26 @@ } } }, + "node_modules/@react-native-community/clipboard": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@react-native-community/clipboard/-/clipboard-1.5.1.tgz", + "integrity": "sha512-AHAmrkLEH5UtPaDiRqoULERHh3oNv7Dgs0bTC0hO5Z2GdNokAMPT5w8ci8aMcRemcwbtdHjxChgtjbeA38GBdA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.0", + "react-native": ">=0.57.0" + } + }, + "node_modules/@react-native-masked-view/masked-view": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", + "integrity": "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16", + "react-native": ">=0.57" + } + }, "node_modules/@react-native-picker/picker": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz", @@ -4627,6 +4679,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@twotalltotems/react-native-otp-input": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@twotalltotems/react-native-otp-input/-/react-native-otp-input-1.3.11.tgz", + "integrity": "sha512-xSsEMa8llYHITKgx1FGwU3uK56Kk6+abgkJpo57pLnUpYC2CZpvhkvRKhFa7Ui6BhdRuh0Ob1O7q234d3CksRg==", + "license": "MIT", + "dependencies": { + "@react-native-community/clipboard": "^1.2.2" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", @@ -5941,6 +6002,20 @@ "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", "license": "MIT" }, + "node_modules/awesome-phonenumber": { + "version": "5.11.0", + "resolved": "https://registry.npmmirror.com/awesome-phonenumber/-/awesome-phonenumber-5.11.0.tgz", + "integrity": "sha512-25GfikMIo6CBQIqvjoewo4uiu5Ai7WqEC8gxesH3LDwCY43oEdkLaT15a+8adC7uWIJCGh+YQiBY5bjmDpoQcg==", + "license": "MIT", + "workspaces": [ + "webpack", + "cjs-test", + "esm-test" + ], + "engines": { + "node": ">=14" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -6147,6 +6222,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6641,6 +6725,15 @@ "dev": true, "license": "MIT" }, + "node_modules/classnames": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.0.tgz", + "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA==", + "license": "MIT", + "workspaces": [ + "benchmarks" + ] + }, "node_modules/cli-cursor": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", @@ -6671,6 +6764,17 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clipboard": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", + "license": "MIT", + "dependencies": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -7063,6 +7167,15 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -7249,6 +7362,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -7353,6 +7475,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7420,6 +7548,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -8566,10 +8700,82 @@ "dev": true, "license": "MIT" }, + "node_modules/expensify-common": { + "version": "2.0.154", + "resolved": "https://registry.npmmirror.com/expensify-common/-/expensify-common-2.0.154.tgz", + "integrity": "sha512-CtzYZFP/n+b390IPEfpXo4GYW7CdaQz0S776NYCABH5LFbF2355N0utD3pB9Z+hKYgru7wGs0sOo7RYiTlmxWg==", + "license": "MIT", + "dependencies": { + "awesome-phonenumber": "^5.4.0", + "classnames": "2.5.0", + "clipboard": "2.0.11", + "html-entities": "^2.5.2", + "jquery": "3.6.0", + "localforage": "^1.10.0", + "lodash": "4.17.21", + "prop-types": "15.8.1", + "react": "16.12.0", + "react-dom": "16.12.0", + "semver": "^7.6.3", + "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", + "ua-parser-js": "^1.0.38" + } + }, + "node_modules/expensify-common/node_modules/react": { + "version": "16.12.0", + "resolved": "https://registry.npmmirror.com/react/-/react-16.12.0.tgz", + "integrity": "sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expensify-common/node_modules/react-dom": { + "version": "16.12.0", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-16.12.0.tgz", + "integrity": "sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.18.0" + }, + "peerDependencies": { + "react": "^16.0.0" + } + }, + "node_modules/expensify-common/node_modules/scheduler": { + "version": "0.18.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.18.0.tgz", + "integrity": "sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/expensify-common/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo": { - "version": "53.0.19", - "resolved": "https://registry.npmjs.org/expo/-/expo-53.0.19.tgz", - "integrity": "sha512-hZWEKw6h5dlfKy6+c3f2exx5x3Loio8p0b2s/Pk1eQfTffqpkQRVVlOzcTWU1RSuMfc47ZMpr97pUJWQczOGsQ==", + "version": "53.0.20", + "resolved": "https://registry.npmjs.org/expo/-/expo-53.0.20.tgz", + "integrity": "sha512-Nh+HIywVy9KxT/LtH08QcXqrxtUOA9BZhsXn3KCsAYA+kNb80M8VKN8/jfQF+I6CgeKyFKJoPNsWgI0y0VBGrA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", @@ -8586,7 +8792,7 @@ "expo-font": "~13.3.2", "expo-keep-awake": "~14.1.4", "expo-modules-autolinking": "2.1.14", - "expo-modules-core": "2.4.2", + "expo-modules-core": "2.5.0", "react-native-edge-to-edge": "1.6.0", "whatwg-url-without-unicode": "8.0.0-3" }, @@ -8672,6 +8878,53 @@ "react-native": "*" } }, + "node_modules/expo-build-properties": { + "version": "0.14.8", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-0.14.8.tgz", + "integrity": "sha512-GTFNZc5HaCS9RmCi6HspCe2+isleuOWt2jh7UEKHTDQ9tdvzkIoWc7U6bQO9lH3Mefk4/BcCUZD/utl7b1wdqw==", + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "semver": "^7.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-build-properties/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/expo-build-properties/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/expo-build-properties/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo-clipboard": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-7.1.5.tgz", @@ -8841,6 +9094,17 @@ "expo": "*" } }, + "node_modules/expo-iap": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/expo-iap/-/expo-iap-2.7.5.tgz", + "integrity": "sha512-+UMLBXKtyoVsfJMQxqGLv4qXeMZzFoOoDMRVJa8OYngDCqfIADkpyNb28HKNZdYiaa0Yq5LHYu42zNkD2m2w0Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-image-loader": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.1.0.tgz", @@ -8979,9 +9243,9 @@ } }, "node_modules/expo-modules-core": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-2.4.2.tgz", - "integrity": "sha512-RCb0wniYCJkxwpXrkiBA/WiNGxzYsEpL0sB50gTnS/zEfX3DImS2npc4lfZ3hPZo1UF9YC6OSI9Do+iacV0NUg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-2.5.0.tgz", + "integrity": "sha512-aIbQxZE2vdCKsolQUl6Q9Farlf8tjh/ROR4hfN1qT7QBGPl1XrJGnaOKkcgYaGrlzCPg/7IBe0Np67GzKMZKKQ==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" @@ -9008,9 +9272,9 @@ } }, "node_modules/expo-router": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-5.1.3.tgz", - "integrity": "sha512-zoAU0clwEj569PpGOzc06wCcxOskHLEyonJhLNPsweJgu+vE010d6XW+yr5ODR6F3ViFJpfcjbe7u3SaTjl24Q==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-5.1.4.tgz", + "integrity": "sha512-8GulCelVN9x+VSOio74K1ZYTG6VyCdJw417gV+M/J8xJOZZTA7rFxAdzujBZZ7jd6aIAG7WEwOUU3oSvUO76Vw==", "license": "MIT", "dependencies": { "@expo/metro-runtime": "5.0.4", @@ -9797,6 +10061,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", + "license": "MIT", + "dependencies": { + "delegate": "^3.1.2" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9968,6 +10241,22 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/html-entities": { + "version": "2.5.3", + "resolved": "https://registry.npmmirror.com/html-entities/-/html-entities-2.5.3.tgz", + "integrity": "sha512-D3AfvN7SjhTgBSA8L1BN4FpPzuEd06uy4lHwSoRWr0lndi9BKaNzPLKGOWZ2ocSGguozr08TTb2jhCLHaemruw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -9984,6 +10273,19 @@ "void-elements": "3.1.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmlparser2": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz", @@ -10214,6 +10516,12 @@ "node": ">=16.x" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", @@ -12001,6 +12309,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jquery": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/jquery/-/jquery-3.6.0.tgz", + "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -12150,6 +12464,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lighthouse-logger": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", @@ -12421,6 +12744,24 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkify-it": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz", + "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==", + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12436,6 +12777,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -12654,6 +13001,37 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz", + "integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "entities": "~2.0.0", + "linkify-it": "^2.0.0", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz", + "integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==", + "license": "BSD-2-Clause" + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -12675,6 +13053,12 @@ "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", "license": "CC0-1.0" }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "license": "MIT" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -14421,6 +14805,23 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qrcode-terminal": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz", @@ -14429,6 +14830,165 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -14738,6 +15298,15 @@ "react-native": "*" } }, + "node_modules/react-native-fit-image": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz", + "integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==", + "license": "Beerware", + "dependencies": { + "prop-types": "^15.5.10" + } + }, "node_modules/react-native-gesture-handler": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.24.0.tgz", @@ -14763,6 +15332,32 @@ "react-native": "*" } }, + "node_modules/react-native-linear-gradient": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.3.tgz", + "integrity": "sha512-KflAXZcEg54PXkLyflaSZQ3PJp4uC4whM7nT/Uot9m0e/qxFV3p6uor1983D1YOBJbJN7rrWdqIjq0T42jOJyA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-markdown-display": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz", + "integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==", + "license": "MIT", + "dependencies": { + "css-to-react-native": "^3.0.0", + "markdown-it": "^10.0.0", + "prop-types": "^15.7.2", + "react-native-fit-image": "^1.5.5" + }, + "peerDependencies": { + "react": ">=16.2.0", + "react-native": ">=0.50.4" + } + }, "node_modules/react-native-modal": { "version": "14.0.0-rc.1", "resolved": "https://registry.npmjs.org/react-native-modal/-/react-native-modal-14.0.0-rc.1.tgz", @@ -14801,6 +15396,22 @@ "react-native-svg": "*" } }, + "node_modules/react-native-qrcode-svg": { + "version": "6.3.15", + "resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.15.tgz", + "integrity": "sha512-vLuNImGfstE8u+rlF4JfFpq65nPhmByuDG6XUPWh8yp8MgLQX11rN5eQ8nb/bf4OB+V8XoLTJB/AZF2g7jQSSQ==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.0", + "qrcode": "^1.5.4", + "text-encoding": "^0.7.0" + }, + "peerDependencies": { + "react": "*", + "react-native": ">=0.63.4", + "react-native-svg": ">=14.0.0" + } + }, "node_modules/react-native-reanimated": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz", @@ -14826,6 +15437,18 @@ "react-native": "*" } }, + "node_modules/react-native-reanimated-carousel": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/react-native-reanimated-carousel/-/react-native-reanimated-carousel-4.0.2.tgz", + "integrity": "sha512-vNpCfPlFoOVKHd+oB7B0luoJswp+nyz0NdJD8+LCrf25JiNQXfM22RSJhLaksBHqk3fm8R4fKWPNcfy5w7wL1Q==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0", + "react-native": ">=0.70.3", + "react-native-gesture-handler": ">=2.9.0", + "react-native-reanimated": ">=3.0.0" + } + }, "node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.7.tgz", @@ -14934,6 +15557,19 @@ "npm": ">=6.0.0" } }, + "node_modules/react-native-view-shot": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz", + "integrity": "sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==", + "license": "MIT", + "dependencies": { + "html2canvas": "^1.4.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.20.0", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz", @@ -15276,6 +15912,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requireg": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz", @@ -15605,6 +16247,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/select": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -15791,6 +16439,12 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -16025,6 +16679,14 @@ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", "license": "MIT" }, + "node_modules/simply-deferred": { + "version": "3.0.0", + "resolved": "git+ssh://git@github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", + "integrity": "sha512-ozF7i2XEEBIcuRW+ThH+wmMeww//KEf1W3MhLHo4aOBmaxvZhBPpKoMCDmvNCIA5HPkkxe6x5XFGkSqHwCd6DQ==", + "engines": { + "node": "*" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -16758,6 +17420,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-encoding": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz", + "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==", + "deprecated": "no longer maintained", + "license": "(Unlicense OR Apache-2.0)" + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -16785,6 +17463,12 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -17205,6 +17889,12 @@ "node": "*" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -17432,6 +18122,15 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", @@ -17647,6 +18346,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -17684,6 +18389,12 @@ "node": ">=0.10.0" } }, + "node_modules/worklet": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/worklet/-/worklet-1.0.3.tgz", + "integrity": "sha512-fU9QogyerNeIu0QBRTAQC1vcOf0NA8eCIJnSRDr+j7N4m+I8OrVd0VBEBhcNEs3rwGl569Hh2NWEOD5lRXlbRg==", + "license": "ISC" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 50c1a34..e9dd680 100644 --- a/package.json +++ b/package.json @@ -14,31 +14,39 @@ "test": "jest" }, "dependencies": { + "@expensify/react-native-live-markdown": "^0.1.299", "@expo/vector-icons": "^14.1.0", + "@react-native-masked-view/masked-view": "0.3.2", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", "@reduxjs/toolkit": "^2.8.2", + "@twotalltotems/react-native-otp-input": "^1.3.11", "@types/p-limit": "^2.2.0", "@types/react-redux": "^7.1.34", - "expo": "53.0.19", + "expensify-common": "^2.0.154", + "expo": "53.0.20", "expo-audio": "~0.4.8", "expo-background-task": "^0.2.8", "expo-blur": "~14.1.5", + "expo-build-properties": "^0.14.8", + "expo-clipboard": "~7.1.5", "expo-constants": "~17.1.6", "expo-dev-client": "~5.2.4", "expo-device": "~7.1.4", "expo-file-system": "~18.1.10", "expo-font": "~13.3.1", "expo-haptics": "~14.1.4", + "expo-iap": "^2.7.5", "expo-image-manipulator": "~13.1.7", "expo-image-picker": "~16.1.4", + "expo-linear-gradient": "~14.1.5", "expo-linking": "~7.1.7", "expo-localization": "^16.1.5", "expo-location": "~18.1.5", "expo-media-library": "~17.1.7", "expo-notifications": "~0.31.4", - "expo-router": "~5.1.3", + "expo-router": "~5.1.4", "expo-secure-store": "~14.2.3", "expo-splash-screen": "~0.30.10", "expo-sqlite": "~15.2.14", @@ -49,6 +57,7 @@ "expo-video": "~2.2.2", "expo-video-thumbnails": "~9.1.3", "expo-web-browser": "~14.2.0", + "html-entities": "2.5.3", "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", @@ -60,21 +69,25 @@ "react-i18next": "^15.5.3", "react-native": "0.79.5", "react-native-gesture-handler": "~2.24.0", + "react-native-linear-gradient": "^2.8.3", + "react-native-markdown-display": "^7.0.2", "react-native-modal": "^14.0.0-rc.1", "react-native-picker-select": "^9.3.1", "react-native-progress": "^5.0.1", + "react-native-qrcode-svg": "^6.3.15", "react-native-reanimated": "~3.17.4", + "react-native-reanimated-carousel": "^4.0.2", "react-native-render-html": "^6.3.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", "react-native-svg": "15.11.2", "react-native-toast-message": "^2.3.0", "react-native-uuid": "^2.0.3", + "react-native-view-shot": "4.0.3", "react-native-web": "~0.20.0", "react-native-webview": "13.13.5", "react-redux": "^9.2.0", - "expo-clipboard": "~7.1.5", - "expo-linear-gradient": "~14.1.5" + "worklet": "^1.0.3" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/provider.tsx b/provider.tsx index 7960a54..5e20661 100644 --- a/provider.tsx +++ b/provider.tsx @@ -1,6 +1,9 @@ import { I18nextProvider } from "react-i18next"; import { Platform } from 'react-native'; +import 'react-native-gesture-handler'; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import 'react-native-reanimated'; import Toast, { BaseToast, ErrorToast, ToastConfig } from 'react-native-toast-message'; import { Provider as ReduxProvider } from "react-redux"; import { AuthProvider } from "./contexts/auth-context"; @@ -70,24 +73,24 @@ const toastConfig: ToastConfig = { export function Provider({ children }: { children: React.ReactNode }) { return ( - - - - - - {children} - - - - - - + + + + + + {children} + + + + + + ); } diff --git a/types/ask.ts b/types/ask.ts index f5365cb..d3164dd 100644 --- a/types/ask.ts +++ b/types/ask.ts @@ -1,4 +1,3 @@ -import { MaterialItem } from "./personal-info"; interface FileInfo { id: string; @@ -34,22 +33,45 @@ export interface Video { video: VideoInfo; video_clips: VideoClip[]; } -export interface Content { - text: string; - image_material_infos?: MaterialItem[]; - video_material_infos?: Video[]; -} -export interface Message { - content: Content; - role: 'User' | 'Assistant'; // 使用联合类型限制 role 的值 - timestamp: string; - askAgain?: Array<{ - id: string; - text: string; - }>; +export interface ContentPart { + type: string; + text?: string; + caption?: string; + url?: string; + id?: string; } +export interface Message { + id: string; + content: string | ContentPart[]; + role: typeof User | typeof Assistant; + timestamp: string; + // askAgain?: Array<{ + // id: string; + // text: string; + // }>; +} + +export function getMessageText(message: Message) { + if (typeof message.content === 'string') { + return message.content; + } else { + return message.content.map((item) => item.text || '').join(''); + } +} + +export function isMessageContainMedia(message: Message) { + if (typeof message.content === 'string') { + return false; + } else { + return message.content.some((item) => item.type === 'image' || item.type === 'video'); + } +} + +export const User = 'user'; +export const Assistant = 'assistant'; + export interface Chat { created_at: string; session_id: string; diff --git a/types/personal-info.ts b/types/personal-info.ts index d1e77e0..317d5d2 100644 --- a/types/personal-info.ts +++ b/types/personal-info.ts @@ -51,4 +51,49 @@ export interface Policy { content: string, created_at: string, updated_at: string -} \ No newline at end of file +} + + +// 订单 +export interface Amount { + amount: string; + currency: string; +} + +export interface OrderItem { + discount_amount: Amount; + id: string; + product_code: string; + product_id: number; + product_name: string; + product_type: string; + quantity: number; + total_price: Amount; + unit_price: Amount; +} + +// 订单 +export interface CreateOrder { + created_at: string; + expired_at: string; + id: string; + items: OrderItem[]; + payment_info: any | null; // 使用 any 或更具体的类型 + status: string; + total_amount: Amount; + updated_at: string; + user_id: string; +} + +// 创建订单 +export interface PayOrder { + created_at: string; + id: string; + paid_at: string; + payment_amount: Amount; + payment_method: string; + payment_status: string; + third_party_transaction_id: string; + transaction_id: string; + updated_at: string; +} diff --git a/types/upload.ts b/types/upload.ts index e69de29..6acb4a5 100644 --- a/types/upload.ts +++ b/types/upload.ts @@ -0,0 +1,19 @@ +// 重新导出 lib/background-uploader/types.ts 中的类型 +export { + ExifData, + defaultExifData, + ImagesuploaderProps as ImagesPickerProps, + FileUploadItem, + ConfirmUpload, + UploadResult, + UploadUrlResponse, +} from '@/lib/background-uploader/types'; + +// 文件状态类型 +export interface FileStatus { + file: File | null; + status: 'pending' | 'uploading' | 'success' | 'error'; + progress: number; + error?: string; + id?: string; +} \ No newline at end of file diff --git a/types/user.ts b/types/user.ts index ff1665d..bb3a291 100644 --- a/types/user.ts +++ b/types/user.ts @@ -12,7 +12,7 @@ export interface User { avatar_file_url?: string } -interface UserCountData { +export interface UserCountData { video_count: number, photo_count: number, live_count: number, @@ -31,7 +31,7 @@ export interface CountData { } } -interface Counter { +export interface Counter { user_id: number, total_count: UserCountData, category_count: { diff --git a/types/works.ts b/types/works.ts deleted file mode 100644 index 1f0c293..0000000 --- a/types/works.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { MaterialItemWithMetadata } from "@/components/waterfall"; - -// 模板列表 list -export interface NodeTemplates { - id: string; - node_template_id: string - name: string; - description: string; - model: string; - prompt: string; - created_at: string; - updated_at: string; - current_version_id: number; - version: number; - is_current_version: boolean -} - -//模板详情 -export interface NodeTemplateDetail extends NodeTemplates { - material_infos: MaterialItemWithMetadata[] - example_material_ids: string[] - available_model_names: string[] -} - -// 更新模板 -export interface UpdateNodeTemplate { - example_material_ids: string[]; -} - -export interface Shot { - camera_direction: string; - camera_movement: string; - clip_id: number; - composition: string; - music_description: string; - perspective: string; - scene_name: string; - scene_sequence: number; - shot_description: string; - shot_duration: string; - shot_name: string; - shot_sequence: number; - shot_sizes: string; - sound_effect: string; - transition_in: string; - voice_over: string; -} -// 图片的debug -interface CaptionResult { - video_shots?: Shot[]; - caption_result: { - common: { - background: string; - }; - daily_life: { - activity: string; - atmosphere: string; - extra_elements: string; - people_presence: string; - scene: string; - }; - person: { - person_count: string; - person_details: Array<{ - action: string; - age: string; - expression: string; - gender: string; - mood: string; - }>; - person_identity: string; - scene: string; - }; - }; -} -export interface PhotosDebug { - id: string, - state: string, - name: string, - context: { - input: Object, - outputs: CaptionResult, - metadata: Object, - } - -} - -// prompt需要传参 -export interface PromptParams { - name: string, - value_type: string, - required: boolean, - description: string -} - -// input output -export interface InputOutput { - is_async: boolean, - name: string, - context: { - metadata: {}, - outputs: { raw_output?: JSON, metadata?: {} }, - inputs: { - material_id: string, - prompt: string - model_name: string - } - } -}