feat: 键盘
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.vscode
|
||||
dist
|
||||
37
.gitea/workflows/dev.yaml
Normal 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
@ -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;"]
|
||||
37
app.json
@ -9,15 +9,31 @@
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"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": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "",
|
||||
"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,
|
||||
"package": "com.jinyaqiu.memowake"
|
||||
"package": "com.memowake.app"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@ -28,20 +44,17 @@
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-font",
|
||||
"expo-audio",
|
||||
{
|
||||
"fonts": [
|
||||
"./assets/fonts/[font-file.ttf]"
|
||||
]
|
||||
"microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
"expo-media-library",
|
||||
{
|
||||
"image": "",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"photosPermission": "Allow $(PRODUCT_NAME) to access your photos.",
|
||||
"savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
]
|
||||
],
|
||||
@ -51,7 +64,7 @@
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "e4634b8b-fdb8-4e6f-ac7e-7032e6898612"
|
||||
"projectId": "04721dd4-6b15-495a-b9ec-98187c613172"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,7 +24,8 @@ export default function TabLayout() {
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{/* 落地页 */}
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
@ -79,6 +80,26 @@ export default function TabLayout() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
211
app/(tabs)/ask.tsx
Normal 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, // 添加一点右边距
|
||||
},
|
||||
});
|
||||
@ -1,20 +1,23 @@
|
||||
import MemoChat from '@/assets/icons/svg/memo-chat.svg';
|
||||
import MemoIP from '@/assets/icons/svg/memo-ip.svg';
|
||||
import IP from '@/assets/icons/svg/ip.svg';
|
||||
import Lottie from '@/components/lottie/lottie';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Platform, Text, TouchableOpacity, View } from 'react-native';
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
let token;
|
||||
|
||||
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">
|
||||
<Text className="text-white text-3xl font-bold mb-3 text-left">
|
||||
{t('auth.welcomeAwaken.awaken', { ns: 'login' })}
|
||||
<br />
|
||||
{"\n"}
|
||||
{t('auth.welcomeAwaken.your', { ns: 'login' })}
|
||||
<br />
|
||||
{"\n"}
|
||||
{t('auth.welcomeAwaken.pm', { ns: 'login' })}
|
||||
</Text>
|
||||
<Text className="text-white/85 text-base text-left">
|
||||
@ -23,35 +26,34 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
|
||||
{/* Memo 形象区域 */}
|
||||
<View className="items-center w-full relative">
|
||||
{/* 气泡对话框 */}
|
||||
<View className="absolute -top-[7rem] right-[1rem] z-10">
|
||||
<View className="relative top-[12rem] -right-[3rem] z-10 w-3 h-3 bg-white rounded-full" />
|
||||
<View className="relative top-[9.5rem] -right-[3.5rem] z-10 w-4 h-4 bg-white rounded-full" />
|
||||
<Text className="text-[#AC7E35] font-bold text-lg text-center leading-6 relative top-[4rem] left-0">
|
||||
{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>
|
||||
{/* 如果是web端,使用静态ip形象,否则使用lottie */}
|
||||
{Platform.OS === 'web' ? <IP /> : <Lottie source={'welcome'} style={{ width: 200, height: 200 }} />}
|
||||
|
||||
{/* 介绍文本 */}
|
||||
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
|
||||
{t('auth.welcomeAwaken.gallery', { ns: 'login' })}
|
||||
<br />
|
||||
{"\n"}
|
||||
{t('auth.welcomeAwaken.back', { ns: 'login' })}
|
||||
</Text>
|
||||
|
||||
{/* 唤醒按钮 */}
|
||||
<TouchableOpacity
|
||||
className="bg-white rounded-full px-10 py-4 shadow-[0_2px_4px_rgba(0,0,0,0.1)] w-full items-center"
|
||||
onPress={() => router.push('/login')}
|
||||
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}
|
||||
>
|
||||
<Text className="text-[#4C320C] font-bold text-lg">
|
||||
|
||||
@ -2,14 +2,14 @@ import Handers from '@/assets/icons/svg/handers.svg';
|
||||
import LoginIP1 from '@/assets/icons/svg/loginIp1.svg';
|
||||
import LoginIP2 from '@/assets/icons/svg/loginIp2.svg';
|
||||
import ForgetPwd from '@/components/login/forgetPwd';
|
||||
import PhoneLogin from '@/components/login/phoneLogin';
|
||||
import Login from '@/components/login/login';
|
||||
import SignUp from '@/components/login/signUp';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LayoutChangeEvent, TouchableOpacity, View, ViewStyle, useWindowDimensions } from 'react-native';
|
||||
import { Keyboard, KeyboardAvoidingView, LayoutChangeEvent, Platform, ScrollView, StatusBar, TouchableOpacity, View, ViewStyle, useWindowDimensions } from 'react-native';
|
||||
|
||||
const LoginScreen = () => {
|
||||
const router = useRouter();
|
||||
@ -18,31 +18,64 @@ const LoginScreen = () => {
|
||||
const [error, setError] = useState<string>('123');
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
// 密码可视
|
||||
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 { height } = event.nativeEvent.layout;
|
||||
setContainerHeight(height);
|
||||
};
|
||||
|
||||
// 更新URL参数而不刷新页面
|
||||
const updateUrlParam = (key: string, value: string) => {
|
||||
router.setParams({ [key]: value });
|
||||
}
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
setError('123')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -statusBarHeight}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
bounces={false}
|
||||
>
|
||||
<ThemedView className="flex-1 bg-bgPrimary justify-end">
|
||||
<View className="flex-1">
|
||||
<View
|
||||
className="absolute left-1/2 z-10"
|
||||
style={{
|
||||
top: containerHeight > 0 ? windowHeight - containerHeight - 210 : 0,
|
||||
transform: [{ translateX: -200 }]
|
||||
top: containerHeight > 0 ? windowHeight - containerHeight - 210 + statusBarHeight : 0,
|
||||
transform: [{ translateX: -200 }, { translateY: keyboardOffset > 0 ? -keyboardOffset + statusBarHeight : -keyboardOffset }]
|
||||
}}
|
||||
>
|
||||
{
|
||||
@ -56,7 +89,8 @@ const LoginScreen = () => {
|
||||
<View
|
||||
className="absolute left-1/2 z-[1000] -translate-x-[39.5px] -translate-y-[4px]"
|
||||
style={{
|
||||
top: containerHeight > 0 ? windowHeight - containerHeight - 1 : 0
|
||||
top: containerHeight > 0 ? windowHeight - containerHeight - 1 + statusBarHeight : 0,
|
||||
transform: [{ translateX: -39.5 }, { translateY: keyboardOffset > 0 ? -4 - keyboardOffset + statusBarHeight : -4 - keyboardOffset }]
|
||||
}}
|
||||
>
|
||||
<Handers />
|
||||
@ -101,7 +135,11 @@ const LoginScreen = () => {
|
||||
/>
|
||||
),
|
||||
login: (
|
||||
<PhoneLogin />
|
||||
<Login
|
||||
{...commonProps}
|
||||
setShowPassword={setShowPassword}
|
||||
showPassword={showPassword}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
@ -130,6 +168,8 @@ const LoginScreen = () => {
|
||||
}
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
300
app/(tabs)/memo-list.tsx
Normal 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;
|
||||
@ -1,22 +1,23 @@
|
||||
import { FileStatus } from '@/components/file-upload/file-uploader';
|
||||
import Choice from '@/components/user-message.tsx/choice';
|
||||
import Done from '@/components/user-message.tsx/done';
|
||||
import Look from '@/components/user-message.tsx/look';
|
||||
import UserName from '@/components/user-message.tsx/userName';
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { FileUploadItem } from '@/types/upload';
|
||||
import { User } from '@/types/user';
|
||||
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 default function UserMessage() {
|
||||
// 步骤
|
||||
const [steps, setSteps] = useState<Steps>("userName")
|
||||
const [username, setUsername] = useState('')
|
||||
const [avatar, setAvatar] = useState('')
|
||||
const [fileData, setFileData] = useState<FileStatus[]>([])
|
||||
const [fileData, setFileData] = useState<FileUploadItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [userInfo, setUserInfo] = useState<User | null>(null);
|
||||
const statusBarHeight = StatusBar?.currentHeight ?? 0;
|
||||
|
||||
// 获取用户信息
|
||||
const getUserInfo = async () => {
|
||||
const res = await fetchApi<User>("/iam/user-info");
|
||||
@ -31,11 +32,10 @@ export default function UserMessage() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
avatar_file_id: fileData?.[0]?.id
|
||||
avatar_file_id: fileData?.[0]?.originalFile?.file_id
|
||||
})
|
||||
}).then(() => {
|
||||
setIsLoading(false);
|
||||
getUserInfo();
|
||||
setSteps('done');
|
||||
}).catch(() => {
|
||||
setIsLoading(false);
|
||||
@ -46,8 +46,19 @@ export default function UserMessage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View className="h-screen" key={steps}>
|
||||
<View className="h-screen" key={steps}>
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : -statusBarHeight}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
bounces={false}
|
||||
>
|
||||
<View className="h-full" key={steps}>
|
||||
{(() => {
|
||||
const components = {
|
||||
userName: (
|
||||
@ -68,12 +79,13 @@ export default function UserMessage() {
|
||||
/>
|
||||
),
|
||||
choice: <Choice setSteps={setSteps} />,
|
||||
done: <Done setSteps={setSteps} />
|
||||
done: <Done />
|
||||
};
|
||||
|
||||
return components[steps as keyof typeof components] || null;
|
||||
})()}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 10C1 14.9706 5.02944 19 10 19C14.9706 19 19 14.9706 19 10C19 5.02944 14.9706 1 10 1C5.02944 1 1 5.02944 1 10Z" fill="white" />
|
||||
<path d="M15.2166 17.3323C13.9349 15.9008 12.0727 15 10 15C7.92734 15 6.06492 15.9008 4.7832 17.3323M10 19C5.02944 19 1 14.9706 1 10C1 5.02944 5.02944 1 10 1C14.9706 1 19 5.02944 19 10C19 14.9706 14.9706 19 10 19ZM10 12C8.34315 12 7 10.6569 7 9C7 7.34315 8.34315 6 10 6C11.6569 6 13 7.34315 13 9C13 10.6569 11.6569 12 10 12Z" stroke="#4C320C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<svg 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>
|
||||
|
Before Width: | Height: | Size: 659 B After Width: | Height: | Size: 565 B |
41
assets/icons/svg/chat.svg
Normal 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
@ -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 |
7
assets/icons/svg/look.svg
Normal 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 |
44
assets/icons/svg/memoListAvatar.svg
Normal 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 |
4
assets/icons/svg/more.svg
Normal 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 |
17
assets/icons/svg/navbar.svg
Normal 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 |
3
assets/icons/svg/returnArrow.svg
Normal 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 |
3
assets/icons/svg/vioce.svg
Normal 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 |
3
assets/icons/svg/voiceDelete.svg
Normal 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 |
3
assets/icons/svg/voiceSend.svg
Normal 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
2090
assets/json/welcome.json
Normal file
@ -7,6 +7,7 @@ module.exports = function (api) {
|
||||
],
|
||||
plugins: [
|
||||
'expo-router/babel',
|
||||
'react-native-reanimated/plugin',
|
||||
],
|
||||
};
|
||||
};
|
||||
279
components/ask/aiChat.tsx
Normal 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
@ -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
@ -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>
|
||||
);
|
||||
}
|
||||
49
components/ask/typewriterText.tsx
Normal 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
@ -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
@ -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, // 添加一点右边距
|
||||
},
|
||||
});
|
||||
@ -1,7 +1,7 @@
|
||||
import { fetchApi } from "@/lib/server-api-util";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import uuid from 'react-native-uuid';
|
||||
|
||||
// 导入子组件
|
||||
import { ConfirmUpload } from "@/types/upload";
|
||||
@ -219,7 +219,7 @@ export default function FileUploader({
|
||||
reject(new Error('Failed to create blob from canvas'));
|
||||
return;
|
||||
}
|
||||
let file_name = uuidv4() + ".png"
|
||||
let file_name = uuid.v4() + ".png"
|
||||
const compressedFile = new File([blob], file_name, {
|
||||
type: outputType,
|
||||
lastModified: Date.now()
|
||||
|
||||
566
components/file-upload/files-uploader.tsx
Normal 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;
|
||||
339
components/file-upload/images-picker.tsx
Normal 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;
|
||||
117
components/file-upload/preview.tsx
Normal 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;
|
||||
@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
|
||||
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 { FileStatus } from './file-uploader';
|
||||
|
||||
interface SingleFileUploaderProps {
|
||||
file: FileStatus;
|
||||
|
||||
39
components/file-upload/utils/video-thumbnail.ts
Normal 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
@ -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',
|
||||
},
|
||||
});
|
||||
@ -36,9 +36,24 @@ const Code = ({ setSteps, phone }: LoginProps) => {
|
||||
const handleCodeChange = (text: string, index: number) => {
|
||||
setError('');
|
||||
const newCode = [...code];
|
||||
|
||||
// Handle pasted code from SMS
|
||||
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 () => {
|
||||
try {
|
||||
@ -49,7 +64,7 @@ const Code = ({ setSteps, phone }: LoginProps) => {
|
||||
})
|
||||
|
||||
} 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 || '')
|
||||
router.replace('/user-message')
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
// console.log(error);
|
||||
setError(t("auth.telLogin.codeVaild", { ns: 'login' }));
|
||||
})
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
console.error(t("auth.telLogin.codeVaild", { ns: 'login' }), error);
|
||||
// console.error(t("auth.telLogin.codeVaild", { ns: 'login' }), error);
|
||||
}
|
||||
}
|
||||
// 60s倒计时
|
||||
@ -131,6 +146,8 @@ const Code = ({ setSteps, phone }: LoginProps) => {
|
||||
className="bg-[#FFF8DE] rounded-xl text-textTertiary text-3xl text-center"
|
||||
keyboardType="number-pad"
|
||||
maxLength={1}
|
||||
textContentType="oneTimeCode" // For iOS autofill
|
||||
autoComplete='sms-otp' // For Android autofill
|
||||
value={digit}
|
||||
onChangeText={text => handleCodeChange(text, index)}
|
||||
onKeyPress={({ nativeEvent }) => focusPrevious(index, nativeEvent.key)}
|
||||
|
||||
@ -50,12 +50,12 @@ const ForgetPwd = ({ setIsSignUp, updateUrlParam, setError }: LoginProps) => {
|
||||
}
|
||||
})
|
||||
.then((_) => {
|
||||
console.log("Password reset email sent successfully");
|
||||
// console.log("Password reset email sent successfully");
|
||||
setIsDisabled(true);
|
||||
setCountdown(60); // 开始60秒倒计时
|
||||
})
|
||||
.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' }));
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TextInput, TouchableOpacity, View } from "react-native";
|
||||
@ -43,11 +44,10 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const userInfo = await fetchApi<User>('/iam/user-info');
|
||||
login({ ...res, email: res?.account }, res.access_token || '');
|
||||
|
||||
router.replace('/user-message');
|
||||
} catch (error) {
|
||||
console.error('Login failed', error);
|
||||
// console.error('Login failed', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ const Phone = ({ setSteps, setPhone, phone }: LoginProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
// 发送验证码
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
setError(t("auth.telLogin.phoneInvalid", { ns: 'login' }));
|
||||
@ -25,7 +25,6 @@ const Phone = ({ setSteps, setPhone, phone }: LoginProps) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 发送验证码
|
||||
await fetchApi(`/iam/veritification-code`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: phone }),
|
||||
@ -35,7 +34,7 @@ const Phone = ({ setSteps, setPhone, phone }: LoginProps) => {
|
||||
} catch (error) {
|
||||
setPhone("")
|
||||
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>
|
||||
}
|
||||
|
||||
|
||||
export default Phone
|
||||
@ -118,7 +118,7 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
// console.error('Registration failed:', error);
|
||||
// 这里可以添加错误处理逻辑
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
30
components/lottie/lottie.native.tsx
Normal 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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
6
components/lottie/lottie.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
// welcome.tsx (Web 版本)
|
||||
// 在 Web 端不显示任何内容
|
||||
// 占位符 移动端实际引入文件是 welcome.native.tsx 文件
|
||||
export default function WebLottie(props: { source: string }) {
|
||||
return null;
|
||||
}
|
||||
@ -1,38 +1,89 @@
|
||||
import { Steps } from '@/app/(tabs)/user-message';
|
||||
import DoneSvg from '@/assets/icons/svg/done.svg';
|
||||
import { router } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TouchableOpacity, View } from 'react-native';
|
||||
import { Platform, TouchableOpacity, View } from 'react-native';
|
||||
import { ThemedText } from '../ThemedText';
|
||||
import { Fireworks } from '../firework';
|
||||
import Lottie from '../lottie/lottie';
|
||||
|
||||
interface Props {
|
||||
setSteps: (steps: Steps) => void;
|
||||
}
|
||||
export default function Done(props: Props) {
|
||||
const { setSteps } = props
|
||||
export default function Done() {
|
||||
const { t } = useTranslation();
|
||||
const handleContinue = () => {
|
||||
|
||||
router.replace('/ask')
|
||||
};
|
||||
return (
|
||||
<View className="flex-1">
|
||||
{
|
||||
Platform.OS === 'web'
|
||||
?
|
||||
<View className="flex-1 bg-bgPrimary absolute top-0 left-0 right-0 bottom-0 h-full">
|
||||
<View className="absolute top-[2rem] left-0 right-0 bottom-[10rem] justify-center items-center">
|
||||
<ThemedText className="text-4xl !text-white text-center">
|
||||
<ThemedText className="!text-4xl !text-white text-center">
|
||||
{t('auth.userMessage.allDone', { ns: 'login' })}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View className='flex-1' />
|
||||
<View className="flex-row justify-end">
|
||||
<DoneSvg />
|
||||
</View>
|
||||
{/* Next Button */}
|
||||
<View className="w-full mt-8 mb-4 absolute bottom-[0.5rem] p-[1rem]">
|
||||
<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-textTertiary text-lg font-semibold">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,162 +1,83 @@
|
||||
import { Steps } from '@/app/(tabs)/user-message';
|
||||
import AtaverSvg from '@/assets/icons/svg/ataver.svg';
|
||||
import ChoicePhotoSvg from '@/assets/icons/svg/choicePhoto.svg';
|
||||
import ChoicePhoto from '@/assets/icons/svg/choicePhoto.svg';
|
||||
import LookSvg from '@/assets/icons/svg/look.svg';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useState } from 'react';
|
||||
import { FileUploadItem } from '@/types/upload';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image, Modal, SafeAreaView, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import FileUploader, { FileStatus } from '../file-upload/file-uploader';
|
||||
import { ActivityIndicator, Image, TouchableOpacity, View } from 'react-native';
|
||||
import FilesUploader from '../file-upload/files-uploader';
|
||||
|
||||
interface Props {
|
||||
setSteps?: (steps: Steps) => void;
|
||||
fileData: FileStatus[];
|
||||
setFileData: (fileData: FileStatus[]) => void;
|
||||
fileData: FileUploadItem[];
|
||||
setFileData: (fileData: FileUploadItem[]) => void;
|
||||
isLoading: boolean;
|
||||
handleUser: () => void;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export default function Look({ fileData, setFileData, isLoading, handleUser, avatar }: Props) {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
export default function Look(props: Props) {
|
||||
const { fileData, setFileData, isLoading, handleUser, avatar } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-textPrimary justify-between p-[2rem]">
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<View className="w-full items-center">
|
||||
<ThemedText className="text-4xl font-bold !text-white mb-[2rem]">
|
||||
{t('auth.userMessage.look', { ns: 'login' })}
|
||||
</ThemedText>
|
||||
<ThemedText className="text-base !text-white/80 text-center mb-[5rem]">
|
||||
<ThemedText className="text-base !text-white/80 text-center mb-[2rem]">
|
||||
{t('auth.userMessage.avatarText', { ns: 'login' })}
|
||||
<br />
|
||||
{"\n"}
|
||||
{t('auth.userMessage.avatorText2', { ns: 'login' })}
|
||||
</ThemedText>
|
||||
|
||||
<View className="rounded-full bg-white/10 items-center justify-center mb-[3rem]">
|
||||
{
|
||||
(() => {
|
||||
const imageSource = fileData?.[0]?.thumbnailUrl || avatar;
|
||||
|
||||
if (!imageSource) {
|
||||
return <AtaverSvg />;
|
||||
}
|
||||
|
||||
return (
|
||||
fileData[0]?.preview
|
||||
?
|
||||
<Image
|
||||
style={styles.image}
|
||||
source={{ uri: imageSource }}
|
||||
resizeMode="cover"
|
||||
className='rounded-full w-[10rem] h-[10rem]'
|
||||
source={{ uri: fileData[0].preview }}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
:
|
||||
avatar
|
||||
?
|
||||
<Image
|
||||
className='rounded-full w-[10rem] h-[10rem]'
|
||||
source={{ uri: avatar }}
|
||||
/>
|
||||
:
|
||||
<LookSvg />
|
||||
}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className={`!bg-[#FFF8DE] rounded-2xl p-4 items-center flex flex-row gap-[1rem]`}
|
||||
disabled={isLoading}
|
||||
onPress={() => { setIsModalVisible(true) }}
|
||||
>
|
||||
<ChoicePhotoSvg />
|
||||
<ThemedText className="text-lg font-semibold ">
|
||||
<FilesUploader
|
||||
onUploadComplete={(fileData) => {
|
||||
setFileData(fileData as FileUploadItem[]);
|
||||
}}
|
||||
showPreview={false}
|
||||
children={
|
||||
<View className="w-full rounded-full px-4 py-2 mt-4 items-center bg-inputBackground flex-row flex gap-2">
|
||||
<ChoicePhoto />
|
||||
<ThemedText className="text-textTertiary text-lg font-semibold">
|
||||
{t('auth.userMessage.choosePhoto', { ns: 'login' })}
|
||||
</ThemedText>
|
||||
|
||||
{/* 上传弹窗 */}
|
||||
<SafeAreaProvider>
|
||||
<SafeAreaView style={styles.centeredView}>
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
visible={isModalVisible}
|
||||
onRequestClose={() => {
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<TouchableWithoutFeedback onPress={() => setIsModalVisible(false)}>
|
||||
<View style={styles.modalOverlayTouchable} />
|
||||
</TouchableWithoutFeedback>
|
||||
<View style={styles.modalContent}>
|
||||
<ThemedText style={styles.modalTitle}>{t('auth.userMessage.choosePhoto', { ns: 'login' })}</ThemedText>
|
||||
<FileUploader
|
||||
allowMultiple={false}
|
||||
maxFiles={1}
|
||||
onFilesUploaded={(file: FileStatus[]) => {
|
||||
setFileData(file);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
allowedFileTypes={["image/png", "image/jpeg", "image/webp"]}
|
||||
maxFileSize={1024 * 1024 * 10}
|
||||
className="w-full"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
</SafeAreaProvider>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
} />
|
||||
</View>
|
||||
|
||||
{/* Continue Button */}
|
||||
<View className="p-6 w-full">
|
||||
<View className="w-full">
|
||||
<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}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#000" />
|
||||
) : (
|
||||
<ThemedText className="text-textTertiary text-lg font-semibold">
|
||||
{t('auth.userMessage.next', { ns: 'login' })}
|
||||
</ThemedText>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
image: {
|
||||
width: 215,
|
||||
height: 215,
|
||||
borderRadius: 107.5,
|
||||
},
|
||||
centeredView: {
|
||||
flex: 1,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
modalOverlayTouchable: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
modalContent: {
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 15,
|
||||
textAlign: 'center',
|
||||
},
|
||||
// ... 其他样式保持不变
|
||||
});
|
||||
@ -2,25 +2,22 @@ import { Steps } from '@/app/(tabs)/user-message';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, Platform, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import { ActivityIndicator, KeyboardAvoidingView, Platform, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface Props {
|
||||
setSteps: (steps: Steps) => void;
|
||||
username: string;
|
||||
setUsername: (username: string) => void;
|
||||
}
|
||||
|
||||
export default function UserName(props: Props) {
|
||||
const { setSteps, username, setUsername } = props
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const handleUserName = () => {
|
||||
if (!username) {
|
||||
if (Platform.OS === 'web') {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: 'Username is required'
|
||||
});
|
||||
}
|
||||
setError('Username is required')
|
||||
return;
|
||||
}
|
||||
setIsLoading(true)
|
||||
@ -29,16 +26,23 @@ export default function UserName(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className='bg-bgPrimary flex-1 h-full'>
|
||||
<View className="flex-1" />
|
||||
{/* Input container fixed at bottom */}
|
||||
<View className="w-full bg-white p-4 border-t border-gray-200 rounded-t-3xl">
|
||||
<View className="flex-col items-center justify-center w-full gap-[3rem]">
|
||||
<View className='w-full flex flex-row items-center justify-between'>
|
||||
<ThemedText className="text-textSecondary font-semibold">{t('auth.userMessage.title', { ns: 'login' })}</ThemedText>
|
||||
<ThemedText className="text-[#E2793F] font-semibold">{error}</ThemedText>
|
||||
</View>
|
||||
<View className='w-full'>
|
||||
<ThemedText className="!text-textPrimary ml-2 mb-2 font-semibold">{t('auth.userMessage.username', { ns: 'login' })}</ThemedText>
|
||||
<TextInput
|
||||
className="flex-1 bg-inputBackground rounded-2xl px-4 py-3 w-full"
|
||||
className="bg-inputBackground rounded-2xl p-4 w-full"
|
||||
placeholder={t('auth.userMessage.usernamePlaceholder', { ns: 'login' })}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={username}
|
||||
@ -61,5 +65,6 @@ export default function UserName(props: Props) {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
|
||||
20
components/utils/objectRemoveUmdefined.ts
Normal 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;
|
||||
}
|
||||
@ -35,20 +35,19 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
token = token || "";
|
||||
})
|
||||
}
|
||||
|
||||
if (token) {
|
||||
// 验证当前 token 是否有效
|
||||
fetchApi('/user/identity-check', {}, false)
|
||||
.catch(async (error) => {
|
||||
console.error("JWT validation failed, attempting to refresh token...");
|
||||
// console.error("JWT validation failed, attempting to refresh token...");
|
||||
try {
|
||||
// 尝试刷新 token
|
||||
await refreshAuthToken("Token expired");
|
||||
console.log("Token refreshed successfully");
|
||||
// console.log("Token refreshed successfully");
|
||||
// Token 刷新成功,不需要做其他操作
|
||||
} catch (refreshError) {
|
||||
// 刷新 token 失败,才进行登出操作
|
||||
console.error("Token refresh failed, logging out", refreshError);
|
||||
// console.error("Token refresh failed, logging out", refreshError);
|
||||
logout();
|
||||
}
|
||||
});
|
||||
@ -57,11 +56,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
try {
|
||||
// 尝试刷新 token
|
||||
await refreshAuthToken("Token expired");
|
||||
console.log("Token refreshed successfully");
|
||||
// console.log("Token refreshed successfully");
|
||||
// Token 刷新成功,不需要做其他操作
|
||||
} catch (refreshError) {
|
||||
// 刷新 token 失败,才进行登出操作
|
||||
console.error("Token refresh failed, logging out", refreshError);
|
||||
// console.error("Token refresh failed, logging out", refreshError);
|
||||
logout();
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,6 +70,7 @@ export const refreshAuthToken = async<T>(message: string | null): Promise<User>
|
||||
|
||||
// 退出刷新会重新填充数据
|
||||
let response;
|
||||
|
||||
response = await fetch(`${API_ENDPOINT}/v1/iam/access-token-refresh`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
@ -80,6 +81,7 @@ export const refreshAuthToken = async<T>(message: string | null): Promise<User>
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const apiResponse: ApiResponse<T> = await response.json();
|
||||
if (apiResponse.code != 0) {
|
||||
throw new Error(message || 'Unknown error');
|
||||
@ -97,7 +99,7 @@ export const refreshAuthToken = async<T>(message: string | null): Promise<User>
|
||||
|
||||
return userData;
|
||||
} catch (error) {
|
||||
console.error(`Error refreshing token:`, error);
|
||||
// console.error(`Error refreshing token:`, 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 message = error instanceof Error ? error.message : defaultMessage;
|
||||
if (needToast) {
|
||||
console.log(message);
|
||||
// console.log(message);
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
@ -124,9 +126,7 @@ export const fetchApi = async <T>(
|
||||
if (Platform.OS === 'web') {
|
||||
token = localStorage.getItem('token') || "";
|
||||
} else {
|
||||
await SecureStore.getItemAsync('token').then((token: string) => {
|
||||
token = token || "";
|
||||
})
|
||||
token = await SecureStore.getItemAsync('token') || "";
|
||||
}
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
|
||||
29
nginx.conf
Normal 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
15
package.json
@ -20,38 +20,49 @@
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"expo": "~53.0.12",
|
||||
"expo-audio": "~0.4.7",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-constants": "~17.1.6",
|
||||
"expo-dev-client": "~5.2.1",
|
||||
"expo-file-system": "~18.1.10",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"expo-image": "~2.3.0",
|
||||
"expo-image-manipulator": "~13.1.7",
|
||||
"expo-image-picker": "~16.1.4",
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-localization": "^16.1.5",
|
||||
"expo-location": "~18.1.5",
|
||||
"expo-media-library": "~17.1.7",
|
||||
"expo-router": "~5.1.0",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-splash-screen": "~0.30.9",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.9",
|
||||
"expo-video": "~2.2.2",
|
||||
"expo-video-thumbnails": "~9.1.3",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"lottie-react-native": "7.2.2",
|
||||
"nativewind": "^4.1.23",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-native": "0.79.4",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-progress": "^5.0.1",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-svg": "15.11.2",
|
||||
"react-native-toast-message": "^2.3.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-web": "~0.20.0",
|
||||
"react-native-webview": "13.13.5",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-native-svg": "15.11.2"
|
||||
"react-redux": "^9.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
|
||||
35
scripts/dev_deploy.sh
Normal 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
|
||||
@ -87,8 +87,7 @@ const moveDirectories = async (userInput) => {
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:");
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||
userInput === "y"
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
|
||||
@ -17,7 +17,8 @@ module.exports = {
|
||||
textSecondary: '#4C320C',
|
||||
inputBackground: '#FFF8DE',
|
||||
textTertiary: '#4C320C',
|
||||
buttonFill: '#E2793F'
|
||||
buttonFill: '#E2793F',
|
||||
aiBubble: '#FFF8DE',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
58
types/ask.ts
Normal 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;
|
||||
}
|
||||
149
types/upload.ts
@ -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 {
|
||||
id: string;
|
||||
file_name: string;
|
||||
@ -44,7 +49,8 @@ export type UploadStatus = 'Pending' | 'Uploading' | 'Completed' | 'Failed';
|
||||
export type DeletionStatus = 'Active' | 'PendingDeletion' | 'Deleted';
|
||||
|
||||
export interface ConfirmUpload {
|
||||
file_id: number;
|
||||
file_id: string;
|
||||
upload_url: string
|
||||
name: string;
|
||||
size: Size;
|
||||
content_type: ContentType;
|
||||
@ -56,3 +62,144 @@ export interface ConfirmUpload {
|
||||
deletion_status: DeletionStatus;
|
||||
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;
|
||||
}
|
||||