Merge branch 'v_1.0.1'
Some checks failed
Dev Deploy / Explore-Gitea-Actions (push) Failing after 1m27s

This commit is contained in:
jinyaqiu 2025-08-06 19:15:07 +08:00
commit 5befe1ab8f
153 changed files with 9899 additions and 2762 deletions

2
.bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
[install]
registry = "https://registry.npmmirror.com/"

1
.env
View File

@ -1 +0,0 @@
API_ENDPOINT=http://192.168.31.115:18080/api

2
.gitignore vendored
View File

@ -41,3 +41,5 @@ app-example
# Expo prebuild generated files
android/
ios/
build*

View File

@ -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"
@ -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"

View File

@ -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<NodeJS.Timeout | number>(null);
const isMounted = useRef(true);
const [token, setToken] = useState('');
const [wsStatus, setWsStatus] = useState<WebSocketStatus>('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 (
<Tabs
screenOptions={{
@ -282,40 +305,54 @@ export default function TabLayout() {
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* 隐私协议 */}
<Tabs.Screen
name="privacy-policy"
options={{
title: 'privacy-policy',
tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* Support Screen */}
<Tabs.Screen
name="support"
options={{
title: t('tabTitle', { ns: 'support' }),
tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* Debug Screen - only in development */}
{process.env.NODE_ENV === 'development' && (
<Tabs.Screen
name="debug"
options={{
title: 'Debug',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'bug' : 'bug-outline'} color={color} />
),
}}
/>
)}
{/* Debug Screen - only in development */}
{process.env.NODE_ENV === 'development' && (
{/* 下载页面 */}
<Tabs.Screen
name="debug"
name="download"
options={{
title: 'Debug',
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? 'bug' : 'bug-outline'} color={color} />
),
title: 'download',
tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
)}
</Tabs >
{/* 购买权益页面 */}
<Tabs.Screen
name="rights"
options={{
title: 'rights',
tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* 设置页面 */}
<Tabs.Screen
name="setting"
options={{
title: 'setting',
tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
</Tabs >
<AskNavbar wsStatus={wsStatus} />
</>
);
}

View File

@ -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<ScrollView>(null);
const chatListRef = useRef<FlatList>(null);
const [isHello, setIsHello] = useState(true);
const [conversationId, setConversationId] = useState<string | null>(null);
const [userMessages, setUserMessages] = useState<Message[]>([]);
// 选择图片
const [selectedImages, setSelectedImages] = useState<string[]>([]);
// 动画值
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 (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* 导航栏 */}
<View style={[styles.navbar, isHello && styles.hiddenNavbar]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.push('/memo-list')}
>
<ReturnArrow />
</TouchableOpacity>
<ThemedText style={styles.title}>MemoWake</ThemedText>
<View style={styles.placeholder} />
</View>
useEffect(() => {
if (!isHello) {
// 不再自动关闭键盘,让用户手动控制
// 这里可以添加其他需要在隐藏hello界面时执行的逻辑
scrollToEnd(false);
}
}, [isHello]);
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
enabled={!isHello}
>
<View style={styles.contentContainer}>
useFocusEffect(
useCallback(() => {
if (!sessionId) {
setIsHello(true);
setUserMessages([])
}
}, [sessionId])
);
return (
<GestureDetector gesture={gesture}>
<View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
{/* 导航栏 */}
<View style={[styles.navbar, { borderBottomWidth: isHello ? 0 : 1 }]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => {
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');
}}
>
<ReturnArrow />
</TouchableOpacity>
<ThemedText style={[styles.title, { opacity: isHello ? 0 : 1 }]} onPress={() => { router.push('/owner') }}>MemoWake</ThemedText>
<View style={styles.placeholder} />
</View>
<View style={[styles.contentContainer, { marginTop: isHello ? -24 : 0 }]}>
{/* 欢迎页面 */}
<Animated.View
style={[
styles.absoluteView,
{
opacity: fadeAnim,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'auto' : 'none',
zIndex: 1
}
]}
>
<AskHello />
<AskHello setUserMessages={setUserMessages} setConversationId={setConversationId} setIsHello={setIsHello} />
</Animated.View>
{/* 聊天页面 */}
@ -137,57 +298,64 @@ export default function AskScreen() {
styles.absoluteView,
{
opacity: fadeAnimChat,
// 使用 pointerEvents 控制交互
pointerEvents: isHello ? 'none' : 'auto',
zIndex: 0
}
]}
>
<Chat userMessages={userMessages} sessionId={sessionId} setSelectedImages={setSelectedImages} selectedImages={selectedImages} />
<Chat
ref={chatListRef}
userMessages={userMessages}
sessionId={sessionId}
setSelectedImages={setSelectedImages}
selectedImages={selectedImages}
style={styles.chatContainer}
contentContainerStyle={styles.chatContentContainer}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => scrollToEnd()}
/>
</Animated.View>
</View>
{/* 输入框 */}
<View style={styles.inputContainer}>
<SendMessage
setIsHello={setIsHello}
setUserMessages={setUserMessages}
setConversationId={setConversationId}
conversationId={conversationId}
selectedImages={selectedImages}
setSelectedImages={setSelectedImages}
/>
</View>
</KeyboardAvoidingView>
</View>
{/* 输入框区域 */}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={0} >
<View style={styles.inputContainer} key={conversationId}>
<SendMessage
setIsHello={setIsHello}
conversationId={conversationId}
setConversationId={setConversationId}
setUserMessages={setUserMessages}
selectedImages={selectedImages}
setSelectedImages={setSelectedImages}
/>
</View>
</KeyboardAvoidingView>
</View >
</GestureDetector>
);
}
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',
},
});

View File

@ -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 (
<View className="flex-1 bg-bgPrimary justify-center items-center">
<Text className="text-white">loading...</Text>
</View>
);
}
return (
<LinearGradient
colors={['#FFB645', '#E2793F']}
className="flex-1 items-center justify-center p-6"
>
<View className="absolute top-0 left-0 w-full h-full">
<MemoIP width="100%" height="100%" style={{ opacity: 0.1 }} />
</View>
<View className="items-center mb-12">
<Text className="text-white text-5xl font-extrabold tracking-tight">
MemoWake
</Text>
<Text className="text-white/90 text-lg mt-4 text-center max-w-xs">
{t('desc', { ns: 'download' })}
</Text>
</View>
<View style={{ flex: 1 }}>
{
platform === 'pc' && <PCDownload IOS_APP_STORE_URL={IOS_APP_STORE_URL} ANDROID_APK_URL={ANDROID_APK_URL} />
}
{
(platform === 'ios' || platform === 'android') && (
<AppDownload IOS_APP_STORE_URL={IOS_APP_STORE_URL} ANDROID_APK_URL={ANDROID_APK_URL} platform={platform} />
)
}
</View>
<View className="w-full max-w-xs">
<TouchableOpacity
className="bg-white/90 rounded-xl px-6 py-4 flex-row items-center justify-center shadow-lg mb-5"
onPress={handleIOSDownload}
activeOpacity={0.8}
>
<AppleLogo width={24} height={24} fill="black" />
<Text className="text-black font-bold text-lg ml-3">
{t('ios', { ns: 'download' })}
</Text>
</TouchableOpacity>
<TouchableOpacity
className="bg-black/80 rounded-xl px-6 py-4 flex-row items-center justify-center shadow-lg"
onPress={handleAndroidDownload}
activeOpacity={0.8}
>
<AndroidLogo width={24} height={24} fill="#3DDC84" />
<Text className="text-white font-bold text-lg ml-3">
{t('android', { ns: 'download' })}
</Text>
</TouchableOpacity>
</View>
</LinearGradient>
);
)
}

View File

