feat: 键盘

This commit is contained in:
jinyaqiu 2025-06-26 18:46:12 +08:00
parent 75d9a6a4fe
commit ae14e05533
58 changed files with 9201 additions and 941 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
.vscode
dist

37
.gitea/workflows/dev.yaml Normal file
View File

@ -0,0 +1,37 @@
name: Dev Deploy
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on:
push:
branches:
- main
- v0.4.0_front
jobs:
Explore-Gitea-Actions:
runs-on: self_hosted
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
- name: Check out repository code
uses: https://git.fairclip.cn/mirrors/actions-checkout@v4
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ gitea.workspace }}
- name: Build Docker Image
run: |
echo "Building the Docker image..."
branch=${{ gitea.ref_name }}
tag_name=$(git rev-parse HEAD)
docker build -t docker.fairclip.cn/memowake/memowake-front:${branch}-${tag_name} .
echo "Docker image built successfully!"
- name: Deploy
run: |
echo "Deploying the project..."
branch=${{ gitea.ref_name }}
tag_name=$(git rev-parse HEAD)
bash ./scripts/dev_deploy.sh ${branch} docker.fairclip.cn/memowake/memowake-front:${branch}-${tag_name}
echo "Deploy successful!"

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
# 第一阶段:构建 Expo Web 静态文件
FROM docker.fairclip.cn/node:22 AS builder
WORKDIR /app
COPY package.json .
# 设置npm源
RUN npm config set registry http://192.168.31.115:8081/repository/npm/
RUN npm install -g expo-cli && npm install
COPY . .
RUN npx expo export -p web
# 第二阶段:使用 nginx 作为 Web 服务器
FROM docker.fairclip.cn/nginx:1.29-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -9,15 +9,31 @@
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"infoPlist": {
"NSPhotoLibraryUsageDescription": "Allow $(PRODUCT_NAME) to access your photos.",
"NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location to get photo location data.",
"ITSAppUsesNonExemptEncryption": false
},
"bundleIdentifier": "com.memowake.app"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "", "foregroundImage": "",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
}, },
"permissions": [
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE",
"ACCESS_MEDIA_LOCATION",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.ACCESS_MEDIA_LOCATION"
],
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"package": "com.jinyaqiu.memowake" "package": "com.memowake.app"
}, },
"web": { "web": {
"bundler": "metro", "bundler": "metro",
@ -28,20 +44,17 @@
"expo-router", "expo-router",
"expo-secure-store", "expo-secure-store",
[ [
"expo-font", "expo-audio",
{ {
"fonts": [ "microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone."
"./assets/fonts/[font-file.ttf]"
]
} }
], ],
[ [
"expo-splash-screen", "expo-media-library",
{ {
"image": "", "photosPermission": "Allow $(PRODUCT_NAME) to access your photos.",
"imageWidth": 200, "savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
"resizeMode": "contain", "isAccessMediaLocationEnabled": true
"backgroundColor": "#ffffff"
} }
] ]
], ],
@ -51,7 +64,7 @@
"extra": { "extra": {
"router": {}, "router": {},
"eas": { "eas": {
"projectId": "e4634b8b-fdb8-4e6f-ac7e-7032e6898612" "projectId": "04721dd4-6b15-495a-b9ec-98187c613172"
} }
} }
} }

View File

@ -24,7 +24,8 @@ export default function TabLayout() {
}, },
default: {}, default: {},
}), }),
}}> }}
>
{/* 落地页 */} {/* 落地页 */}
<Tabs.Screen <Tabs.Screen
name="index" name="index"
@ -79,6 +80,26 @@ export default function TabLayout() {
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示 tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}} }}
/> />
{/* ask页面 */}
<Tabs.Screen
name="ask"
options={{
title: 'ask',
tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* memo list */}
<Tabs.Screen
name="memo-list"
options={{
title: 'memo-list',
tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
</Tabs> </Tabs>
); );
} }

211
app/(tabs)/ask.tsx Normal file
View File

@ -0,0 +1,211 @@
import ReturnArrow from "@/assets/icons/svg/returnArrow.svg";
import Chat from "@/components/ask/chat";
import AskHello from "@/components/ask/hello";
import AudioRecordPlay from "@/components/ask/voice";
import { ThemedText } from "@/components/ThemedText";
import { fetchApi } from "@/lib/server-api-util";
import { Message } from "@/types/ask";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useRef, useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AskScreen() {
const insets = useSafeAreaInsets();
// 在组件内部添加 ref
const scrollViewRef = useRef<ScrollView>(null);
// 用于控制是否显示问候页面
const [isHello, setIsHello] = useState(true);
// 获取对话id
const [conversationId, setConversationId] = useState<string | null>(null);
// 用户对话信息收集
const [userMessages, setUserMessages] = useState<Message[]>([]);
const createNewConversation = useCallback(async () => {
setUserMessages([{
content: {
text: "请输入您的问题,寻找,请稍等..."
},
role: 'Assistant',
timestamp: new Date().toISOString()
}]);
const data = await fetchApi<string>("/chat/new", {
method: "POST",
});
setConversationId(data);
}, []);
// 获取路由参数
const { sessionId, newSession } = useLocalSearchParams<{
sessionId: string;
newSession: string;
}>();
// 添加自动滚动到底部的效果
useEffect(() => {
if (scrollViewRef.current && !isHello) {
scrollViewRef.current.scrollToEnd({ animated: true });
}
}, [userMessages, isHello]);
useEffect(() => {
if (sessionId) {
setConversationId(sessionId)
setIsHello(false)
fetchApi<Message[]>(`/chats/${sessionId}/message-history`).then((res) => {
setUserMessages(res)
})
}
if (newSession) {
setIsHello(false)
createNewConversation()
}
}, [sessionId, newSession])
return (
<View style={{ flex: 1, backgroundColor: 'white', paddingTop: insets.top }}>
{/* 导航栏 - 保持在顶部 */}
<View style={isHello ? "" : styles.navbar} className="relative w-full flex flex-row items-center justify-between pb-3 pt-[2rem]">
{/* 点击去memo list 页面 */}
<TouchableOpacity
style={styles.backButton}
onPress={() => {
router.replace('/memo-list');
}}
>
<ReturnArrow />
</TouchableOpacity>
<ThemedText className={`!text-textSecondary font-semibold text-3xl w-full text-center flex-1 ${isHello ? "opacity-0" : ""}`}>MemoWake</ThemedText>
<View />
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}
onContentSizeChange={() => {
if (scrollViewRef.current && !isHello) {
scrollViewRef.current.scrollToEnd({ animated: true });
}
}}
>
{/* 内容区域 */}
<View className="flex-1">
{isHello ? <AskHello /> : <Chat userMessages={userMessages} sessionId={sessionId} />}
</View>
</ScrollView>
{/* 功能区 - 放在 KeyboardAvoidingView 内但在 ScrollView 外 */}
<View className="w-full px-[1.5rem] mb-[2rem]">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingRight: 16,
alignItems: 'center',
flexDirection: 'row',
gap: 8
}}
className="pb-2"
>
<Chip icon="sunny" text="Tdy's vlog" />
<Chip icon="happy" text="Smiles" />
<Chip icon="image" text="Snapshots" />
<Chip icon="time" text="Moments" />
</ScrollView>
<AudioRecordPlay getConversation={getConversation} setIsHello={setIsHello} setInputValue={setInputValue} conversationId={conversationId} inputValue={inputValue} createNewConversation={createNewConversation} />
</View>
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
navbar: {
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
backgroundColor: 'white',
zIndex: 10,
},
container: {
flex: 1,
backgroundColor: 'white',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingTop: 60
},
backButton: {
marginLeft: 16,
padding: 12
},
content: {
flex: 1,
padding: 20,
alignItems: 'center',
justifyContent: 'center',
},
description: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 40,
paddingHorizontal: 20,
lineHeight: 24,
},
chipsContainer: {
width: "100%",
flexDirection: 'row',
flexWrap: 'nowrap',
justifyContent: 'center',
marginBottom: 40,
display: "flex",
alignItems: "center",
overflow: "scroll",
},
chip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF5E6',
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 20,
margin: 5,
},
chipText: {
marginLeft: 6,
color: '#FF9500',
fontSize: 14,
},
inputContainer: {
flexDirection: 'row',
padding: 16,
paddingBottom: 30,
backgroundColor: 'white',
},
input: {
flex: 1,
borderColor: '#FF9500',
borderWidth: 1,
borderRadius: 25,
paddingHorizontal: 20,
paddingVertical: 12,
fontSize: 16,
width: '100%', // 确保输入框宽度撑满
},
voiceButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#FF9500',
justifyContent: 'center',
alignItems: 'center',
marginRight: 8, // 添加一点右边距
},
});

View File

@ -1,20 +1,23 @@
import MemoChat from '@/assets/icons/svg/memo-chat.svg'; import IP from '@/assets/icons/svg/ip.svg';
import MemoIP from '@/assets/icons/svg/memo-ip.svg'; import Lottie from '@/components/lottie/lottie';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Text, TouchableOpacity, View } from 'react-native'; import { Platform, Text, TouchableOpacity, View } from 'react-native';
export default function HomeScreen() { export default function HomeScreen() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
let token;
return ( return (
<View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem]"> <View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem] pt-[10rem]">
{/* 标题区域 */} {/* 标题区域 */}
<View className="items-start mb-10 w-full px-5"> <View className="items-start mb-10 w-full px-5">
<Text className="text-white text-3xl font-bold mb-3 text-left"> <Text className="text-white text-3xl font-bold mb-3 text-left">
{t('auth.welcomeAwaken.awaken', { ns: 'login' })} {t('auth.welcomeAwaken.awaken', { ns: 'login' })}
<br /> {"\n"}
{t('auth.welcomeAwaken.your', { ns: 'login' })} {t('auth.welcomeAwaken.your', { ns: 'login' })}
<br /> {"\n"}
{t('auth.welcomeAwaken.pm', { ns: 'login' })} {t('auth.welcomeAwaken.pm', { ns: 'login' })}
</Text> </Text>
<Text className="text-white/85 text-base text-left"> <Text className="text-white/85 text-base text-left">
@ -23,35 +26,34 @@ export default function HomeScreen() {
</View> </View>
{/* Memo 形象区域 */} {/* Memo 形象区域 */}
<View className="items-center w-full relative"> {/* 如果是web端使用静态ip形象否则使用lottie */}
{/* 气泡对话框 */} {Platform.OS === 'web' ? <IP /> : <Lottie source={'welcome'} style={{ width: 200, height: 200 }} />}
<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">
{t('auth.welcomeAwaken.hi', { ns: 'login' })}
<br />
{t('auth.welcomeAwaken.memo', { ns: 'login' })}
</Text>
<MemoChat />
</View>
{/* Memo 形象 */}
<View className="justify-center items-center">
<MemoIP />
</View>
</View>
{/* 介绍文本 */} {/* 介绍文本 */}
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]"> <Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
{t('auth.welcomeAwaken.gallery', { ns: 'login' })} {t('auth.welcomeAwaken.gallery', { ns: 'login' })}
<br /> {"\n"}
{t('auth.welcomeAwaken.back', { ns: 'login' })} {t('auth.welcomeAwaken.back', { ns: 'login' })}
</Text> </Text>
{/* 唤醒按钮 */} {/* 唤醒按钮 */}
<TouchableOpacity <TouchableOpacity
className="bg-white rounded-full px-10 py-4 shadow-[0_2px_4px_rgba(0,0,0,0.1)] w-full items-center" 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')} onPress={async () => {
// 判断是否有用户信息有的话直接到usermessage页面 没有到登录页
if (Platform.OS === 'web') {
token = localStorage.getItem('token') || "";
} else {
token = await SecureStore.getItemAsync('token') || "";
}
if (token) {
router.push('/user-message')
} else {
router.push('/login')
}
}}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text className="text-[#4C320C] font-bold text-lg"> <Text className="text-[#4C320C] font-bold text-lg">

View File

