diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index dee2609..6a8269e 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,4 +1,3 @@ -import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic'; import * as MediaLibrary from 'expo-media-library'; import { useRouter } from 'expo-router'; import * as SecureStore from 'expo-secure-store'; @@ -39,7 +38,7 @@ export default function HomeScreen() { // 已登录,请求必要的权限 const { status } = await MediaLibrary.requestPermissionsAsync(); if (status === 'granted') { - await registerBackgroundUploadTask(); + console.log('Media library permission granted in HomeScreen.'); } router.replace('/ask'); } diff --git a/app/(tabs)/memo-list.tsx b/app/(tabs)/memo-list.tsx index 8940e61..f93d5d4 100644 --- a/app/(tabs)/memo-list.tsx +++ b/app/(tabs)/memo-list.tsx @@ -1,14 +1,22 @@ import ChatSvg from "@/assets/icons/svg/chat.svg"; -import AutoUploadScreen from "@/components/file-upload/autoUploadScreen"; +import UploaderProgress from "@/components/file-upload/upload-progress/uploader-progress"; import AskNavbar from "@/components/layout/ask"; +import { endUploadSessionInDb, syncUploadSessionState } from "@/features/appState/appStateSlice"; +import { triggerManualUpload } from "@/lib/background-uploader/automatic"; +import { getUploadTasksSince, UploadTask } from "@/lib/db"; 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 { router, useFocusEffect } from "expo-router"; +import React, { useCallback, 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([]); @@ -39,13 +47,99 @@ const MemoList = () => { getHistoryList() }, []) + const [progressInfo, setProgressInfo] = useState({ total: 0, completed: 0, image: '' }); + + useFocusEffect( + useCallback(() => { + let isActive = true; + let interval: any = null; + + const manageUploadState = async () => { + if (!isActive) 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('MemoList detects all tasks are complete. Ending session.'); + if (interval) clearInterval(interval); + dispatch(endUploadSessionInDb()); + } + } else { + // 如果没有会话,确保本地状态被重置 + if (isActive) { + setProgressInfo({ total: 0, completed: 0, image: '' }); + } + } + }; + + const triggerAndMonitor = async () => { + console.log('MemoList focused, triggering foreground media upload check.'); + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // This now returns the start time string or null + const sessionStartTimeStr = await triggerManualUpload(oneDayAgo, now); + + // Only start monitoring if a session was actually started. + if (sessionStartTimeStr) { + console.log(`Upload session started with time: ${sessionStartTimeStr}, beginning to monitor...`); + // Immediately check the state once, then the interval will take over. + // This ensures the progress bar appears instantly. + manageUploadState(); + interval = setInterval(manageUploadState, 2000); + } + }; + + triggerAndMonitor(); + + return () => { + isActive = false; + if (interval) { + clearInterval(interval); + } + }; + }, [dispatch]) + ); + return ( {/* 上传进度展示区域 */} + {process.env.NODE_ENV === 'development' && uploadSessionStartTime && ( + + + + )} - + {/* - + */} + + router.push('/debug')} + > + + 进入db调试页面 + + {/* 顶部标题和上传按钮 */} diff --git a/app/_layout.tsx b/app/_layout.tsx index d7ef45b..b9af5a4 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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 ( diff --git a/components/file-upload/autoUploadScreen.tsx b/components/file-upload/autoUploadScreen.tsx index 405a394..856443e 100644 --- a/components/file-upload/autoUploadScreen.tsx +++ b/components/file-upload/autoUploadScreen.tsx @@ -1,14 +1,12 @@ -import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic'; import { triggerManualUpload } from '@/lib/background-uploader/manual'; import { router } from 'expo-router'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import UploaderProgressBar from './uploader-progress-bar'; +import UploaderProgressBar from './upload-progress/progress-bar'; export default function AutoUploadScreen() { const [timeRange, setTimeRange] = useState('day'); const [isLoading, setIsLoading] = useState(false); - const [isRegistered, setIsRegistered] = useState(false); const [uploadProgress, setUploadProgress] = useState({ totalCount: 0, uploadedCount: 0, @@ -17,15 +15,6 @@ export default function AutoUploadScreen() { totalSize: 0, }); - // 注册后台任务 - useEffect(() => { - const registerTask = async () => { - const registered = await registerBackgroundUploadTask(); - setIsRegistered(registered); - }; - console.log("register background upload task"); - registerTask(); - }, []); // 处理手动上传 const handleManualUpload = async () => { @@ -147,14 +136,7 @@ export default function AutoUploadScreen() { - - - 后台自动上传状态: {isRegistered ? '已启用' : '未启用'} - - - 系统会自动在后台上传过去24小时内的新照片和视频 - - + { // isLoading && diff --git a/components/file-upload/uploader-progress-bar.tsx b/components/file-upload/upload-progress/progress-bar.tsx similarity index 100% rename from components/file-upload/uploader-progress-bar.tsx rename to components/file-upload/upload-progress/progress-bar.tsx diff --git a/components/file-upload/upload-progress/uploader-progress.tsx b/components/file-upload/upload-progress/uploader-progress.tsx new file mode 100644 index 0000000..ca258b1 --- /dev/null +++ b/components/file-upload/upload-progress/uploader-progress.tsx @@ -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 = ({ 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 ( + + + + ); +}; + +export default React.memo(UploaderProgress); \ No newline at end of file diff --git a/features/appState/appStateSlice.ts b/features/appState/appStateSlice.ts new file mode 100644 index 0000000..9925bf0 --- /dev/null +++ b/features/appState/appStateSlice.ts @@ -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) => { + state.status = 'succeeded'; + state.uploadSessionStartTime = action.payload; + }) + .addCase(syncUploadSessionState.rejected, (state) => { + state.status = 'failed'; + }) + .addCase(endUploadSessionInDb.fulfilled, (state, action: PayloadAction) => { + state.uploadSessionStartTime = action.payload; + }); + }, +}); + +export default appStateSlice.reducer; diff --git a/lib/background-uploader/automatic.ts b/lib/background-uploader/automatic.ts index 2d377ce..f390d85 100644 --- a/lib/background-uploader/automatic.ts +++ b/lib/background-uploader/automatic.ts @@ -1,7 +1,7 @@ import * as BackgroundTask from 'expo-background-task'; import * as TaskManager from 'expo-task-manager'; import pLimit from 'p-limit'; -import { getUploadTaskStatus, initUploadTable, insertUploadTask } from '../db'; +import { filterExistingFiles, initUploadTable, insertUploadTask, setAppState, updateUploadTaskStatus } from '../db'; import { getMediaByDateRange } from './media'; import { processAndUploadMedia } from './uploader'; @@ -11,7 +11,7 @@ const CONCURRENCY_LIMIT = 1; // 后台上传并发数,例如同时上传3个 const limit = pLimit(CONCURRENCY_LIMIT); // 注册后台任务 -export const registerBackgroundUploadTask = async () => { +export async function registerBackgroundUploadTask() { try { // 初始化数据库表 await initUploadTable(); @@ -32,68 +32,89 @@ export const registerBackgroundUploadTask = async () => { } }; +// 触发手动或后台上传的通用函数 +export async function triggerManualUpload(startDate: Date, endDate: Date): Promise { + 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); - - // 获取最近24小时的媒体文件 - const media = await getMediaByDateRange(oneDayAgo, now); - - if (media.length === 0) { - console.log('No new media files to upload in the last 24 hours.'); - return BackgroundTask.BackgroundTaskResult.Success; - } - - console.log(`Found ${media.length} media files to potentially upload.`); - - // 并发上传文件 - let successCount = 0; - let skippedCount = 0; - let failedCount = 0; - - const uploadPromises = media.map((file) => - limit(async () => { - try { - const existingTask = await getUploadTaskStatus(file.uri); - if (!existingTask) { - await insertUploadTask(file.uri, file.filename); - } else if (existingTask.status === 'success' || existingTask.status === 'skipped') { - console.log(`File ${file.uri} already ${existingTask.status}, skipping processing.`); - return { status: 'skipped' }; // 返回状态以便统计 - } - - const result = await processAndUploadMedia(file); - if (result === null) { - return { status: 'skipped' }; - } else if (result.originalSuccess) { - return { status: 'success' }; - } else { - return { status: 'failed' }; - } - } catch (e) { - console.error('Upload failed for', file.uri, e); - return { status: 'failed' }; - } - }) - ); - - const results = await Promise.all(uploadPromises); - - results.forEach(result => { - if (result.status === 'success') { - successCount++; - } else if (result.status === 'skipped') { - skippedCount++; - } else if (result.status === 'failed') { - failedCount++; - } - }); - - console.log(`Background upload task finished. Successful: ${successCount}, Skipped: ${skippedCount}, Failed: ${failedCount}, Total: ${media.length}`); - + await triggerManualUpload(oneDayAgo, now); return BackgroundTask.BackgroundTaskResult.Success; } catch (error) { console.error('Background task error:', error); diff --git a/lib/background-uploader/manual.ts b/lib/background-uploader/manual.ts index 4dcd982..d33f406 100644 --- a/lib/background-uploader/manual.ts +++ b/lib/background-uploader/manual.ts @@ -36,7 +36,7 @@ export const triggerManualUpload = async ( for (const asset of media) { const existingTask = await getUploadTaskStatus(asset.uri); if (!existingTask) { - await insertUploadTask(asset.uri, asset.filename); + 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' diff --git a/lib/background-uploader/uploader.ts b/lib/background-uploader/uploader.ts index 14793be..b01329a 100644 --- a/lib/background-uploader/uploader.ts +++ b/lib/background-uploader/uploader.ts @@ -103,10 +103,10 @@ export const processAndUploadMedia = async ( GPSVersionID: undefined }); - await uploadFileWithProgress(fileToUpload, upload_url, (progress) => { + await uploadFileWithProgress(fileToUpload, upload_url, async (progress) => { if (onProgress) onProgress(progress); const percentage = progress.total > 0 ? (progress.loaded / progress.total) * 100 : 0; - updateUploadTaskProgress(asset.uri, Math.round(percentage * 0.5)); // 原始文件占总进度的50% + await updateUploadTaskProgress(asset.uri, Math.round(percentage * 0.5)); // 原始文件占总进度的50% }); await confirmUpload(file_id); @@ -131,11 +131,11 @@ export const processAndUploadMedia = async ( isCompressed: true }); - await uploadFileWithProgress(compressedFile, upload_url, (progress) => { + 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; - updateUploadTaskProgress(asset.uri, 50 + Math.round(percentage * 0.5)); // 压缩文件占总进度的后50% + await updateUploadTaskProgress(asset.uri, 50 + Math.round(percentage * 0.5)); // 压缩文件占总进度的后50% }); await confirmUpload(file_id); return { success: true, file_id }; @@ -156,12 +156,12 @@ export const processAndUploadMedia = async ( if (!isVideo) { compressedResult = await uploadCompressedFile(); if (originalResult.file_id && compressedResult.file_id) { - addMaterial(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) { - addMaterial(originalResult.file_id, thumbnailResult.file_id); + await addMaterial(originalResult.file_id, thumbnailResult.file_id); } } diff --git a/lib/db.ts b/lib/db.ts index c47cf4d..fd817c7 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -2,85 +2,94 @@ 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 function initUploadTable() { +export async function initUploadTable() { console.log('Initializing upload tasks table...'); - db.execSync(` + 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 + 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(uri: string, filename: string): Promise { - console.log('Inserting upload task:', uri, filename); - db.runSync( - 'INSERT OR IGNORE INTO upload_tasks (uri, filename, status, progress) VALUES (?, ?, ?, ?);', - uri, - filename, - 'pending', - 0 +export async function insertUploadTask(task: Omit) { + 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 { console.log('Checking upload task status for:', uri); - const result = db.getFirstSync( - 'SELECT uri, filename, status, progress, file_id FROM upload_tasks WHERE uri = ?;', + const result = await db.getFirstAsync( + '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): Promise { - console.log('Updating upload task status:', uri, status, file_id); +export async function updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string) { if (file_id) { - db.runSync( - 'UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?;', - status, - file_id, - uri - ); + await db.runAsync('UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?', [status, file_id, uri]); } else { - db.runSync( - 'UPDATE upload_tasks SET status = ? WHERE uri = ?;', - status, - uri - ); + await db.runAsync('UPDATE upload_tasks SET status = ? WHERE uri = ?', [status, uri]); } } // 更新上传任务的进度 -export async function updateUploadTaskProgress(uri: string, progress: number): Promise { - console.log('Updating upload task progress:', uri, progress); - db.runSync( - 'UPDATE upload_tasks SET progress = ? WHERE uri = ?;', - progress, - 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 { console.log('Fetching all upload tasks... time:', new Date().toLocaleString()); - const results = db.getAllSync( - 'SELECT uri, filename, status, progress, file_id FROM upload_tasks;' + const results = await db.getAllAsync( + 'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks ORDER BY created_at DESC;' ); return results; } @@ -88,11 +97,55 @@ export async function getUploadTasks(): Promise { // 清理已完成或失败的任务 (可选,根据需求添加) export async function cleanUpUploadTasks(): Promise { console.log('Cleaning up completed/failed upload tasks...'); - db.runSync( - "DELETE FROM upload_tasks WHERE status = 'success' OR status = 'failed' OR status = 'skipped';" + await db.runAsync( + "DELETE FROM upload_tasks WHERE status = 'success' OR status = 'failed' OR status = 'skipped';" ); } +// 获取某个时间点之后的所有上传任务 +export async function getUploadTasksSince(timestamp: number): Promise { + const rows = await db.getAllAsync( + 'SELECT * FROM upload_tasks WHERE created_at >= ? ORDER BY created_at DESC', + [timestamp] + ); + return rows; +} + +// 检查一组文件URI,返回那些在数据库中不存在或是未成功上传的文件的URI +export async function filterExistingFiles(fileUris: string[]): Promise { + 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 { + 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 { + 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 { try { diff --git a/store.ts b/store.ts index f77b456..482ba2e 100644 --- a/store.ts +++ b/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; -// 推断类型:{posts: PostsState, comments: CommentsState, users: UsersState} -export type AppDispatch = typeof store.dispatch; \ No newline at end of file +export type AppDispatch = typeof store.dispatch; + +// 在整个应用中使用,而不是简单的 `useDispatch` 和 `useSelector` +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; \ No newline at end of file