@ -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<Animated.CompositeAnimation | null>(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<Animated.CompositeAnimation | null>(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<void>((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<void>((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<void>((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 (
<View className="flex-1 bg-bgPrimary justify-center items-center">
<Text className="text-white">...</Text>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t('common.loading')}</Text>
</View>
);
}
return (
<View className="flex-1">
<View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem] " style={{ paddingTop: insets.top + 48 }}>
<View style={styles.container}>
<View style={[styles.contentContainer, { paddingTop: insets.top + 16 }]}>
{/* 标题区域 */}
<View className="mb-10 w-full px-5">
<Text className="text-white text-3xl font-bold mb-3 text-left">
<View style={styles.headerContainer}>
<Animated.Text
style={[
styles.titleText,
{
opacity: textAnimations.line1, transform: [{
translateY: textAnimations.line1.interpolate({
inputRange: [0, 1],
outputRange: [10, 0]
})
}]
}
]}
>
{t('auth.welcomeAwaken.awaken', { ns: 'login' })}
{"\n"}
</Animated.Text>
<Animated.Text
style={[
styles.titleText,
{
opacity: textAnimations.line2, transform: [{
translateY: textAnimations.line2.interpolate({
inputRange: [0, 1],
outputRange: [10, 0]
})
}]
}
]}
>
{t('auth.welcomeAwaken.your', { ns: 'login' })}
{"\n"}
</Animated.Text>
<Animated.Text
style={[
styles.titleText,
{
opacity: textAnimations.line3, transform: [{
translateY: textAnimations.line3.interpolate({
inputRange: [0, 1],
outputRange: [10, 0]
})
}]
}
]}
>
{t('auth.welcomeAwaken.pm', { ns: 'login' })}
</Text>
<Text className="text-white/85 text-base text-left">
</Animated.Text>
<Animated.Text
style={[
styles.subtitleText,
{
opacity: textAnimations.subtitle,
transform: [{
translateY: textAnimations.subtitle.interpolate({
inputRange: [0, 1],
outputRange: [10, 0]
})
}]
}
]}
>
{t('auth.welcomeAwaken.slogan', { ns: 'login' })}
</Text>
</Animated.Text>
</View>
{/* 欢迎语 */}
<View style={{ alignItems: 'flex-end' }}>
<Animated.View
style={[{
height: screenWidth * 0.3,
width: screenWidth * 0.3,
marginTop: -screenWidth * 0.08,
opacity: fadeInAnim,
transform: [{
translateY: fadeInAnim.interpolate({
inputRange: [0, 1],
outputRange: [20, 0]
})
}]
}]}
>
<Image
source={require('@/assets/images/png/icon/think.png')}
style={{
width: '100%',
height: '100%',
resizeMode: 'contain'
}}
/>
</Animated.View>
</View>
{/* Memo 形象区域 */}
<View className="items-center">
<IP />
{/* Animated IP */}
<View style={styles.ipContainer}>
<Animated.View style={[styles.ipWrapper, { transform: [{ rotate }] }]}>
<Image
source={require('@/assets/images/png/icon/ip.png')}
style={{ width: screenWidth * 0.9, marginBottom: - screenWidth * 0.18, marginTop: -screenWidth * 0.22 }}
/>
</Animated.View>
</View>
{/* 介绍文本 */}
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
<Animated.Text
style={[
styles.descriptionText,
{
opacity: descriptionAnim,
transform: [{
translateY: descriptionAnim.interpolate({
inputRange: [0, 1],
outputRange: [20, 0]
})
}]
}
]}
>
{t('auth.welcomeAwaken.gallery', { ns: 'login' })}
{"\n"}
{t('auth.welcomeAwaken.back', { ns: 'login' })}
</Text>
</Animated.Text>
{/* 唤醒按钮 */}
<TouchableOpacity
className="bg-white rounded-full px-10 py-4 shadow-[0_2px_4px_rgba(0,0,0,0.1)] w-full items-center"
onPress={async () => {
router.push('/login')
<Animated.View
style={{
alignItems: "center",
opacity: buttonAnim,
transform: [
{
translateY: buttonAnim.interpolate({
inputRange: [0, 1],
outputRange: [20, 0]
})
},
{
translateX: buttonShakeAnim.interpolate({
inputRange: [-1, 0, 1],
outputRange: [-5, 0, 5]
})
}
]
}}
activeOpacity={0.8}
>
<Text className="text-[#4C320C] font-bold text-lg">
{t('auth.welcomeAwaken.awake', { ns: 'login' })}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.awakenButton}
onPress={async () => {
router.push('/login');
}}
activeOpacity={0.8}
>
<Text style={styles.buttonText}>
{t('auth.welcomeAwaken.awake', { ns: 'login' })}
</Text>
</TouchableOpacity>
</Animated.View>
</View>
</View >
</View>
);
}
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,
},
});

View File

@ -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<string>('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 (
<KeyboardAvoidingView
style={{ flex: 1 }}
@ -64,48 +69,52 @@ const LoginScreen = () => {
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
flexGrow: 1
}}
keyboardShouldPersistTaps="handled"
bounces={false}
>
<ThemedView className="flex-1 bg-bgPrimary justify-end">
<View style={{ width: "100%", alignItems: "center", marginTop: insets.top + 8 }}>
<ThemedText style={{ fontSize: 20, fontWeight: 'bold', color: "#fff" }}>Awake your Memo</ThemedText>
</View>
<View className="flex-1">
<View
className="absolute left-1/2 z-10"
style={{
top: containerHeight > 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)
?
<LoginIP2 />
<Image source={require('@/assets/images/png/icon/ipNoHandsEyes.png')} />
:
<LoginIP1 />
<Image source={require('@/assets/images/png/icon/ipNoHands.png')} />
}
</View>
<View
className="absolute left-1/2 z-[1000] -translate-x-[39.5px] -translate-y-[4px]"
style={{
top: containerHeight > 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 }]
}}
>
<Handers />
</View>
</View>
<ThemedView
className="w-full bg-white pt-12 px-6 relative z-20 shadow-lg pb-5"
className="w-full bg-white pt-4 px-6 relative z-20 shadow-lg pb-5"
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderTopLeftRadius: 50,
borderTopRightRadius: 50,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 5
elevation: 5,
paddingBottom: insets.bottom
} as ViewStyle}
onLayout={handleLayout}
>
@ -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}
/>
// <PhoneLogin {...commonProps} />
),
code: (
<PhoneLogin {...commonProps} />
)
};
return components[status as keyof typeof components] || components.login;
})()}
{status == 'login' || !status &&
<View className="flex-row justify-center mt-2">
<ThemedText className="text-sm !text-textPrimary">
{status === 'login' || !status ? t('auth.agree.logintext', { ns: 'login' }) : t('auth.agree.singupText', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => { }}>
<ThemedText className="text-sm font-semibold ml-1 !text-textPrimary underline">
{t('auth.agree.terms', { ns: 'login' })}
<View style={{ width: "100%", alignItems: "center", marginTop: 16 }}>
{status == 'login' || !status &&
<View className="flex-row justify-center mt-2 flex-wrap w-[85%] items-center">
<ThemedText style={{ fontSize: 11, color: "#FFB645" }}>
{status === 'login' || !status ? t('auth.agree.logintext', { ns: 'login' }) : t('auth.agree.singupText', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t('auth.agree.join', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => { }}>
<ThemedText className="!text-textPrimary underline text-sm font-semibold ml-1">
{t('auth.agree.privacyPolicy', { ns: 'login' })}
<TouchableOpacity onPress={() => { setModalVisible(true); setModalType('terms') }}>
<ThemedText style={{ fontSize: 11, color: "#FFB645", textDecorationLine: 'underline' }}>
{t('auth.agree.terms', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText style={{ fontSize: 11, color: "#FFB645", flexWrap: 'wrap' }}>
{t('auth.agree.join', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
}
<TouchableOpacity onPress={() => { setModalVisible(true); setModalType('privacy') }}>
<ThemedText style={{ fontSize: 11, color: "#FFB645", textDecorationLine: 'underline' }}>
{t('auth.agree.privacyPolicy', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
}
</View>
<PrivacyModal modalVisible={modalVisible} setModalVisible={setModalVisible} type={modalType} />
</ThemedView>
</ThemedView>
</ScrollView>

View File

@ -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<Chat[]>([]);
import SkeletonItem from '@/components/memo/SkeletonItem';
// 获取历史消息
const getHistoryList = async () => {
await fetchApi<Chat[]>(`/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 = () => (
<View style={styles.skeletonContainer}>
{Array(5).fill(0).map((_, index) => (
<SkeletonItem key={`skeleton-${index}`} />
))}
</View>
);
const MemoList = () => {
const router = useRouter();
const insets = useSafeAreaInsets();
const [isMounted, setIsMounted] = useState(false);
const [historyList, setHistoryList] = useState<Chat[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const flatListRef = useRef<FlatList>(null);
const { t } = useTranslation();
// 从缓存或API获取数据
const fetchHistoryList = useCallback(async (forceRefresh = false) => {
try {
setIsLoading(true);
// 先检查缓存
const cachedData = getCachedData<Chat[]>('/chats');
if (cachedData && !forceRefresh) {
setHistoryList(cachedData);
}
// 总是从服务器获取最新数据
const data = await fetchApi<Chat[]>('/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' && <TouchableOpacity
className='mt-2 bg-red-500 items-center h-10 justify-center'
onPress={() => router.push('/debug')}
>
<Text className="text-white">
db调试页面
</Text>
</TouchableOpacity>} */}
// 主数据加载
await fetchHistoryList();
} catch (error) {
console.error('初始化失败:', error);
} finally {
if (isActive) {
setIsMounted(true);
// 延迟隐藏启动画面
setTimeout(SplashScreen.hideAsync, 500);
}
}
};
{/* 顶部标题和上传按钮 */}
<View style={styles.header}>
<Text style={styles.title}>Memo List</Text>
</View>
initialize();
{/* 上传进度展示区域 */}
{uploadSessionStartTime && progressInfo.total > 0 && (
<View className="h-10 mt-6 mb-2 mx-4">
<UploaderProgress
imageUrl={progressInfo.image}
uploadedCount={progressInfo.completed}
totalCount={progressInfo.total}
/>
</View>
)}
</>
return () => {
isActive = false;
};
}, [fetchHistoryList]);
// 每次进入页面就刷新
useFocusEffect(
useCallback(() => {
handleRefresh();
}, [])
);
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* <View className="w-full h-full">
<AutoUploadScreen />
</View> */}
// 渲染列表项
const renderItem = useCallback(({ item }: { item: Chat }) => (
<TouchableOpacity
style={styles.memoItem}
onPress={() => handleMemoPress(item)}
activeOpacity={0.7}
>
<ChatSvg />
<View style={styles.memoContent}>
<Text
style={styles.memoTitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.title || t('ask:ask.unNamed')}
</Text>
<Text
style={styles.memoSubtitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{(item.latest_message && getMessageText(item.latest_message)) || t('ask:ask.noMessage')}
</Text>
</View>
</TouchableOpacity>
), [handleMemoPress]);
{/* 历史对话 */}
<FlatList
ListHeaderComponent={renderHeader}
data={historyList}
keyExtractor={(item) => item.session_id}
ItemSeparatorComponent={() => (
<View style={styles.separator} />
)}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.memoItem}
onPress={() => handleMemoPress(item)}
>
<View className="w-[3rem] h-[3rem] z-1">
<ChatSvg
width="100%"
height="100%"
preserveAspectRatio="xMidYMid meet"
/>
</View>
<View style={styles.memoContent}>
<Text
style={styles.memoTitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.title || 'memo list 历史消息'}
</Text>
<Text
style={styles.memoTitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.latest_message?.content?.text || 'memo list 历史消息'}
</Text>
</View>
</TouchableOpacity>
)}
/>
{/* 底部导航栏 */}
<AskNavbar />
// 渲染列表头部
const renderHeader = useCallback(() => (
<View style={styles.headerContainer}>
<Text style={styles.title}>
{t('ask:ask.memoList')}
</Text>
{/* 上传进度 */}
{/* <UploadProgressSection /> */}
</View>
), [insets.top]);
// 上传进度组件
const UploadProgressSection = () => {
const { progressInfo, uploadSessionStartTime } = useUploadManager();
if (!uploadSessionStartTime || progressInfo.total <= 0) {
return null;
}
return (
<View className="h-10 mt-2 mb-2 mx-4">
<UploaderProgress
imageUrl={progressInfo.image}
uploadedCount={progressInfo.completed}
totalCount={progressInfo.total}
/>
</View>
);
};
// 空状态
const renderEmptyComponent = useCallback(() => (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{t('ask:ask.noChat')}</Text>
<TouchableOpacity
style={styles.refreshButton}
onPress={handleRefresh}
disabled={isRefreshing}
>
<Text style={styles.refreshText}>
{isRefreshing ? t('ask:ask.loading') : t('ask:ask.refresh')}
</Text>
</TouchableOpacity>
</View>
), [handleRefresh, isRefreshing]);
// 如果组件未完全加载,显示骨架屏
if (!isMounted) {
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<SkeletonList />
</View>
);
}
return (
<ErrorBoundary>
<View style={[styles.container, { paddingTop: insets.top }]}>
<FlatList
ref={flatListRef}
data={historyList}
renderItem={renderItem}
keyExtractor={(item) => item.session_id}
ListHeaderComponent={renderHeader}
ListEmptyComponent={!isLoading ? renderEmptyComponent : null}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
colors={['#FFB645']}
tintColor="#FFB645"
progressBackgroundColor="#ffffff"
/>
}
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={() => <View style={styles.separator} />}
/>
</View>
</ErrorBoundary>
);
};
// 使用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, // 添加一些上边距
},
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',
refreshText: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
},
});
export default MemoList;

View File

@ -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<CountData>({} as CountData);
@ -52,24 +47,64 @@ export default function OwnerPage() {
// 获取用户信息
const [userInfoDetails, setUserInfoDetails] = useState<UserInfoDetails>({} 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 (
<View style={[styles.container, { paddingTop: insets.top }]}>
<SkeletonOwner />
</View>
);
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
@ -83,54 +118,29 @@ export default function OwnerPage() {
{/* 用户信息 */}
<UserInfo userInfo={userInfoDetails} />
{/* 设置栏 */}
<AlbumComponent setModalVisible={setModalVisible} />
{/* 资源数据 */}
<View style={styles.resourceContainer}>
<View style={{ gap: 16 }}>
<ResourceComponent title={t("generalSetting.usedStorage", { ns: "personal" })} data={{ all: userInfoDetails.total_bytes, used: countData.used_bytes }} icon={<UsedStorageSvg />} isFormatBytes={true} />
<ResourceComponent title={t("generalSetting.remainingPoints", { ns: "personal" })} data={{ all: userInfoDetails.total_points, used: userInfoDetails.remain_points }} icon={<PointsSvg />} />
</View>
</View>
{/* 数据统计 */}
<CountComponent
data={[{ title: t("generalSetting.totalVideo", { ns: "personal" }), number: countData?.counter?.total_count?.video_count || 0 }, { title: t("generalSetting.totalPhoto", { ns: "personal" }), number: countData?.counter?.total_count?.photo_count || 0 }, { title: t("generalSetting.live", { ns: "personal" }), number: countData?.counter?.total_count?.live_count || 0 }, { title: t("generalSetting.videoLength", { ns: "personal" }), number: formatDuration(countData?.counter?.total_count?.video_length || 0) }]}
/>
{/* 会员卡 */}
<MemberCard pro={userInfoDetails?.membership_level} />
{/* 分类 */}
<View style={{ height: 145 }}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 16 }} >
{countData?.counter?.category_count && Object.entries(countData?.counter?.category_count).map(([key, value], index) => {
return (
<CategoryComponent
key={index}
title={key}
data={[{ title: 'Video', number: value.video_count }, { title: 'Photo', number: value.photo_count }, { title: 'Length', number: formatDuration(value.video_length || 0) }]}
bgSvg={value.cover_url}
style={{ aspectRatio: 1, flex: 1 }}
/>
)
})}
</ScrollView>
<View style={{ marginHorizontal: -16, marginBottom: -16 }}>
<CarouselComponent data={userInfoDetails?.material_counter} />
</View>
{/* 作品数据 */}
<View className='flex flex-row justify-between gap-[1rem]'>
<CreateCountComponent title={t("generalSetting.storiesCreated", { ns: "personal" })} icon={<StoriesSvg />} number={userInfoDetails.stories_count} />
<CreateCountComponent title={t("generalSetting.conversationsWithMemo", { ns: "personal" })} icon={<ConversationsSvg />} number={userInfoDetails.conversations_count} />
<CreateCountComponent title={t("generalSetting.storiesCreated", { ns: "personal" })} icon={<StoriesSvg width={30} height={30} />} number={userInfoDetails.stories_count} />
<CreateCountComponent title={t("generalSetting.conversationsWithMemo", { ns: "personal" })} icon={<ConversationsSvg width={30} height={30} />} number={userInfoDetails.conversations_count} />
</View>
{/* 排行榜 */}
<Ranking data={userInfoDetails.title_rankings} />
</View>
}
// 优化性能:添加 getItemLayout
getItemLayout={(data, index) => (
{ length: 1000, offset: 1000 * index, index }
)}
/>
{/* 设置弹窗 */}
<SettingModal modalVisible={modalVisible} setModalVisible={setModalVisible} userInfo={userInfoDetails.user_info} />
{/* 导航栏 */}
<AskNavbar />
</View>
);
}
@ -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',

View File

@ -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<Policy>({} as Policy);
const insets = useSafeAreaInsets();
useEffect(() => {
const loadArticle = async () => {
fetchApi<Policy>(`/system-config/policy/privacy_policy`).then((res: any) => {
@ -26,7 +28,7 @@ const PrivacyPolicy = () => {
}
return (
<View style={styles.centeredView}>
<View style={[styles.centeredView, { paddingTop: insets.top, marginBottom: insets.bottom }]}>
<View style={styles.modalView}>
<View style={styles.modalHeader}>
<Text style={{ opacity: 0 }}>Settings</Text>
@ -36,14 +38,9 @@ const PrivacyPolicy = () => {
</TouchableOpacity>
</View>
<ScrollView style={styles.modalContent} showsVerticalScrollIndicator={false}>
<RenderHtml
source={{ html: article.content }}
tagsStyles={{
p: { fontSize: 16, lineHeight: 24 },
strong: { fontWeight: 'bold' },
em: { fontStyle: 'italic' },
}}
/>
<Markdown>
{article.content}
</Markdown>
</ScrollView>
</View>
</View>
@ -86,6 +83,7 @@ const styles = StyleSheet.create({
},
modalContent: {
flex: 1,
paddingHorizontal: 8
},
modalText: {
fontSize: 16,

View File

@ -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 (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1 bg-white"
style={styles.container}
>
<ScrollView contentContainerClassName="flex-grow justify-center p-5">
<ThemedView className="w-full max-w-[400px] self-center p-5 rounded-xl bg-white">
<ThemedText className="text-2xl font-bold mb-6 text-center text-gray-800">
<ScrollView contentContainerStyle={styles.scrollContainer}>
<ThemedView style={styles.formContainer}>
<ThemedText style={styles.title}>
{t('auth.resetPwd.title', { ns: 'login' })}
</ThemedText>
{error ? (
<ThemedText className="text-red-500 mb-4 text-center">
<ThemedText style={styles.errorText}>
{error}
</ThemedText>
) : null}
<View className="mb-6">
<View className="flex-row items-center border border-gray-200 rounded-lg px-3">
<View style={styles.inputContainer}>
<View style={styles.passwordInputContainer}>
<TextInput
className="flex-1 h-12 text-gray-800"
placeholder={t('auth.login.passwordPlaceholder', { ns: 'login' })}
placeholderTextColor="#999"
style={[styles.input, { flex: 1 }]}
placeholder={t('auth.signup.confirmPasswordPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={password}
onChangeText={setPassword}
onChangeText={(value) => {
setPassword(value)
}}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className="p-2"
style={styles.eyeIcon}
>
<Ionicons
name={showPassword ? 'eye-off' : 'eye'}
name={showPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
<View className="flex-row items-center border border-gray-200 rounded-lg px-3 mt-4">
<View style={styles.passwordInputContainer}>
<TextInput
className="flex-1 h-12 text-gray-800"
style={[styles.input, { flex: 1 }]}
placeholder={t('auth.signup.confirmPasswordPlaceholder', { ns: 'login' })}
placeholderTextColor="#999"
placeholderTextColor="#ccc"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSubmit}
onChangeText={(value) => {
setConfirmPassword(value)
}}
secureTextEntry={!showSecondPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className="p-2"
onPress={() => setShowSecondPassword(!showSecondPassword)}
style={styles.eyeIcon}
>
<Ionicons
name={showPassword ? 'eye-off' : 'eye'}
name={showSecondPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
className={`w-full py-4 rounded-lg items-center justify-center ${loading ? 'bg-orange-400' : 'bg-[#E2793F]'}`}
style={[styles.submitButton, loading && styles.submitButtonDisabled]}
onPress={handleSubmit}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText className="text-white text-base font-semibold">
<ThemedText style={styles.submitButtonText}>
{t('auth.resetPwd.resetButton', { ns: 'login' })}
</ThemedText>
)}
@ -157,6 +153,87 @@ const resetPassword = () => {
</ScrollView>
</KeyboardAvoidingView>
);
}
};
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;

461
app/(tabs)/rights.tsx Normal file
View File

@ -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<boolean>(false);
// 用户选择购买的loading
const [confirmLoading, setConfirmLoading] = useState<boolean>(false);
// 选择购买方式
const [payChoice, setPayChoice] = useState<'ApplePay'>('ApplePay');
// 普通用户,会员
const [userType, setUserType] = useState<'normal' | 'premium'>('normal');
// 选择权益方式
const [payType, setPayType] = useState<string>('');
// 用户协议弹窗打开
const [showTerms, setShowTerms] = useState<boolean>(false);
// 调接口获取支付信息
const [premiumPay, setPremiumPay] = useState<PayItem[]>();
const [loading, setLoading] = useState<boolean>(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 (
<View style={{ flex: 1 }}>
{/* 整个页面的中间添加一个loading */}
{confirmLoading && (
<View style={[styles.loadingContainer, { top: insets.top + 60 }]}>
<View style={styles.loadingContent}>
<ActivityIndicator size="large" color="#AC7E35" />
<ThemedText style={{ color: '#AC7E35', fontSize: 14, fontWeight: '700' }}>
{t('personal:rights.confirmLoading')}
</ThemedText>
</View>
</View>
)}
<ScrollView style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom + 80 }]}>
{/* 导航栏 */}
<View
style={styles.header}
>
<TouchableOpacity onPress={() => { router.push('/owner'); setConfirmLoading(false) }} style={{ padding: 16 }}>
<ReturnArrowSvg />
</TouchableOpacity>
<ThemedText style={styles.headerTitle}>
{t('rights.title', { ns: 'personal' })}
</ThemedText>
<ThemedText className='opacity-0'>123</ThemedText>
</View>
{/* 会员卡 */}
<View style={styles.card}>
{userType === 'normal' ? (
<Image source={require('@/assets/images/png/owner/normal.png')} style={{ height: 150, objectFit: 'cover', width: '100%' }} />
) : (
<Image source={require('@/assets/images/png/owner/pro.png')} style={{ height: 150, objectFit: 'cover', width: '100%' }} />
)}
<View style={styles.cardContent}>
<View style={styles.cardinfo}>
<ThemedText style={styles.cardTitle}>
{t('rights.purchase', { ns: 'personal' })}
</ThemedText>
<View style={styles.cardPoints}>
<StarSvg />
<ThemedText style={styles.cardPointsText}>{pro}</ThemedText>
</View>
</View>
</View>
</View>
{/* 会员信息 */}
<View style={styles.info}>
{/* 切换按钮 */}
<View style={styles.switchButton}>
<TouchableOpacity
onPress={() => { setUserType("normal") }}
style={[styles.switchButtonItem, { backgroundColor: userType === 'normal' ? "#FFB645" : "#fff", borderColor: userType === 'normal' ? "#FFB645" : "#E2793F" }]}
>
<ThemedText style={{ color: userType === 'normal' ? "#fff" : "#E2793F" }}> {t('rights.free', { ns: 'personal' })}</ThemedText>
</TouchableOpacity>
<TouchableOpacity
onPress={() => { setUserType("premium") }}
style={[styles.switchButtonItem, { backgroundColor: userType === 'premium' ? "#E2793F" : "#fff", borderColor: userType === 'premium' ? "#E2793F" : "#E2793F" }]}
>
<ThemedText style={{ color: userType === 'premium' ? "#fff" : "#E2793F" }}>{t('rights.premium', { ns: 'personal' })}</ThemedText>
</TouchableOpacity>
</View>
{/* 普通权益 */}
<Normal setUserType={setUserType} style={{ display: userType === 'normal' ? "flex" : "none" }} />
{/* 会员权益 */}
<Premium setPayType={setPayType} setShowTerms={setShowTerms} payType={payType} premiumPay={premiumPay} loading={loading} style={{ display: userType === 'normal' ? "none" : "flex" }} />
</View>
{/* 支付方式 */}
{/* <PayTypeModal setConfirmPay={setConfirmPay} modalVisible={showPayType} setModalVisible={setShowPayType} payChoice={payChoice} setPayChoice={setPayChoice} /> */}
{/* 会员权益信息 */}
<View style={{ flex: 1, marginBottom: 120 }}>
<ProRights style={{ display: userType === 'normal' ? "none" : "flex" }} />
</View>
</ScrollView>
{/* 付费按钮 */}
<View style={{
padding: 16,
paddingBottom: 32,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#eee',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: userType === 'normal' ? "none" : "flex"
}}>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', marginLeft: 8, marginBottom: 8 }}>
<TouchableOpacity onPress={() => { setAgree(!agree) }} activeOpacity={0.8}>
<View style={[styles.agree, { backgroundColor: agree ? '#FFB645' : '#fff', borderColor: agree ? '#FFB645' : '#AC7E35' }]}>
{agree && <CheckSvg />}
</View>
</TouchableOpacity>
<View style={{ flexDirection: 'row', gap: 4 }}>
<ThemedText style={{ fontWeight: '400', fontSize: 11 }}>
{t('personal:rights.agreement')}
</ThemedText>
<TouchableOpacity
onPress={async () => {
setShowTerms(true);
}}
activeOpacity={0.8}
>
<ThemedText style={{ color: '#AC7E35', fontWeight: '400', fontSize: 11, textAlign: 'center' }}>
{t('personal:rights.membership')}
</ThemedText>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
style={styles.goPay}
onPress={async () => {
confirmPurchase()
}}
activeOpacity={0.8}
>
<ThemedText style={{ color: '#fff', fontWeight: '700', fontSize: 14 }}>
{t('rights.subscribe', { ns: 'personal' })}
</ThemedText>
</TouchableOpacity>
</View>
{/* 协议弹窗 */}
<PrivacyModal modalVisible={showTerms} setModalVisible={setShowTerms} type={"membership"} />
</View>
);
}
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
}
});

513
app/(tabs)/setting.tsx Normal file
View File

@ -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<User | null>(null);
const getUserInfo = async () => {
const res = await fetchApi<User>("/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<Address>({} 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 (
<View style={{ flex: 1, paddingTop: insets.top, marginBottom: insets.bottom }}>
<Pressable
style={styles.centeredView}
>
<Pressable
style={styles.modalView}
onPress={(e) => e.stopPropagation()}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => { router.push('/owner') }}>
<ReturnArrowSvg />
</TouchableOpacity>
<Text style={styles.modalTitle}>{t('generalSetting.allTitle', { ns: 'personal' })}</Text>
<Text style={{ opacity: 0 }}>×</Text>
</View>
<ScrollView style={styles.modalContent} showsVerticalScrollIndicator={false}>
{/* 用户信息 */}
<UserInfo
userInfo={userInfo || {} as User}
setCurrentLocation={setCurrentLocation}
getCurrentLocation={getCurrentLocation}
isLoading={isLoading}
isRefreshing={isRefreshing}
currentLocation={currentLocation}
/>
{/* 升级版本 */}
{/* <View style={{ marginTop: 16 }}>
<ThemedText style={{ marginLeft: 16, marginVertical: 8, color: '#AC7E35', fontSize: 14, fontWeight: '600' }}>{t('generalSetting.subscription', { ns: 'personal' })}</ThemedText>
<View style={styles.premium}>
<View>
<ThemedText style={styles.itemText}>{t('generalSetting.subscriptionTitle', { ns: 'personal' })}</ThemedText>
<ThemedText style={{ color: '#AC7E35', fontSize: 12 }}>{t('generalSetting.subscriptionText', { ns: 'personal' })}</ThemedText>
</View>
<TouchableOpacity
style={styles.upgradeButton}
onPress={async () => {
}}
>
<Text style={styles.upgradeButtonText}>
{t('generalSetting.upgrade', { ns: 'personal' })}
</Text>
</TouchableOpacity>
</View>
</View> */}
{/* 消息通知 */}
{/* <View style={{ marginTop: 16 }}>
<ThemedText style={{ marginLeft: 16, marginVertical: 8, color: '#AC7E35', fontSize: 14, fontWeight: '600' }}>{t('permission.pushNotification', { ns: 'personal' })}</ThemedText>
<View style={styles.premium}>
<View>
<ThemedText style={styles.itemText}>{t('permission.pushNotification', { ns: 'personal' })}</ThemedText>
</View>
<CustomSwitch
isEnabled={notificationsEnabled}
toggleSwitch={toggleNotifications}
/>
</View>
</View> */}
{/* 权限信息 */}
<View style={{ marginTop: 16 }}>
<ThemedText style={{ marginLeft: 16, marginVertical: 8, color: '#AC7E35', fontSize: 14, fontWeight: '600' }}>{t('permission.permissionManagement', { ns: 'personal' })}</ThemedText>
<View style={styles.content}>
{/* 相册权限 */}
<View style={styles.item}>
<ThemedText style={styles.itemText}>{t('permission.galleryAccess', { ns: 'personal' })}</ThemedText>
<CustomSwitch
isEnabled={albumEnabled}
toggleSwitch={toggleAlbum}
/>
</View>
{/* 分割线 */}
<Divider />
{/* 位置权限 */}
<View style={styles.item}>
<View>
<ThemedText style={styles.itemText}>{t('permission.locationPermission', { ns: 'personal' })}</ThemedText>
</View>
<CustomSwitch
isEnabled={locationEnabled}
toggleSwitch={toggleLocation}
/>
</View>
<Divider />
<View style={styles.item}>
<View>
<ThemedText style={styles.itemText}>{t('permission.pushNotification', { ns: 'personal' })}</ThemedText>
</View>
<CustomSwitch
isEnabled={notificationsEnabled}
toggleSwitch={toggleNotifications}
/>
</View>
{/* 相册成片权限 */}
{/* <View style={styles.item}>
<View>
<ThemedText style={styles.itemText}>Opus Permission</ThemedText>
</View>
<CustomSwitch
isEnabled={albumEnabled}
toggleSwitch={toggleAlbum}
/>
</View> */}
</View>
</View>
{/* 账号 */}
{/* <View style={{ marginTop: 16 }}>
<ThemedText style={{ marginLeft: 16, marginVertical: 8, color: '#AC7E35', fontSize: 14, fontWeight: '600' }}>Account</ThemedText>
<View style={styles.content}>
<View style={styles.item}>
<View>
<ThemedText style={styles.itemText}>Notifications</ThemedText>
</View>
<CustomSwitch
isEnabled={notificationsEnabled}
toggleSwitch={toggleNotifications}
/>
</View>
<Divider />
<View style={styles.item}>
<ThemedText style={styles.itemText}>Delete Account</ThemedText>
<DeleteSvg />
</View>
</View>
</View> */}
{/* 协议 */}
<View style={{ marginTop: 16 }}>
<ThemedText style={{ marginLeft: 16, marginVertical: 8, color: '#AC7E35', fontSize: 14, fontWeight: '600' }}>{t('lcenses.title', { ns: 'personal' })}</ThemedText>
<View style={styles.content}>
<TouchableOpacity style={styles.item} onPress={() => { setModalType('privacy'); setPrivacyModalVisible(true) }} >
<ThemedText style={styles.itemText}>{t('lcenses.privacyPolicy', { ns: 'personal' })}</ThemedText>
<RightArrowSvg />
</TouchableOpacity>
<Divider />
<TouchableOpacity style={styles.item} onPress={() => { setModalType('terms'); setPrivacyModalVisible(true) }} >
<ThemedText style={styles.itemText}>{t('lcenses.applyPermission', { ns: 'personal' })}</ThemedText>
<RightArrowSvg />
</TouchableOpacity>
<Divider />
<TouchableOpacity style={styles.item} onPress={() => { setModalType('user'); setPrivacyModalVisible(true) }} >
<ThemedText style={styles.itemText}>{t('lcenses.userAgreement', { ns: 'personal' })}</ThemedText>
<RightArrowSvg />
</TouchableOpacity>
<Divider />
<TouchableOpacity style={styles.item} onPress={() => { setModalType('ai'); setPrivacyModalVisible(true) }} >
<ThemedText style={styles.itemText}>{t('lcenses.aiPolicy', { ns: 'personal' })}</ThemedText>
<RightArrowSvg />
</TouchableOpacity>
<Divider />
<TouchableOpacity style={styles.item} onPress={() => { setLcensesModalVisible(true) }} >
<ThemedText style={styles.itemText}>{t('lcenses.qualification', { ns: 'personal' })}</ThemedText>
<RightArrowSvg />
</TouchableOpacity>
<Divider />
<TouchableOpacity style={[styles.item, { display: language == "en" ? 'none' : 'flex' }]} onPress={() => Linking.openURL("https://beian.miit.gov.cn/")} >
<ThemedText style={styles.itemText}>{t('lcenses.ICP', { ns: 'personal' })}ICP备2025133004号-2A</ThemedText>
<RightArrowSvg />
</TouchableOpacity>
</View>
</View>
{/* 其他信息 */}
<View style={{ marginTop: 16 }}>
<ThemedText style={{ marginLeft: 16, marginVertical: 8, color: '#AC7E35', fontSize: 14, fontWeight: '600' }}>{t('generalSetting.otherInformation', { ns: 'personal' })}</ThemedText>
<View style={styles.content}>
<TouchableOpacity style={styles.item} onPress={() => Linking.openURL("https://work.weixin.qq.com/kfid/kfca0ac87f4e05e8bfd")} >
<ThemedText style={styles.itemText}>{t('generalSetting.contactUs', { ns: 'personal' })}</ThemedText>
{/* <RightArrowSvg /> */}
</TouchableOpacity>
<Divider />
<View style={styles.item}>
<ThemedText style={styles.itemText}>{t('generalSetting.version', { ns: 'personal' })}</ThemedText>
<ThemedText style={styles.itemText}>{"0.5.0"}</ThemedText>
</View>
</View>
</View>
{/* 退出 */}
<TouchableOpacity style={[styles.premium, { marginVertical: 8 }]} onPress={handleLogout}>
<ThemedText style={{ color: '#E2793F', fontSize: 14, fontWeight: '600' }}>{t('generalSetting.logout', { ns: 'personal' })}</ThemedText>
<LogoutSvg />
</TouchableOpacity>
{/* 注销账号 */}
<TouchableOpacity style={[styles.premium, { marginVertical: 8 }]} onPress={() => setDeleteModalVisible(true)}>
<ThemedText style={{ color: '#E2793F', fontSize: 14, fontWeight: '600' }}>{t('generalSetting.deleteAccount', { ns: 'personal' })}</ThemedText>
<DeleteSvg />
</TouchableOpacity>
</ScrollView>
</Pressable>
</Pressable>
<PrivacyModal modalVisible={privacyModalVisible} setModalVisible={setPrivacyModalVisible} type={modalType} />
<LcensesModal modalVisible={lcensesModalVisible} setModalVisible={setLcensesModalVisible} />
<DeleteModal modalVisible={deleteModalVisible} setModalVisible={setDeleteModalVisible} />
</View>
);
};
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 (
<View className='w-full h-[1px] bg-[#B5977F]'></View>
)
}
export default Setting;

View File

@ -175,7 +175,7 @@ export default function OwnerPage() {
<ThemedText style={styles.headerTitle}>
Top Memory Makers
</ThemedText>
<View className='opacity-0'>123</View>
<ThemedText className='opacity-0'>123</ThemedText>
</View>
<View style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 16, marginHorizontal: 16 }}>
<TouchableOpacity onPress={() => { setLocationModalVisible(true) }} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 8 }}>

View File

@ -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 (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Provider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="login"
options={{
headerShown: false,
animation: 'fade'
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
</Provider>
<PermissionProvider>
<Provider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="login"
options={{
headerShown: false,
animation: 'fade'
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
</Provider>
</PermissionProvider>
<StatusBar style="auto" />
</ThemeProvider>
);

View File

@ -0,0 +1,3 @@
<svg width="12" height="15" viewBox="0 0 12 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 0C6 0 6.33105 5.36245 7.62054 6.97432C8.91004 8.58619 12 9 12 9C12 9 8.91004 9.41381 7.62054 11.0257C6.33105 12.6375 6 15 6 15C6 15 5.66895 12.6375 4.37946 11.0257C3.08996 9.41381 0 9 0 9C0 9 3.08996 8.58619 4.37946 6.97432C5.66895 5.36245 6 0 6 0Z" fill="#4C320C"/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>

After

Width:  |  Height:  |  Size: 312 B

View File

@ -0,0 +1,26 @@
<svg width="376" height="124" viewBox="0 44.9 376 124.1" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 原始所有 path 和 defs 保持不变 -->
<path d="M286.349 53.467L292.529 44.9033H320.021L311.337 51.2785L284.133 56.0518L286.349 53.467Z" fill="#E0A241" />
<path d="M367.546 122.188L363.914 116.368C363.798 116.183 363.714 115.98 363.664 115.768L362.749 111.887C362.722 111.77 362.684 111.657 362.636 111.547L362.079 110.266C361.211 108.273 363.698 106.522 365.282 108.01L375.138 117.274L370.767 122.424C369.891 123.457 368.263 123.338 367.546 122.188Z" fill="#E0A241" />
<rect x="1.17188" y="48.8311" width="370" height="120" rx="34" fill="#4C320C" />
<path opacity="0.24" d="M1 118.031C1.66058 99.3464 16.3302 58.9993 68.3782 57.5584C116.841 56.2168 170.195 82.5409 173.692 84.0733C186.432 89.6553 254.376 122.217 310.713 107.564C355.783 95.8421 366.107 67.6372 365.635 55M1 128.238C31.3602 124.142 111.645 120.135 189.904 136.872C268.164 153.61 343.243 153.145 371 150.821" stroke="white" stroke-width="1.5" />
<path d="M175.398 56.0518C175.398 56.0518 175.916 62.7711 177.936 64.7908C179.956 66.8105 184.795 67.329 184.795 67.329C184.795 67.329 179.956 67.8475 177.936 69.8672C175.916 71.8869 175.398 74.8472 175.398 74.8472C175.398 74.8472 174.879 71.8869 172.859 69.8672C170.84 67.8475 166 67.329 166 67.329C166 67.329 170.84 66.8105 172.859 64.7908C174.879 62.7711 175.398 56.0518 175.398 56.0518Z" fill="#FFB645" />
<path d="M149.929 54.0527C149.929 54.0527 150.816 65.5433 154.27 68.9971C157.724 72.451 166 73.3377 166 73.3377C166 73.3377 157.724 74.2244 154.27 77.6783C150.816 81.1321 149.929 86.1943 149.929 86.1943C149.929 86.1943 149.042 81.1321 145.589 77.6783C142.135 74.2244 133.858 73.3377 133.858 73.3377C133.858 73.3377 142.135 72.451 145.589 68.9971C149.042 65.5433 149.929 54.0527 149.929 54.0527Z" fill="#FFB645" />
<path d="M181.214 75.7812C181.214 75.7812 181.92 84.9229 184.668 87.6707C187.415 90.4186 194 91.124 194 91.124C194 91.124 187.415 91.8295 184.668 94.5773C181.92 97.3251 181.214 101.353 181.214 101.353C181.214 101.353 180.509 97.3251 177.761 94.5773C175.013 91.8295 168.429 91.124 168.429 91.124C168.429 91.124 175.013 90.4186 177.761 87.6707C180.509 84.9229 181.214 75.7812 181.214 75.7812Z" fill="#FFB645" />
<path d="M292.495 44.8398H319.859L375.141 93.3921V117.295L292.495 44.8398Z" fill="#FFC56A" />
<defs>
<linearGradient id="paint0_linear_0_1" x1="26" y1="72.5" x2="106.717" y2="129.81" gradientUnits="userSpaceOnUse">
<stop stop-color="white" />
<stop offset="1" stop-color="#FFE064" />
</linearGradient>
<linearGradient id="paint1_linear_0_1" x1="30.4844" y1="134.396" x2="141.612" y2="134.396" gradientUnits="userSpaceOnUse">
<stop stop-color="#D0BFB0" />
<stop offset="0.405582" stop-color="#FFE57D" />
<stop offset="1" stop-color="white" />
</linearGradient>
<linearGradient id="paint2_linear_0_1" x1="100.511" y1="78.9371" x2="164.522" y2="104.117" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF3E8" />
<stop offset="1" stop-color="#FFFAB9" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -1,26 +1,26 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="30" cy="30" r="30" fill="white"/>
<mask id="chat_mask0_555_1115" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="60" height="60">
<mask id="mask0_106_1225" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="60" height="60">
<circle cx="30" cy="30" r="30" fill="white"/>
</mask>
<g mask="url(#chat_mask0_555_1115)">
<g mask="url(#mask0_106_1225)">
<path d="M-12.302 16.2692C6.49892 -16.2949 53.5011 -16.2949 72.302 16.2692L85.1259 38.4808C103.927 71.0449 80.4256 111.75 42.8238 111.75H17.1762C-20.4256 111.75 -43.9267 71.0449 -25.1258 38.4808L-12.302 16.2692Z" fill="#FFD18A"/>
<rect x="27.116" y="14.1538" width="1.92308" height="2.69231" rx="0.961539" transform="rotate(-180 27.116 14.1538)" fill="#4C320C"/>
<rect x="34.0383" y="14.1538" width="1.92308" height="2.69231" rx="0.961539" transform="rotate(-180 34.0383 14.1538)" fill="#4C320C"/>
<path d="M2.43174 33.8473C15.7285 14.5172 44.2728 14.5172 57.5695 33.8473L72.7134 55.8628C87.985 78.0639 72.0909 108.288 45.1445 108.288H14.8567C-12.0897 108.288 -27.9838 78.0639 -12.7122 55.8628L2.43174 33.8473Z" fill="#FFF8DE"/>
<g filter="url(#filter0_i_555_1115)">
<ellipse cx="56.7313" cy="30.5" rx="31.5385" ry="22.5" fill="#FFF8DE"/>
<rect x="27.1162" y="14.1539" width="1.92308" height="2.69231" rx="0.961539" transform="rotate(-180 27.1162 14.1539)" fill="#4C320C"/>
<rect x="34.0381" y="14.1539" width="1.92308" height="2.69231" rx="0.961539" transform="rotate(-180 34.0381 14.1539)" fill="#4C320C"/>
<path d="M2.43174 33.8474C15.7285 14.5173 44.2728 14.5173 57.5695 33.8474L72.7134 55.8629C87.985 78.064 72.0909 108.288 45.1445 108.288H14.8567C-12.0897 108.288 -27.9838 78.064 -12.7122 55.8629L2.43174 33.8474Z" fill="#FFF8DE"/>
<g filter="url(#filter0_i_106_1225)">
<ellipse cx="56.7318" cy="30.5001" rx="31.5385" ry="22.5" fill="#FFF8DE"/>
</g>
<g filter="url(#filter1_i_555_1115)">
<ellipse cx="3.07711" cy="30.5" rx="31.3462" ry="22.5" fill="#FFF8DE"/>
<g filter="url(#filter1_i_106_1225)">
<ellipse cx="3.07662" cy="30.5001" rx="31.3462" ry="22.5" fill="#FFF8DE"/>
</g>
<ellipse cx="29.8075" cy="19.3464" rx="2.30769" ry="1.73077" transform="rotate(180 29.8075 19.3464)" fill="#FFB8B9"/>
<ellipse cx="5.49476" cy="2.19199" rx="5.49476" ry="2.19199" transform="matrix(0.912659 0.408721 0.408721 -0.912659 41.571 57.001)" fill="#FFD38D"/>
<ellipse cx="12.2567" cy="57.2463" rx="5.49476" ry="2.19199" transform="rotate(155.875 12.2567 57.2463)" fill="#FFD38D"/>
<path d="M29.4743 21.2693C29.6224 21.0129 29.9925 21.0129 30.1405 21.2693L30.4736 21.8462C30.6216 22.1026 30.4366 22.4232 30.1405 22.4232H29.4743C29.1782 22.4232 28.9932 22.1026 29.1412 21.8462L29.4743 21.2693Z" fill="#4C320C"/>
<ellipse cx="29.8075" cy="19.3463" rx="2.30769" ry="1.73077" transform="rotate(180 29.8075 19.3463)" fill="#FFB8B9"/>
<ellipse cx="5.49476" cy="2.19199" rx="5.49476" ry="2.19199" transform="matrix(0.912659 0.408721 0.408721 -0.912659 41.5713 57.0012)" fill="#FFD38D"/>
<ellipse cx="12.2572" cy="57.2465" rx="5.49476" ry="2.19199" transform="rotate(155.875 12.2572 57.2465)" fill="#FFD38D"/>
<path d="M29.4741 21.2693C29.6221 21.0129 29.9922 21.0129 30.1403 21.2693L30.4733 21.8462C30.6214 22.1026 30.4363 22.4232 30.1403 22.4232H29.4741C29.178 22.4232 28.993 22.1026 29.141 21.8462L29.4741 21.2693Z" fill="#4C320C"/>
</g>
<defs>
<filter id="chat_filter0_i_555_1115" x="21.7313" y="8" width="66.5384" height="46.1538" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<filter id="filter0_i_106_1225" x="21.7318" y="8.00012" width="66.5387" height="46.1538" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
@ -28,9 +28,9 @@
<feGaussianBlur stdDeviation="3.17308"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_555_1115"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_106_1225"/>
</filter>
<filter id="chat_filter1_i_555_1115" x="-28.269" y="8" width="66.5385" height="45" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<filter id="filter1_i_106_1225" x="-28.2695" y="8.00012" width="66.5385" height="45" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
@ -38,7 +38,7 @@
<feGaussianBlur stdDeviation="2.11538"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_555_1115"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_106_1225"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="6" fill="#FFB645"/>
</svg>

After

Width:  |  Height:  |  Size: 156 B

View File

@ -0,0 +1,3 @@
<svg width="11" height="20" viewBox="0 0 11 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.0002 1L9.77832 9.77812L1.0002 18.5562" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-to-line-icon lucide-arrow-down-to-line"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></svg>

After

Width:  |  Height:  |  Size: 324 B

42
assets/icons/svg/free.svg Normal file
View File

@ -0,0 +1,42 @@
<svg width="268" height="140" viewBox="0 0 268 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M49.5755 61.4144C50.4475 58.5036 57.1744 61.1692 60.4288 62.8658L52.5625 66.8182C50.6468 66.7604 48.7034 64.3251 49.5755 61.4144Z" fill="#FFDBA3"/>
<path d="M51.0981 61.8437C51.5796 59.3845 56.005 61.8494 58.1575 63.3893L54.2174 65.2282C52.977 65.1247 50.6165 64.3029 51.0981 61.8437Z" fill="#AC7E35"/>
<path d="M133.568 60.0186C132.303 57.2559 126.008 60.8237 123.019 62.9529L131.355 65.7827C133.244 65.4612 134.833 62.7813 133.568 60.0186Z" fill="#FFDBA3"/>
<path d="M132.118 60.6536C131.302 58.2843 127.259 61.3359 125.339 63.1579L129.495 64.436C130.709 64.1624 132.934 63.0229 132.118 60.6536Z" fill="#AC7E35"/>
<path d="M27.1969 92.9743C56.4427 42.319 129.557 42.3191 158.803 92.9744L178.751 127.526C207.997 178.181 171.44 241.5 112.948 241.5H73.0518C14.5601 241.5 -21.9971 178.181 7.24869 127.526L27.1969 92.9743Z" fill="#FFD18A"/>
<rect x="88.5132" y="89.6838" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 88.5132 89.6838)" fill="#4C320C"/>
<rect x="99.2822" y="89.6838" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 99.2822 89.6838)" fill="#4C320C"/>
<path d="M50.1157 120.318C70.7994 90.2494 115.202 90.2493 135.886 120.318L159.443 154.565C183.198 189.1 158.474 236.115 116.558 236.115H69.4435C27.5268 236.115 2.8027 189.1 26.5585 154.565L50.1157 120.318Z" fill="#FFF8DE"/>
<g filter="url(#filter0_i_3223_1552)">
<ellipse cx="134.581" cy="115.111" rx="49.0598" ry="35" fill="#FFF8DE"/>
</g>
<g filter="url(#filter1_i_3223_1552)">
<ellipse cx="51.1196" cy="115.111" rx="48.7607" ry="35" fill="#FFF8DE"/>
</g>
<ellipse cx="92.7008" cy="97.7608" rx="3.58974" ry="2.69231" transform="rotate(180 92.7008 97.7608)" fill="#FFB8B9"/>
<ellipse cx="8.5474" cy="3.40976" rx="8.5474" ry="3.40976" transform="matrix(1 0 0 -1 108.647 142)" fill="#FFD38D"/>
<ellipse cx="65.5473" cy="138.59" rx="8.5474" ry="3.40976" transform="rotate(-180 65.5473 138.59)" fill="#FFD38D"/>
<path d="M91.9591 101.026C92.2223 100.57 92.8803 100.57 93.1434 101.026L93.7356 102.051C93.9988 102.507 93.6698 103.077 93.1434 103.077H91.9591C91.4328 103.077 91.1038 102.507 91.367 102.051L91.9591 101.026Z" fill="#4C320C"/>
<defs>
<filter id="filter0_i_3223_1552" x="80.1369" y="80.1111" width="103.504" height="71.7949" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-5.38462" dy="1.79487"/>
<feGaussianBlur stdDeviation="4.9359"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3223_1552"/>
</filter>
<filter id="filter1_i_3223_1552" x="2.35889" y="80.1111" width="103.504" height="70" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="5.98291"/>
<feGaussianBlur stdDeviation="3.2906"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3223_1552"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

3
assets/icons/svg/get.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 4.00001L4.33357 7L11 1" stroke="#FFB645" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 220 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="17" cy="6" r="2" fill="#FFB645"/>
<path d="M5.22641 11.1447L3.71963 12.4003C3.36576 12.6952 3.18882 12.8426 3.09441 13.0442C3 13.2458 3 13.4761 3 13.9367V16C3 17.8856 3 18.8284 3.58579 19.4142C4.17157 20 5.11438 20 7 20H16C17.8856 20 18.8284 20 19.4142 19.4142C20 18.8284 20 17.8856 20 16V15.8284C20 15.4197 20 15.2153 19.9239 15.0315C19.8478 14.8478 19.7032 14.7032 19.4142 14.4142L19.4142 14.4142L18.3008 13.3008C17.4728 12.4728 17.0588 12.0588 16.5777 11.9137C16.2009 11.8 15.7991 11.8 15.4223 11.9137C14.9412 12.0588 14.5272 12.4728 13.6992 13.3008C13.1138 13.8862 12.8212 14.1788 12.5102 14.2334C12.2685 14.2758 12.0197 14.2279 11.811 14.0988C11.5425 13.9326 11.3795 13.5522 11.0534 12.7913L10.935 12.515C10.1361 10.6509 9.73666 9.71888 8.98531 9.40712C8.79066 9.32635 8.58447 9.27686 8.37436 9.26047C7.56336 9.1972 6.78438 9.84635 5.22641 11.1447Z" fill="#FFB645" stroke="#FFB645" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1021 B

View File

@ -0,0 +1,6 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.92">
<circle cx="17" cy="6.5" r="2" fill="white"/>
<path d="M5.22641 11.6447L3.71963 12.9003C3.36576 13.1952 3.18882 13.3426 3.09441 13.5442C3 13.7458 3 13.9761 3 14.4367V16.5C3 18.3856 3 19.3284 3.58579 19.9142C4.17157 20.5 5.11438 20.5 7 20.5H16C17.8856 20.5 18.8284 20.5 19.4142 19.9142C20 19.3284 20 18.3856 20 16.5V16.3284C20 15.9197 20 15.7153 19.9239 15.5315C19.8478 15.3478 19.7032 15.2032 19.4142 14.9142L19.4142 14.9142L18.3008 13.8008C17.4728 12.9728 17.0588 12.5588 16.5777 12.4137C16.2009 12.3 15.7991 12.3 15.4223 12.4137C14.9412 12.5588 14.5272 12.9728 13.6992 13.8008C13.1138 14.3862 12.8212 14.6788 12.5102 14.7334C12.2685 14.7758 12.0197 14.7279 11.811 14.5988C11.5425 14.4326 11.3795 14.0522 11.0534 13.2913L10.935 13.015C10.1361 11.1509 9.73666 10.2189 8.98531 9.90712C8.79066 9.82635 8.58447 9.77686 8.37436 9.76047C7.56336 9.6972 6.78438 10.3464 5.22641 11.6447Z" fill="white" stroke="white" stroke-width="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,3 @@
<svg width="372" height="99" viewBox="0 0 372 99" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.24" d="M1 64.0308C1.66058 45.3464 16.3302 4.9993 68.3782 3.55844C116.841 2.21683 170.195 28.5409 173.692 30.0733C186.432 35.6553 254.376 68.2174 310.713 53.5645C355.783 41.8421 366.107 13.6372 365.635 1M1 74.2378C31.3602 70.1419 111.645 66.1345 189.904 82.8724C268.164 99.6103 343.243 99.1454 371 96.8207" stroke="white" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 464 B

View File

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 18V7L18 9L12 10L5 9L3 7V18L9 11.5L13 15L16.5 13L19.5 17.5L21 18Z" fill="#FFB645"/>
<path d="M3 17L6.03914 13.4543C7.24119 12.0519 7.84222 11.3507 8.58666 11.2437C8.82846 11.209 9.07458 11.2188 9.31284 11.2727C10.0464 11.4388 10.5896 12.1857 11.676 13.6794L11.9869 14.107C12.2379 14.4521 12.3633 14.6246 12.5031 14.7213C12.8185 14.9396 13.2311 14.9582 13.5649 14.7691C13.7128 14.6854 13.8533 14.5248 14.1342 14.2038V14.2038C14.7172 13.5374 15.0088 13.2043 15.3126 13.0352C15.9993 12.6532 16.846 12.7087 17.4769 13.177C17.7562 13.3842 18.0017 13.7526 18.4929 14.4893L20.5 17.5" stroke="#FFB645" stroke-width="2"/>
<path d="M7 9.5V4.5M17 9.5V4.5" stroke="#FFB645" stroke-width="2" stroke-linecap="round"/>
<path d="M3 17V7M21 7V17" stroke="#FFB645" stroke-width="2" stroke-linecap="round"/>
<path d="M17.0198 4.50998C18.6218 4.86883 19.8353 5.38985 20.4812 5.99615C21.127 6.60244 21.1711 7.26189 20.6067 7.87715C20.0424 8.49242 18.8996 9.03091 17.347 9.41315C15.7944 9.79539 13.9143 10.0011 11.9842 10C10.054 9.99886 8.17609 9.79092 6.62756 9.40686C5.07902 9.0228 3.94192 8.48297 3.38408 7.86705C2.82624 7.25112 2.87721 6.59173 3.52947 5.9862C4.18173 5.38067 5.40073 4.86107 7.00654 4.50411" stroke="#FFB645" stroke-width="2" stroke-linecap="round"/>
<path d="M21 17C21 17.7956 20.0518 18.5587 18.364 19.1213C16.6761 19.6839 14.3869 20 12 20C9.61305 20 7.32387 19.6839 5.63604 19.1213C3.94821 18.5587 3 17.7956 3 17" stroke="#FFB645" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

26
assets/icons/svg/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 952 KiB

View File

@ -0,0 +1,9 @@
<svg width="70" height="24" viewBox="0 0 70 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="0.0673828" width="70" height="23.4326" rx="11.7163" fill="url(#paint0_linear_3450_1422)"/>
<defs>
<linearGradient id="paint0_linear_3450_1422" x1="6.51052" y1="-2.06292" x2="70.5223" y2="23.1173" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF3E8"/>
<stop offset="1" stop-color="#FFFAB9"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View File

@ -0,0 +1,19 @@
<svg width="402" height="154" viewBox="0 0 402 154" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="18.1921" cy="109.346" rx="5.76923" ry="2.5" fill="#AC7E35"/>
<ellipse cx="80.4997" cy="108.192" rx="5.76923" ry="2.5" fill="#AC7E35"/>
<g filter="url(#filter0_d_0_1)">
<path d="M434.5 115C434.5 136.539 417.039 154 395.5 154H9C-12.5391 154 -30 136.539 -30 115V108C-30 86.4609 -12.5391 69 9 69H128.878C135.302 69 140 75.0761 140 81.5C140 110.495 163.505 134 192.5 134C221.495 134 245 110.495 245 81.5C245 75.0761 249.698 69 256.122 69H395.5C417.039 69 434.5 86.4609 434.5 108V115Z" fill="white"/>
</g>
<defs>
<filter id="filter0_d_0_1" x="-39.1" y="57.9" width="482.7" height="103.2" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-2"/>
<feGaussianBlur stdDeviation="4.55"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_1" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,3 @@
<svg width="81" height="33" viewBox="0 0 81 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.3" x="0.248535" y="0.716797" width="80.1118" height="32.0752" rx="16.0376" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 210 B

59
assets/icons/svg/pro.svg Normal file
View File

@ -0,0 +1,59 @@
<svg width="200" height="140" viewBox="0 0 200 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g mask="url(#mask0_3223_1170)">
<g filter="url(#filter0_i_3223_1170)">
<path d="M61.4295 56.6599C60.8472 52.3476 66.5965 50.3378 68.8277 54.0737L71.3125 58.2342C73.0132 61.082 77.2417 60.7415 78.4648 57.6582L88.4108 32.5851C89.7463 29.2183 94.5115 29.2183 95.8471 32.5851L105.793 57.6582C107.016 60.7415 111.245 61.082 112.945 58.2342L115.43 54.0737C117.661 50.3378 123.411 52.3476 122.828 56.6599L120.386 74.7507C120.118 76.735 118.424 78.2155 116.422 78.2155H67.8363C65.8341 78.2155 64.1402 76.735 63.8723 74.7507L61.4295 56.6599Z" fill="#FFE88A" />
</g>
<circle cx="59.1272" cy="39.4803" r="9.12722" fill="#FFB645" stroke="#4C320C" stroke-width="4" />
<circle cx="92.2844" cy="22.1272" r="9.12722" fill="#E2793F" stroke="#4C320C" stroke-width="4" />
<circle cx="125.441" cy="39.4803" r="9.12722" fill="#FFB645" stroke="#4C320C" stroke-width="4" />
<path d="M49.5755 72.4144C50.4475 69.5036 57.1744 72.1692 60.4288 73.8658L52.5625 77.8182C50.6468 77.7604 48.7034 75.3251 49.5755 72.4144Z" fill="#FFDBA3" />
<path d="M51.0981 72.8437C51.5796 70.3845 56.005 72.8494 58.1575 74.3893L54.2174 76.2282C52.977 76.1247 50.6165 75.3029 51.0981 72.8437Z" fill="#AC7E35" />
<path d="M133.568 71.0186C132.303 68.2559 126.008 71.8237 123.019 73.9529L131.355 76.7827C133.244 76.4612 134.833 73.7813 133.568 71.0186Z" fill="#FFDBA3" />
<path d="M132.118 71.6536C131.302 69.2843 127.259 72.3359 125.339 74.1579L129.495 75.436C130.709 75.1624 132.934 74.0229 132.118 71.6536Z" fill="#AC7E35" />
<path d="M27.1969 103.974C56.4427 53.319 129.557 53.3191 158.803 103.974L178.751 138.526C207.997 189.181 171.44 252.5 112.948 252.5H73.0518C14.5601 252.5 -21.9971 189.181 7.24869 138.526L27.1969 103.974Z" fill="#FFD18A" />
<rect x="88.5132" y="100.684" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 88.5132 100.684)" fill="#4C320C" />
<rect x="99.2822" y="100.684" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 99.2822 100.684)" fill="#4C320C" />
<path d="M50.1157 131.318C70.7994 101.249 115.202 101.249 135.886 131.318L159.443 165.565C183.198 200.1 158.474 247.115 116.558 247.115H69.4435C27.5268 247.115 2.8027 200.1 26.5585 165.565L50.1157 131.318Z" fill="#FFF8DE" />
<g filter="url(#filter1_i_3223_1170)">
<ellipse cx="134.581" cy="126.111" rx="49.0598" ry="35" fill="#FFF8DE" />
</g>
<g filter="url(#filter2_i_3223_1170)">
<ellipse cx="51.1196" cy="126.111" rx="48.7607" ry="35" fill="#FFF8DE" />
</g>
<ellipse cx="92.7008" cy="108.761" rx="3.58974" ry="2.69231" transform="rotate(180 92.7008 108.761)" fill="#FFB8B9" />
<ellipse cx="8.5474" cy="3.40976" rx="8.5474" ry="3.40976" transform="matrix(1 0 0 -1 108.647 143)" fill="#FFD38D" />
<ellipse cx="65.5473" cy="139.59" rx="8.5474" ry="3.40976" transform="rotate(-180 65.5473 139.59)" fill="#FFD38D" />
<path d="M91.9591 112.026C92.2223 111.57 92.8803 111.57 93.1434 112.026L93.7356 113.051C93.9988 113.507 93.6698 114.077 93.1434 114.077H91.9591C91.4328 114.077 91.1038 113.507 91.367 113.051L91.9591 112.026Z" fill="#4C320C" />
</g>
<defs>
<filter id="filter0_i_3223_1170" x="61.3882" y="30.0601" width="61.4814" height="48.1555" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="21" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3223_1170" />
</filter>
<filter id="filter1_i_3223_1170" x="80.1369" y="91.1111" width="103.504" height="71.7949" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="-5.38462" dy="1.79487" />
<feGaussianBlur stdDeviation="4.9359" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3223_1170" />
</filter>
<filter id="filter2_i_3223_1170" x="2.35889" y="91.1111" width="103.504" height="70" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="5.98291" />
<feGaussianBlur stdDeviation="3.2906" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3223_1170" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -0,0 +1,62 @@
<svg width="365" height="140" viewBox="0 0 365 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3223_1003" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="365" height="140">
<rect width="365" height="140" rx="26" fill="#FFB645"/>
</mask>
<g mask="url(#mask0_3223_1003)">
<g filter="url(#filter0_i_3223_1003)">
<path d="M61.4295 45.6599C60.8472 41.3476 66.5965 39.3378 68.8277 43.0737L71.3125 47.2342C73.0132 50.082 77.2417 49.7415 78.4648 46.6582L88.4108 21.5851C89.7463 18.2183 94.5115 18.2183 95.8471 21.5851L105.793 46.6582C107.016 49.7415 111.245 50.082 112.945 47.2342L115.43 43.0737C117.661 39.3378 123.411 41.3476 122.828 45.6599L120.386 63.7507C120.118 65.735 118.424 67.2155 116.422 67.2155H67.8363C65.8341 67.2155 64.1402 65.735 63.8723 63.7507L61.4295 45.6599Z" fill="#FFF8DE"/>
</g>
<circle cx="59.1272" cy="28.4803" r="9.12722" fill="white" stroke="#FFB645" stroke-width="4"/>
<circle cx="92.2844" cy="16.1272" r="9.12722" fill="#E2793F" stroke="#FFB645" stroke-width="4"/>
<circle cx="125.441" cy="28.4803" r="9.12722" fill="white" stroke="#FFB645" stroke-width="4"/>
<path d="M49.5755 61.4144C50.4475 58.5036 57.1744 61.1692 60.4288 62.8658L52.5625 66.8182C50.6468 66.7604 48.7034 64.3251 49.5755 61.4144Z" fill="#FFDBA3"/>
<path d="M51.0981 61.8437C51.5796 59.3845 56.005 61.8494 58.1575 63.3893L54.2174 65.2282C52.977 65.1247 50.6165 64.3029 51.0981 61.8437Z" fill="#AC7E35"/>
<path d="M133.568 60.0186C132.303 57.2559 126.008 60.8237 123.019 62.9529L131.355 65.7827C133.244 65.4612 134.833 62.7813 133.568 60.0186Z" fill="#FFDBA3"/>
<path d="M132.118 60.6536C131.302 58.2843 127.259 61.3359 125.339 63.1579L129.495 64.436C130.709 64.1624 132.934 63.0229 132.118 60.6536Z" fill="#AC7E35"/>
<path d="M27.1969 92.9743C56.4427 42.319 129.557 42.3191 158.803 92.9744L178.751 127.526C207.997 178.181 171.44 241.5 112.948 241.5H73.0518C14.5601 241.5 -21.9971 178.181 7.24869 127.526L27.1969 92.9743Z" fill="#FFD18A"/>
<rect x="88.5132" y="89.6838" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 88.5132 89.6838)" fill="#4C320C"/>
<rect x="99.2822" y="89.6838" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 99.2822 89.6838)" fill="#4C320C"/>
<path d="M50.1157 120.318C70.7994 90.2494 115.202 90.2493 135.886 120.318L159.443 154.565C183.198 189.1 158.474 236.115 116.558 236.115H69.4435C27.5268 236.115 2.8027 189.1 26.5585 154.565L50.1157 120.318Z" fill="#FFF8DE"/>
<g filter="url(#filter1_i_3223_1003)">
<ellipse cx="134.581" cy="115.111" rx="49.0598" ry="35" fill="#FFF8DE"/>
</g>
<g filter="url(#filter2_i_3223_1003)">
<ellipse cx="51.1196" cy="115.111" rx="48.7607" ry="35" fill="#FFF8DE"/>
</g>
<ellipse cx="92.7008" cy="97.7608" rx="3.58974" ry="2.69231" transform="rotate(180 92.7008 97.7608)" fill="#FFB8B9"/>
<ellipse cx="8.5474" cy="3.40976" rx="8.5474" ry="3.40976" transform="matrix(1 0 0 -1 108.647 142)" fill="#FFD38D"/>
<ellipse cx="65.5473" cy="138.59" rx="8.5474" ry="3.40976" transform="rotate(-180 65.5473 138.59)" fill="#FFD38D"/>
<path d="M91.9591 101.026C92.2223 100.57 92.8803 100.57 93.1434 101.026L93.7356 102.051C93.9988 102.507 93.6698 103.077 93.1434 103.077H91.9591C91.4328 103.077 91.1038 102.507 91.367 102.051L91.9591 101.026Z" fill="#4C320C"/>
</g>
<defs>
<filter id="filter0_i_3223_1003" x="61.3882" y="19.0601" width="61.4814" height="48.1555" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="21"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.886275 0 0 0 0 0.47451 0 0 0 0 0.247059 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3223_1003"/>
</filter>
<filter id="filter1_i_3223_1003" x="80.1369" y="80.1111" width="103.504" height="71.7949" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-5.38462" dy="1.79487"/>
<feGaussianBlur stdDeviation="4.9359"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3223_1003"/>
</filter>
<filter id="filter2_i_3223_1003" x="2.35889" y="80.1111" width="103.504" height="70" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="5.98291"/>
<feGaussianBlur stdDeviation="3.2906"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3223_1003"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,9 @@
<svg width="55" height="33" viewBox="0 0 55 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.8516 13.3662C16.6758 17.9189 12.998 20.2021 6.81836 20.2158L5.52637 31.8438C5.48535 32.1719 5.31445 32.3428 5.01367 32.3564L0.707031 32.582C0.40625 32.5547 0.255859 32.4248 0.255859 32.1924L3.80371 1.16406C3.8584 0.876953 4.03613 0.719727 4.33691 0.692383C6.89355 0.459961 9.33398 0.34375 11.6582 0.34375C16.2793 0.34375 18.5898 2.55859 18.5898 6.98828C18.5898 9.4082 18.3438 11.5342 17.8516 13.3662ZM8.5 5.12207L7.33105 15.4785C9.69629 15.4785 11.2891 14.9863 12.1094 14.002C12.9297 13.0176 13.3398 11.1035 13.3398 8.25977C13.3398 7.16602 13.0459 6.35937 12.458 5.83984C11.8701 5.32031 11.1182 5.06055 10.2021 5.06055C9.28613 5.06055 8.71875 5.08105 8.5 5.12207ZM34.9756 12.9355L32.7812 12.71C31.0039 12.71 29.5137 13.0654 28.3105 13.7764L26.2803 31.8643C26.2256 32.2061 26.0547 32.3906 25.7676 32.418L21.625 32.582C21.3242 32.5547 21.1738 32.4248 21.1738 32.1924L23.8809 8.3418C23.9355 8.04102 24.1064 7.87695 24.3936 7.84961L28.3926 7.70605C28.7617 7.71973 28.9463 7.89746 28.9463 8.23926L28.7002 10.3516C29.5752 9.46289 30.4229 8.77246 31.2432 8.28027C32.0635 7.77441 32.8838 7.52148 33.7041 7.52148C34.5381 7.52148 35.1738 7.55566 35.6113 7.62402C35.8984 7.67871 36.042 7.87012 36.042 8.19824C36.042 9.1416 35.8848 10.543 35.5703 12.4023C35.502 12.7578 35.3037 12.9355 34.9756 12.9355ZM44.9834 32.7256C42.5635 32.7256 40.7861 32.001 39.6514 30.5518C38.5303 29.0889 37.9697 26.9629 37.9697 24.1738C37.9697 22.875 38.1133 21.1797 38.4004 19.0879C38.6875 16.9961 38.9541 15.4785 39.2002 14.5352C39.4463 13.5781 39.7949 12.6484 40.2461 11.7461C40.6973 10.8301 41.2305 10.0781 41.8457 9.49023C43.1992 8.17773 45.0312 7.52148 47.3418 7.52148C50.623 7.52148 52.749 8.72461 53.7197 11.1309C54.2393 12.416 54.499 13.9746 54.499 15.8066C54.499 17.625 54.3555 19.5117 54.0684 21.4668C53.7949 23.4219 53.5352 24.8711 53.2891 25.8145C53.043 26.7441 52.6943 27.6602 52.2432 28.5625C51.8057 29.4512 51.2793 30.1895 50.6641 30.7773C49.3105 32.0762 47.417 32.7256 44.9834 32.7256ZM48.6133 23.7432C48.7637 23.0186 48.9551 21.665 49.1875 19.6826C49.4336 17.6865 49.5566 16.21 49.5566 15.2529C49.5566 12.9287 48.7158 11.7666 47.0342 11.7666C46.2412 11.7666 45.585 12.1426 45.0654 12.8945C44.5596 13.6465 44.1494 14.8975 43.835 16.6475C43.2061 20.2021 42.8916 23.0869 42.8916 25.3018C42.8916 27.5166 43.7256 28.624 45.3936 28.624C46.1865 28.624 46.8428 28.248 47.3623 27.4961C47.8955 26.7305 48.3125 25.4795 48.6133 23.7432Z" fill="url(#paint0_linear_3450_1414)"/>
<defs>
<linearGradient id="paint0_linear_3450_1414" x1="-2" y1="0.5" x2="78.7168" y2="57.8104" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#FFE064"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,47 @@
<svg width="365" height="140" viewBox="0 0 365 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3223_1545" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="365" height="140">
<rect width="365" height="140" rx="26" fill="#FFB645"/>
</mask>
<g mask="url(#mask0_3223_1545)">
<path d="M49.5755 61.4144C50.4475 58.5036 57.1744 61.1692 60.4288 62.8658L52.5625 66.8182C50.6468 66.7604 48.7034 64.3251 49.5755 61.4144Z" fill="#FFDBA3"/>
<path d="M51.0981 61.8437C51.5796 59.3845 56.005 61.8494 58.1575 63.3893L54.2174 65.2282C52.977 65.1247 50.6165 64.3029 51.0981 61.8437Z" fill="#AC7E35"/>
<path d="M133.568 60.0186C132.303 57.2559 126.008 60.8237 123.019 62.9529L131.355 65.7827C133.244 65.4612 134.833 62.7813 133.568 60.0186Z" fill="#FFDBA3"/>
<path d="M132.118 60.6536C131.302 58.2843 127.259 61.3359 125.339 63.1579L129.495 64.436C130.709 64.1624 132.934 63.0229 132.118 60.6536Z" fill="#AC7E35"/>
<path d="M27.1969 92.9743C56.4427 42.319 129.557 42.3191 158.803 92.9744L178.751 127.526C207.997 178.181 171.44 241.5 112.948 241.5H73.0518C14.5601 241.5 -21.9971 178.181 7.24869 127.526L27.1969 92.9743Z" fill="#FFD18A"/>
<rect x="88.5132" y="89.6838" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 88.5132 89.6838)" fill="#4C320C"/>
<rect x="99.2822" y="89.6838" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 99.2822 89.6838)" fill="#4C320C"/>
<path d="M50.1157 120.318C70.7994 90.2494 115.202 90.2493 135.886 120.318L159.443 154.565C183.198 189.1 158.474 236.115 116.558 236.115H69.4435C27.5268 236.115 2.8027 189.1 26.5585 154.565L50.1157 120.318Z" fill="#FFF8DE"/>
<g filter="url(#filter0_i_3223_1545)">
<ellipse cx="134.581" cy="115.111" rx="49.0598" ry="35" fill="#FFF8DE"/>
</g>
<g filter="url(#filter1_i_3223_1545)">
<ellipse cx="51.1196" cy="115.111" rx="48.7607" ry="35" fill="#FFF8DE"/>
</g>
<ellipse cx="92.7008" cy="97.7608" rx="3.58974" ry="2.69231" transform="rotate(180 92.7008 97.7608)" fill="#FFB8B9"/>
<ellipse cx="8.5474" cy="3.40976" rx="8.5474" ry="3.40976" transform="matrix(1 0 0 -1 108.647 142)" fill="#FFD38D"/>
<ellipse cx="65.5473" cy="138.59" rx="8.5474" ry="3.40976" transform="rotate(-180 65.5473 138.59)" fill="#FFD38D"/>
<path d="M91.9591 101.026C92.2223 100.57 92.8803 100.57 93.1434 101.026L93.7356 102.051C93.9988 102.507 93.6698 103.077 93.1434 103.077H91.9591C91.4328 103.077 91.1038 102.507 91.367 102.051L91.9591 101.026Z" fill="#4C320C"/>
</g>
<defs>
<filter id="filter0_i_3223_1545" x="80.1369" y="80.1111" width="103.504" height="71.7949" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-5.38462" dy="1.79487"/>
<feGaussianBlur stdDeviation="4.9359"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3223_1545"/>
</filter>
<filter id="filter1_i_3223_1545" x="2.35889" y="80.1111" width="103.504" height="70" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="5.98291"/>
<feGaussianBlur stdDeviation="3.2906"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3223_1545"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,3 @@
<svg width="84" height="74" viewBox="0 0 84 74" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.494629 0.839844H27.8592L83.1408 49.3921V73.2953L0.494629 0.839844Z" fill="#FFC56A"/>
</svg>

After

Width:  |  Height:  |  Size: 200 B

View File

@ -1,3 +1,9 @@
<<<<<<< HEAD
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
=======
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="18" r="18" fill="#FFB645"/>
<path d="M15.6281 20.1601L20.4742 15.314M25.4316 12.3564L21.341 25.651C20.9744 26.8425 20.7909 27.4385 20.4748 27.636C20.2005 27.8074 19.8609 27.836 19.5623 27.7121C19.2178 27.5692 18.9383 27.0111 18.3807 25.8958L15.7897 20.7139C15.7012 20.5369 15.6569 20.4488 15.5978 20.3721C15.5453 20.304 15.4848 20.2427 15.4168 20.1903C15.3418 20.1325 15.2552 20.0892 15.0861 20.0046L9.89224 17.4077C8.77693 16.8501 8.21923 16.571 8.07632 16.2266C7.95238 15.9279 7.98064 15.588 8.152 15.3137C8.34959 14.9975 8.94555 14.8138 10.1374 14.4471L23.4319 10.3564C24.3689 10.0682 24.8376 9.92412 25.154 10.0403C25.4297 10.1415 25.647 10.3586 25.7482 10.6343C25.8644 10.9506 25.7202 11.419 25.4322 12.3551L25.4316 12.3564Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
>>>>>>> v_1.0.1
</svg>

Before

Width:  |  Height:  |  Size: 296 B

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 0C8 0 8.4414 5.71995 10.1607 7.43927C11.88 9.1586 16 9.6 16 9.6C16 9.6 11.88 10.0414 10.1607 11.7607C8.4414 13.4801 8 16 8 16C8 16 7.5586 13.4801 5.83927 11.7607C4.11995 10.0414 0 9.6 0 9.6C0 9.6 4.11995 9.1586 5.83927 7.43927C7.5586 5.71995 8 0 8 0Z" fill="#FFB645"/>
</svg>

After

Width:  |  Height:  |  Size: 384 B

3
assets/icons/svg/sun.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 3V1.5M9 15V16.5M4.81066 4.81066L3.75 3.75M13.296 13.296L14.3567 14.3567M3 9H1.5M15 9H16.5M13.2964 4.81066L14.357 3.75M4.81103 13.296L3.75037 14.3567M9 12.75C6.92893 12.75 5.25 11.0711 5.25 9C5.25 6.92893 6.92893 5.25 9 5.25C11.0711 5.25 12.75 6.92893 12.75 9C12.75 11.0711 11.0711 12.75 9 12.75Z" stroke="#FFB645" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 497 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3ZM12 5.5C11.4477 5.5 11 5.94772 11 6.5V11.75C11 12.4404 11.5596 13 12.25 13H15.5C16.0523 13 16.5 12.5523 16.5 12C16.5 11.4477 16.0523 11 15.5 11H13V6.5C13 5.94772 12.5523 5.5 12 5.5Z" fill="#FFB645"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 0.130859C13.9706 0.130859 18 4.1603 18 9.13086C18 14.1014 13.9706 18.1309 9 18.1309C4.02944 18.1309 0 14.1014 0 9.13086C0 4.1603 4.02944 0.130859 9 0.130859ZM9 2.63086C8.44771 2.63086 8 3.07857 8 3.63086V8.88086C8 9.57122 8.55964 10.1309 9.25 10.1309H12.5C13.0523 10.1309 13.5 9.68314 13.5 9.13086C13.5 8.57857 13.0523 8.13086 12.5 8.13086H10V3.63086C10 3.07857 9.55229 2.63086 9 2.63086Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@ -0,0 +1,45 @@
<svg width="303" height="100" viewBox="0 0 403 135" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_2610_1468" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="403" height="135">
<rect width="403" height="135" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_2610_1468)">
<path d="M162.293 51.3874C165.003 47.9968 172.492 55.1223 175.897 59.1089L163.363 60.1427C160.856 59.0426 159.582 54.7779 162.293 51.3874Z" fill="#FFDBA3"/>
<path d="M164.084 52.7696C166.036 49.7687 170.582 55.3988 172.611 58.589L166.409 58.9204C164.82 58.1206 162.132 55.7706 164.084 52.7696Z" fill="#AC7E35"/>
<path d="M242.275 51.3874C239.565 47.9968 232.077 55.1223 228.671 59.1089L241.205 60.1427C243.712 59.0426 244.986 54.7779 242.275 51.3874Z" fill="#FFDBA3"/>
<path d="M240.487 52.7696C238.535 49.7687 233.989 55.3988 231.96 58.589L238.162 58.9204C239.75 58.1206 242.439 55.7706 240.487 52.7696Z" fill="#AC7E35"/>
<path d="M107.996 103.821C149.775 31.4558 254.225 31.4559 296.004 103.821L324.502 153.179C366.282 225.544 314.057 316 230.497 316H173.503C89.9431 316 37.7184 225.544 79.4981 153.179L107.996 103.821Z" fill="#FFD18A"/>
<path d="M196.064 69.1265C196.176 67.9873 195.281 67 194.137 67C192.992 67 192.097 67.9873 192.209 69.1265L192.437 71.4418C192.523 72.3163 193.258 72.9829 194.137 72.9829C195.015 72.9829 195.751 72.3163 195.837 71.4418L196.064 69.1265Z" fill="#4C320C"/>
<path d="M210.769 69.1265C210.881 67.9873 209.986 67 208.842 67C207.697 67 206.802 67.9873 206.914 69.1265L207.142 71.4418C207.228 72.3163 207.963 72.9829 208.842 72.9829C209.72 72.9829 210.456 72.3163 210.542 71.4418L210.769 69.1265Z" fill="#4C320C"/>
<path d="M140.736 142.883C170.285 99.9274 233.716 99.9274 263.265 142.883L296.918 191.806C330.855 241.142 295.535 308.307 235.654 308.307H168.347C108.466 308.307 73.1464 241.142 107.083 191.806L140.736 142.883Z" fill="#FFF8DE"/>
<g filter="url(#filter0_i_2610_1468)">
<ellipse cx="261.402" cy="108.682" rx="70.0855" ry="50" fill="#FFF8DE"/>
</g>
<g filter="url(#filter1_i_2610_1468)">
<ellipse cx="142.17" cy="108.682" rx="69.6581" ry="50" fill="#FFF8DE"/>
</g>
<ellipse cx="201.572" cy="83.8953" rx="5.12821" ry="3.84615" transform="rotate(180 201.572 83.8953)" fill="#FFB8B9"/>
<path d="M200.833 88.1688C201.162 87.599 201.984 87.599 202.313 88.1688L203.053 89.4508C203.382 90.0206 202.971 90.7329 202.313 90.7329H200.833C200.175 90.7329 199.764 90.0206 200.093 89.4508L200.833 88.1688Z" fill="#4C320C"/>
</g>
<defs>
<filter id="filter0_i_2610_1468" x="183.624" y="58.6816" width="147.863" height="102.564" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-7.69231" dy="2.5641"/>
<feGaussianBlur stdDeviation="7.05128"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2610_1468"/>
</filter>
<filter id="filter1_i_2610_1468" x="72.5122" y="58.6816" width="147.863" height="100" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="8.54701"/>
<feGaussianBlur stdDeviation="4.70086"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2610_1468"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,3 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 0.609375C3.35685 0.609375 0 3.96623 0 8.10938C0 12.2525 3.35685 15.6094 7.5 15.6094C11.6431 15.6094 15 12.2525 15 8.10938C15 3.96623 11.6431 0.609375 7.5 0.609375ZM9.91935 5.69002C10.4546 5.69002 10.8871 6.12248 10.8871 6.65776C10.8871 7.19304 10.4546 7.6255 9.91935 7.6255C9.38407 7.6255 8.95161 7.19304 8.95161 6.65776C8.95161 6.12248 9.38407 5.69002 9.91935 5.69002ZM5.08065 5.69002C5.61593 5.69002 6.04839 6.12248 6.04839 6.65776C6.04839 7.19304 5.61593 7.6255 5.08065 7.6255C4.54536 7.6255 4.1129 7.19304 4.1129 6.65776C4.1129 6.12248 4.54536 5.69002 5.08065 5.69002ZM10.9718 10.8372C10.1099 11.8715 8.84577 12.4642 7.5 12.4642C6.15423 12.4642 4.89012 11.8715 4.02823 10.8372C3.61694 10.3443 4.36089 9.72732 4.77218 10.2172C5.4496 11.0307 6.44153 11.4934 7.5 11.4934C8.55847 11.4934 9.5504 11.0277 10.2278 10.2172C10.6331 9.72732 11.38 10.3443 10.9718 10.8372Z" fill="#E2793F"/>
</svg>

After

Width:  |  Height:  |  Size: 1001 B

View File

@ -0,0 +1,3 @@
<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.458 6.625C11.458 7.06787 11.4563 7.50119 11.5039 7.85547C11.5554 8.23848 11.6811 8.67601 12.0439 9.03906C12.407 9.40204 12.8445 9.52756 13.2275 9.5791C13.5819 9.62674 14.015 9.625 14.458 9.625H19.167V15.333C19.167 18.0436 19.1663 19.3991 18.3242 20.2412C17.4822 21.0831 16.1272 21.083 13.417 21.083H9.58301C6.87282 21.083 5.51783 21.0831 4.67578 20.2412C3.83371 19.3991 3.83301 18.0436 3.83301 15.333V7.66699C3.83301 4.95642 3.83371 3.60086 4.67578 2.75879C5.51783 1.91692 6.87282 1.91699 9.58301 1.91699H11.458V6.625ZM10.3857 11.6191C9.5521 11.2096 8.62504 11.9102 8.625 12.9502V15.7998C8.62504 16.8398 9.5521 17.5404 10.3857 17.1309L13.7373 15.4844C14.5875 15.0661 14.5875 13.6839 13.7373 13.2656L10.3857 11.6191ZM13.458 1.92383C13.8295 1.93478 14.0807 1.9657 14.3145 2.0625C14.6667 2.20839 14.9441 2.48511 15.498 3.03906L18.0439 5.58594C18.5976 6.13964 18.8746 6.4165 19.0205 6.76855C19.1173 7.00226 19.1472 7.25364 19.1582 7.625H14.458C13.9586 7.625 13.6843 7.62223 13.4941 7.59668C13.4916 7.59626 13.4888 7.59507 13.4863 7.59473C13.4861 7.59292 13.4856 7.59073 13.4854 7.58887C13.4598 7.39873 13.458 7.12415 13.458 6.625V1.92383Z" fill="#FFB645"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.95801 5.08496C7.95801 5.52783 7.95632 5.96115 8.00391 6.31543C8.05544 6.69844 8.18108 7.13597 8.54395 7.49902C8.90701 7.862 9.34448 7.98752 9.72754 8.03906C10.0819 8.0867 10.515 8.08496 10.958 8.08496H15.667V13.793C15.667 16.5035 15.6663 17.8591 14.8242 18.7012C13.9822 19.543 12.6272 19.543 9.91699 19.543H6.08301C3.37282 19.543 2.01783 19.543 1.17578 18.7012C0.333713 17.8591 0.333008 16.5035 0.333008 13.793V6.12695C0.333008 3.41638 0.333713 2.06082 1.17578 1.21875C2.01783 0.376878 3.37282 0.376953 6.08301 0.376953H7.95801V5.08496ZM6.88574 10.0791C6.0521 9.66953 5.12504 10.3702 5.125 11.4102V14.2598C5.12504 15.2997 6.0521 16.0004 6.88574 15.5908L10.2373 13.9443C11.0875 13.526 11.0875 12.1439 10.2373 11.7256L6.88574 10.0791ZM9.95801 0.383789C10.3295 0.394738 10.5807 0.425665 10.8145 0.522461C11.1667 0.668355 11.4441 0.945073 11.998 1.49902L14.5439 4.0459C15.0976 4.5996 15.3746 4.87646 15.5205 5.22852C15.6173 5.46222 15.6472 5.7136 15.6582 6.08496H10.958C10.4586 6.08496 10.1843 6.08219 9.99414 6.05664C9.9916 6.05622 9.98876 6.05503 9.98633 6.05469C9.98606 6.05288 9.9856 6.0507 9.98535 6.04883C9.95983 5.85869 9.95801 5.58411 9.95801 5.08496V0.383789Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,3 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 0C13 0 13.7173 9.29492 16.5112 12.0888C19.3051 14.8827 26 15.6 26 15.6C26 15.6 19.3051 16.3173 16.5112 19.1112C13.7173 21.9051 13 26 13 26C13 26 12.2827 21.9051 9.48882 19.1112C6.69492 16.3173 0 15.6 0 15.6C0 15.6 6.69492 14.8827 9.48882 12.0888C12.2827 9.29492 13 0 13 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -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<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>;
modalVisible: { visible: boolean, data: Video | MaterialItem };
setModalDetailsVisible: React.Dispatch<React.SetStateAction<boolean>>;
modalDetailsVisible: boolean;
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
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 (
<View className={`flex-row items-start gap-2 w-full ${isUser ? 'justify-end' : 'justify-start'}`}>
{!isUser && <ChatSvg width={36} height={36} />}
<View className="max-w-[90%] mb-[1rem] flex flex-col gap-2">
<View
style={[
styles.messageBubble,
isUser ? styles.userBubble : styles.aiBubble
]}
className={`${isUser ? '!bg-bgPrimary ml-10 rounded-full' : '!bg-aiBubble mr-10 rounded-2xl'} border-0 ${!isUser && (item.content.video_material_infos && item.content.video_material_infos.length > 0 || item.content.image_material_infos && item.content.image_material_infos.length > 0) ? '!rounded-t-3xl !rounded-b-2xl' : '!rounded-3xl'}`}
>
<View className={`${isUser ? 'bg-bgPrimary' : 'bg-aiBubble'}`}>
<Text style={isUser ? styles.userText : styles.aiText}>
{!isUser
?
sessionId ? item.content.text : <TypewriterText text={item.content.text} speed={100} loop={item.content.text == "正在寻找,请稍等..."} />
: item.content.text
}
</Text>
{(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.length || 0 > 0) && (
<View className="relative">
<View style={[styles.imageGridContainer, { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' }]}>
{mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.slice(0, 3)?.map((image) => (
<Pressable
key={image?.id || image?.video?.id}
onPress={() => {
setModalVisible({ visible: true, data: image });
}}
style={{
width: '32%',
aspectRatio: 1,
marginBottom: 8,
}}
>
<Image
source={{ uri: image?.preview_file_info?.url || image.video?.preview_file_info?.url }}
style={{
width: '100%',
height: '100%',
borderRadius: 12,
}}
resizeMode="cover"
/>
</Pressable>
))}
</View>
{
((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3
&& <TouchableOpacity className="absolute top-1/2 -translate-y-1/2 -right-4 translate-x-1/2 bg-bgPrimary flex flex-row items-center gap-2 p-1 pl-2 rounded-full" onPress={() => {
setModalDetailsVisible(true);
}}>
<ThemedText className="!text-white font-semibold">{((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0))}</ThemedText>
<View className="bg-white rounded-full p-2">
<MoreSvg />
</View>
</TouchableOpacity>
}
</View>
)}
</View>
</View>
{/* {item.askAgain && item.askAgain.length > 0 && (
<View className={`mr-10`}>
{item.askAgain.map((suggestion, index, array) => (
<TouchableOpacity
key={suggestion.id}
className={`bg-yellow-50 rounded-xl px-4 py-2 border border-yellow-200 border-0 mb-2 ${index === array.length - 1 ? 'mb-0 rounded-b-3xl rounded-t-2xl' : 'rounded-2xl'}`}
>
<Text className="text-gray-700">{suggestion.text}</Text>
</TouchableOpacity>
))}
</View>
)} */}
<Modal
animationType="fade"
transparent={true}
visible={modalVisible.visible}
onRequestClose={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem });
}}>
<View style={styles.centeredView}>
<TouchableOpacity
style={styles.background}
onPress={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem })
}}
/>
<TouchableOpacity style={styles.modalView} onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}>
{isVideo(modalVisible.data) ? (
// 视频播放器
<TouchableOpacity
activeOpacity={1}
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
maxHeight: "60%",
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
>
<VideoPlayer
videoUrl={modalVisible.data.video.file_info.url}
style={{
width: '100%',
height: '100%',
alignSelf: 'center',
justifyContent: 'center',
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
/>
</TouchableOpacity>
) : (
// 图片预览
<TouchableOpacity
activeOpacity={1}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
style={styles.imageContainer}
>
<Image
source={{ uri: modalVisible.data.preview_file_info?.url }}
style={styles.fullWidthImage}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</TouchableOpacity>
</View>
</Modal>
<Modal
animationType="fade"
visible={modalDetailsVisible}
transparent={false}
statusBarTranslucent={true}
onRequestClose={() => {
setModalDetailsVisible(false);
}}
>
<View style={[detailsStyles.container, { paddingTop: insets?.top }]}>
<View style={detailsStyles.header}>
<TouchableOpacity onPress={() => setModalDetailsVisible(false)}>
<ReturnArrow />
</TouchableOpacity>
<ThemedText style={detailsStyles.headerText}>{t('ask.selectPhoto', { ns: 'ask' })}</ThemedText>
<FolderSvg />
</View>
<View style={{ overflow: 'scroll', height: "100%" }}>
<FlatList
data={mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])}
numColumns={3}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={detailsStyles.flatListContent}
initialNumToRender={12}
maxToRenderPerBatch={12}
updateCellsBatchingPeriod={50}
windowSize={10}
removeClippedSubviews={true}
renderItem={({ item }) => {
return (
<TouchableOpacity
style={detailsStyles.gridItemContainer}
key={item.id}
>
<View style={detailsStyles.gridItem}>
<ThemedText style={detailsStyles.imageNumber}>
{selectedImages?.map((image, index) => {
if (image === item.id || image === item.video?.id) {
return index + 1
}
})}
</ThemedText>
<Image
source={{ uri: item?.preview_file_info?.url || item.video?.preview_file_info?.url }}
style={detailsStyles.image}
onError={(error) => console.log('Image load error:', error.nativeEvent.error)}
onLoad={() => console.log('Image loaded successfully')}
/>
<TouchableOpacity
style={[detailsStyles.circleMarker, selectedImages.includes(item?.id || item?.video?.id) ? detailsStyles.circleMarkerSelected : ""]}
onPress={() => {
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) ? <YesSvg width={16} height={16} /> : ""}
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}}
/>
</View>
<View style={detailsStyles.footer}>
<TouchableOpacity
style={detailsStyles.continueButton}
onPress={async () => {
// 如果用户没有选择 则为选择全部
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}
>
<Text style={detailsStyles.continueButtonText}>
{t('ask.continueAsking', { ns: 'ask' })}
</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</View>
</View>
);
};
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',
}
});

View File

@ -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<FlatListProps<Message>, 'data' | 'renderItem'> {
userMessages: Message[];
sessionId: string;
setSelectedImages: Dispatch<SetStateAction<string[]>>;
selectedImages: string[];
}
function ChatComponent({ userMessages, sessionId, setSelectedImages, selectedImages }: ChatProps) {
const flatListRef = useRef<FlatList>(null);
function ChatComponent(
{ userMessages, sessionId, setSelectedImages, selectedImages, ...restProps }: ChatProps,
ref: ForwardedRef<FlatList<Message>>
) {
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<boolean>(false);
const [modalDetailsVisible, setModalDetailsVisible] = useState<{ visible: boolean, content: any }>({ visible: false, content: [] });
const flatListRef = useRef<FlatList>(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 (
<View style={itemStyle}>
<MessageItem
item={item}
insets={insets}
sessionId={sessionId}
modalVisible={modalVisible}
setModalVisible={setModalVisible}
modalDetailsVisible={modalDetailsVisible}
setModalDetailsVisible={setModalDetailsVisible}
selectedImages={selectedImages}
setSelectedImages={setSelectedImages}
t={t}
cancel={cancel}
setCancel={setCancel}
/>
</View>
);
}, [insets, sessionId, modalVisible, modalDetailsVisible, selectedImages, t, cancel]);
return (
<SafeAreaView className='flex-1'>
<SafeAreaView style={{ flex: 1 }}>
<FlatList
ref={flatListRef}
ref={(node) => {
// 处理转发 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}
/>
{/* 单个图片弹窗 */}
<SingleContentModel modalVisible={modalVisible} setModalVisible={setModalVisible} />
{/* 全部图片详情弹窗 */}
<SelectModel
modalDetailsVisible={modalDetailsVisible}
setModalDetailsVisible={setModalDetailsVisible}
insets={insets}
setSelectedImages={setSelectedImages}
selectedImages={selectedImages}
t={t}
setCancel={setCancel}
cancel={cancel}
/>
</SafeAreaView>
);
}
// 使用 React.memo 包装组件,避免不必要的重渲染
export default memo(ChatComponent);
export default React.memo(forwardRef(ChatComponent));

View File

@ -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<SetStateAction<Message[]>>;
setConversationId: Dispatch<SetStateAction<string | null>>;
setIsHello: Dispatch<SetStateAction<boolean>>;
}
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 (
<View className="flex-1 bg-white w-full">
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: 16,
paddingHorizontal: 8,
paddingBottom: 20
}}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
>
<View className="items-center">
<ThemedText className="text-3xl font-bold text-center">
<ThemedText style={{ fontSize: 32, fontWeight: 'bold', textAlign: 'center', lineHeight: 40, }}>
{t('ask.hi', { ns: 'ask' })}
{"\n"}
{t('ask.iAmMemo', { ns: 'ask' })}
</ThemedText>
<View className="justify-center items-center my-4">
<IP />
<View>
<Image source={require('@/assets/images/png/icon/ip.png')} style={{ width: width * 0.5, height: height * 0.3 }} />
</View>
<ThemedText className="!text-textPrimary text-center">
<ThemedText className="!text-textPrimary text-center -mt-10" style={{ fontSize: 16 }}>
{t('ask.ready', { ns: 'ask' })}
{"\n"}
{t('ask.justAsk', { ns: 'ask' })}
</ThemedText>
<View style={styles.caseContainer}>
<TouchableOpacity onPress={() => {
handleCase(t('ask:ask.case1'));
}}>
<ThemedText style={styles.case}>
{t('ask:ask.case1')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
handleCase(t('ask:ask.case2'));
}}>
<ThemedText style={styles.case}>
{t('ask:ask.case2')}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
handleCase(t('ask:ask.case3'));
}}>
<ThemedText style={styles.case}>
{t('ask:ask.case3')}
</ThemedText>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</View>
);
}
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"
}
})

View File

@ -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<React.SetStateAction<{ visible: boolean, content: any }>>;
insets: { top: number };
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
selectedImages: string[];
t: TFunction;
cancel: boolean;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
}
const SelectModel = ({ modalDetailsVisible, setModalDetailsVisible, insets, setSelectedImages, selectedImages, t, cancel, setCancel }: SelectModelProps) => {
const items = modalDetailsVisible?.content?.filter((media: ContentPart) => media.type !== 'text');
return (
<Modal
animationType="fade"
visible={modalDetailsVisible.visible}
transparent={false}
statusBarTranslucent={true}
onRequestClose={() => {
setModalDetailsVisible({ visible: false, content: [] });
}}
>
<View style={[detailsStyles.container, { paddingTop: insets?.top }]}>
<View style={detailsStyles.header}>
<TouchableOpacity onPress={() => setModalDetailsVisible({ visible: false, content: [] })}>
<ReturnArrow />
</TouchableOpacity>
<ThemedText style={detailsStyles.headerText}>{t('ask.selectPhoto', { ns: 'ask' })}</ThemedText>
<FolderSvg />
</View>
<View style={{ flex: 1 }}>
<FlatList
data={items}
numColumns={3}
keyExtractor={(item) => 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 (
<View style={detailsStyles.gridItemContainer} key={itemId}>
<View style={detailsStyles.gridItem}>
{isSelected && (
<ThemedText style={detailsStyles.imageNumber}>
{selectedImages.indexOf(itemId) + 1}
</ThemedText>
)}
<ContextMenu
items={[
{
svg: <DownloadSvg width={20} height={20} />,
label: t("ask:ask.save"),
onPress: () => {
const imageUrl = item?.url;
if (imageUrl) {
saveMediaToGallery(imageUrl, t);
}
},
textStyle: { color: '#4C320C' }
},
{
svg: <CancelSvg width={20} height={20} color='red' />,
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' ? (
<Image
source={{ uri: item?.url }}
style={detailsStyles.image}
onError={(error) => console.log('Image load error:', error.nativeEvent.error)}
onLoad={() => console.log('Image loaded successfully')}
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
/>
) : (
<VideoPlayer
videoUrl={item?.url || ""}
style={{
width: '100%',
height: '100%',
alignSelf: 'center',
justifyContent: 'center',
}}
/>
)}
</ContextMenu>
<TouchableOpacity
style={[
detailsStyles.circleMarker,
isSelected && detailsStyles.circleMarkerSelected
]}
onPress={() => {
setSelectedImages(prev =>
isSelected
? prev.filter(id => id !== itemId)
: [...prev, itemId]
);
}}
activeOpacity={0.8}
>
{isSelected && <YesSvg width={16} height={16} />}
</TouchableOpacity>
</View>
</View>
);
}}
/>
</View>
<View style={detailsStyles.footer}>
<TouchableOpacity
style={detailsStyles.continueButton}
onPress={async () => {
// 如果用户没有选择 则为选择全部
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}
>
<ThemedText style={detailsStyles.continueButtonText}>
{t('ask.continueAsking', { ns: 'ask' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
</Modal >
)
}
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

View File

@ -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<SetStateAction<boolean>>,
@ -20,97 +26,232 @@ interface Props {
selectedImages: string[];
setSelectedImages: Dispatch<SetStateAction<string[]>>;
}
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<string>("/chat/new", {
method: "POST",
});
setConversationId(data);
await getConversation({ session_id: data, user_text, material_ids: [] });
}, []);
// 添加一个ref来跟踪键盘状态
const isKeyboardVisible = useRef(false);
const chunkQueue = useRef<string[]>([]);
const renderInterval = useRef<ReturnType<typeof setInterval> | 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<Message>(`/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 (
<View style={styles.container}>
<View className="relative w-full">
<ScrollView horizontal={true}>
<TouchableOpacity style={[styles.button, { borderColor: '#FFB645' }]} onPress={() => handleQuitly('search')}>
<SunSvg width={18} height={18} />
<ThemedText>{t("ask:ask.search")}</ThemedText>
</TouchableOpacity><TouchableOpacity style={[styles.button, { borderColor: '#E2793F' }]} onPress={() => handleQuitly('video')}>
<VideoSvg width={18} height={18} />
<ThemedText>{t("ask:ask.video")}</ThemedText>
</TouchableOpacity>
</ScrollView>
<TextInput
style={styles.input}
placeholder="Ask MeMo Anything..."
@ -124,13 +265,14 @@ export default function SendMessage(props: Props) {
returnKeyType="send"
/>
<TouchableOpacity
style={styles.voiceButton}
onPress={handleSubmit}
className={`absolute right-0 top-1/2 -translate-y-1/2 `} // 使用绝对定位将按钮放在输入框内右侧
style={{
position: 'absolute',
right: 6,
bottom: 6
}}
>
<View style={{ transform: [{ rotate: '330deg' }] }}>
<SendSvg color={'white'} width={24} height={24} />
</View>
<SendSvg />
</TouchableOpacity>
</View>
</View>
@ -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, // 添加一点右边距
},
});

View File

@ -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<React.SetStateAction<{ visible: boolean, data: ContentPart }>>;
}
const SingleContentModel = ({ modalVisible, setModalVisible }: SingleContentModelProps) => {
const isVideo = (data: ContentPart) => {
return data.type === 'video';
};
return (
<Modal
animationType="fade"
transparent={true}
visible={modalVisible.visible}
onRequestClose={() => {
setModalVisible({ visible: false, data: {} as ContentPart });
}}>
<View style={styles.centeredView}>
<TouchableOpacity
style={styles.background}
onPress={() => {
setModalVisible({ visible: false, data: {} as ContentPart })
}}
/>
<TouchableOpacity style={styles.modalView} onPress={() => setModalVisible({ visible: false, data: {} as ContentPart })}>
{isVideo(modalVisible.data) ? (
// 视频播放器
<TouchableOpacity
activeOpacity={1}
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
maxHeight: "60%",
}}
onPress={() => setModalVisible({ visible: false, data: {} as ContentPart })}
>
<VideoPlayer
videoUrl={modalVisible.data.url || ""}
style={{
width: '100%',
height: '100%',
alignSelf: 'center',
justifyContent: 'center',
}}
onPress={() => setModalVisible({ visible: false, data: {} as ContentPart })}
/>
</TouchableOpacity>
) : (
// 图片预览
<TouchableOpacity
activeOpacity={1}
onPress={() => setModalVisible({ visible: false, data: {} as ContentPart })}
style={styles.imageContainer}
>
<Image
source={{ uri: modalVisible.data.url }}
style={styles.fullWidthImage}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</TouchableOpacity>
</View>
</Modal>
)
}
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

View File

@ -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 (
<View style={styles.container}>
<Animated.View style={[styles.dot, { backgroundColor: color1 }]} />
<Animated.View style={[styles.dot, { backgroundColor: color2 }]} />
<Animated.View style={[styles.dot, { backgroundColor: color3 }]} />
</View>
);
};
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;

View File

@ -10,7 +10,7 @@ interface TypewriterTextProps {
const TypewriterText: React.FC<TypewriterTextProps> = ({
text,
speed = 150,
speed = 100,
loop = false,
delay = 2000,
}) => {

View File

@ -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<string>("/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<Message | undefined> => {
// 获取对话信息必须要有对话id
if (!session_id) return undefined;
try {
const response = await fetchApi<Message>(`/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);
}
}
};

View File

@ -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<SetStateAction<Message[]>>;
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<NodeJS.Timeout | number>(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<string>("/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<Message>(`/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 (
<View style={styles.container}>
<View className="relative w-full">
{/* <TouchableOpacity
onPress={() => 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"}`} // 使用绝对定位将按钮放在输入框内右侧
>
<VoiceDeleteSvg />
</TouchableOpacity> */}
<TextInput
style={styles.input}
placeholder="Ask MeMo Anything..."
placeholderTextColor="#999"
className={isVoiceStart ? 'bg-bgPrimary border-none pl-12' : ''}
value={isVoiceStart ? `· · · · · · · · · · · · · · ${formatTime(elapsedTime)}` : inputValue}
onChangeText={(text: string) => {
setInputValue(text);
}}
onSubmitEditing={handleSubmit}
editable={!isVoiceStart}
// 调起的键盘类型
returnKeyType="send"
/>
{/* <TouchableOpacity
style={styles.voiceButton}
className={`absolute right-0 top-1/2 -translate-y-1/2 ${isVoiceStart ? 'bg-white px-8' : 'bg-bgPrimary'}`} // 使用绝对定位将按钮放在输入框内右侧
onPress={isVoiceStart ? stopRecording : record}
>
{isVoiceStart ? <VoiceSendSvg /> : <VoiceSvg />}
</TouchableOpacity> */}
</View>
</View>
);
}
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, // 添加一点右边距
},
});

View File

@ -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;
}

View File

@ -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<React.SetStateAction<{ visible: boolean, data: ContentPart }>>;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
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 (
<View style={[styles.container, getContainerStyle()]}>
{displayItems.map((media, index) => (
<Pressable
key={media.id}
onPress={() => {
setModalVisible({ visible: true, data: media });
}}
style={[styles.imageContainer, { width: getItemWidth() }]}
>
<ContextMenu
items={[
{
svg: <DownloadSvg width={20} height={20} />,
label: t("ask:ask.save"),
onPress: () => {
if (media?.url) {
saveMediaToGallery(media?.url, t);
}
},
textStyle: { color: '#4C320C' }
},
{
svg: <CancelSvg width={20} height={20} color='red' />,
label: t("ask:ask.cancel"),
onPress: () => {
setCancel(true);
},
textStyle: { color: 'red' }
}
]}
cancel={cancel}
menuStyle={styles.contextMenu}
>
<Image
source={{ uri: media?.url }}
style={styles.image}
resizeMode="cover"
loadingIndicatorSource={require('@/assets/images/png/placeholder.png')}
/>
{itemCount > 3 && index === 5 && mediaItems.length > 6 && (
<View style={styles.overlay}>
<ThemedText style={styles.overlayText}>+{mediaItems.length - 5}</ThemedText>
</View>
)}
</ContextMenu>
</Pressable>
))}
</View>
);
};
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;

View File

@ -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<React.SetStateAction<{ visible: boolean, data: any }>>;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
t: any;
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
}
const MessageBubble = ({
item,
isUser,
setModalVisible,
setCancel,
cancel,
t,
setSelectedImages,
setModalDetailsVisible
}: MessageBubbleProps) => {
return (
<View
className={`${isUser ? '!bg-bgPrimary ml-10 rounded-full' : '!bg-aiBubble rounded-2xl'} border-0 ${!isUser && isMessageContainMedia(item) ? '!rounded-t-3xl !rounded-b-2xl' : '!rounded-3xl'} px-3`}
style={{ marginRight: getMessageText(item) == "keepSearchIng" ? 0 : isUser ? 0 : 10 }}
>
<MessageContent
item={item}
isUser={isUser}
setModalVisible={setModalVisible}
setCancel={setCancel}
cancel={cancel}
t={t}
setSelectedImages={setSelectedImages}
setModalDetailsVisible={setModalDetailsVisible}
/>
</View>
);
};
export default React.memo(MessageBubble);

View File

@ -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<React.SetStateAction<{ visible: boolean, data: ContentPart }>>;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
t: TFunction;
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
}
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 (
<View className={`${isUser ? 'bg-bgPrimary' : 'bg-aiBubble'} rounded-2xl`}>
{getMessageText(item) == "keepSearchIng" && !isUser ? (
<Loading />
) : (
<View className="px-2">
<Markdown style={chineseMarkdownStyle}>
{getMessageText(item)}
</Markdown>
</View>
)}
{isMessageContainMedia(item) && (
<View className="relative">
{item.content instanceof Array && (() => {
const mediaItems = item.content.filter((media: ContentPart) => media.type !== 'text');
return (
<View className="mt-2">
<MediaGrid
mediaItems={mediaItems}
setModalVisible={setModalVisible}
setCancel={setCancel}
cancel={cancel}
t={t}
/>
</View>
);
})()}
{
(item.content instanceof Array && item.content.length > 3)
&& <TouchableOpacity className="absolute top-1/2 -translate-y-1/2 -right-4 translate-x-1/2 bg-bgPrimary flex flex-row items-center gap-2 p-1 pl-2 rounded-full" onPress={() => {
setSelectedImages([])
setModalDetailsVisible({ visible: true, content: item.content });
}}>
<ThemedText className="!text-white font-semibold">{item.content.length}</ThemedText>
<View className="bg-white rounded-full p-2">
<MoreSvg />
</View>
</TouchableOpacity>
}
</View>
)}
</View>
);
};
export default React.memo(MessageContent);

View File

@ -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<React.SetStateAction<{ visible: boolean, data: any }>>;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
t: any;
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
}
const MessageRow = ({
item,
isUser,
setModalVisible,
setCancel,
cancel,
t,
setSelectedImages,
setModalDetailsVisible
}: MessageRowProps) => {
return (
<View className="w-full" style={{ flexDirection: getMessageText(item) == "keepSearchIng" ? 'row' : 'column', alignItems: getMessageText(item) == "keepSearchIng" ? 'flex-end' : 'flex-start', gap: getMessageText(item) == "keepSearchIng" ? 8 : 0 }}>
<MessageBubble
item={item}
isUser={isUser}
setModalVisible={setModalVisible}
setCancel={setCancel}
cancel={cancel}
t={t}
setSelectedImages={setSelectedImages}
setModalDetailsVisible={setModalDetailsVisible}
/>
{
getMessageText(item) == "keepSearchIng"
&&
<Text className="text-[#d9d9d9]">
{t("ask:ask.think")}
</Text>
}
</View>
);
};
export default React.memo(MessageRow);

View File

@ -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<React.SetStateAction<{ visible: boolean, data: ContentPart }>>;
modalVisible: { visible: boolean, data: ContentPart };
setModalDetailsVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, content: any }>>;
modalDetailsVisible: { visible: boolean, content: any };
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
selectedImages: string[];
t: TFunction;
setCancel: React.Dispatch<React.SetStateAction<boolean>>;
cancel: boolean;
}
const MessageItem = ({ setCancel, cancel = true, t, insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => {
const isUser = item.role === User;
return (
<View className={`flex-row items-start gap-2 w-full ${isUser ? 'justify-end' : 'justify-start'}`}>
{!isUser && <ChatSvg width={36} height={36} />}
<View className="max-w-[85%] mb-[1rem] flex flex-col gap-2">
<MessageRow
item={item}
isUser={isUser}
setModalVisible={setModalVisible}
setCancel={setCancel}
cancel={cancel}
t={t}
setSelectedImages={setSelectedImages}
setModalDetailsVisible={setModalDetailsVisible}
/>
{/* {item.askAgain && item.askAgain.length > 0 && (
<View className={`mr-10`}>
{item.askAgain.map((suggestion, index, array) => (
<TouchableOpacity
key={suggestion.id}
className={`bg-yellow-50 rounded-xl px-4 py-2 border border-yellow-200 border-0 mb-2 ${index === array.length - 1 ? 'mb-0 rounded-b-3xl rounded-t-2xl' : 'rounded-2xl'}`}
>
<Text className="text-gray-700">{suggestion.text}</Text>
</TouchableOpacity>
))}
</View>
)} */}
</View>
</View>
);
};
export default React.memo(MessageItem);

View File

@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
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 (
<View style={styles.container}>
<Text style={styles.title}>{translations.error}</Text>
<Text style={styles.error}>
{this.state.error?.message || translations.issue}
</Text>
<TouchableOpacity style={styles.retryButton} onPress={this.handleRetry}>
<Text style={styles.retryText}>{translations.retry}</Text>
</TouchableOpacity>
</View>
);
}
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;

View File

@ -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<PermissionAlertProps> = ({ visible, onConfirm, onCancel, title, message, confirmText, cancelText }) => {
const { t } = useTranslation();
if (!visible) {
return null;
}
return (
<View style={styles.overlay}>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<Text style={styles.modalTitle}>{title}</Text>
<Text style={styles.modalMessage}>{message}</Text>
<View style={styles.buttonContainer}>
<Pressable style={[styles.button, styles.cancelButton]} onPress={onCancel}>
<Text style={styles.buttonText}>{cancelText || t('cancel', { ns: 'permission' })}</Text>
</Pressable>
<Pressable style={[styles.button, styles.confirmButton]} onPress={onConfirm}>
<Text style={styles.confirmButtonText}>{confirmText || t('goToSettings', { ns: 'permission' })}</Text>
</Pressable>
</View>
</View>
</View>
</View>
);
};
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;

175
components/download/app.tsx Normal file
View File

@ -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 (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Main Content */}
<View style={styles.content}>
{/* App Icon */}
<LogoSvg />
{/* App Name */}
<Text style={styles.appName}>MemoWake</Text>
{/* QRCode */}
<View style={styles.qrCodeContainer}>
<UserinfoTotalSvg style={{ marginBottom: -20, zIndex: 1 }} />
<HandlersSvg style={{ marginBottom: -4, zIndex: 3 }} />
<View style={styles.qrCode}>
<QRDownloadScreen url={platform == "ios" ? IOS_APP_STORE_URL : ANDROID_APK_URL} />
</View>
</View>
{/* Description */}
<Text style={styles.description}>
{t('mobileDescription', { ns: 'download' })}
</Text>
{/* Download Button */}
<TouchableOpacity
style={styles.downloadButton}
onPress={Platform.OS === 'ios' ? handleAppStoreDownload : handlePlayStoreDownload}
>
<Text style={styles.downloadButtonText}>
{t('download', { ns: 'download' })}
</Text>
</TouchableOpacity>
</View>
</View>
);
};
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',
},
});

