Compare commits
28 Commits
04693b805e
...
291df75086
| Author | SHA1 | Date | |
|---|---|---|---|
| 291df75086 | |||
| ddf37c26e9 | |||
| 6bc7d8b362 | |||
| fea5af96fa | |||
| 0c96cc15d1 | |||
| e9079768b3 | |||
| 44ff7ce36d | |||
| 436b44a107 | |||
| 39b768f2cc | |||
| e68e8b7141 | |||
| 681856c36d | |||
| d31b587330 | |||
| 521a4d0a51 | |||
| 006db2af07 | |||
| 6c270302f5 | |||
| 153838aec0 | |||
| 125da0e660 | |||
| b8d00ef850 | |||
| 1c02968071 | |||
| c022e7f92f | |||
| b0fde981e6 | |||
| 19ed3bba52 | |||
| 036a7c7fa1 | |||
| 5072157a01 | |||
| 5b23951643 | |||
| 0307ed0a00 | |||
| 86bdc7089b | |||
| 3f32bb26bc |
11
.eas/workflows/create-production-builds.yml
Normal file
@ -0,0 +1,11 @@
|
||||
name: Create Production Builds
|
||||
|
||||
jobs:
|
||||
build_android:
|
||||
type: build # This job type creates a production build for Android
|
||||
params:
|
||||
platform: android
|
||||
build_ios:
|
||||
type: build # This job type creates a production build for iOS
|
||||
params:
|
||||
platform: ios
|
||||
62
README.md
@ -1,50 +1,18 @@
|
||||
# Welcome to your Expo app 👋
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
|
||||
## Get started
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
## Dependencies
|
||||
### SQLite
|
||||
- [SQLite](https://sqlite.org/index.html)
|
||||
```shell
|
||||
expo install expo-sqlite
|
||||
cp node_modules/wa-sqlite/dist/wa-sqlite.wasm node_modules/expo-sqlite/web/wa-sqlite/
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
## Build
|
||||
### EAS Build
|
||||
```shell
|
||||
eas build --platform android --profile development
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
### EAS Workflow
|
||||
```shell
|
||||
npx eas-cli@latest workflow:run create-production-builds.yml
|
||||
```
|
||||
@ -1,7 +0,0 @@
|
||||
// app.config.js
|
||||
export default ({ config }) => ({
|
||||
...config,
|
||||
extra: {
|
||||
API_ENDPOINT: process.env.API_ENDPOINT || "http://192.168.31.115:18080/api"
|
||||
}
|
||||
});
|
||||
40
app.json
@ -12,8 +12,14 @@
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSPhotoLibraryUsageDescription": "Allow $(PRODUCT_NAME) to access your photos.",
|
||||
"NSPhotoLibraryAddUsageDescription": "需要保存图片到相册",
|
||||
"NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location to get photo location data.",
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"location",
|
||||
"audio"
|
||||
]
|
||||
},
|
||||
"bundleIdentifier": "com.memowake.app"
|
||||
},
|
||||
@ -30,7 +36,11 @@
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
"android.permission.ACCESS_MEDIA_LOCATION"
|
||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
||||
"FOREGROUND_SERVICE",
|
||||
"WAKE_LOCK",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"WRITE_EXTERNAL_STORAGE"
|
||||
],
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.memowake.app"
|
||||
@ -43,6 +53,18 @@
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-background-task",
|
||||
{
|
||||
"minimumInterval": 15
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-task-manager",
|
||||
{
|
||||
"transparency": "opaque"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
@ -51,14 +73,6 @@
|
||||
"locationWhenInUsePermission": "允许 $(PRODUCT_NAME) 访问您的位置"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"color": "#ffffff",
|
||||
"defaultChannel": "default",
|
||||
"enableBackgroundRemoteNotifications": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-audio",
|
||||
{
|
||||
@ -72,7 +86,8 @@
|
||||
"savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"expo-sqlite"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
@ -81,7 +96,8 @@
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "04721dd4-6b15-495a-b9ec-98187c613172"
|
||||
}
|
||||
},
|
||||
"API_ENDPOINT": "http://192.168.31.115:18080/api"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,164 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { HapticTab } from '@/components/HapticTab';
|
||||
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
|
||||
import TabBarBackground from '@/components/ui/TabBarBackground';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Tabs } from 'expo-router';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
interface PollingData {
|
||||
title: string;
|
||||
id: string;
|
||||
content: string;
|
||||
extra: any;
|
||||
}
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const [pollingData, setPollingData] = useState<PollingData[]>([]);
|
||||
const pollingInterval = useRef<NodeJS.Timeout | number>(null);
|
||||
const tokenInterval = useRef<NodeJS.Timeout | number>(null);
|
||||
const isMounted = useRef(true);
|
||||
const [token, setToken] = useState('');
|
||||
const sendNotification = async (item: PollingData) => {
|
||||
// 请求通知权限
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
alert('请先允许通知权限');
|
||||
return;
|
||||
}
|
||||
|
||||
// 调度本地通知
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: item.title,
|
||||
body: item.content,
|
||||
data: { screen: 'ask', extra: item.extra, id: item.id },
|
||||
priority: 'high', // 关键:设置 high 或 max
|
||||
},
|
||||
trigger: {
|
||||
seconds: 2, // 延迟2秒显示
|
||||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL // 添加 type 字段
|
||||
}, // 延迟2秒显示
|
||||
});
|
||||
};
|
||||
|
||||
// 监听通知点击事件
|
||||
useEffect(() => {
|
||||
const notificationListener = Notifications.addNotificationResponseReceivedListener(response => {
|
||||
const data = response.notification.request.content.data;
|
||||
console.log('通知被点击,数据:', data);
|
||||
pollingData?.filter((item) => item.id !== data.id);
|
||||
});
|
||||
|
||||
// 清理监听器
|
||||
return () => {
|
||||
Notifications.removeNotificationSubscription(notificationListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 轮询获取推送消息
|
||||
const startPolling = useCallback(async (interval: number = 5000) => {
|
||||
|
||||
// 设置轮询
|
||||
pollingInterval.current = setInterval(async () => {
|
||||
if (isMounted.current) {
|
||||
await getMessageData();
|
||||
}
|
||||
}, interval);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取推送消息
|
||||
const getMessageData = async () => {
|
||||
try {
|
||||
const response = await fetchApi<PollingData[]>("/notice/push/message", {
|
||||
method: "POST"
|
||||
});
|
||||
setPollingData((prev) => ([...prev, ...response]));
|
||||
} catch (error) {
|
||||
console.error('获取轮询数据时出错:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取认证token
|
||||
const getAuthToken = async (): Promise<string> => {
|
||||
let tokenValue = '';
|
||||
if (Platform.OS === 'web') {
|
||||
tokenValue = localStorage.getItem('token') || '';
|
||||
} else {
|
||||
tokenValue = (await SecureStore.getItemAsync('token')) || '';
|
||||
}
|
||||
setToken(tokenValue); // 只在获取到新token时更新状态
|
||||
return tokenValue;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
if (token) {
|
||||
// 启动轮询
|
||||
startPolling(5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取推送消息出错:', error);
|
||||
}
|
||||
};
|
||||
checkAuthStatus();
|
||||
|
||||
return () => {
|
||||
// 清理函数
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
// 本地推送
|
||||
useEffect(() => {
|
||||
pollingData?.map((item) => {
|
||||
sendNotification(item)
|
||||
})
|
||||
}, [pollingData])
|
||||
|
||||
// 轮询获取token
|
||||
useEffect(() => {
|
||||
// 如果已经有token,直接返回
|
||||
if (token) {
|
||||
if (tokenInterval.current) {
|
||||
clearInterval(tokenInterval.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!tokenInterval.current) return;
|
||||
// 设置轮询
|
||||
tokenInterval.current = setInterval(async () => {
|
||||
if (isMounted.current) {
|
||||
const currentToken = await getAuthToken();
|
||||
// 如果获取到token,清除定时器
|
||||
if (currentToken && tokenInterval.current) {
|
||||
clearInterval(tokenInterval.current);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
if (tokenInterval.current) {
|
||||
clearInterval(tokenInterval.current);
|
||||
}
|
||||
};
|
||||
}, [token]); // 添加token作为依赖
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
@ -120,16 +270,29 @@ export default function TabLayout() {
|
||||
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
|
||||
}}
|
||||
/>
|
||||
{/* 下载 */}
|
||||
{/* 对话详情页 */}
|
||||
<Tabs.Screen
|
||||
name="download"
|
||||
name="chat-details"
|
||||
options={{
|
||||
title: 'download',
|
||||
title: 'chat-details',
|
||||
tabBarButton: () => null, // 隐藏底部标签栏
|
||||
headerShown: false, // 隐藏导航栏
|
||||
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Debug Screen - only in development */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Tabs.Screen
|
||||
name="debug"
|
||||
options={{
|
||||
title: 'Debug',
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name={focused ? 'bug' : 'bug-outline'} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Tabs >
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,110 +3,160 @@ import Chat from "@/components/ask/chat";
|
||||
import AskHello from "@/components/ask/hello";
|
||||
import SendMessage from "@/components/ask/send";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { checkAuthStatus } from '@/lib/auth';
|
||||
import { fetchApi } from "@/lib/server-api-util";
|
||||
import { Message } from "@/types/ask";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function AskScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
useEffect(() => {
|
||||
checkAuthStatus(router);
|
||||
}, []);
|
||||
// 在组件内部添加 ref
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
// 用于控制是否显示问候页面
|
||||
const [isHello, setIsHello] = useState(true);
|
||||
|
||||
// 获取对话id
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
|
||||
// 用户对话信息收集
|
||||
const [userMessages, setUserMessages] = useState<Message[]>([]);
|
||||
// 选择图片
|
||||
const [selectedImages, setSelectedImages] = useState<string[]>([]);
|
||||
// 动画值
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
const fadeAnimChat = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const createNewConversation = useCallback(async () => {
|
||||
// TODO 用户未输入时,显示提示信息
|
||||
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)
|
||||
setConversationId(sessionId);
|
||||
setIsHello(false);
|
||||
fetchApi<Message[]>(`/chats/${sessionId}/message-history`).then((res) => {
|
||||
setUserMessages(res)
|
||||
})
|
||||
setUserMessages(res);
|
||||
});
|
||||
}
|
||||
if (newSession) {
|
||||
setIsHello(false)
|
||||
createNewConversation()
|
||||
// if (newSession) {
|
||||
// setIsHello(false);
|
||||
// createNewConversation();
|
||||
// }
|
||||
}, [sessionId]);
|
||||
|
||||
// 动画效果
|
||||
useEffect(() => {
|
||||
if (isHello) {
|
||||
// 显示欢迎页,隐藏聊天页
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnimChat, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
} else {
|
||||
// 显示聊天页,隐藏欢迎页
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnimChat, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
}
|
||||
}, [sessionId, newSession])
|
||||
}, [isHello, fadeAnim, fadeAnimChat]);
|
||||
|
||||
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 页面 */}
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* 导航栏 */}
|
||||
<View style={[styles.navbar, isHello && styles.hiddenNavbar]}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => {
|
||||
router.replace('/memo-list');
|
||||
}}
|
||||
onPress={() => router.push('/memo-list')}
|
||||
>
|
||||
<ReturnArrow />
|
||||
</TouchableOpacity>
|
||||
<ThemedText className={`!text-textSecondary font-semibold text-3xl w-full text-center flex-1 ${isHello ? "opacity-0" : ""}`}>MemoWake</ThemedText>
|
||||
<View />
|
||||
<ThemedText style={styles.title}>MemoWake</ThemedText>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
style={styles.keyboardAvoidingView}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
|
||||
enabled={!isHello}
|
||||
>
|
||||
<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>
|
||||
<View style={styles.contentContainer}>
|
||||
{/* 欢迎页面 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.absoluteView,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
// 使用 pointerEvents 控制交互
|
||||
pointerEvents: isHello ? 'auto' : 'none',
|
||||
zIndex: 1
|
||||
}
|
||||
]}
|
||||
>
|
||||
<AskHello />
|
||||
</Animated.View>
|
||||
|
||||
{/* 功能区 - 放在 KeyboardAvoidingView 内但在 ScrollView 外 */}
|
||||
<View className="w-full px-[1.5rem] mb-[2rem]">
|
||||
<SendMessage setUserMessages={setUserMessages} setConversationId={setConversationId} setIsHello={setIsHello} conversationId={conversationId} />
|
||||
{/* 聊天页面 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.absoluteView,
|
||||
{
|
||||
opacity: fadeAnimChat,
|
||||
// 使用 pointerEvents 控制交互
|
||||
pointerEvents: isHello ? 'none' : 'auto',
|
||||
zIndex: 0
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Chat userMessages={userMessages} sessionId={sessionId} setSelectedImages={setSelectedImages} selectedImages={selectedImages} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* 输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<SendMessage
|
||||
setIsHello={setIsHello}
|
||||
setUserMessages={setUserMessages}
|
||||
setConversationId={setConversationId}
|
||||
conversationId={conversationId}
|
||||
selectedImages={selectedImages}
|
||||
setSelectedImages={setSelectedImages}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
@ -114,83 +164,66 @@ export default function AskScreen() {
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
navbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: 'white',
|
||||
// 使用 border 替代阴影
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
// 如果需要更柔和的边缘,可以添加一个微妙的阴影
|
||||
elevation: 1, // Android
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 1,
|
||||
},
|
||||
hiddenNavbar: {
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
backButton: {
|
||||
marginLeft: 16,
|
||||
padding: 12
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
marginBottom: 40,
|
||||
paddingHorizontal: 20,
|
||||
lineHeight: 24,
|
||||
flex: 1,
|
||||
},
|
||||
chipsContainer: {
|
||||
width: "100%",
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
// 更新 keyboardAvoidingView 和 contentContainer 样式
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
marginBottom: 40,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
overflow: "scroll",
|
||||
paddingBottom: 20,
|
||||
},
|
||||
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,
|
||||
absoluteView: {
|
||||
position: 'absolute', // 保持绝对定位
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
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, // 添加一点右边距
|
||||
inputContainer: {
|
||||
padding: 16,
|
||||
paddingBottom: 24,
|
||||
backgroundColor: 'white',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f0f0f0',
|
||||
},
|
||||
});
|
||||
132
app/(tabs)/debug.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, TextInput, Button, Text, StyleSheet, ScrollView, SafeAreaView, ActivityIndicator, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { executeSql } from '@/lib/db';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
|
||||
const DebugScreen = () => {
|
||||
const [sql, setSql] = useState('SELECT * FROM upload_tasks;');
|
||||
const [results, setResults] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleExecuteSql = async (query: string) => {
|
||||
if (!query) return;
|
||||
setLoading(true);
|
||||
setResults(null);
|
||||
try {
|
||||
const result = await executeSql(query);
|
||||
setResults(result);
|
||||
} catch (error) {
|
||||
setResults({ error: (error as Error).message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const presetQueries = [
|
||||
{ title: 'All Uploads', query: 'SELECT * FROM upload_tasks;' },
|
||||
{ title: 'Delete All Uploads', query: 'DELETE FROM upload_tasks;' },
|
||||
{ title: 'Show Tables', query: "SELECT name FROM sqlite_master WHERE type='table';" },
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardAvoidingView}
|
||||
>
|
||||
<ThemedText type="title" style={styles.title}>SQL Debugger</ThemedText>
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
onChangeText={setSql}
|
||||
value={sql}
|
||||
placeholder="Enter SQL query"
|
||||
multiline
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Button title="Execute" onPress={() => handleExecuteSql(sql)} disabled={loading} />
|
||||
</View>
|
||||
<View style={styles.presetsContainer}>
|
||||
{presetQueries.map((item, index) => (
|
||||
<View key={index} style={styles.presetButton}>
|
||||
<Button title={item.title} onPress={() => {
|
||||
setSql(item.query);
|
||||
handleExecuteSql(item.query);
|
||||
}} disabled={loading} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<ThemedText type="subtitle" style={styles.resultTitle}>Results:</ThemedText>
|
||||
<ScrollView style={styles.resultsContainer}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" />
|
||||
) : (
|
||||
<Text selectable style={styles.resultsText}>
|
||||
{results ? JSON.stringify(results, null, 2) : 'No results yet.'}
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
padding: 15,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 15,
|
||||
textAlign: 'center',
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
padding: 10,
|
||||
marginBottom: 10,
|
||||
minHeight: 100,
|
||||
textAlignVertical: 'top',
|
||||
backgroundColor: 'white',
|
||||
fontSize: 16,
|
||||
},
|
||||
presetsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-start',
|
||||
marginBottom: 15,
|
||||
},
|
||||
presetButton: {
|
||||
marginRight: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
resultTitle: {
|
||||
marginBottom: 5,
|
||||
},
|
||||
resultsContainer: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
padding: 10,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
resultsText: {
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
color: '#333',
|
||||
},
|
||||
});
|
||||
|
||||
export default DebugScreen;
|
||||
@ -1,65 +1,76 @@
|
||||
import IP from '@/assets/icons/svg/ip.svg';
|
||||
import Lottie from '@/components/lottie/lottie';
|
||||
import { checkAuthStatus } from '@/lib/auth';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Platform, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
let token;
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
checkAuthStatus(router, () => {
|
||||
router.replace('/ask')
|
||||
}, false).then(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 bg-bgPrimary justify-center items-center">
|
||||
<Text className="text-white">加载中...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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' })}
|
||||
<View className="flex-1">
|
||||
<View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem] " style={{ paddingTop: insets.top + 48 }}>
|
||||
{/* 标题区域 */}
|
||||
<View className="mb-10 w-full px-5">
|
||||
<Text className="text-white text-3xl font-bold mb-3 text-left">
|
||||
{t('auth.welcomeAwaken.awaken', { ns: 'login' })}
|
||||
{"\n"}
|
||||
{t('auth.welcomeAwaken.your', { ns: 'login' })}
|
||||
{"\n"}
|
||||
{t('auth.welcomeAwaken.pm', { ns: 'login' })}
|
||||
</Text>
|
||||
<Text className="text-white/85 text-base text-left">
|
||||
{t('auth.welcomeAwaken.slogan', { ns: 'login' })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Memo 形象区域 */}
|
||||
<View className="items-center">
|
||||
<IP />
|
||||
</View>
|
||||
|
||||
{/* 介绍文本 */}
|
||||
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
|
||||
{t('auth.welcomeAwaken.gallery', { ns: 'login' })}
|
||||
{"\n"}
|
||||
{t('auth.welcomeAwaken.your', { ns: 'login' })}
|
||||
{"\n"}
|
||||
{t('auth.welcomeAwaken.pm', { ns: 'login' })}
|
||||
{t('auth.welcomeAwaken.back', { ns: 'login' })}
|
||||
</Text>
|
||||
<Text className="text-white/85 text-base text-left">
|
||||
{t('auth.welcomeAwaken.slogan', { ns: 'login' })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Memo 形象区域 */}
|
||||
{/* 如果是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' })}
|
||||
{"\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={async () => {
|
||||
// 判断是否有用户信息,有的话直接到usermessage页面 没有到登录页
|
||||
if (Platform.OS === 'web') {
|
||||
token = localStorage.getItem('token') || "";
|
||||
} else {
|
||||
token = await SecureStore.getItemAsync('token') || "";
|
||||
}
|
||||
console.log("token111111111", token);
|
||||
if (token) {
|
||||
router.push('/ask')
|
||||
} else {
|
||||
{/* 唤醒按钮 */}
|
||||
<TouchableOpacity
|
||||
className="bg-white rounded-full px-10 py-4 shadow-[0_2px_4px_rgba(0,0,0,0.1)] w-full items-center"
|
||||
onPress={async () => {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text className="text-[#4C320C] font-bold text-lg">
|
||||
{t('auth.welcomeAwaken.awake', { ns: 'login' })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text className="text-[#4C320C] font-bold text-lg">
|
||||
{t('auth.welcomeAwaken.awake', { ns: 'login' })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View >
|
||||
);
|
||||
}
|
||||
@ -53,7 +53,7 @@ const LoginScreen = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setError('123')
|
||||
// setError('123')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
import ChatSvg from "@/assets/icons/svg/chat.svg";
|
||||
import UploaderProgress from "@/components/file-upload/upload-progress/uploader-progress";
|
||||
import AskNavbar from "@/components/layout/ask";
|
||||
import { useUploadManager } from "@/hooks/useUploadManager";
|
||||
import { fetchApi } from "@/lib/server-api-util";
|
||||
import { useAppDispatch, useAppSelector } from "@/store";
|
||||
import { Chat } from "@/types/ask";
|
||||
import { router } from "expo-router";
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } 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 dispatch = useAppDispatch();
|
||||
const uploadSessionStartTime = useAppSelector((state) => state.appState.uploadSessionStartTime);
|
||||
|
||||
// 历史消息
|
||||
const [historyList, setHistoryList] = React.useState<Chat[]>([]);
|
||||
@ -39,15 +45,46 @@ const MemoList = () => {
|
||||
getHistoryList()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
const { progressInfo, uploadSessionStartTime: uploadSessionStartTimeFromHook } = useUploadManager();
|
||||
|
||||
const renderHeader = () => (
|
||||
<>
|
||||
{/* {process.env.NODE_ENV === 'development' && <TouchableOpacity
|
||||
className='mt-2 bg-red-500 items-center h-10 justify-center'
|
||||
onPress={() => router.push('/debug')}
|
||||
>
|
||||
<Text className="text-white">
|
||||
进入db调试页面
|
||||
</Text>
|
||||
</TouchableOpacity>} */}
|
||||
|
||||
{/* 顶部标题和上传按钮 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Memo List</Text>
|
||||
</View>
|
||||
|
||||
{/* 上传进度展示区域 */}
|
||||
{uploadSessionStartTime && progressInfo.total > 0 && (
|
||||
<View className="h-10 mt-6 mb-2 mx-4">
|
||||
<UploaderProgress
|
||||
imageUrl={progressInfo.image}
|
||||
uploadedCount={progressInfo.completed}
|
||||
totalCount={progressInfo.total}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* <View className="w-full h-full">
|
||||
<AutoUploadScreen />
|
||||
</View> */}
|
||||
|
||||
{/* 历史对话 */}
|
||||
<FlatList
|
||||
ListHeaderComponent={renderHeader}
|
||||
data={historyList}
|
||||
keyExtractor={(item) => item.session_id}
|
||||
ItemSeparatorComponent={() => (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import ConversationsSvg from '@/assets/icons/svg/conversations.svg';
|
||||
import MoreArrowSvg from '@/assets/icons/svg/moreArrow.svg';
|
||||
import PointsSvg from '@/assets/icons/svg/points.svg';
|
||||
import StoriesSvg from '@/assets/icons/svg/stories.svg';
|
||||
import UsedStorageSvg from '@/assets/icons/svg/usedStorage.svg';
|
||||
@ -12,15 +13,29 @@ import ResourceComponent from '@/components/owner/resource';
|
||||
import SettingModal from '@/components/owner/setting';
|
||||
import UserInfo from '@/components/owner/userName';
|
||||
import { formatDuration } from '@/components/utils/time';
|
||||
import { checkAuthStatus } from '@/lib/auth';
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { CountData, UserInfoDetails } from '@/types/user';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView, StyleSheet, View } from 'react-native';
|
||||
import { FlatList, ScrollView, StyleSheet, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function OwnerPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const authStatus = await checkAuthStatus(router);
|
||||
if (!authStatus) {
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, [router]);
|
||||
|
||||
// 设置弹窗
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@ -59,54 +74,62 @@ export default function OwnerPage() {
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
<FlatList
|
||||
data={[]} // 空数据,因为我们只需要渲染一次
|
||||
renderItem={null} // 不需要渲染项目
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ flexGrow: 1, gap: 16, marginHorizontal: 16 }}
|
||||
>
|
||||
{/* 用户信息 */}
|
||||
<UserInfo userInfo={userInfoDetails} />
|
||||
ListHeaderComponent={
|
||||
<View style={{ gap: 16 }}>
|
||||
{/* 用户信息 */}
|
||||
<UserInfo userInfo={userInfoDetails} />
|
||||
|
||||
{/* 设置栏 */}
|
||||
<AlbumComponent setModalVisible={setModalVisible} />
|
||||
{/* 设置栏 */}
|
||||
<AlbumComponent setModalVisible={setModalVisible} />
|
||||
|
||||
{/* 资源数据 */}
|
||||
<View style={styles.resourceContainer}>
|
||||
<ResourceComponent title={t("generalSetting.usedStorage", { ns: "personal" })} subtitle={`${countData?.counter?.total_count?.video_count || 0}videos/${countData?.counter?.total_count?.photo_count || 0}photos`} data={{ all: countData.total_bytes, used: countData.used_bytes }} icon={<UsedStorageSvg />} style={{ flex: 1 }} isFormatBytes={true} />
|
||||
<ResourceComponent title={t("generalSetting.remainingPoints", { ns: "personal" })} data={{ all: userInfoDetails.total_points, used: userInfoDetails.remain_points }} icon={<PointsSvg />} style={{ flex: 1 }} />
|
||||
</View>
|
||||
{/* 数据统计 */}
|
||||
<CountComponent
|
||||
data={[{ title: t("generalSetting.totalVideo", { ns: "personal" }), number: countData?.counter?.total_count?.video_count || 0 }, { title: t("generalSetting.totalPhoto", { ns: "personal" }), number: countData?.counter?.total_count?.photo_count || 0 }, { title: t("generalSetting.live", { ns: "personal" }), number: countData?.counter?.total_count?.live_count || 0 }, { title: t("generalSetting.videoLength", { ns: "personal" }), number: formatDuration(countData?.counter?.total_count?.video_length || 0) }]}
|
||||
/>
|
||||
{/* 资源数据 */}
|
||||
<View style={styles.resourceContainer}>
|
||||
<View style={{ gap: 16, width: "80%" }}>
|
||||
<ResourceComponent title={t("generalSetting.usedStorage", { ns: "personal" })} data={{ all: userInfoDetails.total_bytes, used: countData.used_bytes }} icon={<UsedStorageSvg />} isFormatBytes={true} />
|
||||
<ResourceComponent title={t("generalSetting.remainingPoints", { ns: "personal" })} data={{ all: userInfoDetails.total_points, used: userInfoDetails.remain_points }} icon={<PointsSvg />} />
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end', flex: 1 }}>
|
||||
<MoreArrowSvg />
|
||||
</View>
|
||||
</View>
|
||||
{/* 数据统计 */}
|
||||
<CountComponent
|
||||
data={[{ title: t("generalSetting.totalVideo", { ns: "personal" }), number: countData?.counter?.total_count?.video_count || 0 }, { title: t("generalSetting.totalPhoto", { ns: "personal" }), number: countData?.counter?.total_count?.photo_count || 0 }, { title: t("generalSetting.live", { ns: "personal" }), number: countData?.counter?.total_count?.live_count || 0 }, { title: t("generalSetting.videoLength", { ns: "personal" }), number: formatDuration(countData?.counter?.total_count?.video_length || 0) }]}
|
||||
/>
|
||||
|
||||
{/* 分类 */}
|
||||
<View style={{ height: 145 }}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 16 }} >
|
||||
{countData?.counter?.category_count && Object.entries(countData?.counter?.category_count).map(([key, value], index) => {
|
||||
return (
|
||||
<CategoryComponent
|
||||
key={index}
|
||||
title={key}
|
||||
data={[{ title: 'Video', number: value.video_count }, { title: 'Photo', number: value.photo_count }, { title: 'Length', number: formatDuration(value.video_length || 0) }]}
|
||||
bgSvg={value.cover_url}
|
||||
style={{ aspectRatio: 1, flex: 1 }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
{/* 分类 */}
|
||||
<View style={{ height: 145 }}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 16 }} >
|
||||
{countData?.counter?.category_count && Object.entries(countData?.counter?.category_count).map(([key, value], index) => {
|
||||
return (
|
||||
<CategoryComponent
|
||||
key={index}
|
||||
title={key}
|
||||
data={[{ title: 'Video', number: value.video_count }, { title: 'Photo', number: value.photo_count }, { title: 'Length', number: formatDuration(value.video_length || 0) }]}
|
||||
bgSvg={value.cover_url}
|
||||
style={{ aspectRatio: 1, flex: 1 }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 作品数据 */}
|
||||
<View className='flex flex-row justify-between gap-[1rem]'>
|
||||
<CreateCountComponent title={t("generalSetting.storiesCreated", { ns: "personal" })} icon={<StoriesSvg />} number={userInfoDetails.stories_count} />
|
||||
<CreateCountComponent title={t("generalSetting.conversationsWithMemo", { ns: "personal" })} icon={<ConversationsSvg />} number={userInfoDetails.conversations_count} />
|
||||
</View>
|
||||
|
||||
{/* 排行榜 */}
|
||||
<Ranking data={userInfoDetails.title_rankings} />
|
||||
|
||||
</ScrollView>
|
||||
{/* 作品数据 */}
|
||||
<View className='flex flex-row justify-between gap-[1rem]'>
|
||||
<CreateCountComponent title={t("generalSetting.storiesCreated", { ns: "personal" })} icon={<StoriesSvg />} number={userInfoDetails.stories_count} />
|
||||
<CreateCountComponent title={t("generalSetting.conversationsWithMemo", { ns: "personal" })} icon={<ConversationsSvg />} number={userInfoDetails.conversations_count} />
|
||||
</View>
|
||||
|
||||
{/* 排行榜 */}
|
||||
<Ranking data={userInfoDetails.title_rankings} />
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
{/* 设置弹窗 */}
|
||||
<SettingModal modalVisible={modalVisible} setModalVisible={setModalVisible} userInfo={userInfoDetails.user_info} />
|
||||
|
||||
@ -122,9 +145,18 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'white',
|
||||
paddingBottom: 86,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
resourceContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 16
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
backgroundColor: "#FAF9F6",
|
||||
padding: 16,
|
||||
borderRadius: 18,
|
||||
paddingTop: 20
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@ -5,14 +5,23 @@ import ClassifyModal from '@/components/owner/classify';
|
||||
import LocationModal from '@/components/owner/location';
|
||||
import PodiumComponent from '@/components/owner/podium';
|
||||
import RankList from '@/components/owner/rankList';
|
||||
import { findInnermostElement } from '@/components/owner/utils';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { convertRegions } from '@/components/utils/cascaderData';
|
||||
import { transformData } from '@/components/utils/objectToCascader';
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { GroupedData, RankingItem, TargetItem } from '@/types/user';
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { LayoutChangeEvent, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LayoutChangeEvent, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
interface LocationData {
|
||||
id: number;
|
||||
name: string;
|
||||
children: LocationData[];
|
||||
}
|
||||
|
||||
export default function OwnerPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
@ -20,7 +29,8 @@ export default function OwnerPage() {
|
||||
const [locationModalVisible, setLocationModalVisible] = useState(false);
|
||||
// 分类弹窗
|
||||
const [classifyModalVisible, setClassifyModalVisible] = useState(false);
|
||||
|
||||
// 地区数据
|
||||
const [locationData, setLocationData] = useState<CascaderItem[]>([]);
|
||||
// 在组件内部添加:
|
||||
const podiumRef = useRef<View>(null);
|
||||
const [podiumPosition, setPodiumPosition] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
||||
@ -28,6 +38,7 @@ export default function OwnerPage() {
|
||||
const [classify, setClassify] = useState<TargetItem[]>([]);
|
||||
const getClassify = () => {
|
||||
fetchApi<GroupedData>("/title-tags").then((res: GroupedData) => {
|
||||
setSelectedClassify([transformData(res)?.[0]?.children?.[0]]);
|
||||
setClassify(transformData(res));
|
||||
});
|
||||
}
|
||||
@ -51,7 +62,6 @@ export default function OwnerPage() {
|
||||
};
|
||||
// 地区选择
|
||||
const handleLocationChange = useCallback((selectedItems: CascaderItem[]) => {
|
||||
console.log('SelectedLocation:', selectedItems);
|
||||
if (selectedItems.length > 0) {
|
||||
const lastItem = selectedItems[selectedItems.length - 1];
|
||||
// 只有当选择完成时才更新状态
|
||||
@ -63,7 +73,6 @@ export default function OwnerPage() {
|
||||
|
||||
// 分类选择
|
||||
const handleClassifyChange = useCallback((selectedItems: CascaderItem[]) => {
|
||||
console.log('SelectedClassify:', selectedItems);
|
||||
if (selectedItems.length > 0) {
|
||||
const lastItem = selectedItems[selectedItems.length - 1];
|
||||
// 只有当选择完成时才更新状态
|
||||
@ -72,15 +81,26 @@ export default function OwnerPage() {
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
// 获取本地存储的地址信息
|
||||
const getLocation = async () => {
|
||||
let location;
|
||||
if (Platform.OS === 'web') {
|
||||
location = localStorage.getItem('location');
|
||||
|
||||
} else {
|
||||
location = await SecureStore.getItemAsync('location');
|
||||
|
||||
}
|
||||
return location;
|
||||
};
|
||||
// 获取排名信息
|
||||
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
||||
const getRanking = () => {
|
||||
fetchApi<RankingItem[]>("/title-rank", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
"title_tag_id": selectedClassify?.length > 0 ? selectedClassify[selectedClassify?.length - 1].value : 3,
|
||||
"area_id": 1
|
||||
"title_tag_id": selectedClassify?.length > 0 ? selectedClassify[selectedClassify?.length - 1].value : null,
|
||||
"area_id": selectedLocation?.length > 0 ? selectedLocation[selectedLocation?.length - 1].value : null
|
||||
})
|
||||
}).then((res) => {
|
||||
setRanking(res);
|
||||
@ -89,13 +109,57 @@ export default function OwnerPage() {
|
||||
|
||||
// 当用户选择发生变化时,重新获取排名
|
||||
useEffect(() => {
|
||||
getRanking();
|
||||
if (selectedLocation?.length > 0 && selectedClassify?.length > 0) {
|
||||
getRanking();
|
||||
}
|
||||
}, [selectedLocation, selectedClassify])
|
||||
|
||||
// 初始化获取分类
|
||||
useEffect(() => {
|
||||
getClassify();
|
||||
const start = async () => {
|
||||
await getClassify();
|
||||
}
|
||||
start();
|
||||
}, [])
|
||||
const fetchLocationData = useMemo(() => async () => {
|
||||
try {
|
||||
const res = await fetchApi<LocationData>("/area/tree");
|
||||
const transformed = convertRegions(res?.children, {
|
||||
nameKey: 'name', // 源数据中表示"名称"的字段
|
||||
valueKey: 'id', // 源数据中作为 value 的字段
|
||||
regionsKey: 'children', // 源数据中表示"子级区域"的字段
|
||||
childrenKey: 'children' // 输出结构中表示"子级区域"的字段
|
||||
});
|
||||
return transformed;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadLocationData = async () => {
|
||||
const data = await fetchLocationData();
|
||||
if (isMounted) {
|
||||
setLocationData(data);
|
||||
// 获取本地存储的地址信息
|
||||
const location = await getLocation();
|
||||
const xuhuiElement = findInnermostElement(data?.filter((item) => {
|
||||
return item.name === JSON.parse(location || "").city
|
||||
}) || [], JSON.parse(location || "").district);
|
||||
if (location) {
|
||||
setSelectedLocation([xuhuiElement]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadLocationData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fetchLocationData]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
@ -149,6 +213,7 @@ export default function OwnerPage() {
|
||||
setModalVisible={setLocationModalVisible}
|
||||
podiumPosition={podiumPosition}
|
||||
handleChange={handleLocationChange}
|
||||
data={locationData}
|
||||
/>
|
||||
{/* 分类选择弹窗 */}
|
||||
<ClassifyModal
|
||||
|
||||
@ -2,14 +2,17 @@ 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 { checkAuthStatus } from '@/lib/auth';
|
||||
import { FileUploadItem } from '@/lib/background-uploader/types';
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { FileUploadItem } from '@/types/upload';
|
||||
import { User } from '@/types/user';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, StatusBar, View } from 'react-native';
|
||||
export type Steps = "userName" | "look" | "choice" | "done";
|
||||
export default function UserMessage() {
|
||||
// 步骤
|
||||
const router = useRouter();
|
||||
// 步骤
|
||||
const [steps, setSteps] = useState<Steps>("userName")
|
||||
const [username, setUsername] = useState('')
|
||||
const [avatar, setAvatar] = useState('')
|
||||
@ -18,6 +21,14 @@ export default function UserMessage() {
|
||||
const [userInfo, setUserInfo] = useState<User | null>(null);
|
||||
const statusBarHeight = StatusBar?.currentHeight ?? 0;
|
||||
|
||||
// 获取路由参数
|
||||
const params = useLocalSearchParams();
|
||||
const { username: usernameParam } = params;
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus(router);
|
||||
}, []);
|
||||
|
||||
// 获取用户信息
|
||||
const getUserInfo = async () => {
|
||||
const res = await fetchApi<User>("/iam/user-info");
|
||||
@ -43,7 +54,8 @@ export default function UserMessage() {
|
||||
};
|
||||
useEffect(() => {
|
||||
getUserInfo();
|
||||
}, []);
|
||||
setSteps("userName")
|
||||
}, [usernameParam]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
import '../global.css';
|
||||
import { Provider } from "../provider";
|
||||
@ -9,6 +12,22 @@ import { Provider } from "../provider";
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
const setupBackgroundUpload = async () => {
|
||||
const { status } = await MediaLibrary.getPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
console.log('Media library permission not granted. Background task registered but will wait for permission.');
|
||||
}
|
||||
const registered = await registerBackgroundUploadTask();
|
||||
if (registered) {
|
||||
console.log('Background upload task setup finished in RootLayout.');
|
||||
} else {
|
||||
console.error('Failed to register background upload task in RootLayout.');
|
||||
}
|
||||
};
|
||||
setupBackgroundUpload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Provider>
|
||||
|
||||
4
assets/icons/svg/chatIn.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
|
||||
<path d="M4.913 2.658c2.075-.27 4.19-.408 6.337-.408 2.147 0 4.262.139 6.337.408 1.922.25 3.291 1.861 3.405 3.727a4.403 4.403 0 0 0-1.032-.211 50.89 50.89 0 0 0-8.42 0c-2.358.196-4.04 2.19-4.04 4.434v4.286a4.47 4.47 0 0 0 2.433 3.984L7.28 21.53A.75.75 0 0 1 6 21v-4.03a48.527 48.527 0 0 1-1.087-.128C2.905 16.58 1.5 14.833 1.5 12.862V6.638c0-1.97 1.405-3.718 3.413-3.979Z" />
|
||||
<path d="M15.75 7.5c-1.376 0-2.739.057-4.086.169C10.124 7.797 9 9.103 9 10.609v4.285c0 1.507 1.128 2.814 2.67 2.94 1.243.102 2.5.157 3.768.165l2.782 2.781a.75.75 0 0 0 1.28-.53v-2.39l.33-.026c1.542-.125 2.67-1.433 2.67-2.94v-4.286c0-1.505-1.125-2.811-2.664-2.94A49.392 49.392 0 0 0 15.75 7.5Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 780 B |
3
assets/icons/svg/chatNotIn.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 717 B |
3
assets/icons/svg/folder.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 3V13.8C1 14.9201 1 15.4798 1.21799 15.9076C1.40973 16.2839 1.71547 16.5905 2.0918 16.7822C2.5192 17 3.07899 17 4.19691 17H15.8031C16.921 17 17.48 17 17.9074 16.7822C18.2837 16.5905 18.5905 16.2841 18.7822 15.9078C19.0002 15.48 19.0002 14.9199 19.0002 13.7998L19.0002 6.19978C19.0002 5.07967 19.0002 4.51962 18.7822 4.0918C18.5905 3.71547 18.2839 3.40973 17.9076 3.21799C17.4798 3 16.9201 3 15.8 3H10M1 3H10M1 3C1 1.89543 1.89543 1 3 1H6.67452C7.1637 1 7.40886 1 7.63904 1.05526C7.84311 1.10425 8.03785 1.18526 8.2168 1.29492C8.41857 1.41857 8.59181 1.59182 8.9375 1.9375L10 3" stroke="#4C320C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 775 B |
3
assets/icons/svg/moreArrow.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="M1.0002 1L9.77832 9.77812L1.0002 18.5562" stroke="#4C320C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
3
assets/icons/svg/personIn.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
|
||||
<path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 344 B |
3
assets/icons/svg/personNotIn.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
3
assets/icons/svg/yes.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 4.00001L4.33357 7L11 1" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 218 B |
BIN
assets/images/png/owner/ask.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
@ -6,7 +6,6 @@ module.exports = function (api) {
|
||||
"nativewind/babel",
|
||||
],
|
||||
plugins: [
|
||||
'expo-router/babel',
|
||||
'react-native-reanimated/plugin',
|
||||
],
|
||||
};
|
||||
|
||||
45
components/ask/VideoPlayer.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useVideoPlayer, VideoView } from 'expo-video';
|
||||
import {
|
||||
Pressable,
|
||||
StyleProp,
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer
|
||||
@ -1,75 +1,46 @@
|
||||
import ChatSvg from "@/assets/icons/svg/chat.svg";
|
||||
import FolderSvg from "@/assets/icons/svg/folder.svg";
|
||||
import MoreSvg from "@/assets/icons/svg/more.svg";
|
||||
import ReturnArrow from "@/assets/icons/svg/returnArrow.svg";
|
||||
import YesSvg from "@/assets/icons/svg/yes.svg";
|
||||
import { Message, Video } from "@/types/ask";
|
||||
import { MaterialItem } from "@/types/personal-info";
|
||||
import { useVideoPlayer, VideoView } from 'expo-video';
|
||||
import { TFunction } from "i18next";
|
||||
import React from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
Image,
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
View
|
||||
} from 'react-native';
|
||||
import { ThemedText } from "../ThemedText";
|
||||
import TypewriterText from "./typewriterText";
|
||||
import { mergeArrays } from "./utils";
|
||||
import VideoPlayer from "./VideoPlayer";
|
||||
|
||||
interface RenderMessageProps {
|
||||
insets: { top: number };
|
||||
item: Message;
|
||||
sessionId: string;
|
||||
setModalVisible: React.Dispatch<React.SetStateAction<{ visible: boolean, data: Video | MaterialItem }>>;
|
||||
modalVisible: { visible: boolean, data: Video | MaterialItem };
|
||||
setModalDetailsVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
modalDetailsVisible: boolean;
|
||||
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
selectedImages: string[];
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: RenderMessageProps) => {
|
||||
const MessageItem = ({ t, insets, item, sessionId, setModalVisible, modalVisible, setModalDetailsVisible, modalDetailsVisible, setSelectedImages, selectedImages }: RenderMessageProps) => {
|
||||
const isUser = item.role === 'User';
|
||||
const isVideo = (data: Video | MaterialItem): data is Video => {
|
||||
return 'video' in data;
|
||||
};
|
||||
|
||||
// 创建一个新的 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} />}
|
||||
@ -90,25 +61,28 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
|
||||
}
|
||||
</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) && (
|
||||
{(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.length || 0 > 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) => (
|
||||
<View style={[styles.imageGridContainer, { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' }]}>
|
||||
{mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.slice(0, 3)?.map((image) => (
|
||||
<Pressable
|
||||
key={`${image.role}-${image.timestamp}`}
|
||||
key={image?.id || image?.video?.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 } // 添加按下效果
|
||||
]}
|
||||
style={{
|
||||
width: '32%',
|
||||
aspectRatio: 1,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: image?.preview_file_info?.url || image.video?.preview_file_info?.url }}
|
||||
className="rounded-xl w-full h-full"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</Pressable>
|
||||
@ -116,12 +90,14 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
|
||||
</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">
|
||||
&& <TouchableOpacity className="absolute top-1/2 -translate-y-1/2 -right-4 translate-x-1/2 bg-bgPrimary flex flex-row items-center gap-2 p-1 pl-2 rounded-full" onPress={() => {
|
||||
setModalDetailsVisible(true);
|
||||
}}>
|
||||
<ThemedText className="!text-white font-semibold">{((item.content.video_material_infos?.length || 0) + (item.content.image_material_infos?.length || 0))}</ThemedText>
|
||||
<View className="bg-white rounded-full p-2">
|
||||
<MoreSvg />
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
</View>
|
||||
)}
|
||||
@ -194,14 +170,110 @@ const renderMessage = ({ item, sessionId, setModalVisible, modalVisible }: Rende
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Modal>
|
||||
<Modal
|
||||
animationType="fade"
|
||||
visible={modalDetailsVisible}
|
||||
transparent={false}
|
||||
statusBarTranslucent={true}
|
||||
onRequestClose={() => {
|
||||
setModalDetailsVisible(false);
|
||||
}}
|
||||
>
|
||||
<View style={[detailsStyles.container, { paddingTop: insets?.top }]}>
|
||||
<View style={detailsStyles.header}>
|
||||
<TouchableOpacity onPress={() => setModalDetailsVisible(false)}>
|
||||
<ReturnArrow />
|
||||
</TouchableOpacity>
|
||||
<ThemedText style={detailsStyles.headerText}>{t('ask.selectPhoto', { ns: 'ask' })}</ThemedText>
|
||||
<FolderSvg />
|
||||
</View>
|
||||
<View style={{ overflow: 'scroll', height: "100%" }}>
|
||||
<FlatList
|
||||
data={mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])}
|
||||
numColumns={3}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={detailsStyles.flatListContent}
|
||||
initialNumToRender={12}
|
||||
maxToRenderPerBatch={12}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={10}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={detailsStyles.gridItemContainer}
|
||||
key={item.id}
|
||||
>
|
||||
<View style={detailsStyles.gridItem}>
|
||||
<ThemedText style={detailsStyles.imageNumber}>
|
||||
{selectedImages?.map((image, index) => {
|
||||
if (image === item.id || image === item.video?.id) {
|
||||
return index + 1
|
||||
}
|
||||
})}
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={{ uri: item?.preview_file_info?.url || item.video?.preview_file_info?.url }}
|
||||
style={detailsStyles.image}
|
||||
onError={(error) => console.log('Image load error:', error.nativeEvent.error)}
|
||||
onLoad={() => console.log('Image loaded successfully')}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[detailsStyles.circleMarker, selectedImages.includes(item?.id || item?.video?.id) ? detailsStyles.circleMarkerSelected : ""]}
|
||||
onPress={() => {
|
||||
setSelectedImages((prev) => {
|
||||
if (prev.includes(item?.id || item?.video?.id)) {
|
||||
return prev.filter((id) => id !== (item.id || item?.video?.id));
|
||||
} else {
|
||||
return [...prev, item.id || item.video?.id];
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{selectedImages.includes(item?.id || item?.video?.id) ? <YesSvg width={16} height={16} /> : ""}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View style={detailsStyles.footer}>
|
||||
<TouchableOpacity
|
||||
style={detailsStyles.continueButton}
|
||||
onPress={async () => {
|
||||
// 如果用户没有选择 则为选择全部
|
||||
if (selectedImages?.length < 0) {
|
||||
setSelectedImages(mergeArrays(item.content.image_material_infos || [], item.content.video_material_infos || [])?.map((item) => {
|
||||
return item.id || item.video?.id
|
||||
}))
|
||||
}
|
||||
setModalDetailsVisible(false)
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={detailsStyles.continueButtonText}>
|
||||
{t('ask.continueAsking', { ns: 'ask' })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default renderMessage;
|
||||
export default MessageItem;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
imageGridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
},
|
||||
video: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@ -276,4 +348,125 @@ const styles = StyleSheet.create({
|
||||
color: '#000',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
const detailsStyles = StyleSheet.create({
|
||||
gridItemContainer: {
|
||||
flex: 1, // 使用 flex 布局使项目平均分配空间
|
||||
maxWidth: '33.33%', // 每行最多4个项目
|
||||
aspectRatio: 1, // 保持1:1的宽高比
|
||||
},
|
||||
flatListContent: {
|
||||
paddingBottom: 100, // 为底部按钮留出更多空间
|
||||
paddingHorizontal: 8, // 添加水平内边距
|
||||
paddingTop: 8,
|
||||
},
|
||||
headerText: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: "#4C320C"
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
backgroundColor: '#fff',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
},
|
||||
imageNumber: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
zIndex: 10, // 确保数字显示在图片上方
|
||||
},
|
||||
imageNumberText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
numberText: {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.9)', // 使用半透明蓝色背景
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
gridItem: {
|
||||
flex: 1, // 填充父容器
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderWidth: 1,
|
||||
borderColor: '#eee',
|
||||
height: '100%', // 确保高度填满容器
|
||||
position: 'relative',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
circleMarker: {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 3,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
circleMarkerSelected: {
|
||||
backgroundColor: '#FFB645',
|
||||
},
|
||||
markerText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: 16,
|
||||
zIndex: 10,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
continueButton: {
|
||||
backgroundColor: '#E2793F',
|
||||
borderRadius: 32,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
zIndex: 10,
|
||||
},
|
||||
continueButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
}
|
||||
});
|
||||
@ -1,28 +1,35 @@
|
||||
import { Message, Video } from '@/types/ask';
|
||||
import { MaterialItem } from '@/types/personal-info';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { Dispatch, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
FlatList,
|
||||
SafeAreaView
|
||||
} from 'react-native';
|
||||
import renderMessage from "./aiChat";
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import MessageItem from './aiChat';
|
||||
|
||||
interface ChatProps {
|
||||
userMessages: Message[];
|
||||
sessionId: string;
|
||||
setSelectedImages: Dispatch<SetStateAction<string[]>>;
|
||||
selectedImages: string[];
|
||||
}
|
||||
|
||||
function ChatComponent({ userMessages, sessionId }: ChatProps) {
|
||||
function ChatComponent({ userMessages, sessionId, setSelectedImages, selectedImages }: ChatProps) {
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
const [modalVisible, setModalVisible] = React.useState({ visible: false, data: {} as Video | MaterialItem });
|
||||
|
||||
const { t } = useTranslation();
|
||||
// 使用 useCallback 缓存 keyExtractor 函数
|
||||
const keyExtractor = useCallback((item: Message) => `${item.role}-${item.timestamp}`, []);
|
||||
|
||||
// 使用 useMemo 缓存样式对象
|
||||
const contentContainerStyle = useMemo(() => ({ padding: 16 }), []);
|
||||
|
||||
// 详情弹窗
|
||||
const [modalDetailsVisible, setModalDetailsVisible] = useState<boolean>(false);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (userMessages.length > 0) {
|
||||
@ -45,7 +52,7 @@ function ChatComponent({ userMessages, sessionId }: ChatProps) {
|
||||
updateCellsBatchingPeriod={50}
|
||||
initialNumToRender={10}
|
||||
windowSize={11}
|
||||
renderItem={({ item }) => renderMessage({ item, sessionId, modalVisible, setModalVisible })}
|
||||
renderItem={({ item }) => MessageItem({ t, setSelectedImages, selectedImages, insets, item, sessionId, modalVisible, setModalVisible, setModalDetailsVisible, modalDetailsVisible })}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@ -1,28 +1,39 @@
|
||||
import IP from "@/assets/icons/svg/ip.svg";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from 'react-native';
|
||||
import { ScrollView, View } from 'react-native';
|
||||
|
||||
export default function AskHello() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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">
|
||||
{t('ask.hi', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
{t('ask.iAmMemo', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
<View className="justify-center items-center"><IP /></View>
|
||||
|
||||
<ThemedText className="!text-textPrimary text-center -mt-[4rem]">
|
||||
{t('ask.ready', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
{t('ask.justAsk', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View className="flex-1 bg-white w-full">
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20
|
||||
}}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="items-center">
|
||||
<ThemedText className="text-3xl font-bold text-center">
|
||||
{t('ask.hi', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
{t('ask.iAmMemo', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
<View className="justify-center items-center my-4">
|
||||
<IP />
|
||||
</View>
|
||||
<ThemedText className="!text-textPrimary text-center">
|
||||
{t('ask.ready', { ns: 'ask' })}
|
||||
{"\n"}
|
||||
{t('ask.justAsk', { ns: 'ask' })}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
import VoiceSvg from '@/assets/icons/svg/vioce.svg';
|
||||
import React, { Dispatch, SetStateAction, useCallback, useState } from 'react';
|
||||
import React, { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
@ -12,13 +13,15 @@ import { fetchApi } from '@/lib/server-api-util';
|
||||
import { Message } from '@/types/ask';
|
||||
|
||||
interface Props {
|
||||
setIsHello: (isHello: boolean) => void,
|
||||
setIsHello: Dispatch<SetStateAction<boolean>>,
|
||||
conversationId: string | null,
|
||||
setUserMessages: Dispatch<SetStateAction<Message[]>>;
|
||||
setConversationId: (conversationId: string) => void,
|
||||
selectedImages: string[];
|
||||
setSelectedImages: Dispatch<SetStateAction<string[]>>;
|
||||
}
|
||||
export default function SendMessage(props: Props) {
|
||||
const { setIsHello, conversationId, setUserMessages, setConversationId } = props;
|
||||
const { setIsHello, conversationId, setUserMessages, setConversationId, selectedImages, setSelectedImages } = props;
|
||||
|
||||
// 用户询问
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
@ -29,20 +32,23 @@ export default function SendMessage(props: Props) {
|
||||
method: "POST",
|
||||
});
|
||||
setConversationId(data);
|
||||
await getConversation({ session_id: data, user_text });
|
||||
await getConversation({ session_id: data, user_text, material_ids: [] });
|
||||
}, []);
|
||||
|
||||
// 获取对话信息
|
||||
const getConversation = useCallback(async ({ session_id, user_text }: { session_id: string, user_text: string }) => {
|
||||
const getConversation = useCallback(async ({ session_id, user_text, material_ids }: { session_id: string, user_text: string, material_ids: string[] }) => {
|
||||
// 获取对话信息必须要有对话id
|
||||
if (!session_id) return;
|
||||
|
||||
const response = await fetchApi<Message>(`/chat`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
session_id,
|
||||
user_text
|
||||
user_text,
|
||||
material_ids
|
||||
})
|
||||
});
|
||||
setSelectedImages([]);
|
||||
setUserMessages((prev: Message[]) => [...prev, response]?.filter((item: Message) => item.content.text !== '正在寻找,请稍等...'));
|
||||
}, []);
|
||||
|
||||
@ -70,17 +76,37 @@ export default function SendMessage(props: Props) {
|
||||
// 如果没有对话ID,创建新对话并获取消息,否则直接获取消息
|
||||
if (!conversationId) {
|
||||
createNewConversation(text);
|
||||
setIsHello(false);
|
||||
} else {
|
||||
getConversation({
|
||||
session_id: conversationId,
|
||||
user_text: text
|
||||
user_text: text,
|
||||
material_ids: selectedImages
|
||||
});
|
||||
}
|
||||
// 将输入框清空
|
||||
setInputValue('');
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const keyboardWillShowListener = Keyboard.addListener(
|
||||
'keyboardWillShow',
|
||||
() => {
|
||||
console.log('Keyboard will show');
|
||||
setIsHello(false);
|
||||
setUserMessages([{
|
||||
content: {
|
||||
text: "快来寻找你的记忆吧。。。"
|
||||
},
|
||||
role: 'Assistant',
|
||||
timestamp: new Date().toISOString()
|
||||
}])
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
keyboardWillShowListener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@ -124,7 +124,13 @@ const CascaderComponent: React.FC<CascaderProps> = ({
|
||||
]
|
||||
]}
|
||||
>
|
||||
{renderLevel(items, level)}
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
showsVerticalScrollIndicator={true}
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
>
|
||||
{renderLevel(items, level)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
));
|
||||
};
|
||||
@ -143,6 +149,7 @@ const CascaderComponent: React.FC<CascaderProps> = ({
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{renderColumns()}
|
||||
</ScrollView>
|
||||
@ -159,18 +166,22 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
height: 300, // Set a fixed height for the container
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
height: '100%',
|
||||
},
|
||||
column: {
|
||||
height: '100%',
|
||||
maxHeight: '100%',
|
||||
},
|
||||
columnWithDivider: {
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
levelContainer: {
|
||||
height: '100%',
|
||||
maxHeight: '100%',
|
||||
},
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
@ -178,8 +189,8 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
minWidth: '100%', // 确保最小宽度填满容器
|
||||
overflow: 'hidden', // 隐藏超出部分
|
||||
minWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
text: {
|
||||
fontSize: 15,
|
||||
|
||||
38
components/copy.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
const CopyButton = ({ textToCopy }: { textToCopy: string }) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await Clipboard.setStringAsync(textToCopy);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handleCopy} style={styles.button}>
|
||||
{isCopied ? (
|
||||
<Ionicons name="checkmark-circle" size={12} color="#FFB645" />
|
||||
) : (
|
||||
<Ionicons name="copy-outline" size={12} color="#333" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 4,
|
||||
},
|
||||
text: {
|
||||
marginLeft: 8,
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default CopyButton;
|
||||
243
components/file-upload/autoUploadScreen.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
import { triggerManualUpload } from '@/lib/background-uploader/manual';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import UploaderProgressBar from './upload-progress/progress-bar';
|
||||
|
||||
export default function AutoUploadScreen() {
|
||||
const [timeRange, setTimeRange] = useState('day');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState({
|
||||
totalCount: 0,
|
||||
uploadedCount: 0,
|
||||
currentFileUrl: '',
|
||||
uploadedSize: 0,
|
||||
totalSize: 0,
|
||||
});
|
||||
|
||||
|
||||
// 处理手动上传
|
||||
const handleManualUpload = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await triggerManualUpload(
|
||||
getDateRange(timeRange)[0],
|
||||
getDateRange(timeRange)[1],
|
||||
(progress) => {
|
||||
setUploadProgress({
|
||||
totalCount: progress.totalCount,
|
||||
uploadedCount: progress.uploadedCount,
|
||||
currentFileUrl: progress.currentAsset.uri,
|
||||
uploadedSize: progress.uploadedBytes,
|
||||
totalSize: progress.totalBytes,
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取时间范围文本
|
||||
const getDateRangeText = (timeRange: string) => {
|
||||
switch (timeRange) {
|
||||
case 'day':
|
||||
return '最近一天';
|
||||
case 'week':
|
||||
return '最近一周';
|
||||
case 'month':
|
||||
return '最近一个月';
|
||||
case 'all':
|
||||
return '全部';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取时间范围
|
||||
const getDateRange = (timeRange: string) => {
|
||||
const date = new Date();
|
||||
switch (timeRange) {
|
||||
case 'day':
|
||||
date.setDate(date.getDate() - 1);
|
||||
break;
|
||||
case 'week':
|
||||
date.setDate(date.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
date.setMonth(date.getMonth() - 1);
|
||||
break;
|
||||
case 'all':
|
||||
date.setFullYear(date.getFullYear() - 1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return [date, new Date()];
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>自动上传设置</Text>
|
||||
|
||||
<View style={styles.buttonGroup}>
|
||||
<Text style={styles.sectionTitle}>选择时间范围:</Text>
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.timeButton, timeRange === 'day' && styles.activeButton]}
|
||||
onPress={() => setTimeRange('day')}
|
||||
>
|
||||
<Text style={styles.buttonText}>一天</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.timeButton, timeRange === 'week' && styles.activeButton]}
|
||||
onPress={() => setTimeRange('week')}
|
||||
>
|
||||
<Text style={styles.buttonText}>一周</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.timeButton, timeRange === 'month' && styles.activeButton]}
|
||||
onPress={() => setTimeRange('month')}
|
||||
>
|
||||
<Text style={styles.buttonText}>一个月</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.timeButton, timeRange === 'all' && styles.activeButton]}
|
||||
onPress={() => setTimeRange('all')}
|
||||
>
|
||||
<Text style={styles.buttonText}>全部</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.dateRangeText}>
|
||||
{getDateRangeText(timeRange)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.uploadButtonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.uploadButton, isLoading && styles.uploadButtonDisabled]}
|
||||
onPress={handleManualUpload}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.uploadButtonText}>
|
||||
{isLoading ? '上传中...' : '开始上传'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className='mt-2'
|
||||
style={[styles.uploadButton]}
|
||||
onPress={() => router.push('/debug')}
|
||||
>
|
||||
<Text style={styles.uploadButtonText}>
|
||||
进入db调试页面
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
|
||||
{
|
||||
// isLoading &&
|
||||
(
|
||||
<UploaderProgressBar
|
||||
imageUrl={uploadProgress.currentFileUrl}
|
||||
uploadedCount={uploadProgress.uploadedCount}
|
||||
totalCount={uploadProgress.totalCount}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
buttonGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
marginBottom: 10,
|
||||
color: '#333',
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 10,
|
||||
gap: 10,
|
||||
},
|
||||
timeButton: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 15,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
activeButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderColor: '#007AFF',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#333',
|
||||
textAlign: 'center',
|
||||
},
|
||||
dateRangeText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 8,
|
||||
},
|
||||
uploadButtonContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
uploadButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 15,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
uploadButtonDisabled: {
|
||||
backgroundColor: '#84c1ff',
|
||||
},
|
||||
uploadButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
statusContainer: {
|
||||
marginTop: 30,
|
||||
padding: 15,
|
||||
backgroundColor: '#f8f8f8',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#eee',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 15,
|
||||
marginBottom: 5,
|
||||
color: '#333',
|
||||
},
|
||||
hintText: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
},
|
||||
loadingContainer: {
|
||||
marginTop: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 10,
|
||||
color: '#666',
|
||||
},
|
||||
});
|
||||
@ -1,6 +1,8 @@
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { defaultExifData, ExifData, ImagesuploaderProps, UploadUrlResponse } from '@/types/upload';
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import { addMaterial, confirmUpload, getUploadUrl } from '@/lib/background-uploader/api';
|
||||
import { ConfirmUpload, ExifData, FileUploadItem, ImagesuploaderProps, UploadResult, UploadTask, defaultExifData } from '@/lib/background-uploader/types';
|
||||
import { uploadFileWithProgress } from '@/lib/background-uploader/uploader';
|
||||
import { compressImage } from '@/lib/image-process/imageCompress';
|
||||
import { createVideoThumbnailFile } from '@/lib/video-process/videoThumbnail';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as Location from 'expo-location';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
@ -8,46 +10,6 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Platform, TouchableOpacity, View } from 'react-native';
|
||||
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,
|
||||
@ -61,7 +23,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
fileType = ['images'],
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [files, setFiles] = useState<FileUploadItem[]>([]);
|
||||
const [uploadQueue, setUploadQueue] = useState<FileUploadItem[]>([]);
|
||||
|
||||
// 请求权限
|
||||
@ -81,192 +43,10 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
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> => {
|
||||
console.log("asset111111", asset);
|
||||
|
||||
const fileId = `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const isVideo = asset.type === 'video';
|
||||
const uploadResults: UploadResult = {
|
||||
@ -282,12 +62,14 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
// 创建上传项
|
||||
const newFileItem: FileUploadItem = {
|
||||
id: fileId,
|
||||
uri: asset.uri,
|
||||
previewUrl: asset.uri, // 使用 asset.uri 作为初始预览
|
||||
name: asset.fileName || 'file',
|
||||
progress: 0,
|
||||
status: 'uploading' as const,
|
||||
error: null,
|
||||
status: 'uploading',
|
||||
error: undefined,
|
||||
type: isVideo ? 'video' : 'image',
|
||||
thumbnail: null,
|
||||
thumbnail: undefined,
|
||||
};
|
||||
|
||||
setUploadQueue(prev => [...prev, newFileItem]);
|
||||
@ -313,28 +95,14 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
{ 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' }
|
||||
);
|
||||
// 使用复用函数生成视频缩略图
|
||||
thumbnailFile = await createVideoThumbnailFile(asset, 300);
|
||||
} else {
|
||||
// 处理图片
|
||||
const [originalResponse, compressedFileResult] = await Promise.all([
|
||||
fetch(asset.uri),
|
||||
ImageManipulator.manipulateAsync(
|
||||
asset.uri,
|
||||
[{ resize: { width: 800 } }],
|
||||
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
|
||||
)
|
||||
]);
|
||||
// 处理图片,主图和缩略图都用 compressImage 方法
|
||||
// 主图压缩(按 maxWidth/maxHeight/compressQuality)
|
||||
const { file: compressedFile } = await compressImage(asset.uri, maxWidth);
|
||||
// 缩略图压缩(宽度800)
|
||||
const { file: thumbFile } = await compressImage(asset.uri, 800);
|
||||
|
||||
// 如果保留 EXIF 数据,则获取
|
||||
if (preserveExif && asset.exif) {
|
||||
@ -355,20 +123,10 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
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' }
|
||||
);
|
||||
// 用压缩后主图作为上传主文件
|
||||
file = compressedFile as File;
|
||||
// 用缩略图文件作为预览
|
||||
thumbnailFile = thumbFile as File;
|
||||
}
|
||||
|
||||
// 准备上传任务
|
||||
@ -398,8 +156,23 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
const uploadResultsList = [];
|
||||
for (const task of uploadTasks) {
|
||||
try {
|
||||
const result = await uploadWithProgress(task.file, task.metadata);
|
||||
uploadResultsList.push(result);
|
||||
// 统一通过 lib 的 uploadFileWithProgress 实现上传
|
||||
const uploadUrlData = await getUploadUrl(task.file, { ...task.metadata, GPSVersionID: undefined });
|
||||
const taskIndex = uploadTasks.indexOf(task);
|
||||
const totalTasks = uploadTasks.length;
|
||||
const baseProgress = (taskIndex / totalTasks) * 100;
|
||||
|
||||
await uploadFileWithProgress(
|
||||
task.file,
|
||||
uploadUrlData.upload_url,
|
||||
(progress) => {
|
||||
const taskProgress = progress.total > 0 ? (progress.loaded / progress.total) * (100 / totalTasks) : 0;
|
||||
updateProgress(baseProgress + taskProgress);
|
||||
},
|
||||
30000
|
||||
);
|
||||
const result = await confirmUpload(uploadUrlData.file_id);
|
||||
uploadResultsList.push({ ...result, file_id: uploadUrlData.file_id, upload_url: uploadUrlData.upload_url });
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
throw error;
|
||||
@ -420,7 +193,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
item.id === fileId
|
||||
? {
|
||||
...item,
|
||||
status: 'done' as const,
|
||||
status: 'success' as const,
|
||||
progress: 100,
|
||||
thumbnail: uploadResults.thumbnail
|
||||
}
|
||||
@ -432,7 +205,7 @@ export const ImagesUploader: React.FC<ImagesuploaderProps> = ({
|
||||
if (uploadResults.originalFile?.file_id) {
|
||||
await addMaterial(
|
||||
uploadResults.originalFile.file_id,
|
||||
uploadResults.thumbnail
|
||||
uploadResults.compressedFile?.file_id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
315
components/file-upload/getTotal.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import React, { useState } from 'react';
|
||||
import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface MediaStats {
|
||||
total: number;
|
||||
photos: number;
|
||||
videos: number;
|
||||
audios: number;
|
||||
others: number;
|
||||
byMonth: Record<string, number>;
|
||||
}
|
||||
|
||||
type TimeRange = 'day' | 'week' | 'month' | 'all';
|
||||
|
||||
const MediaStatsScreen = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [stats, setStats] = useState<MediaStats | null>(null);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('week'); // 默认显示一周
|
||||
|
||||
const getDateRange = (range: TimeRange) => {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
|
||||
switch (range) {
|
||||
case 'day':
|
||||
start.setDate(now.getDate() - 1);
|
||||
break;
|
||||
case 'week':
|
||||
start.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
start.setMonth(now.getMonth() - 1);
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
return null; // 返回 null 表示不限制时间范围
|
||||
}
|
||||
|
||||
return { start, end: now };
|
||||
};
|
||||
|
||||
const getMediaStatistics = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 1. 请求媒体库权限
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限被拒绝', '需要访问媒体库权限来获取统计信息');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 设置时间范围,直接使用Date对象
|
||||
const dateRange = getDateRange(timeRange);
|
||||
|
||||
// 3. 分页获取媒体资源
|
||||
let allAssets: MediaLibrary.Asset[] = [];
|
||||
let hasNextPage = true;
|
||||
let after: MediaLibrary.AssetRef | undefined = undefined;
|
||||
const pageSize = 100; // 增加每次获取的数量以提高效率
|
||||
|
||||
while (hasNextPage) {
|
||||
const media = await MediaLibrary.getAssetsAsync({
|
||||
first: pageSize,
|
||||
after,
|
||||
sortBy: ['creationTime'],
|
||||
mediaType: ['photo', 'video', 'audio'],
|
||||
createdAfter: dateRange?.start,
|
||||
createdBefore: dateRange?.end,
|
||||
});
|
||||
|
||||
if (media.assets.length > 0) {
|
||||
allAssets.push(...media.assets);
|
||||
}
|
||||
|
||||
hasNextPage = media.hasNextPage;
|
||||
after = media.endCursor;
|
||||
|
||||
// 可选:增加一个最大获取上限,防止无限循环
|
||||
if (allAssets.length > 2000) {
|
||||
console.warn('已达到2000个媒体文件的上限');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`总共获取到 ${allAssets.length} 个媒体文件`);
|
||||
|
||||
// 4. 使用 reduce 进行统计,更高效
|
||||
const stats = allAssets.reduce<MediaStats>((acc, asset) => {
|
||||
acc.total++;
|
||||
switch (asset.mediaType) {
|
||||
case 'photo':
|
||||
acc.photos++;
|
||||
break;
|
||||
case 'video':
|
||||
acc.videos++;
|
||||
break;
|
||||
case 'audio':
|
||||
acc.audios++;
|
||||
break;
|
||||
default:
|
||||
acc.others++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (asset.creationTime) {
|
||||
const date = new Date(asset.creationTime);
|
||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
acc.byMonth[monthKey] = (acc.byMonth[monthKey] || 0) + 1;
|
||||
}
|
||||
return acc;
|
||||
}, {
|
||||
total: 0, photos: 0, videos: 0, audios: 0, others: 0, byMonth: {},
|
||||
});
|
||||
|
||||
setStats(stats);
|
||||
} catch (error) {
|
||||
console.error('获取媒体库统计信息失败:', error);
|
||||
Alert.alert('错误', '获取媒体库统计信息失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 时间范围选择器
|
||||
const TimeRangeSelector = () => (
|
||||
<View style={styles.timeRangeContainer}>
|
||||
{(['day', 'week', 'month', 'all'] as TimeRange[]).map((range) => (
|
||||
<TouchableOpacity
|
||||
key={range}
|
||||
style={[
|
||||
styles.timeRangeButton,
|
||||
timeRange === range && styles.timeRangeButtonActive
|
||||
]}
|
||||
onPress={() => {
|
||||
setTimeRange(range);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={[
|
||||
styles.timeRangeButtonText,
|
||||
timeRange === range && styles.timeRangeButtonTextActive
|
||||
]}>
|
||||
{{
|
||||
day: '今天',
|
||||
week: '本周',
|
||||
month: '本月',
|
||||
all: '全部'
|
||||
}[range]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>媒体库统计</Text>
|
||||
</View>
|
||||
|
||||
<TimeRangeSelector />
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={getMediaStatistics}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>获取媒体库统计</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{stats && (
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statsRow}>
|
||||
<StatItem label="总文件数" value={stats.total.toString()} />
|
||||
<StatItem label="照片" value={stats.photos.toString()} />
|
||||
</View>
|
||||
<View style={styles.statsRow}>
|
||||
<StatItem label="视频" value={stats.videos.toString()} />
|
||||
<StatItem label="音频" value={stats.audios.toString()} />
|
||||
<StatItem label="其他" value={stats.others.toString()} />
|
||||
</View>
|
||||
|
||||
<View style={styles.monthlyContainer}>
|
||||
<Text style={styles.sectionTitle}>按月统计</Text>
|
||||
{Object.entries(stats.byMonth)
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([month, count]) => (
|
||||
<View key={month} style={styles.monthlyItem}>
|
||||
<Text style={styles.monthText}>{month}</Text>
|
||||
<Text style={styles.countText}>{count} 个文件</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const StatItem = ({ label, value }: { label: string; value: string }) => (
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
timeRangeContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
timeRangeButton: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#f0f0f0',
|
||||
},
|
||||
timeRangeButtonActive: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
timeRangeButtonText: {
|
||||
color: '#666',
|
||||
fontSize: 14,
|
||||
},
|
||||
timeRangeButtonTextActive: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
statsContainer: {
|
||||
backgroundColor: '#f8f8f8',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#007AFF',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
monthlyContainer: {
|
||||
marginTop: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 12,
|
||||
},
|
||||
monthlyItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
monthText: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
},
|
||||
countText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
});
|
||||
|
||||
export default MediaStatsScreen;
|
||||
@ -186,6 +186,7 @@ export const ImagesPicker: React.FC<ImagesPickerProps> = ({
|
||||
const file = new File([blob], `compressed_${Date.now()}_${fileName}`, {
|
||||
type: mimeType,
|
||||
});
|
||||
console.log("压缩后的文件", file);
|
||||
|
||||
return { file, uri: manipResult.uri };
|
||||
} catch (error) {
|
||||
|
||||
62
components/file-upload/mediaLibraryUtils.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
|
||||
export type MediaItem = {
|
||||
id: string;
|
||||
uri: string;
|
||||
creationTime: number;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
// 获取指定时间范围内的图片
|
||||
export const getFilteredMedia = async (range: 'today' | 'week' | 'month' | 'all'): Promise<MediaItem[]> => {
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') throw new Error('Permission not granted');
|
||||
console.log("statusq111111111111111111", status);
|
||||
|
||||
let cutoffDate: Date;
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
cutoffDate = new Date();
|
||||
cutoffDate.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case 'week':
|
||||
cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
cutoffDate = new Date();
|
||||
cutoffDate.setMonth(cutoffDate.getMonth() - 1);
|
||||
break;
|
||||
default:
|
||||
cutoffDate = new Date(0); // 所有图片
|
||||
}
|
||||
console.log("cutoffDateq111111111111111111", cutoffDate);
|
||||
|
||||
const albums = await MediaLibrary.getAlbumsAsync({ includeSmartAlbums: true });
|
||||
console.log("albumsq111111111111111111", albums);
|
||||
let allAssets: MediaItem[] = [];
|
||||
|
||||
for (const album of albums) {
|
||||
const result = await MediaLibrary.getAssetsAsync({
|
||||
album: album.id,
|
||||
mediaType: ['photo'],
|
||||
first: 1000,
|
||||
});
|
||||
console.log("result111111111111", result);
|
||||
|
||||
const filtered = result.assets
|
||||
.filter(asset => asset.creationTime > cutoffDate.getTime())
|
||||
.map(asset => ({
|
||||
id: asset.id,
|
||||
uri: asset.uri,
|
||||
creationTime: asset.creationTime,
|
||||
filename: asset.filename,
|
||||
}));
|
||||
console.log("filtered111111111111", filtered);
|
||||
|
||||
}
|
||||
|
||||
console.log("allAssetsq111111111111111111", allAssets);
|
||||
return allAssets;
|
||||
};
|
||||
107
components/file-upload/upload-progress/progress-bar.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native';
|
||||
import * as Progress from 'react-native-progress';
|
||||
|
||||
interface UploaderProgressBarProps {
|
||||
imageUrl: string;
|
||||
uploadedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const UploaderProgressBar: React.FC<UploaderProgressBarProps> = ({
|
||||
imageUrl,
|
||||
uploadedCount,
|
||||
totalCount,
|
||||
}) => {
|
||||
const progress = totalCount > 0 ? (uploadedCount / totalCount) * 100 : 0;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.imageContainer}>
|
||||
<Image source={{ uri: imageUrl }} style={styles.thumbnail} />
|
||||
</View>
|
||||
<View style={styles.progressSection}>
|
||||
<View style={styles.progressInfo}>
|
||||
<Text style={styles.statusText}>{t('upload.uploading', { ns: 'upload' })}</Text>
|
||||
<ActivityIndicator size={12} color="#4A4A4A" className='ml-2' />
|
||||
</View>
|
||||
<Progress.Bar
|
||||
progress={progress / 100}
|
||||
width={null} // Fills the container
|
||||
height={4}
|
||||
color={'#4A4A4A'}
|
||||
unfilledColor={'rgba(255, 255, 255, 0.5)'}
|
||||
borderWidth={0}
|
||||
style={styles.progressBar}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.countText}>{`${uploadedCount}/${totalCount}`}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F5B941', // From image
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
marginHorizontal: 0,
|
||||
height: 60,
|
||||
},
|
||||
imageContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
// This container helps with the skewed frame effect
|
||||
transform: [{ rotate: '-5deg' }],
|
||||
marginRight: 8,
|
||||
},
|
||||
thumbnail: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
borderWidth: 2,
|
||||
borderColor: 'white',
|
||||
transform: [{ rotate: '5deg' }], // Counter-rotate to keep image straight
|
||||
},
|
||||
progressSection: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
marginRight: 15,
|
||||
},
|
||||
progressInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
activityIndicator: {
|
||||
marginLeft: 5,
|
||||
},
|
||||
progressText: {
|
||||
color: '#4A4A4A',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 12,
|
||||
marginRight: 8,
|
||||
},
|
||||
statusText: {
|
||||
color: '#4A4A4A',
|
||||
fontSize: 12,
|
||||
},
|
||||
progressBar: {
|
||||
width: '100%',
|
||||
},
|
||||
countText: {
|
||||
color: '#4A4A4A',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default UploaderProgressBar;
|
||||
33
components/file-upload/upload-progress/uploader-progress.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import UploaderProgressBar from './progress-bar';
|
||||
|
||||
interface UploaderProgressProps {
|
||||
imageUrl: string;
|
||||
uploadedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const UploaderProgress: React.FC<UploaderProgressProps> = ({ imageUrl, uploadedCount, totalCount }) => {
|
||||
// This is now a 'dumb' component. It only displays the progress based on props.
|
||||
// All logic for fetching and state management is handled by the parent component (MemoList).
|
||||
|
||||
if (totalCount === 0) {
|
||||
// Don't render anything if there's no session or tasks.
|
||||
// The parent component's logic (`uploadSessionStartTime && ...`) already handles this,
|
||||
// but this is an extra safeguard.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'transparent' }}>
|
||||
<UploaderProgressBar
|
||||
imageUrl={imageUrl}
|
||||
uploadedCount={uploadedCount}
|
||||
totalCount={totalCount}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(UploaderProgress);
|
||||
93
components/file-upload/uploadQueueManager.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const QUEUE_KEY = 'uploadQueue';
|
||||
|
||||
export type UploadItem = {
|
||||
uri: string;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
// 存储队列
|
||||
export const saveUploadQueue = async (queue: UploadItem[]) => {
|
||||
await SecureStore.setItemAsync(QUEUE_KEY, JSON.stringify(queue));
|
||||
};
|
||||
|
||||
// 获取队列
|
||||
export const getUploadQueue = async (): Promise<UploadItem[]> => {
|
||||
const data = await SecureStore.getItemAsync(QUEUE_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
};
|
||||
|
||||
// 移除已上传项
|
||||
export const removeFromQueue = async (uri: string) => {
|
||||
const queue = await getUploadQueue();
|
||||
const newQueue = queue.filter(item => item.uri !== uri);
|
||||
await saveUploadQueue(newQueue);
|
||||
};
|
||||
|
||||
export const uploadMediaFile = async (asset: any) => {
|
||||
const uri = asset.uri;
|
||||
const filename = uri.split('/').pop() || 'file.jpg';
|
||||
const type =
|
||||
asset.mediaType === 'photo'
|
||||
? `image/${filename.split('.').pop()}`
|
||||
: `video/${filename.split('.').pop()}`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', { uri, name: filename, type } as any);
|
||||
|
||||
await getUploadUrl({
|
||||
...formData,
|
||||
name: filename,
|
||||
type,
|
||||
size: asset.fileSize
|
||||
}, {}).then((res) => {
|
||||
confirmUpload(res.file_id).then((confirmRes) => {
|
||||
addMaterial(res.file_id, confirmRes.file_id)
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
})
|
||||
};
|
||||
|
||||
// 获取上传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);
|
||||
})
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
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)));
|
||||
}
|
||||
};
|
||||
@ -1,138 +0,0 @@
|
||||
/**
|
||||
* 从视频文件中提取第一帧并返回为File对象
|
||||
* @param videoFile 视频文件
|
||||
* @returns 包含视频第一帧的File对象
|
||||
*/
|
||||
export const extractVideoFirstFrame = (videoFile: File): Promise<File> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const videoUrl = URL.createObjectURL(videoFile);
|
||||
const video = document.createElement('video');
|
||||
video.src = videoUrl;
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.muted = true;
|
||||
video.preload = 'metadata';
|
||||
|
||||
video.onloadeddata = () => {
|
||||
try {
|
||||
// 设置视频时间到第一帧
|
||||
video.currentTime = 0.1;
|
||||
} catch (e) {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
video.onseeked = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('无法获取canvas上下文');
|
||||
}
|
||||
|
||||
// 绘制视频帧到canvas
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 将canvas转换为DataURL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg');
|
||||
|
||||
// 将DataURL转换为Blob
|
||||
const byteString = atob(dataUrl.split(',')[1]);
|
||||
const mimeString = dataUrl.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
|
||||
// 创建File对象
|
||||
const frameFile = new File(
|
||||
[blob],
|
||||
`${videoFile.name.replace(/\.[^/.]+$/, '')}_frame.jpg`,
|
||||
{ type: 'image/jpeg' }
|
||||
);
|
||||
|
||||
// 清理URL对象
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
resolve(frameFile);
|
||||
} catch (e) {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(new Error('视频加载失败'));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 获取视频时长
|
||||
export const getVideoDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
resolve(video.duration);
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
resolve(0); // Return 0 if we can't get the duration
|
||||
};
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// 根据 mp4 的url来获取视频时长
|
||||
/**
|
||||
* 根据视频URL获取视频时长
|
||||
* @param videoUrl 视频的URL
|
||||
* @returns 返回一个Promise,解析为视频时长(秒)
|
||||
*/
|
||||
export const getVideoDurationFromUrl = async (videoUrl: string): Promise<number> => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
// 创建临时的video元素
|
||||
const video = document.createElement('video');
|
||||
|
||||
// 设置为只加载元数据,不加载整个视频
|
||||
video.preload = 'metadata';
|
||||
|
||||
// 处理加载成功
|
||||
video.onloadedmetadata = () => {
|
||||
// 释放URL对象
|
||||
URL.revokeObjectURL(video.src);
|
||||
// 返回视频时长(秒)
|
||||
resolve(video.duration);
|
||||
};
|
||||
|
||||
// 处理加载错误
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
reject(new Error('无法加载视频'));
|
||||
};
|
||||
|
||||
// 处理网络错误
|
||||
video.onabort = () => {
|
||||
URL.revokeObjectURL(video.src);
|
||||
reject(new Error('视频加载被中止'));
|
||||
};
|
||||
|
||||
// 设置视频源
|
||||
video.src = videoUrl;
|
||||
|
||||
// 添加跨域属性(如果需要)
|
||||
video.setAttribute('crossOrigin', 'anonymous');
|
||||
|
||||
// 开始加载元数据
|
||||
video.load();
|
||||
});
|
||||
};
|
||||
@ -1,11 +1,18 @@
|
||||
import NavbarSvg from "@/assets/icons/svg/navbar.svg";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import ChatInSvg from "@/assets/icons/svg/chatIn.svg";
|
||||
import ChatNotInSvg from "@/assets/icons/svg/chatNotIn.svg";
|
||||
import PersonInSvg from "@/assets/icons/svg/personIn.svg";
|
||||
import PersonNotInSvg from "@/assets/icons/svg/personNotIn.svg";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import React from 'react';
|
||||
import { Platform, TouchableOpacity, View } from 'react-native';
|
||||
import { Dimensions, Image, Platform, TouchableOpacity, View } from 'react-native';
|
||||
import { Circle, Ellipse, G, Mask, Path, Rect, Svg } from 'react-native-svg';
|
||||
|
||||
const AskNavbar = () => {
|
||||
// 获取设备尺寸
|
||||
const { width } = Dimensions.get('window');
|
||||
// 获取路由
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<View className="absolute bottom-0 left-0 right-0 bg-white" style={{
|
||||
shadowColor: '#000',
|
||||
@ -14,10 +21,11 @@ const AskNavbar = () => {
|
||||
shadowRadius: 8,
|
||||
elevation: 10, // For Android
|
||||
}}>
|
||||
<NavbarSvg className="w-full h-full" />
|
||||
{/* <NavbarSvg className="w-[150%] h-full" /> */}
|
||||
<Image source={require('@/assets/images/png/owner/ask.png')} style={{ width: width, height: 80, resizeMode: 'cover' }} />
|
||||
<View className="absolute bottom-0 top-0 left-0 right-0 flex flex-row justify-between items-center px-[2rem]">
|
||||
<TouchableOpacity onPress={() => router.push('/memo-list')} >
|
||||
<Ionicons name="chatbubbles-outline" size={24} color="#4C320C" />
|
||||
<TouchableOpacity onPress={() => router.push('/memo-list')} style={{ padding: 16 }}>
|
||||
{pathname === "/memo-list" ? <ChatInSvg width={24} height={24} /> : <ChatNotInSvg width={24} height={24} />}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
@ -27,7 +35,7 @@ const AskNavbar = () => {
|
||||
params: { newSession: "true" }
|
||||
});
|
||||
}}
|
||||
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : '-mt-[5rem] ml-[0.8rem]'}`}
|
||||
className={`${Platform.OS === 'web' ? '-mt-[4rem]' : width <= 375 ? '-mt-[5rem]' : '-mt-[5rem]'}`}
|
||||
>
|
||||
<View style={{
|
||||
shadowColor: '#FFB645',
|
||||
@ -67,9 +75,9 @@ const AskNavbar = () => {
|
||||
</Svg>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => router.push('/owner')}>
|
||||
<TouchableOpacity onPress={() => router.push('/owner')} style={{ padding: 16 }}>
|
||||
<View>
|
||||
<Ionicons name="person-outline" size={24} color="#4C320C" />
|
||||
{pathname === "/owner" ? <PersonInSvg width={24} height={24} /> : <PersonNotInSvg width={24} height={24} />}
|
||||
{/* <View className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full" /> */}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -43,11 +43,15 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi
|
||||
const res = await fetchApi<User>('/iam/login/password-login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}, true, false);
|
||||
login({ ...res, email: res?.account }, res.access_token || '');
|
||||
router.replace('/user-message');
|
||||
const userInfo = await fetchApi<User>("/iam/user-info");
|
||||
if (userInfo?.nickname) {
|
||||
router.replace('/ask');
|
||||
} else {
|
||||
router.replace('/user-message');
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('Login failed', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { useAuth } from "../../contexts/auth-context";
|
||||
import { fetchApi } from "../../lib/server-api-util";
|
||||
import { User } from "../../types/user";
|
||||
import { ThemedText } from "../ThemedText";
|
||||
import PrivacyModal from "../owner/qualification/privacy";
|
||||
|
||||
interface LoginProps {
|
||||
updateUrlParam: (status: string, value: string) => void;
|
||||
@ -25,7 +26,9 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
const [passwordsMatch, setPasswordsMatch] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
const [modalType, setModalType] = useState<'ai' | 'terms' | 'privacy' | 'user'>('ai');
|
||||
// 协议弹窗
|
||||
const [privacyModalVisible, setPrivacyModalVisible] = useState(false);
|
||||
// 从 URL 参数中获取 task_id 和 steps
|
||||
const params = useLocalSearchParams<{ task_id?: string; steps?: string }>();
|
||||
const taskId = params.task_id;
|
||||
@ -263,10 +266,10 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{t("auth.telLogin.agree", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => router.push({
|
||||
pathname: '/agreement',
|
||||
params: { type: 'service' }
|
||||
} as any)}>
|
||||
<TouchableOpacity onPress={() => {
|
||||
setModalType('terms');
|
||||
setPrivacyModalVisible(true);
|
||||
}}>
|
||||
<ThemedText className="text-sm !text-[#E2793F]">
|
||||
{t("auth.telLogin.terms", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
@ -274,10 +277,10 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{t("auth.telLogin.and", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => router.push({
|
||||
pathname: '/agreement',
|
||||
params: { type: 'privacy' }
|
||||
} as any)}>
|
||||
<TouchableOpacity onPress={() => {
|
||||
setModalType('privacy');
|
||||
setPrivacyModalVisible(true);
|
||||
}}>
|
||||
<ThemedText className="text-sm !text-[#E2793F]">
|
||||
{t("auth.telLogin.privacyPolicy", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
@ -285,14 +288,25 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{t("auth.telLogin.and", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => router.push({
|
||||
pathname: '/agreement',
|
||||
params: { type: 'user' }
|
||||
} as any)}>
|
||||
<TouchableOpacity onPress={() => {
|
||||
setModalType('user');
|
||||
setPrivacyModalVisible(true);
|
||||
}}>
|
||||
<ThemedText className="text-sm !text-[#E2793F]">
|
||||
{t("auth.telLogin.userAgreement", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{t("auth.telLogin.and", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => {
|
||||
setModalType('ai');
|
||||
setPrivacyModalVisible(true);
|
||||
}}>
|
||||
<ThemedText className="text-sm !text-[#E2793F]">
|
||||
{t("auth.telLogin.aiAgreement", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
<ThemedText className="text-sm !text-textPrimary">
|
||||
{t("auth.telLogin.agreement", { ns: 'login' })}
|
||||
</ThemedText>
|
||||
@ -319,6 +333,9 @@ const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: Log
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 协议弹窗 */}
|
||||
<PrivacyModal modalVisible={privacyModalVisible} setModalVisible={setPrivacyModalVisible} type={modalType} />
|
||||
</View>
|
||||
}
|
||||
|
||||
|
||||
76
components/message-push.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Button, Text, View } from 'react-native';
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export default function MessagePush() {
|
||||
const router = useRouter();
|
||||
const notificationListener = useRef<Notifications.Subscription>(null);
|
||||
const responseListener = useRef<Notifications.Subscription>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听通知点击事件
|
||||
responseListener.current = Notifications.addNotificationResponseReceivedListener(response => {
|
||||
const data = response.notification.request.content.data;
|
||||
console.log('通知被点击,数据:', data);
|
||||
|
||||
// 根据通知数据跳转到指定页面
|
||||
if (data.screen === 'ask') {
|
||||
router.push('/ask');
|
||||
} else if (data.screen === 'owner') {
|
||||
router.push('/owner');
|
||||
}
|
||||
});
|
||||
|
||||
// 清理监听器
|
||||
return () => {
|
||||
if (notificationListener.current) {
|
||||
Notifications.removeNotificationSubscription(notificationListener.current);
|
||||
}
|
||||
if (responseListener.current) {
|
||||
Notifications.removeNotificationSubscription(responseListener.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const sendNotification = async () => {
|
||||
// 请求通知权限
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
alert('请先允许通知权限');
|
||||
return;
|
||||
}
|
||||
|
||||
// 调度本地通知
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: '你有一条新消息 🎉',
|
||||
body: '点击查看详情内容',
|
||||
data: { screen: 'ask' },
|
||||
priority: 'high', // 关键:设置 high 或 max
|
||||
},
|
||||
trigger: {
|
||||
seconds: 2, // 延迟2秒显示
|
||||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL // 添加 type 字段
|
||||
}, // 延迟2秒显示
|
||||
});
|
||||
|
||||
alert('通知将在2秒后显示');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }}>
|
||||
<Text>点击按钮发送本地通知</Text>
|
||||
<Button title="发送通知" onPress={sendNotification} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
8
components/navigation/TabBarIcon.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
// This file is based on the default template provided by Expo.
|
||||
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
|
||||
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||
}
|
||||
@ -19,13 +19,22 @@ const ClassifyModal = (props: ClassifyModalProps) => {
|
||||
<Modal
|
||||
isVisible={modalVisible}
|
||||
onBackdropPress={() => setModalVisible(false)}
|
||||
swipeDirection="right" // 支持向右滑动关闭
|
||||
swipeDirection={['right']} // 改为数组形式
|
||||
propagateSwipe={true}
|
||||
animationIn="slideInRight" // 入场动画
|
||||
animationOut="slideOutRight" // 出场动画
|
||||
animationIn="slideInRight"
|
||||
animationOut="slideOutRight"
|
||||
backdropOpacity={0.5}
|
||||
onSwipeComplete={() => setModalVisible(false)}
|
||||
style={{ margin: 0, justifyContent: 'flex-start', marginTop: podiumPosition.height + podiumPosition.y }}
|
||||
onModalHide={() => {
|
||||
// 确保动画完全结束后再更新状态
|
||||
}}
|
||||
useNativeDriver={true} // 启用原生驱动
|
||||
hideModalContentWhileAnimating={true} // 动画时隐藏内容
|
||||
style={{
|
||||
margin: 0,
|
||||
justifyContent: 'flex-start',
|
||||
marginTop: podiumPosition.height + podiumPosition.y,
|
||||
}}
|
||||
>
|
||||
<View style={styles.modalView}>
|
||||
<View style={styles.modalHeader}>
|
||||
|
||||
@ -1,39 +1,41 @@
|
||||
import locationData from '@/assets/json/location.json';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Modal from 'react-native-modal';
|
||||
import Cascader, { CascaderItem } from '../cascader';
|
||||
import { convertRegions } from '../utils/cascaderData';
|
||||
|
||||
interface LocationModalProps {
|
||||
modalVisible: boolean;
|
||||
setModalVisible: (visible: boolean) => void;
|
||||
podiumPosition: { x: number, y: number, width: number, height: number };
|
||||
handleChange: (selectedItems: CascaderItem[]) => void;
|
||||
data: CascaderItem[];
|
||||
}
|
||||
|
||||
const LocationModal = React.memo((props: LocationModalProps) => {
|
||||
const { modalVisible, setModalVisible, podiumPosition, handleChange } = props;
|
||||
const transformed = convertRegions(locationData, {
|
||||
nameKey: 'name', // 源数据中表示"名称"的字段
|
||||
valueKey: 'name', // 源数据中作为 value 的字段
|
||||
regionsKey: 'regions', // 源数据中表示"子级区域"的字段
|
||||
childrenKey: 'children' // 输出结构中表示"子级区域"的字段
|
||||
});
|
||||
const { modalVisible, setModalVisible, podiumPosition, handleChange, data } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isVisible={modalVisible}
|
||||
onBackdropPress={() => setModalVisible(false)}
|
||||
swipeDirection="right" // 支持向右滑动关闭
|
||||
swipeDirection={['right']} // 改为数组形式
|
||||
propagateSwipe={true}
|
||||
animationIn="slideInRight" // 入场动画
|
||||
animationOut="slideOutRight" // 出场动画
|
||||
animationIn="slideInRight"
|
||||
animationOut="slideOutRight"
|
||||
backdropOpacity={0.5}
|
||||
onSwipeComplete={() => setModalVisible(false)}
|
||||
style={{ margin: 0, justifyContent: 'flex-start', marginTop: podiumPosition.height + podiumPosition.y }}
|
||||
onModalHide={() => {
|
||||
// 确保动画完全结束后再更新状态
|
||||
}}
|
||||
useNativeDriver={true} // 启用原生驱动
|
||||
hideModalContentWhileAnimating={true} // 动画时隐藏内容
|
||||
style={{
|
||||
margin: 0,
|
||||
justifyContent: 'flex-start',
|
||||
marginTop: podiumPosition.height + podiumPosition.y,
|
||||
}}
|
||||
>
|
||||
<View style={styles.modalView}>
|
||||
<View style={styles.modalHeader}>
|
||||
@ -45,7 +47,7 @@ const LocationModal = React.memo((props: LocationModalProps) => {
|
||||
</View>
|
||||
<ScrollView style={styles.modalContent} showsVerticalScrollIndicator={false}>
|
||||
<Cascader
|
||||
data={transformed}
|
||||
data={data}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
@ -3,22 +3,34 @@ import FirstSvg from "@/assets/icons/svg/first.svg";
|
||||
import SecondSvg from "@/assets/icons/svg/second.svg";
|
||||
import ThirdSvg from "@/assets/icons/svg/third.svg";
|
||||
import { RankingItem } from "@/types/user";
|
||||
import { useState } from "react";
|
||||
import { Image, StyleSheet, View } from "react-native";
|
||||
import { ThemedText } from "../ThemedText";
|
||||
interface IPodium {
|
||||
data: RankingItem[]
|
||||
}
|
||||
const PodiumComponent = ({ data }: IPodium) => {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.item, { opacity: data[1]?.user_id ? 1 : 0 }]}>
|
||||
<SecondSvg />
|
||||
<View style={[styles.titleContainer, { backgroundColor: '#FFB645', borderTopRightRadius: 0, height: 60 }]}>
|
||||
{
|
||||
data[1]?.user_avatar_url
|
||||
? <Image source={{ uri: data[1]?.user_avatar_url }} style={{ width: 30, height: 30, borderRadius: 30 }} />
|
||||
: <UserSvg width={30} height={30} />
|
||||
(() => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
if (!data[1]?.user_avatar_url || imageError) {
|
||||
return <UserSvg width={30} height={30} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: data[1].user_avatar_url }}
|
||||
style={{ width: 30, height: 30, borderRadius: 30 }}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
}
|
||||
<ThemedText
|
||||
numberOfLines={1}
|
||||
@ -33,9 +45,21 @@ const PodiumComponent = ({ data }: IPodium) => {
|
||||
<FirstSvg />
|
||||
<View style={[styles.titleContainer, { backgroundColor: '#E2793F', height: 90 }]}>
|
||||
{
|
||||
data[0]?.user_avatar_url
|
||||
? <Image source={{ uri: data[0]?.user_avatar_url }} style={{ width: 40, height: 40, borderRadius: 40 }} />
|
||||
: <UserSvg width={40} height={40} />
|
||||
(() => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
if (!data[0]?.user_avatar_url || imageError) {
|
||||
return <UserSvg width={40} height={40} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: data[0].user_avatar_url }}
|
||||
style={{ width: 40, height: 40, borderRadius: 40 }}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
}
|
||||
<ThemedText
|
||||
numberOfLines={2}
|
||||
@ -50,9 +74,21 @@ const PodiumComponent = ({ data }: IPodium) => {
|
||||
<ThirdSvg />
|
||||
<View style={[styles.titleContainer, { backgroundColor: '#FFD18A', borderTopLeftRadius: 0, height: 50 }]}>
|
||||
{
|
||||
data[2]?.user_avatar_url
|
||||
? <Image source={{ uri: data[2]?.user_avatar_url }} style={{ width: 20, height: 20, borderRadius: 20 }} />
|
||||
: <UserSvg width={20} height={20} />
|
||||
(() => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
if (!data[2]?.user_avatar_url || imageError) {
|
||||
return <UserSvg width={20} height={20} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: data[2].user_avatar_url }}
|
||||
style={{ width: 20, height: 20, borderRadius: 20 }}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
}
|
||||
<ThemedText
|
||||
numberOfLines={1}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { Policy } from '@/types/personal-info';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import RenderHtml from 'react-native-render-html';
|
||||
|
||||
const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible: boolean) => void, type: string }) => {
|
||||
@ -62,12 +62,8 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
onRequestClose={() => {
|
||||
setModalVisible(!modalVisible);
|
||||
}}>
|
||||
<Pressable
|
||||
style={styles.centeredView}
|
||||
onPress={() => setModalVisible(false)}>
|
||||
<Pressable
|
||||
style={styles.modalView}
|
||||
onPress={(e) => e.stopPropagation()}>
|
||||
<View style={styles.centeredView}>
|
||||
<View style={styles.modalView}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={{ opacity: 0 }}>Settings</Text>
|
||||
<Text style={styles.modalTitle}>{type === 'ai' ? 'AI Policy' : type === 'terms' ? 'Terms of Service' : type === 'privacy' ? 'Privacy Policy' : 'User Agreement'}</Text>
|
||||
@ -85,9 +81,8 @@ const PrivacyModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import AtaverSvg from "@/assets/icons/svg/ataver.svg";
|
||||
import OwnerSvg from "@/assets/icons/svg/owner.svg";
|
||||
import { RankingItem } from "@/types/user";
|
||||
import { useState } from "react";
|
||||
import { Image, ScrollView, StyleSheet, View } from "react-native";
|
||||
import { ThemedText } from "../ThemedText";
|
||||
interface IRankList {
|
||||
@ -37,14 +38,21 @@ const RankList = (props: IRankList) => {
|
||||
<ThemedText style={styles.itemRank}>{index + 1}</ThemedText>
|
||||
<ThemedText style={styles.itemName}>{item.user_nick_name}</ThemedText>
|
||||
<View style={{ opacity: index == 1 ? 0 : 1 }}>
|
||||
{item.user_avatar_url ? (
|
||||
<Image
|
||||
source={{ uri: item.user_avatar_url }}
|
||||
style={{ width: 40, height: 40, borderRadius: 40 }}
|
||||
/>
|
||||
) : (
|
||||
<AtaverSvg width={40} height={40} />
|
||||
)}
|
||||
{(() => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
if (!item.user_avatar_url || imageError) {
|
||||
return <AtaverSvg width={40} height={40} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: item.user_avatar_url }}
|
||||
style={{ width: 40, height: 40, borderRadius: 40 }}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@ -23,13 +23,11 @@ const Ranking = ({ data }: { data: TitleRankings[] }) => {
|
||||
data={data}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ width: "100%" }}
|
||||
keyExtractor={(item) => item.display_name}
|
||||
keyExtractor={(item, index) => `${item.display_name}-${index}`}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.item}>
|
||||
<ThemedText style={styles.rank}>No.{item.ranking}</ThemedText>
|
||||
<ThemedText style={styles.title}>{item.region}</ThemedText>
|
||||
<ThemedText style={styles.title}>{item.display_name}</ThemedText>
|
||||
<ThemedText style={styles.number}>{item.value}</ThemedText>
|
||||
<ThemedText style={styles.title}>{item.region}{item.display_name}</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
@ -73,8 +71,8 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: "100%",
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 8, // 建议加行高
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
rank: {
|
||||
fontSize: 20,
|
||||
@ -86,7 +84,6 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#4C320C',
|
||||
flex: 1,
|
||||
marginHorizontal: 8, // 新增
|
||||
},
|
||||
number: {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
||||
import { Dimensions, StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
||||
import * as Progress from 'react-native-progress';
|
||||
import { ThemedText } from "../ThemedText";
|
||||
import { formatBytes } from "../utils/bytes";
|
||||
@ -17,29 +17,20 @@ interface ResourceProps {
|
||||
isFormatBytes?: boolean;
|
||||
}
|
||||
const ResourceComponent = (props: ResourceProps) => {
|
||||
// 获取设备的宽度
|
||||
const width = Dimensions.get("window").width;
|
||||
return (
|
||||
<View style={[styles.container, props.style]}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.titleContent}>
|
||||
<ThemedText style={styles.title} className="!text-textSecondary">{props.title}</ThemedText>
|
||||
<ThemedText style={styles.subtitle} className="!text-textPrimary">{props.subtitle || " "}</ThemedText>
|
||||
<Progress.Bar progress={props.data.used / props.data.all} width={width * 0.5} color="#AC7E35" unfilledColor="#fff" borderWidth={0} borderRadius={8} height={2} />
|
||||
</View>
|
||||
<View>
|
||||
<View style={styles.dataContainer}>
|
||||
<ThemedText style={styles.dataText}>{props.isFormatBytes ? formatBytes(props.data.used) : props.data.used}/{props.isFormatBytes ? formatBytes(props.data.all) : props.data.all}</ThemedText>
|
||||
{props.icon}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.dataContainer}>
|
||||
<ThemedText style={styles.dataText}>{props.isFormatBytes ? formatBytes(props.data.used) : props.data.used}/{props.isFormatBytes ? formatBytes(props.data.all) : props.data.all}</ThemedText>
|
||||
<Progress.Bar
|
||||
progress={props.data.used / props.data.all}
|
||||
width={"100%"}
|
||||
color="#AC7E35"
|
||||
unfilledColor="#ddd"
|
||||
borderWidth={0}
|
||||
borderRadius={8}
|
||||
height={2}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@ -47,25 +38,33 @@ const ResourceComponent = (props: ResourceProps) => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: "100%",
|
||||
backgroundColor: "#FAF9F6",
|
||||
padding: 16,
|
||||
borderRadius: 18,
|
||||
position: "relative",
|
||||
},
|
||||
header: {
|
||||
content: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
titleContent: {
|
||||
width: "90%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
gap: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 12,
|
||||
fontWeight: "700",
|
||||
width: "25%"
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 10,
|
||||
fontWeight: "400",
|
||||
},
|
||||
|
||||
dataContainer: {
|
||||
flexDirection: "column",
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: -16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
dataText: {
|
||||
fontSize: 12,
|
||||
|
||||
@ -7,13 +7,13 @@ import * as Location from 'expo-location';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Linking, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Linking, Modal, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { ThemedText } from '../ThemedText';
|
||||
import LcensesModal from './qualification/lcenses';
|
||||
import PrivacyModal from './qualification/privacy';
|
||||
import CustomSwitch from './switch';
|
||||
import UserInfo from './userInfo';
|
||||
import { getLocationPermission, getPermissions, requestLocationPermission, requestMediaLibraryPermission, reverseGeocode } from './utils';
|
||||
import { checkNotificationPermission, getLocationPermission, getPermissions, requestLocationPermission, requestMediaLibraryPermission, requestNotificationPermission, reverseGeocode } from './utils';
|
||||
|
||||
const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible: boolean) => void, userInfo: User }) => {
|
||||
const { modalVisible, setModalVisible, userInfo } = props;
|
||||
@ -32,7 +32,17 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
};
|
||||
// 通知消息权限开关
|
||||
const [notificationsEnabled, setNotificationsEnabled] = useState(false);
|
||||
const toggleNotifications = () => setNotificationsEnabled(previous => !previous);
|
||||
const toggleNotifications = () => {
|
||||
if (notificationsEnabled) {
|
||||
// 引导去设置关闭权限
|
||||
openAppSettings()
|
||||
} else {
|
||||
console.log('请求通知权限');
|
||||
requestNotificationPermission().then((res) => {
|
||||
setNotificationsEnabled(res as boolean);
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// 相册权限
|
||||
const [albumEnabled, setAlbumEnabled] = useState(false);
|
||||
@ -126,7 +136,6 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
};
|
||||
|
||||
// 退出登录
|
||||
|
||||
const handleLogout = () => {
|
||||
fetchApi("/iam/logout", {
|
||||
method: "POST",
|
||||
@ -154,6 +163,11 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
getPermissions().then((res) => {
|
||||
setAlbumEnabled(res);
|
||||
})
|
||||
// 通知权限
|
||||
checkNotificationPermission().then((res) => {
|
||||
console.log('通知权限:', res);
|
||||
setNotificationsEnabled(res);
|
||||
})
|
||||
}
|
||||
}, [modalVisible])
|
||||
|
||||
@ -191,7 +205,7 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
currentLocation={currentLocation}
|
||||
/>
|
||||
{/* 升级版本 */}
|
||||
<View style={{ marginTop: 16 }}>
|
||||
{/* <View style={{ marginTop: 16 }}>
|
||||
<ThemedText style={{ marginLeft: 16, marginVertical: 8, color: '#AC7E35', fontSize: 14, fontWeight: '600' }}>{t('generalSetting.subscription', { ns: 'personal' })}</ThemedText>
|
||||
<View style={styles.premium}>
|
||||
<View>
|
||||
@ -209,9 +223,9 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View> */}
|
||||
{/* 消息通知 */}
|
||||
<View style={{ marginTop: 16 }}>
|
||||
{/* <View style={{ marginTop: 16 }}>
|
||||
<ThemedText style={{ marginLeft: 16, marginVertical: 8, color: '#AC7E35', fontSize: 14, fontWeight: '600' }}>{t('permission.pushNotification', { ns: 'personal' })}</ThemedText>
|
||||
<View style={styles.premium}>
|
||||
<View>
|
||||
@ -222,7 +236,7 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
toggleSwitch={toggleNotifications}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View> */}
|
||||
{/* 权限信息 */}
|
||||
<View style={{ marginTop: 16 }}>
|
||||
<ThemedText style={{ marginLeft: 16, marginVertical: 8, color: '#AC7E35', fontSize: 14, fontWeight: '600' }}>{t('permission.permissionManagement', { ns: 'personal' })}</ThemedText>
|
||||
@ -247,7 +261,16 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
toggleSwitch={toggleLocation}
|
||||
/>
|
||||
</View>
|
||||
{/* <Divider /> */}
|
||||
<Divider />
|
||||
<View style={styles.item}>
|
||||
<View>
|
||||
<ThemedText style={styles.itemText}>{t('permission.pushNotification', { ns: 'personal' })}</ThemedText>
|
||||
</View>
|
||||
<CustomSwitch
|
||||
isEnabled={notificationsEnabled}
|
||||
toggleSwitch={toggleNotifications}
|
||||
/>
|
||||
</View>
|
||||
{/* 相册成片权限 */}
|
||||
{/* <View style={styles.item}>
|
||||
<View>
|
||||
@ -284,26 +307,11 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
<View style={{ marginTop: 16 }}>
|
||||
<ThemedText style={{ marginLeft: 16, marginVertical: 8, color: '#AC7E35', fontSize: 14, fontWeight: '600' }}>{t('lcenses.title', { ns: 'personal' })}</ThemedText>
|
||||
<View style={styles.content}>
|
||||
<TouchableOpacity style={styles.item} onPress={() => { setLcensesModalVisible(true) }} >
|
||||
<ThemedText style={styles.itemText}>{t('lcenses.qualification', { ns: 'personal' })}</ThemedText>
|
||||
<RightArrowSvg />
|
||||
</TouchableOpacity>
|
||||
<Divider />
|
||||
<TouchableOpacity style={styles.item} onPress={() => Linking.openURL("https://beian.miit.gov.cn/")} >
|
||||
<ThemedText style={styles.itemText}>{t('lcenses.ICP', { ns: 'personal' })}沪ICP备2023032876号-4</ThemedText>
|
||||
<RightArrowSvg />
|
||||
</TouchableOpacity>
|
||||
<Divider />
|
||||
<TouchableOpacity style={styles.item} onPress={() => { setModalType('privacy'); setPrivacyModalVisible(true) }} >
|
||||
<ThemedText style={styles.itemText}>{t('lcenses.privacyPolicy', { ns: 'personal' })}</ThemedText>
|
||||
<RightArrowSvg />
|
||||
</TouchableOpacity>
|
||||
<Divider />
|
||||
<TouchableOpacity style={styles.item} onPress={() => { setModalType('ai'); setPrivacyModalVisible(true) }} >
|
||||
<ThemedText style={styles.itemText}>{t('lcenses.aiPolicy', { ns: 'personal' })}</ThemedText>
|
||||
<RightArrowSvg />
|
||||
</TouchableOpacity>
|
||||
<Divider />
|
||||
<TouchableOpacity style={styles.item} onPress={() => { setModalType('terms'); setPrivacyModalVisible(true) }} >
|
||||
<ThemedText style={styles.itemText}>{t('lcenses.applyPermission', { ns: 'personal' })}</ThemedText>
|
||||
<RightArrowSvg />
|
||||
@ -313,6 +321,21 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
<ThemedText style={styles.itemText}>{t('lcenses.userAgreement', { ns: 'personal' })}</ThemedText>
|
||||
<RightArrowSvg />
|
||||
</TouchableOpacity>
|
||||
<Divider />
|
||||
<TouchableOpacity style={styles.item} onPress={() => { setModalType('ai'); setPrivacyModalVisible(true) }} >
|
||||
<ThemedText style={styles.itemText}>{t('lcenses.aiPolicy', { ns: 'personal' })}</ThemedText>
|
||||
<RightArrowSvg />
|
||||
</TouchableOpacity>
|
||||
<Divider />
|
||||
<TouchableOpacity style={styles.item} onPress={() => { setLcensesModalVisible(true) }} >
|
||||
<ThemedText style={styles.itemText}>{t('lcenses.qualification', { ns: 'personal' })}</ThemedText>
|
||||
<RightArrowSvg />
|
||||
</TouchableOpacity>
|
||||
<Divider />
|
||||
<TouchableOpacity style={styles.item} onPress={() => Linking.openURL("https://beian.miit.gov.cn/")} >
|
||||
<ThemedText style={styles.itemText}>{t('lcenses.ICP', { ns: 'personal' })}沪ICP备2023032876号-4</ThemedText>
|
||||
<RightArrowSvg />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{/* 其他信息 */}
|
||||
@ -324,15 +347,6 @@ const SettingModal = (props: { modalVisible: boolean, setModalVisible: (visible:
|
||||
{/* <RightArrowSvg /> */}
|
||||
</TouchableOpacity>
|
||||
<Divider />
|
||||
{Platform.OS !== 'ios' && (
|
||||
<View>
|
||||
<TouchableOpacity style={styles.item} onPress={() => Linking.openURL("https://work.weixin.qq.com/kfid/kfca0ac87f4e05e8bfd")} >
|
||||
<ThemedText style={styles.itemText}>{t('generalSetting.cleanCache', { ns: 'personal' })}</ThemedText>
|
||||
{/* <RightArrowSvg /> */}
|
||||
</TouchableOpacity>
|
||||
<Divider />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.item}>
|
||||
<ThemedText style={styles.itemText}>{t('generalSetting.version', { ns: 'personal' })}</ThemedText>
|
||||
<ThemedText style={styles.itemText}>{"0.5.0"}</ThemedText>
|
||||
|
||||
@ -125,7 +125,13 @@ const UserInfo = (props: UserInfoProps) => {
|
||||
}
|
||||
<TouchableOpacity style={styles.edit} onPress={() => {
|
||||
setModalVisible(false);
|
||||
router.push('/user-message')
|
||||
// 携带参数跳转
|
||||
router.push({
|
||||
pathname: '/user-message',
|
||||
params: {
|
||||
username: "true"
|
||||
}
|
||||
});
|
||||
}}>
|
||||
<EditSvg />
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -1,25 +1,26 @@
|
||||
import UserSvg from '@/assets/icons/svg/ataver.svg';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { UserInfoDetails } from '@/types/user';
|
||||
// import { Image } from 'expo-image';
|
||||
import { useState } from 'react';
|
||||
import { Image, ScrollView, View } from 'react-native';
|
||||
export default function UserInfo({ userInfo }: { userInfo: UserInfoDetails }) {
|
||||
|
||||
// 添加状态来跟踪图片加载状态
|
||||
const [imageError, setImageError] = useState(false);
|
||||
return (
|
||||
<View className='flex flex-row items-center mt-[1rem] gap-[1rem]'>
|
||||
<View className='flex flex-row justify-between items-center mt-[1rem] gap-[1rem] w-full'>
|
||||
{/* 用户名 */}
|
||||
<View className='flex flex-col gap-4 w-[68vw]'>
|
||||
<View className='flex flex-col gap-4 w-[75%]'>
|
||||
<View className='flex flex-row items-center justify-between w-full'>
|
||||
<View className='flex flex-row items-center gap-2'>
|
||||
<View className='flex flex-row items-center gap-2 w-full'>
|
||||
<ThemedText
|
||||
className='max-w-[36vw] !text-textSecondary !font-semibold !text-2xl'
|
||||
className='max-w-[80%] !text-textSecondary !font-semibold !text-2xl'
|
||||
numberOfLines={1} // 限制为1行
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{userInfo?.user_info?.nickname}
|
||||
</ThemedText>
|
||||
<ScrollView
|
||||
className='max-w-[26vw] '
|
||||
className='max-w-[20%]'
|
||||
horizontal // 水平滚动
|
||||
showsHorizontalScrollIndicator={false} // 隐藏滚动条
|
||||
contentContainerStyle={{
|
||||
@ -40,29 +41,35 @@ export default function UserInfo({ userInfo }: { userInfo: UserInfoDetails }) {
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView
|
||||
className='max-w-[68vw]'
|
||||
horizontal // 水平滚动
|
||||
showsHorizontalScrollIndicator={false} // 隐藏滚动条
|
||||
contentContainerStyle={{
|
||||
flexDirection: 'row',
|
||||
gap: 8 // 间距
|
||||
}}
|
||||
>
|
||||
<ThemedText style={{ color: '#AC7E35', fontSize: 12, fontWeight: '600' }}>User ID:{userInfo?.user_info?.user_id}</ThemedText>
|
||||
</ScrollView>
|
||||
<View>
|
||||
<ScrollView
|
||||
className='max-w-[85%]'
|
||||
horizontal // 水平滚动
|
||||
showsHorizontalScrollIndicator={false} // 隐藏滚动条
|
||||
contentContainerStyle={{
|
||||
flexDirection: 'row',
|
||||
gap: 8 // 间距
|
||||
}}
|
||||
>
|
||||
<ThemedText style={{ color: '#AC7E35', fontSize: 12, fontWeight: '600' }}>User ID: {userInfo?.user_info?.user_id}</ThemedText>
|
||||
</ScrollView>
|
||||
{/* <CopyButton textToCopy={userInfo?.user_info?.user_id || ""} /> */}
|
||||
</View>
|
||||
|
||||
</View>
|
||||
{/* 头像 */}
|
||||
<View>
|
||||
{userInfo?.user_info?.avatar_file_url
|
||||
?
|
||||
<View className='w-auto'>
|
||||
{userInfo?.user_info?.avatar_file_url && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: "http://cdn.fairclip.cn/files/7348942720074911745/original_1752124481039_198d7b9a428c67f2bfd87ec128daad1b.jpg" }}
|
||||
source={{ uri: userInfo.user_info.avatar_file_url }}
|
||||
style={{ width: 80, height: 80, borderRadius: 40 }}
|
||||
onError={() => {
|
||||
setImageError(true);
|
||||
}}
|
||||
/>
|
||||
:
|
||||
) : (
|
||||
<UserSvg width={80} height={80} />
|
||||
}
|
||||
)}
|
||||
</View>
|
||||
</View >
|
||||
);
|
||||
|
||||
@ -1,11 +1,33 @@
|
||||
// 地理位置逆编码
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as Location from 'expo-location';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { Alert, Linking, Platform } from 'react-native';
|
||||
|
||||
interface Address {
|
||||
id: number;
|
||||
name: string;
|
||||
// Add other address properties as needed
|
||||
}
|
||||
|
||||
// 配置通知处理器
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
|
||||
// 逆编码
|
||||
export const reverseGeocode = async (latitude: number, longitude: number) => {
|
||||
try {
|
||||
const addressResults = await Location.reverseGeocodeAsync({ latitude, longitude });
|
||||
const addressResults = await fetchApi<Address[]>(`/area/gecoding?latitude=${latitude}&longitude=${longitude}`);
|
||||
console.log('地址:', addressResults);
|
||||
for (let address of addressResults) {
|
||||
console.log('地址:', address);
|
||||
if (Platform.OS === 'web') {
|
||||
@ -16,7 +38,7 @@ export const reverseGeocode = async (latitude: number, longitude: number) => {
|
||||
return address;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('逆地理编码失败:', error);
|
||||
console.log('逆地理编码失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@ -192,4 +214,128 @@ export const requestMediaLibraryPermission = async (showAlert: boolean = true):
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 检查通知权限
|
||||
export const checkNotificationPermission = async () => {
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
console.log('当前通知权限状态:', status);
|
||||
|
||||
return status === 'granted';
|
||||
};
|
||||
|
||||
// 请求通知权限
|
||||
export const requestNotificationPermission = async () => {
|
||||
try {
|
||||
// 1. 先检查当前权限状态
|
||||
const { status, canAskAgain } = await Notifications.getPermissionsAsync();
|
||||
console.log('当前通知权限状态:', { status, canAskAgain });
|
||||
|
||||
// 2. 如果已经有权限,直接返回
|
||||
if (status === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 如果用户之前选择了"拒绝且不再询问"
|
||||
if (status === 'denied' && !canAskAgain) {
|
||||
// 显示提示,引导用户去设置
|
||||
const openSettings = await new Promise(resolve => {
|
||||
Alert.alert(
|
||||
'需要通知权限',
|
||||
'您之前拒绝了通知权限。要使用此功能,请在设置中启用通知权限。',
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
onPress: () => resolve(false)
|
||||
},
|
||||
{
|
||||
text: '去设置',
|
||||
onPress: () => resolve(true)
|
||||
}
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
if (openSettings) {
|
||||
// 打开应用设置
|
||||
await Linking.openSettings();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 如果是第一次请求或可以再次询问,则请求权限
|
||||
console.log('请求通知权限...');
|
||||
const { status: newStatus } = await Notifications.requestPermissionsAsync();
|
||||
console.log('新通知权限状态:', newStatus);
|
||||
|
||||
if (newStatus !== 'granted') {
|
||||
Alert.alert('需要通知权限', '请允许通知以使用此功能');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('请求通知权限时出错:', error);
|
||||
Alert.alert('错误', '请求通知权限时出错');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 发送本地通知的辅助函数
|
||||
export const sendLocalNotification = async (title: string, body: string, data: Record<string, any> = {}) => {
|
||||
try {
|
||||
const hasPermission = await checkNotificationPermission();
|
||||
if (!hasPermission) {
|
||||
const granted = await requestNotificationPermission();
|
||||
if (!granted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title,
|
||||
body,
|
||||
data,
|
||||
priority: 'high',
|
||||
},
|
||||
trigger: null, // 立即触发
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('发送通知时出错:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 获取定位信息 -- 最子集元素
|
||||
export function findInnermostElement(data: any[], targetName: string): { name: string; value: any } | null {
|
||||
let result: { name: string; value: any } | null = null;
|
||||
|
||||
function search(nodes: any[]): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.name === targetName) {
|
||||
result = { name: node.name, value: node.value };
|
||||
// Keep searching to see if there's a deeper match
|
||||
let foundDeeper = false;
|
||||
if (node.children && node.children.length > 0) {
|
||||
foundDeeper = search(node.children);
|
||||
}
|
||||
// If no deeper match was found, this is the innermost one
|
||||
if (!foundDeeper) {
|
||||
return true;
|
||||
}
|
||||
} else if (node.children && node.children.length > 0) {
|
||||
const found = search(node.children);
|
||||
if (found) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
search(data);
|
||||
return result;
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { Steps } from '@/app/(tabs)/user-message';
|
||||
import ChoicePhoto from '@/assets/icons/svg/choicePhoto.svg';
|
||||
import LookSvg from '@/assets/icons/svg/look.svg';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { FileUploadItem } from '@/types/upload';
|
||||
import { FileUploadItem } from '@/lib/background-uploader/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, Image, TouchableOpacity, View } from 'react-native';
|
||||
import FilesUploader from '../file-upload/files-uploader';
|
||||
@ -32,11 +32,11 @@ export default function Look(props: Props) {
|
||||
{t('auth.userMessage.avatorText2', { ns: 'login' })}
|
||||
</ThemedText>
|
||||
{
|
||||
fileData[0]?.preview
|
||||
fileData[0]?.previewUrl
|
||||
?
|
||||
<Image
|
||||
className='rounded-full w-[10rem] h-[10rem]'
|
||||
source={{ uri: fileData[0].preview }}
|
||||
source={{ uri: fileData[0].previewUrl }}
|
||||
/>
|
||||
:
|
||||
avatar
|
||||
@ -60,7 +60,10 @@ export default function Look(props: Props) {
|
||||
{t('auth.userMessage.choosePhoto', { ns: 'login' })}
|
||||
</ThemedText>
|
||||
</View>
|
||||
} />
|
||||
}
|
||||
/>
|
||||
{/* <AutoUploadScreen /> */}
|
||||
{/* <MediaStatsScreen /> */}
|
||||
</View>
|
||||
|
||||
<View className="w-full">
|
||||
|
||||
42
components/utils/objectFlat.ts
Normal file
@ -0,0 +1,42 @@
|
||||
interface RawData {
|
||||
uri: string;
|
||||
exif?: Record<string, any>;
|
||||
location?: {
|
||||
latitude?: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
export function transformData(data: RawData): RawData {
|
||||
const result = { ...data };
|
||||
|
||||
if (result.exif) {
|
||||
const newExif: Record<string, any> = {};
|
||||
|
||||
for (const key in result.exif) {
|
||||
const value = result.exif[key];
|
||||
|
||||
// 普通对象:{Exif}, {TIFF}, {XMP} 等
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
if (key === '{GPS}') {
|
||||
// 处理 GPS 字段:所有子字段加前缀 "GPS"
|
||||
for (const subKey in value) {
|
||||
const newKey = 'GPS' + subKey; // 所有字段都加前缀
|
||||
newExif[newKey] = value[subKey];
|
||||
}
|
||||
} else {
|
||||
// 其它嵌套对象直接展开字段
|
||||
for (const subKey in value) {
|
||||
newExif[subKey] = value[subKey];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 非对象字段保留原样
|
||||
newExif[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
result.exif = newExif;
|
||||
}
|
||||
// 最后将result的exif信息平铺
|
||||
return { ...result, ...result.exif, exif: undefined };
|
||||
}
|
||||
@ -92,7 +92,13 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
const logout = () => {
|
||||
// 清除 Redux store 中的认证信息
|
||||
dispatch(clearCredentials());
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
localStorage.setItem('user', "");
|
||||
localStorage.setItem('token', "");
|
||||
} else {
|
||||
SecureStore.setItemAsync('user', "");
|
||||
SecureStore.setItemAsync('token', "");
|
||||
}
|
||||
// 触发事件通知
|
||||
eventEmitter.emit(EVENT_TYPES.USER_INFO_UPDATED, null);
|
||||
|
||||
|
||||
54
features/appState/appStateSlice.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { getAppState, setAppState } from '../../lib/db';
|
||||
|
||||
interface AppState {
|
||||
uploadSessionStartTime: number | null;
|
||||
status: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
uploadSessionStartTime: null,
|
||||
status: 'idle',
|
||||
};
|
||||
|
||||
// Thunk to fetch the session start time from the database
|
||||
export const syncUploadSessionState = createAsyncThunk(
|
||||
'appState/syncUploadSessionState',
|
||||
async () => {
|
||||
const startTimeStr = await getAppState('uploadSessionStartTime');
|
||||
return startTimeStr ? parseInt(startTimeStr, 10) : null;
|
||||
}
|
||||
);
|
||||
|
||||
// Thunk to clear the session state in the database, which will then be reflected in the store
|
||||
export const endUploadSessionInDb = createAsyncThunk(
|
||||
'appState/endUploadSessionInDb',
|
||||
async () => {
|
||||
await setAppState('uploadSessionStartTime', null);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
const appStateSlice = createSlice({
|
||||
name: 'appState',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(syncUploadSessionState.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
})
|
||||
.addCase(syncUploadSessionState.fulfilled, (state, action: PayloadAction<number | null>) => {
|
||||
state.status = 'succeeded';
|
||||
state.uploadSessionStartTime = action.payload;
|
||||
})
|
||||
.addCase(syncUploadSessionState.rejected, (state) => {
|
||||
state.status = 'failed';
|
||||
})
|
||||
.addCase(endUploadSessionInDb.fulfilled, (state, action: PayloadAction<null>) => {
|
||||
state.uploadSessionStartTime = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default appStateSlice.reducer;
|
||||
93
hooks/useUploadManager.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { useAppDispatch, useAppSelector } from "@/store";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import { endUploadSessionInDb, syncUploadSessionState } from "@/features/appState/appStateSlice";
|
||||
import { triggerManualUpload } from "@/lib/background-uploader/automatic";
|
||||
import { exist_pending_tasks, getUploadTasksSince, UploadTask } from "@/lib/db";
|
||||
|
||||
export const useUploadManager = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const uploadSessionStartTime = useAppSelector((state) => state.appState.uploadSessionStartTime);
|
||||
const [progressInfo, setProgressInfo] = useState({ total: 0, completed: 0, image: '' });
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let isActive = true;
|
||||
let interval: any = null;
|
||||
|
||||
const manageUploadState = async (restore_session: boolean = false) => {
|
||||
if (!isActive) {
|
||||
console.log('useUploadManager manageUploadState is not active');
|
||||
return;
|
||||
}
|
||||
|
||||
// 首先,同步Redux中的会话开始时间
|
||||
const action = await dispatch(syncUploadSessionState());
|
||||
const sessionStartTime = action.payload as number | null;
|
||||
|
||||
if (sessionStartTime) {
|
||||
// 如果会话存在,则获取任务进度
|
||||
const allTasks = await getUploadTasksSince(sessionStartTime);
|
||||
const total = allTasks.length;
|
||||
const completed = allTasks.filter((t: UploadTask) => t.status === 'success' || t.status === 'failed' || t.status === 'skipped').length;
|
||||
const pending = allTasks.filter((t: UploadTask) => t.status === 'pending' || t.status === 'uploading');
|
||||
|
||||
if (isActive) {
|
||||
setProgressInfo({ total, completed, image: allTasks[0]?.uri || '' });
|
||||
}
|
||||
|
||||
// 如果任务完成,则结束会话并清除定时器
|
||||
if (total > 0 && pending.length === 0) {
|
||||
console.log('useUploadManager detects all tasks are complete. Ending session.');
|
||||
if (interval) clearInterval(interval);
|
||||
dispatch(endUploadSessionInDb());
|
||||
}
|
||||
} else {
|
||||
// 如果没有会话,确保本地状态被重置
|
||||
if (isActive) {
|
||||
setProgressInfo({ total: 0, completed: 0, image: '' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initializeUploadProcess = async () => {
|
||||
// First, check if a session is already active.
|
||||
const action = await dispatch(syncUploadSessionState());
|
||||
const existingSessionStartTime = action.payload as number | null;
|
||||
const existPendingTasks = await exist_pending_tasks();
|
||||
|
||||
if (existingSessionStartTime && existPendingTasks) {
|
||||
console.log('useUploadManager focused, existing session found. Monitoring progress.');
|
||||
// If a session exists, just start monitoring.
|
||||
manageUploadState(true); // Initial check
|
||||
interval = setInterval(manageUploadState, 2000);
|
||||
} else {
|
||||
// If no session, then try to trigger a new upload.
|
||||
console.log('useUploadManager focused, no existing session. Triggering foreground media upload check.');
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const newSessionStartTimeStr = await triggerManualUpload(oneDayAgo, now);
|
||||
|
||||
if (newSessionStartTimeStr) {
|
||||
console.log(`New upload session started with time: ${newSessionStartTimeStr}, beginning to monitor...`);
|
||||
// A new session was started, so start monitoring.
|
||||
manageUploadState(); // Initial check
|
||||
interval = setInterval(manageUploadState, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeUploadProcess();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [dispatch])
|
||||
);
|
||||
|
||||
return { progressInfo, uploadSessionStartTime };
|
||||
};
|
||||
@ -3,6 +3,8 @@
|
||||
"hi": "Hi,",
|
||||
"iAmMemo": "I'm Memo!",
|
||||
"ready": "Ready to wake up your memories?",
|
||||
"justAsk": "Just ask MeMo, let me bring them back to life!"
|
||||
"justAsk": "Just ask MeMo, let me bring them back to life!",
|
||||
"selectPhoto": "Select Photo",
|
||||
"continueAsking": "Continue Asking"
|
||||
}
|
||||
}
|
||||
@ -48,7 +48,8 @@
|
||||
"codeVaild": "The code you entered is invalid",
|
||||
"sendAgain": "Did’nt receive a code?",
|
||||
"resend": "Resend",
|
||||
"goBack": "Go Back"
|
||||
"goBack": "Go Back",
|
||||
"aiAgreement": "AI Function Usage Norms"
|
||||
},
|
||||
"login": {
|
||||
"title": "Log in",
|
||||
|
||||
@ -82,6 +82,7 @@
|
||||
"live": "Live Photos",
|
||||
"videoLength": "Video Duration",
|
||||
"storiesCreated": "Stories Created",
|
||||
"conversationsWithMemo": "Conversations with Memo"
|
||||
"conversationsWithMemo": "Conversations with Memo",
|
||||
"setting": "Settings"
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,8 @@
|
||||
"task": "Task",
|
||||
"taskName": "dynamic family portrait",
|
||||
"taskStatus": "processing",
|
||||
"noName": "No Name"
|
||||
"noName": "No Name",
|
||||
"uploading": "Uploading"
|
||||
},
|
||||
"library": {
|
||||
"title": "My Memory",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"hi": "Hi,",
|
||||
"iAmMemo": "I'm Memo!",
|
||||
"ready": "Ready to wake up your memories?",
|
||||
"justAsk": "Just ask MeMo, let me bring them back to life!"
|
||||
"justAsk": "Just ask MeMo, let me bring them back to life!",
|
||||
"selectPhoto": "Select Photo",
|
||||
"continueAsking": "Continue Asking"
|
||||
}
|
||||
}
|
||||
@ -48,7 +48,8 @@
|
||||
"codeValid": "您输入的验证码无效",
|
||||
"sendAgain": "没有收到验证码?",
|
||||
"resend": "重新发送",
|
||||
"goBack": "返回"
|
||||
"goBack": "返回",
|
||||
"aiAgreement": "《AI功能使用规范》"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
|
||||
@ -40,30 +40,30 @@
|
||||
"otherLcenses": "其他协议",
|
||||
"userAgreement": "用户协议",
|
||||
"privacyPolicy": "隐私政策",
|
||||
"aiPolicy": "《AI功能使用规范》",
|
||||
"applyPermission": "申请使用权限",
|
||||
"aiPolicy": "AI功能使用规范",
|
||||
"applyPermission": "服务协议",
|
||||
"qualification": "资质证照"
|
||||
},
|
||||
"permission": {
|
||||
"permissionManagement": "权限管理设置",
|
||||
"permissionManagement": "权限管理",
|
||||
"pushNotification": "推送权限",
|
||||
"galleryAccess": "相册权限",
|
||||
"locationPermission": "位置权限",
|
||||
"personalizedRecommendation": "个性化推荐设置"
|
||||
"personalizedRecommendation": "个性化推荐"
|
||||
},
|
||||
"generalSetting": {
|
||||
"title": "通用设置",
|
||||
"permissionManagement": "权限管理设置",
|
||||
"permissionManagement": "权限管理",
|
||||
"pushNotification": "推送权限",
|
||||
"galleryAccess": "相册权限",
|
||||
"personalizedRecommendation": "个性化推荐设置",
|
||||
"personalizedRecommendation": "个性化推荐",
|
||||
"deleteAccount": "注销账号",
|
||||
"logout": "退出登录",
|
||||
"upgrade": "升级",
|
||||
"subscription": "订阅",
|
||||
"subscriptionTitle": "MemoWake Premium",
|
||||
"subscriptionText": "Unlock more of what you love",
|
||||
"otherInformation": "其他信息",
|
||||
"otherInformation": "通用设置",
|
||||
"contactUs": "联系客服",
|
||||
"version": "版本号",
|
||||
"cleanCache": "清理缓存",
|
||||
@ -82,6 +82,7 @@
|
||||
"live": "动图",
|
||||
"videoLength": "视频时长",
|
||||
"storiesCreated": "创作视频",
|
||||
"conversationsWithMemo": "Memo对话"
|
||||
"conversationsWithMemo": "Memo对话",
|
||||
"setting": "设置"
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,8 @@
|
||||
"task": "任务",
|
||||
"taskName": "动态全家福",
|
||||
"taskStatus": "正在处理中",
|
||||
"noName": "未命名作品"
|
||||
"noName": "未命名作品",
|
||||
"uploading": "上传中"
|
||||
},
|
||||
"library": {
|
||||
"title": "My Memory",
|
||||
|
||||
11
jest.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
const { createDefaultPreset } = require("ts-jest");
|
||||
|
||||
const tsJestTransformCfg = createDefaultPreset().transform;
|
||||
|
||||
/** @type {import("jest").Config} **/
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
...tsJestTransformCfg,
|
||||
},
|
||||
};
|
||||
43
lib/auth.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { Platform } from 'react-native';
|
||||
import { API_ENDPOINT, refreshAuthToken } from './server-api-util';
|
||||
|
||||
|
||||
export async function identityCheck(token: string) {
|
||||
const res = await fetch(`${API_ENDPOINT}/v1/iam/identity-check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code != 0) {
|
||||
await refreshAuthToken("Token expired");
|
||||
}
|
||||
return data.code == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查登录态,未登录自动跳转到 /login,已登录可执行回调。
|
||||
* @param onAuthed 已登录时的回调(可选)
|
||||
* @param login 是否跳转登录页(可选)
|
||||
*/
|
||||
export async function checkAuthStatus(router: ReturnType<typeof useRouter>, onAuthed?: () => Promise<void> | void, login: boolean = true) {
|
||||
let token: string | null = '';
|
||||
if (Platform.OS === 'web') {
|
||||
token = localStorage.getItem('token') || '';
|
||||
} else {
|
||||
token = await SecureStore.getItemAsync('token') || '';
|
||||
}
|
||||
|
||||
const loggedIn = !!token && await identityCheck(token);
|
||||
if (!loggedIn && login) {
|
||||
router.replace('/login');
|
||||
return false;
|
||||
}
|
||||
if (onAuthed && loggedIn) {
|
||||
await onAuthed();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
41
lib/background-uploader/api.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { fetchApi } from '@/lib/server-api-util';
|
||||
import { ConfirmUpload, UploadUrlResponse } from './types';
|
||||
|
||||
// 获取上传URL
|
||||
export 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: file.type.startsWith('video/') ? 'video' : 'image',
|
||||
isCompressed: metadata.isCompressed || 'false',
|
||||
},
|
||||
};
|
||||
return await fetchApi<UploadUrlResponse>('/file/generate-upload-url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
};
|
||||
|
||||
// 确认上传
|
||||
// 确认上传
|
||||
export const confirmUpload = async (file_id: string): Promise<ConfirmUpload> => {
|
||||
return await fetchApi<ConfirmUpload>('/file/confirm-upload', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_id })
|
||||
});
|
||||
};
|
||||
|
||||
// 新增素材
|
||||
export const addMaterial = async (file: string, compressFile: string) => {
|
||||
await fetchApi('/material', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([{
|
||||
"file_id": file,
|
||||
"preview_file_id": compressFile
|
||||
}])
|
||||
});
|
||||
}
|
||||
123
lib/background-uploader/automatic.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import * as BackgroundTask from 'expo-background-task';
|
||||
import * as TaskManager from 'expo-task-manager';
|
||||
import pLimit from 'p-limit';
|
||||
import { filterExistingFiles, initUploadTable, insertUploadTask, setAppState, updateUploadTaskStatus } from '../db';
|
||||
import { getMediaByDateRange } from './media';
|
||||
import { processAndUploadMedia } from './uploader';
|
||||
|
||||
const BACKGROUND_UPLOAD_TASK = 'background-upload-task';
|
||||
|
||||
const CONCURRENCY_LIMIT = 1; // 后台上传并发数,例如同时上传3个文件
|
||||
const limit = pLimit(CONCURRENCY_LIMIT);
|
||||
|
||||
// 注册后台任务
|
||||
export async function registerBackgroundUploadTask() {
|
||||
try {
|
||||
// 初始化数据库表
|
||||
await initUploadTable();
|
||||
|
||||
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_UPLOAD_TASK);
|
||||
if (isRegistered) {
|
||||
console.log('Background task already registered.');
|
||||
} else {
|
||||
await BackgroundTask.registerTaskAsync(BACKGROUND_UPLOAD_TASK, {
|
||||
minimumInterval: 15 * 60, // 15 分钟
|
||||
});
|
||||
console.log('Background task registered successfully.');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error registering background task:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 触发手动或后台上传的通用函数
|
||||
export async function triggerManualUpload(startDate: Date, endDate: Date): Promise<string | null> {
|
||||
try {
|
||||
console.log(`Triggering upload for range: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
||||
const allMedia = await getMediaByDateRange(startDate, endDate);
|
||||
if (allMedia.length === 0) {
|
||||
console.log('No media files found in the specified range.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Batch filter out files that have already been successfully uploaded.
|
||||
const allFileUris = allMedia.map(a => a.uri);
|
||||
const newFileUris = await filterExistingFiles(allFileUris);
|
||||
|
||||
if (newFileUris.length === 0) {
|
||||
console.log('No new files to upload. All are already synced.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const filesToUpload = allMedia.filter(a => newFileUris.includes(a.uri));
|
||||
|
||||
// 2. Batch pre-register all new files in the database as 'pending'.
|
||||
console.log(`Registering ${filesToUpload.length} new files as 'pending' in the database.`);
|
||||
for (const file of filesToUpload) {
|
||||
await insertUploadTask({ uri: file.uri, filename: file.filename, status: 'pending', progress: 0 });
|
||||
}
|
||||
|
||||
// 3. Start the upload session.
|
||||
const startTime = Math.floor(Date.now() / 1000).toString();
|
||||
await setAppState('uploadSessionStartTime', startTime);
|
||||
|
||||
// 4. Create upload promises for the new files.
|
||||
const uploadPromises = filesToUpload.map((file) =>
|
||||
limit(async () => {
|
||||
try {
|
||||
// 5. Mark the task as 'uploading' right before the upload starts.
|
||||
await updateUploadTaskStatus(file.uri, 'uploading');
|
||||
const result = await processAndUploadMedia(file);
|
||||
|
||||
if (result === null) { // Skipped case
|
||||
await updateUploadTaskStatus(file.uri, 'skipped');
|
||||
return { status: 'skipped' };
|
||||
}
|
||||
|
||||
if (result.originalSuccess) {
|
||||
await updateUploadTaskStatus(file.uri, 'success', result.fileIds?.original);
|
||||
return { status: 'success' };
|
||||
} else {
|
||||
await updateUploadTaskStatus(file.uri, 'failed');
|
||||
return { status: 'failed' };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload failed for', file.uri, e);
|
||||
await updateUploadTaskStatus(file.uri, 'failed');
|
||||
return { status: 'failed' };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// We don't wait for all uploads to complete. The function returns after starting them.
|
||||
// The UI will then poll for progress.
|
||||
Promise.allSettled(uploadPromises).then((results) => {
|
||||
console.log('All upload tasks have been settled.');
|
||||
const successfulUploads = results.filter(
|
||||
(result) => result.status === 'fulfilled' && result.value.status === 'success'
|
||||
).length;
|
||||
console.log(`${successfulUploads} files uploaded successfully.`);
|
||||
});
|
||||
|
||||
return startTime;
|
||||
} catch (error) {
|
||||
console.error('Error during upload trigger:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 定义后台任务
|
||||
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
|
||||
try {
|
||||
console.log('Running background upload task...');
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
await triggerManualUpload(oneDayAgo, now);
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
} catch (error) {
|
||||
console.error('Background task error:', error);
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
}
|
||||
});
|
||||
82
lib/background-uploader/manual.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import pLimit from 'p-limit';
|
||||
import { Alert } from 'react-native';
|
||||
import { getUploadTaskStatus, insertUploadTask } from '../db';
|
||||
import { getMediaByDateRange } from './media';
|
||||
import { ExtendedAsset } from './types';
|
||||
import { processAndUploadMedia } from './uploader';
|
||||
|
||||
// 设置最大并发数
|
||||
const CONCURRENCY_LIMIT = 1; // 同时最多上传10个文件
|
||||
const limit = pLimit(CONCURRENCY_LIMIT);
|
||||
|
||||
// 手动触发上传
|
||||
export const triggerManualUpload = async (
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
onProgress?: (progress: {
|
||||
totalCount: number;
|
||||
uploadedCount: number;
|
||||
totalBytes: number; // Overall total size
|
||||
uploadedBytes: number; // Overall uploaded size
|
||||
currentAsset: ExtendedAsset; // To show a thumbnail
|
||||
}) => void
|
||||
) => {
|
||||
try {
|
||||
const media = await getMediaByDateRange(startDate, endDate);
|
||||
if (media.length === 0) {
|
||||
Alert.alert('提示', '在指定时间范围内未找到媒体文件');
|
||||
return [];
|
||||
}
|
||||
|
||||
const progressMap = new Map<string, { loaded: number; total: number }>();
|
||||
|
||||
const results = [];
|
||||
let uploadedCount = 0;
|
||||
|
||||
for (const asset of media) {
|
||||
const existingTask = await getUploadTaskStatus(asset.uri);
|
||||
if (!existingTask) {
|
||||
await insertUploadTask({ uri: asset.uri, filename: asset.filename, status: 'pending', progress: 0 });
|
||||
} else if (existingTask.status === 'success' || existingTask.status === 'skipped') {
|
||||
console.log(`File ${asset.uri} already ${existingTask.status}, skipping processing.`);
|
||||
uploadedCount++; // Also count skipped files as 'processed'
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await limit(() => processAndUploadMedia(asset, (fileProgress) => {
|
||||
progressMap.set(asset.uri, fileProgress);
|
||||
const uploadedBytes = Array.from(progressMap.values()).reduce((sum, p) => sum + p.loaded, 0);
|
||||
const totalBytes = Array.from(progressMap.values()).reduce((sum, p) => sum + p.total, 0);
|
||||
onProgress?.({
|
||||
totalCount: media.length,
|
||||
uploadedCount,
|
||||
totalBytes,
|
||||
uploadedBytes,
|
||||
currentAsset: asset,
|
||||
});
|
||||
}));
|
||||
|
||||
if (result) {
|
||||
results.push(result);
|
||||
if (result.originalSuccess) {
|
||||
uploadedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉因为已上传而返回 null 的结果
|
||||
const finalResults = results.filter(result => result !== null);
|
||||
|
||||
console.log('Manual upload completed.', {
|
||||
total: media.length,
|
||||
uploaded: finalResults.length,
|
||||
skipped: media.length - finalResults.length
|
||||
});
|
||||
|
||||
return finalResults;
|
||||
} catch (error) {
|
||||
console.error('手动上传过程中出现错误:', error);
|
||||
Alert.alert('错误', '上传过程中出现错误');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
90
lib/background-uploader/media.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { MediaTypeValue } from 'expo-media-library';
|
||||
import { ExtendedAsset } from './types'; // Assuming ExtendedAsset is defined in types.ts
|
||||
|
||||
// Helper to fetch assets with pagination and EXIF info
|
||||
const fetchAssetsWithExif = async (
|
||||
mediaType: MediaTypeValue[],
|
||||
createdAfter?: number,
|
||||
createdBefore?: number,
|
||||
descending: boolean = true // Default to descending
|
||||
): Promise<ExtendedAsset[]> => {
|
||||
let allAssets: MediaLibrary.Asset[] = [];
|
||||
let hasNextPage = true;
|
||||
let after: string | undefined = undefined;
|
||||
|
||||
while (hasNextPage) {
|
||||
const media = await MediaLibrary.getAssetsAsync({
|
||||
mediaType,
|
||||
first: 500, // Fetch in batches of 500
|
||||
sortBy: [MediaLibrary.SortBy.creationTime],
|
||||
createdAfter,
|
||||
createdBefore,
|
||||
after,
|
||||
});
|
||||
|
||||
allAssets = allAssets.concat(media.assets);
|
||||
hasNextPage = media.hasNextPage;
|
||||
after = media.endCursor;
|
||||
}
|
||||
|
||||
// For each asset, get full EXIF information
|
||||
const assetsWithExif = await Promise.all(
|
||||
allAssets.map(async (asset) => {
|
||||
try {
|
||||
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id);
|
||||
return {
|
||||
...asset,
|
||||
|
||||
exif: assetInfo.exif || null,
|
||||
location: assetInfo.location || null
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get EXIF for asset ${asset.id}:`, error);
|
||||
return asset;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return assetsWithExif;
|
||||
};
|
||||
|
||||
// 获取指定时间范围内的媒体文件(包含 EXIF 信息),并按日期倒序
|
||||
export const getMediaByDateRange = async (startDate: Date, endDate: Date): Promise<ExtendedAsset[]> => {
|
||||
try {
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
console.warn('Media library permission not granted');
|
||||
return [];
|
||||
}
|
||||
return await fetchAssetsWithExif(
|
||||
['photo', 'video'],
|
||||
startDate.getTime(),
|
||||
endDate.getTime(),
|
||||
true // Always descending for this function
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in getMediaByDateRange:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 获取所有需要上传的媒体文件(从某个时间点之后,按日期倒序,包含 EXIF 信息)
|
||||
export const getMediaForUpload = async (lastUploadTimestamp?: number): Promise<ExtendedAsset[]> => {
|
||||
try {
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
console.warn('Media library permission not granted');
|
||||
return [];
|
||||
}
|
||||
return await fetchAssetsWithExif(
|
||||
['photo', 'video'],
|
||||
lastUploadTimestamp, // Only fetch assets created after this timestamp
|
||||
undefined, // No end date
|
||||
true // Always descending
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in getMediaForUpload:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
27
lib/background-uploader/summary.md
Normal file
@ -0,0 +1,27 @@
|
||||
此目录包含后台媒体上传的逻辑,包括 API 交互、自动和手动上传过程、媒体库访问以及实用功能。
|
||||
|
||||
**主要组件:**
|
||||
|
||||
- `api.ts`:处理与文件上传相关的 API 调用,例如获取上传 URL、确认上传和添加素材记录。
|
||||
- `automatic.ts`:实现后台任务的注册和定义,用于自动媒体上传,通常定期运行(例如,每 15 分钟)。它获取最近的媒体并处理它们以上传。
|
||||
- `manual.ts`:提供在指定日期范围内手动触发媒体上传的功能,并带有并发控制。
|
||||
- `media.ts`:管理与设备媒体库的交互(使用 `expo-media-library`),包括请求权限、获取带有 EXIF 数据的资产以及按日期范围过滤。
|
||||
- `types.ts`:定义后台上传模块中使用的 TypeScript 接口和类型,包括 `ExtendedAsset`、`UploadTask`、`ConfirmUpload` 和 `UploadUrlResponse`。
|
||||
- `uploader.ts`:包含处理和上传单个媒体文件的核心逻辑。这包括处理 HEIC 转换、图像压缩、视频缩略图生成、重复检查以及与上传 API 的交互。它还包括一个 `uploadFileWithProgress` 函数,用于跟踪上传进度。
|
||||
- `utils.ts`:提供实用功能,例如检查媒体库权限、提取文件扩展名和确定 MIME 类型。
|
||||
|
||||
**整体功能:**
|
||||
|
||||
`background-uploader` 模块使应用程序能够:
|
||||
1. 在后台**自动上传**新创建的媒体文件(照片和视频)。
|
||||
2. 允许在指定日期范围内**手动上传**媒体文件。
|
||||
3. 在上传前**处理媒体文件**,包括:
|
||||
* 将 HEIC 图像转换为 JPEG。
|
||||
* 压缩图像。
|
||||
* 为视频生成缩略图。
|
||||
4. **与后端 API 交互**以管理上传生命周期(获取 URL、上传、确认)。
|
||||
5. **管理媒体库权限**并获取带有详细 EXIF 信息的媒体资产。
|
||||
6. 通过检查本地数据库**防止重复上传**。
|
||||
7. 提供上传的**进度跟踪**。
|
||||
|
||||
该模块通过必要的预处理和错误处理,确保高效、可靠的媒体上传,无论是自动还是按需上传。
|
||||
135
lib/background-uploader/types.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
|
||||
export type ExtendedAsset = MediaLibrary.Asset & {
|
||||
size?: number;
|
||||
exif?: Record<string, any> | null;
|
||||
};
|
||||
|
||||
// 上传任务类型
|
||||
export type UploadTask = {
|
||||
file: File;
|
||||
metadata: {
|
||||
isCompressed: string;
|
||||
type: string;
|
||||
isThumbnail?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
// 文件元数据信息
|
||||
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: string; // 这里与 ConfirmUpload 的 content_type 定义不同,需要注意
|
||||
upload_time: string;
|
||||
storage_medium: string;
|
||||
file_path: string; // 这里与 ConfirmUpload 的 file_path 定义不同
|
||||
uploader_id: number;
|
||||
upload_status: string;
|
||||
deletion_status: string;
|
||||
metadata: FileMetadata;
|
||||
}
|
||||
|
||||
// 上传队列项 - 作为唯一的类型定义
|
||||
// 定义 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?: React.ReactNode;
|
||||
style?: import('react-native').StyleProp<import('react-native').ViewStyle>;
|
||||
onPickImage?: (file: File, exifData: ExifData) => void;
|
||||
compressQuality?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
preserveExif?: boolean;
|
||||
uploadOriginal?: boolean;
|
||||
onUploadComplete?: (result: FileUploadItem[]) => void;
|
||||
onProgress?: (progress: any) => void; // TODO: Define a proper type for progress
|
||||
multipleChoice?: boolean;
|
||||
fileType?: any[]; // TODO: Use MediaType from expo-image-picker
|
||||
showPreview?: boolean;
|
||||
}
|
||||
|
||||
export interface FileUploadItem {
|
||||
id: string;
|
||||
uri: string; // 用于本地展示的资源URI
|
||||
name: string;
|
||||
progress: number;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error'; // 统一状态
|
||||
error?: string | null;
|
||||
previewUrl: string; // 预览URL
|
||||
file?: File;
|
||||
type: 'image' | 'video';
|
||||
thumbnail?: string; // 缩略图URL
|
||||
thumbnailFile?: File; // 缩略图文件对象
|
||||
originalFile?: FileInfo // 上传后返回的文件信息
|
||||
}
|
||||
|
||||
// 确认上传返回
|
||||
export type ConfirmUpload = {
|
||||
file_id: string;
|
||||
upload_url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
content_type: string;
|
||||
file_path: string;
|
||||
};
|
||||
|
||||
// 上传结果
|
||||
export type UploadResult = {
|
||||
originalUrl?: string;
|
||||
compressedUrl: string;
|
||||
file: File | null;
|
||||
exif: any;
|
||||
originalFile: ConfirmUpload;
|
||||
compressedFile: ConfirmUpload;
|
||||
thumbnail: string;
|
||||
thumbnailFile: File;
|
||||
};
|
||||
|
||||
// 上传URL响应类型
|
||||
export type UploadUrlResponse = {
|
||||
upload_url: string;
|
||||
file_id: string;
|
||||
};
|
||||
239
lib/background-uploader/uploader.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { transformData } from '@/components/utils/objectFlat';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { convertHeicToJpeg } from '../image-process/heicConvert';
|
||||
import { compressImage } from '../image-process/imageCompress';
|
||||
import { uploadVideoThumbnail } from '../video-process/videoThumbnail';
|
||||
import { addMaterial, confirmUpload, getUploadUrl } from './api';
|
||||
import { ExtendedAsset } from './types';
|
||||
import { checkMediaLibraryPermission, getFileExtension, getMimeType } from './utils';
|
||||
import { getUploadTaskStatus, updateUploadTaskStatus, updateUploadTaskProgress } from '../db';
|
||||
|
||||
// 基础文件上传实现
|
||||
export const uploadFile = async (file: File, uploadUrl: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// 处理单个媒体文件上传的核心逻辑
|
||||
export const processAndUploadMedia = async (
|
||||
asset: ExtendedAsset,
|
||||
onProgress?: (progress: { loaded: number; total: number }) => void
|
||||
) => {
|
||||
try {
|
||||
// 1. 文件去重检查 (从数据库获取状态)
|
||||
const existingTask = await getUploadTaskStatus(asset.uri);
|
||||
if (existingTask && (existingTask.status === 'success' || existingTask.status === 'skipped')) {
|
||||
console.log(`File ${asset.uri} already ${existingTask.status}, skipping processing.`);
|
||||
return null; // 返回 null 表示已上传或已跳过,调用方可以据此过滤
|
||||
}
|
||||
|
||||
// 标记为正在上传
|
||||
await updateUploadTaskStatus(asset.uri, 'uploading');
|
||||
|
||||
// 2. 检查权限
|
||||
const { hasPermission } = await checkMediaLibraryPermission();
|
||||
if (!hasPermission) {
|
||||
throw new Error('No media library permission');
|
||||
}
|
||||
|
||||
const isVideo = asset.mediaType === 'video';
|
||||
|
||||
// 3. 上传原始文件
|
||||
const uploadOriginalFile = async () => {
|
||||
let fileToUpload: File;
|
||||
const mimeType = getMimeType(asset.filename || '', isVideo);
|
||||
|
||||
let filename = asset.filename ||
|
||||
`${isVideo ? 'video' : 'image'}_${Date.now()}_original.${isVideo ? (getFileExtension(asset.filename || 'mp4') || 'mp4') : 'jpg'}`;
|
||||
|
||||
// 处理 HEIC 格式
|
||||
if (filename.toLowerCase().endsWith('.heic') || filename.toLowerCase().endsWith('.heif')) {
|
||||
fileToUpload = await convertHeicToJpeg(asset.uri);
|
||||
filename = filename.replace(/\.(heic|heif)$/i, '.jpg');
|
||||
} else {
|
||||
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id, {
|
||||
shouldDownloadFromNetwork: true
|
||||
});
|
||||
if (!assetInfo.localUri) throw new Error('无法获取资源的本地路径');
|
||||
|
||||
const fileExtension = getFileExtension(assetInfo.filename || '') || (isVideo ? 'mp4' : 'jpg');
|
||||
if (!filename.toLowerCase().endsWith(`.${fileExtension}`)) {
|
||||
const baseName = filename.split('.')[0];
|
||||
filename = `${baseName}.${fileExtension}`;
|
||||
}
|
||||
|
||||
const response = await fetch(assetInfo.localUri);
|
||||
const blob = await response.blob();
|
||||
fileToUpload = new File([blob], filename, { type: mimeType });
|
||||
}
|
||||
|
||||
let exifData = {};
|
||||
if (asset.exif) {
|
||||
try {
|
||||
exifData = transformData({
|
||||
...asset,
|
||||
exif: { ...asset.exif, '{MakerApple}': undefined }
|
||||
});
|
||||
} catch (exifError) {
|
||||
console.warn('处理 EXIF 数据时出错:', exifError);
|
||||
}
|
||||
}
|
||||
|
||||
const { upload_url, file_id } = await getUploadUrl(fileToUpload, {
|
||||
originalUri: asset.uri,
|
||||
creationTime: asset.creationTime,
|
||||
mediaType: isVideo ? 'video' : 'image',
|
||||
isCompressed: false,
|
||||
...exifData,
|
||||
GPSVersionID: undefined
|
||||
});
|
||||
|
||||
await uploadFileWithProgress(fileToUpload, upload_url, async (progress) => {
|
||||
if (onProgress) onProgress(progress);
|
||||
const percentage = progress.total > 0 ? (progress.loaded / progress.total) * 100 : 0;
|
||||
await updateUploadTaskProgress(asset.uri, Math.round(percentage * 0.5)); // 原始文件占总进度的50%
|
||||
});
|
||||
await confirmUpload(file_id);
|
||||
|
||||
return { success: true, file_id, filename: fileToUpload.name };
|
||||
};
|
||||
|
||||
// 4. 上传压缩文件(仅图片)
|
||||
const uploadCompressedFile = async () => {
|
||||
if (isVideo) return { success: true, file_id: null };
|
||||
|
||||
try {
|
||||
const manipResult = await compressImage(asset.uri);
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = asset.filename ? `compressed_${asset.filename}` : `image_${Date.now()}_compressed.jpg`;
|
||||
const compressedFile = new File([blob], filename, { type: 'image/jpeg' });
|
||||
|
||||
const { upload_url, file_id } = await getUploadUrl(compressedFile, {
|
||||
originalUri: asset.uri,
|
||||
creationTime: asset.creationTime,
|
||||
mediaType: 'image',
|
||||
isCompressed: true
|
||||
});
|
||||
|
||||
await uploadFileWithProgress(compressedFile, upload_url, async (progress) => {
|
||||
// For compressed files, we can't easily report byte progress relative to the whole process,
|
||||
// as we don't know the compressed size in advance. We'll just update the DB progress.
|
||||
const percentage = progress.total > 0 ? (progress.loaded / progress.total) * 100 : 0;
|
||||
await updateUploadTaskProgress(asset.uri, 50 + Math.round(percentage * 0.5)); // 压缩文件占总进度的后50%
|
||||
});
|
||||
await confirmUpload(file_id);
|
||||
return { success: true, file_id };
|
||||
} catch (error) {
|
||||
console.error('Error uploading compressed file:', error);
|
||||
return { success: false, error, file_id: null };
|
||||
}
|
||||
};
|
||||
|
||||
// 执行上传
|
||||
const originalResult = await uploadOriginalFile();
|
||||
|
||||
if (!originalResult.success) {
|
||||
throw new Error('Original file upload failed');
|
||||
}
|
||||
|
||||
let compressedResult: { success: boolean; file_id?: string | null; error?: any } = { success: true, file_id: null };
|
||||
if (!isVideo) {
|
||||
compressedResult = await uploadCompressedFile();
|
||||
if (originalResult.file_id && compressedResult.file_id) {
|
||||
await addMaterial(originalResult.file_id, compressedResult.file_id);
|
||||
}
|
||||
} else {
|
||||
const thumbnailResult = await uploadVideoThumbnail(asset);
|
||||
if (thumbnailResult.success && originalResult.file_id && thumbnailResult.file_id) {
|
||||
await addMaterial(originalResult.file_id, thumbnailResult.file_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 标记为已上传
|
||||
await updateUploadTaskStatus(asset.uri, 'success', originalResult.file_id);
|
||||
await updateUploadTaskProgress(asset.uri, 100);
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
originalSuccess: originalResult.success,
|
||||
compressedSuccess: compressedResult.success,
|
||||
fileIds: {
|
||||
original: originalResult.file_id,
|
||||
compressed: compressedResult.file_id
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error processing media upload for asset:', asset.uri, error);
|
||||
// 标记为失败
|
||||
await updateUploadTaskStatus(asset.uri, 'failed');
|
||||
await updateUploadTaskProgress(asset.uri, 0);
|
||||
return {
|
||||
id: asset.id,
|
||||
originalSuccess: false,
|
||||
compressedSuccess: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
};
|
||||
export const uploadFileWithProgress = async (
|
||||
file: File,
|
||||
uploadUrl: string,
|
||||
onProgress?: (progress: { loaded: number; total: number }) => void,
|
||||
timeout: number = 30000
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
|
||||
// 进度监听
|
||||
if (onProgress) {
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
onProgress({ loaded: event.loaded, total: event.total });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error('Network error during upload'));
|
||||
};
|
||||
|
||||
// 超时处理
|
||||
timeoutId = setTimeout(() => {
|
||||
xhr.abort();
|
||||
reject(new Error('上传超时,请检查网络连接'));
|
||||
}, timeout);
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
};
|
||||
50
lib/background-uploader/utils.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
|
||||
// 检查并请求媒体库权限
|
||||
export const checkMediaLibraryPermission = async (): Promise<{ hasPermission: boolean, status?: string }> => {
|
||||
try {
|
||||
const { status, accessPrivileges } = await MediaLibrary.getPermissionsAsync();
|
||||
|
||||
// 如果已经授权,直接返回
|
||||
if (status === 'granted' && accessPrivileges === 'all') {
|
||||
return { hasPermission: true, status };
|
||||
}
|
||||
|
||||
// 如果没有授权,请求权限
|
||||
const { status: newStatus, accessPrivileges: newPrivileges } = await MediaLibrary.requestPermissionsAsync();
|
||||
const isGranted = newStatus === 'granted' && newPrivileges === 'all';
|
||||
|
||||
if (!isGranted) {
|
||||
console.log('Media library permission not granted or limited access');
|
||||
}
|
||||
|
||||
return {
|
||||
hasPermission: isGranted,
|
||||
status: newStatus
|
||||
};
|
||||
} catch (error) {
|
||||
return { hasPermission: false };
|
||||
}
|
||||
};
|
||||
|
||||
// 获取文件扩展名
|
||||
export const getFileExtension = (filename: string) => {
|
||||
return filename.split('.').pop()?.toLowerCase() || '';
|
||||
};
|
||||
|
||||
// 获取 MIME 类型
|
||||
export const getMimeType = (filename: string, isVideo: boolean) => {
|
||||
if (!isVideo) return 'image/jpeg';
|
||||
|
||||
const ext = getFileExtension(filename);
|
||||
switch (ext) {
|
||||
case 'mov':
|
||||
return 'video/quicktime';
|
||||
case 'mp4':
|
||||
return 'video/mp4';
|
||||
case 'm4v':
|
||||
return 'video/x-m4v';
|
||||
default:
|
||||
return 'video/mp4'; // 默认值
|
||||
}
|
||||
};
|
||||
176
lib/db.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
|
||||
const db = SQLite.openDatabaseSync('upload_status.db');
|
||||
|
||||
// Set a busy timeout to handle concurrent writes and avoid "database is locked" errors.
|
||||
// This will make SQLite wait for 5 seconds if the database is locked by another process.
|
||||
db.execSync('PRAGMA busy_timeout = 5000;');
|
||||
|
||||
export type UploadTask = {
|
||||
uri: string;
|
||||
filename: string;
|
||||
status: 'pending' | 'uploading' | 'success' | 'failed' | 'skipped';
|
||||
progress: number; // 0-100
|
||||
file_id?: string; // 后端返回的文件ID
|
||||
created_at: number; // unix timestamp
|
||||
};
|
||||
|
||||
// 初始化表
|
||||
export async function initUploadTable() {
|
||||
console.log('Initializing upload tasks table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS upload_tasks (
|
||||
uri TEXT PRIMARY KEY NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
file_id TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Add created_at column to existing table if it doesn't exist
|
||||
const columns = await db.getAllAsync('PRAGMA table_info(upload_tasks);');
|
||||
const columnExists = columns.some((column: any) => column.name === 'created_at');
|
||||
|
||||
if (!columnExists) {
|
||||
console.log('Adding created_at column to upload_tasks table...');
|
||||
// SQLite doesn't support non-constant DEFAULT values on ALTER TABLE.
|
||||
// So we add the column, then update existing rows.
|
||||
await db.execAsync(`ALTER TABLE upload_tasks ADD COLUMN created_at INTEGER;`);
|
||||
await db.execAsync(`UPDATE upload_tasks SET created_at = (strftime('%s', 'now')) WHERE created_at IS NULL;`);
|
||||
console.log('created_at column added and populated.');
|
||||
}
|
||||
console.log('Upload tasks table initialized');
|
||||
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS app_state (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT
|
||||
);
|
||||
`);
|
||||
console.log('App state table initialized');
|
||||
}
|
||||
|
||||
// 插入新的上传任务
|
||||
export async function insertUploadTask(task: Omit<UploadTask, 'created_at'>) {
|
||||
console.log('Inserting upload task:', task.uri);
|
||||
await db.runAsync(
|
||||
'INSERT OR REPLACE INTO upload_tasks (uri, filename, status, progress, file_id, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[task.uri, task.filename, task.status, task.progress, task.file_id ?? null, Math.floor(Date.now() / 1000)]
|
||||
);
|
||||
}
|
||||
|
||||
// 检查文件是否已上传或正在上传
|
||||
export async function getUploadTaskStatus(uri: string): Promise<UploadTask | null> {
|
||||
console.log('Checking upload task status for:', uri);
|
||||
const result = await db.getFirstAsync<UploadTask>(
|
||||
'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks WHERE uri = ?;',
|
||||
uri
|
||||
);
|
||||
return result || null;
|
||||
}
|
||||
|
||||
// 更新上传任务的状态
|
||||
export async function updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string) {
|
||||
if (file_id) {
|
||||
await db.runAsync('UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?', [status, file_id, uri]);
|
||||
} else {
|
||||
await db.runAsync('UPDATE upload_tasks SET status = ? WHERE uri = ?', [status, uri]);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新上传任务的进度
|
||||
export async function updateUploadTaskProgress(uri: string, progress: number) {
|
||||
await db.runAsync('UPDATE upload_tasks SET progress = ? WHERE uri = ?', [progress, uri]);
|
||||
}
|
||||
|
||||
// 获取所有上传任务
|
||||
export async function getUploadTasks(): Promise<UploadTask[]> {
|
||||
console.log('Fetching all upload tasks... time:', new Date().toLocaleString());
|
||||
const results = await db.getAllAsync<UploadTask>(
|
||||
'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks ORDER BY created_at DESC;'
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
// 清理已完成或失败的任务 (可选,根据需求添加)
|
||||
export async function cleanUpUploadTasks(): Promise<void> {
|
||||
console.log('Cleaning up completed/failed upload tasks...');
|
||||
await db.runAsync(
|
||||
"DELETE FROM upload_tasks WHERE status = 'success' OR status = 'failed' OR status = 'skipped';"
|
||||
);
|
||||
}
|
||||
|
||||
// 获取某个时间点之后的所有上传任务
|
||||
export async function getUploadTasksSince(timestamp: number): Promise<UploadTask[]> {
|
||||
const rows = await db.getAllAsync<UploadTask>(
|
||||
'SELECT * FROM upload_tasks WHERE created_at >= ? ORDER BY created_at DESC',
|
||||
[timestamp]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function exist_pending_tasks(): Promise<boolean> {
|
||||
const rows = await db.getAllAsync<UploadTask>(
|
||||
'SELECT * FROM upload_tasks WHERE status = "pending" OR status = "uploading"'
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// 检查一组文件URI,返回那些在数据库中不存在或是未成功上传的文件的URI
|
||||
export async function filterExistingFiles(fileUris: string[]): Promise<string[]> {
|
||||
if (fileUris.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 创建占位符字符串 '?,?,?'
|
||||
const placeholders = fileUris.map(() => '?').join(',');
|
||||
|
||||
// 查询已经存在且状态为 'success' 的任务
|
||||
const query = `SELECT uri FROM upload_tasks WHERE uri IN (${placeholders}) AND status = 'success'`;
|
||||
|
||||
const existingFiles = await db.getAllAsync<{ uri: string }>(query, fileUris);
|
||||
const existingUris = new Set(existingFiles.map(f => f.uri));
|
||||
|
||||
// 过滤出新文件
|
||||
const newFileUris = fileUris.filter(uri => !existingUris.has(uri));
|
||||
|
||||
console.log(`[DB] Total files: ${fileUris.length}, Existing successful files: ${existingUris.size}, New files to upload: ${newFileUris.length}`);
|
||||
|
||||
return newFileUris;
|
||||
}
|
||||
|
||||
// 设置全局状态值
|
||||
export async function setAppState(key: string, value: string | null): Promise<void> {
|
||||
console.log(`Setting app state: ${key} = ${value}`);
|
||||
await db.runAsync('INSERT OR REPLACE INTO app_state (key, value) VALUES (?, ?)', [key, value]);
|
||||
}
|
||||
|
||||
// 获取全局状态值
|
||||
export async function getAppState(key: string): Promise<string | null> {
|
||||
const result = await db.getFirstAsync<{ value: string }>('SELECT value FROM app_state WHERE key = ?;', key);
|
||||
return result?.value ?? null;
|
||||
}
|
||||
|
||||
// for debug page
|
||||
export async function executeSql(sql: string, params: any[] = []): Promise<any> {
|
||||
try {
|
||||
// Trim and check if it's a SELECT query
|
||||
const isSelect = sql.trim().toLowerCase().startsWith('select');
|
||||
if (isSelect) {
|
||||
const results = db.getAllSync(sql, params);
|
||||
return results;
|
||||
} else {
|
||||
const result = db.runSync(sql, params);
|
||||
return {
|
||||
changes: result.changes,
|
||||
lastInsertRowId: result.lastInsertRowId,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error executing SQL:", error);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
47
lib/image-process/heicConvert.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
// 将 HEIC/HEIF 图片转为 JPEG
|
||||
export const convertHeicToJpeg = async (uri: string): Promise<File> => {
|
||||
try {
|
||||
// 1. 将文件复制到缓存目录
|
||||
const cacheDir = FileSystem.cacheDirectory;
|
||||
if (!cacheDir) {
|
||||
throw new Error('Cache directory not available');
|
||||
}
|
||||
const tempUri = `${cacheDir}${Date.now()}.heic`;
|
||||
await FileSystem.copyAsync({ from: uri, to: tempUri });
|
||||
// 2. 检查文件是否存在
|
||||
const fileInfo = await FileSystem.getInfoAsync(tempUri);
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error('Temporary file was not created');
|
||||
}
|
||||
// 3. 读取文件为 base64
|
||||
const base64 = await FileSystem.readAsStringAsync(tempUri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
if (!base64) {
|
||||
throw new Error('Failed to read file as base64');
|
||||
}
|
||||
// 4. 创建 Blob
|
||||
const response = await fetch(`data:image/jpeg;base64,${base64}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed with status ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (!blob || blob.size === 0) {
|
||||
throw new Error('Failed to create blob from base64');
|
||||
}
|
||||
// 5. 创建文件名
|
||||
const originalName = uri.split('/').pop() || 'converted';
|
||||
const filename = originalName.replace(/\.(heic|heif)$/i, '.jpg');
|
||||
// 清理临时文件
|
||||
try {
|
||||
await FileSystem.deleteAsync(tempUri, { idempotent: true });
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary file:', cleanupError);
|
||||
}
|
||||
return new File([blob], filename, { type: 'image/jpeg' });
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`);
|
||||
}
|
||||
};
|
||||
56
lib/image-process/imageCompress.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import { Image } from 'react-native';
|
||||
|
||||
// 压缩图片,自动等比缩放,最大边不超过 800
|
||||
export const compressImage = async (uri: string, maxSize = 800): Promise<{ uri: string; file: File }> => {
|
||||
// 获取原图尺寸
|
||||
const getImageSize = (uri: string): Promise<{ width: number; height: number }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => resolve({ width, height }),
|
||||
reject
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const { width, height } = await getImageSize(uri);
|
||||
let targetWidth = width;
|
||||
let targetHeight = height;
|
||||
if (width > maxSize || height > maxSize) {
|
||||
if (width > height) {
|
||||
targetWidth = maxSize;
|
||||
targetHeight = Math.round((height / width) * maxSize);
|
||||
} else {
|
||||
targetHeight = maxSize;
|
||||
targetWidth = Math.round((width / height) * maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
const manipResult = await ImageManipulator.manipulateAsync(
|
||||
uri,
|
||||
[
|
||||
{
|
||||
resize: {
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
compress: 0.7,
|
||||
format: ImageManipulator.SaveFormat.WEBP,
|
||||
base64: false,
|
||||
}
|
||||
);
|
||||
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = uri.split('/').pop() || `image_${Date.now()}.webp`;
|
||||
const file = new File([blob], filename, { type: 'image/webp' });
|
||||
|
||||
return { uri: manipResult.uri, file };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@ -64,15 +64,14 @@ export const refreshAuthToken = async<T>(message: string | null): Promise<User>
|
||||
cookie = localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') || "")?.refresh_token || "" : "";
|
||||
userId = localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') || "")?.user_id || "" : "";
|
||||
} else {
|
||||
await SecureStore.getItemAsync('user').then((user: User) => {
|
||||
cookie = user?.refresh_token || "";
|
||||
userId = user?.user_id || "";
|
||||
await SecureStore.getItemAsync('user').then((user: string | null) => {
|
||||
cookie = JSON.parse(user || "")?.refresh_token || "";
|
||||
userId = JSON.parse(user || "")?.user_id || "";
|
||||
})
|
||||
}
|
||||
|
||||
// 退出刷新会重新填充数据
|
||||
let response;
|
||||
|
||||
response = await fetch(`${API_ENDPOINT}/v1/iam/access-token-refresh`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
@ -97,6 +96,13 @@ export const refreshAuthToken = async<T>(message: string | null): Promise<User>
|
||||
user: userData,
|
||||
token: userData.access_token
|
||||
}));
|
||||
if (Platform.OS === 'web') {
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
localStorage.setItem('token', userData.access_token);
|
||||
} else {
|
||||
SecureStore.setItemAsync('user', JSON.stringify(userData));
|
||||
SecureStore.setItemAsync('token', userData.access_token);
|
||||
}
|
||||
}
|
||||
|
||||
return userData;
|
||||
|
||||
51
lib/video-process/videoThumbnail.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import { confirmUpload, getUploadUrl } from '../background-uploader/api';
|
||||
import { ExtendedAsset } from '../background-uploader/types';
|
||||
import { uploadFile } from '../background-uploader/uploader';
|
||||
import { compressImage } from '../image-process/imageCompress';
|
||||
|
||||
/**
|
||||
* @description 从视频资源创建缩略图文件
|
||||
* @param asset 媒体资源
|
||||
* @param width 缩略图宽度,默认为 300
|
||||
* @returns 返回一个包含缩略图的 File 对象
|
||||
*/
|
||||
export const createVideoThumbnailFile = async (asset: { uri: string }, width: number = 300): Promise<File> => {
|
||||
const thumbnailResult = await ImageManipulator.manipulateAsync(
|
||||
asset.uri,
|
||||
[{ resize: { width } }],
|
||||
{ compress: 0.7, format: ImageManipulator.SaveFormat.WEBP }
|
||||
);
|
||||
|
||||
const response = await fetch(thumbnailResult.uri);
|
||||
const blob = await response.blob();
|
||||
|
||||
return new File([blob], `thumb_${Date.now()}.webp`, { type: 'image/webp' });
|
||||
};
|
||||
|
||||
// 提取视频的首帧进行压缩并上传
|
||||
export const uploadVideoThumbnail = async (asset: ExtendedAsset) => {
|
||||
try {
|
||||
const manipResult = await compressImage(asset.uri);
|
||||
const response = await fetch(manipResult.uri);
|
||||
const blob = await response.blob();
|
||||
const filename = asset.filename ?
|
||||
`compressed_${asset.filename}` :
|
||||
`image_${Date.now()}_compressed.jpg`;
|
||||
const compressedFile = new File([blob], filename, { type: 'image/jpeg' });
|
||||
|
||||
const { upload_url, file_id } = await getUploadUrl(compressedFile, {
|
||||
originalUri: asset.uri,
|
||||
creationTime: asset.creationTime,
|
||||
mediaType: 'image',
|
||||
isCompressed: true
|
||||
});
|
||||
|
||||
await uploadFile(compressedFile, upload_url);
|
||||
await confirmUpload(file_id);
|
||||
|
||||
return { success: true, file_id };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
2803
package-lock.json
generated
31
package.json
@ -10,7 +10,8 @@
|
||||
"web": "expo start --web --port 5173",
|
||||
"lint": "expo lint",
|
||||
"prebuild": "npm run generate:translations",
|
||||
"generate:translations": "tsx i18n/generate-imports.ts"
|
||||
"generate:translations": "tsx i18n/generate-imports.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
@ -18,31 +19,33 @@
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@types/p-limit": "^2.2.0",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"expo": "~53.0.12",
|
||||
"expo-audio": "~0.4.7",
|
||||
"expo": "53.0.19",
|
||||
"expo-audio": "~0.4.8",
|
||||
"expo-background-task": "^0.2.8",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-constants": "~17.1.6",
|
||||
"expo-dev-client": "~5.2.1",
|
||||
"expo-dev-client": "~5.2.4",
|
||||
"expo-device": "~7.1.4",
|
||||
"expo-file-system": "~18.1.10",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"expo-image": "~2.3.2",
|
||||
"expo-image-manipulator": "~13.1.7",
|
||||
"expo-image-picker": "~16.1.4",
|
||||
"expo-linear-gradient": "^14.1.5",
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-linking": "~7.1.7",
|
||||
"expo-localization": "^16.1.5",
|
||||
"expo-location": "~18.1.5",
|
||||
"expo-media-library": "~17.1.7",
|
||||
"expo-notifications": "~0.31.4",
|
||||
"expo-router": "~5.1.0",
|
||||
"expo-router": "~5.1.3",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-splash-screen": "~0.30.9",
|
||||
"expo-splash-screen": "~0.30.10",
|
||||
"expo-sqlite": "~15.2.14",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.9",
|
||||
"expo-task-manager": "^13.1.6",
|
||||
"expo-video": "~2.2.2",
|
||||
"expo-video-thumbnails": "~9.1.3",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
@ -51,10 +54,11 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"lottie-react-native": "7.2.2",
|
||||
"nativewind": "^4.1.23",
|
||||
"p-limit": "^6.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-native": "0.79.4",
|
||||
"react-native": "0.79.5",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-modal": "^14.0.0-rc.1",
|
||||
"react-native-picker-select": "^9.3.1",
|
||||
@ -68,17 +72,22 @@
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-web": "~0.20.0",
|
||||
"react-native-webview": "13.13.5",
|
||||
"react-redux": "^9.2.0"
|
||||
"react-redux": "^9.2.0",
|
||||
"expo-clipboard": "~7.1.5",
|
||||
"expo-linear-gradient": "~14.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/react": "~19.0.10",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~9.2.0",
|
||||
"jest": "~29.7.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"react-native-svg-transformer": "^1.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-jest": "^29.4.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "~5.8.3"
|
||||
|
||||
1
scripts/android_local_build.sh
Normal file
@ -0,0 +1 @@
|
||||
eas build --platform android --profile development --local
|
||||
12
store.ts
@ -1,15 +1,21 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import { counterSlice } from './components/steps';
|
||||
import authReducer from './features/auth/authSlice';
|
||||
import appStateReducer from './features/appState/appStateSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
counter: counterSlice.reducer,
|
||||
auth: authReducer
|
||||
auth: authReducer,
|
||||
appState: appStateReducer
|
||||
},
|
||||
});
|
||||
|
||||
// 从 store 本身推断 `RootState` 和 `AppDispatch` 类型
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
// 推断类型:{posts: PostsState, comments: CommentsState, users: UsersState}
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
// 在整个应用中使用,而不是简单的 `useDispatch` 和 `useSelector`
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
@ -2,6 +2,7 @@
|
||||
"extends": "expo/tsconfig.base",
|
||||
"typeRoots": ["./node_modules/@types", "./src/types"],
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"],
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
210
types/upload.ts
@ -1,210 +0,0 @@
|
||||
import { MediaType } from "expo-image-picker";
|
||||
import { ReactNode } from "react";
|
||||
import { StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
export interface FileStatus {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
progress: number;
|
||||
error?: string;
|
||||
}
|
||||
export interface MaterialFile {
|
||||
id: string;
|
||||
file_name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface OutputVideoFile {
|
||||
id: string;
|
||||
file_name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ManualTask {
|
||||
task_id: string;
|
||||
user_id: string;
|
||||
status: 'Created' | 'Processing' | 'Completed' | 'Failed';
|
||||
created_at: string;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
failure_reason: string | null;
|
||||
template_id: number;
|
||||
source_files: MaterialFile[];
|
||||
output_video_file?: OutputVideoFile;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
value: number;
|
||||
unit: 'B' | 'KB' | 'MB' | 'GB' | 'TB';
|
||||
}
|
||||
|
||||
export interface ContentType {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FilePath {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Metadata {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type UploadStatus = 'Pending' | 'Uploading' | 'Completed' | 'Failed';
|
||||
export type DeletionStatus = 'Active' | 'PendingDeletion' | 'Deleted';
|
||||
|
||||
export interface ConfirmUpload {
|
||||
file_id: string;
|
||||
upload_url: string
|
||||
name: string;
|
||||
size: Size;
|
||||
content_type: ContentType;
|
||||
upload_time: string; // ISO date string
|
||||
storage_medium: string;
|
||||
file_path: FilePath;
|
||||
uploader_id: number;
|
||||
upload_status: UploadStatus;
|
||||
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;
|
||||
}
|
||||
@ -54,6 +54,7 @@ export interface UserInfoDetails {
|
||||
remain_points: number,
|
||||
total_points: number,
|
||||
title_rankings: TitleRankings[],
|
||||
total_bytes: number,
|
||||
medal_infos: {
|
||||
"id": number,
|
||||
"url": string
|
||||
|
||||