@ -2,14 +2,14 @@ import Handers from '@/assets/icons/svg/handers.svg';
import LoginIP1 from '@/assets/icons/svg/loginIp1.svg'; import LoginIP1 from '@/assets/icons/svg/loginIp1.svg';
import LoginIP2 from '@/assets/icons/svg/loginIp2.svg'; import LoginIP2 from '@/assets/icons/svg/loginIp2.svg';
import ForgetPwd from '@/components/login/forgetPwd'; import ForgetPwd from '@/components/login/forgetPwd';
import PhoneLogin from '@/components/login/phoneLogin'; import Login from '@/components/login/login';
import SignUp from '@/components/login/signUp'; import SignUp from '@/components/login/signUp';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView'; import { ThemedView } from '@/components/ThemedView';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LayoutChangeEvent, TouchableOpacity, View, ViewStyle, useWindowDimensions } from 'react-native'; import { Keyboard, KeyboardAvoidingView, LayoutChangeEvent, Platform, ScrollView, StatusBar, TouchableOpacity, View, ViewStyle, useWindowDimensions } from 'react-native';
const LoginScreen = () => { const LoginScreen = () => {
const router = useRouter(); const router = useRouter();
@ -18,118 +18,158 @@ const LoginScreen = () => {
const [error, setError] = useState<string>('123'); const [error, setError] = useState<string>('123');
const [containerHeight, setContainerHeight] = useState(0); const [containerHeight, setContainerHeight] = useState(0);
const { height: windowHeight } = useWindowDimensions(); const { height: windowHeight } = useWindowDimensions();
// 密码可视
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [keyboardOffset, setKeyboardOffset] = useState(0);
// 判断是否有白边
const statusBarHeight = StatusBar?.currentHeight ?? 0;
useEffect(() => {
const keyboardWillShowListener = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
(e) => {
setKeyboardOffset(e.endCoordinates.height);
}
);
const keyboardWillHideListener = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
() => {
setKeyboardOffset(0);
}
);
return () => {
keyboardWillShowListener.remove();
keyboardWillHideListener.remove();
};
}, []);
const handleLayout = (event: LayoutChangeEvent) => { const handleLayout = (event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout; const { height } = event.nativeEvent.layout;
setContainerHeight(height); setContainerHeight(height);
}; };
// 更新URL参数而不刷新页面
const updateUrlParam = (key: string, value: string) => { const updateUrlParam = (key: string, value: string) => {
router.setParams({ [key]: value }); router.setParams({ [key]: value });
} }
// 初始化
useEffect(() => { useEffect(() => {
setError('123') setError('123')
}, []) }, [])
return ( return (
<ThemedView className="flex-1 bg-bgPrimary justify-end"> <KeyboardAvoidingView
<View className="flex-1"> style={{ flex: 1 }}
<View behavior={Platform.OS === "ios" ? "padding" : "height"}
className="absolute left-1/2 z-10" keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -statusBarHeight}
style={{ >
top: containerHeight > 0 ? windowHeight - containerHeight - 210 : 0, <ScrollView
transform: [{ translateX: -200 }] contentContainerStyle={{
}} flexGrow: 1,
> }}
{ keyboardShouldPersistTaps="handled"
showPassword bounces={false}
?
<LoginIP2 />
:
<LoginIP1 />
}
</View>
<View
className="absolute left-1/2 z-[1000] -translate-x-[39.5px] -translate-y-[4px]"
style={{
top: containerHeight > 0 ? windowHeight - containerHeight - 1 : 0
}}
>
<Handers />
</View>
</View>
<ThemedView
className="w-full bg-white pt-12 px-6 relative z-20 shadow-lg pb-5"
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 5
} as ViewStyle}
onLayout={handleLayout}
> >
{/* 错误提示 */} <ThemedView className="flex-1 bg-bgPrimary justify-end">
<View className={`${error !== "123" ? 'opacity-100' : 'opacity-0'} w-full flex justify-center items-center text-primary-500 text-sm`}> <View className="flex-1">
<ThemedText className="text-sm !text-textPrimary"> <View
{error} className="absolute left-1/2 z-10"
</ThemedText> style={{
</View> top: containerHeight > 0 ? windowHeight - containerHeight - 210 + statusBarHeight : 0,
{(() => { transform: [{ translateX: -200 }, { translateY: keyboardOffset > 0 ? -keyboardOffset + statusBarHeight : -keyboardOffset }]
const commonProps = { }}
updateUrlParam, >
setError, {
}; showPassword
?
const components = { <LoginIP2 />
signUp: ( :
<SignUp <LoginIP1 />
{...commonProps} }
setShowPassword={setShowPassword} </View>
showPassword={showPassword} <View
/> className="absolute left-1/2 z-[1000] -translate-x-[39.5px] -translate-y-[4px]"
), style={{
forgetPwd: ( top: containerHeight > 0 ? windowHeight - containerHeight - 1 + statusBarHeight : 0,
<ForgetPwd transform: [{ translateX: -39.5 }, { translateY: keyboardOffset > 0 ? -4 - keyboardOffset + statusBarHeight : -4 - keyboardOffset }]
{...commonProps} }}
/> >
), <Handers />
login: ( </View>
<PhoneLogin />
)
};
return components[status as keyof typeof components] || components.login;
})()}
{status == 'login' || !status &&
<View className="flex-row justify-center mt-2">
<ThemedText className="text-sm !text-textPrimary">
{status === 'login' || !status ? t('auth.agree.logintext', { ns: 'login' }) : t('auth.agree.singupText', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => { }}>
<ThemedText className="text-sm font-semibold ml-1 !text-textPrimary underline">
{t('auth.agree.terms', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t('auth.agree.join', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => { }}>
<ThemedText className="!text-textPrimary underline text-sm font-semibold ml-1">
{t('auth.agree.privacyPolicy', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View> </View>
} <ThemedView
</ThemedView> className="w-full bg-white pt-12 px-6 relative z-20 shadow-lg pb-5"
</ThemedView> style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 5
} as ViewStyle}
onLayout={handleLayout}
>
{/* 错误提示 */}
<View className={`${error !== "123" ? 'opacity-100' : 'opacity-0'} w-full flex justify-center items-center text-primary-500 text-sm`}>
<ThemedText className="text-sm !text-textPrimary">
{error}
</ThemedText>
</View>
{(() => {
const commonProps = {
updateUrlParam,
setError,
};
const components = {
signUp: (
<SignUp
{...commonProps}
setShowPassword={setShowPassword}
showPassword={showPassword}
/>
),
forgetPwd: (
<ForgetPwd
{...commonProps}
/>
),
login: (
<Login
{...commonProps}
setShowPassword={setShowPassword}
showPassword={showPassword}
/>
)
};
return components[status as keyof typeof components] || components.login;
})()}
{status == 'login' || !status &&
<View className="flex-row justify-center mt-2">
<ThemedText className="text-sm !text-textPrimary">
{status === 'login' || !status ? t('auth.agree.logintext', { ns: 'login' }) : t('auth.agree.singupText', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => { }}>
<ThemedText className="text-sm font-semibold ml-1 !text-textPrimary underline">
{t('auth.agree.terms', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t('auth.agree.join', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => { }}>
<ThemedText className="!text-textPrimary underline text-sm font-semibold ml-1">
{t('auth.agree.privacyPolicy', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
}
</ThemedView>
</ThemedView>
</ScrollView>
</KeyboardAvoidingView>
); );
} }

300
app/(tabs)/memo-list.tsx Normal file
View File

@ -0,0 +1,300 @@
import ChatSvg from "@/assets/icons/svg/chat.svg";
import IPSvg from "@/assets/icons/svg/ip.svg";
import { fetchApi } from "@/lib/server-api-util";
import { Chat } from "@/types/ask";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import React, { useEffect } from 'react';
import { FlatList, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const MemoList = () => {
const insets = useSafeAreaInsets();
// 历史消息
const [historyList, setHistoryList] = React.useState<Chat[]>([]);
// 获取历史消息
const getHistoryList = async () => {
await fetchApi<Chat[]>(`/chats`).then((res) => {
setHistoryList(res)
})
}
// 获取对话历史消息
const getChatHistory = async (id: string) => {
// 跳转到聊天页面,并携带参数
router.push({
pathname: '/ask',
params: {
sessionId: id,
}
});
}
const handleMemoPress = (item: Chat) => {
getChatHistory(item.session_id)
}
useEffect(() => {
getHistoryList()
}, [])
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* 顶部标题和上传按钮 */}
<View style={styles.header}>
<Text style={styles.title}>Memo List</Text>
</View>
{/* 历史对话 */}
<FlatList
data={historyList}
keyExtractor={(item) => item.session_id}
ItemSeparatorComponent={() => (
<View style={styles.separator} />
)}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.memoItem}
onPress={() => handleMemoPress(item)}
>
<ChatSvg />
<View style={styles.memoContent}>
<Text
style={styles.memoTitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.title || 'memo list 历史消息'}
</Text>
<Text
style={styles.memoTitle}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.latest_message?.content?.text || 'memo list 历史消息'}
</Text>
</View>
</TouchableOpacity>
)}
/>
{/* 底部导航栏 */}
<View className="flex-row justify-between items-center px-8 shadow-lg shadow-black/20 pb-10">
<TouchableOpacity >
<Ionicons name="chatbubbles-outline" size={24} color="#4C320C" />
</TouchableOpacity>
<TouchableOpacity
className="bg-white rounded-full shadow-lg shadow-black/20 items-center justify-center -mt-8"
onPress={() => {
router.push({
pathname: '/ask',
params: {
newSession: "true",
}
});
}}
>
<IPSvg width={96} height={96} />
</TouchableOpacity>
<TouchableOpacity>
<View className="">
<Ionicons name="person-outline" size={24} color="#4C320C" />
<View className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full" />
</View>
</TouchableOpacity>
</View>
</View>
);
};
const styles = StyleSheet.create({
separator: {
height: 1,
backgroundColor: '#f0f0f0',
marginLeft: 60, // 与头像对齐
},
container: {
flex: 1,
backgroundColor: 'white',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#4C320C',
},
uploadButton: {
padding: 8,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF',
borderRadius: 20,
marginHorizontal: 16,
paddingHorizontal: 16,
height: 48,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
searchIcon: {
marginRight: 8,
},
memoItem: {
flexDirection: 'row',
borderRadius: 0, // 移除圆角
padding: 16,
marginBottom: 0, // 移除底部边距
alignItems: 'center',
gap: 16,
backgroundColor: 'white',
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
marginRight: 16,
},
memoContent: {
flex: 1,
marginLeft: 12,
gap: 6,
justifyContent: 'center',
minWidth: 0, // 这行很重要,确保文本容器可以收缩到比内容更小
},
memoTitle: {
fontSize: 16,
fontWeight: '500',
color: '#333',
flex: 1, // 或者 flexShrink: 1
marginLeft: 12,
},
memoSubtitle: {
fontSize: 14,
color: '#666',
},
tabBar: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#EEE',
paddingVertical: 12,
},
tabBarSvg: {
color: 'red',
},
tabItem: {
flex: 1,
alignItems: 'center',
},
tabCenter: {
width: 60,
height: 60,
alignItems: 'center',
justifyContent: 'center',
},
centerTabIcon: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: '#FF9500',
justifyContent: 'center',
alignItems: 'center',
marginTop: -30,
},
centerTabImage: {
width: 40,
height: 40,
borderRadius: 20,
},
// 在 tabBarContainer 样式中添加
tabBarContainer: {
position: 'relative',
paddingBottom: 0,
overflow: 'visible',
marginTop: 10, // 添加一些上边距
},
tabBarContent: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
height: 60,
position: 'relative',
backgroundColor: 'rgba(255, 255, 255, 0.7)', // 半透明白色背景
borderRadius: 30, // 圆角
marginHorizontal: 16, // 左右边距
// 添加边框效果
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.8)',
// 添加阴影
...Platform.select({
ios: {
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.8,
shadowRadius: 4,
},
android: {
elevation: 8,
},
}),
},
// 移除之前的 tabBarBackground 样式
// 修改 centerTabShadow 样式
centerTabShadow: {
position: 'absolute',
bottom: 15,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: 'white',
...Platform.select({
ios: {
shadowColor: 'rgba(0, 0, 0, 0.2)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 6,
},
android: {
elevation: 10,
},
}),
},
centerTabContainer: {
flex: 1,
alignItems: 'center',
position: 'relative',
height: '100%',
},
centerTabButton: {
width: '100%',
height: '100%',
borderRadius: 30,
backgroundColor: '#FF9500',
justifyContent: 'center',
alignItems: 'center',
},
notificationDot: {
position: 'absolute',
top: -2,
right: -4,
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#FF3B30',
},
});
export default MemoList;

View File

@ -1,22 +1,23 @@
import { FileStatus } from '@/components/file-upload/file-uploader';
import Choice from '@/components/user-message.tsx/choice'; import Choice from '@/components/user-message.tsx/choice';
import Done from '@/components/user-message.tsx/done'; import Done from '@/components/user-message.tsx/done';
import Look from '@/components/user-message.tsx/look'; import Look from '@/components/user-message.tsx/look';
import UserName from '@/components/user-message.tsx/userName'; import UserName from '@/components/user-message.tsx/userName';
import { fetchApi } from '@/lib/server-api-util'; import { fetchApi } from '@/lib/server-api-util';
import { FileUploadItem } from '@/types/upload';
import { User } from '@/types/user'; import { User } from '@/types/user';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { View } from 'react-native'; import { KeyboardAvoidingView, Platform, ScrollView, StatusBar, View } from 'react-native';
export type Steps = "userName" | "look" | "choice" | "done"; export type Steps = "userName" | "look" | "choice" | "done";
export default function UserMessage() { export default function UserMessage() {
// 步骤 // 步骤
const [steps, setSteps] = useState<Steps>("userName") const [steps, setSteps] = useState<Steps>("userName")
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [avatar, setAvatar] = useState('') const [avatar, setAvatar] = useState('')
const [fileData, setFileData] = useState<FileStatus[]>([]) const [fileData, setFileData] = useState<FileUploadItem[]>([])
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [userInfo, setUserInfo] = useState<User | null>(null); const [userInfo, setUserInfo] = useState<User | null>(null);
const statusBarHeight = StatusBar?.currentHeight ?? 0;
// 获取用户信息 // 获取用户信息
const getUserInfo = async () => { const getUserInfo = async () => {
const res = await fetchApi<User>("/iam/user-info"); const res = await fetchApi<User>("/iam/user-info");
@ -31,11 +32,10 @@ export default function UserMessage() {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
username, username,
avatar_file_id: fileData?.[0]?.id avatar_file_id: fileData?.[0]?.originalFile?.file_id
}) })
}).then(() => { }).then(() => {
setIsLoading(false); setIsLoading(false);
getUserInfo();
setSteps('done'); setSteps('done');
}).catch(() => { }).catch(() => {
setIsLoading(false); setIsLoading(false);
@ -46,34 +46,46 @@ export default function UserMessage() {
}, []); }, []);
return ( return (
<View className="h-screen" key={steps}> <KeyboardAvoidingView
<View className="h-screen" key={steps}> style={{ flex: 1 }}
{(() => { behavior={Platform.OS === "ios" ? "padding" : "height"}
const components = { keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -statusBarHeight}
userName: ( >
<UserName <ScrollView
setSteps={setSteps} contentContainerStyle={{
username={username} flexGrow: 1,
setUsername={setUsername} }}
/> keyboardShouldPersistTaps="handled"
), bounces={false}
look: ( >
<Look <View className="h-full" key={steps}>
setSteps={setSteps} {(() => {
fileData={fileData} const components = {
setFileData={setFileData} userName: (
isLoading={isLoading} <UserName
handleUser={handleUser} setSteps={setSteps}
avatar={avatar} username={username}
/> setUsername={setUsername}
), />
choice: <Choice setSteps={setSteps} />, ),
done: <Done setSteps={setSteps} /> look: (
}; <Look
setSteps={setSteps}
fileData={fileData}
setFileData={setFileData}
isLoading={isLoading}
handleUser={handleUser}
avatar={avatar}
/>
),
choice: <Choice setSteps={setSteps} />,
done: <Done />
};
return components[steps as keyof typeof components] || null; return components[steps as keyof typeof components] || null;
})()} })()}
</View> </View>
</View> </ScrollView>
</KeyboardAvoidingView>
); );
} }

View File

@ -1,4 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="215" height="215" viewBox="0 0 215 215" 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" /> <circle cx="107.5" cy="107.5" r="107.5" fill="#FFF8DE"/>
<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" /> <rect x="62" y="60" width="13" height="38" rx="6.5" fill="#B48C64"/>
<rect x="141" y="60" width="13" height="38" rx="6.5" fill="#B48C64"/>
<path d="M103.65 99.9515C99.6771 110.557 96.9654 130.216 117.903 124.011" stroke="#B48C64" stroke-width="9" stroke-linecap="round"/>
<path d="M89.147 155.142C89.147 171.5 120.992 184.277 128.087 154.706" stroke="#B48C64" stroke-width="9" stroke-linecap="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 659 B

After

Width:  |  Height:  |  Size: 565 B

41
assets/icons/svg/chat.svg Normal file
View File

