From 20c1b2b767105b41940979f197f3e3b7054c82c2 Mon Sep 17 00:00:00 2001 From: Junhui Chen Date: Mon, 21 Jul 2025 14:42:53 +0800 Subject: [PATCH 1/3] fix: db for web --- lib/database/database-factory.ts | 23 ++++ lib/database/database-test.ts | 60 +++++++++ lib/database/empty-sqlite.js | 9 ++ lib/database/index.ts | 6 + lib/database/sqlite-database.ts | 156 ++++++++++++++++++++++ lib/database/sqlite-database.web.ts | 140 ++++++++++++++++++++ lib/database/types.ts | 24 ++++ lib/database/web-database.ts | 139 ++++++++++++++++++++ lib/db.ts | 195 +++------------------------- metro.config.js | 9 ++ 10 files changed, 587 insertions(+), 174 deletions(-) create mode 100644 lib/database/database-factory.ts create mode 100644 lib/database/database-test.ts create mode 100644 lib/database/empty-sqlite.js create mode 100644 lib/database/index.ts create mode 100644 lib/database/sqlite-database.ts create mode 100644 lib/database/sqlite-database.web.ts create mode 100644 lib/database/types.ts create mode 100644 lib/database/web-database.ts diff --git a/lib/database/database-factory.ts b/lib/database/database-factory.ts new file mode 100644 index 0000000..abf4f47 --- /dev/null +++ b/lib/database/database-factory.ts @@ -0,0 +1,23 @@ +import { DatabaseInterface } from './types'; +import { SQLiteDatabase } from './sqlite-database'; + +class DatabaseFactory { + private static instance: DatabaseInterface | null = null; + + static getInstance(): DatabaseInterface { + if (!this.instance) { + // Metro 会根据平台自动选择正确的文件 + // Web: sqlite-database.web.ts + // Native: sqlite-database.ts + this.instance = new SQLiteDatabase(); + } + return this.instance!; + } + + // 用于测试或重置实例 + static resetInstance(): void { + this.instance = null; + } +} + +export const database = DatabaseFactory.getInstance(); diff --git a/lib/database/database-test.ts b/lib/database/database-test.ts new file mode 100644 index 0000000..fc1beb2 --- /dev/null +++ b/lib/database/database-test.ts @@ -0,0 +1,60 @@ +// 数据库架构测试文件 +import { database } from './database-factory'; +import { UploadTask } from './types'; + +// 测试数据库基本功能 +export async function testDatabase() { + console.log('开始测试数据库功能...'); + + try { + // 初始化数据库 + await database.initUploadTable(); + console.log('✓ 数据库初始化成功'); + + // 测试插入任务 + const testTask: Omit = { + uri: 'test://example.jpg', + filename: 'example.jpg', + status: 'pending', + progress: 0, + file_id: undefined + }; + + await database.insertUploadTask(testTask); + console.log('✓ 任务插入成功'); + + // 测试查询任务 + const retrievedTask = await database.getUploadTaskStatus(testTask.uri); + if (retrievedTask) { + console.log('✓ 任务查询成功:', retrievedTask); + } else { + console.log('✗ 任务查询失败'); + } + + // 测试更新任务状态 + await database.updateUploadTaskStatus(testTask.uri, 'success', 'file123'); + console.log('✓ 任务状态更新成功'); + + // 测试获取所有任务 + const allTasks = await database.getUploadTasks(); + console.log('✓ 获取所有任务成功,数量:', allTasks.length); + + // 测试应用状态 + await database.setAppState('test_key', 'test_value'); + const stateValue = await database.getAppState('test_key'); + console.log('✓ 应用状态测试成功:', stateValue); + + // 清理测试数据 + await database.cleanUpUploadTasks(); + console.log('✓ 数据清理成功'); + + console.log('🎉 所有数据库测试通过!'); + + } catch (error) { + console.error('❌ 数据库测试失败:', error); + throw error; + } +} + +// 导出测试函数供调用 +export default testDatabase; diff --git a/lib/database/empty-sqlite.js b/lib/database/empty-sqlite.js new file mode 100644 index 0000000..aa6e3fc --- /dev/null +++ b/lib/database/empty-sqlite.js @@ -0,0 +1,9 @@ +// 空的 SQLite 模块,用于 Web 环境 +console.warn('SQLite is not available in web environment'); + +// 导出空的对象,避免导入错误 +module.exports = { + openDatabaseSync: () => { + throw new Error('SQLite is not available in web environment'); + } +}; diff --git a/lib/database/index.ts b/lib/database/index.ts new file mode 100644 index 0000000..1c5595f --- /dev/null +++ b/lib/database/index.ts @@ -0,0 +1,6 @@ +// 数据库模块统一导出 +export { DatabaseInterface, UploadTask } from './types'; +export { WebDatabase } from './web-database'; +export { SQLiteDatabase } from './sqlite-database'; +export { database } from './database-factory'; +export { testDatabase } from './database-test'; diff --git a/lib/database/sqlite-database.ts b/lib/database/sqlite-database.ts new file mode 100644 index 0000000..9782d6c --- /dev/null +++ b/lib/database/sqlite-database.ts @@ -0,0 +1,156 @@ +import { DatabaseInterface, UploadTask } from './types'; + +export class SQLiteDatabase implements DatabaseInterface { + private db: any; + + constructor() { + // 动态导入,避免在Web环境下加载 + try { + const SQLite = require('expo-sqlite'); + this.db = SQLite.openDatabaseSync('upload_status.db'); + this.db.execSync('PRAGMA busy_timeout = 5000;'); + } catch (error) { + console.error('Failed to initialize SQLite:', error); + throw new Error('SQLite is not available in this environment'); + } + } + + async initUploadTable(): Promise { + console.log('Initializing upload tasks table...'); + await this.db.execAsync(` + CREATE TABLE IF NOT EXISTS upload_tasks ( + uri TEXT PRIMARY KEY NOT NULL, + filename TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + progress INTEGER NOT NULL DEFAULT 0, + file_id TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ); + `); + + // Add created_at column to existing table if it doesn't exist + const columns = await this.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...'); + await this.db.execAsync(`ALTER TABLE upload_tasks ADD COLUMN created_at INTEGER;`); + await this.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 this.db.execAsync(` + CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT + ); + `); + console.log('App state table initialized'); + } + + async insertUploadTask(task: Omit): Promise { + console.log('Inserting upload task:', task.uri); + await this.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)] + ); + } + + async getUploadTaskStatus(uri: string): Promise { + console.log('Checking upload task status for:', uri); + const result = await this.db.getFirstAsync( + 'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks WHERE uri = ?;', + uri + ); + return result || null; + } + + async updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise { + if (file_id) { + await this.db.runAsync('UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?', [status, file_id, uri]); + } else { + await this.db.runAsync('UPDATE upload_tasks SET status = ? WHERE uri = ?', [status, uri]); + } + } + + async updateUploadTaskProgress(uri: string, progress: number): Promise { + await this.db.runAsync('UPDATE upload_tasks SET progress = ? WHERE uri = ?', [progress, uri]); + } + + async getUploadTasks(): Promise { + console.log('Fetching all upload tasks... time:', new Date().toLocaleString()); + const results = await this.db.getAllAsync( + 'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks ORDER BY created_at DESC;' + ); + return results; + } + + async cleanUpUploadTasks(): Promise { + console.log('Cleaning up completed/failed upload tasks...'); + await this.db.runAsync( + "DELETE FROM upload_tasks WHERE status = 'success' OR status = 'failed' OR status = 'skipped';" + ); + } + + async getUploadTasksSince(timestamp: number): Promise { + const rows = await this.db.getAllAsync( + 'SELECT * FROM upload_tasks WHERE created_at >= ? ORDER BY created_at DESC', + [timestamp] + ); + return rows; + } + + async exist_pending_tasks(): Promise { + const rows = await this.db.getAllAsync( + 'SELECT * FROM upload_tasks WHERE status = "pending" OR status = "uploading"' + ); + return rows.length > 0; + } + + async filterExistingFiles(fileUris: string[]): Promise { + if (fileUris.length === 0) { + return []; + } + + const placeholders = fileUris.map(() => '?').join(','); + const query = `SELECT uri FROM upload_tasks WHERE uri IN (${placeholders}) AND status = 'success'`; + + const existingFiles = await this.db.getAllAsync(query, fileUris); + const existingUris = new Set(existingFiles.map((f: { uri: string }) => 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; + } + + async setAppState(key: string, value: string | null): Promise { + console.log(`Setting app state: ${key} = ${value}`); + await this.db.runAsync('INSERT OR REPLACE INTO app_state (key, value) VALUES (?, ?)', [key, value]); + } + + async getAppState(key: string): Promise { + const result = await this.db.getFirstAsync('SELECT value FROM app_state WHERE key = ?;', key); + return result?.value ?? null; + } + + async executeSql(sql: string, params: any[] = []): Promise { + try { + const isSelect = sql.trim().toLowerCase().startsWith('select'); + if (isSelect) { + const results = this.db.getAllSync(sql, params); + return results; + } else { + const result = this.db.runSync(sql, params); + return { + changes: result.changes, + lastInsertRowId: result.lastInsertRowId, + }; + } + } catch (error: any) { + console.error("Error executing SQL:", error); + return { error: error.message }; + } + } +} diff --git a/lib/database/sqlite-database.web.ts b/lib/database/sqlite-database.web.ts new file mode 100644 index 0000000..3b3ba1a --- /dev/null +++ b/lib/database/sqlite-database.web.ts @@ -0,0 +1,140 @@ +import { DatabaseInterface, UploadTask } from './types'; + +// Web 环境下的 SQLite 数据库实现(实际使用 localStorage) +export class SQLiteDatabase implements DatabaseInterface { + private getStorageKey(table: string): string { + return `memowake_${table}`; + } + + private getUploadTasksFromStorage(): UploadTask[] { + const data = localStorage.getItem(this.getStorageKey('upload_tasks')); + return data ? JSON.parse(data) : []; + } + + private saveUploadTasks(tasks: UploadTask[]): void { + localStorage.setItem(this.getStorageKey('upload_tasks'), JSON.stringify(tasks)); + } + + private getAppStateData(): Record { + const data = localStorage.getItem(this.getStorageKey('app_state')); + return data ? JSON.parse(data) : {}; + } + + private saveAppStateData(state: Record): void { + localStorage.setItem(this.getStorageKey('app_state'), JSON.stringify(state)); + } + + async initUploadTable(): Promise { + console.log('Initializing web storage tables (SQLite fallback)...'); + // Web端不需要初始化表结构,localStorage会自动处理 + } + + async insertUploadTask(task: Omit): Promise { + console.log('Inserting upload task:', task.uri); + const tasks = this.getUploadTasksFromStorage(); + const existingIndex = tasks.findIndex(t => t.uri === task.uri); + const newTask: UploadTask = { + ...task, + created_at: Math.floor(Date.now() / 1000) + }; + + if (existingIndex >= 0) { + tasks[existingIndex] = newTask; + } else { + tasks.push(newTask); + } + + this.saveUploadTasks(tasks); + } + + async getUploadTaskStatus(uri: string): Promise { + console.log('Checking upload task status for:', uri); + const tasks = this.getUploadTasksFromStorage(); + return tasks.find(t => t.uri === uri) || null; + } + + async updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise { + const tasks = this.getUploadTasksFromStorage(); + const taskIndex = tasks.findIndex(t => t.uri === uri); + if (taskIndex >= 0) { + tasks[taskIndex].status = status; + if (file_id) { + tasks[taskIndex].file_id = file_id; + } + this.saveUploadTasks(tasks); + } + } + + async updateUploadTaskProgress(uri: string, progress: number): Promise { + const tasks = this.getUploadTasksFromStorage(); + const taskIndex = tasks.findIndex(t => t.uri === uri); + if (taskIndex >= 0) { + tasks[taskIndex].progress = progress; + this.saveUploadTasks(tasks); + } + } + + async getUploadTasks(): Promise { + console.log('Fetching all upload tasks... time:', new Date().toLocaleString()); + const tasks = this.getUploadTasksFromStorage(); + return tasks.sort((a, b) => b.created_at - a.created_at); + } + + async cleanUpUploadTasks(): Promise { + console.log('Cleaning up completed/failed upload tasks...'); + const tasks = this.getUploadTasksFromStorage(); + const filteredTasks = tasks.filter(t => + t.status !== 'success' && t.status !== 'failed' && t.status !== 'skipped' + ); + this.saveUploadTasks(filteredTasks); + } + + async getUploadTasksSince(timestamp: number): Promise { + const tasks = this.getUploadTasksFromStorage(); + const filteredTasks = tasks.filter(t => t.created_at >= timestamp); + return filteredTasks.sort((a, b) => b.created_at - a.created_at); + } + + async exist_pending_tasks(): Promise { + const tasks = this.getUploadTasksFromStorage(); + return tasks.some(t => t.status === 'pending' || t.status === 'uploading'); + } + + async filterExistingFiles(fileUris: string[]): Promise { + if (fileUris.length === 0) { + return []; + } + + const tasks = this.getUploadTasksFromStorage(); + const successfulUris = new Set( + tasks.filter(t => t.status === 'success').map(t => t.uri) + ); + + const newFileUris = fileUris.filter(uri => !successfulUris.has(uri)); + + console.log(`[WebDB] Total files: ${fileUris.length}, Existing successful files: ${successfulUris.size}, New files to upload: ${newFileUris.length}`); + + return newFileUris; + } + + async setAppState(key: string, value: string | null): Promise { + console.log(`Setting app state: ${key} = ${value}`); + const state = this.getAppStateData(); + if (value === null) { + delete state[key]; + } else { + state[key] = value; + } + this.saveAppStateData(state); + } + + async getAppState(key: string): Promise { + const state = this.getAppStateData(); + return state[key] || null; + } + + async executeSql(sql: string, params: any[] = []): Promise { + console.warn('SQL execution not supported in web environment:', sql); + return { error: 'SQL execution not supported in web environment' }; + } +} diff --git a/lib/database/types.ts b/lib/database/types.ts new file mode 100644 index 0000000..848aee6 --- /dev/null +++ b/lib/database/types.ts @@ -0,0 +1,24 @@ +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 interface DatabaseInterface { + initUploadTable(): Promise; + insertUploadTask(task: Omit): Promise; + getUploadTaskStatus(uri: string): Promise; + updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise; + updateUploadTaskProgress(uri: string, progress: number): Promise; + getUploadTasks(): Promise; + cleanUpUploadTasks(): Promise; + getUploadTasksSince(timestamp: number): Promise; + exist_pending_tasks(): Promise; + filterExistingFiles(fileUris: string[]): Promise; + setAppState(key: string, value: string | null): Promise; + getAppState(key: string): Promise; + executeSql(sql: string, params?: any[]): Promise; +} diff --git a/lib/database/web-database.ts b/lib/database/web-database.ts new file mode 100644 index 0000000..38d7201 --- /dev/null +++ b/lib/database/web-database.ts @@ -0,0 +1,139 @@ +import { DatabaseInterface, UploadTask } from './types'; + +export class WebDatabase implements DatabaseInterface { + private getStorageKey(table: string): string { + return `memowake_${table}`; + } + + private getUploadTasksFromStorage(): UploadTask[] { + const data = localStorage.getItem(this.getStorageKey('upload_tasks')); + return data ? JSON.parse(data) : []; + } + + private saveUploadTasks(tasks: UploadTask[]): void { + localStorage.setItem(this.getStorageKey('upload_tasks'), JSON.stringify(tasks)); + } + + private getAppStateData(): Record { + const data = localStorage.getItem(this.getStorageKey('app_state')); + return data ? JSON.parse(data) : {}; + } + + private saveAppStateData(state: Record): void { + localStorage.setItem(this.getStorageKey('app_state'), JSON.stringify(state)); + } + + async initUploadTable(): Promise { + console.log('Initializing web storage tables...'); + // Web端不需要初始化表结构,localStorage会自动处理 + } + + async insertUploadTask(task: Omit): Promise { + console.log('Inserting upload task:', task.uri); + const tasks = this.getUploadTasksFromStorage(); + const existingIndex = tasks.findIndex(t => t.uri === task.uri); + const newTask: UploadTask = { + ...task, + created_at: Math.floor(Date.now() / 1000) + }; + + if (existingIndex >= 0) { + tasks[existingIndex] = newTask; + } else { + tasks.push(newTask); + } + + this.saveUploadTasks(tasks); + } + + async getUploadTaskStatus(uri: string): Promise { + console.log('Checking upload task status for:', uri); + const tasks = this.getUploadTasksFromStorage(); + return tasks.find(t => t.uri === uri) || null; + } + + async updateUploadTaskStatus(uri: string, status: UploadTask['status'], file_id?: string): Promise { + const tasks = this.getUploadTasksFromStorage(); + const taskIndex = tasks.findIndex(t => t.uri === uri); + if (taskIndex >= 0) { + tasks[taskIndex].status = status; + if (file_id) { + tasks[taskIndex].file_id = file_id; + } + this.saveUploadTasks(tasks); + } + } + + async updateUploadTaskProgress(uri: string, progress: number): Promise { + const tasks = this.getUploadTasksFromStorage(); + const taskIndex = tasks.findIndex(t => t.uri === uri); + if (taskIndex >= 0) { + tasks[taskIndex].progress = progress; + this.saveUploadTasks(tasks); + } + } + + async getUploadTasks(): Promise { + console.log('Fetching all upload tasks... time:', new Date().toLocaleString()); + const tasks = this.getUploadTasksFromStorage(); + return tasks.sort((a, b) => b.created_at - a.created_at); + } + + async cleanUpUploadTasks(): Promise { + console.log('Cleaning up completed/failed upload tasks...'); + const tasks = this.getUploadTasksFromStorage(); + const filteredTasks = tasks.filter(t => + t.status !== 'success' && t.status !== 'failed' && t.status !== 'skipped' + ); + this.saveUploadTasks(filteredTasks); + } + + async getUploadTasksSince(timestamp: number): Promise { + const tasks = this.getUploadTasksFromStorage(); + const filteredTasks = tasks.filter(t => t.created_at >= timestamp); + return filteredTasks.sort((a, b) => b.created_at - a.created_at); + } + + async exist_pending_tasks(): Promise { + const tasks = this.getUploadTasksFromStorage(); + return tasks.some(t => t.status === 'pending' || t.status === 'uploading'); + } + + async filterExistingFiles(fileUris: string[]): Promise { + if (fileUris.length === 0) { + return []; + } + + const tasks = this.getUploadTasksFromStorage(); + const successfulUris = new Set( + tasks.filter(t => t.status === 'success').map(t => t.uri) + ); + + const newFileUris = fileUris.filter(uri => !successfulUris.has(uri)); + + console.log(`[WebDB] Total files: ${fileUris.length}, Existing successful files: ${successfulUris.size}, New files to upload: ${newFileUris.length}`); + + return newFileUris; + } + + async setAppState(key: string, value: string | null): Promise { + console.log(`Setting app state: ${key} = ${value}`); + const state = this.getAppStateData(); + if (value === null) { + delete state[key]; + } else { + state[key] = value; + } + this.saveAppStateData(state); + } + + async getAppState(key: string): Promise { + const state = this.getAppStateData(); + return state[key] || null; + } + + async executeSql(sql: string, params: any[] = []): Promise { + console.warn('SQL execution not supported in web environment:', sql); + return { error: 'SQL execution not supported in web environment' }; + } +} diff --git a/lib/db.ts b/lib/db.ts index 5aadded..217cb12 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,176 +1,23 @@ -import * as SQLite from 'expo-sqlite'; +// 使用数据库接口架构,支持 Web 和移动端 +import { database } from './database/database-factory'; +import { UploadTask } from './database/types'; -const db = SQLite.openDatabaseSync('upload_status.db'); - -// Set a busy timeout to handle concurrent writes and avoid "database is locked" errors. -// This will make SQLite wait for 5 seconds if the database is locked by another process. -db.execSync('PRAGMA busy_timeout = 5000;'); - -export type UploadTask = { - uri: string; - filename: string; - status: 'pending' | 'uploading' | 'success' | 'failed' | 'skipped'; - progress: number; // 0-100 - file_id?: string; // 后端返回的文件ID - created_at: number; // unix timestamp -}; - -// 初始化表 -export async function initUploadTable() { - console.log('Initializing upload tasks table...'); - await db.execAsync(` - CREATE TABLE IF NOT EXISTS upload_tasks ( - uri TEXT PRIMARY KEY NOT NULL, - filename TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - progress INTEGER NOT NULL DEFAULT 0, - file_id TEXT, - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) - ); - `); - - // Add created_at column to existing table if it doesn't exist - const columns = await db.getAllAsync('PRAGMA table_info(upload_tasks);'); - const columnExists = columns.some((column: any) => column.name === 'created_at'); - - if (!columnExists) { - console.log('Adding created_at column to upload_tasks table...'); - // SQLite doesn't support non-constant DEFAULT values on ALTER TABLE. - // So we add the column, then update existing rows. - await db.execAsync(`ALTER TABLE upload_tasks ADD COLUMN created_at INTEGER;`); - await db.execAsync(`UPDATE upload_tasks SET created_at = (strftime('%s', 'now')) WHERE created_at IS NULL;`); - console.log('created_at column added and populated.'); - } - console.log('Upload tasks table initialized'); - - await db.execAsync(` - CREATE TABLE IF NOT EXISTS app_state ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT - ); - `); - console.log('App state table initialized'); -} - -// 插入新的上传任务 -export async function insertUploadTask(task: Omit) { - 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 = 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) { - if (file_id) { - await db.runAsync('UPDATE upload_tasks SET status = ?, file_id = ? WHERE uri = ?', [status, file_id, uri]); - } else { - await db.runAsync('UPDATE upload_tasks SET status = ? WHERE uri = ?', [status, uri]); - } -} - -// 更新上传任务的进度 -export async function updateUploadTaskProgress(uri: string, progress: number) { - await db.runAsync('UPDATE upload_tasks SET progress = ? WHERE uri = ?', [progress, uri]); -} - -// 获取所有上传任务 -export async function getUploadTasks(): Promise { - console.log('Fetching all upload tasks... time:', new Date().toLocaleString()); - const results = await db.getAllAsync( - 'SELECT uri, filename, status, progress, file_id, created_at FROM upload_tasks ORDER BY created_at DESC;' - ); - return results; -} - -// 清理已完成或失败的任务 (可选,根据需求添加) -export async function cleanUpUploadTasks(): Promise { - console.log('Cleaning up completed/failed upload tasks...'); - await db.runAsync( - "DELETE FROM upload_tasks WHERE status = 'success' OR status = 'failed' OR status = 'skipped';" - ); -} - -// 获取某个时间点之后的所有上传任务 -export async function getUploadTasksSince(timestamp: number): Promise { - const rows = await db.getAllAsync( - 'SELECT * FROM upload_tasks WHERE created_at >= ? ORDER BY created_at DESC', - [timestamp] - ); - return rows; -} - -export async function exist_pending_tasks(): Promise { - const rows = await db.getAllAsync( - 'SELECT * FROM upload_tasks WHERE status = "pending" OR status = "uploading"' - ); - return rows.length > 0; -} - -// 检查一组文件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 { - // Trim and check if it's a SELECT query - const isSelect = sql.trim().toLowerCase().startsWith('select'); - if (isSelect) { - const results = db.getAllSync(sql, params); - return results; - } else { - const result = db.runSync(sql, params); - return { - changes: result.changes, - lastInsertRowId: result.lastInsertRowId, - }; - } - } catch (error: any) { - console.error("Error executing SQL:", error); - return { error: error.message }; - } -} +// 重新导出类型 +export type { UploadTask }; +// 重新导出所有数据库函数,使用统一接口 +export const initUploadTable = () => database.initUploadTable(); +export const insertUploadTask = (task: Omit) => database.insertUploadTask(task); +export const getUploadTaskStatus = (uri: string) => database.getUploadTaskStatus(uri); +export const updateUploadTaskStatus = (uri: string, status: UploadTask['status'], file_id?: string) => + database.updateUploadTaskStatus(uri, status, file_id); +export const updateUploadTaskProgress = (uri: string, progress: number) => + database.updateUploadTaskProgress(uri, progress); +export const getUploadTasks = () => database.getUploadTasks(); +export const cleanUpUploadTasks = () => database.cleanUpUploadTasks(); +export const getUploadTasksSince = (timestamp: number) => database.getUploadTasksSince(timestamp); +export const exist_pending_tasks = () => database.exist_pending_tasks(); +export const filterExistingFiles = (fileUris: string[]) => database.filterExistingFiles(fileUris); +export const setAppState = (key: string, value: string | null) => database.setAppState(key, value); +export const getAppState = (key: string) => database.getAppState(key); +export const executeSql = (sql: string, params: any[] = []) => database.executeSql(sql, params); diff --git a/metro.config.js b/metro.config.js index 7ba56c4..a20435c 100644 --- a/metro.config.js +++ b/metro.config.js @@ -19,6 +19,15 @@ config.resolver = { ...config.resolver?.alias, '@/': path.resolve(__dirname, './'), }, + platforms: ['ios', 'android', 'native', 'web'], }; +// Web 环境下的模块别名 +if (process.env.EXPO_PLATFORM === 'web') { + config.resolver.alias = { + ...config.resolver.alias, + 'expo-sqlite': path.resolve(__dirname, './lib/database/empty-sqlite.js'), + }; +} + module.exports = withNativeWind(config, { input: './global.css' }); \ No newline at end of file From 80eaad039e5cfe5b87a8873af022c3eaa0d424b2 Mon Sep 17 00:00:00 2001 From: jinyaqiu Date: Mon, 21 Jul 2025 18:21:13 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E8=BD=AE=E6=92=AD=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/owner.tsx | 27 +------- components/owner/carousel.tsx | 127 ++++++++++++++++++++++++++++++++++ components/owner/category.tsx | 12 ++-- package-lock.json | 13 ++++ package.json | 7 +- types/user.ts | 4 +- 6 files changed, 157 insertions(+), 33 deletions(-) create mode 100644 components/owner/carousel.tsx diff --git a/app/(tabs)/owner.tsx b/app/(tabs)/owner.tsx index 19dcac5..1486e8a 100644 --- a/app/(tabs)/owner.tsx +++ b/app/(tabs)/owner.tsx @@ -5,21 +5,19 @@ import StoriesSvg from '@/assets/icons/svg/stories.svg'; import UsedStorageSvg from '@/assets/icons/svg/usedStorage.svg'; import AskNavbar from '@/components/layout/ask'; import AlbumComponent from '@/components/owner/album'; -import CategoryComponent from '@/components/owner/category'; -import CountComponent from '@/components/owner/count'; +import CarouselComponent from '@/components/owner/carousel'; import CreateCountComponent from '@/components/owner/createCount'; import Ranking from '@/components/owner/ranking'; 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 { FlatList, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from "react-native-safe-area-context"; export default function OwnerPage() { @@ -97,27 +95,8 @@ export default function OwnerPage() { - {/* 数据统计 */} - - {/* 分类 */} - - - {countData?.counter?.category_count && Object.entries(countData?.counter?.category_count).map(([key, value], index) => { - return ( - - ) - })} - - + {/* 作品数据 */} diff --git a/components/owner/carousel.tsx b/components/owner/carousel.tsx new file mode 100644 index 0000000..9d2e76d --- /dev/null +++ b/components/owner/carousel.tsx @@ -0,0 +1,127 @@ +import { Counter, UserCountData } from "@/types/user"; +import * as React from "react"; +import { Dimensions, StyleSheet, View } from "react-native"; +import { useSharedValue } from "react-native-reanimated"; +import Carousel, { + ICarouselInstance +} from "react-native-reanimated-carousel"; +import { ThemedText } from "../ThemedText"; +import { formatDuration } from "../utils/time"; +import CategoryComponent from "./category"; +interface Props { + data: Counter +} + +interface CarouselData { + key: string, + value: UserCountData + +}[] +const width = Dimensions.get("window").width; + +function CarouselComponent(props: Props) { + const { data } = props; + + const ref = React.useRef(null); + const progress = useSharedValue(0); + const [carouselDataValue, setCarouselDataValue] = React.useState([]); + const dataHandle = () => { + const carouselData = { ...data?.category_count, total_count: data?.total_count } + // 1. 转换为数组并过滤掉 'total' + const entries = Object?.entries(carouselData) + ?.filter(([key]) => key !== 'total_count') + ?.map(([key, value]) => ({ key, value })); + + // 2. 找到 total 数据 + const totalEntry = { + key: 'total_count', + value: carouselData?.total_count + }; + + // 3. 插入到中间位置 + const middleIndex = Math.floor((entries || [])?.length / 2); + entries?.splice(middleIndex, 0, totalEntry); + setCarouselDataValue(entries) + return entries; + } + + const totleItem = (data: UserCountData) => { + return + {Object?.entries(data)?.filter(([key]) => key !== 'cover_url')?.map((item, index) => ( + + {item[0]} + {item[1]} + + ))} + + } + + React.useEffect(() => { + if (data) { + dataHandle() + } + }, [data]); + + return ( + + item?.key === 'total_count') - 1 || 0} + renderItem={({ item }) => { + if (item?.key === 'total_count') { + return totleItem(item.value) + } + return CategoryComponent( + { + title: item?.key, + data: + [ + { title: 'Video', number: item?.value?.video_count }, + { title: 'Photo', number: item?.value?.photo_count }, + { title: 'Length', number: formatDuration(item?.value?.video_length || 0) } + ], + bgSvg: item?.value?.cover_url, + }) + }} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#FFB645", + padding: 16, + borderRadius: 20, + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + height: '100%', + }, + item: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 8 + }, + title: { + color: "#4C320C", + fontWeight: "700", + fontSize: 14, + }, + number: { + color: "#fff", + fontWeight: "700", + fontSize: 32, + textAlign: 'right', + flex: 1, + paddingTop: 8 + } +}) + +export default CarouselComponent; \ No newline at end of file diff --git a/components/owner/category.tsx b/components/owner/category.tsx index 06a0a15..f3f79ce 100644 --- a/components/owner/category.tsx +++ b/components/owner/category.tsx @@ -14,8 +14,11 @@ const CategoryComponent = ({ title, data, bgSvg, style }: CategoryProps) => { @@ -37,11 +40,12 @@ const styles = StyleSheet.create({ borderRadius: 32, overflow: 'hidden', position: 'relative', + aspectRatio: 1, }, backgroundContainer: { ...StyleSheet.absoluteFillObject, - width: '100%', - height: '100%', + width: "100%", + height: "100%", }, overlay: { ...StyleSheet.absoluteFillObject, diff --git a/package-lock.json b/package-lock.json index 2d3c8d1..66a15e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "react-native-picker-select": "^9.3.1", "react-native-progress": "^5.0.1", "react-native-reanimated": "~3.17.4", + "react-native-reanimated-carousel": "^4.0.2", "react-native-render-html": "^6.3.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", @@ -14826,6 +14827,18 @@ "react-native": "*" } }, + "node_modules/react-native-reanimated-carousel": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/react-native-reanimated-carousel/-/react-native-reanimated-carousel-4.0.2.tgz", + "integrity": "sha512-vNpCfPlFoOVKHd+oB7B0luoJswp+nyz0NdJD8+LCrf25JiNQXfM22RSJhLaksBHqk3fm8R4fKWPNcfy5w7wL1Q==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0", + "react-native": ">=0.70.3", + "react-native-gesture-handler": ">=2.9.0", + "react-native-reanimated": ">=3.0.0" + } + }, "node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.7.tgz", diff --git a/package.json b/package.json index 50c1a34..bf8c777 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "expo-audio": "~0.4.8", "expo-background-task": "^0.2.8", "expo-blur": "~14.1.5", + "expo-clipboard": "~7.1.5", "expo-constants": "~17.1.6", "expo-dev-client": "~5.2.4", "expo-device": "~7.1.4", @@ -33,6 +34,7 @@ "expo-haptics": "~14.1.4", "expo-image-manipulator": "~13.1.7", "expo-image-picker": "~16.1.4", + "expo-linear-gradient": "~14.1.5", "expo-linking": "~7.1.7", "expo-localization": "^16.1.5", "expo-location": "~18.1.5", @@ -64,6 +66,7 @@ "react-native-picker-select": "^9.3.1", "react-native-progress": "^5.0.1", "react-native-reanimated": "~3.17.4", + "react-native-reanimated-carousel": "^4.0.2", "react-native-render-html": "^6.3.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", @@ -72,9 +75,7 @@ "react-native-uuid": "^2.0.3", "react-native-web": "~0.20.0", "react-native-webview": "13.13.5", - "react-redux": "^9.2.0", - "expo-clipboard": "~7.1.5", - "expo-linear-gradient": "~14.1.5" + "react-redux": "^9.2.0" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/types/user.ts b/types/user.ts index ff1665d..bb3a291 100644 --- a/types/user.ts +++ b/types/user.ts @@ -12,7 +12,7 @@ export interface User { avatar_file_url?: string } -interface UserCountData { +export interface UserCountData { video_count: number, photo_count: number, live_count: number, @@ -31,7 +31,7 @@ export interface CountData { } } -interface Counter { +export interface Counter { user_id: number, total_count: UserCountData, category_count: { From 9d1c4c97442aa54e37e4ae890032bdd67756cb45 Mon Sep 17 00:00:00 2001 From: jinyaqiu Date: Mon, 21 Jul 2025 19:59:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E8=BD=AE=E6=92=AD=E5=9B=BE?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/owner/carousel.tsx | 44 +++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/components/owner/carousel.tsx b/components/owner/carousel.tsx index 9d2e76d..0b6ff1e 100644 --- a/components/owner/carousel.tsx +++ b/components/owner/carousel.tsx @@ -1,6 +1,6 @@ import { Counter, UserCountData } from "@/types/user"; import * as React from "react"; -import { Dimensions, StyleSheet, View } from "react-native"; +import { Dimensions, StyleSheet, View, ViewStyle } from "react-native"; import { useSharedValue } from "react-native-reanimated"; import Carousel, { ICarouselInstance @@ -72,21 +72,31 @@ function CarouselComponent(props: Props) { mode="parallax" onProgressChange={progress} defaultIndex={carouselDataValue?.findIndex((item) => item?.key === 'total_count') - 1 || 0} - renderItem={({ item }) => { - if (item?.key === 'total_count') { - return totleItem(item.value) - } - return CategoryComponent( - { - title: item?.key, - data: - [ - { title: 'Video', number: item?.value?.video_count }, - { title: 'Photo', number: item?.value?.photo_count }, - { title: 'Length', number: formatDuration(item?.value?.video_length || 0) } - ], - bgSvg: item?.value?.cover_url, - }) + renderItem={({ item, index }) => { + const style: ViewStyle = { + marginHorizontal: 10, + width: '92%', + height: '92%', + }; + return ( + + {item?.key === 'total_count' ? ( + totleItem(item.value) + ) : ( + + {CategoryComponent({ + title: item?.key, + data: [ + { title: 'Video', number: item?.value?.video_count }, + { title: 'Photo', number: item?.value?.photo_count }, + { title: 'Length', number: formatDuration(item?.value?.video_length || 0) } + ], + bgSvg: item?.value?.cover_url, + })} + + )} + + ) }} /> @@ -97,7 +107,7 @@ const styles = StyleSheet.create({ container: { backgroundColor: "#FFB645", padding: 16, - borderRadius: 20, + borderRadius: 16, display: "flex", flexDirection: "column", justifyContent: "space-between",