feat: 上传自动展示进度条

This commit is contained in:
Junhui Chen 2025-07-17 15:08:03 +08:00
parent c7df8a66d0
commit d1d7fbbe30
12 changed files with 394 additions and 133 deletions

View File

@ -1,4 +1,3 @@
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
import * as MediaLibrary from 'expo-media-library'; import * as MediaLibrary from 'expo-media-library';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
@ -39,7 +38,7 @@ export default function HomeScreen() {
// 已登录,请求必要的权限 // 已登录,请求必要的权限
const { status } = await MediaLibrary.requestPermissionsAsync(); const { status } = await MediaLibrary.requestPermissionsAsync();
if (status === 'granted') { if (status === 'granted') {
await registerBackgroundUploadTask(); console.log('Media library permission granted in HomeScreen.');
} }
router.replace('/ask'); router.replace('/ask');
} }

View File

@ -1,14 +1,22 @@
import ChatSvg from "@/assets/icons/svg/chat.svg"; 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 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 { fetchApi } from "@/lib/server-api-util";
import { useAppDispatch, useAppSelector } from "@/store";
import { Chat } from "@/types/ask"; import { Chat } from "@/types/ask";
import { router } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import React, { useEffect } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { FlatList, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { FlatList, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
const MemoList = () => { const MemoList = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const dispatch = useAppDispatch();
const uploadSessionStartTime = useAppSelector((state) => state.appState.uploadSessionStartTime);
// 历史消息 // 历史消息
const [historyList, setHistoryList] = React.useState<Chat[]>([]); const [historyList, setHistoryList] = React.useState<Chat[]>([]);
@ -39,13 +47,99 @@ const MemoList = () => {
getHistoryList() 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 ( return (
<View style={[styles.container, { paddingTop: insets.top }]}> <View style={[styles.container, { paddingTop: insets.top }]}>
{/* 上传进度展示区域 */} {/* 上传进度展示区域 */}
{process.env.NODE_ENV === 'development' && uploadSessionStartTime && (
<View className="w-full h-20">
<UploaderProgress
imageUrl={progressInfo.image}
uploadedCount={progressInfo.completed}
totalCount={progressInfo.total}
/>
</View>
)}
<View className="w-full h-full"> {/* <View className="w-full h-full">
<AutoUploadScreen /> <AutoUploadScreen />
</View> </View> */}
<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}> <View style={styles.header}>

View File

@ -1,7 +1,10 @@
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import * as MediaLibrary from 'expo-media-library';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
import 'react-native-reanimated'; import 'react-native-reanimated';
import '../global.css'; import '../global.css';
import { Provider } from "../provider"; import { Provider } from "../provider";
@ -9,6 +12,22 @@ import { Provider } from "../provider";
export default function RootLayout() { export default function RootLayout() {
const colorScheme = useColorScheme(); 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 ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Provider> <Provider>

View File

@ -1,14 +1,12 @@
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
import { triggerManualUpload } from '@/lib/background-uploader/manual'; import { triggerManualUpload } from '@/lib/background-uploader/manual';
import { router } from 'expo-router'; 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 { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import UploaderProgressBar from './uploader-progress-bar'; import UploaderProgressBar from './upload-progress/progress-bar';
export default function AutoUploadScreen() { export default function AutoUploadScreen() {
const [timeRange, setTimeRange] = useState('day'); const [timeRange, setTimeRange] = useState('day');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);
const [uploadProgress, setUploadProgress] = useState({ const [uploadProgress, setUploadProgress] = useState({
totalCount: 0, totalCount: 0,
uploadedCount: 0, uploadedCount: 0,
@ -17,15 +15,6 @@ export default function AutoUploadScreen() {
totalSize: 0, totalSize: 0,
}); });
// 注册后台任务
useEffect(() => {
const registerTask = async () => {
const registered = await registerBackgroundUploadTask();
setIsRegistered(registered);
};
console.log("register background upload task");
registerTask();
}, []);
// 处理手动上传 // 处理手动上传
const handleManualUpload = async () => { const handleManualUpload = async () => {
@ -147,14 +136,7 @@ export default function AutoUploadScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.statusContainer}>
<Text style={styles.statusText}>
: {isRegistered ? '已启用' : '未启用'}
</Text>
<Text style={styles.hintText}>
24
</Text>
</View>
{ {
// isLoading && // isLoading &&

View 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);

View 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;

View File

@ -1,7 +1,7 @@
import * as BackgroundTask from 'expo-background-task'; import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager'; import * as TaskManager from 'expo-task-manager';
import pLimit from 'p-limit'; import pLimit from 'p-limit';
import { getUploadTaskStatus, initUploadTable, insertUploadTask } from '../db'; import { filterExistingFiles, initUploadTable, insertUploadTask, setAppState, updateUploadTaskStatus } from '../db';
import { getMediaByDateRange } from './media'; import { getMediaByDateRange } from './media';
import { processAndUploadMedia } from './uploader'; import { processAndUploadMedia } from './uploader';
@ -11,7 +11,7 @@ const CONCURRENCY_LIMIT = 1; // 后台上传并发数例如同时上传3个
const limit = pLimit(CONCURRENCY_LIMIT); const limit = pLimit(CONCURRENCY_LIMIT);
// 注册后台任务 // 注册后台任务
export const registerBackgroundUploadTask = async () => { export async function registerBackgroundUploadTask() {
try { try {
// 初始化数据库表 // 初始化数据库表
await initUploadTable(); await initUploadTable();
@ -32,68 +32,89 @@ export const registerBackgroundUploadTask = async () => {
} }
}; };
// 触发手动或后台上传的通用函数
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 () => { TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
try { try {
console.log('Running background upload task...'); console.log('Running background upload task...');
const now = new Date(); const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
await triggerManualUpload(oneDayAgo, now);
// 获取最近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}`);
return BackgroundTask.BackgroundTaskResult.Success; return BackgroundTask.BackgroundTaskResult.Success;
} catch (error) { } catch (error) {
console.error('Background task error:', error); console.error('Background task error:', error);

View File

@ -36,7 +36,7 @@ export const triggerManualUpload = async (
for (const asset of media) { for (const asset of media) {
const existingTask = await getUploadTaskStatus(asset.uri); const existingTask = await getUploadTaskStatus(asset.uri);
if (!existingTask) { 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') { } else if (existingTask.status === 'success' || existingTask.status === 'skipped') {
console.log(`File ${asset.uri} already ${existingTask.status}, skipping processing.`); console.log(`File ${asset.uri} already ${existingTask.status}, skipping processing.`);
uploadedCount++; // Also count skipped files as 'processed' uploadedCount++; // Also count skipped files as 'processed'

View File

@ -103,10 +103,10 @@ export const processAndUploadMedia = async (
GPSVersionID: undefined GPSVersionID: undefined
}); });
await uploadFileWithProgress(fileToUpload, upload_url, (progress) => { await uploadFileWithProgress(fileToUpload, upload_url, async (progress) => {
if (onProgress) onProgress(progress); if (onProgress) onProgress(progress);
const percentage = progress.total > 0 ? (progress.loaded / progress.total) * 100 : 0; 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); await confirmUpload(file_id);
@ -131,11 +131,11 @@ export const processAndUploadMedia = async (
isCompressed: true 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, // 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. // 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; 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); await confirmUpload(file_id);
return { success: true, file_id }; return { success: true, file_id };
@ -156,12 +156,12 @@ export const processAndUploadMedia = async (
if (!isVideo) { if (!isVideo) {
compressedResult = await uploadCompressedFile(); compressedResult = await uploadCompressedFile();
if (originalResult.file_id && compressedResult.file_id) { if (originalResult.file_id && compressedResult.file_id) {
addMaterial(originalResult.file_id, compressedResult.file_id); await addMaterial(originalResult.file_id, compressedResult.file_id);
} }
} else { } else {
const thumbnailResult = await uploadVideoThumbnail(asset); const thumbnailResult = await uploadVideoThumbnail(asset);
if (thumbnailResult.success && originalResult.file_id && thumbnailResult.file_id) { if (thumbnailResult.success && originalResult.file_id && thumbnailResult.file_id) {
addMaterial(originalResult.file_id, thumbnailResult.file_id); await addMaterial(originalResult.file_id, thumbnailResult.file_id);
} }
} }

127
lib/db.ts
View File

@ -2,85 +2,94 @@ import * as SQLite from 'expo-sqlite';
const db = SQLite.openDatabaseSync('upload_status.db'); 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 = { export type UploadTask = {
uri: string; uri: string;
filename: string; filename: string;
status: 'pending' | 'uploading' | 'success' | 'failed' | 'skipped'; status: 'pending' | 'uploading' | 'success' | 'failed' | 'skipped';
progress: number; // 0-100 progress: number; // 0-100
file_id?: string; // 后端返回的文件ID file_id?: string; // 后端返回的文件ID
created_at: number; // unix timestamp
}; };
// 初始化表 // 初始化表
export function initUploadTable() { export async function initUploadTable() {
console.log('Initializing upload tasks table...'); console.log('Initializing upload tasks table...');
db.execSync(` await db.execAsync(`
CREATE TABLE IF NOT EXISTS upload_tasks ( CREATE TABLE IF NOT EXISTS upload_tasks (
uri TEXT PRIMARY KEY NOT NULL, uri TEXT PRIMARY KEY NOT NULL,
filename TEXT NOT NULL, filename TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0, 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'); 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<void> { export async function insertUploadTask(task: Omit<UploadTask, 'created_at'>) {
console.log('Inserting upload task:', uri, filename); console.log('Inserting upload task:', task.uri);
db.runSync( await db.runAsync(
'INSERT OR IGNORE INTO upload_tasks (uri, filename, status, progress) VALUES (?, ?, ?, ?);', 'INSERT OR REPLACE INTO upload_tasks (uri, filename, status, progress, file_id, created_at) VALUES (?, ?, ?, ?, ?, ?)',
uri, [task.uri, task.filename, task.status, task.progress, task.file_id ?? null, Math.floor(Date.now() / 1000)]
filename,
'pending',
0
); );
} }
// 检查文件是否已上传或正在上传 // 检查文件是否已上传或正在上传
export async function getUploadTaskStatus(uri: string): Promise<UploadTask | null> { export async function getUploadTaskStatus(uri: string): Promise<UploadTask | null> {
console.log('Checking upload task status for:', uri); console.log('Checking upload task status for:', uri);
const result = db.getFirstSync<UploadTask>( const result = await db.getFirstAsync<UploadTask>(
'SELECT uri, filename, status, progress, file_id FROM upload_tasks WHERE uri = ?;', 'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks WHERE uri = ?;',
uri uri
); );
return result || null; return result || null;
} }
// 更新上传任务的状态 // 更新上传任务的状态
export async function updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise<void> { export async function updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string) {
console.log('Updating upload task status:', uri, status, file_id);
if (file_id) { if (file_id) {
db.runSync( await db.runAsync('UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?', [status, file_id, uri]);
'UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?;',
status,
file_id,
uri
);
} else { } else {
db.runSync( await db.runAsync('UPDATE upload_tasks SET status = ? WHERE uri = ?', [status, uri]);
'UPDATE upload_tasks SET status = ? WHERE uri = ?;',
status,
uri
);
} }
} }
// 更新上传任务的进度 // 更新上传任务的进度
export async function updateUploadTaskProgress(uri: string, progress: number): Promise<void> { export async function updateUploadTaskProgress(uri: string, progress: number) {
console.log('Updating upload task progress:', uri, progress); await db.runAsync('UPDATE upload_tasks SET progress = ? WHERE uri = ?', [progress, uri]);
db.runSync(
'UPDATE upload_tasks SET progress = ? WHERE uri = ?;',
progress,
uri
);
} }
// 获取所有上传任务 // 获取所有上传任务
export async function getUploadTasks(): Promise<UploadTask[]> { export async function getUploadTasks(): Promise<UploadTask[]> {
console.log('Fetching all upload tasks... time:', new Date().toLocaleString()); console.log('Fetching all upload tasks... time:', new Date().toLocaleString());
const results = db.getAllSync<UploadTask>( const results = await db.getAllAsync<UploadTask>(
'SELECT uri, filename, status, progress, file_id FROM upload_tasks;' 'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks ORDER BY created_at DESC;'
); );
return results; return results;
} }
@ -88,11 +97,55 @@ export async function getUploadTasks(): Promise<UploadTask[]> {
// 清理已完成或失败的任务 (可选,根据需求添加) // 清理已完成或失败的任务 (可选,根据需求添加)
export async function cleanUpUploadTasks(): Promise<void> { export async function cleanUpUploadTasks(): Promise<void> {
console.log('Cleaning up completed/failed upload tasks...'); console.log('Cleaning up completed/failed upload tasks...');
db.runSync( await db.runAsync(
"DELETE FROM upload_tasks WHERE status = 'success' OR status = 'failed' OR status = 'skipped';" "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;
}
// 检查一组文件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 // for debug page
export async function executeSql(sql: string, params: any[] = []): Promise<any> { export async function executeSql(sql: string, params: any[] = []): Promise<any> {
try { try {

View File

@ -1,15 +1,21 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { counterSlice } from './components/steps'; import { counterSlice } from './components/steps';
import authReducer from './features/auth/authSlice'; import authReducer from './features/auth/authSlice';
import appStateReducer from './features/appState/appStateSlice';
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
counter: counterSlice.reducer, counter: counterSlice.reducer,
auth: authReducer auth: authReducer,
appState: appStateReducer
}, },
}); });
// 从 store 本身推断 `RootState` 和 `AppDispatch` 类型 // 从 store 本身推断 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<typeof store.getState>; 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;