@ -0,0 +1,41 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_106_1356" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="30" height="30">
<circle cx="15" cy="15" r="15" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_106_1356)">
<path d="M-40.9027 16.1923C-15.8348 -27.2265 46.8348 -27.2265 71.9027 16.1923L89.0011 45.8077C114.069 89.2265 82.7342 143.5 32.5984 143.5H-1.59845C-51.7342 143.5 -83.069 89.2265 -58.0011 45.8077L-40.9027 16.1923Z" fill="#FFD18A"/>
<rect x="11.6543" y="13.3718" width="2.5641" height="3.58974" rx="1.28205" transform="rotate(-180 11.6543 13.3718)" fill="#4C320C"/>
<rect x="20.8848" y="13.3718" width="2.5641" height="3.58974" rx="1.28205" transform="rotate(-180 20.8848 13.3718)" fill="#4C320C"/>
<path d="M-21.2587 39.6301C-3.52975 13.8566 34.5294 13.8565 52.2583 39.63L72.4502 68.9839C92.8123 98.5854 71.6203 138.885 35.6917 138.885H-4.69213C-40.6207 138.885 -61.8127 98.5854 -41.4506 68.984L-21.2587 39.6301Z" fill="#FFF8DE"/>
<g filter="url(#filter0_i_106_1356)">
<ellipse cx="51.1411" cy="35.1667" rx="42.0513" ry="30" fill="#FFF8DE"/>
</g>
<g filter="url(#filter1_i_106_1356)">
<ellipse cx="-20.3975" cy="35.1667" rx="41.7949" ry="30" fill="#FFF8DE"/>
</g>
<ellipse cx="15.2434" cy="20.2949" rx="3.07692" ry="2.30769" transform="rotate(180 15.2434 20.2949)" fill="#FFB8B9"/>
<path d="M14.7994 22.859C14.9968 22.5171 15.4903 22.5171 15.6877 22.859L16.1318 23.6282C16.3292 23.9701 16.0824 24.3974 15.6877 24.3974H14.7994C14.4047 24.3974 14.1579 23.9701 14.3553 23.6282L14.7994 22.859Z" fill="#4C320C"/>
</g>
<defs>
<filter id="filter0_i_106_1356" x="0.628305" y="5.16666" width="92.5641" 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="-10" 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_106_1356"/>
</filter>
<filter id="filter1_i_106_1356" x="-62.1924" y="5.16666" 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.12821"/>
<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_106_1356"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

44
assets/icons/svg/ip.svg Normal file
View File

@ -0,0 +1,44 @@
<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.436 86.4596C173.997 84.0588 177.634 88.5629 179.257 91.1151L174.295 91.3802C173.025 90.7403 170.874 88.8603 172.436 86.4596Z" fill="#AC7E35"/>
<path d="M229.42 85.3536C227.252 82.6412 221.261 88.3416 218.537 91.5309L228.564 92.3579C230.57 91.4779 231.589 88.0661 229.42 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"/>
<rect x="194.872" y="126.496" width="3.4188" height="4.78632" rx="1.7094" transform="rotate(-180 194.872 126.496)" fill="#4C320C"/>
<rect x="207.18" y="126.496" width="3.4188" height="4.78632" rx="1.7094" transform="rotate(-180 207.18 126.496)" fill="#4C320C"/>
<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_226_60)">
<ellipse cx="247.521" cy="155.556" rx="56.0684" ry="40" fill="#FFF8DE"/>
</g>
<g filter="url(#filter1_i_226_60)">
<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"/>
<ellipse cx="9.76846" cy="3.89687" rx="9.76846" ry="3.89687" transform="matrix(0.912659 0.408721 0.408721 -0.912659 216.571 202.669)" fill="#FFD38D"/>
<ellipse cx="168.457" cy="203.105" rx="9.76846" ry="3.89687" transform="rotate(155.875 168.457 203.105)" fill="#FFD38D"/>
<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_226_60" 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_226_60"/>
</filter>
<filter id="filter1_i_226_60" 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_226_60"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,7 @@
<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"/>
<rect x="62" y="60" width="13" height="38" rx="6.5" fill="#B48C64"/>
<rect x="141" y="60" width="13" height="38" rx="6.5" fill="#B48C64"/>
<path d="M103.65 99.9515C99.6771 110.557 96.9654 130.216 117.903 124.011" stroke="#B48C64" stroke-width="9" stroke-linecap="round"/>
<path d="M89.147 155.142C89.147 171.5 120.992 184.277 128.087 154.706" stroke="#B48C64" stroke-width="9" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 566 B

View File

@ -0,0 +1,44 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="30" cy="30" r="30" fill="white"/>
<mask id="mask0_215_64" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="60" height="60">
<circle cx="30" cy="30" r="30" fill="white"/>
</mask>
<g mask="url(#mask0_215_64)">
<path d="M-12.302 16.2692C6.49892 -16.2949 53.5011 -16.2949 72.302 16.2692L85.1259 38.4808C103.927 71.0449 80.4256 111.75 42.8238 111.75H17.1762C-20.4256 111.75 -43.9267 71.0449 -25.1258 38.4808L-12.302 16.2692Z" fill="#FFD18A"/>
<rect x="27.1162" y="14.1539" width="1.92308" height="2.69231" rx="0.961539" transform="rotate(-180 27.1162 14.1539)" fill="#4C320C"/>
<rect x="34.0381" y="14.1539" width="1.92308" height="2.69231" rx="0.961539" transform="rotate(-180 34.0381 14.1539)" fill="#4C320C"/>
<path d="M2.43174 33.8474C15.7285 14.5173 44.2728 14.5173 57.5695 33.8474L72.7134 55.8629C87.985 78.064 72.0909 108.288 45.1445 108.288H14.8567C-12.0897 108.288 -27.9838 78.064 -12.7122 55.8629L2.43174 33.8474Z" fill="#FFF8DE"/>
<g filter="url(#filter0_i_215_64)">
<ellipse cx="56.7318" cy="30.5001" rx="31.5385" ry="22.5" fill="#FFF8DE"/>
</g>
<g filter="url(#filter1_i_215_64)">
<ellipse cx="3.07662" cy="30.5001" rx="31.3462" ry="22.5" fill="#FFF8DE"/>
</g>
<ellipse cx="29.8075" cy="19.3463" rx="2.30769" ry="1.73077" transform="rotate(180 29.8075 19.3463)" fill="#FFB8B9"/>
<ellipse cx="5.49476" cy="2.19199" rx="5.49476" ry="2.19199" transform="matrix(0.912659 0.408721 0.408721 -0.912659 41.5713 57.0012)" fill="#FFD38D"/>
<ellipse cx="12.2572" cy="57.2465" rx="5.49476" ry="2.19199" transform="rotate(155.875 12.2572 57.2465)" fill="#FFD38D"/>
<path d="M29.4741 21.2693C29.6221 21.0129 29.9922 21.0129 30.1403 21.2693L30.4733 21.8462C30.6214 22.1026 30.4363 22.4232 30.1403 22.4232H29.4741C29.178 22.4232 28.993 22.1026 29.141 21.8462L29.4741 21.2693Z" fill="#4C320C"/>
</g>
<defs>
<filter id="filter0_i_215_64" x="21.7318" y="8.00012" width="66.5387" height="46.1538" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-3.46154" dy="1.15385"/>
<feGaussianBlur stdDeviation="3.17308"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_215_64"/>
</filter>
<filter id="filter1_i_215_64" x="-28.2695" y="8.00012" width="66.5385" height="45" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="3.84615"/>
<feGaussianBlur stdDeviation="2.11538"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_215_64"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,4 @@
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.52734 1L5.52734 9.94488" stroke="#FFB645" stroke-width="2" stroke-linecap="round"/>
<path d="M10 5.66272L1.05512 5.66272" stroke="#FFB645" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@ -0,0 +1,17 @@
<svg width="402" height="97" viewBox="0 0 402 97" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_456_147)">
<path d="M434.5 58C434.5 79.5391 417.039 97 395.5 97H9C-12.5391 97 -30 79.5391 -30 58V51C-30 29.4609 -12.5391 12 9 12H128.878C135.302 12 140 18.0761 140 24.5C140 53.4949 163.505 77 192.5 77C221.495 77 245 53.4949 245 24.5C245 18.0761 249.698 12 256.122 12H395.5C417.039 12 434.5 29.4609 434.5 51V58Z" fill="white"/>
</g>
<defs>
<filter id="filter0_d_456_147" x="-39.1" y="0.9" width="482.7" height="103.2" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-2"/>
<feGaussianBlur stdDeviation="4.55"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_456_147"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_456_147" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 233 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="24" viewBox="0 0 18 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.7 23.4845C19.1012 16.9312 19.0988 6.73895 12.7 0.188241C12.4594 -0.058104 12.052 -0.0629126 11.8023 0.175295L10.4754 1.44179C10.2371 1.6689 10.232 2.03435 10.4605 2.27034C15.6824 7.66774 15.6836 16.0043 10.4605 21.4028C10.232 21.6388 10.2375 22.0042 10.4754 22.2313L11.8023 23.4978C12.052 23.7357 12.4594 23.7308 12.7 23.4845ZM5 11.8364C5 10.5288 3.88086 9.46909 2.5 9.46909C1.11914 9.46909 -4.86032e-07 10.5288 -5.46391e-07 11.8364C-6.0675e-07 13.1439 1.11914 14.2037 2.5 14.2037C3.88086 14.2037 5 13.1439 5 11.8364ZM8.26523 19.3329C12.2469 15.0696 12.2426 8.59838 8.26523 4.33986C8.02695 4.08464 7.61328 4.0765 7.36094 4.31878L6.0332 5.59267C5.80195 5.8146 5.78633 6.17191 6.00195 6.40827C8.83672 9.51348 8.83047 14.1663 6.00195 17.2641C5.78633 17.5005 5.80156 17.8578 6.0332 18.0797L7.36094 19.3536C7.61328 19.5962 8.02734 19.5877 8.26523 19.3329Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 983 B

View File

@ -0,0 +1,3 @@
<svg width="17" height="19" viewBox="0 0 17 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.7197 5.75L13.9834 17.2666C13.971 17.4579 13.8845 17.6422 13.7344 17.7803C13.5833 17.9191 13.3788 18 13.1631 18H3.83691C3.62123 18 3.4167 17.9191 3.26562 17.7803C3.11554 17.6422 3.02896 17.4579 3.0166 17.2666L2.28027 5.75H14.7197ZM10.6182 1L10.9502 1.64453L11.2285 2.1875H16V2.5625H1V2.1875H5.77148L6.0498 1.64453L6.38184 1H10.6182Z" stroke="#AC7E35" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 485 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.62808 11.1601L13.4742 6.31397M18.4316 3.35645L14.341 16.651C13.9744 17.8425 13.7909 18.4385 13.4748 18.636C13.2005 18.8074 12.8609 18.836 12.5623 18.7121C12.2178 18.5692 11.9383 18.0111 11.3807 16.8958L8.7897 11.7139C8.7012 11.5369 8.65691 11.4488 8.5978 11.3721C8.54535 11.304 8.48481 11.2427 8.41676 11.1903C8.34182 11.1325 8.25517 11.0892 8.08608 11.0046L2.89224 8.40772C1.77693 7.85006 1.21923 7.57098 1.07632 7.22656C0.95238 6.92787 0.980645 6.588 1.152 6.31375C1.34959 5.99751 1.94555 5.8138 3.13735 5.44709L16.4319 1.35645C17.3689 1.06815 17.8376 0.924119 18.154 1.0403C18.4297 1.1415 18.647 1.35861 18.7482 1.63428C18.8644 1.9506 18.7202 2.41904 18.4322 3.35506L18.4316 3.35645Z" stroke="#E2793F" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 886 B

2873
assets/json/AllDone.json Normal file

File diff suppressed because one or more lines are too long

2090
assets/json/welcome.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -7,6 +7,7 @@ module.exports = function (api) {
], ],
plugins: [ plugins: [
'expo-router/babel', 'expo-router/babel',
'react-native-reanimated/plugin',
], ],
}; };
}; };

279
components/ask/aiChat.tsx Normal file
View File

@ -0,0 +1,279 @@
import ChatSvg from "@/assets/icons/svg/chat.svg";
import MoreSvg from "@/assets/icons/svg/more.svg";
import { Message, Video } from "@/types/ask";
import { MaterialItem } from "@/types/personal-info";
import { useVideoPlayer, VideoView } from 'expo-video';
import React from 'react';
import {
Image,
Modal,
Pressable,
StyleProp,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import { ThemedText } from "../ThemedText";
import TypewriterText from "./typewriterText";
import { mergeArrays } from "./utils";
interface RenderMessageProps {
item: Message;
sessionId: string;
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>;
modalVisible: { visible: boolean, data: Video | MaterialItem };
}
const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: RenderMessageProps) => {
const isUser = item.role === 'User';
const isVideo = (data: Video | MaterialItem): data is Video => {
return 'video' in data;
};
// 创建一个新的 VideoPlayer 组件
const VideoPlayer = ({
videoUrl,
style,
onPress
}: {
videoUrl: string;
style?: StyleProp<ViewStyle>;
onPress?: () => void;
}) => {
const player = useVideoPlayer(videoUrl, (player) => {
player.loop = true;
player.play();
});
return (
<Pressable
style={[{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
}, style]}
onPress={onPress}
>
<VideoView
style={{
width: '100%',
height: '100%',
backgroundColor: '#000', // 添加背景色
}}
player={player}
allowsFullscreen
allowsPictureInPicture
/>
</Pressable>
);
};
return (
<View className={`flex-row items-start gap-2 w-full ${isUser ? 'justify-end' : 'justify-start'}`}>
{!isUser && <ChatSvg width={36} height={36} />}
<View className="max-w-[90%] mb-[1rem] flex flex-col gap-2">
<View
style={[
styles.messageBubble,
isUser ? styles.userBubble : styles.aiBubble
]}
className={`${isUser ? '!bg-bgPrimary ml-10 rounded-full' : '!bg-aiBubble mr-10 rounded-2xl'} border-0 ${!isUser && (item.content.video_material_infos && item.content.video_material_infos.length > 0 || item.content.image_material_infos && item.content.image_material_infos.length > 0) ? '!rounded-t-3xl !rounded-b-2xl' : '!rounded-3xl'}`}
>
<View className={`${isUser ? 'bg-bgPrimary' : 'bg-aiBubble'}`}>
<Text style={isUser ? styles.userText : styles.aiText}>
{!isUser
?
sessionId ? item.content.text : <TypewriterText text={item.content.text} speed={100} loop={item.content.text == "正在寻找,请稍等..."} />
: item.content.text
}
</Text>
{(item.content.image_material_infos && item.content.image_material_infos.length > 0 || item.content.video_material_infos && item.content.video_material_infos.length > 0) && (
<View className="relative">
<View className="mt-2 flex flex-row gap-2 w-full">
{mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.slice(0, 3)?.map((image, index, array) => (
<Pressable
key={image.id}
onPress={() => {
setModalVisible({ visible: true, data: image });
}}
style={({ pressed }) => [
array.length === 1 ? styles.fullWidthImage : styles.gridImage,
array.length === 2 && { width: '49%' },
array.length >= 3 && { width: '32%' },
{ opacity: pressed ? 0.8 : 1 } // 添加按下效果
]}
>
<Image
source={{ uri: image?.preview_file_info?.url || image.video?.preview_file_info?.url }}
className="rounded-xl w-full h-full"
resizeMode="cover"
/>
</Pressable>
))}
</View>
{
((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0)) > 3
&& <View className="absolute top-1/2 -translate-y-1/2 -right-4 translate-x-1/2 bg-bgPrimary flex flex-row items-center gap-2 p-1 pl-2 rounded-full">
<ThemedText className="!text-white font-semibold">{((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0))}</ThemedText>
<View className="bg-white rounded-full p-2">
<MoreSvg />
</View>
</View>
}
</View>
)}
</View>
</View>
{item.askAgain && item.askAgain.length > 0 && (
<View className={`mr-10`}>
{item.askAgain.map((suggestion, index, array) => (
<TouchableOpacity
key={suggestion.id}
className={`bg-yellow-50 rounded-xl px-4 py-2 border border-yellow-200 border-0 mb-2 ${index === array.length - 1 ? 'mb-0 rounded-b-3xl rounded-t-2xl' : 'rounded-2xl'}`}
>
<Text className="text-gray-700">{suggestion.text}</Text>
</TouchableOpacity>
))}
</View>
)}
<Modal
animationType="fade"
transparent={true}
visible={modalVisible.visible}
onRequestClose={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem });
}}>
<View style={styles.centeredView}>
<TouchableOpacity
style={styles.background}
onPress={() => {
setModalVisible({ visible: false, data: {} as Video | MaterialItem })
}}
/>
<TouchableOpacity style={styles.modalView} onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}>
{isVideo(modalVisible.data) ? (
// 视频播放器
<TouchableOpacity
activeOpacity={1}
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
maxHeight: "60%",
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
>
<VideoPlayer
videoUrl={modalVisible.data.video.file_info.url}
style={{
width: '100%',
height: '100%',
alignSelf: 'center',
justifyContent: 'center',
}}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
/>
</TouchableOpacity>
) : (
// 图片预览
<TouchableOpacity
activeOpacity={1}
onPress={() => setModalVisible({ visible: false, data: {} as Video | MaterialItem })}
style={styles.imageContainer}
>
<Image
source={{ uri: modalVisible.data.preview_file_info?.url }}
style={styles.fullWidthImage}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</TouchableOpacity>
</View>
</Modal>
</View>
</View>
);
};
export default renderMessage;
const styles = StyleSheet.create({
video: {
width: '100%',
height: '100%',
},
imageContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
width: '100%',
maxHeight: '60%',
},
fullWidthImage: {
width: '100%',
height: "54%",
marginBottom: 8,
},
gridImage: {
aspectRatio: 1,
marginBottom: 8,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)', // 添加半透明黑色背景
},
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
modalView: {
borderRadius: 20,
alignItems: 'center',
height: '100%',
width: "100%",
justifyContent: 'center',
alignSelf: 'center',
},
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
userAvatar: {
width: 30,
height: 30,
borderRadius: 15,
},
messageList: {
padding: 16,
},
messageBubble: {
paddingHorizontal: 16,
paddingVertical: 12,
fontWeight: "600"
},
userBubble: {
alignSelf: 'flex-end',
backgroundColor: '#FFB645',
marginLeft: '20%',
},
aiBubble: {
alignSelf: 'flex-start',
backgroundColor: '#fff',
marginRight: '20%',
borderWidth: 1,
borderColor: '#e5e5ea',
},
userText: {
color: '#4C320C',
fontSize: 16,
},
aiText: {
color: '#000',
fontSize: 16,
},
});