View File

@ -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 (
<LinearGradient
colors={['#FFB645', '#E2793F']}
className="flex-1 items-center justify-center p-6"
>
<View className="absolute top-0 left-0 w-full h-full">
<MemoIP width="100%" height="100%" style={{ opacity: 0.1 }} />
</View>
<View className="items-center mb-12">
<Text className="text-white text-5xl font-extrabold tracking-tight">
MemoWake
</Text>
<Text className="text-white/90 text-lg mt-4 text-center max-w-xs">
{t('desc', { ns: 'download' })}
</Text>
</View>
<View className="w-full max-w-xs">
<TouchableOpacity
className="bg-white/90 rounded-xl px-6 py-4 flex-row items-center justify-center shadow-lg mb-5"
onPress={handleIOSDownload}
activeOpacity={0.8}
>
<AppleLogo width={24} height={24} fill="black" />
<Text className="text-black font-bold text-lg ml-3">
{t('ios', { ns: 'download' })}
</Text>
</TouchableOpacity>
<TouchableOpacity
className="bg-black/80 rounded-xl px-6 py-4 flex-row items-center justify-center shadow-lg"
onPress={handleAndroidDownload}
activeOpacity={0.8}
>
<AndroidLogo width={24} height={24} fill="#3DDC84" />
<Text className="text-white font-bold text-lg ml-3">
{t('android', { ns: 'download' })}
</Text>
</TouchableOpacity>
</View>
</LinearGradient>
)
}
export default PCDownload

