From 56d8737bc9fe030bb49c0e734d3e2e169b9a37ec Mon Sep 17 00:00:00 2001 From: Junhui Chen Date: Wed, 16 Jul 2025 18:07:43 +0800 Subject: [PATCH] chore --- .eas/workflows/create-production-builds.yml | 11 + README.md | 62 +- app.json | 21 +- app/(tabs)/ask.tsx | 5 + app/(tabs)/index.tsx | 10 +- app/(tabs)/login.tsx | 2 +- app/(tabs)/owner.tsx | 14 + app/(tabs)/user-message.tsx | 8 +- components/file-upload/autoUploadScreen.tsx | 6 +- components/file-upload/backgroundUploader.ts | 639 ------------------- components/file-upload/images-picker.tsx | 1 + components/login/login.tsx | 2 +- lib/auth.ts | 42 ++ lib/background-uploader/api.ts | 41 ++ lib/background-uploader/db.ts | 37 ++ lib/background-uploader/fileProcessor.ts | 141 ++++ lib/background-uploader/index.ts | 259 ++++++++ lib/background-uploader/media.ts | 89 +++ lib/background-uploader/summary.md | 28 + lib/background-uploader/task.ts | 79 +++ lib/background-uploader/types.ts | 5 + lib/background-uploader/uploader.ts | 30 + lib/background-uploader/utils.ts | 50 ++ package-lock.json | 23 +- package.json | 1 + 25 files changed, 894 insertions(+), 712 deletions(-) create mode 100644 .eas/workflows/create-production-builds.yml delete mode 100644 components/file-upload/backgroundUploader.ts create mode 100644 lib/auth.ts create mode 100644 lib/background-uploader/api.ts create mode 100644 lib/background-uploader/db.ts create mode 100644 lib/background-uploader/fileProcessor.ts create mode 100644 lib/background-uploader/index.ts create mode 100644 lib/background-uploader/media.ts create mode 100644 lib/background-uploader/summary.md create mode 100644 lib/background-uploader/task.ts create mode 100644 lib/background-uploader/types.ts create mode 100644 lib/background-uploader/uploader.ts create mode 100644 lib/background-uploader/utils.ts diff --git a/.eas/workflows/create-production-builds.yml b/.eas/workflows/create-production-builds.yml new file mode 100644 index 0000000..7342cd8 --- /dev/null +++ b/.eas/workflows/create-production-builds.yml @@ -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 diff --git a/README.md b/README.md index 48dd63f..c50af91 100644 --- a/README.md +++ b/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 +``` \ No newline at end of file diff --git a/app.json b/app.json index b8aa3fc..bf8aaf7 100644 --- a/app.json +++ b/app.json @@ -15,7 +15,11 @@ "NSPhotoLibraryAddUsageDescription": "需要保存图片到相册", "NSLocationWhenInUseUsageDescription": "Allow $(PRODUCT_NAME) to access your location to get photo location data.", "ITSAppUsesNonExemptEncryption": false, - "UIBackgroundModes": ["fetch", "location", "audio"] + "UIBackgroundModes": [ + "fetch", + "location", + "audio" + ] }, "bundleIdentifier": "com.memowake.app" }, @@ -48,7 +52,8 @@ }, "plugins": [ "expo-router", - "expo-secure-store", [ + "expo-secure-store", + [ "expo-background-task", { "minimumInterval": 15 @@ -68,15 +73,6 @@ "locationWhenInUsePermission": "允许 $(PRODUCT_NAME) 访问您的位置" } ], - // [ - // "expo-notifications", - // { - // "color": "#ffffff", - // "defaultChannel": "default", - // "enableBackgroundRemoteNotifications": false, - // "mode": "client" - // } - // ], [ "expo-audio", { @@ -90,7 +86,8 @@ "savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.", "isAccessMediaLocationEnabled": true } - ] + ], + "expo-sqlite" ], "experiments": { "typedRoutes": true diff --git a/app/(tabs)/ask.tsx b/app/(tabs)/ask.tsx index 25cedb6..a50f7b2 100644 --- a/app/(tabs)/ask.tsx +++ b/app/(tabs)/ask.tsx @@ -3,6 +3,7 @@ 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"; @@ -20,6 +21,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; export default function AskScreen() { const insets = useSafeAreaInsets(); + useEffect(() => { + checkAuthStatus(router); + }, []); + // 在组件内部添加 ref const scrollViewRef = useRef(null); const [isHello, setIsHello] = useState(true); const [conversationId, setConversationId] = useState(null); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 4b739e8..3b9fccc 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,11 +1,10 @@ -import IP from '@/assets/icons/svg/ip.svg'; -import { registerBackgroundUploadTask, triggerManualUpload } from '@/components/file-upload/backgroundUploader'; +import { registerBackgroundUploadTask } from '@/lib/background-uploader'; import * as MediaLibrary from 'expo-media-library'; import { useRouter } from 'expo-router'; import * as SecureStore from 'expo-secure-store'; import { useEffect, useRef, 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"; import MemoList from './memo-list'; @@ -41,9 +40,6 @@ export default function HomeScreen() { const { status } = await MediaLibrary.requestPermissionsAsync(); if (status === 'granted') { await registerBackgroundUploadTask(); - const now = new Date(); - const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); - await triggerManualUpload(oneDayAgo, now); } router.replace('/ask'); } @@ -140,6 +136,6 @@ export default function HomeScreen() { } - + ); } \ No newline at end of file diff --git a/app/(tabs)/login.tsx b/app/(tabs)/login.tsx index e4b0fe7..9aea541 100644 --- a/app/(tabs)/login.tsx +++ b/app/(tabs)/login.tsx @@ -53,7 +53,7 @@ const LoginScreen = () => { } useEffect(() => { - setError('123') + // setError('123') }, []) return ( diff --git a/app/(tabs)/owner.tsx b/app/(tabs)/owner.tsx index 40f5d46..cff16bf 100644 --- a/app/(tabs)/owner.tsx +++ b/app/(tabs)/owner.tsx @@ -12,15 +12,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 { 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); diff --git a/app/(tabs)/user-message.tsx b/app/(tabs)/user-message.tsx index 4c45fdd..b37c07a 100644 --- a/app/(tabs)/user-message.tsx +++ b/app/(tabs)/user-message.tsx @@ -2,14 +2,16 @@ 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 { fetchApi } from '@/lib/server-api-util'; import { FileUploadItem } from '@/types/upload'; import { User } from '@/types/user'; -import { useLocalSearchParams } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { 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("userName") const [username, setUsername] = useState('') @@ -23,6 +25,10 @@ export default function UserMessage() { const params = useLocalSearchParams(); const { username: usernameParam } = params; + useEffect(() => { + checkAuthStatus(router); + }, []); + // 获取用户信息 const getUserInfo = async () => { const res = await fetchApi("/iam/user-info"); diff --git a/components/file-upload/autoUploadScreen.tsx b/components/file-upload/autoUploadScreen.tsx index 87b1a6e..02d7d90 100644 --- a/components/file-upload/autoUploadScreen.tsx +++ b/components/file-upload/autoUploadScreen.tsx @@ -1,6 +1,6 @@ +import { registerBackgroundUploadTask, triggerManualUpload } from '@/lib/background-uploader'; import React, { useEffect, useState } from 'react'; import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { registerBackgroundUploadTask, triggerManualUpload } from './backgroundUploader'; export default function AutoUploadScreen() { const [timeRange, setTimeRange] = useState('day'); @@ -13,8 +13,8 @@ export default function AutoUploadScreen() { const registered = await registerBackgroundUploadTask(); setIsRegistered(registered); }; - - registerTask(); + console.log("register background upload task"); + // registerTask(); }, []); // 处理手动上传 diff --git a/components/file-upload/backgroundUploader.ts b/components/file-upload/backgroundUploader.ts deleted file mode 100644 index 39683c4..0000000 --- a/components/file-upload/backgroundUploader.ts +++ /dev/null @@ -1,639 +0,0 @@ -/** - * @file backgroundUploader.ts - * @description - * 本模块为基于 Expo 的 React Native 应用提供了一个健壮的后台媒体上传系统。 - * 其核心职责是在用户不感知的情况下,高效地将用户设备上指定时间范围内的照片和视频静默上传到服务器, - * 即使应用处于后台或被终止也能正常工作。 - * - * @features - * - 后台任务处理: 利用 `expo-task-manager` 和 `expo-background-fetch` 注册一个后台任务, - * 该任务可以周期性(例如每15分钟)运行,即使用户退出应用或设备重启也能被激活。 - * - 媒体文件检索: 使用 `expo-media-library` 访问用户相册,能够获取指定日期范围内的所有媒体文件, - * 并包含其 EXIF 元数据(如GPS位置、拍摄时间等)。 - * - 文件处理与压缩: - * - 图片压缩: 通过 `expo-image-manipulator` 调整图片尺寸并进行压缩,以生成用于预览的缩略图。 - * - HEIC 格式转换: 自动将苹果设备上常见的 HEIC/HEIF 格式图片转换为通用的 JPEG 格式再上传。 - * - 视频缩略图: 提取视频的第一帧作为预览图进行上传。 - * - 高效并发上传: 使用 `p-limit` 库限制并发上传的数量(默认为10),以防止网络拥堵并保证上传过程的稳定性。 - * - 手动触发机制: 提供 `triggerManualUpload` 函数,允许应用在需要时(例如用户在设置页面点击“立即同步”)主动触发上传流程。 - * - * @workflow - * 1. 触发: 上传流程可以通过两种方式启动:由后台任务调度器自动唤醒,或由应用内代码手动调用。 - * 2. 查询媒体文件: 根据指定的日期范围,从用户设备相册中查询需要上传的图片和视频列表。 - * 3. 处理单个文件 (循环处理列表中的每个文件): - * a. 权限检查: 确保应用拥有访问媒体库的权限。 - * b. 上传原始文件: 首先上传完整分辨率的原始文件。此过程包括处理 HEIC 转换、从后端获取预签名上传URL、 - * 通过 XMLHttpRequest 上传文件,以及在上传成功后向后端确认。 - * c. 上传派生文件: 接着,生成并上传一个压缩版的图片或视频缩略图,流程与上传原始文件类似。 - * d. 关联素材: 当原始文件和派生文件(压缩图/缩略图)都成功上传后,调用后端API将两者进行关联。 - * 4. 完成: 所有文件处理完毕后,任务结束,等待下一次调度或手动触发。 - */ -import { fetchApi } from '@/lib/server-api-util'; -import * as BackgroundFetch from 'expo-background-task'; -import * as FileSystem from 'expo-file-system'; -import * as ImageManipulator from 'expo-image-manipulator'; -import * as MediaLibrary from 'expo-media-library'; -import * as TaskManager from 'expo-task-manager'; -import pLimit from 'p-limit'; -import { Alert } from 'react-native'; -import { transformData } from '../utils/objectFlat'; - - -type ExtendedAsset = MediaLibrary.Asset & { - exif?: Record; -}; - -const BACKGROUND_UPLOAD_TASK = 'background-upload-task'; -// 设置最大并发数 -const CONCURRENCY_LIMIT = 10; // 同时最多上传10个文件 -// 在 CONCURRENCY_LIMIT 定义后添加 -const limit = pLimit(CONCURRENCY_LIMIT); - -// 获取文件扩展名 -const getFileExtension = (filename: string) => { - return filename.split('.').pop()?.toLowerCase() || ''; -}; - -// 获取 MIME 类型 -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'; // 默认值 - } -}; - -// 将 HEIC 图片转化 -const convertHeicToJpeg = async (uri: string): Promise => { - try { - console.log('Starting HEIC to JPEG conversion for:', uri); - - // 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'); - - console.log('Successfully converted HEIC to JPEG:', filename); - - // 清理临时文件 - 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) { - console.error('Detailed HEIC conversion error:', { - error: error instanceof Error ? { - message: error.message, - name: error.name, - stack: error.stack - } : error, - uri: uri - }); - throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`); - } -}; - -// 获取指定时间范围内的媒体文件(包含 EXIF 信息) -export const getMediaByDateRange = async (startDate: Date, endDate: Date) => { - try { - const { status } = await MediaLibrary.requestPermissionsAsync(); - if (status !== 'granted') { - console.warn('Media library permission not granted'); - return []; - } - - const media = await MediaLibrary.getAssetsAsync({ - mediaType: ['photo', 'video'], - first: 100, - sortBy: [MediaLibrary.SortBy.creationTime], - createdAfter: startDate.getTime(), - createdBefore: endDate.getTime(), - }); - - // 为每个资源获取完整的 EXIF 信息 - const assetsWithExif = await Promise.all( - media.assets.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; - } catch (error) { - console.error('Error in getMediaByDateRange:', error); - return []; - } -}; - -// 压缩图片 -const compressImage = async (uri: string): Promise<{ uri: string; file: File }> => { - try { - const manipResult = await ImageManipulator.manipulateAsync( - uri, - [ - { - resize: { - width: 1200, - height: 1200, - }, - }, - ], - { - compress: 0.7, - format: ImageManipulator.SaveFormat.JPEG, - base64: false, - } - ); - - const response = await fetch(manipResult.uri); - const blob = await response.blob(); - const filename = uri.split('/').pop() || `image_${Date.now()}.jpg`; - const file = new File([blob], filename, { type: 'image/jpeg' }); - - return { uri: manipResult.uri, file }; - } catch (error) { - throw error; - } -}; - -// 获取上传URL -const getUploadUrl = async (file: File, metadata: Record = {}): Promise<{ upload_url: string; file_id: string }> => { - - 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: 'true', - }, - }; - - return await fetchApi<{ upload_url: string; file_id: string }>("/file/generate-upload-url", { - method: 'POST', - body: JSON.stringify(body) - }); -}; - -// 确认上传 -const confirmUpload = async (file_id: string) => { - return await fetchApi('/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) => { - }) -} - -// 上传文件到URL -const uploadFile = async (file: File, uploadUrl: string): Promise => { - 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); - } - }; - - 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 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 }; - } -}; -// 提取视频的首帧进行压缩并上传 -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); - - console.log('视频首帧文件上传成功:', { - fileId: file_id, - filename: compressedFile.name, - type: compressedFile.type - }); - return { success: true, file_id }; - } catch (error) { - return { success: false, error }; - } -}; -// 处理单个媒体文件上传 -const processMediaUpload = async (asset: ExtendedAsset) => { - try { - // 检查权限 - const { hasPermission } = await checkMediaLibraryPermission(); - if (!hasPermission) { - throw new Error('No media library permission'); - } - - const isVideo = asset.mediaType === 'video'; - - // 上传原始文件 - const uploadOriginalFile = async () => { - try { - let fileToUpload: File; - const isVideo = asset.mediaType === 'video'; - 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 }); - console.log('文件准备上传:', { - name: fileToUpload.name, - type: fileToUpload.type, - size: fileToUpload.size - }); - } - - // 准备元数据 - let exifData = {}; - if (asset.exif) { - try { - exifData = transformData({ - ...asset, - exif: { - ...asset.exif, - '{MakerApple}': undefined - } - }); - } catch (exifError) { - console.warn('处理 EXIF 数据时出错:', exifError); - } - } - - // 获取上传 URL - const { upload_url, file_id } = await getUploadUrl(fileToUpload, { - originalUri: asset.uri, - creationTime: asset.creationTime, - mediaType: isVideo ? 'video' : 'image', - isCompressed: false, - ...exifData, - GPSVersionID: undefined - }); - - // 上传文件 - await uploadFile(fileToUpload, upload_url); - await confirmUpload(file_id); - - console.log('文件上传成功:', { - fileId: file_id, - filename: fileToUpload.name, - type: fileToUpload.type - }); - - return { - success: true, - file_id, - filename: fileToUpload.name - }; - - } catch (error: any) { - const errorMessage = error instanceof Error ? error.message : '未知错误'; - console.error('上传原始文件时出错:', { - error: errorMessage, - assetId: asset.id, - filename: asset.filename, - uri: asset.uri - }); - throw new Error(`上传失败: ${errorMessage}`); - } - }; - - // 上传压缩文件(仅图片) - 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 uploadFile(compressedFile, upload_url); - await confirmUpload(file_id); - return { success: true, file_id }; - } catch (error) { - return { success: false, error }; - } - }; - - // 先上传原始文件 - const originalResult = await uploadOriginalFile(); - - // 如果是图片,再上传压缩文件 - let compressedResult = { success: true, file_id: null }; - if (!isVideo) { - compressedResult = await uploadCompressedFile(); - // 添加素材 - addMaterial(originalResult.file_id, compressedResult?.file_id || ''); - } else { - // 上传压缩首帧 - uploadVideoThumbnail(asset) - } - - return { - originalSuccess: originalResult.success, - compressedSuccess: compressedResult.success, - fileIds: { - original: originalResult.file_id, - compressed: compressedResult.file_id - } - }; - } catch (error: any) { - if (error.message === 'No media library permission') { - throw error; - } - return { - originalSuccess: false, - compressedSuccess: false, - error: error.message - }; - } -}; - -// 注册后台任务 -export const registerBackgroundUploadTask = async () => { - try { - // 检查是否已经注册了任务 - const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_UPLOAD_TASK); - if (isRegistered) { - await BackgroundFetch.unregisterTaskAsync(BACKGROUND_UPLOAD_TASK); - } - - // 注册后台任务 - await BackgroundFetch.registerTaskAsync(BACKGROUND_UPLOAD_TASK, { - minimumInterval: 15 * 60, // 15 分钟 - stopOnTerminate: false, // 应用退出后继续运行 - startOnBoot: true, // 设备启动后自动启动 - }); - - console.log('Background task registered'); - return true; - } catch (error) { - console.error('Error registering background task:', error); - return false; - } -}; - -// 定义后台任务 -TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => { - try { - const now = new Date(); - const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); - - // 获取最近24小时的媒体文件 - const media = await getMediaByDateRange(oneDayAgo, now); - - if (media.length === 0) { - console.log('No media files to upload'); - return BackgroundFetch.BackgroundTaskResult.Success; - } - - // 处理媒体文件上传 - const results = await triggerManualUpload(oneDayAgo, now); - const successCount = results.filter(r => r.originalSuccess).length; - - console.log(`Background upload completed. Success: ${successCount}/${results.length}`); - - return successCount > 0 - ? BackgroundFetch.BackgroundTaskResult.Success - : BackgroundFetch.BackgroundTaskResult.Failed; - } catch (error) { - console.error('Background task error:', error); - return BackgroundFetch.BackgroundTaskResult.Failed; - } -}); - -// 手动触发上传 -export const triggerManualUpload = async (startDate: Date, endDate: Date) => { - try { - const media = await getMediaByDateRange(startDate, endDate); - if (media.length === 0) { - Alert.alert('提示', '在指定时间范围内未找到媒体文件'); - return []; - } - - // 分离图片和视频 - const photos = media.filter(item => item.mediaType === 'photo'); - const videos = media.filter(item => item.mediaType === 'video'); - console.log('videos11111111', videos); - - const results: any[] = []; - - // 处理所有图片(带并发控制) - const processPhoto = async (item: any) => { - try { - const result = await processMediaUpload(item); - results.push({ - id: item.id, - ...result - }); - } catch (error: any) { - results.push({ - id: item.id, - originalSuccess: false, - compressedSuccess: false, - error: error.message - }); - } - }; - - // 处理所有视频(带并发控制) - const processVideo = async (item: any) => { - try { - const result = await processMediaUpload(item); - results.push({ - id: item.id, - ...result - }); - } catch (error: any) { - results.push({ - id: item.id, - originalSuccess: false, - compressedSuccess: false, - error: error.message - }); - } - }; - - // 并发处理图片和视频 - await Promise.all([ - ...photos.map(photo => limit(() => processPhoto(photo))), - ...videos.map(video => limit(() => processVideo(video))) - ]); - - return results; - } catch (error) { - Alert.alert('错误', '上传过程中出现错误'); - throw error; - } -}; diff --git a/components/file-upload/images-picker.tsx b/components/file-upload/images-picker.tsx index a3b4c7e..f68b0cd 100644 --- a/components/file-upload/images-picker.tsx +++ b/components/file-upload/images-picker.tsx @@ -186,6 +186,7 @@ export const ImagesPicker: React.FC = ({ const file = new File([blob], `compressed_${Date.now()}_${fileName}`, { type: mimeType, }); + console.log("压缩后的文件", file); return { file, uri: manipResult.uri }; } catch (error) { diff --git a/components/login/login.tsx b/components/login/login.tsx index 0ea2f08..134e560 100644 --- a/components/login/login.tsx +++ b/components/login/login.tsx @@ -43,7 +43,7 @@ const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: Logi const res = await fetchApi('/iam/login/password-login', { method: 'POST', body: JSON.stringify(body), - }); + }, true, false); login({ ...res, email: res?.account }, res.access_token || ''); const userInfo = await fetchApi("/iam/user-info"); if (userInfo?.nickname) { diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..ee0b349 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,42 @@ +import { useRouter } from 'expo-router'; +import * as SecureStore from 'expo-secure-store'; +import { Platform } from 'react-native'; +import { API_ENDPOINT } 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(); + return data.code == 0; +} + +/** + * 检查登录态,未登录自动跳转到 /login,已登录可执行回调。 + * @param onAuthed 已登录时的回调(可选) + */ +export async function checkAuthStatus(router: ReturnType, onAuthed?: () => Promise | void) { + let token: string | null = ''; + if (Platform.OS === 'web') { + token = localStorage.getItem('token') || ''; + } else { + token = await SecureStore.getItemAsync('token') || ''; + } + + const loggedIn = !!token && await identityCheck(token); + console.log('token', token); + console.log('loggedIn', loggedIn); + if (!loggedIn) { + console.log('未登录'); + router.replace('/login'); + return false; + } + if (onAuthed) { + await onAuthed(); + } + return true; +} diff --git a/lib/background-uploader/api.ts b/lib/background-uploader/api.ts new file mode 100644 index 0000000..ff8b175 --- /dev/null +++ b/lib/background-uploader/api.ts @@ -0,0 +1,41 @@ +import { fetchApi } from '@/lib/server-api-util'; + +// 获取上传URL +export const getUploadUrl = async (file: File, metadata: Record = {}): Promise<{ upload_url: string; file_id: string }> => { + + 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: 'true', + }, + }; + + return await fetchApi<{ upload_url: string; file_id: string }>("/file/generate-upload-url", { + method: 'POST', + body: JSON.stringify(body) + }); +}; + +// 确认上传 +export const confirmUpload = async (file_id: string) => { + return await fetchApi('/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 + }]) + }); +} \ No newline at end of file diff --git a/lib/background-uploader/db.ts b/lib/background-uploader/db.ts new file mode 100644 index 0000000..e850c52 --- /dev/null +++ b/lib/background-uploader/db.ts @@ -0,0 +1,37 @@ +// import * as SQLite from 'expo-sqlite'; + +// const db = SQLite.openDatabase('upload_status.db'); + +// // 初始化表 +// export function initUploadTable() { +// db.transaction(tx => { +// tx.executeSql( +// `CREATE TABLE IF NOT EXISTS uploaded_files ( +// uri TEXT PRIMARY KEY NOT NULL +// );` +// ); +// }); +// } + +// // 检查文件是否已上传 +// export function isFileUploaded(uri: string): Promise { +// return new Promise(resolve => { +// db.transaction(tx => { +// tx.executeSql( +// 'SELECT uri FROM uploaded_files WHERE uri = ?;', +// [uri], +// (_, { rows }) => resolve(rows.length > 0) +// ); +// }); +// }); +// } + +// // 记录文件已上传 +// export function markFileAsUploaded(uri: string) { +// db.transaction(tx => { +// tx.executeSql( +// 'INSERT OR IGNORE INTO uploaded_files (uri) VALUES (?);', +// [uri] +// ); +// }); +// } diff --git a/lib/background-uploader/fileProcessor.ts b/lib/background-uploader/fileProcessor.ts new file mode 100644 index 0000000..9e5926c --- /dev/null +++ b/lib/background-uploader/fileProcessor.ts @@ -0,0 +1,141 @@ +import * as FileSystem from 'expo-file-system'; +import * as ImageManipulator from 'expo-image-manipulator'; +import { ExtendedAsset } from './types'; +import { getUploadUrl, confirmUpload } from './api'; +import { uploadFile } from './uploader'; + +// 将 HEIC 图片转化 +export const convertHeicToJpeg = async (uri: string): Promise => { + try { + console.log('Starting HEIC to JPEG conversion for:', uri); + + // 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'); + + console.log('Successfully converted HEIC to JPEG:', filename); + + // 清理临时文件 + 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) { + console.error('Detailed HEIC conversion error:', { + error: error instanceof Error ? { + message: error.message, + name: error.name, + stack: error.stack + } : error, + uri: uri + }); + throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`); + } +}; + +// 压缩图片 +export const compressImage = async (uri: string): Promise<{ uri: string; file: File }> => { + try { + const manipResult = await ImageManipulator.manipulateAsync( + uri, + [ + { + resize: { + width: 1200, + height: 1200, + }, + }, + ], + { + compress: 0.7, + format: ImageManipulator.SaveFormat.JPEG, + base64: false, + } + ); + + const response = await fetch(manipResult.uri); + const blob = await response.blob(); + const filename = uri.split('/').pop() || `image_${Date.now()}.jpg`; + const file = new File([blob], filename, { type: 'image/jpeg' }); + + return { uri: manipResult.uri, file }; + } catch (error) { + throw error; + } +}; + +// 提取视频的首帧进行压缩并上传 +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); + + console.log('视频首帧文件上传成功:', { + fileId: file_id, + filename: compressedFile.name, + type: compressedFile.type + }); + return { success: true, file_id }; + } catch (error) { + return { success: false, error }; + } +}; \ No newline at end of file diff --git a/lib/background-uploader/index.ts b/lib/background-uploader/index.ts new file mode 100644 index 0000000..eefd1e4 --- /dev/null +++ b/lib/background-uploader/index.ts @@ -0,0 +1,259 @@ +import pLimit from 'p-limit'; +import { Alert } from 'react-native'; +import { transformData } from '@/components/utils/objectFlat'; +import { ExtendedAsset } from './types'; +import { getMediaByDateRange } from './media'; +import { checkMediaLibraryPermission, getFileExtension, getMimeType } from './utils'; +import { convertHeicToJpeg, compressImage, uploadVideoThumbnail } from './fileProcessor'; +import { getUploadUrl, confirmUpload, addMaterial } from './api'; +import { uploadFile } from './uploader'; +import * as MediaLibrary from 'expo-media-library'; + +// 设置最大并发数 +const CONCURRENCY_LIMIT = 10; // 同时最多上传10个文件 +const limit = pLimit(CONCURRENCY_LIMIT); + +// 处理单个媒体文件上传 +export const processMediaUpload = async (asset: ExtendedAsset) => { + try { + // 检查权限 + const { hasPermission } = await checkMediaLibraryPermission(); + if (!hasPermission) { + throw new Error('No media library permission'); + } + + const isVideo = asset.mediaType === 'video'; + + // 上传原始文件 + const uploadOriginalFile = async () => { + try { + let fileToUpload: File; + const isVideo = asset.mediaType === 'video'; + 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 }); + console.log('文件准备上传:', { + name: fileToUpload.name, + type: fileToUpload.type, + size: fileToUpload.size + }); + } + + // 准备元数据 + let exifData = {}; + if (asset.exif) { + try { + exifData = transformData({ + ...asset, + exif: { + ...asset.exif, + '{MakerApple}': undefined + } + }); + } catch (exifError) { + console.warn('处理 EXIF 数据时出错:', exifError); + } + } + + // 获取上传 URL + const { upload_url, file_id } = await getUploadUrl(fileToUpload, { + originalUri: asset.uri, + creationTime: asset.creationTime, + mediaType: isVideo ? 'video' : 'image', + isCompressed: false, + ...exifData, + GPSVersionID: undefined + }); + + // 上传文件 + await uploadFile(fileToUpload, upload_url); + await confirmUpload(file_id); + + console.log('文件上传成功:', { + fileId: file_id, + filename: fileToUpload.name, + type: fileToUpload.type + }); + + return { + success: true, + file_id, + filename: fileToUpload.name + }; + + } catch (error: any) { + const errorMessage = error instanceof Error ? error.message : '未知错误'; + console.error('上传原始文件时出错:', { + error: errorMessage, + assetId: asset.id, + filename: asset.filename, + uri: asset.uri + }); + throw new Error(`上传失败: ${errorMessage}`); + } + }; + + // 上传压缩文件(仅图片) + 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 uploadFile(compressedFile, upload_url); + await confirmUpload(file_id); + return { success: true, file_id }; + } catch (error) { + return { success: false, error }; + } + }; + + // 先上传原始文件 + const originalResult = await uploadOriginalFile(); + + // 如果是图片,再上传压缩文件 + let compressedResult = { success: true, file_id: null }; + if (!isVideo) { + compressedResult = await uploadCompressedFile(); + // 添加素材 + addMaterial(originalResult.file_id, compressedResult?.file_id || ''); + } else { + // 上传压缩首帧 + const thumbnailResult = await uploadVideoThumbnail(asset); + if (thumbnailResult.success) { + addMaterial(originalResult.file_id, thumbnailResult.file_id || ''); + } + } + + return { + originalSuccess: originalResult.success, + compressedSuccess: compressedResult.success, + fileIds: { + original: originalResult.file_id, + compressed: compressedResult.file_id + } + }; + } catch (error: any) { + if (error.message === 'No media library permission') { + throw error; + } + return { + originalSuccess: false, + compressedSuccess: false, + error: error.message + }; + } +}; + +// 手动触发上传 +export const triggerManualUpload = async (startDate: Date, endDate: Date) => { + try { + const media = await getMediaByDateRange(startDate, endDate); + if (media.length === 0) { + Alert.alert('提示', '在指定时间范围内未找到媒体文件'); + return []; + } + + // 分离图片和视频 + const photos = media.filter(item => item.mediaType === 'photo'); + const videos = media.filter(item => item.mediaType === 'video'); + console.log('videos11111111', videos); + + const results: any[] = []; + + // 处理所有图片(带并发控制) + const processPhoto = async (item: any) => { + try { + const result = await processMediaUpload(item); + results.push({ + id: item.id, + ...result + }); + } catch (error: any) { + results.push({ + id: item.id, + originalSuccess: false, + compressedSuccess: false, + error: error.message + }); + } + }; + + // 处理所有视频(带并发控制) + const processVideo = async (item: any) => { + try { + const result = await processMediaUpload(item); + results.push({ + id: item.id, + ...result + }); + } catch (error: any) { + results.push({ + id: item.id, + originalSuccess: false, + compressedSuccess: false, + error: error.message + }); + } + }; + + // 并发处理图片和视频 + await Promise.all([ + ...photos.map(photo => limit(() => processPhoto(photo))), + ...videos.map(video => limit(() => processVideo(video))) + ]); + + return results; + } catch (error) { + Alert.alert('错误', '上传过程中出现错误'); + throw error; + } +}; + +export { registerBackgroundUploadTask } from './task'; diff --git a/lib/background-uploader/media.ts b/lib/background-uploader/media.ts new file mode 100644 index 0000000..953d7c1 --- /dev/null +++ b/lib/background-uploader/media.ts @@ -0,0 +1,89 @@ +import * as MediaLibrary 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: MediaLibrary.MediaType[], + createdAfter?: number, + createdBefore?: number, + descending: boolean = true // Default to descending +): Promise => { + 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, + descending, + }); + + 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 => { + 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 => { + 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 []; + } +}; \ No newline at end of file diff --git a/lib/background-uploader/summary.md b/lib/background-uploader/summary.md new file mode 100644 index 0000000..0d62e4d --- /dev/null +++ b/lib/background-uploader/summary.md @@ -0,0 +1,28 @@ +### `lib/background-uploader` 模块概要 + +该模块负责将用户设备中的媒体文件(图片和视频)上传到服务器,并支持后台处理。 + +**核心功能:** + +* **媒体选择:** 从设备的媒体库中获取指定日期范围内的照片和视频(见 `media.ts`),同时获取相关的元数据,如 EXIF 和位置信息。 +* **文件处理:** + * 处理 `HEIC` 格式图片转为 `JPEG` 格式(见 `fileProcessor.ts`)。 + * 将图片压缩到标准的宽高和质量(见 `fileProcessor.ts`)。 + * 对于视频,提取首帧、压缩后作为缩略图上传(见 `fileProcessor.ts`)。 +* **API 交互:** + * 与后端服务器通信,获取用于上传文件的安全临时 URL(见 `api.ts`)。 + * 上传完成后与后端确认(见 `api.ts`)。 + * 文件及其预览/缩略图上传后,将元数据发送到另一个接口以创建“素材”记录(见 `api.ts`)。 +* **上传引擎:** + * 主要上传逻辑位于 `index.ts`,负责整体流程的编排:检查权限、处理文件、调用 API。 + * 使用并发限制(`p-limit`),防止同时上传过多文件,提高可靠性和性能。 + * 同时处理原始高质量文件和压缩版本(或视频缩略图)的上传。 +* **后台任务:** + * 可注册后台任务,定期(如每 15 分钟)自动上传过去 24 小时内的新媒体文件(见 `task.ts`)。 + * 即使应用不在前台,也能持续上传文件,提升无缝体验。 +* **工具与类型定义:** + * 包含用于检查媒体库权限、获取文件扩展名和 MIME 类型的辅助函数(见 `utils.ts`)。 + * 定义了自定义的 `ExtendedAsset` 类型,包含 `exif` 数据和标准的 `MediaLibrary.Asset` 属性(见 `types.ts`)。 + * 实际上传文件使用 `XMLHttpRequest`,以支持进度追踪,并通过 Promise 封装(见 `uploader.ts`)。 + +简而言之,这是一个为移动应用设计的、健壮高效的后台上传系统。 \ No newline at end of file diff --git a/lib/background-uploader/task.ts b/lib/background-uploader/task.ts new file mode 100644 index 0000000..0e242c4 --- /dev/null +++ b/lib/background-uploader/task.ts @@ -0,0 +1,79 @@ +import * as BackgroundFetch from 'expo-background-task'; +import * as TaskManager from 'expo-task-manager'; +import { isFileUploaded, markFileAsUploaded } from './db'; +import { getMediaByDateRange } from './media'; + +const BACKGROUND_UPLOAD_TASK = 'background-upload-task'; + +// 注册后台任务 +export const registerBackgroundUploadTask = async () => { + try { + // 初始化数据库表 + // initUploadTable(); + // 检查是否已经注册了任务 + const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_UPLOAD_TASK); + if (isRegistered) { + await BackgroundFetch.unregisterTaskAsync(BACKGROUND_UPLOAD_TASK); + } + + // 注册后台任务 + await BackgroundFetch.registerTaskAsync(BACKGROUND_UPLOAD_TASK, { + minimumInterval: 15 * 60, // 15 分钟 + }); + + console.log('Background task registered'); + return true; + } catch (error) { + console.error('Error registering background task:', error); + return false; + } +}; + +// 定义后台任务 +TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => { + try { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // 获取最近24小时的媒体文件 + const media = await getMediaByDateRange(oneDayAgo, now); + + // 过滤已上传文件(以 uri 为唯一标识) + const filesToUpload = []; + for (const file of media) { + const uploaded = await isFileUploaded(file.uri); + if (!uploaded) filesToUpload.push(file); + } + + if (filesToUpload.length === 0) { + console.log('No media files to upload'); + return BackgroundFetch.BackgroundTaskResult.Success; + } + + // 上传未上传文件 + let successCount = 0; + for (const file of filesToUpload) { + // 这里假设有 uploadSingleMedia 函数,或直接使用 index.ts 的 processMediaUpload + try { + // 你可以根据实际情况替换为自己的上传逻辑 + const { processMediaUpload } = await import('./index'); + const result = await processMediaUpload(file); + if (result.originalSuccess) { + markFileAsUploaded(file.uri); + successCount++; + } + } catch (e) { + console.error('Upload failed for', file.uri, e); + } + } + + console.log(`Background upload completed. Success: ${successCount}/${filesToUpload.length}`); + + return successCount > 0 + ? BackgroundFetch.BackgroundTaskResult.Success + : BackgroundFetch.BackgroundTaskResult.Failed; + } catch (error) { + console.error('Background task error:', error); + return BackgroundFetch.BackgroundTaskResult.Failed; + } +}); \ No newline at end of file diff --git a/lib/background-uploader/types.ts b/lib/background-uploader/types.ts new file mode 100644 index 0000000..ae6b38c --- /dev/null +++ b/lib/background-uploader/types.ts @@ -0,0 +1,5 @@ +import * as MediaLibrary from 'expo-media-library'; + +export type ExtendedAsset = MediaLibrary.Asset & { + exif?: Record; +}; \ No newline at end of file diff --git a/lib/background-uploader/uploader.ts b/lib/background-uploader/uploader.ts new file mode 100644 index 0000000..0381903 --- /dev/null +++ b/lib/background-uploader/uploader.ts @@ -0,0 +1,30 @@ +// 上传文件到URL +export const uploadFile = async (file: File, uploadUrl: string): Promise => { + 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); + } + }; + + 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); + }); +}; \ No newline at end of file diff --git a/lib/background-uploader/utils.ts b/lib/background-uploader/utils.ts new file mode 100644 index 0000000..9e1e1b0 --- /dev/null +++ b/lib/background-uploader/utils.ts @@ -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'; // 默认值 + } +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 77c2f16..7a63607 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "expo-router": "~5.1.0", "expo-secure-store": "~14.2.3", "expo-splash-screen": "~0.30.9", + "expo-sqlite": "~15.2.14", "expo-status-bar": "~2.2.3", "expo-symbols": "~0.4.5", "expo-system-ui": "~5.0.9", @@ -5468,6 +5469,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -7803,7 +7810,7 @@ }, "node_modules/expo-background-task": { "version": "0.2.8", - "resolved": "http://192.168.31.115:8081/repository/npm/expo-background-task/-/expo-background-task-0.2.8.tgz", + "resolved": "https://registry.npmjs.org/expo-background-task/-/expo-background-task-0.2.8.tgz", "integrity": "sha512-dePyskpmyDZeOtbr9vWFh+Nrse0TvF6YitJqnKcd+3P7pDMiDr1V2aT6zHdNOc5iV9vPaDJoH/zdmlarp1uHMQ==", "license": "MIT", "dependencies": { @@ -8240,6 +8247,20 @@ "expo": "*" } }, + "node_modules/expo-sqlite": { + "version": "15.2.14", + "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-15.2.14.tgz", + "integrity": "sha512-6tWnEE0fcir30/e7eVwjeC7eKdncfVnIgo2JvnKpRndedyiFMXLMyOQWNVGnuhnSrPV2BHvGGjLByS/j5VgH4w==", + "license": "MIT", + "dependencies": { + "await-lock": "^2.2.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-status-bar": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-2.2.3.tgz", diff --git a/package.json b/package.json index 479da80..7c6ed12 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "expo-router": "~5.1.0", "expo-secure-store": "~14.2.3", "expo-splash-screen": "~0.30.9", + "expo-sqlite": "~15.2.14", "expo-status-bar": "~2.2.3", "expo-symbols": "~0.4.5", "expo-system-ui": "~5.0.9",