52
components/ask/chat.tsx Normal file
View File

@ -0,0 +1,52 @@
import { Message, Video } from '@/types/ask';
import { MaterialItem } from '@/types/personal-info';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import {
FlatList,
SafeAreaView
} from 'react-native';
import renderMessage from "./aiChat";
interface ChatProps {
userMessages: Message[];
sessionId: string;
}
function ChatComponent({ userMessages, sessionId }: ChatProps) {
const flatListRef = useRef<FlatList>(null);
const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem });
// 使用 useCallback 缓存 keyExtractor 函数
const keyExtractor = useCallback((item: Message) => item.timestamp, []);
// 使用 useMemo 缓存样式对象
const contentContainerStyle = useMemo(() => ({ padding: 16 }), []);
// 自动滚动到底部
useEffect(() => {
if (userMessages.length > 0) {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 100);
}
}, [userMessages]);
return (
<SafeAreaView className='flex-1'>
<FlatList
ref={flatListRef}
data={userMessages}
keyExtractor={keyExtractor}
contentContainerStyle={contentContainerStyle}
keyboardDismissMode="interactive"
removeClippedSubviews={true}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
initialNumToRender={10}
windowSize={11}
renderItem={({ item }) => renderMessage({ item, sessionId, modalVisible, setModalVisible })}
/>
</SafeAreaView>
);
}

26
components/ask/hello.tsx Normal file
View File

@ -0,0 +1,26 @@
import IP from "@/assets/icons/svg/ip.svg";
import { ThemedText } from "@/components/ThemedText";
import { View } from 'react-native';
export default function AskHello() {
return (
<View className="flex-1 bg-white overflow-auto w-full">
{/* 内容区域 IP与介绍文本*/}
<View className="items-center flex-1">
<ThemedText className="text-3xl font-bold text-center">
Hi,
{"\n"}
I'm MeMo!
</ThemedText>
<View className="justify-center items-center"><IP /></View>
<ThemedText className="!text-textPrimary text-center -mt-[4rem]">
Ready to wake up your memories?
{"\n"}
Just ask MeMo, let me bring them back to life!
</ThemedText>
</View>
</View>
);
}

View File

@ -0,0 +1,49 @@
import React, { useEffect, useState } from 'react';
import { Text } from 'react-native';
interface TypewriterTextProps {
text: string;
speed?: number; // 打字速度(毫秒)
loop?: boolean; // 是否循环播放
delay?: number; // 每轮之间的延迟时间
}
const TypewriterText: React.FC<TypewriterTextProps> = ({
text,
speed = 150,
loop = false,
delay = 2000,
}) => {
const [displayedText, setDisplayedText] = useState<string>(text[0] || ''); // 初始为第一个字符
const [currentIndex, setCurrentIndex] = useState<number>(1); // 从第2个字符开始
useEffect(() => {
let typingTimeout: ReturnType<typeof setTimeout> | null = null;
let resetTimeout: ReturnType<typeof setTimeout> | null = null;
if (currentIndex < text.length) {
// 正常打字过程
typingTimeout = setTimeout(() => {
setDisplayedText(text.slice(0, currentIndex + 1));
setCurrentIndex((prev) => prev + 1);
}, speed);
} else if (loop) {
// 当前一轮完成,等待 delay 后重新开始
resetTimeout = setTimeout(() => {
setCurrentIndex(1); // 从第2个字符重新开始
setDisplayedText(text[0] || ''); // 重置显示文本
}, delay);
}
return () => {
if (typingTimeout) clearTimeout(typingTimeout);
if (resetTimeout) clearTimeout(resetTimeout);
};
}, [currentIndex, text, speed, loop, delay]);
return (
<Text>{displayedText}</Text>
);
};
export default TypewriterText;

10
components/ask/utils.ts Normal file
View File

@ -0,0 +1,10 @@
// 实现一个函数,从两个数组中轮流插入新数组
export const mergeArrays = (arr1: any[], arr2: any[]) => {
const result: any[] = [];
const maxLength = Math.max(arr1.length, arr2.length);
for (let i = 0; i < maxLength; i++) {
if (i < arr1.length) result.push(arr1[i]);
if (i < arr2.length) result.push(arr2[i]);
}
return result;
};

204
components/ask/voice.tsx Normal file
View File