View File

@ -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<View>(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 (
<View style={styles.container}>
{/* 可截图的容器 */}
<TouchableOpacity onLongPress={saveQRToGallery} activeOpacity={0.8}>
<View ref={qrViewRef} style={styles.qrContainer}>
<QRCode value={qrValue} size={200} />
</View>
</TouchableOpacity>
</View>
);
}
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,
},
});

View File

@ -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<ImagesuploaderProps> = ({
@ -26,23 +27,6 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
const [files, setFiles] = useState<FileUploadItem[]>([]);
const [uploadQueue, setUploadQueue] = useState<FileUploadItem[]>([]);
// 请求权限
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<UploadResult | null> => {
console.log("asset111111", asset);
@ -64,6 +48,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
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<ImagesuploaderProps> = ({
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<ImagesuploaderProps> = ({
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<ImagesuploaderProps> = ({
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<ImagesuploaderProps> = ({
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<ImagesuploaderProps> = ({
}
}));
} catch (error) {
Alert.alert('错误', '部分文件处理失败,请重试');
PermissionService.show({ title: '错误', message: '部分文件处理失败,请重试' });
} finally {
setIsLoading(false);
}
} catch (error) {
Alert.alert('错误', '选择图片时出错,请重试');
PermissionService.show({ title: '错误', message: '选择图片时出错,请重试' });
} finally {
setIsLoading(false);
}

View File

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

View File

@ -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<ImagesPickerProps> = ({
@ -24,17 +25,13 @@ export const ImagesPicker: React.FC<ImagesPickerProps> = ({
// 请求权限
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<ImagesPickerProps> = ({
// 使用函数更新文件状态,确保每次更新都是原子的
const updateFileStatus = (updates: Partial<FileStatus>) => {
setCurrentFileStatus((original) => ({ ...original, ...updates }))
setCurrentFileStatus((original: FileStatus) => ({ ...original, ...updates } as FileStatus))
};
// 上传文件
const uploadFile = async (file: File, metadata: Record<string, any> = {}): Promise<ConfirmUpload> => {
@ -262,9 +259,11 @@ export const ImagesPicker: React.FC<ImagesPickerProps> = ({
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<ImagesPickerProps> = ({
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);
}

View File

@ -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) => {

View File

@ -47,7 +47,7 @@ export const Fireworks: React.FC<FireworksProps> = ({
const [particles, setParticles] = useState<Particle[]>([]);
const [isPlaying, setIsPlaying] = useState(autoPlay);
const particleId = useRef(0);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 生成随机位置
const getRandomPosition = () => {
@ -112,7 +112,7 @@ export const Fireworks: React.FC<FireworksProps> = ({
]),
// 旋转效果
Animated.timing(rotation, {
toValue: rotation._value + 360,
toValue: (rotation as Animated.Value & { _value: number })._value + 360,
duration: 2000,
easing: Easing.linear,
useNativeDriver: true,

View File

@ -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<TextStyle>;
}
interface ContextMenuProps {
children: React.ReactNode;
items: MenuItem[];
menuStyle?: StyleProp<ViewStyle>;
menuItemStyle?: StyleProp<ViewStyle>;
menuTextStyle?: StyleProp<TextStyle>;
dividerStyle?: StyleProp<ViewStyle>;
onOpen?: () => void;
onClose?: () => void;
longPressDuration?: number;
activeOpacity?: number;
cancel?: boolean;
}
const ContextMenu: React.FC<ContextMenuProps> = ({
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<View>(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 (
<>
<View ref={containerRef} collapsable={false} style={{ flex: 1 }}>
<GestureDetector gesture={gesture}>
<View style={{ flex: 1 }}>
{children}
</View>
</GestureDetector>
</View>
<Modal
visible={menuVisible}
transparent
animationType="fade"
onRequestClose={hideMenu}
>
<TouchableWithoutFeedback onPress={hideMenu}>
<View style={styles.modalOverlay} />
</TouchableWithoutFeedback>
<View
style={[
styles.menu,
{
position: 'absolute',
top: Math.min(
menuPosition.y,
screenHeight - 300
),
left: Math.min(
menuPosition.x > screenWidth / 2 ? menuPosition.x - 150 : menuPosition.x,
screenWidth - 160
),
},
menuStyle,
]}
onStartShouldSetResponder={() => true}
>
{items.map((item, index) => (
<React.Fragment key={item.label}>
<TouchableOpacity
style={[styles.menuItem, menuItemStyle]}
onPress={() => handleItemPress(item.onPress)}
activeOpacity={activeOpacity}
>
{item.svg}
<Text style={[styles.menuText, menuTextStyle, item.textStyle]}>
{item.label}
</Text>
</TouchableOpacity>
{index < items.length - 1 && (
<View style={[styles.divider, dividerStyle]} />
)}
</React.Fragment>
))}
</View>
</Modal>
</>
);
};
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;

View File

@ -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 <Icon width={24} height={24} />;
});
// 提取 SVG 组件,避免重复渲染
const CenterButtonSvg = React.memo(() => (
<Svg width="100%" height="100%" viewBox="0 0 85 85" fill="none">
<Mask id="mask0_1464_1669" maskUnits="userSpaceOnUse" x="0" y="0" width="85" height="85">
<Circle cx="42.5" cy="42.5" r="42.5" fill="#FFC959" />
</Mask>
<G mask="url(#mask0_1464_1669)">
<Circle cx="42.5" cy="42.5" r="42.5" fill="#FFD38D" />
<Path d="M20.2018 14.6411C21.8694 12.5551 26.4765 16.939 28.5716 19.3917L20.8604 20.0277C19.3178 19.3509 18.5342 16.7271 20.2018 14.6411Z" fill="#FFDBA3" />
<Path d="M21.3021 15.4913C22.503 13.6451 25.3001 17.1089 26.5485 19.0716L22.7323 19.2755C21.7552 18.7834 20.1012 17.3376 21.3021 15.4913Z" fill="#AC7E35" />
<Path d="M65.1253 14.6411C63.4577 12.5551 58.8506 16.939 56.7556 19.3917L64.4667 20.0277C66.0093 19.3509 66.7929 16.7271 65.1253 14.6411Z" fill="#FFDBA3" />
<Path d="M64.0255 15.4913C62.8246 13.6451 60.0276 17.1089 58.7792 19.0716L62.5953 19.2755C63.5724 18.7834 65.2264 17.3376 64.0255 15.4913Z" fill="#AC7E35" />
<Path d="M-15.3352 49.1734C10.3693 4.65192 74.6306 4.65187 100.335 49.1734L117.868 79.5409C143.572 124.062 111.442 179.714 60.0327 179.714H24.9673C-26.4417 179.714 -58.5724 124.062 -32.8679 79.5409L-15.3352 49.1734Z" fill="#FFD18A" />
<Rect x="38.5571" y="46.2812" width="2.62922" height="3.68091" rx="1.31461" transform="rotate(-180 38.5571 46.2812)" fill="#4C320C" />
<Rect x="48.0205" y="46.2812" width="2.62922" height="3.68091" rx="1.31461" transform="rotate(-180 48.0205 46.2812)" fill="#4C320C" />
<Path d="M4.8084 73.2062C22.9876 46.7781 62.0132 46.7782 80.1924 73.2062L100.897 103.306C121.776 133.659 100.046 174.982 63.2051 174.982H21.7957C-15.0453 174.982 -36.7756 133.659 -15.8963 103.306L4.8084 73.2062Z" fill="#FFF8DE" />
<Ellipse cx="79.047" cy="68.6298" rx="43.1193" ry="30.7619" fill="#FFF8DE" />
<Ellipse cx="5.69032" cy="68.6298" rx="42.8563" ry="30.7619" fill="#FFF8DE" />
<Ellipse cx="42.2365" cy="53.3803" rx="3.15507" ry="2.3663" transform="rotate(180 42.2365 53.3803)" fill="#FFB8B9" />
<Path d="M41.7813 56.0095C41.9837 55.6589 42.4897 55.6589 42.6921 56.0095L43.1475 56.7982C43.3499 57.1488 43.0969 57.587 42.6921 57.587H41.7813C41.3765 57.587 41.1235 57.1488 41.3259 56.7982L41.7813 56.0095Z" fill="#4C320C" />
</G>
</Svg>
));
interface AskNavbarProps {
wsStatus: WebSocketStatus;
}
const AskNavbar = ({ wsStatus }: AskNavbarProps) => {
// 获取设备尺寸
const { width } = Dimensions.get('window');
// 获取路由
const { width } = useMemo(() => Dimensions.get('window'), []);
const pathname = usePathname();
return (
<View className="absolute bottom-0 left-0 right-0 bg-white" style={{
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 }, // Negative height for bottom shadow
shadowOpacity: 0.1,
const statusColor = useMemo(() => {
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
}}>
{/* <NavbarSvg className="w-[150%] h-full" /> */}
<Image source={require('@/assets/images/png/owner/ask.png')} style={{ width: width, height: 80, resizeMode: 'cover' }} />
<View className="absolute bottom-0 top-0 left-0 right-0 flex flex-row justify-between items-center px-[2rem]">
<TouchableOpacity onPress={() => router.push('/memo-list')} style={{ padding: 16 }}>
{pathname === "/memo-list" ? <ChatInSvg width={24} height={24} /> : <ChatNotInSvg width={24} height={24} />}
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 (
<View style={styles.container}>
<Image source={require('@/assets/images/png/owner/ask.png')} style={{ width: width * 1.18, height: 100, resizeMode: 'cover', marginLeft: -width * 0.07 }} />
<View style={styles.navContainer}>
<TouchableOpacity
onPress={() => navigateTo('/memo-list')}
style={[styles.navButton, { alignItems: "flex-start", paddingLeft: 16 }]}
>
<TabIcon
isActive={pathname === "/memo-list"}
ActiveIcon={ChatInSvg}
InactiveIcon={ChatNotInSvg}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
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}
>
<View style={{
shadowColor: '#FFB645',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
borderRadius: 50,
backgroundColor: 'transparent',
zIndex: 10,
width: 85,
height: 85
}}>
<Svg width="100%" height="100%" viewBox="0 0 85 85" fill="none">
<Mask id="mask0_1464_1669" maskUnits="userSpaceOnUse" x="0" y="0" width="85" height="85">
<Circle cx="42.5" cy="42.5" r="42.5" fill="#FFC959" />
</Mask>
<G mask="url(#mask0_1464_1669)">
<Circle cx="42.5" cy="42.5" r="42.5" fill="#FFD38D" />
<Path d="M20.2018 14.6411C21.8694 12.5551 26.4765 16.939 28.5716 19.3917L20.8604 20.0277C19.3178 19.3509 18.5342 16.7271 20.2018 14.6411Z" fill="#FFDBA3" />
<Path d="M21.3021 15.4913C22.503 13.6451 25.3001 17.1089 26.5485 19.0716L22.7323 19.2755C21.7552 18.7834 20.1012 17.3376 21.3021 15.4913Z" fill="#AC7E35" />
<Path d="M65.1253 14.6411C63.4577 12.5551 58.8506 16.939 56.7556 19.3917L64.4667 20.0277C66.0093 19.3509 66.7929 16.7271 65.1253 14.6411Z" fill="#FFDBA3" />
<Path d="M64.0255 15.4913C62.8246 13.6451 60.0276 17.1089 58.7792 19.0716L62.5953 19.2755C63.5724 18.7834 65.2264 17.3376 64.0255 15.4913Z" fill="#AC7E35" />
<Path d="M-15.3352 49.1734C10.3693 4.65192 74.6306 4.65187 100.335 49.1734L117.868 79.5409C143.572 124.062 111.442 179.714 60.0327 179.714H24.9673C-26.4417 179.714 -58.5724 124.062 -32.8679 79.5409L-15.3352 49.1734Z" fill="#FFD18A" />
<Rect x="38.5571" y="46.2812" width="2.62922" height="3.68091" rx="1.31461" transform="rotate(-180 38.5571 46.2812)" fill="#4C320C" />
<Rect x="48.0205" y="46.2812" width="2.62922" height="3.68091" rx="1.31461" transform="rotate(-180 48.0205 46.2812)" fill="#4C320C" />
<Path d="M4.8084 73.2062C22.9876 46.7781 62.0132 46.7782 80.1924 73.2062L100.897 103.306C121.776 133.659 100.046 174.982 63.2051 174.982H21.7957C-15.0453 174.982 -36.7756 133.659 -15.8963 103.306L4.8084 73.2062Z" fill="#FFF8DE" />
<G>
<Ellipse cx="79.047" cy="68.6298" rx="43.1193" ry="30.7619" fill="#FFF8DE" />
</G>
<G>
<Ellipse cx="5.69032" cy="68.6298" rx="42.8563" ry="30.7619" fill="#FFF8DE" />
</G>
<Ellipse cx="42.2365" cy="53.3803" rx="3.15507" ry="2.3663" transform="rotate(180 42.2365 53.3803)" fill="#FFB8B9" />
<Path d="M41.7813 56.0095C41.9837 55.6589 42.4897 55.6589 42.6921 56.0095L43.1475 56.7982C43.3499 57.1488 43.0969 57.587 42.6921 57.587H41.7813C41.3765 57.587 41.1235 57.1488 41.3259 56.7982L41.7813 56.0095Z" fill="#4C320C" />
</G>
</Svg>
</View>
<View style={styles.statusIndicator} />
<Image source={require('@/assets/images/png/owner/askIP.png')} />
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/owner')} style={{ padding: 16 }}>
<View>
{pathname === "/owner" ? <PersonInSvg width={24} height={24} /> : <PersonNotInSvg width={24} height={24} />}
{/* <View className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full" /> */}
</View>
<TouchableOpacity
onPress={() => navigateTo('/owner')}
style={styles.navButton}
>
<TabIcon
isActive={pathname === "/owner"}
ActiveIcon={PersonInSvg}
InactiveIcon={PersonNotInSvg}
/>
</TouchableOpacity>
</View>
</View>
);
};
export default AskNavbar;
export default React.memo(AskNavbar);

View File

@ -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<RNTextInput | null>>(Array(6).fill(null));
const shakeAnim = useRef(new Animated.Value(0)).current;
const [code, setCode] = useState<string[]>(['', '', '', '', '', '']);
const [code, setCode] = useState<string[]>([]);
const [error, setError] = useState<string>('');
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 (
<View className="flex-1 bg-white p-6">
<View className="flex-1 justify-center">
<View className="items-center mb-8">
<ThemedText className="text-2xl font-semibold mb-2 text-gray-900">
{t("auth.telLogin.title", { ns: 'login' })}
<View style={styles.container}>
<View style={styles.contentContainer}>
<View style={styles.headerContainer}>
<ThemedText style={styles.title}>
{t("auth.telLogin.codeTitle", { ns: 'login' })}
</ThemedText>
<ThemedText className="text-base text-gray-600 text-center mb-1">
<ThemedText style={styles.subtitle}>
{t("auth.telLogin.secondTitle", { ns: 'login' })}
</ThemedText>
<ThemedText className="text-base font-medium !text-buttonFill">
<ThemedText style={styles.phoneNumber}>
{phone}
</ThemedText>
</View>
<Animated.View
style={{
transform: [{ translateX: shakeAnim }],
display: 'flex',
flexDirection: 'row',
gap: 24,
marginBottom: 16,
alignItems: 'center',
justifyContent: 'center',
<OTPInputView
pinCount={6}
onCodeChanged={(code) => {
setCode([code]);
}}
>
{code.map((digit, index) => (
<TextInput
key={index}
ref={(ref) => {
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}
/>
))}
</Animated.View>
<View className={`w-full flex-row justify-end mb-[1rem] items-center ${error ? 'opacity-100' : 'opacity-0'}`}>
onCodeFilled={() => {
handleTelLogin()
}}
code={code.join('')}
autoFocusOnLoad={false}
codeInputFieldStyle={styles.underlineStyleBase}
codeInputHighlightStyle={styles.underlineStyleHighLighted}
style={styles.otpContainer}
placeholderCharacter="-"
placeholderTextColor="#AC7E35"
/>
<View style={[styles.errorContainer, { opacity: error ? 1 : 0 }]}>
<Error />
<ThemedText className="text-base font-medium !text-buttonFill ml-2">
<ThemedText style={styles.errorText}>
{error}
</ThemedText>
</View>
<TouchableOpacity
className="bg-buttonFill py-3 rounded-full items-center justify-center"
onPress={handleTelLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#ffffff" />
) : (
<ThemedText className="!text-white font-medium text-base">
{t("auth.telLogin.continue", { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
<View className="flex-row justify-center mt-4">
<ThemedText className="!text-textPrimary">
<View style={styles.footerContainer}>
<ThemedText style={styles.footerText}>
{t("auth.telLogin.sendAgain", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
if (countdown > 0) {
return
} else {
if (countdown <= 0) {
sendVerificationCode()
}
}}>
<ThemedText className={`!text-buttonFill font-medium ml-1 ${countdown > 0 ? '!text-gray-400' : ''}`}>
<ThemedText style={[
styles.resendText,
countdown > 0 && styles.disabledResendText
]}>
{countdown > 0 ? `${countdown}s${t("auth.telLogin.resend", { ns: 'login' })}` : t("auth.telLogin.resend", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
<View className="py-4">
<TouchableOpacity
className="py-3 items-center"
onPress={() => setSteps('phone')}
>
<ThemedText className="!text-buttonFill font-medium">
{t("auth.telLogin.goBack", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
)
}
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
export default Code;

View File

@ -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<User>('/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 <View>
{/* 邮箱输入框 */}
<View className="mb-5">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.forgetPwd.title', { ns: 'login' })}
</ThemedText>
<TextInput
className="border border-gray-300 rounded-2xl p-3 text-base bg-inputBackground"
placeholder={t('auth.forgetPwd.emailPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* 发送重置密码邮件 */}
<TouchableOpacity
className={`w-full bg-[#E2793F] rounded-full p-4 items-center ${isDisabled ? 'opacity-50' : ''}`}
onPress={handleSubmit}
disabled={isDisabled || loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText className="!text-white font-semibold">
{isDisabled
? `${t("auth.forgetPwd.sendEmailBtnDisabled", { ns: "login" })} (${countdown}s)`
: t("auth.forgetPwd.sendEmailBtn", { ns: "login" })}
return (
<View style={styles.container}>
<View style={styles.inputContainer}>
<ThemedText style={styles.inputLabel}>
{t('auth.forgetPwd.title', { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
{/* 返回登陆 */}
<TouchableOpacity
className="self-center mt-6"
onPress={handleBackToLogin}
>
<ThemedText className="!text-textPrimary text-sm">
{t('auth.forgetPwd.goback', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
<TextInput
style={styles.textInput}
placeholder={t('auth.forgetPwd.emailPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
<TouchableOpacity
style={[
styles.submitButton,
(isDisabled || loading) && styles.disabledButton
]}
onPress={handleSubmit}
disabled={isDisabled || loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText style={styles.buttonText}>
{isDisabled
? `${t("auth.forgetPwd.sendEmailBtnDisabled", { ns: "login" })} (${countdown}s)`
: t("auth.forgetPwd.sendEmailBtn", { ns: "login" })}
</ThemedText>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.backButton}
onPress={handleBackToLogin}
>
<ThemedText style={styles.backButtonText}>
{t('auth.forgetPwd.goback', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
);
}
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
export default ForgetPwd;

View File

@ -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 <View>
{/* 邮箱输入框 */}
<View className="mb-5">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.login.email', { ns: 'login' })}
</ThemedText>
<TextInput
className="border border-gray-300 rounded-2xl p-3 text-base bg-inputBackground"
placeholder={t('auth.login.accountPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={email}
onChangeText={(text) => {
setEmail(text);
setError('123');
}}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* 密码输入框 */}
<View className="mb-2">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.login.password', { ns: 'login' })}
</ThemedText>
<View className="relative">
return (
<View style={styles.container}>
<View style={[styles.inputContainer, { marginBottom: 20 }]}>
<ThemedText style={styles.inputLabel}>
{t('auth.login.email', { ns: 'login' })}
</ThemedText>
<TextInput
className="border border-gray-300 rounded-2xl p-3 text-base bg-inputBackground pr-12"
placeholder={t('auth.login.passwordPlaceholder', { ns: 'login' })}
style={styles.textInput}
placeholder={t('auth.login.accountPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={password}
value={email}
onChangeText={(text) => {
setPassword(text);
setEmail(text);
setError('123');
}}
secureTextEntry={!showPassword}
autoCapitalize="none"
/>
<TouchableOpacity
className="absolute right-3 top-3.5"
onPress={() => setShowPassword(!showPassword)}
>
<Ionicons
name={showPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
</View>
<View style={styles.inputContainer}>
<ThemedText style={styles.inputLabel}>
{t('auth.login.password', { ns: 'login' })}
</ThemedText>
<View style={styles.passwordInputContainer}>
<TextInput
style={[styles.textInput, { paddingRight: 48 }]}
placeholder={t('auth.login.passwordPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={password}
onChangeText={(text) => {
setPassword(text);
setError('123');
}}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
style={styles.eyeIcon}
onPress={() => setShowPassword(!showPassword)}
>
<Ionicons
name={showPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
style={styles.forgotPassword}
onPress={handleForgotPassword}
>
<ThemedText style={styles.forgotPasswordText}>
{t('auth.login.forgotPassword', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[styles.loginButton, isLoading && { opacity: 0.7 }]}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText style={styles.loginButtonText}>
{t('auth.login.loginButton', { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
<View style={{ width: "100%", alignItems: "center", opacity: 0 }}>
<View style={styles.loginTypeContainer}>
<ThemedText>
OR
</ThemedText>
<View style={{ flexDirection: 'row', justifyContent: "space-between", width: "100%" }}>
<ThemedText style={styles.loginType} />
<ThemedText style={styles.loginType} />
<ThemedText style={styles.loginType} />
</View>
</View>
</View>
<View style={styles.signupContainer}>
<ThemedText style={styles.signupText}>
{t('auth.login.signUpMessage', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={handleSignUp}>
<ThemedText style={styles.signupLink}>
{t('auth.login.signUp', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
);
};
{/* 忘记密码链接 */}
<TouchableOpacity
className="self-end mb-6"
onPress={handleForgotPassword}
>
<ThemedText className="!text-textPrimary text-sm">
{t('auth.login.forgotPassword', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
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',
},
});
{/* 登录按钮 */}
<TouchableOpacity
className={`w-full bg-[#E2793F] rounded-full text-[#fff] p-4 items-center mb-6 ${isLoading ? 'opacity-70' : ''}`}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText className="!text-white font-semibold">
{t('auth.login.loginButton', { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
{/* 注册链接 */}
<View className="flex-row justify-center mt-2">
<ThemedText className="!text-textPrimary text-sm">
{t('auth.login.signUpMessage', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={handleSignUp}>
<ThemedText className="!text-[#E2793F] text-sm font-semibold ml-1">
{t('auth.login.signUp', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
}
export default Login
export default Login;

View File

@ -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<string>('');
@ -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("")

View File

@ -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<Steps>("phone");
const [phone, setPhone] = useState('');
return <View>
{
steps === "phone" ? <Phone setSteps={setSteps} setPhone={setPhone} phone={phone} /> : <Code setSteps={setSteps} phone={phone} />
steps === "phone" ? <Phone setSteps={setSteps} setPhone={setPhone} phone={phone} updateUrlParam={updateUrlParam} /> : <Code phone={phone} />
}
</View>
}

View File

@ -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 <View className="w-full">
{/* 邮箱输入 */}
<View className="mb-4">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.login.email', { ns: 'login' })}
</ThemedText>
<View className="border border-gray-300 rounded-2xl bg-inputBackground overflow-hidden">
<TextInput
className="p-3 text-base flex-1"
placeholder={t('auth.login.accountPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={email}
onChangeText={(value) => {
setEmail(value)
setError('123')
}}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
</View>
{/* 密码输入 */}
<View className="mb-4">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.login.password', { ns: 'login' })}
</ThemedText>
<View className="border border-gray-300 rounded-2xl bg-inputBackground overflow-hidden flex-row items-center">
<TextInput
className="p-3 text-base flex-1"
placeholder={t('auth.login.passwordPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={password}
onChangeText={(value) => {
handlePasswordChange(value)
setError('123')
}}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className="px-3 py-2"
>
<Ionicons
name={showPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
</View>
{/* 确认密码 */}
<View className="mb-6">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.signup.confirmPassword', { ns: 'login' })}
</ThemedText>
<View className="border border-gray-300 rounded-2xl bg-inputBackground overflow-hidden flex-row items-center">
<TextInput
className="p-3 text-base flex-1"
placeholder={t('auth.signup.confirmPasswordPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={confirmPassword}
onChangeText={(value) => {
handleConfirmPasswordChange(value)
setError('123')
}}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className="px-3 py-2"
>
<Ionicons
name={showPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
</View>
{/* 注册按钮 */}
<TouchableOpacity
className={`w-full bg-[#E2793F] rounded-full p-4 items-center ${loading ? 'opacity-70' : ''}`}
onPress={handleSubmit}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText className="!text-white font-semibold">
{t("auth.signup.signupButton", { ns: 'login' })}
return (
<View style={styles.container}>
{/* 邮箱输入 */}
<View style={styles.inputContainer}>
<ThemedText style={styles.inputLabel}>
{t('auth.login.email', { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
<View style={{ flexDirection: 'row', alignItems: 'center', marginVertical: 10 }}>
<TouchableOpacity
onPress={() => {
const newValue = !checked;
setChecked(newValue);
if (!newValue) {
setError(t('auth.signup.checkedRequired', { ns: 'login' }));
return
} else {
setError("123")
}
<View style={styles.inputWrapper}>
<TextInput
style={styles.textInput}
placeholder={t('auth.login.accountPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={email}
onChangeText={(value) => {
setEmail(value)
setError('123')
}}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
</View>
}}
style={{
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: checked ? '#E2793F' : '#ccc',
backgroundColor: checked ? '#E2793F' : 'transparent',
justifyContent: 'center',
alignItems: 'center',
marginRight: 8,
}}
{/* 密码输入 */}
<View style={styles.inputContainer}>
<ThemedText style={styles.inputLabel}>
{t('auth.login.password', { ns: 'login' })}
</ThemedText>
<View style={styles.passwordInputContainer}>
<TextInput
style={[styles.textInput, { flex: 1 }]}
placeholder={t('auth.login.passwordPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={password}
onChangeText={(value) => {
handlePasswordChange(value)
setError('123')
}}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={styles.eyeIcon}
>
<Ionicons
name={showPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
</View>
{/* 确认密码 */}
<View style={[styles.inputContainer, { marginBottom: 24 }]}>
<ThemedText style={styles.inputLabel}>
{t('auth.signup.confirmPassword', { ns: 'login' })}
</ThemedText>
<View style={styles.passwordInputContainer}>
<TextInput
style={[styles.textInput, { flex: 1 }]}
placeholder={t('auth.signup.confirmPasswordPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={confirmPassword}
onChangeText={(value) => {
handleConfirmPasswordChange(value)
setError('123')
}}
secureTextEntry={!showSecondPassword}
/>
<TouchableOpacity
onPress={() => setShowSecondPassword(!showSecondPassword)}
style={styles.eyeIcon}
>
<Ionicons
name={showSecondPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
</View>
{/* 注册按钮 */}
<TouchableOpacity
style={[styles.signupButton, loading && { opacity: 0.7 }]}
onPress={handleSubmit}
disabled={loading}
>
{checked && (
<Ionicons name="checkmark" size={14} color="white" />
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText style={styles.signupButtonText}>
{t("auth.signup.signupButton", { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', flex: 1 }}>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.agree", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
setModalType('terms');
setPrivacyModalVisible(true);
}}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.terms", { ns: 'login' })}
</ThemedText>
<View style={styles.termsContainer}>
<TouchableOpacity
onPress={() => {
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 && (
<Ionicons name="checkmark" size={14} color="white" />
)}
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
setModalType('privacy');
setPrivacyModalVisible(true);
}}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.privacyPolicy", { ns: 'login' })}
<View style={styles.termsTextContainer}>
<ThemedText style={styles.termsText}>
{t("auth.telLogin.agree", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
setModalType('user');
setPrivacyModalVisible(true);
}}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.userAgreement", { ns: 'login' })}
<TouchableOpacity onPress={() => {
setModalType('terms');
setPrivacyModalVisible(true);
}}>
<ThemedText style={styles.termsLink}>
{t("auth.telLogin.terms", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText style={styles.termsText}>
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
setModalType('ai');
setPrivacyModalVisible(true);
}}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.aiAgreement", { ns: 'login' })}
<TouchableOpacity onPress={() => {
setModalType('privacy');
setPrivacyModalVisible(true);
}}>
<ThemedText style={styles.termsLink}>
{t("auth.telLogin.privacyPolicy", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText style={styles.termsText}>
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.agreement", { ns: 'login' })}
</ThemedText>
<ThemedText className="text-sm !text-[#E2793F]">
{t("common.name")}
</ThemedText>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.getPhone", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
setModalType('user');
setPrivacyModalVisible(true);
}}>
<ThemedText style={styles.termsLink}>
{t("auth.telLogin.userAgreement", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText style={styles.termsText}>
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
setModalType('ai');
setPrivacyModalVisible(true);
}}>
<ThemedText style={styles.termsLink}>
{t("auth.telLogin.aiAgreement", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText style={styles.termsText}>
{t("auth.telLogin.agreement", { ns: 'login' })}
</ThemedText>
<ThemedText style={styles.termsLink}>
{t("common.name")}
</ThemedText>
<ThemedText style={styles.termsText}>
{t("auth.telLogin.getPhone", { ns: 'login' })}
</ThemedText>
</View>
</View>
</View>
{/* 已有账号 */}
<View className="flex-row justify-center mt-6">
<ThemedText className="text-sm !text-textPrimary">
{t("auth.signup.haveAccount", { ns: 'login' })}
</ThemedText>
<TouchableOpacity
onPress={() => {
updateUrlParam("status", "login");
}}
>
<ThemedText className="!text-[#E2793F] text-sm font-semibold ml-1">
{t("auth.signup.login", { ns: 'login' })}
{/* 已有账号 */}
<View style={styles.loginContainer}>
<ThemedText style={styles.loginText}>
{t("auth.signup.haveAccount", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
updateUrlParam("status", "login");
}}
>
<ThemedText style={styles.loginLink}>
{t("auth.signup.login", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
{/* 协议弹窗 */}
<PrivacyModal modalVisible={privacyModalVisible} setModalVisible={setPrivacyModalVisible} type={modalType} />
</View>
);
};
{/* 协议弹窗 */}
<PrivacyModal modalVisible={privacyModalVisible} setModalVisible={setPrivacyModalVisible} type={modalType} />
</View>
}
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
export default SignUp;

View File

@ -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<ViewStyle>, loop?: boolean }) {
return null;
}

View File

@ -0,0 +1,49 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';
const SkeletonItem = () => {
return (
<View style={styles.container}>
<View style={styles.avatar} />
<View style={styles.content}>
<View style={styles.title} />
<View style={styles.subtitle} />
</View>
</View>
);
};
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);

View File

@ -0,0 +1,92 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// 骨架屏占位组件
const SkeletonItem = () => (
<View style={styles.skeletonItem} />
);
const SkeletonOwner = () => {
const insets = useSafeAreaInsets();
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* 用户信息骨架屏 */}
<View style={styles.section}>
<View style={styles.userInfoHeader}>
<SkeletonItem />
<View style={styles.userInfoTextContainer}>
<SkeletonItem />
<SkeletonItem />
</View>
</View>
<View style={styles.userInfoStats}>
<SkeletonItem />
<SkeletonItem />
</View>
</View>
{/* 会员卡骨架屏 */}
<View style={styles.section}>
<SkeletonItem />
</View>
{/* 作品数据骨架屏 */}
<View style={styles.section}>
<View style={styles.countContainer}>
<SkeletonItem />
<SkeletonItem />
</View>
</View>
{/* 排行榜骨架屏 */}
<View style={styles.section}>
<SkeletonItem />
<View style={styles.rankingList}>
{Array(3).fill(0).map((_, index) => (
<SkeletonItem key={`ranking-${index}`} />
))}
</View>
</View>
</View>
);
};
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;

View File

@ -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<ViewStyle>;
}
const AlbumComponent = ({ setModalVisible, style }: CategoryProps) => {
const AlbumComponent = ({ style }: CategoryProps) => {
const { t } = useTranslation();
const router = useRouter();
return (
<View style={[styles.container, style]}>
<TouchableOpacity style={{ flex: 3, opacity: 0 }}>
@ -17,7 +19,13 @@ const AlbumComponent = ({ setModalVisible, style }: CategoryProps) => {
<TouchableOpacity style={{ flex: 3, opacity: 0 }}>
<ThemedText style={styles.text}>{t('generalSetting.shareProfile', { ns: 'personal' })}</ThemedText>
</TouchableOpacity>
<TouchableOpacity onPress={() => setModalVisible(true)} style={[styles.text, { flex: 1, alignItems: "center", paddingVertical: 6 }]}>
<TouchableOpacity
onPress={() => {
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 }]}>
<SettingSvg />
</TouchableOpacity>
</View>

View File

@ -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<CarouselData[]>([]);
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 <View style={styles.container}>
{Object?.entries(data)?.filter(([key]) => key !== 'cover_url')?.map((item, index) => (
<View style={styles.item} key={index}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, width: "75%", overflow: 'hidden' }}>
<View style={styles.icon}>
{
item?.[0]?.includes('video_count') ? <VideoTotalSvg /> : item?.[0]?.includes('photo') ? <ImgTotalSvg /> : item?.[0]?.includes('live') ? <LiveTotalSvg /> : <TimeTotalSvg />
}
</View>
<ThemedText style={styles.title}>{item[0]}</ThemedText>
</View>
<ThemedText style={styles.number}>{item[1]}</ThemedText>
</View>
))}
</View>
}
React.useEffect(() => {
if (data) {
dataHandle()
}
}, [data]);
return (
<View style={{ flex: 1 }}>
<Carousel
width={width}
height={width * 0.75}
data={carouselDataValue || []}
mode="parallax"
// defaultIndex={
// carouselDataValue?.length
// ? Math.max(0, Math.min(
// carouselDataValue.length - 1,
// carouselDataValue.findIndex((item) => 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 (
<View key={index} style={style}>
{item?.key === 'total_count' ? (
totleItem(item.value)
) : (
<View style={{ flex: 1, width: width * 0.65 }}>
{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,
})}
</View>
)}
</View>
)
}}
/>
</View>
);
}
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;

View File

@ -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) => {
<View style={[styles.container, style]}>
<View style={styles.backgroundContainer}>
<Image
source={bgSvg !== "" && bgSvg !== null ? { uri: bgSvg } : require('@/assets/images/png/owner/animals.png')}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
source={bgSvg !== "" && bgSvg !== null ? { uri: bgSvg } : require('@/assets/images/png/owner/people.png')}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
<View style={styles.overlay} />
<BlurView intensity={10} style={styles.overlay} />
</View>
<View style={styles.content}>
<ThemedText style={styles.title}>{title}</ThemedText>
{data.map((item, index) => (
<View style={styles.item} key={index}>
<ThemedText style={styles.itemTitle}>{item.title}</ThemedText>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: index == 1 ? 12 : 16, flex: 3 }}>
<View>
{
index == 0 ? <VideoTotalSvg width={20} height={20} /> : index == 1 ? <ImgTotalSvg width={24} height={24} /> : index == 2 ? <TimeTotalSvg width={20} height={20} /> : <LiveTotalSvg width={20} height={20} />
}
</View>
<ThemedText style={styles.itemTitle}>{item.title}</ThemedText>
</View>
<ThemedText style={styles.itemNumber}>{item.number}</ThemedText>
</View>
))}
<View style={styles.titleContent}>
<ThemedText style={styles.title}>{title}</ThemedText>
<PeopleSvg />
</View>
</View>
</View>
);
@ -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
}
});

Some files were not shown because too many files have changed in this diff Show More