commit 3ab1f2db2464f9e98b3d1b2d62b180cab1272847 Author: Rain <60972458+Vitaminsss@users.noreply.github.com> Date: Thu Apr 2 16:09:00 2026 +0800 [入库]初次入库各种工具 - DBManager DB创建管理器 - FormatTimeTool - ImageViewer工具 - InputArear工具 - OverlayPage工具 - 下拉刷新容器工具 - 视频查看器工具 diff --git a/CacheManager.ts b/CacheManager.ts new file mode 100644 index 0000000..eb391a4 --- /dev/null +++ b/CacheManager.ts @@ -0,0 +1,487 @@ +import { DBManager, DBSchema } from '@/src/service/DBManager/DBManager'; + +/* ======================= 类型定义 ======================= */ + +// 策略:下载后 tempFilePath 立即可用,saveFile 延迟批量执行,实现无感持久化 +// TODO: 增加删除功能 实现文件删除 + +interface CacheIndexRow { + key: string; + url: string; + local_path: string; + size: number; + create_time: number; + last_access: number; +} + +interface PendingSaveItem { + key: string; + url: string; + tempFilePath: string; + createTime: number; +} + +/* ======================= 任务队列 ======================= */ + +class TaskQueue { + private running = 0; + private queue: Array<() => Promise> = []; + + constructor( + private concurrency: number, + private spaceDelay: number = 0 + ) {} + + add(task: () => Promise) { + this.queue.push(task); + this.run(); + } + + private async run() { + if (this.running >= this.concurrency) return; + const task = this.queue.shift(); + if (!task) return; + + this.running++; + setTimeout(() => { + task() + .catch(console.error) + .finally(() => { + this.running--; + this.run(); + }); + }, this.spaceDelay); + } +} + +/* ======================= CacheManager ======================= */ + +class CacheManager { + /* -------- 基础配置 -------- */ + private readonly rootPath = '_doc/cache.db'; + private readonly PERSIST_DELAY = 3000; + private readonly PERSIST_BATCH_SIZE = 5; + private readonly PERSIST_INTERVAL = 200; + + /* -------- 内存结构 -------- */ + private memoryIndex = new Map(); + private downloading = new Set(); + private checkingDB = new Set(); + private dirtyKeys = new Set(); + + /* -------- 延迟持久化队列 -------- */ + private pendingSave: PendingSaveItem[] = []; + private persistTimer: any = null; + private flushTimer: any = null; + + /* -------- IO 队列 -------- */ + private downloadQueue = new TaskQueue(2); + private checkQueue = new TaskQueue(2, 10); + + /* -------- 异步等待 -------- */ + private waitingCallbacks = new Map void>>(); + private preloadReady = false; // 标记 preloadIndex 是否完成 + + /* -------- 数据库 -------- */ + private db: DBManager; + + /* ======================= 初始化 ======================= */ + + constructor() { + const schema: DBSchema = { + dbName: 'cache.db', + dbPath: this.rootPath, + tableName: 'cache_index', + columns: ` + key TEXT PRIMARY KEY, + url TEXT, + local_path TEXT, + size INTEGER, + create_time INTEGER, + last_access INTEGER + `, + indexes: ['last_access'], + primaryKey: 'key' + }; + this.db = new DBManager(schema); + this.preloadIndex(); + this.listenAppLifecycle(); + } + + public async preloadIndex() { + try { + await this.db.open(); + const rows = await this.db.find({ + columns: ['key', 'local_path'], + orderBy: 'last_access DESC', + limit: 2000, + }); + rows.forEach(r => { + if (r.local_path) { + this.memoryIndex.set(r.key, r.local_path); + } + }); + console.log(`[CacheManager] 索引预热完成,共 ${rows.length} 条`); + this.preloadReady = true; // 标记预热完成 + } catch (e) { + console.error(e); + this.preloadReady = true; // 即使失败也要标记,避免一直等待 + } + } + + private listenAppLifecycle() { + // @ts-ignore + if (typeof uni !== 'undefined' && uni.onAppHide) { + // @ts-ignore + uni.onAppHide(() => { + this.flushPendingSave(); + this.flushAccessTimes(); + }); + } + } + + /* ======================= 对外接口 ======================= */ + + /** + * 同步缓存入口(显代存) + * 立即返回,若无缓存则返回原 URL,后台自动下载 + */ + public GetCache(url: string): string { + if (!url) return ''; + + // 非 HTTP/HTTPS 链接直接返回原值,不尝试下载 + if (!this.isHttpUrl(url)) return url; + + const key = this.hash(url); + + // 内存命中直接返回 + if (this.memoryIndex.has(key)) { + this.touch(key); + return this.memoryIndex.get(key)!; + } + + // 如果 preload 还没完成,等待完成后再检查 + if (!this.preloadReady) { + // 同步等待 preload 完成(最多等待 100ms) + const startTime = Date.now(); + while (!this.preloadReady && Date.now() - startTime < 100) { + // 空循环等待 + } + + // 等待完成后再次检查内存 + if (this.memoryIndex.has(key)) { + this.touch(key); + return this.memoryIndex.get(key)!; + } + } + + // 延迟到下一 tick 执行,避免 UI 卡顿 + setTimeout(() => {this.checkQueue.add(() => this.checkDBOrDownload(url, key));}, 0); + + // 立即返回原 URL + return url; + } + + /** + * 异步缓存入口 + * 等待缓存就绪后返回本地路径,全程 await + */ + public async GetCacheAsync(url: string): Promise { + if (!url) return ''; + + // 非 HTTP/HTTPS 链接直接返回原值,不尝试下载 + if (!this.isHttpUrl(url)) return url; + + const key = this.hash(url); + + // 如果 preload 还没完成,先等待完成 + if (!this.preloadReady) { + await this.waitForPreload(); + } + + // 内存命中直接返回 + if (this.memoryIndex.has(key)) { + this.touch(key); + return this.memoryIndex.get(key)!; + } + + // 检查 DB + const dbPath = await this.checkDB(key); + if (dbPath) { + this.memoryIndex.set(key, dbPath); + this.touch(key); + return dbPath; + } + + // 需要下载,等待下载完成 + return this.downloadAndWait(url, key); + } + + /** + * 等待 preloadIndex 完成 + */ + private async waitForPreload(): Promise { + if (this.preloadReady) return; + + return new Promise(resolve => { + const check = () => { + if (this.preloadReady) { + resolve(); + } else { + setTimeout(check, 10); + } + }; + check(); + }); + } + + /* ======================= 异步下载等待 ======================= */ + + private async checkDB(key: string): Promise { + try { + const rows = await this.db.find({ + where: { key }, + columns: ['local_path'], + limit: 1 + }); + if (rows.length > 0 && rows[0].local_path) { + return rows[0].local_path; + } + } catch (e) { + console.error(e); + } + return null; + } + + private downloadAndWait(url: string, key: string): Promise { + return new Promise((resolve) => { + // 如果已经在下载,加入等待队列 + if (this.downloading.has(key)) { + this.addWaitingCallback(key, resolve); + return; + } + + // 如果已有缓存路径,直接返回 + if (this.memoryIndex.has(key)) { + resolve(this.memoryIndex.get(key)!); + return; + } + + // 加入等待队列并开始下载 + this.addWaitingCallback(key, resolve); + this.enqueueDownload(url, key); + }); + } + + private addWaitingCallback(key: string, callback: (path: string) => void) { + if (!this.waitingCallbacks.has(key)) { + this.waitingCallbacks.set(key, []); + } + this.waitingCallbacks.get(key)!.push(callback); + } + + private resolveWaitingCallbacks(key: string, path: string) { + const callbacks = this.waitingCallbacks.get(key); + if (callbacks) { + callbacks.forEach(cb => cb(path)); + this.waitingCallbacks.delete(key); + } + } + + /* ======================= 下载与写盘 ======================= */ + + private async checkDBOrDownload(url: string, key: string) { + // 防重:正在查 / 正在下 / 已在内存 → 跳过 + if (this.checkingDB.has(key) || this.downloading.has(key) || this.memoryIndex.has(key)) { + return; + } + this.checkingDB.add(key); + + try { + const rows = await this.db.find({ + where: { key }, + columns: ['local_path'], + limit: 1 + }); + + if (rows.length > 0 && rows[0].local_path) { + this.memoryIndex.set(key, rows[0].local_path); + this.touch(key); + } else { + this.enqueueDownload(url, key); + } + } catch (e) { + console.error(e); + } finally { + this.checkingDB.delete(key); + } + } + + private enqueueDownload(url: string, key: string) { + if (this.downloading.has(key)) return; + this.downloading.add(key); + + this.downloadQueue.add(async () => { + try { + const res = await uni.downloadFile({ url }); + if (res.statusCode !== 200) { + this.resolveWaitingCallbacks(key, url); // 下载失败返回原 URL + return; + } + + // 下载完立即用 tempFilePath,UI 无感 + const tempUrl = plus.io.convertLocalFileSystemURL(res.tempFilePath); + this.memoryIndex.set(key, tempUrl); + + // 通知等待中的异步调用 + this.resolveWaitingCallbacks(key, tempUrl); + + // 丢进待持久化队列 + this.pendingSave.push({ + key, + url, + tempFilePath: res.tempFilePath, + createTime: Date.now() + }); + this.schedulePersist(); + + } catch (e) { + console.warn('[CacheManager] 下载失败:', url, e); + this.resolveWaitingCallbacks(key, url); // 下载失败返回原 URL + } finally { + this.downloading.delete(key); + } + }); + } + + /* ======================= 延迟批量持久化 ======================= */ + + private schedulePersist() { + if (this.persistTimer) return; + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + this.runPersistBatch(); + }, this.PERSIST_DELAY); + } + + private async runPersistBatch() { + if (this.pendingSave.length === 0) return; + + const batch = this.pendingSave.splice(0, this.PERSIST_BATCH_SIZE); + + for (let i = 0; i < batch.length; i++) { + const item = batch[i]; + try { + const saveRes = await uni.saveFile({ tempFilePath: item.tempFilePath }); + const finalUrl = plus.io.convertLocalFileSystemURL(saveRes.savedFilePath); + + this.memoryIndex.set(item.key, finalUrl); + + // 写 DB(不阻塞) + this.db.Upsert({ + key: item.key, + url: item.url, + local_path: finalUrl, + size: 0, + create_time: item.createTime, + last_access: Date.now() + }).catch(e => console.error('[CacheManager] DB写入失败:', e)); + + } catch (e) { + console.warn('[CacheManager] 持久化失败:', item.url, e); + } + + // 每个文件之间间隔,避免 IO 堆积 + if (i < batch.length - 1) { + await this.sleep(this.PERSIST_INTERVAL); + } + } + + // 如果还有剩余,继续调度 + if (this.pendingSave.length > 0) { + this.schedulePersist(); + } + } + + private async flushPendingSave() { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + + const all = this.pendingSave.splice(0); + for (const item of all) { + try { + const saveRes = await uni.saveFile({ tempFilePath: item.tempFilePath }); + const finalUrl = plus.io.convertLocalFileSystemURL(saveRes.savedFilePath); + this.memoryIndex.set(item.key, finalUrl); + await this.db.Upsert({ + key: item.key, + url: item.url, + local_path: finalUrl, + size: 0, + create_time: item.createTime, + last_access: Date.now() + }); + } catch (e) { + console.warn('[CacheManager] flushPendingSave 失败:', item.url, e); + } + } + } + + /* ======================= 工具方法 ======================= */ + + /** + * 判断是否为 HTTP/HTTPS 链接 + */ + private isHttpUrl(url: string): boolean { + return url.startsWith('http://') || url.startsWith('https://'); + } + + private hash(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return (hash >>> 0).toString(36); + } + + private touch(key: string) { + this.dirtyKeys.add(key); + this.scheduleFlush(); + } + + private scheduleFlush() { + if (this.flushTimer) return; + this.flushTimer = setTimeout(() => { + this.flushAccessTimes(); + }, 5000); + } + + private async flushAccessTimes() { + if (this.dirtyKeys.size === 0) return; + + const keys = Array.from(this.dirtyKeys); + this.dirtyKeys.clear(); + this.flushTimer = null; + + const now = Date.now(); + const keyString = keys.map(k => `'${k}'`).join(','); + try { + await this.db.executeSql( + `UPDATE cache_index SET last_access=${now} WHERE key IN (${keyString})` + ); + } catch (e) { + console.error('Flush error', e); + } + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +/* ======================= 导出单例 ======================= */ + +export default new CacheManager(); diff --git a/DBManager/DBManager.ts b/DBManager/DBManager.ts new file mode 100644 index 0000000..c8a2b36 --- /dev/null +++ b/DBManager/DBManager.ts @@ -0,0 +1,349 @@ +export interface DBSchema { + dbName: string; + dbPath: string; + tableName: string; + columns: string; // 建表 SQL + indexes?: string[]; // 索引 + jsonFields?: string[]; // 需要自动 JSON 序列化的字段名 + primaryKey: string; // 主键 + immutableFields?: string[]; // 只插入不更新的值 +} + +// 导入输入下面这个👇 +// import { DBManager , DBSchema} from '@/src/service/DBManager/DBManager' + +export class DBManager { + private tableColumns!: string[]; + constructor(private config: DBSchema) {} + + // 外部调用的开启数据库的接口 + async open(): Promise { + this.compileColumns();// 建立Scheme规范 + await this.connectDB(); // 先链接 + const sqlList = this.generateInitSqls(); // 后建表 + if (sqlList.length > 0) { await this.executeSql(sqlList);} // 再建索引 + } + + /* ================================= 基础 CRUD 封装 ================================= */ + +async Upsert(data: any): Promise { + const row = this.serialize(data); + const keys = Object.keys(row); + if (keys.length === 0) return; + const values = keys.map(k => this.formatValue(row[k])); + const insertSql = + `INSERT INTO ${this.config.tableName} (${keys.join(',')}) + VALUES (${values.join(',')})`; + + const updateCols = keys.filter( + k => + k !== this.config.primaryKey && + !this.config.immutableFields?.includes(k) + ); + + if (updateCols.length === 0) { + // 只有主键或不可变字段,直接插 + return this.executeSql(insertSql); + } + + const updateSet = updateCols + .map(k => `${k} = excluded.${k}`) + .join(','); + + const sql = ` + ${insertSql} + ON CONFLICT(${this.config.primaryKey}) + DO UPDATE SET ${updateSet} + `; + return this.executeSql(sql); +} + +async UpsertOnlyChanged(data: any): Promise { + const row = this.serialize(data); + const keys = Object.keys(row); + if (keys.length === 0) return; + + const pk = this.config.primaryKey; + if (!pk || !keys.includes(pk)) { + throw new Error('[DB] UpsertOnlyChanged 需要 primaryKey'); + } + + const values = keys.map(k => this.formatValue(row[k])); + + const insertSql = + `INSERT INTO ${this.config.tableName} (${keys.join(',')}) + VALUES (${values.join(',')})`; + + const updateCols = keys.filter( + k => + k !== pk && + !this.config.immutableFields?.includes(k) + ); + + if (updateCols.length === 0) { + return this.executeSql(insertSql); + } + + // SET 子句 + const setClause = updateCols + .map(k => `${k} = excluded.${k}`) + .join(', '); + + // 变化检测(核心) + const diffWhere = updateCols + .map(k => `${k} IS NOT excluded.${k}`) + .join(' OR '); + + const sql = ` + ${insertSql} + ON CONFLICT(${pk}) + DO UPDATE SET ${setClause} + WHERE ${diffWhere} + `; + return this.executeSql(sql); +} + + + + /** 批量保存 */ + async saveBatch(list: any[]): Promise { + if (!list || list.length === 0) return; + await this.executeSql("BEGIN TRANSACTION;"); + try { + for (const item of list) { await this.Upsert(item);} + + await this.executeSql("COMMIT;"); + } catch (e) { + await this.executeSql("ROLLBACK;"); + throw e; + } + } + + /** + * 批量查询多个 target 的最新 N 条记录 + * @param targetField 目标字段名 (如 'target_guid') + * @param targetIds 目标ID数组 + * @param limit 每个 target 取多少条 + * @param orderByField 排序字段 (默认按此字段倒序) + * @returns 所有结果的数组 + */ + async findBatchLatest( + targetField: string, + targetIds: string[], + limit: number = 50, + orderByField: string = 'timestamp' + ): Promise { + if (!targetIds || targetIds.length === 0) return []; + + // 过滤掉无效的 ID(undefined、null、空字符串) + const validIds = targetIds.filter(id => id != null && id !== ''); + if (validIds.length === 0) return []; + + // 使用子查询 + UNION ALL,每个子查询需要用括号包起来 + const subQueries = validIds.map(id => { + const safeId = id.replace(/'/g, "''"); + return `SELECT * FROM ( + SELECT * FROM ${this.config.tableName} + WHERE ${targetField} = '${safeId}' + ORDER BY ${orderByField} DESC + LIMIT ${limit} + )`; + }); + + const sql = subQueries.join(' UNION ALL '); + const res = await this.selectRaw(sql); + return res.map(row => this.deserialize(row)) as T[]; + } + + /** 通用条件查询 */ + async find(options: { + where?: Record, + columns?: string[], // 支持只查特定列,如 ['key', 'local_path'] + orderBy?: string, + limit?: number, + offset?: number + }): Promise { + const colStr = options.columns && options.columns.length > 0 + ? options.columns.join(',') : '*'; + + let sql = `SELECT ${colStr} FROM ${this.config.tableName}`; + + const whereClause = this.buildWhere(options.where); + if (whereClause) sql += ` WHERE ${whereClause}`; + + if (options.orderBy) sql += ` ORDER BY ${options.orderBy}`; + if (options.limit) sql += ` LIMIT ${options.limit}`; + if (options.offset) sql += ` OFFSET ${options.offset}`; + + const res = await this.selectRaw(sql); + return res.map(row => this.deserialize(row)) as T[]; + } + + /** 通用删除 */ + async delete(whereClause: string): Promise { + const sql = `DELETE FROM ${this.config.tableName} WHERE ${whereClause}`; + return this.executeSql(sql); + } + + /** 执行原生SQL查询 */ + async queryRaw(sql: string): Promise { + const res = await this.selectRaw(sql); + return res.map(row => this.deserialize(row)) as T[]; + } + + /** 统计数量 */ + async count(where?: Record): Promise { + let sql = `SELECT COUNT(*) as total FROM ${this.config.tableName}`; + const whereClause = this.buildWhere(where); + if (whereClause) sql += ` WHERE ${whereClause}`; + + const res = await this.selectRaw(sql); + return res.length > 0 ? res[0].total : 0; + } + + /** + * 替代手动写 "DELETE FROM table WHERE key='...'" + */ + async deleteBy(where: Record): Promise { + const whereClause = this.buildWhere(where); + if (!whereClause) { + console.warn('[DB] deleteBy 被调用但没有条件,操作被阻止以防止清空全表'); + return; + } + const sql = `DELETE FROM ${this.config.tableName} WHERE ${whereClause}`; + return this.executeSql(sql); + } + + /* ================================= 内部工具 ================================= */ + + private buildWhere(where?: Record): string { + if (!where) return ''; + const clauses: string[] = []; + for (const key in where) { + const val = where[key]; + if (val === undefined) continue; + // 处理特殊符号,如 "age >": 18 + if (key.includes(' ') || key.includes('<') || key.includes('>') || key.includes('LIKE')) + clauses.push(`${key} ${this.formatValue(val)}`); + else + clauses.push(`${key} = ${this.formatValue(val)}`); + } + return clauses.join(' AND '); + } + + // 执行SQL语句 + public executeSql(sql: string | string[]): Promise { + return new Promise((resolve, reject) => { + plus.sqlite.executeSql({ + name: this.config.dbName, + sql: sql, + success: () => resolve(), + fail: (e) => { + console.error(`[DB] SQL Error:`, e); + reject(e); + } + }); + }); + } + + private selectRaw(sql: string): Promise { + return new Promise((resolve, reject) => { + plus.sqlite.selectSql({ + name: this.config.dbName, + sql, + success: (res) => resolve(res as any[]), + fail: (e) => { + console.error("SQL查询失败:", sql, e); + reject(e); + } + }); + }); + } + +// 序列化数值 + private formatValue(val: any): string { + if (typeof val === 'string') return `'${val.replace(/'/g, "''")}'`; + if (typeof val === 'boolean') return val ? '1' : '0'; + if (val === null || val === undefined) return 'NULL'; + return String(val); + } + + /** 整体序列化*/ +private serialize(data: any): Record { + const row: any = {}; + for (const col of this.tableColumns) { + let val = data[col]; + if (val === undefined) continue; + // JSON 字段处理 + if (this.config.jsonFields?.includes(col)) { + if (val !== null && typeof val === 'object') { val = JSON.stringify(val);} + } + // boolean 统一 + if (typeof val === 'boolean') + val = val ? 1 : 0; + row[col] = val; + } + return row; +} + + + /** 整体反序列化 */ + private deserialize(row: any) { + if (!row) return row; + this.config.jsonFields?.forEach(f => { + if (row[f] && typeof row[f] === 'string') { + try { + // 判断是否像 json 只有 {, [ 开头的才解析 + if (row[f].startsWith('{') || row[f].startsWith('[')) { + row[f] = JSON.parse(row[f]); + } + } catch (e) { + console.warn(`[DB] JSON 解析错误 ${f}:`, e); + } + } + }); + return row; + } + + // 初始化数据库SQL样式 + private generateInitSqls(): string[] { + const sqls: string[] = []; + const { tableName, columns, indexes } = this.config; + sqls.push(`CREATE TABLE IF NOT EXISTS ${tableName} (${columns})`); + if (indexes && indexes.length > 0) { + indexes.forEach((fieldStr) => { + const safeSuffix = fieldStr.replace(/[, ]+/g, '_'); + const indexName = `idx_${tableName}_${safeSuffix}`; + sqls.push(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${fieldStr})`); + }); + } + return sqls; + } + + private get realDbPath(): string { + const p = this.config.dbPath; + // 如果本来就以 .db/.sqlite/.sqlite3 结尾,就不再拼 + return /(\.db|\.sqlite3?)$/i.test(p) ? p : `${p}.db`; + } + + private compileColumns() { + this.tableColumns = this.config.columns + .split(',') + .map(c => c.trim().split(/\s+/)[0]); + } + + private connectDB(): Promise { + return new Promise((resolve, reject) => { + if (plus.sqlite.isOpenDatabase({ name: this.config.dbName, path: this.realDbPath })) { + return resolve(); + } + plus.sqlite.openDatabase({ + name: this.config.dbName, + path: this.realDbPath, + success: () => resolve(), + fail: (e) => reject(e) + }); + }); + } + +} \ No newline at end of file diff --git a/DBManager/DBManagerReadme.md b/DBManager/DBManagerReadme.md new file mode 100644 index 0000000..b0fd595 --- /dev/null +++ b/DBManager/DBManagerReadme.md @@ -0,0 +1,216 @@ + +# DBManager (SQLite 封装工具类) + +`DBManager` 是基于 `plus.sqlite` 的 Promise 化封装,旨在简化 App 端本地数据库的操作。它支持自动建表、自动创建索引、以及 JSON 字段的自动序列化/反序列化。 + + #✨ 核心特性 +* **开箱即用**:`open()` 时自动检测并创建数据库、表结构和索引。 +* **配置灵活**:通过 `DBSchema` 接口纯配置化定义表结构。 +* **JSON 友好**:配置 `jsonFields` 后,无需手动 `JSON.stringify/parse`,直接存取对象。 +* **Promise 封装**:支持 `async/await`,告别回调地狱。 +* **批量操作**:内置事务支持,提升批量插入性能。 + +--- + +## ⚙️ 配置说明 (DBSchema) + +在使用前,你需要定义一个 `DBSchema` 对象。 + +```typescript +export interface DBSchema { + /** 数据库逻辑名称 (如: 'chat_db') */ + dbName: string; + /** 数据库文件路径 (推荐: '_doc/xxx.db') */ + dbPath: string; + /** 表名 */ + tableName: string; + /** + * 建表列定义 (标准 SQL 语法) + * 必须在此处定义 PRIMARY KEY + */ + columns: string; + /** + * 需要创建索引的字段列表 + * 支持单字段: ['timestamp'] + * 支持组合索引: ['user_id, timestamp'] + */ + indexes?: string[]; + /** + * 需要自动 JSON 序列化的字段名 + * 对应的 columns 类型建议为 TEXT + */ + jsonFields?: string[]; +} +``` + +--- + +## 📝 填表案例 (Schema Examples) + +以下是几种常见业务场景的配置写法: + +### 场景一:聊天记录表 (标准场景) +* **需求**:使用 UUID 字符串作为主键,存储消息内容,其中 `media` 和 `extra` 字段存储复杂的 JSON 对象,需要按会话 ID 和时间查询。 + +```typescript +const ChatSchema: DBSchema = { + dbName: 'im_core', + dbPath: '_doc/im_core.db', + tableName: 'messages', + + // 定义列:注意 media 和 extra 定义为 TEXT 以存储 JSON 字符串 + columns: ` + guid TEXT PRIMARY KEY, + session_id TEXT, + sender_id TEXT, + content TEXT, + msg_type INTEGER, + media TEXT, + extra TEXT, + is_read INTEGER, + timestamp INTEGER + `, + + // 定义索引:加快查询速度 + indexes: [ + 'timestamp', // 按时间排序 + 'session_id, timestamp' // 获取某个会话的历史记录 (组合索引) + ], + + // 自动转换:存入时自动 stringify,取出时自动 parse + jsonFields: ['media', 'extra'] +}; +``` + +### 场景二:待办事项表 (自增主键) +* **需求**:简单的任务列表,ID 需要自动递增。 + +```typescript +const TodoSchema: DBSchema = { + dbName: 'todo_app', + dbPath: '_doc/todo.db', + tableName: 'tasks', + + // 使用 SQLite 的自增语法 + columns: ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + is_completed INTEGER, + created_at INTEGER + `, + + indexes: ['is_completed'] +}; +``` + +### 场景三:用户关系表 (联合主键) +* **需求**:存储群组成员关系,一个用户在一个群里只能有一条记录(`user_id` + `group_id` 唯一)。 + +```typescript +const MemberSchema: DBSchema = { + dbName: 'relation_db', + dbPath: '_doc/relation.db', + tableName: 'group_members', + + // 在末尾定义联合主键 + columns: ` + group_id TEXT, + user_id TEXT, + nickname TEXT, + role TEXT, + join_time INTEGER, + PRIMARY KEY (group_id, user_id) + `, + + indexes: ['user_id'] // group_id 已经在主键索引里了,不需要单独建 +}; +``` + +--- + +## 🚀 快速开始 +### 1. 初始化 + +推荐在 Store 或 Service 层创建一个单例实例。 + +```typescript +import { DBManager, DBSchema } from './utils/DBManager'; + +// 1. 定义配置 +const config: DBSchema = { /* 参考上面的案例 */ }; + +// 2. 创建实例 +export const ChatDB = new DBManager(config); + +// 3. 打开数据库 (通常在 App 启动或登录后调用) +await ChatDB.open(); +``` + +### 2. 插入/更新数据 (Upsert) + +使用 `Upsert` 方法,如果主键存在则更新,不存在则插入。 +**注意**:可以直接传入包含对象的 JSON 字段,无需手动转换。 + +```typescript +const msg = { + guid: 'msg_1001', + session_id: 'sess_a', + content: 'Hello World', + timestamp: 1679000000, + is_read: true, // 会自动转为 1 + // 这里的 media 对象会自动转为字符串存入数据库 + media: { + type: 'image', + url: 'https://xxx.com/img.png', + width: 100 + } +}; + +await ChatDB.Upsert(msg); +``` + +### 3. 查询数据 (Find) + +支持 `where`、`orderBy`、`limit`、`offset`。 + +```typescript +const history = await ChatDB.find({ + where: { + session_id: 'sess_a', + // 支持简单的比较操作符 (需在 key 中包含空格) + 'timestamp <': 1679999999 + }, + orderBy: 'timestamp DESC', + limit: 20 +}); + +// history[0].media 会自动被还原为对象 +console.log(history[0].media.url); +``` + +### 4. 复杂查询 (Raw SQL) + +如果内置方法无法满足需求(如模糊搜索),可执行原生 SQL。 + +```typescript +// 模糊搜索示例 +const keyword = '测试'; +// 注意手动处理 SQL 转义,防止单引号报错 +const safeKeyword = keyword.replace(/'/g, "''"); + +const sql = ` + SELECT * FROM messages + WHERE content LIKE '%${safeKeyword}%' + ORDER BY timestamp DESC +`; + +const res = await ChatDB.queryRaw(sql); +``` + +--- + +## ⚠️ 注意事项 + +1. **修改表结构**:SQLite 不支持直接修改列类型或删除列。如果修改了 `DBSchema` 的 `columns`,通常需要卸载 App 或手动迁移数据(`ALTER TABLE ADD COLUMN` 可通过 `executeSql` 手动执行)。 +2. **布尔值**:SQLite 没有 Boolean 类型,本工具会自动将 `true/false` 转换为 `1/0` 存储。 +3. **单引号转义**:在使用 `queryRaw` 手动拼接 SQL 时,务必使用 `.replace(/'/g, "''")` 处理字符串参数,防止 SQL 报错或注入。 diff --git a/FormatTimeTool.ts b/FormatTimeTool.ts new file mode 100644 index 0000000..e044986 --- /dev/null +++ b/FormatTimeTool.ts @@ -0,0 +1,119 @@ +/** + * 自动将任意精度的时间戳转换为毫秒级时间戳 + * 支持:秒(10位)、毫秒(13位)、微秒(16位)、纳秒(19位) + */ +function normalizeTimestamp(ts: number): number { + if (ts <= 0) return ts; + + const len = Math.floor(ts).toString().length; + + if (len <= 10) { + return ts * 1000; + } else if (len <= 13) { + return ts; + } else if (len <= 16) { + return Math.floor(ts / 1000); + } else { + return Math.floor(ts / 1000000); + } +} + +/** + * 格式化时间戳为友好的显示格式(用于聊天/消息列表等) + * - 当天、昨天:显示详细时间 HH:MM(昨天带「昨天」前缀) + * - 大于昨天且在七天内:显示「X天前」 + * - 超过七天且本年:显示 MM/DD + * - 非本年:显示 YY/MM/DD + * 自动识别时间戳精度,支持秒/毫秒/微秒/纳秒级时间戳 + */ +export function formatTime(ts: number): string { + const MS = 1; + const MINUTE = MS * 1000 * 60; + const HOUR = MINUTE * 60; + const DAY = HOUR * 24; + + const normalizedTs = normalizeTimestamp(ts); + const now = Date.now(); + const diff = now - normalizedTs; + + if (diff <= 0) return '未来'; + if (diff < MINUTE) return '刚刚'; + + const target = new Date(normalizedTs); + const today = new Date(now); + + const pad = (n: number) => String(n).padStart(2, '0'); + const hm = `${pad(target.getHours())}:${pad(target.getMinutes())}`; + + const isSameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + + if (isSameDay(today, target)) return hm; + + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + if (isSameDay(yesterday, target)) return `昨天 ${hm}`; + + // 按日历天数算「X天前」,避免「前天」因不足 48 小时被算成 1 天 + const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime(); + const targetStart = new Date(target.getFullYear(), target.getMonth(), target.getDate()).getTime(); + const calendarDaysAgo = Math.floor((todayStart - targetStart) / DAY); + if (calendarDaysAgo >= 2 && calendarDaysAgo <= 7) return `${calendarDaysAgo}天前`; + + const MM = pad(target.getMonth() + 1); + const DD = pad(target.getDate()); + if (target.getFullYear() !== today.getFullYear()) { + const YY = String(target.getFullYear()).slice(-2); + return `${YY}/${MM}/${DD}`; + } + return `${MM}/${DD}`; +} + +/** + * 信息流用时间格式(Feed 专用) + * - 1 分钟内:刚刚 + * - 1 分钟~1 小时:xx分钟前 + * - 1 小时~1 天:xx小时前 + * - 1 天~1 周:x天前 + * - 超过 1 周、今年内:MM/DD + * - 不是今年:YYYY/MM/DD + */ +export function formatFeedTime(ts: number): string { + const MS = 1; + const MINUTE = MS * 1000 * 60; + const HOUR = MINUTE * 60; + const DAY = HOUR * 24; + const WEEK = DAY * 7; + + const normalizedTs = normalizeTimestamp(ts); + const now = Date.now(); + const diff = now - normalizedTs; + + if (diff <= 0) return '刚刚'; + if (diff < MINUTE) return '刚刚'; + if (diff < HOUR) { + const m = Math.floor(diff / MINUTE); + return `${m}分钟前`; + } + if (diff < DAY) { + const h = Math.floor(diff / HOUR); + return `${h}小时前`; + } + if (diff < WEEK) { + const d = Math.floor(diff / DAY); + return `${d}天前`; + } + + const target = new Date(normalizedTs); + const today = new Date(now); + const pad = (n: number) => String(n).padStart(2, '0'); + const MM = pad(target.getMonth() + 1); + const DD = pad(target.getDate()); + + if (target.getFullYear() !== today.getFullYear()) { + return `${target.getFullYear()}/${MM}/${DD}`; + } + return `${MM}/${DD}`; +} diff --git a/ImageViewer/ImageViewer.vue b/ImageViewer/ImageViewer.vue new file mode 100644 index 0000000..21bd4c4 --- /dev/null +++ b/ImageViewer/ImageViewer.vue @@ -0,0 +1,1126 @@ + + + + + diff --git a/InputArea/InputArea.vue b/InputArea/InputArea.vue new file mode 100644 index 0000000..de649b7 --- /dev/null +++ b/InputArea/InputArea.vue @@ -0,0 +1,742 @@ +