@ -0,0 +1,204 @@
'use client';
import React, { useCallback, useState } from 'react';
import {
Platform,
StyleSheet,
TextInput,
View
} from 'react-native';
import { RecordingPresets, useAudioRecorder } from 'expo-audio';
interface Props {
setIsHello: (isHello: boolean) => void,
setInputValue: (inputValue: string) => void,
inputValue: string,
createNewConversation: (user_text: string) => void,
conversationId: string | null,
getConversation: ({ user_text, session_id }: { user_text: string, session_id: string }) => void
}
export default function AudioRecordPlay(props: Props) {
const { setIsHello, setInputValue, inputValue, createNewConversation, conversationId, getConversation } = props;
const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const [isRecording, setIsRecording] = useState(false);
const [isVoiceStart, setIsVoiceStart] = useState(false);
const [elapsedTime, setElapsedTime] = useState(0);
const [timerInterval, setTimerInterval] = useState<NodeJS.Timeout | number>(0);
const formatTime = (ms: number): string => {
const totalSeconds = ms / 1000;
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.floor(totalSeconds % 60);
const milliseconds = Math.floor(ms % 1000);
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}`;
};
// 开始录音
const record = async () => {
await audioRecorder.prepareToRecordAsync();
const startTime = Date.now();
// 每 10ms 更新一次时间
const interval = setInterval(() => {
const elapsed = Date.now() - startTime;
setElapsedTime(elapsed);
}, 10);
setTimerInterval(interval);
setIsVoiceStart(true)
audioRecorder.record();
setIsRecording(true);
};
const stopRecording = async () => {
// The recording will be available on `audioRecorder.uri`.
if (timerInterval) clearInterval(timerInterval);
setTimerInterval(0);
await audioRecorder.stop();
setIsRecording(false);
};
// useEffect(() => {
// (async () => {
// const status = await AudioModule.requestRecordingPermissionsAsync();
// if (!status.granted) {
// Alert.alert('Permission to access microphone was denied');
// }
// })();
// }, []);
// 使用 useCallback 缓存回调函数
const handleChangeText = useCallback((text: string) => {
setInputValue(text);
}, []);
// 使用 useCallback 缓存 handleSubmit
const handleSubmit = useCallback(() => {
const text = inputValue.trim();
if (text) {
if (!conversationId) {
createNewConversation(text);
setIsHello(false);
} else {
getConversation({
session_id: conversationId,
user_text: text
});
}
setInputValue('');
}
}, [conversationId]);
// 使用 useCallback 缓存 handleKeyPress
const handleKeyPress = useCallback((e: any) => {
if (Platform.OS === 'web' && e.nativeEvent.key !== 'Enter') {
return;
}
handleSubmit();
}, [handleSubmit]);
return (
<View style={styles.container}>
<View className="relative w-full">
{/* <TouchableOpacity
onPress={() => console.log('Left icon pressed')}
className={`absolute left-2 top-1/2 -translate-y-1/2 p-2 bg-white rounded-full ${isVoiceStart ? "opacity-100" : "opacity-0"}`} // 使用绝对定位将按钮放在输入框内右侧
>
<VoiceDeleteSvg />
</TouchableOpacity> */}
<TextInput
style={styles.input}
placeholder="Ask MeMo Anything..."
placeholderTextColor="#999"
className={isVoiceStart ? 'bg-bgPrimary border-none pl-12' : ''}
value={isVoiceStart ? `· · · · · · · · · · · · · · ${formatTime(elapsedTime)}` : inputValue}
onChangeText={(text: string) => {
setInputValue(text);
}}
onSubmitEditing={handleSubmit}
editable={!isVoiceStart}
// 调起的键盘类型
returnKeyType="send"
/>
{/* <TouchableOpacity
style={styles.voiceButton}
className={`absolute right-0 top-1/2 -translate-y-1/2 ${isVoiceStart ? 'bg-white px-8' : 'bg-bgPrimary'}`} // 使用绝对定位将按钮放在输入框内右侧
onPress={isVoiceStart ? stopRecording : record}
>
{isVoiceStart ? <VoiceSendSvg /> : <VoiceSvg />}
</TouchableOpacity> */}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
backgroundColor: '#fff',
},
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
},
recordButton: {
padding: 15,
borderRadius: 8,
alignItems: 'center',
marginBottom: 20,
},
startButton: {
backgroundColor: '#ff6b6b',
},
stopButton: {
backgroundColor: '#4CAF50',
},
buttonText: {
color: 'white',
fontSize: 16,
},
listTitle: {
fontWeight: 'bold',
marginBottom: 10,
},
emptyText: {
fontStyle: 'italic',
color: '#888',
marginBottom: 10,
},
recordingItem: {
padding: 10,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
uriText: {
fontSize: 12,
color: '#777',
},
leftIcon: {
padding: 10,
paddingLeft: 15,
},
input: {
borderColor: '#FF9500',
borderWidth: 1,
borderRadius: 25,
paddingHorizontal: 20,
paddingVertical: 12,
fontSize: 16,
width: '100%', // 确保输入框宽度撑满
paddingRight: 50
},
voiceButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#FF9500',
justifyContent: 'center',
alignItems: 'center',
marginRight: 8, // 添加一点右边距
},
});

View File

@ -1,7 +1,7 @@
import { fetchApi } from "@/lib/server-api-util"; import { fetchApi } from "@/lib/server-api-util";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from 'uuid'; import uuid from 'react-native-uuid';
// 导入子组件 // 导入子组件
import { ConfirmUpload } from "@/types/upload"; import { ConfirmUpload } from "@/types/upload";
@ -219,7 +219,7 @@ export default function FileUploader({
reject(new Error('Failed to create blob from canvas')); reject(new Error('Failed to create blob from canvas'));
return; return;
} }
let file_name = uuidv4() + ".png" let file_name = uuid.v4() + ".png"
const compressedFile = new File([blob], file_name, { const compressedFile = new File([blob], file_name, {
type: outputType, type: outputType,
lastModified: Date.now() lastModified: Date.now()

View File

@ -0,0 +1,566 @@
import { fetchApi } from '@/lib/server-api-util';
import { defaultExifData, ExifData, ImagesuploaderProps } from '@/types/upload';
import * as ImageManipulator from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location';
import * as MediaLibrary from 'expo-media-library';
import React, { useEffect, useState } from 'react';
import { Alert, Button, Platform, TouchableOpacity, View } from 'react-native';
import { UploadUrlResponse } from './file-uploader';
import UploadPreview from './preview';
// 在文件顶部添加这些类型
type UploadTask = {
file: File;
metadata: {
isCompressed: string;
type: string;
isThumbnail?: string;
[key: string]: any;
};
};
type FileUploadItem = {
id: string;
name: string;
progress: number;
status: 'pending' | 'uploading' | 'done' | 'error';
error: string | null;
type: 'image' | 'video';
thumbnail: string | null;
};
type ConfirmUpload = {
file_id: string;
upload_url: string;
name: string;
size: number;
content_type: string;
file_path: string;
};
type UploadResult = {
originalUrl?: string;
compressedUrl: string;
file: File | null;
exif: any;
originalFile: ConfirmUpload;
compressedFile: ConfirmUpload;
thumbnail: string;
thumbnailFile: File;
};
export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
children,
style,
compressQuality = 0.8,
maxWidth = 2048,
maxHeight = 2048,
preserveExif = true,
onUploadComplete,
multipleChoice = false,
showPreview = true,
fileType = ['images'],
}) => {
const [isLoading, setIsLoading] = useState(false);
const [uploadQueue, setUploadQueue] = useState<FileUploadItem[]>([]);
// 请求权限
const requestPermissions = async () => {
if (Platform.OS !== 'web') {
const { status: mediaStatus } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (mediaStatus !== 'granted') {
Alert.alert('需要媒体库权限', '请允许访问媒体库以选择图片');
return false;
}
const { status: locationStatus } = await Location.requestForegroundPermissionsAsync();;
if (locationStatus !== 'granted') {
Alert.alert('需要位置权限', '需要位置权限才能获取图片位置信息');
}
}
return true;
};
// 获取上传URL
const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<UploadUrlResponse> => {
const body = {
filename: file.name,
content_type: file.type,
file_size: file.size,
metadata: {
...metadata,
originalName: file.name,
fileType: 'image',
isCompressed: metadata.isCompressed || 'false',
},
};
return await fetchApi<UploadUrlResponse>("/file/generate-upload-url", {
method: 'POST',
body: JSON.stringify(body)
});
};
// 向服务端confirm上传
const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => await fetchApi<ConfirmUpload>('/file/confirm-upload', {
method: 'POST',
body: JSON.stringify({
file_id
})
});
// 新增素材
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);
})
}
// 上传文件到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 processImage = async (uri: string, fileName: string, mimeType: string) => {
try {
// 压缩图片
const manipResult = await ImageManipulator.manipulateAsync(
uri,
[
{
resize: {
width: maxWidth,
height: maxHeight,
},
},
],
{
compress: compressQuality,
format: ImageManipulator.SaveFormat.JPEG,
base64: false,
}
);
// 获取压缩后的图片数据
const response = await fetch(manipResult.uri);
const blob = await response.blob();
// 创建文件对象
const file = new File([blob], `compressed_${Date.now()}_${fileName}`, {
type: mimeType,
});
return { file, uri: manipResult.uri };
} catch (error) {
// console.error('图片压缩失败:', error);
throw new Error('图片处理失败');
}
};
const uploadWithProgress = async (file: File, metadata: any): Promise<ConfirmUpload> => {
let timeoutId: number
try {
console.log("Starting upload for file:", file.name, "size:", file.size, "type:", file.type);
// 检查文件大小
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_FILE_SIZE) {
throw new Error(`文件大小超过限制 (${(MAX_FILE_SIZE / 1024 / 1024).toFixed(1)}MB)`);
}
const uploadUrlData = await getUploadUrl(file, {});
console.log("Got upload URL for:", file.name);
return new Promise<ConfirmUpload>((resolve, reject) => {
try {
// 设置超时
timeoutId = setTimeout(() => {
reject(new Error('上传超时,请检查网络连接'));
}, 30000);
// 上传文件
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrlData.upload_url, true);
xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
console.log(`Upload progress for ${file.name}: ${progress}%`);
}
};
xhr.onload = async () => {
clearTimeout(timeoutId!);
if (xhr.status >= 200 && xhr.status < 300) {
try {
const result = await confirmUpload(uploadUrlData.file_id);
resolve({
...result,
file_id: uploadUrlData.file_id,
upload_url: uploadUrlData.upload_url,
});
} catch (error) {
reject(error);
}
} else {
reject(new Error(`上传失败,状态码: ${xhr.status}`));
}
};
xhr.onerror = () => {
clearTimeout(timeoutId!);
reject(new Error('网络错误,请检查网络连接'));
};
xhr.send(file);
} catch (error) {
clearTimeout(timeoutId!);
reject(error);
}
});
} catch (error) {
console.error('Error in uploadWithProgress:', {
error,
fileName: file?.name,
fileSize: file?.size,
fileType: file?.type
});
throw error;
}
};
// 处理单个资源
const processSingleAsset = async (asset: ImagePicker.ImagePickerAsset): Promise<UploadResult | null> => {
const fileId = `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const isVideo = asset.type === 'video';
const uploadResults: UploadResult = {
originalUrl: undefined,
compressedUrl: '',
file: null,
exif: {},
originalFile: {} as ConfirmUpload,
compressedFile: {} as ConfirmUpload,
thumbnail: '',
thumbnailFile: {} as File,
};
// 创建上传项
const newFileItem: FileUploadItem = {
id: fileId,
name: asset.fileName || 'file',
progress: 0,
status: 'uploading' as const,
error: null,
type: isVideo ? 'video' : 'image',
thumbnail: null,
};
setUploadQueue(prev => [...prev, newFileItem]);
const updateProgress = (progress: number) => {
setUploadQueue(prev =>
prev.map(item =>
item.id === fileId ? { ...item, progress } : item
)
);
};
try {
let file: File;
let thumbnailFile: File | null = null;
let exifData: ExifData = { ...defaultExifData };
if (isVideo) {
// 处理视频文件
file = new File(
[await (await fetch(asset.uri)).blob()],
`video_${Date.now()}.mp4`,
{ type: 'video/mp4' }
);
// 生成视频缩略图
const thumbnailResult = await ImageManipulator.manipulateAsync(
asset.uri,
[{ resize: { width: 300 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
);
thumbnailFile = new File(
[await (await fetch(thumbnailResult.uri)).blob()],
`thumb_${Date.now()}.jpg`,
{ type: 'image/jpeg' }
);
} else {
// 处理图片
const [originalResponse, compressedFileResult] = await Promise.all([
fetch(asset.uri),
ImageManipulator.manipulateAsync(
asset.uri,
[{ resize: { width: 800 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
)
]);
// 如果保留 EXIF 数据,则获取
if (preserveExif && asset.exif) {
exifData = { ...exifData, ...asset.exif };
if (asset.uri && Platform.OS !== 'web') {
try {
const mediaAsset = await MediaLibrary.getAssetInfoAsync(asset.uri);
if (mediaAsset.exif) {
exifData = { ...exifData, ...mediaAsset.exif };
}
if (mediaAsset.location) {
exifData.GPSLatitude = mediaAsset.location.latitude;
exifData.GPSLongitude = mediaAsset.location.longitude;
}
} catch (error) {
console.warn('从媒体库获取 EXIF 数据失败:', error);
}
}
}
const originalBlob = await originalResponse.blob();
const compressedBlob = await compressedFileResult.file;
file = new File(
[originalBlob],
`original_${Date.now()}_${asset.fileName || 'photo.jpg'}`,
{ type: asset.mimeType || 'image/jpeg' }
);
thumbnailFile = new File(
[compressedBlob],
`compressed_${Date.now()}_${asset.fileName || 'photo.jpg'}`,
{ type: 'image/jpeg' }
);
}
// 准备上传任务
const uploadTasks: UploadTask[] = [
{
file,
metadata: {
isCompressed: 'false',
type: isVideo ? 'video' : 'image',
...(isVideo ? {} : exifData)
}
}
];
if (thumbnailFile) {
uploadTasks.push({
file: thumbnailFile,
metadata: {
isCompressed: 'true',
type: 'image',
isThumbnail: 'true'
}
});
}
// 顺序上传文件
const uploadResultsList = [];
for (const task of uploadTasks) {
try {
const result = await uploadWithProgress(task.file, task.metadata);
uploadResultsList.push(result);
} catch (error) {
console.error('Upload failed:', error);
throw error;
}
}
// 处理上传结果
const [mainUpload, thumbnailUpload] = uploadResultsList;
uploadResults.originalFile = mainUpload;
uploadResults.compressedFile = thumbnailUpload || mainUpload;
uploadResults.thumbnail = thumbnailUpload?.upload_url || '';
uploadResults.thumbnailFile = thumbnailFile;
// 更新上传状态
updateProgress(100);
setUploadQueue(prev =>
prev.map(item =>
item.id === fileId
? {
...item,
status: 'done' as const,
progress: 100,
thumbnail: uploadResults.thumbnail
}
: item
)
);
// 添加到素材库
if (uploadResults.originalFile?.file_id) {
await addMaterial(
uploadResults.originalFile.file_id,
uploadResults.thumbnail
);
}
return uploadResults;
} catch (error) {
console.error('Error processing file:', error);
setUploadQueue(prev =>
prev.map(item =>
item.id === fileId
? {
...item,
status: 'error' as const,
error: error instanceof Error ? error.message : '上传失败'
}
: item
)
);
return null;
}
};
// 处理所有选中的图片
const processAssets = async (assets: ImagePicker.ImagePickerAsset[]): Promise<UploadResult[]> => {
// 设置最大并发数
const CONCURRENCY_LIMIT = 3;
const results: UploadResult[] = [];
// 分批处理资源
for (let i = 0; i < assets.length; i += CONCURRENCY_LIMIT) {
const batch = assets.slice(i, i + CONCURRENCY_LIMIT);
// 并行处理当前批次的所有资源
const batchResults = await Promise.allSettled(
batch.map(asset => processSingleAsset(asset))
);
// 收集成功的结果
for (const result of batchResults) {
if (result.status === 'fulfilled' && result.value) {
results.push(result.value);
}
}
// 添加小延迟,避免过多占用系统资源
if (i + CONCURRENCY_LIMIT < assets.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
return results;
};
// 处理图片选择
const pickImage = async () => {
try {
setIsLoading(true);
const hasPermission = await requestPermissions();
console.log("hasPermission", hasPermission);
if (!hasPermission) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: fileType,
allowsMultipleSelection: multipleChoice,
quality: 1,
exif: preserveExif,
});
console.log("result", result?.assets);
if (result.canceled || !result.assets) {
setIsLoading(false);
return;
}
try {
const uploadResults = await processAssets(result.assets);
// 所有文件处理完成后的回调
// @ts-ignore
onUploadComplete?.(uploadResults?.map((item, index) => {
return {
...item,
preview: result?.assets?.[index]?.uri
}
}));
} catch (error) {
Alert.alert('错误', '部分文件处理失败,请重试');
} finally {
setIsLoading(false);
}
} catch (error) {
Alert.alert('错误', '选择图片时出错,请重试');
} finally {
setIsLoading(false);
}
};
// 在组件卸载时清理已完成的上传
useEffect(() => {
return () => {
// 只保留未完成的上传项
setUploadQueue(prev =>
prev.filter(item =>
item.status === 'uploading' || item.status === 'pending'
)
);
};
}, []);
return (
<View style={[style]}>
{children ? (
<TouchableOpacity onPress={pickImage} disabled={isLoading} activeOpacity={0.7}>
{children}
</TouchableOpacity>
) : (
<Button
title={isLoading ? '处理中...' : '选择图片'}
onPress={pickImage}
disabled={isLoading}
/>
)}
{/* 上传预览 */}
{showPreview && <UploadPreview items={uploadQueue} />}
</View>
);
};
export default ImagesUploader;

View File

@ -0,0 +1,339 @@
import { fetchApi } from '@/lib/server-api-util';
import { ConfirmUpload, defaultExifData, ExifData, ImagesPickerProps, UploadResult } from '@/types/upload';
import * as ImageManipulator from 'expo-image-manipulator';
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location';
import * as MediaLibrary from 'expo-media-library';
import React, { useEffect, useState } from 'react';
import { Alert, Button, Platform, TouchableOpacity, View } from 'react-native';
import * as Progress from 'react-native-progress';
import { FileStatus, UploadUrlResponse } from './file-uploader';
export const ImagesPicker: React.FC<ImagesPickerProps> = ({
children,
style,
onPickImage,
compressQuality = 0.8,
maxWidth = 2048,
maxHeight = 2048,
preserveExif = true,
onUploadComplete,
onProgress,
}) => {
const [isLoading, setIsLoading] = useState(false);
// 请求权限
const requestPermissions = async () => {
if (Platform.OS !== 'web') {
const { status: mediaStatus } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (mediaStatus !== 'granted') {
Alert.alert('需要媒体库权限', '请允许访问媒体库以选择图片');
return false;
}
const { status: locationStatus } = await Location.requestForegroundPermissionsAsync();;
if (locationStatus !== 'granted') {
Alert.alert('需要位置权限', '需要位置权限才能获取图片位置信息');
}
}
return true;
};
// 获取上传URL
const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<UploadUrlResponse> => {
const body = {
filename: file.name,
content_type: file.type,
file_size: file.size,
metadata: {
...metadata,
originalName: file.name,
fileType: 'image',
isCompressed: metadata.isCompressed || 'false',
},
};
return await fetchApi<UploadUrlResponse>("/file/generate-upload-url", {
method: 'POST',
body: JSON.stringify(body)
});
};
// 向服务端confirm上传
const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => await fetchApi<ConfirmUpload>('/file/confirm-upload', {
method: 'POST',
body: JSON.stringify({
file_id
})
});
// 新增素材
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);
})
}
// 上传文件到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 [currentFileStatus, setCurrentFileStatus] = useState<FileStatus>({
file: null as unknown as File,
status: 'pending',
progress: 0
});
// 使用函数更新文件状态,确保每次更新都是原子的
const updateFileStatus = (updates: Partial<FileStatus>) => {
setCurrentFileStatus((original) => ({ ...original, ...updates }))
};
// 上传文件
const uploadFile = async (file: File, metadata: Record<string, any> = {}): Promise<ConfirmUpload> => {
try {
// 初始化上传状态
updateFileStatus({ status: 'uploading', progress: 1 });
// 添加小延迟,确保初始状态能被看到
await new Promise(resolve => setTimeout(resolve, 300));
// 获取上传URL
const { upload_url, file_id } = await getUploadUrl(file, metadata);
// 上传文件到URL
await uploadFileToUrl(
file,
upload_url,
(progress) => {
// 将实际进度映射到 60%-90% 区间
const mappedProgress = 60 + (progress * 0.3);
updateFileStatus({ progress: Math.round(mappedProgress) });
}
);
// 确认上传到服务器
const fileData = confirmUpload(file_id)
// 将fileData, upload_url,file_id 传递出去
return { ...fileData, upload_url, file_id }
} catch (error) {
console.error('上传文件时出错:', error);
updateFileStatus({
status: 'error',
error: error instanceof Error ? error.message : '上传失败'
});
throw new Error('文件上传失败');
}
};
// 压缩并处理图片
const processImage = async (uri: string, fileName: string, mimeType: string) => {
try {
// 压缩图片
const manipResult = await ImageManipulator.manipulateAsync(
uri,
[
{
resize: {
width: maxWidth,
height: maxHeight,
},
},
],
{
compress: compressQuality,
format: ImageManipulator.SaveFormat.JPEG,
base64: false,
}
);
// 获取压缩后的图片数据
const response = await fetch(manipResult.uri);
const blob = await response.blob();
// 创建文件对象
const file = new File([blob], `compressed_${Date.now()}_${fileName}`, {
type: mimeType,
});
return { file, uri: manipResult.uri };
} catch (error) {
console.error('图片压缩失败:', error);
throw new Error('图片处理失败');
}
};
// 处理图片选择
const pickImage = async () => {
try {
setIsLoading(true);
const hasPermission = await requestPermissions();
if (!hasPermission) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsMultipleSelection: false,
quality: 1,
exif: preserveExif,
});
if (result.canceled || !result.assets?.[0]) {
setIsLoading(false);
return;
}
const asset = result.assets[0];
try {
// 获取原图文件
const originalResponse = await fetch(asset.uri);
const originalBlob = await originalResponse.blob();
const originalFile = new File(
[originalBlob],
`original_${Date.now()}_${asset.fileName || 'photo.jpg'}`,
{ type: asset.mimeType || 'image/jpeg' }
) as File;
// 压缩并处理图片
const { file: compressedFile } = await processImage(
asset.uri,
asset.fileName || 'photo.jpg',
asset.mimeType || 'image/jpeg'
);
let exifData: ExifData = { ...defaultExifData };
// 如果保留 EXIF 数据,则获取
if (preserveExif && asset.exif) {
exifData = { ...exifData, ...asset.exif };
if (asset.uri && Platform.OS !== 'web') {
try {
const mediaAsset = await MediaLibrary.getAssetInfoAsync(asset.uri);
if (mediaAsset.exif) {
exifData = { ...exifData, ...mediaAsset.exif };
}
if (mediaAsset.location) {
exifData.GPSLatitude = mediaAsset.location.latitude;
exifData.GPSLongitude = mediaAsset.location.longitude;
}
} catch (error) {
console.warn('从媒体库获取 EXIF 数据失败:', error);
}
}
}
// 调用 onPickImage 回调
onPickImage?.(compressedFile, exifData);
// 上传文件
const uploadResults: UploadResult = {
originalUrl: undefined,
compressedUrl: '',
file: compressedFile,
exifData,
originalFile: {} as ConfirmUpload,
compressedFile: {} as ConfirmUpload,
};
try {
// 上传压缩后的图片
const compressedResult = await uploadFile(compressedFile, {
isCompressed: 'true',
...exifData,
});
uploadResults.originalFile = compressedResult;
// 上传原图
const originalResult = await uploadFile(originalFile, {
isCompressed: 'false',
...exifData,
});
uploadResults.compressedFile = originalResult;
// 添加到素材库
await addMaterial(uploadResults.originalFile?.file_id, uploadResults.compressedFile?.file_id);
// 等待一些时间再标记为成功
await new Promise(resolve => setTimeout(resolve, 300));
// 更新状态为成功
await updateFileStatus({ status: 'success', progress: 100, id: uploadResults.originalFile?.file_id });
// 调用上传完成回调
onUploadComplete?.(uploadResults);
} catch (error) {
updateFileStatus({ status: 'error', progress: 0, id: uploadResults.originalFile?.file_id });
throw error; // 重新抛出错误,让外层 catch 处理
}
} catch (error) {
Alert.alert('错误', '处理图片时出错');
}
} catch (error) {
Alert.alert('错误', '选择图片时出错,请重试');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (onProgress) {
onProgress(currentFileStatus);
}
}, [currentFileStatus.progress]);
return (
<View style={[style]}>
{children ? (
<TouchableOpacity
onPress={pickImage}
disabled={isLoading}
activeOpacity={0.7}
>
{children}
</TouchableOpacity>
) : (
<Button
title={isLoading ? '处理中...' : '选择图片'}
onPress={pickImage}
disabled={isLoading}
/>
)}
{/* 添加上传进度条 */}
{currentFileStatus.status === 'uploading' && (
<Progress.Bar progress={currentFileStatus.progress / 100} width={200} key={currentFileStatus.id} animated={true} />
)}
</View>
);
};
export default ImagesPicker;

