feat: 登录
This commit is contained in:
parent
9939fd23aa
commit
341640cb42
4
.gitignore
vendored
4
.gitignore
vendored
@ -37,3 +37,7 @@ yarn-error.*
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# Expo prebuild generated files
|
||||
android/
|
||||
ios/
|
||||
|
||||
24
app.json
24
app.json
@ -4,7 +4,7 @@
|
||||
"slug": "memowake",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"icon": "",
|
||||
"scheme": "memowake",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
@ -13,7 +13,7 @@
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"foregroundImage": "",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
@ -22,24 +22,26 @@
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
"favicon": ""
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
"expo-font",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"fonts": [
|
||||
"./assets/fonts/[font-file.ttf]"
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-secure-store",
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"configureAndroidBackup": true,
|
||||
"faceIDPermission": "Allow $(PRODUCT_NAME) to access your Face ID biometric data."
|
||||
"image": "",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
@ -3,7 +3,6 @@ import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { HapticTab } from '@/components/HapticTab';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import TabBarBackground from '@/components/ui/TabBarBackground';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
@ -26,11 +25,58 @@ export default function TabLayout() {
|
||||
default: {},
|
||||
}),
|
||||
}}>
|
||||
{/* 落地页 */}
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
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>
|
||||
|
||||
@ -1,82 +1,112 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
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 { TouchableOpacity, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { login } = useAuth();
|
||||
const token = store.getState().auth.token;
|
||||
console.log(token);
|
||||
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));
|
||||
}
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<ThemedView className="flex-1 bg-beige">
|
||||
<View className="flex-1 items-center pt-[60px] px-5">
|
||||
<ThemedText className="text-3xl font-bold mb-8 text-saddlebrown" onPress={handleApi}>
|
||||
{t('title', { ns: "example" })}
|
||||
</ThemedText>
|
||||
|
||||
<View className="mb-8 items-center">
|
||||
<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">
|
||||
<Ionicons name="person" size={60} color="#8B4513" />
|
||||
<ThemedText className="mt-1 text-lg font-semibold text-saddlebrown">
|
||||
MeMo
|
||||
</ThemedText>
|
||||
<View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem]">
|
||||
{/* 标题区域 */}
|
||||
<View className="items-start mb-10 w-full px-5">
|
||||
<Text className="text-white text-3xl font-bold mb-3 text-left">
|
||||
Awaken{"\n"}your{"\n"}precious memories
|
||||
</Text>
|
||||
<Text className="text-white/85 text-base text-left">
|
||||
let every moment speak and feel alive
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Memo 形象区域 */}
|
||||
<View className="items-center w-full relative">
|
||||
{/* 气泡对话框 */}
|
||||
<View className="absolute -top-[7rem] right-[1rem] z-10">
|
||||
<View className="relative top-[12rem] -right-[3rem] z-10 w-3 h-3 bg-white rounded-full" />
|
||||
<View className="relative top-[9.5rem] -right-[3.5rem] z-10 w-4 h-4 bg-white rounded-full" />
|
||||
<Text className="text-[#AC7E35] font-bold text-lg text-center leading-6 relative top-[4rem] left-0">
|
||||
Hi!{"\n"}I'm Memo
|
||||
</Text>
|
||||
<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>
|
||||
</View>
|
||||
{/* Memo 形象 */}
|
||||
<View className="justify-center items-center">
|
||||
<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>
|
||||
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ThemedText className="text-base text-center mb-10 text-saddlebrown px-5 leading-6">
|
||||
Ready to wake up your memories? Just ask! Let MeMo bring them back to life!
|
||||
</ThemedText>
|
||||
{/* 介绍文本 */}
|
||||
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
|
||||
I live deep inside your photo gallery, {"\n"}waiting for you to bring me back...
|
||||
</Text>
|
||||
|
||||
{/* 唤醒按钮 */}
|
||||
<TouchableOpacity
|
||||
className="w-20 h-20 rounded-full bg-white/90 justify-center items-center mb-8 shadow-lg"
|
||||
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}
|
||||
>
|
||||
<Ionicons name="mic" size={32} color="#8B0000" />
|
||||
<Text className="text-[#4C320C] font-bold text-lg">
|
||||
Awake your Memo
|
||||
</Text>
|
||||
</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>
|
||||
</ThemedView >
|
||||
);
|
||||
}
|
||||
55
app/(tabs)/loading.tsx
Normal file
55
app/(tabs)/loading.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
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">
|
||||
<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>
|
||||
<View className="text-[#AC7E35]">Loading...</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
135
app/(tabs)/login.tsx
Normal file
135
app/(tabs)/login.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
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
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
{
|
||||
status === 'signUp'
|
||||
?
|
||||
<SignUp
|
||||
updateUrlParam={updateUrlParam}
|
||||
setError={setError}
|
||||
setShowPassword={setShowPassword}
|
||||
showPassword={showPassword}
|
||||
/>
|
||||
:
|
||||
status === 'forgetPwd' ?
|
||||
<ForgetPwd
|
||||
updateUrlParam={updateUrlParam}
|
||||
setError={setError}
|
||||
/>
|
||||
: <PhoneLogin
|
||||
// setShowPassword={setShowPassword}
|
||||
// showPassword={showPassword}
|
||||
updateUrlParam={updateUrlParam}
|
||||
setError={setError}
|
||||
/>
|
||||
}
|
||||
{status == 'login' &&
|
||||
<View className="flex-row justify-center mt-2">
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{status === 'login' ? 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
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
|
||||
67
app/(tabs)/user-message.tsx
Normal file
67
app/(tabs)/user-message.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
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}>
|
||||
{
|
||||
steps == "userName"
|
||||
?
|
||||
<UserName setSteps={setSteps} username={username} setUsername={setUsername} />
|
||||
:
|
||||
steps == "look"
|
||||
?
|
||||
<Look setSteps={setSteps} fileData={fileData} setFileData={setFileData} isLoading={isLoading} handleUser={handleUser} avatar={avatar} />
|
||||
:
|
||||
steps == "choice"
|
||||
?
|
||||
<Choice setSteps={setSteps} />
|
||||
:
|
||||
<Done setSteps={setSteps} />
|
||||
}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<View>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<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>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,13 @@ export default function RootLayout() {
|
||||
<Provider>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
headerShown: false,
|
||||
animation: 'fade'
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
</Provider>
|
||||
|
||||
42
assets/icons/svg/loginIp1.svg
Normal file
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
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 |
@ -5,5 +5,8 @@ module.exports = function (api) {
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
plugins: [
|
||||
'expo-router/babel',
|
||||
],
|
||||
};
|
||||
};
|
||||
117
components/file-upload/file-item-phone.tsx
Normal file
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
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
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
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
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
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();
|
||||
});
|
||||
};
|
||||
197
components/login/code.tsx
Normal file
197
components/login/code.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
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'}`}>
|
||||
<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>
|
||||
<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
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
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
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
|
||||
24
components/login/phoneLogin.tsx
Normal file
24
components/login/phoneLogin.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import Code from "./code";
|
||||
import Phone from "./phone";
|
||||
|
||||
interface LoginProps {
|
||||
updateUrlParam: (status: string, value: string) => void;
|
||||
setError: (error: string) => void
|
||||
}
|
||||
export type Steps = "phone" | "code";
|
||||
|
||||
const PhoneLogin = ({ updateUrlParam, setError }: LoginProps) => {
|
||||
const [steps, setSteps] = useState<Steps>("phone");
|
||||
const [phone, setPhone] = useState('');
|
||||
|
||||
return <View>
|
||||
{
|
||||
steps === "phone" ? <Phone setError={setError} setSteps={setSteps} setPhone={setPhone} phone={phone} /> : <Code setError={setError} updateUrlParam={updateUrlParam} setSteps={setSteps} phone={phone} />
|
||||
}
|
||||
</View>
|
||||
}
|
||||
|
||||
|
||||
export default PhoneLogin
|
||||
325
components/login/signUp.tsx
Normal file
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
|
||||
117
components/login/vetify.tsx
Normal file
117
components/login/vetify.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Animated, Keyboard, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
const VerificationCodeInput = () => {
|
||||
const [code, setCode] = useState(['', '', '', '', '', '']);
|
||||
const refs = useRef([]);
|
||||
const shakeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const focusNext = (index, value) => {
|
||||
if (value && index < 5) {
|
||||
refs.current[index + 1].focus();
|
||||
}
|
||||
};
|
||||
|
||||
const focusPrevious = (index, key) => {
|
||||
if (key === 'Backspace' && index > 0 && !code[index]) {
|
||||
refs.current[index - 1].focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeChange = (text, index) => {
|
||||
const newCode = [...code];
|
||||
newCode[index] = text;
|
||||
setCode(newCode);
|
||||
focusNext(index, text);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const fullCode = code.join('');
|
||||
if (fullCode.length === 5) {
|
||||
Keyboard.dismiss();
|
||||
// 这里处理验证逻辑
|
||||
console.log('验证码:', fullCode);
|
||||
} else {
|
||||
// 抖动动画效果
|
||||
Animated.sequence([
|
||||
Animated.timing(shakeAnim, {
|
||||
toValue: 10,
|
||||
duration: 50,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.timing(shakeAnim, {
|
||||
toValue: -10,
|
||||
duration: 50,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.timing(shakeAnim, {
|
||||
toValue: 10,
|
||||
duration: 50,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.timing(shakeAnim, {
|
||||
toValue: 0,
|
||||
duration: 50,
|
||||
useNativeDriver: true
|
||||
})
|
||||
]).start();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setCode(['', '', '', '', '']);
|
||||
refs.current[0].focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="w-full mt-8">
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ translateX: shakeAnim }]
|
||||
}}
|
||||
className="flex flex-row justify-center space-x-3 mb-6 gap-[1rem]"
|
||||
>
|
||||
{code.map((digit, index) => (
|
||||
<TextInput
|
||||
key={index}
|
||||
ref={ref => refs.current[index] = ref}
|
||||
className="w-16 h-16 bg-[#FFF8DE] rounded-xl text-textTertiary text-3xl text-center"
|
||||
keyboardType="number-pad"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChangeText={text => handleCodeChange(text, index)}
|
||||
onKeyPress={({ nativeEvent: { key } }) => focusPrevious(index, key)}
|
||||
selectTextOnFocus
|
||||
caretHidden={true}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
<View className="flex-row justify-center space-x-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleClear}
|
||||
className="px-6 py-3 border border-purple-400 rounded-full"
|
||||
>
|
||||
<Text className="text-purple-300 font-medium">清除</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleSubmit}
|
||||
className="bg-amber-400 px-8 py-3 rounded-full shadow"
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text className="text-purple-900 font-bold">验证</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="mt-6 flex-row justify-center space-x-2">
|
||||
<Text className="text-purple-300">未收到验证码?</Text>
|
||||
<TouchableOpacity>
|
||||
<Text className="text-amber-400 font-medium">重新发送</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationCodeInput;
|
||||
23
components/svg-icon.tsx
Normal file
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>
|
||||
)
|
||||
}
|
||||
170
components/user-message.tsx/choice.tsx
Normal file
170
components/user-message.tsx/choice.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { Steps } from '@/app/(tabs)/user-message';
|
||||
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]">
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
78
components/user-message.tsx/done.tsx
Normal file
78
components/user-message.tsx/done.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { Steps } from '@/app/(tabs)/user-message';
|
||||
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' />
|
||||
<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>
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
172
components/user-message.tsx/look.tsx
Normal file
172
components/user-message.tsx/look.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { Steps } from '@/app/(tabs)/user-message';
|
||||
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]">
|
||||
{
|
||||
fileData?.[0]?.thumbnailUrl
|
||||
?
|
||||
<Image
|
||||
style={styles.image}
|
||||
source={{
|
||||
uri: fileData?.[0].thumbnailUrl,
|
||||
}}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
:
|
||||
avatar
|
||||
?
|
||||
<Image
|
||||
style={styles.image}
|
||||
source={{
|
||||
uri: avatar,
|
||||
}}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
:
|
||||
<svg width="215" height="215" viewBox="0 0 215 215" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="107.5" cy="107.5" r="107.5" fill="#FFF8DE" />
|
||||
</svg>
|
||||
|
||||
}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className={`!bg-[#FFF8DE] rounded-2xl p-4 items-center flex flex-row gap-[1rem]`}
|
||||
disabled={isLoading}
|
||||
onPress={() => { setIsModalVisible(true) }}
|
||||
>
|
||||
<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>
|
||||
<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
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 { store } from '@/store';
|
||||
import { User } from '@/types/user';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import React, { createContext, ReactNode, useContext, useEffect } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
interface AuthContextType {
|
||||
@ -25,7 +27,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
useEffect(() => {
|
||||
// 检查 Redux store 中是否已有 token
|
||||
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) {
|
||||
// 验证当前 token 是否有效
|
||||
fetchApi('/user/identity-check', {}, false)
|
||||
@ -67,6 +77,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
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);
|
||||
};
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
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 {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
task_id: string | null;
|
||||
url: string | null;
|
||||
refresh_token: string | null;
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
@ -14,7 +16,8 @@ const initialState: AuthState = {
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
task_id: null,
|
||||
url: null
|
||||
url: null,
|
||||
refresh_token: null
|
||||
};
|
||||
|
||||
export const authSlice = createSlice({
|
||||
@ -34,6 +37,13 @@ export const authSlice = createSlice({
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
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 }>) => {
|
||||
state.task_id = action.payload.task_id;
|
||||
|
||||
@ -1,6 +1,21 @@
|
||||
{"auth": {
|
||||
{
|
||||
"auth": {
|
||||
"userMessage": {
|
||||
"title": "Choose Username",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "Enter your username",
|
||||
"next": "Next",
|
||||
"look": "Choose Your Look",
|
||||
"avatarText": "Choose an avatar to begin your journey",
|
||||
"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...",
|
||||
@ -18,11 +33,15 @@
|
||||
"codeRequired": "Please enter the verification code",
|
||||
"codeInvalid": "Invalid verification code format",
|
||||
"checkedRequired": "Please agree to the terms",
|
||||
"loginError": "Login failed, please try again"
|
||||
},
|
||||
"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": {
|
||||
"title": "Log in",
|
||||
"email": "Email",
|
||||
"email": "Email Address",
|
||||
"account": "Account",
|
||||
"password": "Password",
|
||||
"emailPlaceholder": "Enter your email",
|
||||
@ -34,13 +53,15 @@
|
||||
"accountPlaceholder": "Enter your account or email",
|
||||
"signUpMessage": "Don’t have an account?",
|
||||
"signUp": "Sign up",
|
||||
"phoneLogin":"Phone Login"
|
||||
"phoneLogin": "Phone Login",
|
||||
"passwordNotMatch": "Passwords do not match"
|
||||
},
|
||||
"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",
|
||||
"join": "&",
|
||||
"privacyPolicy": " Privacy Policy."
|
||||
"privacyPolicy": " Privacy Policy."
|
||||
},
|
||||
"welcome": {
|
||||
"welcome": "Welcome to MemoWake",
|
||||
@ -52,7 +73,7 @@
|
||||
"emailPlaceholder": "Enter your email",
|
||||
"sendEmailBtn": "Send email",
|
||||
"goback": "Go back",
|
||||
"success":"Email sent successfully, please check your email",
|
||||
"success": "Email sent successfully, please check your email",
|
||||
"sendEmailBtnDisabled": "Email sent"
|
||||
},
|
||||
"resetPwd": {
|
||||
@ -83,7 +104,10 @@
|
||||
"verifyCodePlaceholder": "Enter 6-digit code",
|
||||
"sendCode": "Send Code",
|
||||
"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,25 +1,43 @@
|
||||
{
|
||||
"auth": {
|
||||
"telLogin":{
|
||||
"sendCode":"获取验证码",
|
||||
"login":"登录",
|
||||
"codePlaceholder":"请输入验证码",
|
||||
"sending":"发送中...",
|
||||
"codeSeconds":"秒",
|
||||
"agree":"我已同意",
|
||||
"terms":"《服务条款》",
|
||||
"and":"和",
|
||||
"privacyPolicy":"《隐私政策》",
|
||||
"userAgreement":"《用户协议》",
|
||||
"agreement":"并授权",
|
||||
"getPhone":"获得本机号码",
|
||||
"codeError":"发送验证码失败,请重试",
|
||||
"phoneRequired":"请输入手机号",
|
||||
"phoneInvalid":"请输入正确的手机号",
|
||||
"codeRequired":"请输入验证码",
|
||||
"codeInvalid":"验证码格式不正确",
|
||||
"checkedRequired":"请勾选协议",
|
||||
"loginError":"登录失败,请重试"
|
||||
"userMessage": {
|
||||
"title": "设置用户名",
|
||||
"username": "用户名",
|
||||
"usernamePlaceholder": "请输入您的用户名",
|
||||
"next": "下一步",
|
||||
"look": "选择您的头像",
|
||||
"avatarText": "选择一个头像开始您的旅程",
|
||||
"avatorText2": "您可以随时更改",
|
||||
"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": {
|
||||
"title": "登录",
|
||||
@ -35,10 +53,12 @@
|
||||
"accountPlaceholder": "请输入您的账号或邮箱",
|
||||
"signUpMessage": "还没有账号?",
|
||||
"signUp": "注册",
|
||||
"phoneLogin":"手机号登录"
|
||||
"phoneLogin": "手机号登录",
|
||||
"passwordNotMatch": "密码不一致"
|
||||
},
|
||||
"agree": {
|
||||
"text": "注册即表示您同意我们的",
|
||||
"logintext": "登录即表示您同意我们的",
|
||||
"singupText": "注册即表示您同意我们的",
|
||||
"terms": "服务条款",
|
||||
"join": "&",
|
||||
"privacyPolicy": "隐私政策"
|
||||
@ -62,7 +82,7 @@
|
||||
"signupButton": "注册",
|
||||
"resetButton": "重置",
|
||||
"goback": "返回登录",
|
||||
"success":"邮件已发送,请注意查收"
|
||||
"success": "邮件已发送,请注意查收"
|
||||
},
|
||||
"signup": {
|
||||
"title": "注册",
|
||||
@ -85,7 +105,10 @@
|
||||
"verifyCodePlaceholder": "请输入6位验证码",
|
||||
"sendCode": "发送验证码",
|
||||
"resendCode": "重新发送",
|
||||
"codeExpireTime": "验证码将在以下时间后过期"
|
||||
"codeExpireTime": "验证码将在以下时间后过期",
|
||||
"checkedRequired": "请勾选协议",
|
||||
"emailAuth": "请输入一个有效的邮箱地址",
|
||||
"passwordAuth": "请输入一个有效的密码"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { setCredentials } from '@/features/auth/authSlice';
|
||||
import { store } from '@/store';
|
||||
import { User } from '@/types/user';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
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 = {
|
||||
UNAUTHORIZED: 1004010001,
|
||||
@ -30,7 +34,12 @@ export const useAuthToken = async<T>(message: string | null) => {
|
||||
|
||||
// 如果接口报错,页面弹出来错误信息
|
||||
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');
|
||||
} else {
|
||||
const userData = apiResponse.data as User;
|
||||
@ -47,15 +56,32 @@ export const useAuthToken = async<T>(message: string | null) => {
|
||||
// 使用Redux存储token的刷新token函数
|
||||
export const refreshAuthToken = async<T>(message: string | null): Promise<User> => {
|
||||
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;
|
||||
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();
|
||||
if (apiResponse.code != 0) {
|
||||
// addToast({
|
||||
// title: message || 'Unknown error',
|
||||
// color: "danger",
|
||||
// })
|
||||
throw new Error(message || 'Unknown error');
|
||||
}
|
||||
|
||||
@ -89,11 +115,19 @@ const handleApiError = (error: unknown, needToast = true, defaultMessage = 'Unkn
|
||||
export const fetchApi = async <T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
needToast = true
|
||||
needToast = true,
|
||||
needToken = true,
|
||||
): Promise<T> => {
|
||||
const makeRequest = async (isRetry = false): Promise<ApiResponse<T>> => {
|
||||
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);
|
||||
|
||||
// 添加必要的 headers
|
||||
@ -101,7 +135,7 @@ export const fetchApi = async <T>(
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
if (token != null) {
|
||||
if (token != null && needToken) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
@ -121,6 +155,13 @@ export const fetchApi = async <T>(
|
||||
|
||||
// 处理其他错误
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,23 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
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
1158
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@ -18,6 +18,7 @@
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"expo": "~53.0.12",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-constants": "~17.1.6",
|
||||
@ -28,6 +29,7 @@
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-localization": "^16.1.5",
|
||||
"expo-router": "~5.1.0",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-splash-screen": "~0.30.9",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
@ -43,11 +45,13 @@
|
||||
"react-native": "0.79.4",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"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-toast-message": "^2.3.0",
|
||||
"react-native-web": "~0.20.0",
|
||||
"react-native-webview": "13.13.5",
|
||||
"expo-secure-store": "~14.2.3"
|
||||
"react-redux": "^9.2.0",
|
||||
"react-native-svg": "15.11.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
@ -56,10 +60,18 @@
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~9.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"react-native-svg-transformer": "^1.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
"private": true,
|
||||
"expo": {
|
||||
"doctor": {
|
||||
"reactNativeDirectoryCheck": {
|
||||
"listUnknownPackages": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
72
provider.tsx
72
provider.tsx
@ -1,21 +1,93 @@
|
||||
|
||||
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 { AuthProvider } from "./contexts/auth-context";
|
||||
import i18n from "./i18n";
|
||||
import { LanguageProvider } from "./i18n/LanguageContext";
|
||||
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 }) {
|
||||
return (
|
||||
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<LanguageProvider>
|
||||
<ReduxProvider store={store}>
|
||||
<AuthProvider>
|
||||
{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>
|
||||
</ReduxProvider>
|
||||
</LanguageProvider>
|
||||
</I18nextProvider>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
6
src/svg.d.ts
vendored
Normal file
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
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',
|
||||
saddlebrown: '#8B4513',
|
||||
darkred: '#8B0000',
|
||||
bgPrimary: '#FFB645',
|
||||
textPrimary: '#AC7E35',
|
||||
textSecondary: '#4C320C',
|
||||
inputBackground: '#FFF8DE',
|
||||
textTertiary: '#4C320C',
|
||||
buttonFill: '#E2793F'
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"typeRoots": ["./node_modules/@types", "./src/types"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
|
||||
@ -8,4 +8,6 @@ export interface User {
|
||||
nickname?: string
|
||||
email: string
|
||||
user_id?: string
|
||||
refresh_token?: string
|
||||
avatar_file_url?: string
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user