feat: 登录 #1
4
.gitignore
vendored
@ -37,3 +37,7 @@ yarn-error.*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
app-example
|
app-example
|
||||||
|
|
||||||
|
# Expo prebuild generated files
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
|
|||||||
24
app.json
@ -4,7 +4,7 @@
|
|||||||
"slug": "memowake",
|
"slug": "memowake",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "",
|
||||||
"scheme": "memowake",
|
"scheme": "memowake",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
"foregroundImage": "",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
@ -22,24 +22,26 @@
|
|||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
"output": "static",
|
"output": "static",
|
||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": ""
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-font",
|
||||||
{
|
{
|
||||||
"image": "./assets/images/splash-icon.png",
|
"fonts": [
|
||||||
"imageWidth": 200,
|
"./assets/fonts/[font-file.ttf]"
|
||||||
"resizeMode": "contain",
|
]
|
||||||
"backgroundColor": "#ffffff"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"expo-secure-store",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"configureAndroidBackup": true,
|
"image": "",
|
||||||
"faceIDPermission": "Allow $(PRODUCT_NAME) to access your Face ID biometric data."
|
"imageWidth": 200,
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import React from 'react';
|
|||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
import { HapticTab } from '@/components/HapticTab';
|
import { HapticTab } from '@/components/HapticTab';
|
||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
|
||||||
import TabBarBackground from '@/components/ui/TabBarBackground';
|
import TabBarBackground from '@/components/ui/TabBarBackground';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
@ -26,11 +25,58 @@ export default function TabLayout() {
|
|||||||
default: {},
|
default: {},
|
||||||
}),
|
}),
|
||||||
}}>
|
}}>
|
||||||
|
{/* 落地页 */}
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Memo',
|
title: 'Memo',
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
tabBarButton: () => null, // 隐藏底部标签栏
|
||||||
|
headerShown: false, // 隐藏导航栏
|
||||||
|
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 登录 */}
|
||||||
|
<Tabs.Screen
|
||||||
|
name="login"
|
||||||
|
options={{
|
||||||
|
title: 'Login',
|
||||||
|
href: '/login',
|
||||||
|
// tabBarButton: () => null, // 隐藏底部标签栏
|
||||||
|
headerShown: false, // 隐藏导航栏
|
||||||
|
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 重置密码 */}
|
||||||
|
<Tabs.Screen
|
||||||
|
name="reset-password"
|
||||||
|
options={{
|
||||||
|
title: 'reset-password',
|
||||||
|
href: '/reset-password',
|
||||||
|
// tabBarButton: () => null, // 隐藏底部标签栏
|
||||||
|
headerShown: false, // 隐藏导航栏
|
||||||
|
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* loading页面 */}
|
||||||
|
<Tabs.Screen
|
||||||
|
name="loading"
|
||||||
|
options={{
|
||||||
|
title: 'loading',
|
||||||
|
href: '/loading',
|
||||||
|
// tabBarButton: () => null, // 隐藏底部标签栏
|
||||||
|
headerShown: false, // 隐藏导航栏
|
||||||
|
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 用户信息收集 */}
|
||||||
|
<Tabs.Screen
|
||||||
|
name="user-message"
|
||||||
|
options={{
|
||||||
|
title: 'user-message',
|
||||||
|
href: '/user-message',
|
||||||
|
// tabBarButton: () => null, // 隐藏底部标签栏
|
||||||
|
headerShown: false, // 隐藏导航栏
|
||||||
|
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@ -1,82 +1,63 @@
|
|||||||
import { ThemedText } from '@/components/ThemedText';
|
import MemoChat from '@/assets/icons/svg/memo-chat.svg';
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
import MemoIP from '@/assets/icons/svg/memo-ip.svg';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useRouter } from 'expo-router';
|
||||||
import { fetchApi } from '@/lib/server-api-util';
|
|
||||||
import { store } from '@/store';
|
|
||||||
import { User } from '@/types/user';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TouchableOpacity, View } from 'react-native';
|
import { Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { login } = useAuth();
|
const router = useRouter();
|
||||||
const token = store.getState().auth.token;
|
|
||||||
console.log(token);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleApi = () => {
|
|
||||||
fetchApi('/iam/login/password-login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
account: "jinyaqiu@fairclip.cn",
|
|
||||||
password: "111111",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
login(data as User, data.access_token || '')
|
|
||||||
})
|
|
||||||
.catch((err) => console.log(err));
|
|
||||||
// fetch('http://192.168.31.42/api/v1/iam/login/password-login', {
|
|
||||||
// method: 'POST',
|
|
||||||
// headers: {
|
|
||||||
// 'Content-Type': 'application/json',
|
|
||||||
// },
|
|
||||||
// body: JSON.stringify({
|
|
||||||
// account: "jinyaqiu@fairclip.cn",
|
|
||||||
// password: "111111",
|
|
||||||
// }),
|
|
||||||
// })
|
|
||||||
// .then((res) => res.json())
|
|
||||||
// .then((data) => console.log(data))
|
|
||||||
// .catch((err) => console.log(err));
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<ThemedView className="flex-1 bg-beige">
|
<View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem]">
|
||||||
<View className="flex-1 items-center pt-[60px] px-5">
|
{/* 标题区域 */}
|
||||||
<ThemedText className="text-3xl font-bold mb-8 text-saddlebrown" onPress={handleApi}>
|
<View className="items-start mb-10 w-full px-5">
|
||||||
{t('title', { ns: "example" })}
|
<Text className="text-white text-3xl font-bold mb-3 text-left">
|
||||||
</ThemedText>
|
{t('auth.welcomeAwaken.awaken', { ns: 'login' })}
|
||||||
|
<br />
|
||||||
|
{t('auth.welcomeAwaken.your', { ns: 'login' })}
|
||||||
|
<br />
|
||||||
|
{t('auth.welcomeAwaken.pm', { ns: 'login' })}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-white/85 text-base text-left">
|
||||||
|
{t('auth.welcomeAwaken.slogan', { ns: 'login' })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="mb-8 items-center">
|
{/* Memo 形象区域 */}
|
||||||
<View className="w-[150px] h-[150px] rounded-full bg-white/80 justify-center items-center border-8 border-saddlebrown shadow-lg">
|
<View className="items-center w-full relative">
|
||||||
<View className="items-center">
|
{/* 气泡对话框 */}
|
||||||
<Ionicons name="person" size={60} color="#8B4513" />
|
<View className="absolute -top-[7rem] right-[1rem] z-10">
|
||||||
<ThemedText className="mt-1 text-lg font-semibold text-saddlebrown">
|
<View className="relative top-[12rem] -right-[3rem] z-10 w-3 h-3 bg-white rounded-full" />
|
||||||
MeMo
|
<View className="relative top-[9.5rem] -right-[3.5rem] z-10 w-4 h-4 bg-white rounded-full" />
|
||||||
</ThemedText>
|
<Text className="text-[#AC7E35] font-bold text-lg text-center leading-6 relative top-[4rem] left-0">
|
||||||
</View>
|
{t('auth.welcomeAwaken.hi', { ns: 'login' })}
|
||||||
</View>
|
<br />
|
||||||
|
{t('auth.welcomeAwaken.memo', { ns: 'login' })}
|
||||||
|
</Text>
|
||||||
|
<MemoChat />
|
||||||
</View>
|
</View>
|
||||||
|
{/* Memo 形象 */}
|
||||||
<ThemedText className="text-base text-center mb-10 text-saddlebrown px-5 leading-6">
|
<View className="justify-center items-center">
|
||||||
Ready to wake up your memories? Just ask! Let MeMo bring them back to life!
|
<MemoIP />
|
||||||
</ThemedText>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
className="w-20 h-20 rounded-full bg-white/90 justify-center items-center mb-8 shadow-lg"
|
|
||||||
>
|
|
||||||
<Ionicons name="mic" size={32} color="#8B0000" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<View className="w-full items-center mt-auto mb-8">
|
|
||||||
<View className="h-px w-4/5 bg-saddlebrown/50 mb-4" />
|
|
||||||
<ThemedText className="text-center text-saddlebrown text-sm">
|
|
||||||
<ThemedText className="font-bold text-darkred">Explore More</ThemedText> Memory Reel Made Just For You!
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ThemedView >
|
|
||||||
|
{/* 介绍文本 */}
|
||||||
|
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
|
||||||
|
{t('auth.welcomeAwaken.gallery', { ns: 'login' })}
|
||||||
|
<br />
|
||||||
|
{t('auth.welcomeAwaken.back', { ns: 'login' })}
|
||||||
|
</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={() => router.push('/login')}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text className="text-[#4C320C] font-bold text-lg">
|
||||||
|
{t('auth.welcomeAwaken.awake', { ns: 'login' })}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
13
app/(tabs)/loading.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import LoadingIP from '@/assets/icons/svg/loadingIP.svg';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
export default function LoadingScreen() {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white justify-center items-center p-5">
|
||||||
|
<View className="items-center">
|
||||||
|
<LoadingIP />
|
||||||
|
<View className="text-[#AC7E35]">Loading...</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
app/(tabs)/login.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
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 PhoneLogin from '@/components/login/phoneLogin';
|
||||||
|
import SignUp from '@/components/login/signUp';
|
||||||
|
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 { LayoutChangeEvent, TouchableOpacity, View, ViewStyle, useWindowDimensions } from 'react-native';
|
||||||
|
|
||||||
|
const LoginScreen = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { status } = useLocalSearchParams();
|
||||||
|
const [error, setError] = useState<string>('123');
|
||||||
|
const [containerHeight, setContainerHeight] = useState(0);
|
||||||
|
const { height: windowHeight } = useWindowDimensions();
|
||||||
|
// 密码可视
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const handleLayout = (event: LayoutChangeEvent) => {
|
||||||
|
const { height } = event.nativeEvent.layout;
|
||||||
|
setContainerHeight(height);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新URL参数而不刷新页面
|
||||||
|
const updateUrlParam = (key: string, value: string) => {
|
||||||
|
router.setParams({ [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
useEffect(() => {
|
||||||
|
setError('123')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView className="flex-1 bg-bgPrimary justify-end">
|
||||||
|
<View className="flex-1">
|
||||||
|
<View
|
||||||
|
className="absolute left-1/2 z-10"
|
||||||
|
style={{
|
||||||
|
top: containerHeight > 0 ? windowHeight - containerHeight - 210 : 0,
|
||||||
|
transform: [{ translateX: -200 }]
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
showPassword
|
||||||
|
?
|
||||||
|
<LoginIP2 />
|
||||||
|
:
|
||||||
|
<LoginIP1 />
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="absolute left-1/2 z-[1000] -translate-x-[39.5px] -translate-y-[4px]"
|
||||||
|
style={{
|
||||||
|
top: containerHeight > 0 ? windowHeight - containerHeight - 1 : 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handers />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ThemedView
|
||||||
|
className="w-full bg-white pt-12 px-6 relative z-20 shadow-lg pb-5"
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 5
|
||||||
|
} as ViewStyle}
|
||||||
|
onLayout={handleLayout}
|
||||||
|
>
|
||||||
|
{/* 错误提示 */}
|
||||||
|
<View className={`${error !== "123" ? 'opacity-100' : 'opacity-0'} w-full flex justify-center items-center text-primary-500 text-sm`}>
|
||||||
|
<ThemedText className="text-sm !text-textPrimary">
|
||||||
|
{error}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
{(() => {
|
||||||
|
const commonProps = {
|
||||||
|
updateUrlParam,
|
||||||
|
setError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const components = {
|
||||||
|
signUp: (
|
||||||
|
<SignUp
|
||||||
|
{...commonProps}
|
||||||
|
setShowPassword={setShowPassword}
|
||||||
|
showPassword={showPassword}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
forgetPwd: (
|
||||||
|
<ForgetPwd
|
||||||
|
{...commonProps}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
login: (
|
||||||
|
<PhoneLogin />
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
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' })}
|
||||||
|
</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' })}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
</ThemedView>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginScreen
|
||||||
162
app/(tabs)/reset-password.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
|
import { fetchApi } from '@/lib/server-api-util';
|
||||||
|
import { User } from '@/types/user';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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 [error, setError] = useState('');
|
||||||
|
|
||||||
|
const validatePassword = (pwd: string) => {
|
||||||
|
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
|
||||||
|
return passwordRegex.test(pwd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!password) {
|
||||||
|
setError(t('auth.login.passwordPlaceholder', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError(t('auth.signup.passwordNotMatch', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validatePassword(password)) {
|
||||||
|
setError(t('auth.signup.passwordAuth', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
new_password: password,
|
||||||
|
reset_password_session_id: resetPasswordSessionId,
|
||||||
|
token
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetchApi<User>('/iam/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (login) {
|
||||||
|
login(response, response.access_token || '');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reset password error:', error);
|
||||||
|
setError(t('auth.resetPwd.error', { ns: 'login' }) || 'Failed to reset password');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
className="flex-1 bg-white"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{t('auth.resetPwd.title', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<ThemedText className="text-red-500 mb-4 text-center">
|
||||||
|
{error}
|
||||||
|
</ThemedText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View className="mb-6">
|
||||||
|
<View className="flex-row items-center border border-gray-200 rounded-lg px-3">
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 h-12 text-gray-800"
|
||||||
|
placeholder={t('auth.login.passwordPlaceholder', { ns: 'login' })}
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
className="p-2"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={showPassword ? 'eye-off' : 'eye'}
|
||||||
|
size={20}
|
||||||
|
color="#666"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
|
<View className="flex-row items-center border border-gray-200 rounded-lg px-3 mt-4">
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 h-12 text-gray-800"
|
||||||
|
placeholder={t('auth.signup.confirmPasswordPlaceholder', { ns: 'login' })}
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={setConfirmPassword}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
className="p-2"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={showPassword ? 'eye-off' : 'eye'}
|
||||||
|
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]'}`}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<ThemedText className="text-white text-base font-semibold">
|
||||||
|
{t('auth.resetPwd.resetButton', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ThemedView>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default resetPassword
|
||||||
79
app/(tabs)/user-message.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { FileStatus } from '@/components/file-upload/file-uploader';
|
||||||
|
import Choice from '@/components/user-message.tsx/choice';
|
||||||
|
import Done from '@/components/user-message.tsx/done';
|
||||||
|
import Look from '@/components/user-message.tsx/look';
|
||||||
|
import UserName from '@/components/user-message.tsx/userName';
|
||||||
|
import { fetchApi } from '@/lib/server-api-util';
|
||||||
|
import { User } from '@/types/user';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
export type Steps = "userName" | "look" | "choice" | "done";
|
||||||
|
|
||||||
|
export default function UserMessage() {
|
||||||
|
// 步骤
|
||||||
|
const [steps, setSteps] = useState<Steps>("userName")
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [avatar, setAvatar] = useState('')
|
||||||
|
const [fileData, setFileData] = useState<FileStatus[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [userInfo, setUserInfo] = useState<User | null>(null);
|
||||||
|
// 获取用户信息
|
||||||
|
const getUserInfo = async () => {
|
||||||
|
const res = await fetchApi<User>("/iam/user-info");
|
||||||
|
setUserInfo(res);
|
||||||
|
setUsername(res?.nickname || '');
|
||||||
|
setAvatar(res?.avatar_file_url || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUser = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
fetchApi("/iam/user/info", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
avatar_file_id: fileData?.[0]?.id
|
||||||
|
})
|
||||||
|
}).then(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
getUserInfo();
|
||||||
|
setSteps('done');
|
||||||
|
}).catch(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
getUserInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="h-screen" key={steps}>
|
||||||
|
<View className="h-screen" key={steps}>
|
||||||
|
{(() => {
|
||||||
|
const components = {
|
||||||
|
userName: (
|
||||||
|
<UserName
|
||||||
|
setSteps={setSteps}
|
||||||
|
username={username}
|
||||||
|
setUsername={setUsername}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
look: (
|
||||||
|
<Look
|
||||||
|
setSteps={setSteps}
|
||||||
|
fileData={fileData}
|
||||||
|
setFileData={setFileData}
|
||||||
|
isLoading={isLoading}
|
||||||
|
handleUser={handleUser}
|
||||||
|
avatar={avatar}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
choice: <Choice setSteps={setSteps} />,
|
||||||
|
done: <Done setSteps={setSteps} />
|
||||||
|
};
|
||||||
|
|
||||||
|
return components[steps as keyof typeof components] || null;
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,12 @@
|
|||||||
import { Link, Stack } from 'expo-router';
|
import { Link, Stack } from 'expo-router';
|
||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
return (
|
return (
|
||||||
<>
|
<View>
|
||||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type="title">This screen does not exist.</ThemedText>
|
<ThemedText type="title">This screen does not exist.</ThemedText>
|
||||||
@ -14,7 +14,7 @@ export default function NotFoundScreen() {
|
|||||||
<ThemedText type="link">Go to home screen!</ThemedText>
|
<ThemedText type="link">Go to home screen!</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,13 @@ export default function RootLayout() {
|
|||||||
<Provider>
|
<Provider>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="login"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
animation: 'fade'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
<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-activity-icon lucide-activity">
|
|
||||||
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 381 B |
@ -1,6 +0,0 @@
|
|||||||
<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-ai-icon lucide-ai">
|
|
||||||
<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z" />
|
|
||||||
<path d="m6.2 5.3 3.1 3.9" />
|
|
||||||
<path d="m12.4 3.4 3.1 4" />
|
|
||||||
<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 444 B |
@ -1,4 +0,0 @@
|
|||||||
<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-album-icon lucide-album">
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
||||||
<polyline points="11 3 11 11 14 8 17 11 17 3" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 350 B |
@ -1 +0,0 @@
|
|||||||
<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-big-left-icon lucide-arrow-big-left"><path d="M18 15h-6v4l-7-7 7-7v4h6v6z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 290 B |
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 274 B |
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 274 B |
@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<polyline points="9 18 15 12 9 6" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 230 B |
4
assets/icons/svg/ataver.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 10C1 14.9706 5.02944 19 10 19C14.9706 19 19 14.9706 19 10C19 5.02944 14.9706 1 10 1C5.02944 1 1 5.02944 1 10Z" fill="white" />
|
||||||
|
<path d="M15.2166 17.3323C13.9349 15.9008 12.0727 15 10 15C7.92734 15 6.06492 15.9008 4.7832 17.3323M10 19C5.02944 19 1 14.9706 1 10C1 5.02944 5.02944 1 10 1C14.9706 1 19 5.02944 19 10C19 14.9706 14.9706 19 10 19ZM10 12C8.34315 12 7 10.6569 7 9C7 7.34315 8.34315 6 10 6C11.6569 6 13 7.34315 13 9C13 10.6569 11.6569 12 10 12Z" stroke="#4C320C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 659 B |
@ -1 +0,0 @@
|
|||||||
<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-chevron-left-icon lucide-chevron-left"><path d="m15 18-6-6 6-6"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 273 B |
@ -1,4 +0,0 @@
|
|||||||
<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-badge-check-icon lucide-badge-check">
|
|
||||||
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" />
|
|
||||||
<path d="m9 12 2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 455 B |
@ -1,9 +0,0 @@
|
|||||||
<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-bot-off-icon lucide-bot-off">
|
|
||||||
<path d="M13.67 8H18a2 2 0 0 1 2 2v4.33" />
|
|
||||||
<path d="M2 14h2" />
|
|
||||||
<path d="M20 14h2" />
|
|
||||||
<path d="M22 22 2 2" />
|
|
||||||
<path d="M8 8H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 1.414-.586" />
|
|
||||||
<path d="M9 13v2" />
|
|
||||||
<path d="M9.67 4H12v2.33" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 502 B |
@ -1,17 +0,0 @@
|
|||||||
<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-brain-cog-icon lucide-brain-cog">
|
|
||||||
<path d="m10.852 14.772-.383.923" />
|
|
||||||
<path d="m10.852 9.228-.383-.923" />
|
|
||||||
<path d="m13.148 14.772.382.924" />
|
|
||||||
<path d="m13.531 8.305-.383.923" />
|
|
||||||
<path d="m14.772 10.852.923-.383" />
|
|
||||||
<path d="m14.772 13.148.923.383" />
|
|
||||||
<path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 0 0-5.63-1.446 3 3 0 0 0-.368 1.571 4 4 0 0 0-2.525 5.771" />
|
|
||||||
<path d="M17.998 5.125a4 4 0 0 1 2.525 5.771" />
|
|
||||||
<path d="M19.505 10.294a4 4 0 0 1-1.5 7.706" />
|
|
||||||
<path d="M4.032 17.483A4 4 0 0 0 11.464 20c.18-.311.892-.311 1.072 0a4 4 0 0 0 7.432-2.516" />
|
|
||||||
<path d="M4.5 10.291A4 4 0 0 0 6 18" />
|
|
||||||
<path d="M6.002 5.125a3 3 0 0 0 .4 1.375" />
|
|
||||||
<path d="m9.228 10.852-.923-.383" />
|
|
||||||
<path d="m9.228 13.148-.923.383" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1009 B |
@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 278 B |
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
|
|
||||||
<path d="m15 18-6-6 6-6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 257 B |
44
assets/icons/svg/choice.svg
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<svg width="92" height="92" viewBox="0 0 92 92" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="46" cy="46" r="40" fill="white" stroke="#FFB645" stroke-width="11" />
|
||||||
|
<mask id="mask0_33_184" style="maskType: alpha" maskUnits="userSpaceOnUse" x="6" y="6" width="80" height="80">
|
||||||
|
<circle cx="46" cy="46" r="40" fill="white" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_33_184)">
|
||||||
|
<path d="M-10.4027 27.6923C14.6652 -15.7265 77.3348 -15.7265 102.403 27.6923L119.501 57.3077C144.569 100.727 113.234 155 63.0984 155H28.9016C-21.2342 155 -52.569 100.726 -27.5011 57.3077L-10.4027 27.6923Z" fill="#FFD18A" />
|
||||||
|
<rect x="42.1543" y="24.8718" width="2.5641" height="3.58974" rx="1.28205" transform="rotate(-180 42.1543 24.8718)" fill="#4C320C" />
|
||||||
|
<rect x="51.3848" y="24.8718" width="2.5641" height="3.58974" rx="1.28205" transform="rotate(-180 51.3848 24.8718)" fill="#4C320C" />
|
||||||
|
<path d="M9.24226 51.13C26.9712 25.3565 65.0303 25.3565 82.7593 51.13L102.951 80.4839C123.313 110.085 102.121 150.385 66.1926 150.385H25.8088C-10.1197 150.385 -31.3117 110.085 -10.9496 80.4839L9.24226 51.13Z" fill="#FFF8DE" />
|
||||||
|
<g filter="url(#filter0_i_33_184)">
|
||||||
|
<ellipse cx="81.6411" cy="46.6667" rx="42.0513" ry="30" fill="#FFF8DE" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#filter1_i_33_184)">
|
||||||
|
<ellipse cx="10.1025" cy="46.6667" rx="41.7949" ry="30" fill="#FFF8DE" />
|
||||||
|
</g>
|
||||||
|
<ellipse cx="45.7434" cy="31.795" rx="3.07692" ry="2.30769" transform="rotate(180 45.7434 31.795)" fill="#FFB8B9" />
|
||||||
|
<ellipse cx="7.32634" cy="2.92265" rx="7.32634" ry="2.92265" transform="matrix(0.912659 0.408721 0.408721 -0.912659 61.4287 82.0015)" fill="#FFD38D" />
|
||||||
|
<ellipse cx="22.3426" cy="82.3285" rx="7.32634" ry="2.92265" transform="rotate(155.875 22.3426 82.3285)" fill="#FFD38D" />
|
||||||
|
<path d="M45.2994 34.359C45.4968 34.0171 45.9903 34.0171 46.1877 34.359L46.6318 35.1282C46.8292 35.4701 46.5824 35.8975 46.1877 35.8975H45.2994C44.9047 35.8975 44.6579 35.4701 44.8553 35.1282L45.2994 34.359Z" fill="#4C320C" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_i_33_184" x="34.9745" y="16.6667" width="88.7179" height="61.5385" 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="-4.61538" dy="1.53846" />
|
||||||
|
<feGaussianBlur stdDeviation="4.23077" />
|
||||||
|
<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_33_184" />
|
||||||
|
</filter>
|
||||||
|
<filter id="filter1_i_33_184" x="-31.6924" y="16.6667" width="88.718" height="60" 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.1282" />
|
||||||
|
<feGaussianBlur stdDeviation="2.82051" />
|
||||||
|
<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_33_184" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
4
assets/icons/svg/choicePhoto.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 10C1 14.9706 5.02944 19 10 19C14.9706 19 19 14.9706 19 10C19 5.02944 14.9706 1 10 1C5.02944 1 1 5.02944 1 10Z" fill="white" />
|
||||||
|
<path d="M15.2166 17.3323C13.9349 15.9008 12.0727 15 10 15C7.92734 15 6.06492 15.9008 4.7832 17.3323M10 19C5.02944 19 1 14.9706 1 10C1 5.02944 5.02944 1 10 1C14.9706 1 19 5.02944 19 10C19 14.9706 14.9706 19 10 19ZM10 12C8.34315 12 7 10.6569 7 9C7 7.34315 8.34315 6 10 6C11.6569 6 13 7.34315 13 9C13 10.6569 11.6569 12 10 12Z" stroke="#4C320C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 659 B |
@ -1 +0,0 @@
|
|||||||
<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-cloud-upload-icon lucide-cloud-upload"><path d="M12 13v8"/><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="m8 17 4-4 4 4"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 360 B |
@ -1,5 +0,0 @@
|
|||||||
<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-user-circle-icon lucide-user-circle">
|
|
||||||
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
|
|
||||||
<path d="m10 15-3-3 3-3" />
|
|
||||||
<path d="M7 12h7a2 2 0 0 1 2 2v1" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 368 B |
@ -1,9 +0,0 @@
|
|||||||
<svg viewBox="0 0 63 55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="38.7098" y="17.6353" width="8.3012" height="37.3927" rx="3" transform="rotate(58.8959 38.7098 17.6353)" stroke="#FFBB00" stroke-width="2" />
|
|
||||||
<line x1="30.0343" y1="21.7041" x2="35.3559" y2="30.5243" stroke="#FFBB00" stroke-width="2" />
|
|
||||||
<path d="M41.8246 30.6299L45.5694 36.8367" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
|
|
||||||
<path d="M29.4093 10.0507L33.1541 16.2575" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
|
|
||||||
<path d="M40.8665 6.25525L40.0611 14.0031" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
|
|
||||||
<path d="M53.9637 27.9646L46.813 24.947" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
|
|
||||||
<path d="M54.1488 13.3939L46.3335 18.1092" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 866 B |
@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 325 B |
42
assets/icons/svg/done.svg
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<svg width="402" height="658" viewBox="0 0 402 658" fill="none" xmlns="http://www.w3.org/2000/svg" className='!w-full !h-full'>
|
||||||
|
<path d="M188.942 376.831C189.985 369.965 206.061 373.848 213.969 376.647L197.429 388.107C193.075 388.588 187.898 383.698 188.942 376.831Z" fill="#FFDBA3" />
|
||||||
|
<path d="M192.525 377.316C192.829 371.596 203.633 375.761 208.997 378.558L200.667 383.979C197.827 384.142 192.221 383.036 192.525 377.316Z" fill="#AC7E35" />
|
||||||
|
<path d="M290.119 318.416C283.651 315.886 278.975 331.75 277.446 339.999L295.64 331.404C298.234 327.874 296.588 320.946 290.119 318.416Z" fill="#FFDBA3" />
|
||||||
|
<path d="M288.748 321.762C283.643 319.165 281.847 330.604 281.588 336.647L290.448 332.145C292.008 329.766 293.853 324.359 288.748 321.762Z" fill="#AC7E35" />
|
||||||
|
<path d="M153.808 500.814C153.808 367.119 298.537 283.56 414.32 350.407L493.295 396.003C609.078 462.851 609.078 629.97 493.295 696.817L414.32 742.413C298.537 809.261 153.808 725.701 153.808 592.006L153.808 500.814Z" fill="#FFD18A" />
|
||||||
|
<rect x="271.421" y="424.225" width="6.83761" height="9.57265" rx="3.4188" transform="rotate(150 271.421 424.225)" fill="#4C320C" />
|
||||||
|
<rect x="292.738" y="411.917" width="6.83761" height="9.57265" rx="3.4188" transform="rotate(150 292.738 411.917)" fill="#4C320C" />
|
||||||
|
<path d="M230.424 528.749C237.002 445.589 324.896 394.844 400.204 430.726L485.973 471.594C572.466 512.806 577.258 634.129 494.284 682.034L401.022 735.879C318.049 783.784 215.375 718.973 222.931 623.462L230.424 528.749Z" fill="#FFF8DE" />
|
||||||
|
<g filter="url(#filter0_i_35_262)">
|
||||||
|
<ellipse cx="391.673" cy="421.909" rx="112.137" ry="80" transform="rotate(-30 391.673 421.909)" fill="#FFF8DE" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#filter1_i_35_262)">
|
||||||
|
<ellipse cx="226.462" cy="517.293" rx="111.453" ry="80" transform="rotate(-30 226.462 517.293)" fill="#FFF8DE" />
|
||||||
|
</g>
|
||||||
|
<ellipse cx="288.942" cy="435.427" rx="8.20513" ry="6.15385" transform="rotate(150 288.942 435.427)" fill="#FFB8B9" />
|
||||||
|
<ellipse cx="19.5369" cy="7.79375" rx="19.5369" ry="7.79375" transform="matrix(0.994747 -0.102367 -0.102367 -0.994747 385.179 534.461)" fill="#FFD38D" />
|
||||||
|
<ellipse cx="302.278" cy="583.331" rx="19.5369" ry="7.79375" transform="rotate(125.875 302.278 583.331)" fill="#FFD38D" />
|
||||||
|
<path d="M291.335 441.941C291.335 440.888 292.475 440.23 293.386 440.756L295.438 441.941C296.349 442.467 296.349 443.783 295.438 444.309L293.386 445.494C292.475 446.02 291.335 445.362 291.335 444.309L291.335 441.941Z" fill="#4C320C" />
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_i_35_262" x="274.309" y="332.779" width="222.419" height="182.363" 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="-12.3077" dy="4.10256" />
|
||||||
|
<feGaussianBlur stdDeviation="11.2821" />
|
||||||
|
<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_35_262" />
|
||||||
|
</filter>
|
||||||
|
<filter id="filter1_i_35_262" x="121.953" y="428.378" width="222.692" height="177.831" 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="13.6752" />
|
||||||
|
<feGaussianBlur stdDeviation="7.52137" />
|
||||||
|
<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_35_262" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@ -1,5 +0,0 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M11.3694 8.14966V10.5459C11.3694 10.8636 11.2474 11.1684 11.0302 11.393C10.8131 11.6177 10.5185 11.744 10.2114 11.744H2.10528C1.79816 11.744 1.50361 11.6177 1.28644 11.393C1.06927 11.1684 0.947266 10.8636 0.947266 10.5459V8.14966" stroke="currentColor" stroke-width="1.15531" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M3.26416 5.1543L6.1592 8.14955L9.05423 5.1543" stroke="currentColor" stroke-width="1.15531" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M6.16064 8.14953V0.960938" stroke="currentColor" stroke-width="1.15531" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 723 B |
4
assets/icons/svg/error.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1.5 9C1.5 13.1421 4.85786 16.5 9 16.5C13.1421 16.5 16.5 13.1421 16.5 9C16.5 4.85786 13.1421 1.5 9 1.5C4.85786 1.5 1.5 4.85786 1.5 9Z" fill="#E2793F" />
|
||||||
|
<path d="M9 6.04183V9.37516M9 16.5C4.85786 16.5 1.5 13.1421 1.5 9C1.5 4.85786 4.85786 1.5 9 1.5C13.1421 1.5 16.5 4.85786 16.5 9C16.5 13.1421 13.1421 16.5 9 16.5ZM9.0415 11.8752V11.9585L8.9585 11.9582V11.8752H9.0415Z" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 577 B |
@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M0.625 7.5C0.625 7.5 3.125 2.5 7.5 2.5C11.875 2.5 14.375 7.5 14.375 7.5C14.375 7.5 11.875 12.5 7.5 12.5C3.125 12.5 0.625 7.5 0.625 7.5Z" stroke="currentColor" stroke-width="1.16" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M7.5 9.375C8.53553 9.375 9.375 8.53553 9.375 7.5C9.375 6.46447 8.53553 5.625 7.5 5.625C6.46447 5.625 5.625 6.46447 5.625 7.5C5.625 8.53553 6.46447 9.375 7.5 9.375Z" stroke="currentColor" stroke-width="1.16" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 648 B |
@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 345 B |
@ -1,5 +0,0 @@
|
|||||||
<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-image-icon lucide-image">
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
||||||
<circle cx="9" cy="9" r="2" />
|
|
||||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
4
assets/icons/svg/handers.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="79" height="8" viewBox="0 0 79 8" fill="none" xmlns="http://www.w3.org/2000/svg" style="transform: scale(0.8)">
|
||||||
|
<ellipse cx="9.76846" cy="3.89687" rx="9.76846" ry="3.89687" transform="matrix(1 0 0 -1 59.3115 7.79376)" fill="#FFD38D" />
|
||||||
|
<ellipse cx="9.76865" cy="3.89689" rx="9.76846" ry="3.89687" transform="rotate(-180 9.76865 3.89689)" fill="#FFD38D" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 382 B |
@ -1,7 +0,0 @@
|
|||||||
<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-image-plus-icon lucide-image-plus">
|
|
||||||
<path d="M16 5h6" />
|
|
||||||
<path d="M19 2v6" />
|
|
||||||
<path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5" />
|
|
||||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
||||||
<circle cx="9" cy="9" r="2" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 468 B |
44
assets/icons/svg/loadingIP.svg
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M128.254 64.0152C129.88 61.9809 134.373 66.2562 136.417 68.6482L128.896 69.2685C127.392 68.6084 126.628 66.0496 128.254 64.0152Z" fill="#FFDBA3" />
|
||||||
|
<path d="M129.327 64.8447C130.498 63.0441 133.226 66.4222 134.443 68.3363L130.721 68.5351C129.769 68.0552 128.155 66.6452 129.327 64.8447Z" fill="#AC7E35" />
|
||||||
|
<path d="M172.065 64.0152C170.439 61.9809 165.946 66.2562 163.903 68.6482L171.423 69.2685C172.927 68.6084 173.692 66.0496 172.065 64.0152Z" fill="#FFDBA3" />
|
||||||
|
<path d="M170.993 64.8447C169.822 63.0441 167.094 66.4222 165.876 68.3363L169.598 68.5351C170.551 68.0552 172.164 66.6452 170.993 64.8447Z" fill="#AC7E35" />
|
||||||
|
<ellipse cx="110.257" cy="221.795" rx="7.69231" ry="3.33333" fill="#AC7E35" />
|
||||||
|
<ellipse cx="193.333" cy="220.256" rx="7.69231" ry="3.33333" fill="#AC7E35" />
|
||||||
|
<path d="M93.5973 97.6923C118.665 54.2735 181.335 54.2735 206.403 97.6923L223.501 127.308C248.569 170.727 217.234 225 167.098 225H132.902C82.7658 225 51.431 170.726 76.4989 127.308L93.5973 97.6923Z" fill="#FFD18A" />
|
||||||
|
<rect x="146.154" y="94.8718" width="2.5641" height="3.58974" rx="1.28205" transform="rotate(-180 146.154 94.8718)" fill="#4C320C" />
|
||||||
|
<rect x="155.385" y="94.8718" width="2.5641" height="3.58974" rx="1.28205" transform="rotate(-180 155.385 94.8718)" fill="#4C320C" />
|
||||||
|
<path d="M113.241 121.13C130.97 95.3566 169.029 95.3565 186.758 121.13L206.95 150.484C227.312 180.085 206.12 220.385 170.192 220.385H129.808C93.8793 220.385 72.6873 180.085 93.0495 150.484L113.241 121.13Z" fill="#FFF8DE" />
|
||||||
|
<g filter="url(#filter0_i_33_78)">
|
||||||
|
<ellipse cx="185.641" cy="116.667" rx="42.0513" ry="30" fill="#FFF8DE" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#filter1_i_33_78)">
|
||||||
|
<ellipse cx="114.102" cy="116.667" rx="41.7949" ry="30" fill="#FFF8DE" />
|
||||||
|
</g>
|
||||||
|
<ellipse cx="149.743" cy="101.795" rx="3.07692" ry="2.30769" transform="rotate(180 149.743 101.795)" fill="#FFB8B9" />
|
||||||
|
<ellipse cx="7.32634" cy="2.92265" rx="7.32634" ry="2.92265" transform="matrix(0.912659 0.408721 0.408721 -0.912659 162.429 152.001)" fill="#FFD38D" />
|
||||||
|
<ellipse cx="126.343" cy="152.329" rx="7.32634" ry="2.92265" transform="rotate(155.875 126.343 152.329)" fill="#FFD38D" />
|
||||||
|
<path d="M149.299 104.359C149.497 104.017 149.99 104.017 150.188 104.359L150.632 105.128C150.829 105.47 150.582 105.897 150.188 105.897H149.299C148.905 105.897 148.658 105.47 148.855 105.128L149.299 104.359Z" fill="#4C320C" />
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_i_33_78" x="138.974" y="86.6667" width="88.7179" height="61.5385" 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="-4.61538" dy="1.53846" />
|
||||||
|
<feGaussianBlur stdDeviation="4.23077" />
|
||||||
|
<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_33_78" />
|
||||||
|
</filter>
|
||||||
|
<filter id="filter1_i_33_78" x="72.3076" y="86.6667" width="88.718" height="60" 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.1282" />
|
||||||
|
<feGaussianBlur stdDeviation="2.82051" />
|
||||||
|
<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_33_78" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
42
assets/icons/svg/loginIp1.svg
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<svg width="400" height="391" viewBox="0 0 400 391" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M171.005 76.3536C173.174 73.6412 179.164 79.3416 181.889 82.5309L171.862 83.3579C169.856 82.4779 168.837 79.0661 171.005 76.3536Z" fill="#FFDBA3"/>
|
||||||
|
<path d="M172.437 77.4596C173.998 75.0588 177.635 79.5629 179.258 82.1151L174.296 82.3802C173.026 81.7403 170.875 79.8603 172.437 77.4596Z" fill="#AC7E35"/>
|
||||||
|
<path d="M229.421 76.3536C227.253 73.6412 221.262 79.3416 218.538 82.5309L228.565 83.3579C230.571 82.4779 231.59 79.0661 229.421 76.3536Z" fill="#FFDBA3"/>
|
||||||
|
<path d="M227.99 77.4596C226.429 75.0588 222.792 79.5629 221.168 82.1151L226.131 82.3802C227.401 81.7403 229.552 79.8603 227.99 77.4596Z" fill="#AC7E35"/>
|
||||||
|
<ellipse cx="147.008" cy="286.727" rx="10.2564" ry="4.44444" fill="#AC7E35"/>
|
||||||
|
<ellipse cx="257.778" cy="284.675" rx="10.2564" ry="4.44444" fill="#AC7E35"/>
|
||||||
|
<path d="M124.796 121.256C158.22 63.3647 241.78 63.3647 275.204 121.256L298.001 160.744C331.425 218.635 289.646 291 222.798 291H177.202C110.354 291 68.5747 218.635 101.998 160.744L124.796 121.256Z" fill="#FFD18A"/>
|
||||||
|
<rect x="194.872" y="117.496" width="3.4188" height="4.78632" rx="1.7094" transform="rotate(-180 194.872 117.496)" fill="#4C320C"/>
|
||||||
|
<rect x="207.18" y="117.496" width="3.4188" height="4.78632" rx="1.7094" transform="rotate(-180 207.18 117.496)" fill="#4C320C"/>
|
||||||
|
<path d="M150.989 152.507C174.628 118.142 225.373 118.142 249.012 152.507L275.934 191.645C303.084 231.114 274.828 284.846 226.923 284.846H173.078C125.173 284.846 96.9171 231.114 124.067 191.645L150.989 152.507Z" fill="#FFF8DE"/>
|
||||||
|
<g filter="url(#filter0_i_95_1557)">
|
||||||
|
<ellipse cx="247.521" cy="146.556" rx="56.0684" ry="40" fill="#FFF8DE"/>
|
||||||
|
</g>
|
||||||
|
<g filter="url(#filter1_i_95_1557)">
|
||||||
|
<ellipse cx="152.137" cy="146.556" rx="55.7265" ry="40" fill="#FFF8DE"/>
|
||||||
|
</g>
|
||||||
|
<ellipse cx="199.658" cy="126.726" rx="4.10256" ry="3.07692" transform="rotate(180 199.658 126.726)" fill="#FFB8B9"/>
|
||||||
|
<path d="M199.066 130.145C199.329 129.689 199.987 129.689 200.25 130.145L200.842 131.171C201.105 131.627 200.776 132.197 200.25 132.197H199.066C198.539 132.197 198.21 131.627 198.473 131.171L199.066 130.145Z" fill="#4C320C"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_i_95_1557" x="185.299" y="106.556" width="118.291" height="82.0513" 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="-6.15385" dy="2.05128"/>
|
||||||
|
<feGaussianBlur stdDeviation="5.64103"/>
|
||||||
|
<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_95_1557"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="filter1_i_95_1557" x="96.4102" y="106.556" width="118.291" height="80" 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="6.83761"/>
|
||||||
|
<feGaussianBlur stdDeviation="3.76068"/>
|
||||||
|
<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_95_1557"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
42
assets/icons/svg/loginIp2.svg
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M171.005 85.3536C173.174 82.6412 179.164 88.3416 181.889 91.5309L171.862 92.3579C169.856 91.4779 168.837 88.0661 171.005 85.3536Z" fill="#FFDBA3" />
|
||||||
|
<path d="M172.437 86.4596C173.998 84.0588 177.635 88.5629 179.258 91.1151L174.296 91.3802C173.026 90.7403 170.875 88.8603 172.437 86.4596Z" fill="#AC7E35" />
|
||||||
|
<path d="M229.421 85.3536C227.253 82.6412 221.262 88.3416 218.538 91.5309L228.565 92.3579C230.571 91.4779 231.59 88.0661 229.421 85.3536Z" fill="#FFDBA3" />
|
||||||
|
<path d="M227.99 86.4596C226.429 84.0588 222.792 88.5629 221.168 91.1151L226.131 91.3802C227.401 90.7403 229.552 88.8603 227.99 86.4596Z" fill="#AC7E35" />
|
||||||
|
<ellipse cx="147.008" cy="295.727" rx="10.2564" ry="4.44444" fill="#AC7E35" />
|
||||||
|
<ellipse cx="257.778" cy="293.675" rx="10.2564" ry="4.44444" fill="#AC7E35" />
|
||||||
|
<path d="M124.796 130.256C158.22 72.3647 241.78 72.3647 275.204 130.256L298.001 169.744C331.425 227.635 289.646 300 222.798 300H177.202C110.354 300 68.5747 227.635 101.998 169.744L124.796 130.256Z" fill="#FFD18A" />
|
||||||
|
<path d="M150.989 161.507C174.628 127.142 225.373 127.142 249.012 161.507L275.934 200.645C303.084 240.114 274.828 293.846 226.923 293.846H173.078C125.173 293.846 96.9171 240.114 124.067 200.645L150.989 161.507Z" fill="#FFF8DE" />
|
||||||
|
<g filter="url(#filter0_i_28_495)">
|
||||||
|
<ellipse cx="247.521" cy="155.556" rx="56.0684" ry="40" fill="#FFF8DE" />
|
||||||
|
</g>
|
||||||
|
<rect x="204" y="124.667" width="1.66681" height="4.78632" rx="0.833407" transform="rotate(-90 204 124.667)" fill="#4C320C" />
|
||||||
|
<rect x="191" y="124.667" width="1.66681" height="4.78632" rx="0.833407" transform="rotate(-90 191 124.667)" fill="#4C320C" />
|
||||||
|
<g filter="url(#filter1_i_28_495)">
|
||||||
|
<ellipse cx="152.137" cy="155.556" rx="55.7265" ry="40" fill="#FFF8DE" />
|
||||||
|
</g>
|
||||||
|
<ellipse cx="199.658" cy="135.726" rx="4.10256" ry="3.07692" transform="rotate(180 199.658 135.726)" fill="#FFB8B9" />
|
||||||
|
<path d="M199.066 139.145C199.329 138.689 199.987 138.689 200.25 139.145L200.842 140.171C201.105 140.627 200.776 141.197 200.25 141.197H199.066C198.539 141.197 198.21 140.627 198.473 140.171L199.066 139.145Z" fill="#4C320C" />
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_i_28_495" x="185.299" y="115.556" width="118.291" height="82.0513" 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="-6.15385" dy="2.05128" />
|
||||||
|
<feGaussianBlur stdDeviation="5.64103" />
|
||||||
|
<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_28_495" />
|
||||||
|
</filter>
|
||||||
|
<filter id="filter1_i_28_495" x="96.4102" y="115.556" width="118.291" height="80" 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="6.83761" />
|
||||||
|
<feGaussianBlur stdDeviation="3.76068" />
|
||||||
|
<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_28_495" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
||||||
<polyline points="16 17 21 12 16 7" />
|
|
||||||
<line x1="21" y1="12" x2="9" y2="12" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 333 B |
17
assets/icons/svg/memo-chat.svg
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<svg width="141" height="90" viewBox="0 0 141 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_d_27_1126)">
|
||||||
|
<path d="M78.0607 8.049C55.6607 -8.751 41.0607 8.38232 36.5607 19.049C11.0607 8.049 -6.43929 33.0493 11.0607 49.5491C-1.43929 61.5491 25.0607 81.0491 36.5607 65.0491C41.5607 91.5491 67.0607 84.5491 76.5607 68.5491C80.0607 81.5491 118.061 82.5491 118.061 62.0491C142.061 70.0491 143.561 22.0491 118.061 22.0491C121.561 -1.4509 93.0607 -1.95093 78.0607 8.049Z" fill="white" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_d_27_1126" x="0.3" y="0.3" width="140.028" height="89.1634" 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="3" />
|
||||||
|
<feGaussianBlur stdDeviation="1.85" />
|
||||||
|
<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.25 0" />
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_27_1126" />
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_27_1126" result="shape" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
43
assets/icons/svg/memo-ip.svg
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M149.63 74.6845C151.528 72.3111 156.769 77.2989 159.153 80.0896L150.38 80.8132C148.624 80.0432 147.733 77.0579 149.63 74.6845Z" fill="#FFDBA3" />
|
||||||
|
<path d="M150.882 75.6521C152.248 73.5515 155.431 77.4925 156.851 79.7257L152.509 79.9577C151.398 79.3978 149.516 77.7528 150.882 75.6521Z" fill="#AC7E35" />
|
||||||
|
<path d="M200.744 74.6845C198.846 72.3111 193.605 77.2989 191.221 80.0896L199.994 80.8132C201.75 80.0432 202.641 77.0579 200.744 74.6845Z" fill="#FFDBA3" />
|
||||||
|
<path d="M199.491 75.6521C198.125 73.5515 194.942 77.4925 193.522 79.7257L197.864 79.9577C198.975 79.3978 200.857 77.7528 199.491 75.6521Z" fill="#AC7E35" />
|
||||||
|
<ellipse cx="128.633" cy="258.761" rx="8.97436" ry="3.88889" fill="#AC7E35" />
|
||||||
|
<ellipse cx="225.555" cy="256.966" rx="8.97436" ry="3.88889" fill="#AC7E35" />
|
||||||
|
<path d="M109.197 113.974C138.443 63.319 211.557 63.3191 240.803 113.974L260.751 148.526C289.997 199.181 253.44 262.5 194.948 262.5H155.052C96.5601 262.5 60.0029 199.181 89.2487 148.526L109.197 113.974Z" fill="#FFD18A" />
|
||||||
|
<rect x="170.513" y="110.684" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 170.513 110.684)" fill="#4C320C" />
|
||||||
|
<rect x="181.282" y="110.684" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 181.282 110.684)" fill="#4C320C" />
|
||||||
|
<path d="M132.116 141.318C152.799 111.249 197.202 111.249 217.886 141.318L241.443 175.565C265.198 210.1 240.474 257.115 198.558 257.115H151.443C109.527 257.115 84.8027 210.1 108.559 175.565L132.116 141.318Z" fill="#FFF8DE" />
|
||||||
|
<g filter="url(#filter0_i_68_1022)">
|
||||||
|
<ellipse cx="216.581" cy="136.111" rx="49.0598" ry="35" fill="#FFF8DE" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#filter1_i_68_1022)">
|
||||||
|
<ellipse cx="133.12" cy="136.111" rx="48.7607" ry="35" fill="#FFF8DE" />
|
||||||
|
</g>
|
||||||
|
<ellipse cx="174.701" cy="118.761" rx="3.58974" ry="2.69231" transform="rotate(180 174.701 118.761)" fill="#FFB8B9" />
|
||||||
|
<ellipse cx="8.5474" cy="3.40976" rx="8.5474" ry="3.40976" transform="matrix(0.912659 0.408721 0.408721 -0.912659 189.5 177.335)" fill="#FFD38D" />
|
||||||
|
<ellipse cx="147.399" cy="177.717" rx="8.5474" ry="3.40976" transform="rotate(155.875 147.399 177.717)" fill="#FFD38D" />
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_i_68_1022" x="162.137" y="101.111" width="103.505" 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_68_1022" />
|
||||||
|
</filter>
|
||||||
|
<filter id="filter1_i_68_1022" x="84.3594" y="101.111" 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_68_1022" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@ -1 +0,0 @@
|
|||||||
<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-computer-icon lucide-computer"><rect width="14" height="8" x="5" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 18h2"/><path d="M12 18h6"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 375 B |
@ -1,5 +0,0 @@
|
|||||||
<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-vibrate-icon lucide-vibrate">
|
|
||||||
<path d="m2 8 2 2-2 2 2 2-2 2" />
|
|
||||||
<path d="m22 8-2 2 2 2-2 2 2 2" />
|
|
||||||
<rect width="8" height="14" x="8" y="5" rx="1" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 370 B |
@ -1 +0,0 @@
|
|||||||
<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" className="lucide lucide-image-icon lucide-image"><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" /></svg>
|
|
||||||
|
Before Width: | Height: | Size: 378 B |
@ -1,3 +0,0 @@
|
|||||||
<svg width="22" height="22" viewBox="0 0 22 22">
|
|
||||||
<polygon points="6,4 18,11 6,18" fill="#fff" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 108 B |
@ -1,3 +0,0 @@
|
|||||||
<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">
|
|
||||||
<path d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 233 B |
@ -1,5 +0,0 @@
|
|||||||
<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-share-2-icon lucide-share-2">
|
|
||||||
<path d="M13.5 19.5 12 21l-7-7c-1.5-1.45-3-3.2-3-5.5A5.5 5.5 0 0 1 7.5 3c1.76 0 3 .5 4.5 2 1.5-1.5 2.74-2 4.5-2a5.5 5.5 0 0 1 5.402 6.5" />
|
|
||||||
<path d="M15 15h6" />
|
|
||||||
<path d="M18 12v6" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 435 B |
@ -1,4 +0,0 @@
|
|||||||
<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-send-icon lucide-send">
|
|
||||||
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" />
|
|
||||||
<path d="m21.854 2.147-10.94 10.939" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 422 B |
@ -1 +0,0 @@
|
|||||||
<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-cog-icon lucide-cog"><path d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"/><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/><path d="M12 2v2"/><path d="M12 22v-2"/><path d="m17 20.66-1-1.73"/><path d="M11 10.27 7 3.34"/><path d="m20.66 17-1.73-1"/><path d="m3.34 7 1.73 1"/><path d="M14 12h8"/><path d="M2 12h2"/><path d="m20.66 7-1.73 1"/><path d="m3.34 17 1.73-1"/><path d="m17 3.34-1 1.73"/><path d="m11 13.73-4 6.93"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 623 B |
@ -1,7 +0,0 @@
|
|||||||
<svg width="26" height="28" viewBox="0 0 26 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M20.4237 9.10858C22.4634 9.10858 24.1169 7.45507 24.1169 5.41537C24.1169 3.37567 22.4634 1.72217 20.4237 1.72217C18.384 1.72217 16.7305 3.37567 16.7305 5.41537C16.7305 7.45507 18.384 9.10858 20.4237 9.10858Z" stroke="currentColor" stroke-width="2.46215" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M5.65317 17.728C7.69287 17.728 9.34637 16.0745 9.34637 14.0348C9.34637 11.9951 7.69287 10.3416 5.65317 10.3416C3.61347 10.3416 1.95996 11.9951 1.95996 14.0348C1.95996 16.0745 3.61347 17.728 5.65317 17.728Z" stroke="currentColor" stroke-width="2.46215" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M20.4237 26.3434C22.4634 26.3434 24.1169 24.6899 24.1169 22.6502C24.1169 20.6105 22.4634 18.957 20.4237 18.957C18.384 18.957 16.7305 20.6105 16.7305 22.6502C16.7305 24.6899 18.384 26.3434 20.4237 26.3434Z" stroke="currentColor" stroke-width="2.46215" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M8.84277 15.8914L17.251 20.791" stroke="currentColor" stroke-width="2.46215" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M17.2387 7.27588L8.84277 12.1755" stroke="currentColor" stroke-width="2.46215" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1,4 +0,0 @@
|
|||||||
<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-shield-check-icon lucide-shield-check">
|
|
||||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
|
||||||
<path d="m9 12 2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 460 B |
@ -1,6 +0,0 @@
|
|||||||
<svg viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<line x1="4.72803" y1="12.7279" x2="20.728" y2="12.7279" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
|
|
||||||
<line x1="12.728" y1="4.72791" x2="12.728" y2="20.7279" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
|
|
||||||
<line x1="7.07107" y1="7.07104" x2="18.3848" y2="18.3848" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
|
|
||||||
<line x1="18.3848" y1="7.07107" x2="7.07106" y2="18.3848" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 565 B |
@ -1,7 +0,0 @@
|
|||||||
<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-star-icon lucide-star">
|
|
||||||
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
|
|
||||||
<path d="M20 3v4" />
|
|
||||||
<path d="M22 5h-4" />
|
|
||||||
<path d="M4 17v2" />
|
|
||||||
<path d="M5 18H3" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 603 B |
@ -1,5 +0,0 @@
|
|||||||
<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-sync-icon lucide-sync">
|
|
||||||
<rect width="10" height="14" x="3" y="8" rx="2" />
|
|
||||||
<path d="M5 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2h-2.4" />
|
|
||||||
<path d="M8 18h.01" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 390 B |
@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M7 16a4 4 0 0 1-.88-7.903A5 5 0 1 1 15.9 6L16 6a5 5 0 0 1 1 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 299 B |
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
|
|
||||||
<path d="M2 21a8 8 0 0 1 10.821-7.487" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<circle cx="10" cy="8" r="5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 574 B |
@ -1,6 +0,0 @@
|
|||||||
<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-book-lock-icon lucide-book-lock">
|
|
||||||
<path d="M18 6V4a2 2 0 1 0-4 0v2" />
|
|
||||||
<path d="M20 15v6a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
|
|
||||||
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H10" />
|
|
||||||
<rect x="12" y="6" width="8" height="5" rx="1" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 451 B |
@ -1,5 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<circle cx="12" cy="4" r="2" />
|
|
||||||
<path d="M10.2 3.2C5.5 4 2 8.1 2 13a2 2 0 0 0 4 0v-1a2 2 0 0 1 4 0v4a2 2 0 0 0 4 0v-4a2 2 0 0 1 4 0v1a2 2 0 0 0 4 0c0-4.9-3.5-9-8.2-9.8" />
|
|
||||||
<path d="M3.2 14.8a9 9 0 0 0 17.6 0" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 413 B |
@ -1 +0,0 @@
|
|||||||
<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-target-icon lucide-target"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 329 B |
@ -1 +0,0 @@
|
|||||||
<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-monitor-play-icon lucide-monitor-play"><path d="M10 7.75a.75.75 0 0 1 1.142-.638l3.664 2.249a.75.75 0 0 1 0 1.278l-3.664 2.25a.75.75 0 0 1-1.142-.64z"/><path d="M12 17v4"/><path d="M8 21h8"/><rect x="2" y="3" width="20" height="14" rx="2"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 448 B |
@ -1,3 +0,0 @@
|
|||||||
<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-video-icon lucide-video">
|
|
||||||
<path d="M4.5 4.5a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h8.25a3 3 0 0 0 3-3v-9a3 3 0 0 0-3-3H4.5ZM19.94 18.75l-2.69-2.69V7.94l2.69-2.69c.944-.945 2.56-.276 2.56 1.06v11.38c0 1.336-1.616 2.005-2.56 1.06Z" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 437 B |
@ -1,3 +0,0 @@
|
|||||||
<svg viewBox="0 0 113 152" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M25.8559 151C12.8609 139.722 -9.23051 107.241 6.36346 67.5413C25.8559 17.9171 91.25 30.8873 97.5379 49.4961C100.053 56.9397 103.197 67.5413 91.25 74.3084C82.2279 79.4187 66.1175 77.9572 61.6969 71.4888C57.2763 65.0204 42.8332 32.0151 112 1" stroke="#FFBB00" stroke-width="2" stroke-dasharray="4 4" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 396 B |
@ -1 +0,0 @@
|
|||||||
<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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 269 B |
@ -5,5 +5,8 @@ module.exports = function (api) {
|
|||||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
"nativewind/babel",
|
"nativewind/babel",
|
||||||
],
|
],
|
||||||
|
plugins: [
|
||||||
|
'expo-router/babel',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
117
components/file-upload/file-item-phone.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import SvgIcon from "@/components/svg-icon";
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { FileStatus } from "./file-uploader";
|
||||||
|
|
||||||
|
interface FileItemProps {
|
||||||
|
fileStatus: FileStatus;
|
||||||
|
index: number;
|
||||||
|
onRemove: (file: File) => void;
|
||||||
|
formatFileSize: (bytes: number) => string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileItem({
|
||||||
|
fileStatus,
|
||||||
|
index,
|
||||||
|
onRemove,
|
||||||
|
formatFileSize,
|
||||||
|
disabled = false
|
||||||
|
}: FileItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const translateY = React.useRef(new Animated.Value(10)).current;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 200,
|
||||||
|
delay: index * 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(translateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 200,
|
||||||
|
delay: index * 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
})
|
||||||
|
]).start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
opacity: fadeAnim,
|
||||||
|
transform: [{ translateY }]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.cardBody}>
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text style={styles.fileName}>{fileStatus.file.name}</Text>
|
||||||
|
<Text style={styles.fileSize}>{formatFileSize(fileStatus.file.size)}</Text>
|
||||||
|
</View>
|
||||||
|
{!disabled && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onRemove(fileStatus.file)}
|
||||||
|
style={styles.removeButton}
|
||||||
|
>
|
||||||
|
<SvgIcon name="close" size={16} color="#666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{fileStatus.progress !== undefined && fileStatus.progress < 100 && (
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
<View style={[styles.progressBar, { width: `${fileStatus.progress}%` }]} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
cardBody: {
|
||||||
|
padding: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
fileInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
fileSize: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
progressContainer: {
|
||||||
|
height: 2,
|
||||||
|
backgroundColor: '#e9ecef',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#007bff',
|
||||||
|
},
|
||||||
|
});
|
||||||
609
components/file-upload/file-uploader.tsx
Normal file
@ -0,0 +1,609 @@
|
|||||||
|
import { fetchApi } from "@/lib/server-api-util";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
// 导入子组件
|
||||||
|
import { ConfirmUpload } from "@/types/upload";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import MultiFileUploader from "./multi-file-uploader";
|
||||||
|
import SingleFileUploader from "./single-file-uploader";
|
||||||
|
import UploadDropzone from "./upload-dropzone";
|
||||||
|
import { extractVideoFirstFrame, getVideoDuration } from "./utils/videoUtils";
|
||||||
|
|
||||||
|
// 默认允许的文件类型
|
||||||
|
export const DEFAULT_ALLOWED_FILE_TYPES = ["video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska"];
|
||||||
|
export const DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
|
||||||
|
|
||||||
|
// 上传URL响应接口
|
||||||
|
export interface UploadUrlResponse {
|
||||||
|
upload_url: string;
|
||||||
|
file_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件状态接口
|
||||||
|
export interface FileStatus {
|
||||||
|
file: File;
|
||||||
|
id?: string;
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||||
|
url?: string;
|
||||||
|
thumbnailUrl?: string; // 添加缩略图URL
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileUploaderProps {
|
||||||
|
onFilesUploaded?: (files: FileStatus[]) => void;
|
||||||
|
maxFiles?: number;
|
||||||
|
allowMultiple?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
allowedFileTypes?: string[]; // 外部传入的允许文件类型
|
||||||
|
maxFileSize?: number; // 外部传入的最大文件大小
|
||||||
|
thumbnailPropsUrl?: string; // 注册后返回ai处理展示缩略图
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileUploader({
|
||||||
|
onFilesUploaded,
|
||||||
|
maxFiles = 1,
|
||||||
|
allowMultiple = false,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
allowedFileTypes = DEFAULT_ALLOWED_FILE_TYPES,
|
||||||
|
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||||
|
thumbnailPropsUrl = ""
|
||||||
|
}: FileUploaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// 图片的最小值
|
||||||
|
const MIN_IMAGE_SIZE = 300;
|
||||||
|
|
||||||
|
// 校验图片尺寸(异步)
|
||||||
|
function validateImageDimensions(file: File): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (img.width < MIN_IMAGE_SIZE || img.height < MIN_IMAGE_SIZE) {
|
||||||
|
resolve(`图片尺寸不能小于${MIN_IMAGE_SIZE}px,当前为${img.width}x${img.height}`);
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve("无法读取图片尺寸");
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 格式化文件大小
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证文件
|
||||||
|
const validateFile = (file: File): string | null => {
|
||||||
|
// 验证文件类型
|
||||||
|
if (!allowedFileTypes.includes(file.type)) {
|
||||||
|
const errorMsg = t('validation.file.invalidType');
|
||||||
|
// addToast({
|
||||||
|
// title: t('fileUploader.invalidFileTitle'),
|
||||||
|
// description: errorMsg,
|
||||||
|
// color: "danger",
|
||||||
|
// });
|
||||||
|
return errorMsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小
|
||||||
|
// if (file.size > maxFileSize) {
|
||||||
|
// const errorMsg = t('validation.file.tooLarge');
|
||||||
|
// addToast({
|
||||||
|
// title: t('fileUploader.fileTooLargeTitle'),
|
||||||
|
// description: `${errorMsg} ${formatFileSize(maxFileSize)}`,
|
||||||
|
// color: "danger",
|
||||||
|
// });
|
||||||
|
// return errorMsg;
|
||||||
|
// }
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建缩略图
|
||||||
|
const createThumbnail = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 如果是图片文件,直接使用图片作为缩略图
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
if (e.target && typeof e.target.result === 'string') {
|
||||||
|
resolve(e.target.result);
|
||||||
|
} else {
|
||||||
|
reject(new Error('图片加载失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(new Error('图片读取失败'));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是视频文件,创建视频缩略图
|
||||||
|
const videoUrl = URL.createObjectURL(file);
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.src = videoUrl;
|
||||||
|
video.crossOrigin = 'anonymous';
|
||||||
|
video.muted = true;
|
||||||
|
video.preload = 'metadata';
|
||||||
|
|
||||||
|
video.onloadeddata = () => {
|
||||||
|
try {
|
||||||
|
// 设置视频时间到第一帧
|
||||||
|
video.currentTime = 0.1;
|
||||||
|
} catch (e) {
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onseeked = () => {
|
||||||
|
try {
|
||||||
|
// 创建canvas并绘制视频帧
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
const thumbnailUrl = canvas.toDataURL('image/jpeg', 0.7);
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
resolve(thumbnailUrl);
|
||||||
|
} else {
|
||||||
|
reject(new Error('无法创建canvas上下文'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onerror = () => {
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
reject(new Error('视频加载失败'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// 压缩图片函数
|
||||||
|
async function compressImageToFile(
|
||||||
|
file: File,
|
||||||
|
maxWidth = 600,
|
||||||
|
quality = 0.7,
|
||||||
|
outputType = 'image/png'
|
||||||
|
): Promise<File> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event: ProgressEvent<FileReader>) => {
|
||||||
|
const target = event.target as FileReader;
|
||||||
|
if (!target || !target.result) {
|
||||||
|
reject(new Error('Failed to read file'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
// 计算压缩尺寸
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
let width = img.width;
|
||||||
|
let height = img.height;
|
||||||
|
|
||||||
|
if (width > maxWidth) {
|
||||||
|
height = Math.round((height * maxWidth) / width);
|
||||||
|
width = maxWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// 绘制压缩图片
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Could not get 2D context from canvas');
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// 转为 Blob 并生成 File 对象
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob: Blob | null) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('Failed to create blob from canvas'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let file_name = uuidv4() + ".png"
|
||||||
|
const compressedFile = new File([blob], file_name, {
|
||||||
|
type: outputType,
|
||||||
|
lastModified: Date.now()
|
||||||
|
});
|
||||||
|
resolve(compressedFile);
|
||||||
|
},
|
||||||
|
outputType,
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = target.result as string;
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 新增素材
|
||||||
|
const addMaterial = async (file: string, compressFile: string) => {
|
||||||
|
await fetchApi('/material', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify([{
|
||||||
|
"file_id": file,
|
||||||
|
"preview_file_id": compressFile
|
||||||
|
}])
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传单个文件
|
||||||
|
const uploadFile = async (fileStatus: FileStatus, fileIndex: number, compressedFile: File) => {
|
||||||
|
// 创建新的文件状态对象,而不是修改原来的数组
|
||||||
|
// 这样可以避免多个异步操作对同一个数组的并发修改
|
||||||
|
let currentFileStatus: FileStatus = {
|
||||||
|
...fileStatus,
|
||||||
|
status: 'uploading' as const,
|
||||||
|
progress: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用函数更新文件状态,确保每次更新都是原子的
|
||||||
|
const updateFileStatus = (updates: Partial<FileStatus>) => {
|
||||||
|
currentFileStatus = { ...currentFileStatus, ...updates };
|
||||||
|
setFiles(prevFiles => {
|
||||||
|
const newFiles = [...prevFiles];
|
||||||
|
newFiles[fileIndex] = currentFileStatus;
|
||||||
|
return newFiles;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化上传状态
|
||||||
|
updateFileStatus({ status: 'uploading', progress: 0 });
|
||||||
|
|
||||||
|
// 添加小延迟,确保初始状态能被看到
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取视频时长
|
||||||
|
let metadata = {};
|
||||||
|
if (fileStatus.file.type.startsWith('video/')) {
|
||||||
|
metadata = {
|
||||||
|
duration: (await getVideoDuration(fileStatus.file)).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取上传URL
|
||||||
|
updateFileStatus({ progress: 10 });
|
||||||
|
const uploadUrlData = await getUploadUrl(fileStatus.file, metadata);
|
||||||
|
const compressedFileData = await getUploadUrl(compressedFile, {})
|
||||||
|
|
||||||
|
// 确保正确更新文件ID
|
||||||
|
updateFileStatus({ id: uploadUrlData.file_id, progress: 20 });
|
||||||
|
|
||||||
|
// 上传文件到URL
|
||||||
|
await uploadFileToUrl(
|
||||||
|
compressedFile,
|
||||||
|
compressedFileData.upload_url,
|
||||||
|
(progress) => {
|
||||||
|
// 将实际进度映射到 60%-90% 区间
|
||||||
|
const mappedProgress = 60 + (progress * 0.3);
|
||||||
|
updateFileStatus({ progress: Math.round(mappedProgress) });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await uploadFileToUrl(
|
||||||
|
fileStatus.file,
|
||||||
|
uploadUrlData.upload_url,
|
||||||
|
(progress) => {
|
||||||
|
// 将实际进度映射到 60%-90% 区间
|
||||||
|
const mappedProgress = 60 + (progress * 0.3);
|
||||||
|
updateFileStatus({ progress: Math.round(mappedProgress) });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 向服务端confirm上传
|
||||||
|
await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_id: uploadUrlData.file_id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_id: compressedFileData.file_id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待一些时间再标记为成功
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// 更新状态为成功
|
||||||
|
updateFileStatus({ status: 'success', progress: 100, id: uploadUrlData.file_id });
|
||||||
|
|
||||||
|
await addMaterial(uploadUrlData.file_id, compressedFileData.file_id)
|
||||||
|
// 打印最终状态以进行调试
|
||||||
|
// console.log('最终文件状态:', currentFileStatus);
|
||||||
|
// 调用回调函数
|
||||||
|
if (onFilesUploaded) {
|
||||||
|
// 使用当前文件状态创建一个新的数组传递给回调函数
|
||||||
|
const updatedFiles = [...files];
|
||||||
|
updatedFiles[fileIndex] = {
|
||||||
|
...currentFileStatus,
|
||||||
|
id: uploadUrlData.file_id, // 确保 ID 正确传递
|
||||||
|
};
|
||||||
|
|
||||||
|
// 延迟调用回调函数,确保状态已更新
|
||||||
|
setTimeout(() => {
|
||||||
|
// console.log('传递给回调的文件:', updatedFiles);
|
||||||
|
onFilesUploaded(updatedFiles);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
// 更新状态为错误
|
||||||
|
updateFileStatus({
|
||||||
|
status: 'error',
|
||||||
|
error: error instanceof Error ? error.message : '上传失败'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理文件选择
|
||||||
|
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
|
||||||
|
if (!selectedFiles) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
const newFiles: FileStatus[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < selectedFiles.length; i++) {
|
||||||
|
const file = selectedFiles[i];
|
||||||
|
const error = validateFile(file);
|
||||||
|
let dimensionError: string | null = null;
|
||||||
|
if (!error && file.type.startsWith('image/')) {
|
||||||
|
dimensionError = await validateImageDimensions(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
let thumbnailUrl = '';
|
||||||
|
// 只在文件验证通过且尺寸合格时创建缩略图
|
||||||
|
if (!error && !dimensionError) {
|
||||||
|
try {
|
||||||
|
// 创建缩略图,支持图片和视频
|
||||||
|
thumbnailUrl = await createThumbnail(file);
|
||||||
|
newFiles.push({
|
||||||
|
file,
|
||||||
|
progress: 0,
|
||||||
|
error: error ?? undefined,
|
||||||
|
status: error ? 'error' : 'pending',
|
||||||
|
thumbnailUrl
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('缩略图创建失败:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 添加警告
|
||||||
|
// addToast({
|
||||||
|
// title: t('fileUploader.fileTooSmallTitle'),
|
||||||
|
// description: t('fileUploader.fileTooSmall'),
|
||||||
|
// color: "warning",
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新文件列表
|
||||||
|
setFiles(prev => {
|
||||||
|
// 单文件模式下且已有文件时,替换现有文件
|
||||||
|
if (maxFiles === 1 && prev.length > 0 && newFiles.length > 0) {
|
||||||
|
return [newFiles[0]]; // 只保留新选择的第一个文件
|
||||||
|
} else {
|
||||||
|
// 多文件模式,合并并限制数量
|
||||||
|
const combinedFiles = [...prev, ...newFiles];
|
||||||
|
return combinedFiles.length > maxFiles
|
||||||
|
? combinedFiles.slice(0, maxFiles)
|
||||||
|
: combinedFiles;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 在状态更新后,使用 useEffect 来处理上传
|
||||||
|
setIsUploading(false);
|
||||||
|
}, [maxFiles]);
|
||||||
|
|
||||||
|
|
||||||
|
// 获取上传URL
|
||||||
|
const getUploadUrl = async (file: File, metadata: { [key: string]: string }): Promise<UploadUrlResponse> => {
|
||||||
|
const body = {
|
||||||
|
filename: file.name,
|
||||||
|
content_type: file.type,
|
||||||
|
file_size: file.size,
|
||||||
|
metadata
|
||||||
|
}
|
||||||
|
return await fetchApi<UploadUrlResponse>("/file/generate-upload-url", {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 上传文件到URL
|
||||||
|
const uploadFileToUrl = async (file: File, uploadUrl: string, onProgress: (progress: number) => void): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.open('PUT', uploadUrl);
|
||||||
|
xhr.setRequestHeader('Content-Type', file.type);
|
||||||
|
|
||||||
|
// 进度监听
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const progress = Math.round((event.loaded / event.total) * 100);
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
reject(new Error('Network error during upload'));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除文件
|
||||||
|
const removeFile = (fileToRemove: File) => {
|
||||||
|
setFiles(prev => prev.filter(fileStatus => fileStatus.file !== fileToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除所有文件
|
||||||
|
const clearFiles = () => {
|
||||||
|
setFiles([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开文件选择器
|
||||||
|
const openFileSelector = () => {
|
||||||
|
if (!disabled) {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
} else {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = allowedFileTypes.join(',');
|
||||||
|
input.multiple = allowMultiple;
|
||||||
|
input.onchange = (e) => handleFileSelect((e.target as HTMLInputElement).files);
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 useEffect 监听 files 变化,处理待上传的文件
|
||||||
|
useEffect(() => {
|
||||||
|
const processFiles = async () => {
|
||||||
|
// Only process files that are in 'pending' status
|
||||||
|
const pendingFiles = files
|
||||||
|
.filter(f => f.status === 'pending')
|
||||||
|
.filter((fileStatus, index, self) =>
|
||||||
|
index === self.findIndex(f => f.file === fileStatus.file)
|
||||||
|
); // Remove duplicates
|
||||||
|
|
||||||
|
if (pendingFiles.length === 0) return;
|
||||||
|
|
||||||
|
// Create a new array with updated status to prevent infinite loops
|
||||||
|
setFiles(prevFiles =>
|
||||||
|
prevFiles.map(file =>
|
||||||
|
pendingFiles.some(pf => pf.file === file.file)
|
||||||
|
? { ...file, status: 'uploading' as const }
|
||||||
|
: file
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process each file sequentially to avoid race conditions
|
||||||
|
for (const fileStatus of pendingFiles) {
|
||||||
|
try {
|
||||||
|
const fileIndex = files.findIndex(f => f.file === fileStatus.file);
|
||||||
|
if (fileIndex === -1) continue;
|
||||||
|
|
||||||
|
let compressedFile: File;
|
||||||
|
if (fileStatus.file.type?.includes('video')) {
|
||||||
|
const frameFile = await extractVideoFirstFrame(fileStatus.file);
|
||||||
|
compressedFile = await compressImageToFile(frameFile, 600, 0.7);
|
||||||
|
} else {
|
||||||
|
compressedFile = fileStatus.file;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadFile(
|
||||||
|
{ ...fileStatus, status: 'uploading' as const },
|
||||||
|
fileIndex,
|
||||||
|
compressedFile
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing file:', error);
|
||||||
|
setFiles(prevFiles =>
|
||||||
|
prevFiles.map(f =>
|
||||||
|
f.file === fileStatus.file
|
||||||
|
? {
|
||||||
|
...f,
|
||||||
|
status: 'error' as const,
|
||||||
|
error: error instanceof Error ? error.message : '处理文件失败'
|
||||||
|
}
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processFiles();
|
||||||
|
}, [files]); // Only run when files array changes
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={className}
|
||||||
|
aria-label="文件上传"
|
||||||
|
>
|
||||||
|
{/* 隐藏的文件输入 */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={allowedFileTypes.join(',')}
|
||||||
|
multiple={allowMultiple}
|
||||||
|
onChange={(e) => handleFileSelect(e.target.files)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 文件上传区域 - 始终可见 */}
|
||||||
|
<View className="space-y-6 h-full">
|
||||||
|
{/* 上传区域 */}
|
||||||
|
{maxFiles === 1 && files.length === 1 ? (
|
||||||
|
/* 单文件模式且已有文件 - 不添加外层的onClick事件 */
|
||||||
|
<SingleFileUploader
|
||||||
|
file={files[0]}
|
||||||
|
onReplace={openFileSelector}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
) : thumbnailPropsUrl ? <img
|
||||||
|
src={thumbnailPropsUrl}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/> : (
|
||||||
|
/* 多文件模式或无文件 - 只在组件上添加一个onClick事件 */
|
||||||
|
<UploadDropzone
|
||||||
|
onClick={openFileSelector}
|
||||||
|
disabled={disabled}
|
||||||
|
allowedFileTypes={allowedFileTypes}
|
||||||
|
// maxFileSize={maxFileSize}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 文件列表区域 - 仅在多文件模式下显示 */}
|
||||||
|
{maxFiles !== 1 && files.length > 0 && (
|
||||||
|
<MultiFileUploader
|
||||||
|
files={files}
|
||||||
|
onRemove={removeFile}
|
||||||
|
onClearAll={clearFiles}
|
||||||
|
formatFileSize={formatFileSize}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
components/file-upload/multi-file-uploader.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import useWindowSize from "@/hooks/useWindowSize";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { ThemedText } from "../ThemedText";
|
||||||
|
import FileItemPhone from "./file-item-phone";
|
||||||
|
import { FileStatus } from "./file-uploader";
|
||||||
|
|
||||||
|
interface MultiFileUploaderProps {
|
||||||
|
files: FileStatus[];
|
||||||
|
onRemove: (file: File) => void;
|
||||||
|
onClearAll: () => void;
|
||||||
|
formatFileSize: (bytes: number) => string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多文件上传组件 - 用于显示文件列表和管理多个文件
|
||||||
|
*/
|
||||||
|
export default function MultiFileUploader({
|
||||||
|
files,
|
||||||
|
onRemove,
|
||||||
|
onClearAll,
|
||||||
|
formatFileSize,
|
||||||
|
disabled = false
|
||||||
|
}: MultiFileUploaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// 获取当前屏幕尺寸
|
||||||
|
const { isMobile } = useWindowSize();
|
||||||
|
return (
|
||||||
|
<View className="space-y-4">
|
||||||
|
<View className="flex justify-between items-center">
|
||||||
|
<ThemedText className="text-md font-medium">
|
||||||
|
{t('fileUploader.uploadedFiles')}
|
||||||
|
</ThemedText>
|
||||||
|
<View className="p-6 w-full">
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`w-full bg-white rounded-full p-4 items-center `}
|
||||||
|
onPress={onClearAll}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<ThemedText className="text-textTertiary text-lg font-semibold">
|
||||||
|
{t('fileUploader.clearAll')}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="grid grid-cols-3 xl:grid-cols-4 gap-4 sm:max-h-[15rem] overflow-y-auto w-full">
|
||||||
|
{files.map((fileStatus, index) => (
|
||||||
|
(
|
||||||
|
<FileItemPhone
|
||||||
|
key={index}
|
||||||
|
fileStatus={fileStatus}
|
||||||
|
index={index}
|
||||||
|
onRemove={onRemove}
|
||||||
|
formatFileSize={formatFileSize}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
components/file-upload/single-file-uploader.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FileStatus } from './file-uploader';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
|
||||||
|
interface SingleFileUploaderProps {
|
||||||
|
file: FileStatus;
|
||||||
|
onReplace: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
formatFileSize?: (bytes: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SingleFileUploader({
|
||||||
|
file,
|
||||||
|
onReplace,
|
||||||
|
disabled = false,
|
||||||
|
formatFileSize = (bytes) => `${bytes} B`
|
||||||
|
}: SingleFileUploaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 缩略图容器 */}
|
||||||
|
<View style={styles.thumbnailContainer}>
|
||||||
|
{file.thumbnailUrl ? (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
source={{ uri: file.thumbnailUrl }}
|
||||||
|
style={styles.thumbnailImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
{/* 错误信息显示 */}
|
||||||
|
{file.error && (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{file.error}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={styles.placeholderContainer}>
|
||||||
|
<Icon name="videocam" size={40} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 显示替换按钮 */}
|
||||||
|
{file.thumbnailUrl && !disabled && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.replaceButton}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onReplace();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<View style={styles.replaceButtonContent}>
|
||||||
|
<Icon name="upload" size={24} color="white" />
|
||||||
|
<Text style={styles.replaceButtonText}>
|
||||||
|
{t('common.replace')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 文件信息 */}
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<View style={styles.fileInfoText}>
|
||||||
|
<Text style={styles.fileName} numberOfLines={1}>
|
||||||
|
{file.file.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.fileSize}>
|
||||||
|
{formatFileSize(file.file.size)} • {file.status}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{file.status === 'uploading' && (
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
<View style={[styles.progressBar, { width: `${file.progress}%` }]} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
thumbnailContainer: {
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: 16/9,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
thumbnailImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: 'rgba(220, 38, 38, 0.8)',
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
placeholderContainer: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
replaceButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
replaceButtonContent: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
replaceButtonText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
fileInfo: {
|
||||||
|
marginTop: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
fileInfoText: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#111827',
|
||||||
|
},
|
||||||
|
fileSize: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
progressContainer: {
|
||||||
|
width: 96,
|
||||||
|
height: 8,
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
},
|
||||||
|
});
|
||||||
77
components/file-upload/upload-dropzone.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import SvgIcon from "@/components/svg-icon";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DEFAULT_ALLOWED_FILE_TYPES, DEFAULT_MAX_FILE_SIZE } from "./file-uploader";
|
||||||
|
|
||||||
|
interface UploadDropzoneProps {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
allowedFileTypes?: string[];
|
||||||
|
maxFileSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传区域组件 - 用于显示文件拖放和选择区域
|
||||||
|
*/
|
||||||
|
export default function UploadDropzone({
|
||||||
|
onClick,
|
||||||
|
disabled = false,
|
||||||
|
allowedFileTypes = DEFAULT_ALLOWED_FILE_TYPES,
|
||||||
|
maxFileSize = DEFAULT_MAX_FILE_SIZE
|
||||||
|
}: UploadDropzoneProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 格式化文件类型显示
|
||||||
|
const formatFileTypes = (types: string[]): string => {
|
||||||
|
return types.map(type => {
|
||||||
|
// 从 MIME 类型提取文件扩展名
|
||||||
|
const extensions: Record<string, string> = {
|
||||||
|
'video/mp4': 'MP4',
|
||||||
|
'video/quicktime': 'MOV',
|
||||||
|
'video/x-msvideo': 'AVI',
|
||||||
|
'video/x-matroska': 'MKV',
|
||||||
|
'image/jpeg': 'JPG',
|
||||||
|
'image/png': 'PNG',
|
||||||
|
'image/gif': 'GIF',
|
||||||
|
'image/webp': 'WEBP'
|
||||||
|
};
|
||||||
|
return extensions[type] || type.split('/')[1]?.toUpperCase() || type;
|
||||||
|
}).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化文件大小显示
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all
|
||||||
|
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary/50'} h-full`}
|
||||||
|
onClick={disabled ? undefined : onClick}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 h-full">
|
||||||
|
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
|
||||||
|
<SvgIcon name="upload" className="h-6 w-6 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium text-gray-900">
|
||||||
|
{t('fileUploader.dragAndDropFiles')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
{t('fileUploader.orClickToUpload')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{t('fileUploader.supportedFormats')}: {formatFileTypes(allowedFileTypes)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{t('fileUploader.maxFileSize')}: {formatFileSize(maxFileSize)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
components/file-upload/utils/videoUtils.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 从视频文件中提取第一帧并返回为File对象
|
||||||
|
* @param videoFile 视频文件
|
||||||
|
* @returns 包含视频第一帧的File对象
|
||||||
|
*/
|
||||||
|
export const extractVideoFirstFrame = (videoFile: File): Promise<File> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const videoUrl = URL.createObjectURL(videoFile);
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.src = videoUrl;
|
||||||
|
video.crossOrigin = 'anonymous';
|
||||||
|
video.muted = true;
|
||||||
|
video.preload = 'metadata';
|
||||||
|
|
||||||
|
video.onloadeddata = () => {
|
||||||
|
try {
|
||||||
|
// 设置视频时间到第一帧
|
||||||
|
video.currentTime = 0.1;
|
||||||
|
} catch (e) {
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onseeked = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('无法获取canvas上下文');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制视频帧到canvas
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// 将canvas转换为DataURL
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg');
|
||||||
|
|
||||||
|
// 将DataURL转换为Blob
|
||||||
|
const byteString = atob(dataUrl.split(',')[1]);
|
||||||
|
const mimeString = dataUrl.split(',')[0].split(':')[1].split(';')[0];
|
||||||
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
|
const ia = new Uint8Array(ab);
|
||||||
|
for (let i = 0; i < byteString.length; i++) {
|
||||||
|
ia[i] = byteString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([ab], { type: mimeString });
|
||||||
|
|
||||||
|
// 创建File对象
|
||||||
|
const frameFile = new File(
|
||||||
|
[blob],
|
||||||
|
`${videoFile.name.replace(/\.[^/.]+$/, '')}_frame.jpg`,
|
||||||
|
{ type: 'image/jpeg' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清理URL对象
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
resolve(frameFile);
|
||||||
|
} catch (e) {
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onerror = () => {
|
||||||
|
URL.revokeObjectURL(videoUrl);
|
||||||
|
reject(new Error('视频加载失败'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取视频时长
|
||||||
|
export const getVideoDuration = (file: File): Promise<number> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.preload = 'metadata';
|
||||||
|
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
URL.revokeObjectURL(video.src);
|
||||||
|
resolve(video.duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onerror = () => {
|
||||||
|
URL.revokeObjectURL(video.src);
|
||||||
|
resolve(0); // Return 0 if we can't get the duration
|
||||||
|
};
|
||||||
|
|
||||||
|
video.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据 mp4 的url来获取视频时长
|
||||||
|
/**
|
||||||
|
* 根据视频URL获取视频时长
|
||||||
|
* @param videoUrl 视频的URL
|
||||||
|
* @returns 返回一个Promise,解析为视频时长(秒)
|
||||||
|
*/
|
||||||
|
export const getVideoDurationFromUrl = async (videoUrl: string): Promise<number> => {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
// 创建临时的video元素
|
||||||
|
const video = document.createElement('video');
|
||||||
|
|
||||||
|
// 设置为只加载元数据,不加载整个视频
|
||||||
|
video.preload = 'metadata';
|
||||||
|
|
||||||
|
// 处理加载成功
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
// 释放URL对象
|
||||||
|
URL.revokeObjectURL(video.src);
|
||||||
|
// 返回视频时长(秒)
|
||||||
|
resolve(video.duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理加载错误
|
||||||
|
video.onerror = () => {
|
||||||
|
URL.revokeObjectURL(video.src);
|
||||||
|
reject(new Error('无法加载视频'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理网络错误
|
||||||
|
video.onabort = () => {
|
||||||
|
URL.revokeObjectURL(video.src);
|
||||||
|
reject(new Error('视频加载被中止'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置视频源
|
||||||
|
video.src = videoUrl;
|
||||||
|
|
||||||
|
// 添加跨域属性(如果需要)
|
||||||
|
video.setAttribute('crossOrigin', 'anonymous');
|
||||||
|
|
||||||
|
// 开始加载元数据
|
||||||
|
video.load();
|
||||||
|
});
|
||||||
|
};
|
||||||
195
components/login/code.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import Error from "@/assets/icons/svg/error.svg";
|
||||||
|
import { fetchApi } from "@/lib/server-api-util";
|
||||||
|
import { User } from "@/types/user";
|
||||||
|
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 { useAuth } from "../../contexts/auth-context";
|
||||||
|
import { ThemedText } from "../ThemedText";
|
||||||
|
import { Steps } from "./phoneLogin";
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
setSteps: (steps: Steps) => void;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Code = ({ setSteps, phone }: LoginProps) => {
|
||||||
|
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 [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const focusNext = (index: number, value: string) => {
|
||||||
|
if (value && index < 5) {
|
||||||
|
refs?.current?.[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const focusPrevious = (index: number, key: string) => {
|
||||||
|
if (key === 'Backspace' && index > 0 && !code[index]) {
|
||||||
|
refs?.current?.[index - 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleCodeChange = (text: string, index: number) => {
|
||||||
|
setError('');
|
||||||
|
const newCode = [...code];
|
||||||
|
newCode[index] = text;
|
||||||
|
setCode(newCode);
|
||||||
|
focusNext(index, text);
|
||||||
|
};
|
||||||
|
const sendVerificationCode = async () => {
|
||||||
|
try {
|
||||||
|
// 发送验证码
|
||||||
|
await fetchApi(`/iam/veritification-code`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: phone }),
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(t("auth.telLogin.sendCodeError", { ns: 'login' }), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTelLogin = async () => {
|
||||||
|
setError('');
|
||||||
|
if (!code.join('')) {
|
||||||
|
setError(t("auth.telLogin.codeRequired", { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果验证码不是六位,提示错误
|
||||||
|
if (code.join('').length !== 6) {
|
||||||
|
setError(t("auth.telLogin.codeInvalid", { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setCountdown(60);
|
||||||
|
try {
|
||||||
|
await fetchApi<User>(`/iam/login/phone-login`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: phone, code: code.join('') }),
|
||||||
|
}).then((res) => {
|
||||||
|
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倒计时
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown > 0) {
|
||||||
|
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [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' })}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText className="text-base text-gray-600 text-center mb-1">
|
||||||
|
{t("auth.telLogin.secondTitle", { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText className="text-base font-medium !text-buttonFill">
|
||||||
|
{phone}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ translateX: shakeAnim }],
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 24,
|
||||||
|
marginBottom: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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}
|
||||||
|
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'}`}>
|
||||||
|
<Error />
|
||||||
|
<ThemedText className="text-base font-medium !text-buttonFill ml-2">
|
||||||
|
{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">
|
||||||
|
{t("auth.telLogin.sendAgain", { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
<TouchableOpacity onPress={() => {
|
||||||
|
if (countdown > 0) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
sendVerificationCode()
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<ThemedText className={`!text-buttonFill font-medium ml-1 ${countdown > 0 ? '!text-gray-400' : ''}`}>
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default Code
|
||||||
121
components/login/forgetPwd.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
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 { ThemedText } from "../ThemedText";
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
setIsSignUp?: (isSignUp: string) => void;
|
||||||
|
closeModal?: () => void;
|
||||||
|
updateUrlParam?: (status: string, value: string) => void;
|
||||||
|
setError: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else if (countdown === 0 && isDisabled) {
|
||||||
|
setIsDisabled(false);
|
||||||
|
}
|
||||||
|
}, [countdown, isDisabled]);
|
||||||
|
|
||||||
|
// 发送邮件
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!email) {
|
||||||
|
setError(t('auth.forgetPwd.emailPlaceholder', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocading(true);
|
||||||
|
const body = {
|
||||||
|
email: email,
|
||||||
|
}
|
||||||
|
// 调接口确定邮箱是否正确,是否有该用户邮箱权限
|
||||||
|
fetchApi<User>('/iam/reset-password-session', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((_) => {
|
||||||
|
console.log("Password reset email sent successfully");
|
||||||
|
setIsDisabled(true);
|
||||||
|
setCountdown(60); // 开始60秒倒计时
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to send reset email:', error);
|
||||||
|
setError(t('auth.forgetPwd.sendEmailError', { ns: 'login' }));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLocading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// 返回登陆
|
||||||
|
const handleBackToLogin = () => {
|
||||||
|
if (setIsSignUp) {
|
||||||
|
setIsSignUp('login');
|
||||||
|
}
|
||||||
|
if (updateUrlParam) {
|
||||||
|
updateUrlParam('status', 'login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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" })}
|
||||||
|
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default ForgetPwd
|
||||||
152
components/login/login.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TextInput, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useAuth } from "../../contexts/auth-context";
|
||||||
|
import { fetchApi } from "../../lib/server-api-util";
|
||||||
|
import { User } from "../../types/user";
|
||||||
|
import { ThemedText } from "../ThemedText";
|
||||||
|
|
||||||
|
const REMEMBER_ACCOUNT_KEY = 'fairclip_remembered_account';
|
||||||
|
interface LoginProps {
|
||||||
|
updateUrlParam: (status: string, value: string) => void;
|
||||||
|
setError: (error: string) => void;
|
||||||
|
setShowPassword: (showPassword: boolean) => void;
|
||||||
|
showPassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: LoginProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!email) {
|
||||||
|
setError(t('auth.login.emailPlaceholder', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if (!password) {
|
||||||
|
setError(t('auth.login.passwordPlaceholder', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
account: email,
|
||||||
|
password: password,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetchApi<User>('/iam/login/password-login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const userInfo = await fetchApi<User>('/iam/user-info');
|
||||||
|
login({ ...res, email: res?.account }, res.access_token || '');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForgotPassword = () => {
|
||||||
|
updateUrlParam('status', 'forgetPwd');
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<TextInput
|
||||||
|
className="border border-gray-300 rounded-2xl p-3 text-base bg-inputBackground pr-12"
|
||||||
|
placeholder={t('auth.login.passwordPlaceholder', { ns: 'login' })}
|
||||||
|
placeholderTextColor="#ccc"
|
||||||
|
value={password}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPassword(text);
|
||||||
|
setError('123');
|
||||||
|
}}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="absolute right-3 top-3.5"
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={showPassword ? 'eye' : 'eye-off'}
|
||||||
|
size={20}
|
||||||
|
color="#666"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<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
|
||||||
85
components/login/phone.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { fetchApi } from "@/lib/server-api-util";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TextInput, TouchableOpacity, View } from "react-native";
|
||||||
|
import { ThemedText } from "../ThemedText";
|
||||||
|
import { Steps } from "./phoneLogin";
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
setSteps: (steps: Steps) => void;
|
||||||
|
setPhone: (phone: string) => void;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Phone = ({ setSteps, setPhone, phone }: LoginProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
// 发送验证码
|
||||||
|
const sendVerificationCode = async () => {
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||||
|
setError(t("auth.telLogin.phoneInvalid", { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// 发送验证码
|
||||||
|
await fetchApi(`/iam/veritification-code`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: phone }),
|
||||||
|
})
|
||||||
|
setSteps('code')
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
setPhone("")
|
||||||
|
setIsLoading(false);
|
||||||
|
console.error(t("auth.telLogin.sendCodeError", { ns: 'login' }), error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <View>
|
||||||
|
{/* 手机号输入框 */}
|
||||||
|
<View className="mb-5">
|
||||||
|
<View className="w-full flex flex-row justify-between">
|
||||||
|
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
|
||||||
|
{t('auth.telLogin.title', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText className="text-sm !text-textPrimary mb-2 ml-2">
|
||||||
|
{error}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
className="border border-gray-300 rounded-2xl p-3 text-base bg-inputBackground"
|
||||||
|
placeholder={t('auth.telLogin.phoneRequired', { ns: 'login' })}
|
||||||
|
placeholderTextColor="#ccc"
|
||||||
|
value={phone}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPhone(text);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 发送验证码 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`w-full bg-[#E2793F] rounded-full text-[#fff] p-4 items-center mb-6 ${isLoading ? 'opacity-70' : ''}`}
|
||||||
|
onPress={sendVerificationCode}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<ThemedText className="!text-white font-semibold">
|
||||||
|
{t('auth.telLogin.sendCode', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default Phone
|
||||||
20
components/login/phoneLogin.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import Code from "./code";
|
||||||
|
import Phone from "./phone";
|
||||||
|
|
||||||
|
export type Steps = "phone" | "code";
|
||||||
|
|
||||||
|
const PhoneLogin = () => {
|
||||||
|
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} />
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default PhoneLogin
|
||||||
325
components/login/signUp.tsx
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
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 { useAuth } from "../../contexts/auth-context";
|
||||||
|
import { fetchApi } from "../../lib/server-api-util";
|
||||||
|
import { User } from "../../types/user";
|
||||||
|
import { ThemedText } from "../ThemedText";
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
updateUrlParam: (status: string, value: string) => void;
|
||||||
|
setError: (error: string) => void;
|
||||||
|
setShowPassword: (showPassword: boolean) => void;
|
||||||
|
showPassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: LoginProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [passwordsMatch, setPasswordsMatch] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
|
||||||
|
// 从 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);
|
||||||
|
// 当密码或确认密码变化时,检查是否匹配
|
||||||
|
if (confirmPassword && value !== confirmPassword) {
|
||||||
|
setPasswordsMatch(false);
|
||||||
|
} else {
|
||||||
|
setPasswordsMatch(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPasswordChange = (value: string) => {
|
||||||
|
setConfirmPassword(value);
|
||||||
|
// 当密码或确认密码变化时,检查是否匹配
|
||||||
|
if (password && value !== password) {
|
||||||
|
setPasswordsMatch(false);
|
||||||
|
} else {
|
||||||
|
setPasswordsMatch(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!email) {
|
||||||
|
setError(t('auth.signup.emailPlaceholder', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
setError(t('auth.signup.passwordPlaceholder', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 验证两次密码是否一致
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setPasswordsMatch(false);
|
||||||
|
setError(t('auth.signup.passwordNotMatch', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!checked) {
|
||||||
|
setError(t('auth.signup.checkedRequired', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
// 校验是否符合邮箱规范
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
setError(t('auth.signup.emailAuth', { ns: 'login' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
email: email,
|
||||||
|
password: password
|
||||||
|
};
|
||||||
|
|
||||||
|
// 这里调用实际的注册API
|
||||||
|
const response = await fetchApi<User>('/iam/register/email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}, true, false);
|
||||||
|
|
||||||
|
// 存储token
|
||||||
|
login(response as User, response.access_token || '');
|
||||||
|
|
||||||
|
// 如果有任务ID,跳转到上传页面
|
||||||
|
if (taskId) {
|
||||||
|
// 使用字符串路径格式传递参数
|
||||||
|
// router.push(`/upload?steps=${encodeURIComponent(steps || '')}&task_id=${encodeURIComponent(taskId)}`);
|
||||||
|
} else {
|
||||||
|
// 注册成功后跳转到首页
|
||||||
|
router.replace('/user-message');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration failed:', error);
|
||||||
|
// 这里可以添加错误处理逻辑
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!passwordsMatch) {
|
||||||
|
setError(t('auth.login.passwordNotMatch', { ns: 'login' }));
|
||||||
|
}
|
||||||
|
}, [passwordsMatch])
|
||||||
|
// 初始化
|
||||||
|
useEffect(() => {
|
||||||
|
setShowPassword(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' })}
|
||||||
|
</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")
|
||||||
|
}
|
||||||
|
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: checked ? '#E2793F' : '#ccc',
|
||||||
|
backgroundColor: checked ? '#E2793F' : 'transparent',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{checked && (
|
||||||
|
<Ionicons name="checkmark" size={14} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', flex: 1 }}>
|
||||||
|
<ThemedText className="text-sm !text-textPrimary">
|
||||||
|
{t("auth.telLogin.agree", { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
<TouchableOpacity onPress={() => router.push({
|
||||||
|
pathname: '/agreement',
|
||||||
|
params: { type: 'service' }
|
||||||
|
} as any)}>
|
||||||
|
<ThemedText className="text-sm !text-[#E2793F]">
|
||||||
|
{t("auth.telLogin.terms", { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<ThemedText className="text-sm !text-textPrimary">
|
||||||
|
{t("auth.telLogin.and", { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
<TouchableOpacity onPress={() => router.push({
|
||||||
|
pathname: '/agreement',
|
||||||
|
params: { type: 'privacy' }
|
||||||
|
} as any)}>
|
||||||
|
<ThemedText className="text-sm !text-[#E2793F]">
|
||||||
|
{t("auth.telLogin.privacyPolicy", { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<ThemedText className="text-sm !text-textPrimary">
|
||||||
|
{t("auth.telLogin.and", { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
<TouchableOpacity onPress={() => router.push({
|
||||||
|
pathname: '/agreement',
|
||||||
|
params: { type: 'user' }
|
||||||
|
} as any)}>
|
||||||
|
<ThemedText className="text-sm !text-[#E2793F]">
|
||||||
|
{t("auth.telLogin.userAgreement", { 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>
|
||||||
|
</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' })}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignUp
|
||||||
23
components/svg-icon.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export default function svgIcon({
|
||||||
|
name,
|
||||||
|
prefix = 'icon',
|
||||||
|
color = '#333',
|
||||||
|
width = '16px',
|
||||||
|
height = '16px',
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
prefix?: string;
|
||||||
|
color?: string;
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}) {
|
||||||
|
const symbolId = `#${prefix}-${name}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg {...props} aria-hidden="true" style={{ width, height }}>
|
||||||
|
<use href={symbolId} fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
components/user-message.tsx/choice.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { Steps } from '@/app/(tabs)/user-message';
|
||||||
|
import ChoiceSvg from '@/assets/icons/svg/choice.svg';
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { TouchableOpacity, View } from 'react-native';
|
||||||
|
interface Props {
|
||||||
|
setSteps: (steps: Steps) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChoiceOption = {
|
||||||
|
id: string;
|
||||||
|
emoji: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Choice({ setSteps }: Props) {
|
||||||
|
const [selectedOption, setSelectedOption] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (!selectedOption) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
// Simulate API call
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setSteps('done');
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: ChoiceOption[] = [
|
||||||
|
{
|
||||||
|
id: 'wakeup',
|
||||||
|
emoji: '⏰',
|
||||||
|
label: 'Wake Up',
|
||||||
|
description: 'Start your day right with a gentle wake-up'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sleep',
|
||||||
|
emoji: '😴',
|
||||||
|
label: 'Sleep',
|
||||||
|
description: 'Drift off with calming sounds'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'focus',
|
||||||
|
emoji: '🎯',
|
||||||
|
label: 'Focus',
|
||||||
|
description: 'Enhance your concentration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'relax',
|
||||||
|
emoji: '🧘',
|
||||||
|
label: 'Relax',
|
||||||
|
description: 'Unwind and de-stress'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'relax1',
|
||||||
|
emoji: '🧘',
|
||||||
|
label: 'Relax1',
|
||||||
|
description: 'Unwind and de-stress'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'relax11',
|
||||||
|
emoji: '🧘',
|
||||||
|
label: 'Relax11',
|
||||||
|
description: 'Unwind and de-stress'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="h-full bg-textPrimary overflow-y-auto">
|
||||||
|
{/* Fixed Header */}
|
||||||
|
<View className="p-[2rem] pb-0">
|
||||||
|
<View className="items-center flex flex-col gap-[2rem]">
|
||||||
|
<ChoiceSvg />
|
||||||
|
<ThemedText className="text-3xl !text-white text-center font-semibold">
|
||||||
|
Every memory matters.
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText className="text-base !text-white text-center">
|
||||||
|
Select a few to help Memo create better video capsules for you
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<View className="flex-1">
|
||||||
|
<View className="p-[2rem] pb-0">
|
||||||
|
<View className="gap-4">
|
||||||
|
{options.map((option) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.id}
|
||||||
|
className={`p-[1rem] rounded-full bg-[#FFF8DE] border-2 ${selectedOption.includes(option.id) ? 'border-bgPrimary' : 'border-transparent'} w-full flex items-center justify-center`}
|
||||||
|
onPress={() => {
|
||||||
|
// 如果存在则删除,没有则添加,多选
|
||||||
|
if (selectedOption.includes(option.id)) {
|
||||||
|
// 剔除
|
||||||
|
setSelectedOption((prev) => prev.filter((id) => id !== option.id));
|
||||||
|
} else {
|
||||||
|
// 添加
|
||||||
|
setSelectedOption((prev) => ([...prev, option.id]));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemedText className="text-lg font-semibold !text-textTertiary text-center">
|
||||||
|
{option.label}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Next Button */}
|
||||||
|
<View className="mt-8 mb-4">
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`w-full bg-white rounded-full p-4 items-center ${isLoading ? 'opacity-70' : ''}`}
|
||||||
|
onPress={handleContinue}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<ThemedText className="text-textTertiary text-lg font-semibold">
|
||||||
|
Next
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
components/user-message.tsx/done.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Steps } from '@/app/(tabs)/user-message';
|
||||||
|
import DoneSvg from '@/assets/icons/svg/done.svg';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TouchableOpacity, View } from 'react-native';
|
||||||
|
import { ThemedText } from '../ThemedText';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
setSteps: (steps: Steps) => void;
|
||||||
|
}
|
||||||
|
export default function Done(props: Props) {
|
||||||
|
const { setSteps } = props
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const handleContinue = () => {
|
||||||
|
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-bgPrimary absolute top-0 left-0 right-0 bottom-0 h-full">
|
||||||
|
<View className="absolute top-[2rem] left-0 right-0 bottom-[10rem] justify-center items-center">
|
||||||
|
<ThemedText className="text-4xl !text-white text-center">
|
||||||
|
{t('auth.userMessage.allDone', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
<View className='flex-1' />
|
||||||
|
<DoneSvg />
|
||||||
|
{/* Next Button */}
|
||||||
|
<View className="w-full mt-8 mb-4 absolute bottom-[0.5rem] p-[1rem]">
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`w-full bg-buttonFill rounded-full p-4 items-center`}
|
||||||
|
onPress={handleContinue}
|
||||||
|
>
|
||||||
|
<ThemedText className="text-textTertiary text-lg font-semibold">
|
||||||
|
{t('auth.userMessage.next', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
162
components/user-message.tsx/look.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { Steps } from '@/app/(tabs)/user-message';
|
||||||
|
import AtaverSvg from '@/assets/icons/svg/ataver.svg';
|
||||||
|
import ChoicePhotoSvg from '@/assets/icons/svg/choicePhoto.svg';
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Image, Modal, SafeAreaView, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import FileUploader, { FileStatus } from '../file-upload/file-uploader';
|
||||||
|
interface Props {
|
||||||
|
setSteps?: (steps: Steps) => void;
|
||||||
|
fileData: FileStatus[];
|
||||||
|
setFileData: (fileData: FileStatus[]) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
handleUser: () => void;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Look({ fileData, setFileData, isLoading, handleUser, avatar }: Props) {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-textPrimary justify-between p-[2rem]">
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<View className="w-full items-center">
|
||||||
|
<ThemedText className="text-4xl font-bold !text-white mb-[2rem]">
|
||||||
|
{t('auth.userMessage.look', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText className="text-base !text-white/80 text-center mb-[5rem]">
|
||||||
|
{t('auth.userMessage.avatarText', { ns: 'login' })}
|
||||||
|
<br />
|
||||||
|
{t('auth.userMessage.avatorText2', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
|
||||||
|
<View className="rounded-full bg-white/10 items-center justify-center mb-[3rem]">
|
||||||
|
|
|||||||
|
{
|
||||||
|
(() => {
|
||||||
|
const imageSource = fileData?.[0]?.thumbnailUrl || avatar;
|
||||||
|
|
||||||
|
if (!imageSource) {
|
||||||
|
return <AtaverSvg />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
style={styles.image}
|
||||||
|
source={{ uri: imageSource }}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`!bg-[#FFF8DE] rounded-2xl p-4 items-center flex flex-row gap-[1rem]`}
|
||||||
|
disabled={isLoading}
|
||||||
|
onPress={() => { setIsModalVisible(true) }}
|
||||||
|
>
|
||||||
|
<ChoicePhotoSvg />
|
||||||
|
<ThemedText className="text-lg font-semibold ">
|
||||||
|
{t('auth.userMessage.choosePhoto', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
|
||||||
|
{/* 上传弹窗 */}
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<SafeAreaView style={styles.centeredView}>
|
||||||
|
<Modal
|
||||||
|
animationType="fade"
|
||||||
|
transparent={true}
|
||||||
|
visible={isModalVisible}
|
||||||
|
onRequestClose={() => {
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<TouchableWithoutFeedback onPress={() => setIsModalVisible(false)}>
|
||||||
|
<View style={styles.modalOverlayTouchable} />
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<ThemedText style={styles.modalTitle}>{t('auth.userMessage.choosePhoto', { ns: 'login' })}</ThemedText>
|
||||||
|
<FileUploader
|
||||||
|
allowMultiple={false}
|
||||||
|
maxFiles={1}
|
||||||
|
onFilesUploaded={(file: FileStatus[]) => {
|
||||||
|
setFileData(file);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
allowedFileTypes={["image/png", "image/jpeg", "image/webp"]}
|
||||||
|
maxFileSize={1024 * 1024 * 10}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</SafeAreaView>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Continue Button */}
|
||||||
|
<View className="p-6 w-full">
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`w-full bg-white rounded-full p-4 items-center ${isLoading ? 'opacity-70' : ''
|
||||||
|
}`}
|
||||||
|
onPress={handleUser}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<ThemedText className="text-textTertiary text-lg font-semibold">
|
||||||
|
{t('auth.userMessage.next', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
image: {
|
||||||
|
width: 215,
|
||||||
|
height: 215,
|
||||||
|
borderRadius: 107.5,
|
||||||
|
},
|
||||||
|
centeredView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
},
|
||||||
|
modalOverlayTouchable: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
width: '90%',
|
||||||
|
maxWidth: 400,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 15,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
// ... 其他样式保持不变
|
||||||
|
});
|
||||||
65
components/user-message.tsx/userName.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { Steps } from '@/app/(tabs)/user-message';
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ActivityIndicator, Platform, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
|
import Toast from 'react-native-toast-message';
|
||||||
|
interface Props {
|
||||||
|
setSteps: (steps: Steps) => void;
|
||||||
|
username: string;
|
||||||
|
setUsername: (username: string) => void;
|
||||||
|
}
|
||||||
|
export default function UserName(props: Props) {
|
||||||
|
const { setSteps, username, setUsername } = props
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const handleUserName = () => {
|
||||||
|
if (!username) {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
Toast.show({
|
||||||
|
type: 'error',
|
||||||
|
text1: 'Username is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true)
|
||||||
|
setSteps("look")
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='bg-bgPrimary flex-1 h-full'>
|
||||||
|
<View className="flex-1" />
|
||||||
|
{/* Input container fixed at bottom */}
|
||||||
|
<View className="w-full bg-white p-4 border-t border-gray-200 rounded-t-3xl">
|
||||||
|
<View className="flex-col items-center justify-center w-full gap-[3rem]">
|
||||||
|
<ThemedText className="text-textSecondary font-semibold">{t('auth.userMessage.title', { ns: 'login' })}</ThemedText>
|
||||||
|
<View className='w-full'>
|
||||||
|
<ThemedText className="!text-textPrimary ml-2 mb-2 font-semibold">{t('auth.userMessage.username', { ns: 'login' })}</ThemedText>
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 bg-inputBackground rounded-2xl px-4 py-3 w-full"
|
||||||
|
placeholder={t('auth.userMessage.usernamePlaceholder', { ns: 'login' })}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`w-full bg-[#E2793F] rounded-full text-[#fff] p-4 items-center mb-6 ${isLoading ? 'opacity-70' : ''} rounded-2xl`}
|
||||||
|
onPress={handleUserName}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<ThemedText className="!text-white font-semibold">
|
||||||
|
{t('auth.userMessage.next', { ns: 'login' })}
|
||||||
|
</ThemedText>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -3,7 +3,9 @@ import { EVENT_TYPES, eventEmitter } from '@/lib/event-util';
|
|||||||
import { fetchApi, refreshAuthToken } from '@/lib/server-api-util';
|
import { fetchApi, refreshAuthToken } from '@/lib/server-api-util';
|
||||||
import { store } from '@/store';
|
import { store } from '@/store';
|
||||||
import { User } from '@/types/user';
|
import { User } from '@/types/user';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import React, { createContext, ReactNode, useContext, useEffect } from 'react';
|
import React, { createContext, ReactNode, useContext, useEffect } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
@ -25,7 +27,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 检查 Redux store 中是否已有 token
|
// 检查 Redux store 中是否已有 token
|
||||||
const refreshTokenAction = async () => {
|
const refreshTokenAction = async () => {
|
||||||
const token = store.getState().auth.token;
|
let token = store.getState().auth.token;
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
token = localStorage.getItem('token') || "";
|
||||||
|
} else {
|
||||||
|
await SecureStore.getItemAsync('token').then((token) => {
|
||||||
|
token = token || "";
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
// 验证当前 token 是否有效
|
// 验证当前 token 是否有效
|
||||||
fetchApi('/user/identity-check', {}, false)
|
fetchApi('/user/identity-check', {}, false)
|
||||||
@ -67,6 +77,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
token: newJwt
|
token: newJwt
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 判断运行环境是web则存在localstorage或者expo-secure-store中
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
localStorage.setItem('user', JSON.stringify(newUser));
|
||||||
|
localStorage.setItem('token', newJwt);
|
||||||
|
} else {
|
||||||
|
SecureStore.setItemAsync('user', JSON.stringify(newUser));
|
||||||
|
SecureStore.setItemAsync('token', newJwt);
|
||||||
|
}
|
||||||
|
|
||||||
// 触发事件通知
|
// 触发事件通知
|
||||||
eventEmitter.emit(EVENT_TYPES.USER_INFO_UPDATED, newUser);
|
eventEmitter.emit(EVENT_TYPES.USER_INFO_UPDATED, newUser);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
|
||||||
import { User } from '@/types/user';
|
import { User } from '@/types/user';
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
task_id: string | null;
|
task_id: string | null;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
|
refresh_token: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
@ -14,7 +16,8 @@ const initialState: AuthState = {
|
|||||||
token: null,
|
token: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
task_id: null,
|
task_id: null,
|
||||||
url: null
|
url: null,
|
||||||
|
refresh_token: null
|
||||||
};
|
};
|
||||||
|
|
||||||
export const authSlice = createSlice({
|
export const authSlice = createSlice({
|
||||||
@ -34,6 +37,13 @@ export const authSlice = createSlice({
|
|||||||
state.user = null;
|
state.user = null;
|
||||||
state.token = null;
|
state.token = null;
|
||||||
state.isAuthenticated = false;
|
state.isAuthenticated = false;
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
localStorage.setItem('user', "");
|
||||||
|
localStorage.setItem('token', "");
|
||||||
|
} else {
|
||||||
|
SecureStore.setItemAsync('user', "");
|
||||||
|
SecureStore.setItemAsync('token', "");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setGuestTaskData: (state, action: PayloadAction<{ task_id: string; url: string }>) => {
|
setGuestTaskData: (state, action: PayloadAction<{ task_id: string; url: string }>) => {
|
||||||
state.task_id = action.payload.task_id;
|
state.task_id = action.payload.task_id;
|
||||||
|
|||||||
@ -1,28 +1,58 @@
|
|||||||
{"auth": {
|
{
|
||||||
"telLogin": {
|
"auth": {
|
||||||
"sendCode": "send code",
|
"welcomeAwaken": {
|
||||||
"login": "Login",
|
"hi": "Hi!",
|
||||||
"codePlaceholder": "Enter verification code",
|
"memo": "I'm Memo",
|
||||||
"sending": "Sending...",
|
"awaken": "Awaken",
|
||||||
"codeSeconds": "seconds",
|
"your": "Your",
|
||||||
"agree": "I agree to the ",
|
"pm": "precious memories",
|
||||||
"terms": "Terms of Service",
|
"slogan": "let every moment speak and feel alive",
|
||||||
"and": " and ",
|
"gallery": "I live deep inside your photo gallery, ",
|
||||||
"privacyPolicy": "Privacy Policy",
|
"back": "waiting for you to bring me back...",
|
||||||
"userAgreement": "User Agreement",
|
"awake": "Awake your Memo"
|
||||||
"agreement": " and authorize ",
|
},
|
||||||
"getPhone": " to obtain my phone number",
|
"userMessage": {
|
||||||
"codeError": "Failed to send verification code, please try again",
|
"title": "Choose Username",
|
||||||
"phoneRequired": "Please enter your phone number",
|
"username": "Username",
|
||||||
"phoneInvalid": "Please enter a valid phone number",
|
"usernamePlaceholder": "Enter your username",
|
||||||
"codeRequired": "Please enter the verification code",
|
"next": "Next",
|
||||||
"codeInvalid": "Invalid verification code format",
|
"look": "Choose Your Look",
|
||||||
"checkedRequired": "Please agree to the terms",
|
"avatarText": "Choose an avatar to begin your journey",
|
||||||
"loginError": "Login failed, please try again"
|
"avatorText2": "You can always change it later",
|
||||||
},
|
"choosePhoto": "Choose Photo",
|
||||||
|
"allDone": "All Done!"
|
||||||
|
},
|
||||||
|
"telLogin": {
|
||||||
|
"title": "Verify Your Identity",
|
||||||
|
"secondTitle": "We’ve sent an email with your code to:",
|
||||||
|
"sendCode": "send code",
|
||||||
|
"continue": " Continue",
|
||||||
|
"login": "Login",
|
||||||
|
"codePlaceholder": "Enter verification code",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"codeSeconds": "seconds",
|
||||||
|
"agree": "I agree to the ",
|
||||||
|
"terms": "Terms of Service",
|
||||||
|
"and": " and ",
|
||||||
|
"privacyPolicy": "Privacy Policy",
|
||||||
|
"userAgreement": "User Agreement",
|
||||||
|
"agreement": " and authorize ",
|
||||||
|
"getPhone": " to obtain my phone number",
|
||||||
|
"codeError": "Failed to send verification code, please try again",
|
||||||
|
"phoneRequired": "Please enter your phone number",
|
||||||
|
"phoneInvalid": "Please enter a valid phone number",
|
||||||
|
"codeRequired": "Please enter the verification code",
|
||||||
|
"codeInvalid": "Invalid verification code format",
|
||||||
|
"checkedRequired": "Please agree to the terms",
|
||||||
|
"loginError": "Login failed, please try again",
|
||||||
|
"codeVaild": "The code you entered is invalid",
|
||||||
|
"sendAgain": "Did’nt receive a code?",
|
||||||
|
"resend": "Resend",
|
||||||
|
"goBack": "Go Back"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Log in",
|
"title": "Log in",
|
||||||
"email": "Email",
|
"email": "Email Address",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"emailPlaceholder": "Enter your email",
|
"emailPlaceholder": "Enter your email",
|
||||||
@ -34,13 +64,15 @@
|
|||||||
"accountPlaceholder": "Enter your account or email",
|
"accountPlaceholder": "Enter your account or email",
|
||||||
"signUpMessage": "Don’t have an account?",
|
"signUpMessage": "Don’t have an account?",
|
||||||
"signUp": "Sign up",
|
"signUp": "Sign up",
|
||||||
"phoneLogin":"Phone Login"
|
"phoneLogin": "Phone Login",
|
||||||
|
"passwordNotMatch": "Passwords do not match"
|
||||||
},
|
},
|
||||||
"agree": {
|
"agree": {
|
||||||
"text": "By signing up, you agree to our",
|
"logintext": "By logging in, you agree to our",
|
||||||
|
"singupText": "By signing up, you agree to our",
|
||||||
"terms": " Terms",
|
"terms": " Terms",
|
||||||
"join": "&",
|
"join": "&",
|
||||||
"privacyPolicy": " Privacy Policy."
|
"privacyPolicy": " Privacy Policy."
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"welcome": "Welcome to MemoWake",
|
"welcome": "Welcome to MemoWake",
|
||||||
@ -52,8 +84,8 @@
|
|||||||
"emailPlaceholder": "Enter your email",
|
"emailPlaceholder": "Enter your email",
|
||||||
"sendEmailBtn": "Send email",
|
"sendEmailBtn": "Send email",
|
||||||
"goback": "Go back",
|
"goback": "Go back",
|
||||||
"success":"Email sent successfully, please check your email",
|
"success": "Email sent successfully, please check your email",
|
||||||
"sendEmailBtnDisabled": "Email sent"
|
"sendEmailBtnDisabled": "Email sent"
|
||||||
},
|
},
|
||||||
"resetPwd": {
|
"resetPwd": {
|
||||||
"title": "Reset password",
|
"title": "Reset password",
|
||||||
@ -83,7 +115,10 @@
|
|||||||
"verifyCodePlaceholder": "Enter 6-digit code",
|
"verifyCodePlaceholder": "Enter 6-digit code",
|
||||||
"sendCode": "Send Code",
|
"sendCode": "Send Code",
|
||||||
"resendCode": "Resend",
|
"resendCode": "Resend",
|
||||||
"codeExpireTime": "Code will expire in"
|
"codeExpireTime": "Code will expire in",
|
||||||
|
"checkedRequired": "Please agree to the terms",
|
||||||
|
"emailAuth": "Please enter a valid email address",
|
||||||
|
"passwordAuth": "Please enter a valid password"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,26 +1,55 @@
|
|||||||
{
|
{
|
||||||
"auth": {
|
"auth": {
|
||||||
"telLogin":{
|
"welcomeAwaken": {
|
||||||
"sendCode":"获取验证码",
|
"hi": "Hi!",
|
||||||
"login":"登录",
|
"memo": "I'm Memo",
|
||||||
"codePlaceholder":"请输入验证码",
|
"awaken": "Awaken",
|
||||||
"sending":"发送中...",
|
"your": "Your",
|
||||||
"codeSeconds":"秒",
|
"pm": "precious memories",
|
||||||
"agree":"我已同意",
|
"slogan": "let every moment speak and feel alive",
|
||||||
"terms":"《服务条款》",
|
"gallery": "I live deep inside your photo gallery, ",
|
||||||
"and":"和",
|
"back": "waiting for you to bring me back...",
|
||||||
"privacyPolicy":"《隐私政策》",
|
"awake": "Awake your Memo"
|
||||||
"userAgreement":"《用户协议》",
|
},
|
||||||
"agreement":"并授权",
|
"userMessage": {
|
||||||
"getPhone":"获得本机号码",
|
"title": "设置用户名",
|
||||||
"codeError":"发送验证码失败,请重试",
|
"username": "用户名",
|
||||||
"phoneRequired":"请输入手机号",
|
"usernamePlaceholder": "请输入您的用户名",
|
||||||
"phoneInvalid":"请输入正确的手机号",
|
"next": "下一步",
|
||||||
"codeRequired":"请输入验证码",
|
"look": "选择您的头像",
|
||||||
"codeInvalid":"验证码格式不正确",
|
"avatarText": "选择一个头像开始您的旅程",
|
||||||
"checkedRequired":"请勾选协议",
|
"avatorText2": "您可以随时更改",
|
||||||
"loginError":"登录失败,请重试"
|
"choosePhoto": "选择照片",
|
||||||
},
|
"allDone": "完成!"
|
||||||
|
},
|
||||||
|
"telLogin": {
|
||||||
|
"title": "验证身份",
|
||||||
|
"secondTitle": "我们已发送验证码至:",
|
||||||
|
"sendCode": "发送验证码",
|
||||||
|
"continue": "继续",
|
||||||
|
"login": "登录",
|
||||||
|
"codePlaceholder": "输入验证码",
|
||||||
|
"sending": "发送中...",
|
||||||
|
"codeSeconds": "秒",
|
||||||
|
"agree": "我已同意",
|
||||||
|
"terms": "《服务条款》",
|
||||||
|
"and": "和",
|
||||||
|
"privacyPolicy": "《隐私政策》",
|
||||||
|
"userAgreement": "《用户协议》",
|
||||||
|
"agreement": "并授权",
|
||||||
|
"getPhone": "获取本机号码",
|
||||||
|
"codeError": "验证码发送失败,请重试",
|
||||||
|
"phoneRequired": "请输入手机号",
|
||||||
|
"phoneInvalid": "请输入有效的手机号",
|
||||||
|
"codeRequired": "请输入验证码",
|
||||||
|
"codeInvalid": "验证码格式不正确",
|
||||||
|
"checkedRequired": "请同意相关条款",
|
||||||
|
"loginError": "登录失败,请重试",
|
||||||
|
"codeValid": "您输入的验证码无效",
|
||||||
|
"sendAgain": "没有收到验证码?",
|
||||||
|
"resend": "重新发送",
|
||||||
|
"goBack": "返回"
|
||||||
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "登录",
|
"title": "登录",
|
||||||
"account": "账号",
|
"account": "账号",
|
||||||
@ -35,10 +64,12 @@
|
|||||||
"accountPlaceholder": "请输入您的账号或邮箱",
|
"accountPlaceholder": "请输入您的账号或邮箱",
|
||||||
"signUpMessage": "还没有账号?",
|
"signUpMessage": "还没有账号?",
|
||||||
"signUp": "注册",
|
"signUp": "注册",
|
||||||
"phoneLogin":"手机号登录"
|
"phoneLogin": "手机号登录",
|
||||||
|
"passwordNotMatch": "密码不一致"
|
||||||
},
|
},
|
||||||
"agree": {
|
"agree": {
|
||||||
"text": "注册即表示您同意我们的",
|
"logintext": "登录即表示您同意我们的",
|
||||||
|
"singupText": "注册即表示您同意我们的",
|
||||||
"terms": "服务条款",
|
"terms": "服务条款",
|
||||||
"join": "&",
|
"join": "&",
|
||||||
"privacyPolicy": "隐私政策"
|
"privacyPolicy": "隐私政策"
|
||||||
@ -62,7 +93,7 @@
|
|||||||
"signupButton": "注册",
|
"signupButton": "注册",
|
||||||
"resetButton": "重置",
|
"resetButton": "重置",
|
||||||
"goback": "返回登录",
|
"goback": "返回登录",
|
||||||
"success":"邮件已发送,请注意查收"
|
"success": "邮件已发送,请注意查收"
|
||||||
},
|
},
|
||||||
"signup": {
|
"signup": {
|
||||||
"title": "注册",
|
"title": "注册",
|
||||||
@ -85,7 +116,10 @@
|
|||||||
"verifyCodePlaceholder": "请输入6位验证码",
|
"verifyCodePlaceholder": "请输入6位验证码",
|
||||||
"sendCode": "发送验证码",
|
"sendCode": "发送验证码",
|
||||||
"resendCode": "重新发送",
|
"resendCode": "重新发送",
|
||||||
"codeExpireTime": "验证码将在以下时间后过期"
|
"codeExpireTime": "验证码将在以下时间后过期",
|
||||||
|
"checkedRequired": "请勾选协议",
|
||||||
|
"emailAuth": "请输入一个有效的邮箱地址",
|
||||||
|
"passwordAuth": "请输入一个有效的密码"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,7 +1,11 @@
|
|||||||
import { useAuth } from '@/contexts/auth-context';
|
|
||||||
import { setCredentials } from '@/features/auth/authSlice';
|
import { setCredentials } from '@/features/auth/authSlice';
|
||||||
import { store } from '@/store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import { User } from '@/types/user';
|
import { Platform } from 'react-native';
|
||||||
|
import Toast from 'react-native-toast-message';
|
||||||
|
import { useAuth } from '../contexts/auth-context';
|
||||||
|
import { store } from '../store';
|
||||||
|
import { User } from '../types/user';
|
||||||
|
|
||||||
// 定义错误码常量
|
// 定义错误码常量
|
||||||
const ERROR_CODES = {
|
const ERROR_CODES = {
|
||||||
UNAUTHORIZED: 1004010001,
|
UNAUTHORIZED: 1004010001,
|
||||||
@ -30,7 +34,12 @@ export const useAuthToken = async<T>(message: string | null) => {
|
|||||||
|
|
||||||
// 如果接口报错,页面弹出来错误信息
|
// 如果接口报错,页面弹出来错误信息
|
||||||
if (apiResponse.code != 0) {
|
if (apiResponse.code != 0) {
|
||||||
console.log(message || 'Unknown error');
|
if (Platform.OS === 'web') {
|
||||||
|
Toast.show({
|
||||||
|
type: 'error',
|
||||||
|
text1: apiResponse.message || 'Request failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
throw new Error(message || 'Unknown error');
|
throw new Error(message || 'Unknown error');
|
||||||
} else {
|
} else {
|
||||||
const userData = apiResponse.data as User;
|
const userData = apiResponse.data as User;
|
||||||
@ -47,15 +56,32 @@ export const useAuthToken = async<T>(message: string | null) => {
|
|||||||
// 使用Redux存储token的刷新token函数
|
// 使用Redux存储token的刷新token函数
|
||||||
export const refreshAuthToken = async<T>(message: string | null): Promise<User> => {
|
export const refreshAuthToken = async<T>(message: string | null): Promise<User> => {
|
||||||
try {
|
try {
|
||||||
|
let cookie = "";
|
||||||
|
let userId = "";
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
cookie = localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') || "")?.refresh_token || "" : "";
|
||||||
|
userId = localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') || "")?.user_id || "" : "";
|
||||||
|
} else {
|
||||||
|
await SecureStore.getItemAsync('user').then((user: User) => {
|
||||||
|
cookie = user?.refresh_token || "";
|
||||||
|
userId = user?.user_id || "";
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 退出刷新会重新填充数据
|
// 退出刷新会重新填充数据
|
||||||
let response;
|
let response;
|
||||||
response = await fetch(`${API_ENDPOINT}/v1/iam/access-token-refresh`);
|
response = await fetch(`${API_ENDPOINT}/v1/iam/access-token-refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
"refresh_token": cookie,
|
||||||
|
"user_id": userId
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
const apiResponse: ApiResponse<T> = await response.json();
|
const apiResponse: ApiResponse<T> = await response.json();
|
||||||
if (apiResponse.code != 0) {
|
if (apiResponse.code != 0) {
|
||||||
// addToast({
|
|
||||||
// title: message || 'Unknown error',
|
|
||||||
// color: "danger",
|
|
||||||
// })
|
|
||||||
throw new Error(message || 'Unknown error');
|
throw new Error(message || 'Unknown error');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,11 +115,19 @@ const handleApiError = (error: unknown, needToast = true, defaultMessage = 'Unkn
|
|||||||
export const fetchApi = async <T>(
|
export const fetchApi = async <T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
needToast = true
|
needToast = true,
|
||||||
|
needToken = true,
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
const makeRequest = async (isRetry = false): Promise<ApiResponse<T>> => {
|
const makeRequest = async (isRetry = false): Promise<ApiResponse<T>> => {
|
||||||
try {
|
try {
|
||||||
const token = store.getState().auth.token;
|
let token = "";
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
token = localStorage.getItem('token') || "";
|
||||||
|
} else {
|
||||||
|
await SecureStore.getItemAsync('token').then((token: string) => {
|
||||||
|
token = token || "";
|
||||||
|
})
|
||||||
|
}
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
|
|
||||||
// 添加必要的 headers
|
// 添加必要的 headers
|
||||||
@ -101,7 +135,7 @@ export const fetchApi = async <T>(
|
|||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token != null) {
|
if (token != null && needToken) {
|
||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,6 +155,13 @@ export const fetchApi = async <T>(
|
|||||||
|
|
||||||
// 处理其他错误
|
// 处理其他错误
|
||||||
if (apiResponse.code !== 0) {
|
if (apiResponse.code !== 0) {
|
||||||
|
// 如果是web端则显示提示
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
Toast.show({
|
||||||
|
type: 'error',
|
||||||
|
text1: apiResponse.message || 'Request failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
throw new Error(apiResponse.message || 'Request failed');
|
throw new Error(apiResponse.message || 'Request failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,23 @@
|
|||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
const { withNativeWind } = require('nativewind/metro');
|
const { withNativeWind } = require('nativewind/metro');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
const config = getDefaultConfig(__dirname)
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
module.exports = withNativeWind(config, { input: './global.css' })
|
// SVG 转换配置
|
||||||
|
config.transformer = {
|
||||||
|
...config.transformer,
|
||||||
|
babelTransformerPath: require.resolve('react-native-svg-transformer'),
|
||||||
|
};
|
||||||
|
|
||||||
|
config.resolver = {
|
||||||
|
...config.resolver,
|
||||||
|
assetExts: config.resolver.assetExts.filter(ext => ext !== 'svg'),
|
||||||
|
sourceExts: [...config.resolver.sourceExts, 'svg'],
|
||||||
|
alias: {
|
||||||
|
...config.resolver?.alias,
|
||||||
|
'@/': path.resolve(__dirname, './'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = withNativeWind(config, { input: './global.css' });
|
||||||
1158
package-lock.json
generated
18
package.json
@ -18,6 +18,7 @@
|
|||||||
"@react-navigation/elements": "^2.3.8",
|
"@react-navigation/elements": "^2.3.8",
|
||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
|
"@types/react-redux": "^7.1.34",
|
||||||
"expo": "~53.0.12",
|
"expo": "~53.0.12",
|
||||||
"expo-blur": "~14.1.5",
|
"expo-blur": "~14.1.5",
|
||||||
"expo-constants": "~17.1.6",
|
"expo-constants": "~17.1.6",
|
||||||
@ -28,6 +29,7 @@
|
|||||||
"expo-linking": "~7.1.5",
|
"expo-linking": "~7.1.5",
|
||||||
"expo-localization": "^16.1.5",
|
"expo-localization": "^16.1.5",
|
||||||
"expo-router": "~5.1.0",
|
"expo-router": "~5.1.0",
|
||||||
|
"expo-secure-store": "~14.2.3",
|
||||||
"expo-splash-screen": "~0.30.9",
|
"expo-splash-screen": "~0.30.9",
|
||||||
"expo-status-bar": "~2.2.3",
|
"expo-status-bar": "~2.2.3",
|
||||||
"expo-symbols": "~0.4.5",
|
"expo-symbols": "~0.4.5",
|
||||||
@ -43,11 +45,13 @@
|
|||||||
"react-native": "0.79.4",
|
"react-native": "0.79.4",
|
||||||
"react-native-gesture-handler": "~2.24.0",
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
"react-native-reanimated": "~3.17.4",
|
"react-native-reanimated": "~3.17.4",
|
||||||
"react-native-safe-area-context": "^5.4.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
"react-native-screens": "~4.11.1",
|
"react-native-screens": "~4.11.1",
|
||||||
|
"react-native-toast-message": "^2.3.0",
|
||||||
"react-native-web": "~0.20.0",
|
"react-native-web": "~0.20.0",
|
||||||
"react-native-webview": "13.13.5",
|
"react-native-webview": "13.13.5",
|
||||||
"expo-secure-store": "~14.2.3"
|
"react-redux": "^9.2.0",
|
||||||
|
"react-native-svg": "15.11.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
@ -56,10 +60,18 @@
|
|||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~9.2.0",
|
"eslint-config-expo": "~9.2.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||||
|
"react-native-svg-transformer": "^1.5.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "~5.8.3"
|
"typescript": "~5.8.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
|
"expo": {
|
||||||
|
"doctor": {
|
||||||
|
"reactNativeDirectoryCheck": {
|
||||||
|
"listUnknownPackages": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
provider.tsx
@ -1,21 +1,93 @@
|
|||||||
|
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import Toast, { BaseToast, ErrorToast, ToastConfig } from 'react-native-toast-message';
|
||||||
import { Provider as ReduxProvider } from "react-redux";
|
import { Provider as ReduxProvider } from "react-redux";
|
||||||
import { AuthProvider } from "./contexts/auth-context";
|
import { AuthProvider } from "./contexts/auth-context";
|
||||||
import i18n from "./i18n";
|
import i18n from "./i18n";
|
||||||
import { LanguageProvider } from "./i18n/LanguageContext";
|
import { LanguageProvider } from "./i18n/LanguageContext";
|
||||||
import { store } from "./store";
|
import { store } from "./store";
|
||||||
|
|
||||||
|
// 自定义 Toast 配置
|
||||||
|
const toastConfig: ToastConfig = {
|
||||||
|
/*
|
||||||
|
覆盖默认 success 类型
|
||||||
|
- 使用自定义组件 BaseToast
|
||||||
|
- 可以添加任何 props 到组件
|
||||||
|
*/
|
||||||
|
success: (props) => (
|
||||||
|
<BaseToast
|
||||||
|
{...props}
|
||||||
|
style={{ borderLeftColor: '#4CAF50' }}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 15 }}
|
||||||
|
text1Style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600'
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#666'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
/*
|
||||||
|
覆盖默认 error 类型
|
||||||
|
*/
|
||||||
|
error: (props) => (
|
||||||
|
<ErrorToast
|
||||||
|
{...props}
|
||||||
|
style={{ borderLeftColor: '#F44336' }}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 15 }}
|
||||||
|
text1Style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600'
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#666'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
/*
|
||||||
|
自定义 info 类型
|
||||||
|
*/
|
||||||
|
info: (props) => (
|
||||||
|
<BaseToast
|
||||||
|
{...props}
|
||||||
|
style={{ borderLeftColor: '#2196F3' }}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 15 }}
|
||||||
|
text1Style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600'
|
||||||
|
}}
|
||||||
|
text2Style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#666'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export function Provider({ children }: { children: React.ReactNode }) {
|
export function Provider({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<Toast
|
||||||
|
config={toastConfig}
|
||||||
|
position={Platform.OS === 'web' ? 'top' : 'bottom'}
|
||||||
|
topOffset={Platform.OS === 'web' ? 20 : undefined}
|
||||||
|
bottomOffset={Platform.OS === 'web' ? undefined : 40}
|
||||||
|
visibilityTime={3000}
|
||||||
|
autoHide
|
||||||
|
/>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ReduxProvider>
|
</ReduxProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/svg.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
declare module '*.svg' {
|
||||||
|
import React from 'react';
|
||||||
|
import { SvgProps } from 'react-native-svg';
|
||||||
|
const content: React.FC<SvgProps>;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
44
src/utils/toast.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import Toast from 'react-native-toast-message';
|
||||||
|
|
||||||
|
type ToastType = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
export const showToast = (
|
||||||
|
type: ToastType,
|
||||||
|
text1: string,
|
||||||
|
text2?: string,
|
||||||
|
options: {
|
||||||
|
position?: 'top' | 'bottom';
|
||||||
|
visibilityTime?: number;
|
||||||
|
autoHide?: boolean;
|
||||||
|
topOffset?: number;
|
||||||
|
bottomOffset?: number;
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
Toast.show({
|
||||||
|
type,
|
||||||
|
text1,
|
||||||
|
text2,
|
||||||
|
position: options.position || 'bottom',
|
||||||
|
visibilityTime: options.visibilityTime || 3000,
|
||||||
|
autoHide: options.autoHide !== false,
|
||||||
|
topOffset: options.topOffset,
|
||||||
|
bottomOffset: options.bottomOffset,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 快捷方法
|
||||||
|
export const showSuccess = (message: string, description?: string) => {
|
||||||
|
showToast('success', message, description);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showError = (message: string, description?: string) => {
|
||||||
|
showToast('error', message, description);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showInfo = (message: string, description?: string) => {
|
||||||
|
showToast('info', message, description);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hideToast = () => {
|
||||||
|
Toast.hide();
|
||||||
|
};
|
||||||
@ -12,6 +12,12 @@ module.exports = {
|
|||||||
beige: '#F5F5DC',
|
beige: '#F5F5DC',
|
||||||
saddlebrown: '#8B4513',
|
saddlebrown: '#8B4513',
|
||||||
darkred: '#8B0000',
|
darkred: '#8B0000',
|
||||||
|
bgPrimary: '#FFB645',
|
||||||
|
textPrimary: '#AC7E35',
|
||||||
|
textSecondary: '#4C320C',
|
||||||
|
inputBackground: '#FFF8DE',
|
||||||
|
textTertiary: '#4C320C',
|
||||||
|
buttonFill: '#E2793F'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "expo/tsconfig.base",
|
"extends": "expo/tsconfig.base",
|
||||||
|
"typeRoots": ["./node_modules/@types", "./src/types"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./*"
|
"./*"
|
||||||
|
|||||||
@ -8,4 +8,6 @@ export interface User {
|
|||||||
nickname?: string
|
nickname?: string
|
||||||
email: string
|
email: string
|
||||||
user_id?: string
|
user_id?: string
|
||||||
|
refresh_token?: string
|
||||||
|
avatar_file_url?: string
|
||||||
}
|
}
|
||||||
一起看看