View File

@ -0,0 +1,117 @@
import { FileUploadItem } from "@/types/upload";
import { Image, StyleSheet, Text, View } from "react-native";
import * as Progress from 'react-native-progress';
import { ThemedText } from "../ThemedText";
const UploadPreview = ({
items,
onRetry
}: {
items: FileUploadItem[];
onRetry?: (id: string) => void;
}) => {
return (
<View style={styles.previewContainer}>
{items.map((item) => (
<View key={item.id} style={[
styles.previewItem,
item.status === 'error' && styles.errorItem
]}>
<Image
source={{ uri: item.thumbnail }}
style={styles.previewImage}
resizeMode="cover"
/>
<View style={styles.progressContainer}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
</Text>
<View style={styles.statusContainer}>
{item.status === 'uploading' && (
<Progress.Bar
progress={item.progress / 100}
width={200}
color={'#007AFF'}
/>
)}
{item.status === 'success' && (
<ThemedText style={styles.successText}> </ThemedText>
)}
</View>
{item.error && (
<ThemedText style={styles.errorText} numberOfLines={2}>
{item.error}
</ThemedText>
)}
</View>
</View>
))}
</View>
);
};
// 添加样式
const styles = StyleSheet.create({
previewContainer: {
marginTop: 16,
},
previewItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
padding: 12,
backgroundColor: '#f8f9fa',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e9ecef',
},
errorItem: {
backgroundColor: '#fff5f5',
borderColor: '#ffd6d6',
},
previewImage: {
width: 50,
height: 50,
borderRadius: 4,
marginRight: 12,
backgroundColor: '#e9ecef',
},
progressContainer: {
flex: 1,
},
statusContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
},
fileName: {
fontSize: 14,
fontWeight: '500',
color: '#212529',
},
retryButton: {
paddingHorizontal: 12,
paddingVertical: 4,
backgroundColor: '#ff3b30',
borderRadius: 4,
marginLeft: 8,
},
retryText: {
color: 'white',
fontSize: 12,
fontWeight: '500',
},
errorText: {
color: '#ff3b30',
fontSize: 12,
marginTop: 4,
},
successText: {
color: '#34c759',
fontSize: 12,
fontWeight: '500',
},
});
export default UploadPreview;

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FileStatus } from './file-uploader'; import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons'; import Icon from 'react-native-vector-icons/MaterialIcons';
import { FileStatus } from './file-uploader';
interface SingleFileUploaderProps { interface SingleFileUploaderProps {
file: FileStatus; file: FileStatus;
@ -92,7 +92,7 @@ const styles = StyleSheet.create({
}, },
thumbnailContainer: { thumbnailContainer: {
width: '100%', width: '100%',
aspectRatio: 16/9, aspectRatio: 16 / 9,
backgroundColor: '#F3F4F6', backgroundColor: '#F3F4F6',
borderRadius: 6, borderRadius: 6,
overflow: 'hidden', overflow: 'hidden',

View File

@ -0,0 +1,39 @@
import * as ImageManipulator from 'expo-image-manipulator';
import * as VideoThumbnail from 'expo-video-thumbnails';
export const extractVideoThumbnail = async (videoUri: string): Promise<{ uri: string; file: File }> => {
try {
// 获取视频的第一帧
const { uri: thumbnailUri } = await VideoThumbnail.getThumbnailAsync(
videoUri,
{
time: 1000, // 1秒的位置
quality: 0.8,
}
);
// 转换为 WebP 格式
const manipResult = await ImageManipulator.manipulateAsync(
thumbnailUri,
[{ resize: { width: 800 } }], // 调整大小以提高性能
{
compress: 0.8,
format: ImageManipulator.SaveFormat.WEBP
}
);
// 转换为 File 对象
const response = await fetch(manipResult.uri);
const blob = await response.blob();
const file = new File(
[blob],
`thumb_${Date.now()}.webp`,
{ type: 'image/webp' }
);
return { uri: manipResult.uri, file };
} catch (error) {
console.error('Error generating video thumbnail:', error);
throw new Error('无法生成视频缩略图: ' + (error instanceof Error ? error.message : String(error)));
}
};

290
components/firework.tsx Normal file
View File

@ -0,0 +1,290 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Animated,
Dimensions,
Easing,
StyleSheet,
View
} from 'react-native';
const { width, height } = Dimensions.get('window');
// 粒子类型定义
interface Particle {
id: number;
position: { x: number; y: number };
animation: Animated.CompositeAnimation;
color: string;
size: number;
translateX: Animated.Value;
translateY: Animated.Value;
opacity: Animated.Value;
scale: Animated.Value;
rotation: Animated.Value;
}
// 烟花组件属性
interface FireworksProps {
autoPlay?: boolean;
loop?: boolean;
interval?: number;
particleCount?: number;
colors?: string[];
}
export const Fireworks: React.FC<FireworksProps> = ({
autoPlay = true,
loop = true,
interval = 2000,
particleCount = 80,
colors = [
'#FF5252', '#FF4081', '#E040FB', '#7C4DFF',
'#536DFE', '#448AFF', '#40C4FF', '#18FFFF',
'#64FFDA', '#69F0AE', '#B2FF59', '#EEFF41',
'#FFD740', '#FFAB40', '#FF6E40'
]
}) => {
const [particles, setParticles] = useState<Particle[]>([]);
const [isPlaying, setIsPlaying] = useState(autoPlay);
const particleId = useRef(0);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// 生成随机位置
const getRandomPosition = () => {
const x = 50 + Math.random() * (width - 100);
const y = 100 + Math.random() * (height / 2);
return { x, y };
};
// 创建烟花粒子
const createParticles = (position?: { x: number; y: number }) => {
const pos = position || getRandomPosition();
const newParticles: Particle[] = [];
for (let i = 0; i < particleCount; i++) {
const id = particleId.current++;
const angle = Math.random() * Math.PI * 2;
const speed = 1 + Math.random() * 3;
const size = 3 + Math.random() * 7;
// 动画值
const translateX = new Animated.Value(0);
const translateY = new Animated.Value(0);
const opacity = new Animated.Value(1);
const scale = new Animated.Value(0.1);
const rotation = new Animated.Value(Math.random() * 360);
// 粒子动画
const moveAnimation = Animated.parallel([
// X轴移动
Animated.timing(translateX, {
toValue: Math.cos(angle) * speed * 100,
duration: 1500 + Math.random() * 1000,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
// Y轴移动添加重力效果
Animated.timing(translateY, {
toValue: Math.sin(angle) * speed * 100 + 50, // 向下弯曲
duration: 1500 + Math.random() * 1000,
easing: Easing.quad,
useNativeDriver: true,
}),
// 淡出
Animated.timing(opacity, {
toValue: 0,
duration: 1500 + Math.random() * 500,
easing: Easing.ease,
useNativeDriver: true,
}),
// 缩放效果
Animated.sequence([
Animated.timing(scale, {
toValue: 1.8,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(scale, {
toValue: 1,
duration: 300,
useNativeDriver: true,
})
]),
// 旋转效果
Animated.timing(rotation, {
toValue: rotation._value + 360,
duration: 2000,
easing: Easing.linear,
useNativeDriver: true,
})
]);
// 创建粒子对象
newParticles.push({
id,
position: pos,
animation: moveAnimation,
color: colors[Math.floor(Math.random() * colors.length)],
size,
translateX,
translateY,
opacity,
scale,
rotation
});
}
// 添加新粒子
setParticles(prev => [...prev, ...newParticles]);
// 启动动画并在结束后移除粒子
newParticles.forEach(particle => {
particle.animation.start(() => {
setParticles(prev => prev.filter(p => p.id !== particle.id));
});
});
};
// 开始烟花效果
const startFireworks = () => {
if (!isPlaying) return;
createParticles();
if (loop) {
timerRef.current = setTimeout(() => {
startFireworks();
}, interval);
}
};
// 停止烟花效果
const stopFireworks = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
// 切换播放状态
const togglePlay = () => {
setIsPlaying(prev => {
const newState = !prev;
if (newState) {
startFireworks();
} else {
stopFireworks();
}
return newState;
});
};
// 初始化
useEffect(() => {
if (autoPlay) {
startFireworks();
}
return () => {
stopFireworks();
};
}, [autoPlay, loop, interval]);
return (
<View style={styles.container}>
{/* 渲染所有粒子 */}
{particles.map((particle) => (
<Animated.View
key={particle.id}
style={[
styles.particle,
{
left: particle.position.x,
top: particle.position.y,
backgroundColor: particle.color,
width: particle.size,
height: particle.size,
borderRadius: particle.size / 2,
opacity: particle.opacity,
transform: [
{ translateX: particle.translateX },
{ translateY: particle.translateY },
{ scale: particle.scale },
{
rotate: particle.rotation.interpolate({
inputRange: [0, 360],
outputRange: ['0deg', '360deg']
})
}
]
}
]}
/>
))}
{/* 控制面板 */}
{/* <View style={styles.controlPanel}>
<Text style={styles.title}></Text>
<TouchableOpacity
style={[styles.button, isPlaying ? styles.pauseButton : styles.playButton]}
onPress={togglePlay}
>
<Text style={styles.buttonText}>
{isPlaying ? '暂停动画' : '播放动画'}
</Text>
</TouchableOpacity>
</View> */}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFB645',
justifyContent: 'flex-end',
alignItems: 'center',
zIndex: 9999,
},
particle: {
position: 'absolute',
},
controlPanel: {
backgroundColor: 'rgba(0, 0, 0, 0.6)',
padding: 20,
borderRadius: 20,
marginBottom: 40,
alignItems: 'center',
width: '90%',
borderWidth: 1,
borderColor: '#7C4DFF',
},
title: {
color: '#FFFFFF',
fontSize: 28,
fontWeight: 'bold',
marginBottom: 20,
textShadowColor: 'rgba(255, 255, 255, 0.75)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
},
button: {
paddingVertical: 12,
paddingHorizontal: 30,
borderRadius: 30,
marginVertical: 8,
width: '100%',
alignItems: 'center',
},
playButton: {
backgroundColor: '#4CAF50',
},
pauseButton: {
backgroundColor: '#FF5252',
},
buttonText: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
});

View File

@ -36,9 +36,24 @@ const Code = ({ setSteps, phone }: LoginProps) => {
const handleCodeChange = (text: string, index: number) => { const handleCodeChange = (text: string, index: number) => {
setError(''); setError('');
const newCode = [...code]; const newCode = [...code];
newCode[index] = text;
setCode(newCode); // Handle pasted code from SMS
focusNext(index, text); if (text.length === 6 && /^\d{6}$/.test(text)) {
const digits = text.split('');
setCode(digits);
refs.current[5]?.focus(); // Focus on the last input after autofill
return;
}
// Handle manual input
if (text.length <= 1) {
newCode[index] = text;
setCode(newCode);
if (text) {
focusNext(index, text);
}
}
}; };
const sendVerificationCode = async () => { const sendVerificationCode = async () => {
try { try {
@ -49,7 +64,7 @@ const Code = ({ setSteps, phone }: LoginProps) => {
}) })
} catch (error) { } catch (error) {
console.error(t("auth.telLogin.sendCodeError", { ns: 'login' }), error); // console.error(t("auth.telLogin.sendCodeError", { ns: 'login' }), error);
} }
} }
@ -75,13 +90,13 @@ const Code = ({ setSteps, phone }: LoginProps) => {
login(res, res.access_token || '') login(res, res.access_token || '')
router.replace('/user-message') router.replace('/user-message')
}).catch((error) => { }).catch((error) => {
console.log(error); // console.log(error);
setError(t("auth.telLogin.codeVaild", { ns: 'login' })); setError(t("auth.telLogin.codeVaild", { ns: 'login' }));
}) })
setIsLoading(false); setIsLoading(false);
} catch (error) { } catch (error) {
setIsLoading(false); setIsLoading(false);
console.error(t("auth.telLogin.codeVaild", { ns: 'login' }), error); // console.error(t("auth.telLogin.codeVaild", { ns: 'login' }), error);
} }
} }
// 60s倒计时 // 60s倒计时
@ -131,6 +146,8 @@ const Code = ({ setSteps, phone }: LoginProps) => {
className="bg-[#FFF8DE] rounded-xl text-textTertiary text-3xl text-center" className="bg-[#FFF8DE] rounded-xl text-textTertiary text-3xl text-center"
keyboardType="number-pad" keyboardType="number-pad"
maxLength={1} maxLength={1}
textContentType="oneTimeCode" // For iOS autofill
autoComplete='sms-otp' // For Android autofill
value={digit} value={digit}
onChangeText={text => handleCodeChange(text, index)} onChangeText={text => handleCodeChange(text, index)}
onKeyPress={({ nativeEvent }) => focusPrevious(index, nativeEvent.key)} onKeyPress={({ nativeEvent }) => focusPrevious(index, nativeEvent.key)}

View File

@ -50,12 +50,12 @@ const ForgetPwd = ({ setIsSignUp, updateUrlParam, setError }: LoginProps) => {
} }
}) })
.then((_) => { .then((_) => {
console.log("Password reset email sent successfully"); // console.log("Password reset email sent successfully");
setIsDisabled(true); setIsDisabled(true);
setCountdown(60); // 开始60秒倒计时 setCountdown(60); // 开始60秒倒计时
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to send reset email:', error); // console.error('Failed to send reset email:', error);
setError(t('auth.forgetPwd.sendEmailError', { ns: 'login' })); setError(t('auth.forgetPwd.sendEmailError', { ns: 'login' }));
}) })
.finally(() => { .finally(() => {

View File

@ -1,4 +1,5 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ActivityIndicator, TextInput, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TextInput, TouchableOpacity, View } from "react-native";
@ -43,11 +44,10 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const userInfo = await fetchApi<User>('/iam/user-info');
login({ ...res, email: res?.account }, res.access_token || ''); login({ ...res, email: res?.account }, res.access_token || '');
router.replace('/user-message');
} catch (error) { } catch (error) {
console.error('Login failed', error); // console.error('Login failed', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -15,7 +15,7 @@ const Phone = ({ setSteps, setPhone, phone }: LoginProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
// 发送验证码
const sendVerificationCode = async () => { const sendVerificationCode = async () => {
if (!/^1[3-9]\d{9}$/.test(phone)) { if (!/^1[3-9]\d{9}$/.test(phone)) {
setError(t("auth.telLogin.phoneInvalid", { ns: 'login' })); setError(t("auth.telLogin.phoneInvalid", { ns: 'login' }));
@ -25,7 +25,6 @@ const Phone = ({ setSteps, setPhone, phone }: LoginProps) => {
try { try {
setIsLoading(true); setIsLoading(true);
// 发送验证码
await fetchApi(`/iam/veritification-code`, { await fetchApi(`/iam/veritification-code`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone: phone }), body: JSON.stringify({ phone: phone }),
@ -35,7 +34,7 @@ const Phone = ({ setSteps, setPhone, phone }: LoginProps) => {
} catch (error) { } catch (error) {
setPhone("") setPhone("")
setIsLoading(false); setIsLoading(false);
console.error(t("auth.telLogin.sendCodeError", { ns: 'login' }), error); // console.error(t("auth.telLogin.sendCodeError", { ns: 'login' }), error);
} }
}; };
@ -81,5 +80,4 @@ const Phone = ({ setSteps, setPhone, phone }: LoginProps) => {
</View> </View>
} }
export default Phone export default Phone

View File

@ -118,7 +118,7 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
} }
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error('Registration failed:', error); // console.error('Registration failed:', error);
// 这里可以添加错误处理逻辑 // 这里可以添加错误处理逻辑
setLoading(false); setLoading(false);
} }

View File

@ -0,0 +1,30 @@
import LottieView from 'lottie-react-native';
import React from 'react';
import { StyleProp, ViewStyle } from 'react-native';
// welcome.native.tsx
export default function NativeLottie(props: { source: string, style?: StyleProp<ViewStyle>, loop?: boolean }) {
const { source, style, loop } = props;
// require() 的参数需要是静态字符串,不能使用变量。要实现动态加载 JSON 文件,使用 switch 或对象映射
const getAnimationSource = () => {
switch (source) {
case 'allDone':
return require('@/assets/json/AllDone.json');
case 'welcome':
return require('@/assets/json/welcome.json');
// 添加其他需要的 JSON 文件
default:
return require('@/assets/json/welcome.json'); // 默认文件
}
};
return (
<LottieView
autoPlay
style={style}
loop={loop}
source={getAnimationSource()}
/>
);
}

View File

@ -0,0 +1,6 @@
// welcome.tsx (Web 版本)
// 在 Web 端不显示任何内容
// 占位符 移动端实际引入文件是 welcome.native.tsx 文件
export default function WebLottie(props: { source: string }) {
return null;
}

View File

@ -1,38 +1,89 @@
import { Steps } from '@/app/(tabs)/user-message';
import DoneSvg from '@/assets/icons/svg/done.svg'; import DoneSvg from '@/assets/icons/svg/done.svg';
import { router } from 'expo-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TouchableOpacity, View } from 'react-native'; import { Platform, TouchableOpacity, View } from 'react-native';
import { ThemedText } from '../ThemedText'; import { ThemedText } from '../ThemedText';
import { Fireworks } from '../firework';
import Lottie from '../lottie/lottie';
interface Props { export default function Done() {
setSteps: (steps: Steps) => void;
}
export default function Done(props: Props) {
const { setSteps } = props
const { t } = useTranslation(); const { t } = useTranslation();
const handleContinue = () => { const handleContinue = () => {
router.replace('/ask')
}; };
return ( return (
<View className="flex-1 bg-bgPrimary absolute top-0 left-0 right-0 bottom-0 h-full"> <View className="flex-1">
<View className="absolute top-[2rem] left-0 right-0 bottom-[10rem] justify-center items-center"> {
<ThemedText className="text-4xl !text-white text-center"> Platform.OS === 'web'
{t('auth.userMessage.allDone', { ns: 'login' })} ?
</ThemedText> <View className="flex-1 bg-bgPrimary absolute top-0 left-0 right-0 bottom-0 h-full">
</View> <View className="absolute top-[2rem] left-0 right-0 bottom-[10rem] justify-center items-center">
<View className='flex-1' /> <ThemedText className="!text-4xl !text-white text-center">
<DoneSvg /> {t('auth.userMessage.allDone', { ns: 'login' })}
{/* Next Button */} </ThemedText>
<View className="w-full mt-8 mb-4 absolute bottom-[0.5rem] p-[1rem]"> </View>
<TouchableOpacity <View className='flex-1' />
className={`w-full bg-buttonFill rounded-full p-4 items-center`} <View className="flex-row justify-end">
onPress={handleContinue} <DoneSvg />
> </View>
<ThemedText className="text-textTertiary text-lg font-semibold"> {/* Next Button */}
{t('auth.userMessage.next', { ns: 'login' })} <View className="absolute bottom-[1rem] left-0 right-0 p-[1rem] z-99">
</ThemedText> <TouchableOpacity
</TouchableOpacity> className={`w-full bg-buttonFill rounded-full p-4 items-center`}
</View> onPress={handleContinue}
>
<ThemedText className="!text-white text-lg font-semibold">
{t('auth.userMessage.next', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
:
<View className="flex-1 bg-transparent">
{/* 文字 */}
<View className="absolute top-0 left-0 right-0 bottom-0 z-30">
<View className="flex-1 justify-center items-center">
<ThemedText className="!text-4xl !text-white text-center">
{t('auth.userMessage.allDone', { ns: 'login' })}
</ThemedText>
</View>
{/* Next Button */}
<View className="absolute bottom-[1rem] left-0 right-0 p-[1rem] z-99">
<TouchableOpacity
className={`w-full bg-buttonFill rounded-full p-4 items-center`}
onPress={handleContinue}
>
<ThemedText className="!text-white text-lg font-semibold">
{t('auth.userMessage.next', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
{/* 背景动画 - 烟花 */}
<View className="absolute top-0 left-0 right-0 bottom-0 z-10">
<Fireworks
autoPlay={true}
loop={false}
interval={1500}
particleCount={90}
/>
</View>
{/* 前景动画 - Lottie */}
<View className="absolute top-0 left-0 right-0 bottom-0 z-20">
<Lottie
source={'allDone'}
style={{
width: "100%",
height: "100%",
backgroundColor: 'transparent'
}}
loop={false}
/>
</View>
</View>
}
</View> </View>
) )
} }

View File

@ -1,162 +1,83 @@
import { Steps } from '@/app/(tabs)/user-message'; import { Steps } from '@/app/(tabs)/user-message';
import AtaverSvg from '@/assets/icons/svg/ataver.svg'; import ChoicePhoto from '@/assets/icons/svg/choicePhoto.svg';
import ChoicePhotoSvg from '@/assets/icons/svg/choicePhoto.svg'; import LookSvg from '@/assets/icons/svg/look.svg';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { useState } from 'react'; import { FileUploadItem } from '@/types/upload';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Image, Modal, SafeAreaView, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native'; import { ActivityIndicator, Image, TouchableOpacity, View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import FilesUploader from '../file-upload/files-uploader';
import FileUploader, { FileStatus } from '../file-upload/file-uploader';
interface Props { interface Props {
setSteps?: (steps: Steps) => void; setSteps?: (steps: Steps) => void;
fileData: FileStatus[]; fileData: FileUploadItem[];
setFileData: (fileData: FileStatus[]) => void; setFileData: (fileData: FileUploadItem[]) => void;
isLoading: boolean; isLoading: boolean;
handleUser: () => void; handleUser: () => void;
avatar: string; avatar: string;
} }
export default function Look({ fileData, setFileData, isLoading, handleUser, avatar }: Props) { export default function Look(props: Props) {
const [isModalVisible, setIsModalVisible] = useState(false); const { fileData, setFileData, isLoading, handleUser, avatar } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View className="flex-1 bg-textPrimary justify-between p-[2rem]"> <View className="flex-1 bg-textPrimary justify-between p-[2rem]">
<View className="flex-1 justify-center items-center"> <View className="flex-1 justify-center items-center">
<View className="w-full items-center"> <ThemedText className="text-4xl font-bold !text-white mb-[2rem]">
<ThemedText className="text-4xl font-bold !text-white mb-[2rem]"> {t('auth.userMessage.look', { ns: 'login' })}
{t('auth.userMessage.look', { ns: 'login' })} </ThemedText>
</ThemedText> <ThemedText className="text-base !text-white/80 text-center mb-[2rem]">
<ThemedText className="text-base !text-white/80 text-center mb-[5rem]"> {t('auth.userMessage.avatarText', { ns: 'login' })}
{t('auth.userMessage.avatarText', { ns: 'login' })} {"\n"}
<br /> {t('auth.userMessage.avatorText2', { ns: 'login' })}
{t('auth.userMessage.avatorText2', { ns: 'login' })} </ThemedText>
</ThemedText> {
fileData[0]?.preview
<View className="rounded-full bg-white/10 items-center justify-center mb-[3rem]"> ?
{ <Image
(() => { className='rounded-full w-[10rem] h-[10rem]'
const imageSource = fileData?.[0]?.thumbnailUrl || avatar; source={{ uri: fileData[0].preview }}
/>
if (!imageSource) { :
return <AtaverSvg />; avatar
} ?
<Image
return ( className='rounded-full w-[10rem] h-[10rem]'
<Image source={{ uri: avatar }}
style={styles.image} />
source={{ uri: imageSource }} :
resizeMode="cover" <LookSvg />
/> }
); <FilesUploader
})() onUploadComplete={(fileData) => {
} setFileData(fileData as FileUploadItem[]);
</View> }}
<TouchableOpacity showPreview={false}
className={`!bg-[#FFF8DE] rounded-2xl p-4 items-center flex flex-row gap-[1rem]`} children={
disabled={isLoading} <View className="w-full rounded-full px-4 py-2 mt-4 items-center bg-inputBackground flex-row flex gap-2">
onPress={() => { setIsModalVisible(true) }} <ChoicePhoto />
> <ThemedText className="text-textTertiary text-lg font-semibold">
<ChoicePhotoSvg /> {t('auth.userMessage.choosePhoto', { ns: 'login' })}
<ThemedText className="text-lg font-semibold "> </ThemedText>
{t('auth.userMessage.choosePhoto', { ns: 'login' })} </View>
</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> </View>
{/* Continue Button */} <View className="w-full">
<View className="p-6 w-full">
<TouchableOpacity <TouchableOpacity
className={`w-full bg-white rounded-full p-4 items-center ${isLoading ? 'opacity-70' : '' className={`w-full bg-white rounded-full p-4 items-center ${isLoading ? 'opacity-70' : ''}`}
}`}
onPress={handleUser} onPress={handleUser}
disabled={isLoading} disabled={isLoading}
> >
<ThemedText className="text-textTertiary text-lg font-semibold"> {isLoading ? (
{t('auth.userMessage.next', { ns: 'login' })} <ActivityIndicator color="#000" />
</ThemedText> ) : (
<ThemedText className="text-textTertiary text-lg font-semibold">
{t('auth.userMessage.next', { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</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',
},
// ... 其他样式保持不变
});

View File

@ -2,25 +2,22 @@ import { Steps } from '@/app/(tabs)/user-message';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Platform, TextInput, TouchableOpacity, View } from 'react-native'; import { ActivityIndicator, KeyboardAvoidingView, Platform, TextInput, TouchableOpacity, View } from 'react-native';
import Toast from 'react-native-toast-message';
interface Props { interface Props {
setSteps: (steps: Steps) => void; setSteps: (steps: Steps) => void;
username: string; username: string;
setUsername: (username: string) => void; setUsername: (username: string) => void;
} }
export default function UserName(props: Props) { export default function UserName(props: Props) {
const { setSteps, username, setUsername } = props const { setSteps, username, setUsername } = props
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const handleUserName = () => { const handleUserName = () => {
if (!username) { if (!username) {
if (Platform.OS === 'web') { setError('Username is required')
Toast.show({
type: 'error',
text1: 'Username is required'
});
}
return; return;
} }
setIsLoading(true) setIsLoading(true)
@ -29,37 +26,45 @@ export default function UserName(props: Props) {
} }
return ( return (
<View className='bg-bgPrimary flex-1 h-full'> <KeyboardAvoidingView
<View className="flex-1" /> behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
{/* Input container fixed at bottom */} style={{ flex: 1 }}
<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]"> <View className='bg-bgPrimary flex-1 h-full'>
<ThemedText className="text-textSecondary font-semibold">{t('auth.userMessage.title', { ns: 'login' })}</ThemedText> <View className="flex-1" />
<View className='w-full'> {/* Input container fixed at bottom */}
<ThemedText className="!text-textPrimary ml-2 mb-2 font-semibold">{t('auth.userMessage.username', { ns: 'login' })}</ThemedText> <View className="w-full bg-white p-4 border-t border-gray-200 rounded-t-3xl">
<TextInput <View className="flex-col items-center justify-center w-full gap-[3rem]">
className="flex-1 bg-inputBackground rounded-2xl px-4 py-3 w-full" <View className='w-full flex flex-row items-center justify-between'>
placeholder={t('auth.userMessage.usernamePlaceholder', { ns: 'login' })} <ThemedText className="text-textSecondary font-semibold">{t('auth.userMessage.title', { ns: 'login' })}</ThemedText>
placeholderTextColor="#9CA3AF" <ThemedText className="text-[#E2793F] font-semibold">{error}</ThemedText>
value={username} </View>
onChangeText={setUsername} <View className='w-full'>
/> <ThemedText className="!text-textPrimary ml-2 mb-2 font-semibold">{t('auth.userMessage.username', { ns: 'login' })}</ThemedText>
<TextInput
className="bg-inputBackground rounded-2xl p-4 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>
<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> </View>
</View> </KeyboardAvoidingView>
) )
} }

View File

@ -0,0 +1,20 @@
export function removeUndefined(obj: any): any {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj
.map(removeUndefined)
.filter(item => item !== undefined);
}
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
const newValue = removeUndefined(value);
if (newValue !== undefined) {
result[key] = newValue;
}
}
return result;
}

View File

@ -35,20 +35,19 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
token = token || ""; token = token || "";
}) })
} }
if (token) { if (token) {
// 验证当前 token 是否有效 // 验证当前 token 是否有效
fetchApi('/user/identity-check', {}, false) fetchApi('/user/identity-check', {}, false)
.catch(async (error) => { .catch(async (error) => {
console.error("JWT validation failed, attempting to refresh token..."); // console.error("JWT validation failed, attempting to refresh token...");
try { try {
// 尝试刷新 token // 尝试刷新 token
await refreshAuthToken("Token expired"); await refreshAuthToken("Token expired");
console.log("Token refreshed successfully"); // console.log("Token refreshed successfully");
// Token 刷新成功,不需要做其他操作 // Token 刷新成功,不需要做其他操作
} catch (refreshError) { } catch (refreshError) {
// 刷新 token 失败,才进行登出操作 // 刷新 token 失败,才进行登出操作
console.error("Token refresh failed, logging out", refreshError); // console.error("Token refresh failed, logging out", refreshError);
logout(); logout();
} }
}); });
@ -57,11 +56,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
try { try {
// 尝试刷新 token // 尝试刷新 token
await refreshAuthToken("Token expired"); await refreshAuthToken("Token expired");
console.log("Token refreshed successfully"); // console.log("Token refreshed successfully");
// Token 刷新成功,不需要做其他操作 // Token 刷新成功,不需要做其他操作
} catch (refreshError) { } catch (refreshError) {
// 刷新 token 失败,才进行登出操作 // 刷新 token 失败,才进行登出操作
console.error("Token refresh failed, logging out", refreshError); // console.error("Token refresh failed, logging out", refreshError);
logout(); logout();
} }
} }

View File

@ -70,6 +70,7 @@ export const refreshAuthToken = async<T>(message: string | null): Promise<User>
// 退出刷新会重新填充数据 // 退出刷新会重新填充数据
let response; let response;
response = await fetch(`${API_ENDPOINT}/v1/iam/access-token-refresh`, { response = await fetch(`${API_ENDPOINT}/v1/iam/access-token-refresh`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
@ -80,6 +81,7 @@ export const refreshAuthToken = async<T>(message: string | null): Promise<User>
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
}); });
const apiResponse: ApiResponse<T> = await response.json(); const apiResponse: ApiResponse<T> = await response.json();
if (apiResponse.code != 0) { if (apiResponse.code != 0) {
throw new Error(message || 'Unknown error'); throw new Error(message || 'Unknown error');
@ -97,7 +99,7 @@ export const refreshAuthToken = async<T>(message: string | null): Promise<User>
return userData; return userData;
} catch (error) { } catch (error) {
console.error(`Error refreshing token:`, error); // console.error(`Error refreshing token:`, error);
throw error; throw error;
} }
} }
@ -106,7 +108,7 @@ export const refreshAuthToken = async<T>(message: string | null): Promise<User>
const handleApiError = (error: unknown, needToast = true, defaultMessage = 'Unknown error') => { const handleApiError = (error: unknown, needToast = true, defaultMessage = 'Unknown error') => {
const message = error instanceof Error ? error.message : defaultMessage; const message = error instanceof Error ? error.message : defaultMessage;
if (needToast) { if (needToast) {
console.log(message); // console.log(message);
} }
throw new Error(message); throw new Error(message);
}; };
@ -124,9 +126,7 @@ export const fetchApi = async <T>(
if (Platform.OS === 'web') { if (Platform.OS === 'web') {
token = localStorage.getItem('token') || ""; token = localStorage.getItem('token') || "";
} else { } else {
await SecureStore.getItemAsync('token').then((token: string) => { token = await SecureStore.getItemAsync('token') || "";
token = token || "";
})
} }
const headers = new Headers(options.headers); const headers = new Headers(options.headers);

29
nginx.conf Normal file
View File

@ -0,0 +1,29 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

1217
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,38 +20,49 @@
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@types/react-redux": "^7.1.34", "@types/react-redux": "^7.1.34",
"expo": "~53.0.12", "expo": "~53.0.12",
"expo-audio": "~0.4.7",
"expo-blur": "~14.1.5", "expo-blur": "~14.1.5",
"expo-constants": "~17.1.6", "expo-constants": "~17.1.6",
"expo-dev-client": "~5.2.1", "expo-dev-client": "~5.2.1",
"expo-file-system": "~18.1.10",
"expo-font": "~13.3.1", "expo-font": "~13.3.1",
"expo-haptics": "~14.1.4", "expo-haptics": "~14.1.4",
"expo-image": "~2.3.0", "expo-image": "~2.3.0",
"expo-image-manipulator": "~13.1.7",
"expo-image-picker": "~16.1.4",
"expo-linking": "~7.1.5", "expo-linking": "~7.1.5",
"expo-localization": "^16.1.5", "expo-localization": "^16.1.5",
"expo-location": "~18.1.5",
"expo-media-library": "~17.1.7",
"expo-router": "~5.1.0", "expo-router": "~5.1.0",
"expo-secure-store": "~14.2.3", "expo-secure-store": "~14.2.3",
"expo-splash-screen": "~0.30.9", "expo-splash-screen": "~0.30.9",
"expo-status-bar": "~2.2.3", "expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5", "expo-symbols": "~0.4.5",
"expo-system-ui": "~5.0.9", "expo-system-ui": "~5.0.9",
"expo-video": "~2.2.2",
"expo-video-thumbnails": "~9.1.3",
"expo-web-browser": "~14.2.0", "expo-web-browser": "~14.2.0",
"i18next": "^25.2.1", "i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"lottie-react-native": "7.2.2",
"nativewind": "^4.1.23", "nativewind": "^4.1.23",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-i18next": "^15.5.3", "react-i18next": "^15.5.3",
"react-native": "0.79.4", "react-native": "0.79.4",
"react-native-gesture-handler": "~2.24.0", "react-native-gesture-handler": "~2.24.0",
"react-native-progress": "^5.0.1",
"react-native-reanimated": "~3.17.4", "react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.11.1",
"react-native-svg": "15.11.2",
"react-native-toast-message": "^2.3.0", "react-native-toast-message": "^2.3.0",
"react-native-uuid": "^2.0.3",
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5",
"react-redux": "^9.2.0", "react-redux": "^9.2.0"
"react-native-svg": "15.11.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",

35
scripts/dev_deploy.sh Normal file
View File

@ -0,0 +1,35 @@
#!/bin/bash
# dev_deploy.sh
set -x
# 用法: ./dev_deploy.sh <分支名> <镜像名>
BRANCH_NAME=$1
IMAGE_NAME=$2
if [ -z "$BRANCH_NAME" ] || [ -z "$IMAGE_NAME" ]; then
echo "用法: $0 <分支名> <镜像名>"
exit 1
fi
# 分支名到端口映射
declare -A PORT_MAP
PORT_MAP[v0.4.0_front]="10280:80"
PORT_MAP[main]="80:80"
PORTS=${PORT_MAP[$BRANCH_NAME]}
if [ -z "$PORTS" ]; then
echo "未知分支: $BRANCH_NAME"
exit 1
fi
CONTAINER_NAME="memowake-front-$BRANCH_NAME"
# 检查容器是否存在,如果存在则停止并删除
if docker ps -a | grep -q $CONTAINER_NAME; then
docker stop $CONTAINER_NAME
docker rm $CONTAINER_NAME
fi
# 运行新容器
docker run --name $CONTAINER_NAME -p $PORTS -d $IMAGE_NAME

View File

@ -87,10 +87,9 @@ const moveDirectories = async (userInput) => {
console.log("\n✅ Project reset complete. Next steps:"); console.log("\n✅ Project reset complete. Next steps:");
console.log( console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${ `1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${userInput === "y"
userInput === "y" ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.` : ""
: ""
}` }`
); );
} catch (error) { } catch (error) {

View File

@ -17,7 +17,8 @@ module.exports = {
textSecondary: '#4C320C', textSecondary: '#4C320C',
inputBackground: '#FFF8DE', inputBackground: '#FFF8DE',
textTertiary: '#4C320C', textTertiary: '#4C320C',
buttonFill: '#E2793F' buttonFill: '#E2793F',
aiBubble: '#FFF8DE',
}, },
}, },
}, },

