feat: 上传自动展示进度条
This commit is contained in:
parent
c7df8a66d0
commit
d1d7fbbe30
@ -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');
|
||||
}
|
||||
|
||||
@ -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<Chat[]>([]);
|
||||
|
||||
@ -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 (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* 上传进度展示区域 */}
|
||||
|
||||
<View className="w-full h-full">
|
||||
<AutoUploadScreen />
|
||||
{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">
|
||||
<AutoUploadScreen />
|
||||
</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}>
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { registerBackgroundUploadTask } from '@/lib/background-uploader/automatic';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
import '../global.css';
|
||||
import { Provider } from "../provider";
|
||||
@ -9,6 +12,22 @@ import { Provider } from "../provider";
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
const setupBackgroundUpload = async () => {
|
||||
const { status } = await MediaLibrary.getPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
console.log('Media library permission not granted. Background task registered but will wait for permission.');
|
||||
}
|
||||
const registered = await registerBackgroundUploadTask();
|
||||
if (registered) {
|
||||
console.log('Background upload task setup finished in RootLayout.');
|
||||
} else {
|
||||
console.error('Failed to register background upload task in RootLayout.');
|
||||
}
|
||||
};
|
||||
setupBackgroundUpload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Provider>
|
||||
|
||||
@ -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() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusContainer}>
|
||||
<Text style={styles.statusText}>
|
||||
后台自动上传状态: {isRegistered ? '已启用' : '未启用'}
|
||||
</Text>
|
||||
<Text style={styles.hintText}>
|
||||
系统会自动在后台上传过去24小时内的新照片和视频
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
{
|
||||
// isLoading &&
|
||||
|
||||
33
components/file-upload/upload-progress/uploader-progress.tsx
Normal file
33
components/file-upload/upload-progress/uploader-progress.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import UploaderProgressBar from './progress-bar';
|
||||
|
||||
interface UploaderProgressProps {
|
||||
imageUrl: string;
|
||||
uploadedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const UploaderProgress: React.FC<UploaderProgressProps> = ({ imageUrl, uploadedCount, totalCount }) => {
|
||||
// This is now a 'dumb' component. It only displays the progress based on props.
|
||||
// All logic for fetching and state management is handled by the parent component (MemoList).
|
||||
|
||||
if (totalCount === 0) {
|
||||
// Don't render anything if there's no session or tasks.
|
||||
// The parent component's logic (`uploadSessionStartTime && ...`) already handles this,
|
||||
// but this is an extra safeguard.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'transparent' }}>
|
||||
<UploaderProgressBar
|
||||
imageUrl={imageUrl}
|
||||
uploadedCount={uploadedCount}
|
||||
totalCount={totalCount}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(UploaderProgress);
|
||||
54
features/appState/appStateSlice.ts
Normal file
54
features/appState/appStateSlice.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { getAppState, setAppState } from '../../lib/db';
|
||||
|
||||
interface AppState {
|
||||
uploadSessionStartTime: number | null;
|
||||
status: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
uploadSessionStartTime: null,
|
||||
status: 'idle',
|
||||
};
|
||||
|
||||
// Thunk to fetch the session start time from the database
|
||||
export const syncUploadSessionState = createAsyncThunk(
|
||||
'appState/syncUploadSessionState',
|
||||
async () => {
|
||||
const startTimeStr = await getAppState('uploadSessionStartTime');
|
||||
return startTimeStr ? parseInt(startTimeStr, 10) : null;
|
||||
}
|
||||
);
|
||||
|
||||
// Thunk to clear the session state in the database, which will then be reflected in the store
|
||||
export const endUploadSessionInDb = createAsyncThunk(
|
||||
'appState/endUploadSessionInDb',
|
||||
async () => {
|
||||
await setAppState('uploadSessionStartTime', null);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
const appStateSlice = createSlice({
|
||||
name: 'appState',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(syncUploadSessionState.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
})
|
||||
.addCase(syncUploadSessionState.fulfilled, (state, action: PayloadAction<number | null>) => {
|
||||
state.status = 'succeeded';
|
||||
state.uploadSessionStartTime = action.payload;
|
||||
})
|
||||
.addCase(syncUploadSessionState.rejected, (state) => {
|
||||
state.status = 'failed';
|
||||
})
|
||||
.addCase(endUploadSessionInDb.fulfilled, (state, action: PayloadAction<null>) => {
|
||||
state.uploadSessionStartTime = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default appStateSlice.reducer;
|
||||
@ -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<string | null> {
|
||||
try {
|
||||
console.log(`Triggering upload for range: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
||||
const allMedia = await getMediaByDateRange(startDate, endDate);
|
||||
if (allMedia.length === 0) {
|
||||
console.log('No media files found in the specified range.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Batch filter out files that have already been successfully uploaded.
|
||||
const allFileUris = allMedia.map(a => a.uri);
|
||||
const newFileUris = await filterExistingFiles(allFileUris);
|
||||
|
||||
if (newFileUris.length === 0) {
|
||||
console.log('No new files to upload. All are already synced.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const filesToUpload = allMedia.filter(a => newFileUris.includes(a.uri));
|
||||
|
||||
// 2. Batch pre-register all new files in the database as 'pending'.
|
||||
console.log(`Registering ${filesToUpload.length} new files as 'pending' in the database.`);
|
||||
for (const file of filesToUpload) {
|
||||
await insertUploadTask({ uri: file.uri, filename: file.filename, status: 'pending', progress: 0 });
|
||||
}
|
||||
|
||||
// 3. Start the upload session.
|
||||
const startTime = Math.floor(Date.now() / 1000).toString();
|
||||
await setAppState('uploadSessionStartTime', startTime);
|
||||
|
||||
// 4. Create upload promises for the new files.
|
||||
const uploadPromises = filesToUpload.map((file) =>
|
||||
limit(async () => {
|
||||
try {
|
||||
// 5. Mark the task as 'uploading' right before the upload starts.
|
||||
await updateUploadTaskStatus(file.uri, 'uploading');
|
||||
const result = await processAndUploadMedia(file);
|
||||
|
||||
if (result === null) { // Skipped case
|
||||
await updateUploadTaskStatus(file.uri, 'skipped');
|
||||
return { status: 'skipped' };
|
||||
}
|
||||
|
||||
if (result.originalSuccess) {
|
||||
await updateUploadTaskStatus(file.uri, 'success', result.fileIds?.original);
|
||||
return { status: 'success' };
|
||||
} else {
|
||||
await updateUploadTaskStatus(file.uri, 'failed');
|
||||
return { status: 'failed' };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload failed for', file.uri, e);
|
||||
await updateUploadTaskStatus(file.uri, 'failed');
|
||||
return { status: 'failed' };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// We don't wait for all uploads to complete. The function returns after starting them.
|
||||
// The UI will then poll for progress.
|
||||
Promise.allSettled(uploadPromises).then((results) => {
|
||||
console.log('All upload tasks have been settled.');
|
||||
const successfulUploads = results.filter(
|
||||
(result) => result.status === 'fulfilled' && result.value.status === 'success'
|
||||
).length;
|
||||
console.log(`${successfulUploads} files uploaded successfully.`);
|
||||
});
|
||||
|
||||
return startTime;
|
||||
} catch (error) {
|
||||
console.error('Error during upload trigger:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 定义后台任务
|
||||
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
|
||||
try {
|
||||
console.log('Running background upload task...');
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// 获取最近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);
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
125
lib/db.ts
125
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<void> {
|
||||
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<UploadTask, 'created_at'>) {
|
||||
console.log('Inserting upload task:', task.uri);
|
||||
await db.runAsync(
|
||||
'INSERT OR REPLACE INTO upload_tasks (uri, filename, status, progress, file_id, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[task.uri, task.filename, task.status, task.progress, task.file_id ?? null, Math.floor(Date.now() / 1000)]
|
||||
);
|
||||
}
|
||||
|
||||
// 检查文件是否已上传或正在上传
|
||||
export async function getUploadTaskStatus(uri: string): Promise<UploadTask | null> {
|
||||
console.log('Checking upload task status for:', uri);
|
||||
const result = db.getFirstSync<UploadTask>(
|
||||
'SELECT uri, filename, status, progress, file_id FROM upload_tasks WHERE uri = ?;',
|
||||
const result = await db.getFirstAsync<UploadTask>(
|
||||
'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks WHERE uri = ?;',
|
||||
uri
|
||||
);
|
||||
return result || null;
|
||||
}
|
||||
|
||||
// 更新上传任务的状态
|
||||
export async function updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise<void> {
|
||||
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<void> {
|
||||
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<UploadTask[]> {
|
||||
console.log('Fetching all upload tasks... time:', new Date().toLocaleString());
|
||||
const results = db.getAllSync<UploadTask>(
|
||||
'SELECT uri, filename, status, progress, file_id FROM upload_tasks;'
|
||||
const results = await db.getAllAsync<UploadTask>(
|
||||
'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks ORDER BY created_at DESC;'
|
||||
);
|
||||
return results;
|
||||
}
|
||||
@ -88,11 +97,55 @@ export async function getUploadTasks(): Promise<UploadTask[]> {
|
||||
// 清理已完成或失败的任务 (可选,根据需求添加)
|
||||
export async function cleanUpUploadTasks(): Promise<void> {
|
||||
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';"
|
||||
);
|
||||
}
|
||||
|
||||
// 获取某个时间点之后的所有上传任务
|
||||
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
|
||||
export async function executeSql(sql: string, params: any[] = []): Promise<any> {
|
||||
try {
|
||||
|
||||
10
store.ts
10
store.ts
@ -1,15 +1,21 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import { counterSlice } from './components/steps';
|
||||
import authReducer from './features/auth/authSlice';
|
||||
import appStateReducer from './features/appState/appStateSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
counter: counterSlice.reducer,
|
||||
auth: authReducer
|
||||
auth: authReducer,
|
||||
appState: appStateReducer
|
||||
},
|
||||
});
|
||||
|
||||
// 从 store 本身推断 `RootState` 和 `AppDispatch` 类型
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
// 推断类型:{posts: PostsState, comments: CommentsState, users: UsersState}
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
// 在整个应用中使用,而不是简单的 `useDispatch` 和 `useSelector`
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
Loading…
x
Reference in New Issue
Block a user