58
types/ask.ts Normal file
View File

@ -0,0 +1,58 @@
import { MaterialItem } from "./personal-info";
interface FileInfo {
id: string;
file_name: string;
url: string;
metadata: Record<string, any>;
}
interface VideoClip {
clip_id: number;
start_time: number;
end_time: number;
description: string;
tags: string[];
composition: string;
shot_size: string;
point_of_view: string;
created_at: string;
}
interface VideoInfo {
id: string;
name: string | null;
description: string | null;
file_info: FileInfo;
preview_file_info: FileInfo;
user_id: string;
created_at: string;
updated_at: string;
}
export interface Video {
video: VideoInfo;
video_clips: VideoClip[];
}
export interface Content {
text: string;
image_material_infos?: MaterialItem[];
video_material_infos?: Video[];
}
export interface Message {
content: Content;
role: 'User' | 'Assistant'; // 使用联合类型限制 role 的值
timestamp: string;
askAgain?: Array<{
id: string;
text: string;
}>;
}
export interface Chat {
created_at: string;
session_id: string;
title: string;
latest_message: Message;
}

View File

@ -1,3 +1,8 @@
import { FileStatus } from "@/components/file-upload/file-uploader";
import { MediaType } from "expo-image-picker";
import { ReactNode } from "react";
import { StyleProp, ViewStyle } from "react-native";
export interface MaterialFile { export interface MaterialFile {
id: string; id: string;
file_name: string; file_name: string;
@ -44,7 +49,8 @@ export type UploadStatus = 'Pending' | 'Uploading' | 'Completed' | 'Failed';
export type DeletionStatus = 'Active' | 'PendingDeletion' | 'Deleted'; export type DeletionStatus = 'Active' | 'PendingDeletion' | 'Deleted';
export interface ConfirmUpload { export interface ConfirmUpload {
file_id: number; file_id: string;
upload_url: string
name: string; name: string;
size: Size; size: Size;
content_type: ContentType; content_type: ContentType;
@ -56,3 +62,144 @@ export interface ConfirmUpload {
deletion_status: DeletionStatus; deletion_status: DeletionStatus;
metadata: Metadata; metadata: Metadata;
} }
// 定义 EXIF 数据类型
export type ExifData = {
GPSLatitude?: number | undefined;
GPSLongitude?: number | undefined;
GPSAltitude?: number | undefined;
DateTimeOriginal?: string | undefined;
Make?: string | undefined;
Model?: string | undefined;
ExposureTime?: number | undefined;
FNumber?: number | undefined;
ISOSpeedRatings?: number | undefined;
FocalLength?: number | undefined;
[key: string]: any;
};
// 默认的 EXIF 数据结构
export const defaultExifData: ExifData = {
GPSLatitude: undefined,
GPSLongitude: undefined,
GPSAltitude: undefined,
DateTimeOriginal: undefined,
Make: undefined,
Model: undefined,
ExposureTime: undefined,
FNumber: undefined,
ISOSpeedRatings: undefined,
FocalLength: undefined,
};
// 压缩图片可配置参数
export interface ImagesuploaderProps {
children?: ReactNode;
style?: StyleProp<ViewStyle>;
onPickImage?: (file: File, exifData: ExifData) => void;
/** 压缩质量0-1 之间的数字,默认为 0.8 */
compressQuality?: number;
/** 最大宽度,图片会被等比例缩放 */
maxWidth?: number;
/** 最大高度,图片会被等比例缩放 */
maxHeight?: number;
/** 是否保留 EXIF 数据,默认为 true */
preserveExif?: boolean;
/** 是否上传原图,默认为 false */
uploadOriginal?: boolean;
/** 上传完成回调 */
onUploadComplete?: UploadCompleteCallback;
/** 进度 */
onProgress?: (progress: FileStatus) => void;
/** 多选单选 默认单选*/
multipleChoice?: boolean;
/** 文件类型 默认图片*/
fileType?: MediaType[];
/** 是否展示预览 默认展示*/
showPreview?: boolean;
}
// 定义上传结果类型
export interface UploadResult {
originalUrl?: string;
compressedUrl: string;
file: File;
exifData: ExifData;
originalFile: ConfirmUpload;
compressedFile: ConfirmUpload;
thumbnail: string;
thumbnailFile: File;
}
// 定义上传完成回调类型
export type UploadCompleteCallback = (result: FileUploadItem[]) => void;
// 单张图片上传完成回调类型
export type UploadSingleCompleteCallback = (result: FileUploadItem) => void;
// 定义上传 URL 响应类型
export interface UploadUrlResponse {
expires_in: number;
file_id: string;
file_path: string;
upload_url: string;
}
interface FileSize {
value: number;
unit: string;
}
interface FileMetadata {
originalName: string;
type: string;
isCompressed: string;
fileType: string;
}
interface FileInfo {
file_id: number;
name: string;
size: FileSize;
content_type: ContentType;
upload_time: string;
storage_medium: string;
file_path: FilePath;
uploader_id: number;
upload_status: string;
deletion_status: string;
metadata: FileMetadata;
}
export interface FileUploadItem {
id: string;
uri: string;
name: string;
progress: number;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
previewUrl: string;
file?: File;
type: 'image' | 'video';
thumbnail?: string; // 缩略图URL
thumbnailFile?: File; // 缩略图文件对象
originalFile?: FileInfo
}
// 压缩图片可配置参数
export interface ImagesPickerProps {
children?: ReactNode;
style?: StyleProp<ViewStyle>;
onPickImage?: (file: File, exifData: ExifData) => void;
/** 压缩质量0-1 之间的数字,默认为 0.8 */
compressQuality?: number;
/** 最大宽度,图片会被等比例缩放 */
maxWidth?: number;
/** 最大高度,图片会被等比例缩放 */
maxHeight?: number;
/** 是否保留 EXIF 数据,默认为 true */
preserveExif?: boolean;
/** 是否上传原图,默认为 false */
uploadOriginal?: boolean;
/** 上传完成回调 */
onUploadComplete?: UploadSingleCompleteCallback;
/** 进度 */
onProgress?: (progress: FileStatus) => void;
}