[入库]初次入库各种工具

- DBManager DB创建管理器
- FormatTimeTool
- ImageViewer工具
- InputArear工具
- OverlayPage工具
- 下拉刷新容器工具
- 视频查看器工具
This commit is contained in:
Rain 2026-04-02 16:09:00 +08:00
commit 3ab1f2db24
11 changed files with 4678 additions and 0 deletions

487
CacheManager.ts Normal file
View File

@ -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<void>> = [];
constructor(
private concurrency: number,
private spaceDelay: number = 0
) {}
add(task: () => Promise<void>) {
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<string, string>();
private downloading = new Set<string>();
private checkingDB = new Set<string>();
private dirtyKeys = new Set<string>();
/* -------- 延迟持久化队列 -------- */
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<string, Array<(path: string) => 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<CacheIndexRow>({
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<string> {
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<void> {
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<string | null> {
try {
const rows = await this.db.find<CacheIndexRow>({
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<string> {
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<CacheIndexRow>({
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;
}
// 下载完立即用 tempFilePathUI 无感
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
/* ======================= 导出单例 ======================= */
export default new CacheManager();

349
DBManager/DBManager.ts Normal file
View File

@ -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<void> {
this.compileColumns();// 建立Scheme规范
await this.connectDB(); // 先链接
const sqlList = this.generateInitSqls(); // 后建表
if (sqlList.length > 0) { await this.executeSql(sqlList);} // 再建索引
}
/* ================================= 基础 CRUD 封装 ================================= */
async Upsert(data: any): Promise<void> {
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<void> {
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<void> {
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<T>(
targetField: string,
targetIds: string[],
limit: number = 50,
orderByField: string = 'timestamp'
): Promise<T[]> {
if (!targetIds || targetIds.length === 0) return [];
// 过滤掉无效的 IDundefined、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<T>(options: {
where?: Record<string, any>,
columns?: string[], // 支持只查特定列,如 ['key', 'local_path']
orderBy?: string,
limit?: number,
offset?: number
}): Promise<T[]> {
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<void> {
const sql = `DELETE FROM ${this.config.tableName} WHERE ${whereClause}`;
return this.executeSql(sql);
}
/** 执行原生SQL查询 */
async queryRaw<T>(sql: string): Promise<T[]> {
const res = await this.selectRaw(sql);
return res.map(row => this.deserialize(row)) as T[];
}
/** 统计数量 */
async count(where?: Record<string, any>): Promise<number> {
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<string, any>): Promise<void> {
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, any>): 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<void> {
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<any[]> {
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<string, any> {
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<void> {
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)
});
});
}
}

View File

@ -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 报错或注入。

119
FormatTimeTool.ts Normal file
View File

@ -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}`;
}

1126
ImageViewer/ImageViewer.vue Normal file

File diff suppressed because it is too large Load Diff

742
InputArea/InputArea.vue Normal file
View File

@ -0,0 +1,742 @@
<template>
<view class="input-wrapper" :style="{ '--main-color': themeColor , '--bar-height' :barHeight , 'padding-bottom': dynamicBottom }">
<!-- 引用条 + 输入框纵向 flex引用条在上输入框在下 -->
<view class="input-bar-group">
<!-- 引用回复条 -->
<view v-if="isReplyValid" class="reply-bar" :style="{ '--dynamic-radius': dynamicRadius + 'rpx' }" @touchstart.prevent>
<view class="reply-bar-inner">
<view class="reply-text-wrap">
<text class="reply-name">{{ replyDisplayName }}</text>
<text class="reply-content">{{ replyDisplayContent }}</text>
</view>
</view>
</view>
<!-- 取消引用 -->
<view v-if="isReplyValid" class="reply-seam" @touchstart.prevent="clearReplyRef">
<view class="reply-seam-line" />
<text class="reply-seam-text">取消引用</text>
</view>
<view
class="input-container"
:class="{
'has-reply': isReplyValid,
'mode-longpress': isLongPressing,
'mode-cancelling': isLongPressing && isCancelling
}"
:style="{
'--dynamic-radius': dynamicRadius + 'rpx',
height: containerHeightRpx + 'rpx'
}"
@tap.stop
>
<!-- 左侧图标 -->
<view
class="toggle-wrapper"
:class="{ 'node-hidden': isLongPressing, 'btn-press': leftBtnPressed }"
v-if="showLeftBtn"
@touchstart="leftBtnPressed = true"
@touchend="leftBtnPressed = false"
@touchcancel="leftBtnPressed = false"
@tap="LeftButtonHandle"
>
<image class="toggle-icon" :src="LeftIcon" />
</view>
<!-- 中间触发区域 -->
<view
class="input-area"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
>
<textarea
class="input-text"
:class="{ 'input-hidden': isLongPressing, 'is-scrollable': !shouldAutoHeight }"
v-model="localInputText"
:adjust-position="false"
:hold-keyboard="holdKeyboard"
:focus="inputFocus"
:confirm-type="confirmType"
:auto-height="shouldAutoHeight"
:fixed="true"
:maxlength="-1"
:style="{
fontSize: textareaFontSize + 'rpx',
...(!shouldAutoHeight ? { height: MAX_TEXTAREA_HEIGHT + 'rpx' } : {})
}"
@focus="onFocus"
@blur="onBlur"
@confirm="handleConfirm"
@linechange="onLineChange"
:placeholder="placeholder"
placeholder-class="input-placeholder"
/>
<!-- 长按激活时的遮罩/提示 -->
<view v-if="isLongPressing" class="longpress-overlay">
<text class="longpress-tip">
{{ isCancelling ? cancelTip : longPressTip }}
</text>
</view>
</view>
<!-- 右侧按钮区域 -->
<view
class="attachment-btn"
:class="{ 'is-send': hasText, 'node-hidden': isLongPressing, 'btn-press': rightBtnPressed }"
@touchstart.prevent="rightBtnPressed = true; handleRightBtnTap()"
@touchend="rightBtnPressed = false"
@touchcancel="rightBtnPressed = false"
>
<image class="icon send-icon" :src="sendIcon"></image>
<image class="icon attachment-icon" :src="attachmentIcon"></image>
</view>
</view>
</view>
<!-- 悬浮面板 -->
<floatpanel
:visible="innerShowPanel"
:buttons="buttons"
:height="panelHeight"
:bottomOffset="panelBottomOffset"
:horizontalPadding="panelPadding"
@handleTap="onPanelSelect"
>
<template v-if="hasCustomPanel" #default>
<slot name="panel-content"></slot>
</template>
</floatpanel>
<!-- 遮罩层点击空白处关闭面板 -->
<view
v-if="innerShowPanel"
class="panel-mask"
@tap="setPanelVisible(false)"
/>
</view>
</template>
<script setup>
import { ref, computed, nextTick, watch, useSlots , onMounted , onUnmounted } from 'vue'
import floatpanel from './floatPanel.vue'
const slots = useSlots()
const props = defineProps({
// --- ---
modelValue: { type: String, default: '' }, // v-model
showPanel: { type: Boolean, default: false }, // v-model:showPanel
/** 引用回复:外部传入任意对象,内部仅要求包含 content(string) 与 Name/name有效时才显示引用条支持双向 */
replyRef: { type: [Object, null], default: null },
/** 外部控制的焦点状态 */
focus: { type: Boolean, default: false },
// --- ---
placeholder: { type: String, default: '点击输入或按住说话....' },
longPressTip: { type: String, default: '正在录音,上滑取消' },
cancelTip: { type: String, default: '松开手指,取消发送' },
// --- ---
themeColor: { type: String, default: '#a846e6' },
barHeight: { type: String, default: '80rpx' },
padBottom: { type: String, default: '4rpx' },
LeftIcon: { type: String, default: '/static/Mic.png' },
sendIcon: { type: String, default: '/static/send.png' },
attachmentIcon: { type: String, default: '/static/Attachment.png' },
// --- ---
showLeftBtn: { type: Boolean, default: true }, //
// --- ---
buttons: { type: Array },
panelHeight: { type: String, default: '100rpx' },
panelBottomOffset: { type: String, default: '10rpx' },
panelPadding: { type: String, default: '15rpx' },
// --- ---
longPressDelay: { type: Number, default: 200}, // (ms) -- 500
cancelThreshold: { type: Number, default: 100 }, // (px)
// --- Input ---
adjustPosition: { type: Boolean, default: false },
holdKeyboard: { type: Boolean, default: true },
confirmType: { type: String, default: 'send' }
})
const emit = defineEmits([
'update:modelValue',
'update:showPanel',
'update:replyRef',
'longPressStart',
'longPressEnd',
'longPressCancel',
'send',
'panelClick',
'leftButtonClick',
'focus',
'blur',
'keyboardHeightChange'
])
// --- ---
const inputFocus = ref(false)
const isLongPressing = ref(false)
const isCancelling = ref(false)
const startY = ref(0)
const lastMoveY = ref(0) // Y
const longPressTimer = ref(null)
const focusLock = ref(false)
const didTouchMove = ref(false) // 是否发生了滑动(用于区分点击聚焦 / 滑动松手
const leftBtnPressed = ref(false)
const rightBtnPressed = ref(false)
const keyboardHeight = ref(0)
// focus prop
watch(() => props.focus, (val) => {
if (val !== inputFocus.value) {
inputFocus.value = val
// false blur
if (!val) {
emit('blur')
}
}
}, { immediate: true })
//
const localInputText = ref(props.modelValue)
watch(() => props.modelValue, (val) => localInputText.value = val)
watch(localInputText, (val) => emit('update:modelValue', val))
//
const innerShowPanel = ref(props.showPanel)
watch(() => props.showPanel, (val) => innerShowPanel.value = val)
const setPanelVisible = (visible) => {
innerShowPanel.value = visible
emit('update:showPanel', visible)
}
// content(string) Name/name
const isReplyValid = computed(() => {
const r = props.replyRef
if (!r || typeof r !== 'object') return false
const hasContent = typeof r.content === 'string'
const hasName = typeof (r.Name ?? r.name) === 'string'
return hasContent && hasName
})
const replyDisplayName = computed(() => {
if (!isReplyValid.value) return ''
const r = props.replyRef
return (r.Name ?? r.name) ?? ''
})
const replyDisplayContent = computed(() => {
if (!isReplyValid.value) return ''
const raw = props.replyRef?.content ?? ''
const oneLine = String(raw).replace(/\s*[\r\n]+\s*/g, ' ').trim()
return oneLine.length > 40 ? oneLine.slice(0, 40) + '…' : oneLine
})
const clearReplyRef = () => {
emit('update:replyRef', null)
}
//
const hasCustomPanel = computed(() => !!slots['panel-content'])
const hasText = computed(() => localInputText.value.trim().length > 0)
// (rpx)
const textareaHeightRpx = ref(68)
// barHeight prop 'rpx'
const PILL_HEIGHT = computed(() => {
const heightStr = props.barHeight.replace('rpx', '')
return parseInt(heightStr) || 80
})
// barHeight 80rpx 28rpx
const textareaFontSize = computed(() => {
const baseHeight = 80
const baseFontSize = 28
const ratio = PILL_HEIGHT.value / baseHeight
return Math.round(baseFontSize * ratio)
})
const MAX_TEXTAREA_HEIGHT = 300 // textarea
const MAX_HEIGHT = 16 + MAX_TEXTAREA_HEIGHT // = + textarea
const PILL_RADIUS = 50
const RECT_RADIUS = 36
// -
const shouldAutoHeight = computed(() => textareaHeightRpx.value < MAX_TEXTAREA_HEIGHT)
// rpx
const containerHeightRpx = computed(() => {
// = barHeight - padding (6rpx = 12rpx)
const minInnerH = PILL_HEIGHT.value - 12
const innerH = Math.max(minInnerH, textareaHeightRpx.value)
const h = 12 + innerH // padding 6rpx
return Math.min(MAX_HEIGHT, Math.max(PILL_HEIGHT.value, h))
})
const dynamicRadius = computed(() => {
const containerH = containerHeightRpx.value
const pillH = PILL_HEIGHT.value
if (containerH <= pillH) return PILL_RADIUS
if (containerH >= MAX_HEIGHT) return RECT_RADIUS
return Math.round(PILL_RADIUS - (containerH - pillH) * (PILL_RADIUS - RECT_RADIUS) / (MAX_HEIGHT - pillH))
})
const dynamicBottom = computed(() => {
if (keyboardHeight.value > 0) {
// 使 +
return `calc(${keyboardHeight.value}px + ${props.padBottom})`
} else {
//
return `calc(env(safe-area-inset-bottom) + ${props.padBottom})`
}
})
const onLineChange = (e) => {
let { height, heightRpx } = e.detail || {}
// height(px) rpx
if ((!heightRpx || heightRpx <= 0) && height > 0) {
const winWidth = uni.getSystemInfoSync().windowWidth || 375
heightRpx = Math.round(height * 750 / winWidth)
}
// if (heightRpx > 0) textareaHeightRpx.value = heightRpx
if (heightRpx > 0) {
textareaHeightRpx.value = heightRpx;
//
nextTick(() => {
emit('heightChange');
});
}
}
//
watch(localInputText, (val) => {
if (!val.trim()) {
// barHeight - padding (6rpx = 12rpx)
textareaHeightRpx.value = PILL_HEIGHT.value - 12
}
})
// --- ---
const handleTouchStart = (e) => {
didTouchMove.value = false
if (focusLock.value || hasText.value || inputFocus.value) return
const touchY = e.touches[0].clientY
startY.value = touchY
lastMoveY.value = touchY //
longPressTimer.value = setTimeout(() => {
isLongPressing.value = true
isCancelling.value = false
uni.vibrateShort();
emit('longPressStart');
}, props.longPressDelay)
}
const handleTouchMove = (e) => {
if (!isLongPressing.value) {
didTouchMove.value = true // touchend
return // textarea
}
e.preventDefault()
const moveY = e.touches[0].clientY
lastMoveY.value = moveY
isCancelling.value = (startY.value - moveY > props.cancelThreshold)
}
const handleTouchEnd = () => {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
if (isLongPressing.value) {
const shouldCancel = (startY.value - lastMoveY.value > props.cancelThreshold)
emit(shouldCancel ? 'longPressCancel' : 'longPressEnd')
isLongPressing.value = false
isCancelling.value = false
focusLock.value = true
setTimeout(() => { focusLock.value = false }, 200) //
} else {
//
// focus
if (!focusLock.value && !didTouchMove.value) {
// focus true
if (props.focus) {
inputFocus.value = true
} else if (!inputFocus.value) {
//
nextTick(() => {
inputFocus.value = true
emit('focus')
})
}
}
//
didTouchMove.value = false
}
}
const onFocus = () => {
inputFocus.value = true
emit('focus');
}
const onBlur = () => {
inputFocus.value = false
emit('blur'); // blur
}
const handleRightBtnTap = () => {
if (hasText.value) {
emit('send', { type: 'text', content: localInputText.value })
localInputText.value = ''
} else {
setPanelVisible(!innerShowPanel.value)
}
}
//
const handleConfirm = () => {
if (hasText.value) {
emit('send', { type: 'text', content: localInputText.value })
localInputText.value = ''
}
}
const onPanelSelect = (item) => {
emit('panelClick', item.button)//button
setTimeout(() => setPanelVisible(false), 150)
}
const LeftButtonHandle = () => {
emit('leftButtonClick');
}
onMounted(() => {
uni.onKeyboardHeightChange(res => {
keyboardHeight.value = res.height;
emit('keyboardHeightChange', res.height);
});
});
onUnmounted(() => {
uni.offKeyboardHeightChange();
});
</script>
<style lang="scss" scoped>
$paddingBottom : var(--paddingBottom);
$MainColor: var(--main-color);
.input-wrapper {
width: 100%;
box-sizing: border-box;
position: relative;
flex-shrink: 0;
z-index: 999;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 12rpx;
transition: padding-bottom 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* 引用条 + 输入框的纵向容器 */
.input-bar-group {
display: flex;
flex-direction: column;
width: 100%;
align-items: stretch;
}
/* 引用回复条*/
.reply-bar {
width: 100%;
box-sizing: border-box;
background-color: rgba(38, 38, 38, 0.6);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border: 1px solid rgba(255, 255, 255, 0.05);
border-bottom: none;
border-radius: var(--dynamic-radius, 50rpx) var(--dynamic-radius, 50rpx) 0 0;
margin-bottom: -1rpx;
padding: 8rpx 10rpx 10rpx;
}
.reply-bar-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
}
.reply-text-wrap {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
}
/* Name */
.reply-name {
font-size: 20rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.42);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
text-align: center;
}
/* Content */
.reply-content {
font-size: 28rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.88);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
text-align: center;
max-width: 100%;
}
/* 引用内容渐变线 */
.reply-seam-line {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
to right,
transparent 0%,
transparent 28%,
rgba(255, 255, 255, 0.2) 48%,
rgba(255, 255, 255, 0.2) 52%,
transparent 72%,
transparent 100%
);
}
/* 接缝 + 「取消引用」文字 */
.reply-seam {
position: relative;
width: 100%;
box-sizing: border-box;
background-color: rgba(38, 38, 38, 0.6);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-left: 1px solid rgba(255, 255, 255, 0.05);
border-right: 1px solid rgba(255, 255, 255, 0.05);
padding: 5rpx 10rpx 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.reply-seam-text {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.4);
}
.input-container {
display: flex;
box-sizing: border-box;
align-items: center;
width: 100%;
min-height: var(--bar-height);
max-height: 360rpx;
background-color: rgba(38, 38, 38, 0.6);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-radius: var(--dynamic-radius, 50rpx);
padding-top: 6rpx;
padding-bottom: 6rpx;
padding-left: 6rpx;
padding-right: 6rpx;
border: 1px solid rgba(255, 255, 255, 0.05);
/* 高度、圆角、背景色 带缓动过渡 */
transition: height 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94),
border-radius 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94),
background-color 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
backdrop-filter 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
overflow: hidden;
&.has-reply {
border-radius: 0 0 var(--dynamic-radius, 36rpx) var(--dynamic-radius, 36rpx);
}
&.mode-longpress {
background-color: var(--main-color) !important;
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
&.mode-cancelling {
background-color: rgba(255, 77, 79, 0.8) !important;
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
}
/* 长按时两侧按钮的平滑缩小/隐藏(带过渡动效) */
.node-hidden {
width: 0 !important;
height: 0 !important;
opacity: 0 !important;
margin: 0 !important;
padding: 0 !important;
transform: scale(0);
pointer-events: none;
}
.toggle-wrapper {
height: calc(var(--bar-height) - 12rpx);
width: calc(var(--bar-height) - 12rpx);
aspect-ratio: 1;
border-radius: 999rpx;
background-color: $MainColor;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
/* 按下缩放 + 长按隐藏时的缩小动效 */
transition: width 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
margin 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
padding 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&.btn-press {
transform: scale(0.92);
}
.toggle-icon {
height: 70%;
width: 70%;
}
}
.input-area {
flex: 1;
margin: 0 8rpx;
display: flex;
align-items: center;
position: relative;
overflow: visible;
min-width: 0;
min-height: calc(var(--bar-height) - 12rpx);
max-height: 300rpx;
.input-placeholder {
color: #999999;
font-weight: 400;
}
.input-text {
width: 100%;
font-weight: 800;
color: #ffffff;
transition: opacity 0.2s;
line-height: 1.3;
box-sizing: border-box;
padding: 0;
&.input-hidden {
opacity: 0;
visibility: hidden;
}
/* 超过最大高度后变为可滚动 */
&.is-scrollable {
overflow-y: auto !important;
}
}
}
.longpress-overlay {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
.longpress-tip {
font-size: 30rpx;
color: #ffffff;
font-weight: bold;
}
}
.attachment-btn {
--offset: 6%;
width: calc(var(--bar-height) - 12rpx);
height: calc(var(--bar-height) - 12rpx);
border-radius: 999rpx;
background-color: rgba(255, 255, 255, 0.08);
display: flex;
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
flex-shrink: 0;
/* 按下缩放 + 长按隐藏时的缩小动效 + 主题色渐变 */
transition: width 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
height 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
margin 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
padding 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
background-color 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94),
filter 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&.btn-press {
transform: scale(0.92);
}
.icon {
position: absolute;
width: 50%;
height: 50%;
transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.attachment-icon {
opacity: 1;
transform: rotate(0deg) scale(1);
}
.send-icon {
opacity: 0;
transform: rotate(-90deg) scale(0) translateX(var(--offset));
}
&.is-send {
background-color: $MainColor;
filter: brightness(1.1); //
.attachment-icon {
opacity: 0;
transform: rotate(90deg) scale(0);
}
.send-icon {
opacity: 1;
transform: rotate(0deg) scale(1) translateX(var(--offset));
}
}
}
.panel-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 998;
}
</style>

155
InputArea/Readme.md Normal file
View File

@ -0,0 +1,155 @@
Make By. RainyTears
基于 Vue移动端打造的「悬浮面板 + 输入框」
```sh
├── floatPanel.vue # 横向滚动按钮组 / 插槽面板
└── InputArea.vue # 语音/文字双模式输入容器
```
---
## 1. 特性总览
| 层级 | 可插拔 | |
| ------ | -------------------------------------- | --- |
| **外层** | `InputArea.vue` 负责长录音、键盘切换、主题色、v-model | |
| **面板** | `floatPanel.vue` 提供按钮渲染 + 两种插槽,零逻辑依赖 | |
> ✅ 支持 uni-app、hbuilder、vue3setup
---
## 2. 安装与最简调用
```vue
<!-- 页面 -->
<!-- 关于showPanel - 即便你不想要主动操控 内部也内置了一部分的showPanel管理 -->
<template>
<inputArea
v-model="msg"
v-model:showPanel="show"
:buttons="btns"
themeColor="#a846e6"
@Send="onSend"
@LongPressStart="recordStart"
@LongPressEnd="recordEnd"
@LongPressCancel="recordCancel"
@PanelClick="panelSelect"
>
<!-- 如果你想自定义悬浮面板里的内容,可以使用 slot -->
<template #panel-content>
<view>自定义表情面板</view>
</template>
<!-- 如果自定义了悬浮面板 那将默认不会显示buttons -->
</inputArea>
</template>
<script setup>
import InputKit from '@/components/FloatingInputKit/index.vue'
const msg = ref('')
const show = ref(false)
const btns = [
{ label:'相册', icon:'/static/pic.png', value:'image' },
{ label:'拍摄', icon:'/static/cam.png', value:'video' },
{ label:'文件', icon:'/static/file.png', value:'file' }
] // 如果不定义btns直接移除的话可以使用Slot
function onSend(e) { console.log('文字',e.content) }
function panelSelect(e) {
switch(e.value){
case image:
ChooseImage(); break;
case vide:
ChooseVideo(); break;
}
}
</script>
```
---
## 3. Propsindex.Vue
| 属性 | 类型 | 默认 | 说明 |
| ----------------- | ------- | ---------------------- | --------------------------- |
| modelValue | String | '' | 输入框内容,支持 v-model |
| showPanel | Boolean | false | 浮层面板显隐,支持 v-model:showPanel |
| placeholder | String | 点击输入或按住说话... | 输入框占位 |
| longPressTip | String | 正在录音,上滑取消 | 长按时提示 |
| cancelTip | String | 松开手指,取消发送 | 上滑取消时提示 |
| LeftIcon | String | /static/button.Png | 左侧图标 |
| sendIcon | String | /static/send.Png | 发送态图标 |
| attachmentIcon | String | /static/Attachment.Png | 附件图标 |
| showLeftBtn | Boolean | true | 是否显示左侧按钮 |
| buttons | Array | 见代码 | 面板的按钮数据 |
| panelHeight | String | 100rpx | 面板高度 |
| panelBottomOffset | String | 10rpx | 面板与输入框距离 |
| panelPadding | String | 15rpx | 面板左右留空 |
| longPressDelay | Number | 200 | 长按触发阈值 ms |
| cancelThreshold | Number | 100 | 上滑取消的 px 阈值 |
| adjustPosition | Boolean | false | input adjust-position |
| holdKeyboard | Boolean | true | input hold-keyboard |
| confirmType | String | send | input confirm-type |
| themeColor<br> | String | #a846e6 | 主色,同--main-color |
| BarHeight | String | 100rpx | 整个聊天条高度 |
| PadBottom | String | 36rpx | 距离底部距离 |
---
## 4. Eventsindex.Vue
| 事件 | 参数 | 说明 |
| ----------------- | ---------------------------- | -------------- |
| update:modelValue | text:String | 文本变化 |
| update:showPanel | show:Boolean | 面板显隐变化 |
| LongPressStart | - | 开始长按输入框(可用于录音) |
| LongPressEnd | - | 正常松手结束 |
| LongPressCancel | - | 上滑取消 |
| Send | {type:'text',content:String} | 点击发送触发 |
| PanelClick | {button,index} | 面板按钮/插槽点击 |
| LeftButtonClick | - | 左侧按钮触发 |
---
## 5. Slots — 高自由度
| 插槽 | 作用域 | 用法 |
| ------------------------------ | -------------- | ------------------------ |
| panel-contentindex.Vue | - | **外层整体替换面板**,常用于自定义复杂 ui |
| -floatPanel.Vue 默认插槽) | - | **完全覆盖按钮区**,保留滚动和动效骨架 |
| button-contentfloatPanel.Vue | {button,index} | **仅定制按钮内部结构**,不破坏外层 item |
| | | |
> 注意:一旦使用 floatPanel 默认插槽 (`<slot v-if="hasDefaultSlot">`),预设按钮循环即不再渲染,仅展示你提供的内容,仍享有 hide/disabled 动效。
---
## 6. 内部状态index.Vue
- `focusLock:boolean` —— 防止长按后立即抖动聚焦
- `isLongPressing / isCancelling` —— 手势长按聊天条状态
- `innerShowPanel` —— 本地面板显隐,外部通过 v-model:showPanel 同步
> 面板显隐会自动受 focus/blur 影响,可在回调里 `setPanelVisible` 覆盖
---
## 7. 样式变量index.Scss
| CSS 变量 | 默认 | 用途 |
| ------------ | -------------------------------- | ------ |
| --main-color | #a846e6 | 主色 |
| $elastic | transform 0.35s cubic-bezier(... | 全局弹性曲线 |
| $btn-height | 100rpx | 输入条高度 |
---
## 8. 常见修补/扩展
1. 底部安全区 & 全面屏定位已内置:`env(safe-area-inset-bottom) + 60rpx`
2. 举例:想替换「长按录音」为「长按拍照」:
- 监听 `LongPressStart` 调起相机,返回值自己构造
3. 面板支持无限长按钮,内部 `scroll-view``white-space:nowrap`
---

182
InputArea/floatPanel.vue Normal file
View File

@ -0,0 +1,182 @@
<template>
<view
class="floating-panel"
:class="{ 'panel-hide': !visible, 'panel-disabled': !visible }"
:style="panelStyle"
@tap.stop
>
<scroll-view class="button-scroll" scroll-x :show-scrollbar="false">
<view class="button-list">
<!-- 如果用户提供了自定义插槽内容 -->
<slot v-if="hasDefaultSlot"></slot>
<!-- 如果没有插槽而使用的是buttons则显示预制的渲染逻辑 -->
<template v-else>
<view
v-for="(button, index) in buttons"
:key="index"
class="button-item"
:class="{ 'active': activeIndex === index }"
@tap="handleButtonClick(button, index)"
hover-class="button-hover"
>
<!-- 这里也可以再套一层作用域插槽允许只自定义单个按钮内部 -->
<slot name="button-content" :button="button" :index="index">
<image v-if="button.icon" class="button-icon" :src="button.icon" />
<text class="button-text">{{ button.label }}</text>
</slot>
</view>
</template>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed ,useSlots } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
buttons: {
type: Array,
default: () => [
{ label: '', icon: '', value: 'text' },
{ label: '', icon: '', value: 'image' },
{ label: '', icon: '', value: 'video' },
{ label: '', icon: '', value: 'file' },
{ label: '', icon: '', value: 'location' },
{ label: '', icon: '', value: 'contact' }
]
},
height: {
type: String,
default: '100rpx'
},
horizontalPadding: {
type: String,
default: '20rpx'
},
bottomOffset: {
type: String,
default: '10rpx'
}
})
const emit = defineEmits(['handleTap'])
const slots = useSlots() //
const activeIndex = ref(-1)
const hasDefaultSlot = computed(() => !!slots.default)
const panelStyle = computed(() => ({
height: props.height,
left: props.horizontalPadding,
right: props.horizontalPadding,
bottom: `calc(100% + ${props.bottomOffset})`
}))
// 使button
const handleButtonClick = (button, index) => {
activeIndex.value = index
emit('handleTap', { button, index })
}
</script>
<style lang="scss" scoped>
$elastic: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
.floating-panel {
position: absolute;
background: linear-gradient(135deg, #1a1a1a 0%, #262626 100%);
border-radius: 24rpx;
padding: 20rpx 0;
border: 1px solid rgba(255, 255, 255, 0.1);
z-index: 1000;
opacity: 1;
transform: translateY(0) scale(1);
backdrop-filter: blur(4px); //
/* 弹出 */
transition: opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94),$elastic;
&.panel-hide {
/* 消失 */
opacity: 0;
transform: translateY(15rpx) scale(0.96);
transition: opacity 0.1s cubic-bezier(0.25, 0.46, 0.45, 0.94),$elastic;
}
&.panel-disabled {
pointer-events: none;
}
}
.button-scroll {
width: 100%;
height: 100%;
white-space: nowrap;
}
.button-list {
display: inline-flex;
align-items: center;
height: 100%;
gap: 20rpx;
padding: 0 20rpx;
}
.button-item {
display: inline-flex;
position: relative;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 90rpx;
height: 100%;
aspect-ratio: 1 ;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 16rpx;
padding: 15rpx;
box-sizing: border-box;
flex-shrink: 0;
transition: $elastic;
border: 1px solid transparent; //
&:active, &.button-hover {
transform: scale(0.94);
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.02); //
filter: brightness(0.9); //
.button-text {
color: #fff;
text-shadow: 0 0 8rpx rgba(255, 255, 255, 0.4); //
}
}
}
.button-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50rpx;
height: 50rpx;
flex-shrink: 0;
transition: $elastic;
}
.button-text {
position: absolute;
bottom: 5%;
left: 0;
right: 0;
text-align: center;
color: #ffffff;
font-size: 22rpx;
font-weight: 500;
text-align: center;
white-space: nowrap;
transition: $elastic;
}
</style>

326
OverlayPage/OverlayPage.vue Normal file
View File

@ -0,0 +1,326 @@
<script setup lang="ts">
import { ref, watch, inject, onUnmounted } from 'vue'
//
let _zSeed = 1000
let _activeCount = 0
// overlay z-index
const _activeZIndices = new Set<number>()
const props = withDefaults(defineProps<{
visible: boolean
title?: string
noHeader?: boolean
/** 点击标题时的回调,传入后标题可点击 */
titleClick?: () => void
/** 为 true 时:默认槽在首次打开后才挂载,关闭后保留 DOM减少未打开 overlay 的重内容渲染 */
lazy?: boolean
}>(), {
title: '',
noHeader: false,
lazy: false
})
const emit = defineEmits<{
/** 打开时触发(当 visible 变为 true 时) */
open: []
/** 关闭时触发(用户主动关闭时,如点击关闭按钮、返回键等) */
close: []
/** 关闭完成时触发(当 visible 变为 false 时,用于生命周期监听) */
closed: []
}>()
// Z-Index
const zIndex = ref(_zSeed)
//
const statusBarHeight = ref(0)
//
const tabRootBackPress = inject<{
register: (h: { check: () => boolean; close: () => void }) => () => void
}>('tabRootBackPress', undefined as any)
let unreg: (() => void) | null = null
//
const isTracked = ref(false)
/** lazy 模式下,首次打开后为 true用于保持 slot 挂载避免重复创建 */
const hasOpenedOnce = ref(false)
/** 关闭动画定时器,用于延迟触发 closed 事件 */
let closeTimer: ReturnType<typeof setTimeout> | null = null
//
/**
* 获取状态栏高度
*/
const getStatusBarHeight = (): number => {
try {
return uni.getSystemInfoSync().statusBarHeight ?? 0
} catch {
return 0
}
}
/**
* 注册到动态页面计数
*/
const registerActive = () => {
if (!isTracked.value) {
isTracked.value = true
_activeCount++
_activeZIndices.add(zIndex.value)
}
}
/**
* 从动态页面计数中注销
*/
const unregisterActive = () => {
if (isTracked.value) {
isTracked.value = false
_activeZIndices.delete(zIndex.value)
if (--_activeCount <= 0) {
_activeCount = 0
_zSeed = 1000
_activeZIndices.clear()
}
}
}
/**
* 获取当前最大的 z-index
*/
const getMaxZIndex = (): number => {
if (_activeZIndices.size === 0) return 0
return Math.max(...Array.from(_activeZIndices))
}
/**
* 注册返回键处理
* 只有当当前 overlay 可见且是最顶层z-index 最高时才响应返回键
*/
const registerBackPress = () => {
if (tabRootBackPress) {
unreg = tabRootBackPress.register({
check: () => {
// overlay true
if (!props.visible) return false;
// z-index overlay z-index
const maxZ = getMaxZIndex()
return zIndex.value === maxZ && maxZ > 0
},
close: handleClose
})
}
}
/**
* 注销返回键处理
*/
const unregisterBackPress = () => {
if (unreg) {
unreg()
unreg = null
}
}
/**
* 处理打开逻辑
*/
const handleOpen = () => {
//
zIndex.value = ++_zSeed
//
registerActive()
//
statusBarHeight.value = getStatusBarHeight()
//
registerBackPress()
//
emit('open')
}
/**
* 处理关闭逻辑
*/
const handleClose = () => {
emit('close')
}
/**
* 处理关闭后的清理
* 延迟触发 closed 事件等待退出动画完成
*/
const handleCloseCleanup = () => {
//
unregisterBackPress()
//
unregisterActive()
//
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
// closed
closeTimer = setTimeout(() => {
closeTimer = null
emit('closed')
}, 600)
}
// visible
watch(() => props.visible, (newVal, oldVal) => {
if (newVal === oldVal) return
if (newVal) {
//
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
if (props.lazy) hasOpenedOnce.value = true
handleOpen()
} else {
handleCloseCleanup()
}
})
//
onUnmounted(() => {
unregisterBackPress()
unregisterActive()
//
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
})
//
const close = handleClose
</script>
<template>
<view class="ov-page" :class="{ 'ov-page--visible': visible }" :style="{ zIndex }">
<!-- 遮罩: 屏蔽下层触摸 -->
<view
v-show="visible"
class="ov-page__backdrop"
:class="{ 'ov-page__backdrop--visible': visible }"
@touchmove.stop.prevent
@touchstart.stop
@touchend.stop
/>
<!-- 滑入面板 -->
<view class="ov-page__panel" :class="{ 'ov-page__panel--in': visible }">
<!-- 顶栏 (noHeader 关闭 / #header 完全替换 / #header-right 自定义右侧) -->
<template v-if="!noHeader">
<slot name="header" :close="close" :statusBarHeight="statusBarHeight">
<view class="ov-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="ov-header__inner">
<view class="ov-header__back" @click="close">
<wd-icon name="thin-arrow-left" size="32rpx" color="var(--dv-text)" />
</view>
<view
v-if="titleClick"
class="ov-header__title ov-header__title--clickable"
@click="titleClick"
>{{ title }}</view>
<text v-else class="ov-header__title">{{ title }}</text>
<view class="ov-header__right">
<slot name="header-right" />
</view>
</view>
</view>
</slot>
</template>
<!-- 主内容lazy 时首次打开后才挂载减少未打开 overlay 的重内容 -->
<template v-if="!lazy || visible || hasOpenedOnce">
<slot />
</template>
</view>
<!-- 额外内容插槽用于放置嵌套 overlay避免 transform 影响 fixed 定位 -->
<slot name="extra" />
</view>
</template>
<style lang="scss" scoped>
.ov-page {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
}
.ov-page--visible { pointer-events: auto; }
.ov-page__backdrop {
position: absolute;
inset: 0;
z-index: 0;
opacity: 0;
transition: opacity .1s cubic-bezier(.22, .61, .36, 1);
}
.ov-page__backdrop--visible {
opacity: 1;
transition: opacity .1s cubic-bezier(.22, .61, .36, 1);
}
.ov-page__panel {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
background: var(--dv-bg);
transform: translateX(100%);
opacity: 0;
filter: blur(20rpx);
box-shadow: -8rpx 0 24rpx rgba(0, 0, 0, 0.15);
transition: transform .3s cubic-bezier(.22, .61, .36, 1), opacity .6s cubic-bezier(.22, .61, .36, 1), filter .6s cubic-bezier(.22, .61, .36, 1);
will-change: transform, opacity, filter;
}
.ov-page__panel--in {
transform: translateX(0);
opacity: 1;
filter: blur(0);
transition: transform .3s cubic-bezier(.22, .61, .36, 1), opacity .25s cubic-bezier(.22, .61, .36, 1), filter .25s cubic-bezier(.22, .61, .36, 1);
}
.ov-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--dv-bg);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.ov-header__inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 24rpx;
}
.ov-header__back {
width: 72rpx;
display: flex;
align-items: center;
padding: 8rpx;
}
.ov-header__title {
flex: 1;
text-align: center;
font-size: var(--dv-font-title);
font-weight: 600;
color: var(--dv-text);
}
.ov-header__title--clickable {
cursor: pointer;
}
.ov-header__right {
width: 88rpx;
display: flex;
align-items: center;
justify-content: flex-end;
}
</style>

305
PullRefresh/PullRefresh.vue Normal file
View File

@ -0,0 +1,305 @@
<template>
<view class="pull-refresh" :style="wrapStyle">
<view
class="pull-refresh__zone"
:class="{ 'pull-refresh__zone--no-transition': pulling }"
:style="refreshZoneStyle"
>
<view
class="pull-refresh__circle-wrap"
:class="{ 'pull-refresh__circle-wrap--no-transition': pulling }"
:style="circleWrapStyle"
>
<view class="pull-refresh__circle" :style="circleInnerStyle" />
</view>
</view>
<scroll-view
:class="['pull-refresh__scroll', scrollClass]"
:scroll-y="scrollEnabled && !pulling"
:show-scrollbar="showScrollbar"
:scroll-with-animation="false"
:lower-threshold="lowerThreshold"
@scroll="onScroll"
@touchstart="onPullStart"
@touchmove="onPullMove"
@touchend="onPullEnd"
@touchcancel="onPullEnd"
@scrolltolower="onScrollToLower"
>
<slot />
<!-- 底部加载状态footerMode 时显示预留 footerPaddingBottom 避免被底栏/安全区遮挡 -->
<view
v-if="footerMode"
class="pull-refresh__footer"
:style="footerStyle"
>
<view v-if="loading && !isRefreshing && !(noEmptyFooter && listLength === 0)" class="pull-refresh__footer-text">加载中...</view>
<view v-else-if="noMore && listLength > 0" class="pull-refresh__footer-text">已经到底啦</view>
<view v-else-if="listLength === 0 && loaded && !noEmptyFooter" class="pull-refresh__footer-text">还没有动态</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
/**
* PullRefresh 下拉刷新
* 用法当成Scroll组件用然后包住内容监听 @refresh 拉取数据拉完后设 :is-refreshing="false"
* pullMax 下拉最大 pxcircleOffsetStart / circleOffsetEnd 圆圈起止位置
*/
import { ref, computed, watch, onUnmounted } from 'vue'
const props = withDefaults(
defineProps<{
isRefreshing?: boolean
scrollClass?: string
showScrollbar?: boolean
height?: string
/** 下拉最多允许超出多少 px超过后刷新圈不再继续往下拉默认 160 */
pullMax?: number
circleOffsetStart?: string
circleOffsetEnd?: string
/** 启用底部加载状态 UI加载中/已经到底啦/还没有动态) */
footerMode?: boolean
/** 触底加载中 */
loading?: boolean
/** 已加载完,无更多 */
noMore?: boolean
/** 列表长度,用于空态判断 */
listLength?: number
/** 首屏已加载完成,用于空态判断 */
loaded?: boolean
/** 触底检测距离px默认 100 */
lowerThreshold?: number
/** 底部预留(如 tab 栏+安全区),避免 footer 文字被遮挡,如 "calc(120rpx + env(safe-area-inset-bottom))" */
footerPaddingBottom?: string
/** 为 true 时不显示「还没有动态」空态(用于详情页等自有空态的场景) */
noEmptyFooter?: boolean
/** 是否允许纵向滚动 */
scrollEnabled?: boolean
}>(),
{
isRefreshing: false,
scrollClass: '',
showScrollbar: false,
height: '100%',
scrollEnabled: true,
pullMax: 60,
circleOffsetStart: '0rpx',
circleOffsetEnd: '36rpx',
footerMode: false,
loading: false,
noMore: false,
listLength: 0,
loaded: true,
lowerThreshold: 100,
footerPaddingBottom: '0rpx',
noEmptyFooter: false,
}
)
const emit = defineEmits<{
(e: 'refresh'): void
(e: 'scroll', ev: any): void
(e: 'scrolltolower', ev: any): void
(e: 'touchstart'): void
(e: 'touchend'): void
}>()
// --- ---
const CONFIG = {
PULL_TRIGGER: 56,
PULL_DAMPING: 0.82,
ZONE_HEIGHT_PX: 50,
LERP_FACTOR: 0.62,
SPIN_DURATION_MS: 700,
SPIN_INTERVAL_MS: 16,
PULL_CAP: 500,
/** 下拉时旋转阻尼0~1越小拉同样距离转得越少默认 0.35(约拉满 pullMax 转 1/3 圈) */
ROTATION_DAMPING: 0.35
} as const
const SPIN_STEP = (360 / CONFIG.SPIN_DURATION_MS) * CONFIG.SPIN_INTERVAL_MS
// --- ---
const scrollTop = ref(0)
const pullDistance = ref(0)
const pulling = ref(false)
const refreshStartRotation = ref(0)
const spinDeg = ref(0)
let touchStartY = 0
let touchStartScrollTop = 0
let spinTimerId: number | ReturnType<typeof setInterval> = 0
// --- ---
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
const getTouchY = (e: any): number | null => e.touches?.[0]?.clientY ?? null
function clearSpinTimer() {
if (spinTimerId) {
clearInterval(spinTimerId)
spinTimerId = 0
}
}
// --- ---
const wrapStyle = computed(() => ({ height: props.height }))
/** 展示进度:按实际下拉量 / pullMax可>1用于缩放/透明度/旋转(位移单独用 pullDistance capped */
const revealProgress = computed(() => {
if (props.isRefreshing) return 1
return pullDistance.value / Math.max(1, props.pullMax)
})
/** 位移在 pullMax 处封顶,再往下拉只动动画不动位置 */
const refreshZoneStyle = computed(() => {
const displacement = Math.min(pullDistance.value, props.pullMax)
const y = props.isRefreshing ? 0 : -CONFIG.ZONE_HEIGHT_PX + displacement
return { transform: `translateY(${y}px)` }
})
const circleWrapStyle = computed(() => {
const p = revealProgress.value
return {
marginTop: props.isRefreshing ? props.circleOffsetEnd : props.circleOffsetStart,
transform: `scale(${Math.min(1, 0.3 + p * 0.7)})`,
opacity: Math.min(1, p),
transformOrigin: 'center center'
}
})
const circleInnerStyle = computed(() => {
const deg = props.isRefreshing
? spinDeg.value
: revealProgress.value * 360 * CONFIG.ROTATION_DAMPING
return { transform: `rotate(${deg}deg)` }
})
const footerStyle = computed(() =>
props.footerPaddingBottom ? { paddingBottom: props.footerPaddingBottom } : {}
)
// --- ---
function onPullStart(e: any) {
emit('touchstart')
const y = getTouchY(e)
if (y == null) return
touchStartY = y
touchStartScrollTop = scrollTop.value
pulling.value = false
}
function onPullMove(e: any) {
const y = getTouchY(e)
if (y == null) return
const dy = y - touchStartY
if (touchStartScrollTop > 0 || dy <= 0) return
pulling.value = true
e.preventDefault?.()
const target = Math.min(CONFIG.PULL_CAP, dy * CONFIG.PULL_DAMPING)
pullDistance.value = lerp(pullDistance.value, target, CONFIG.LERP_FACTOR)
}
function onPullEnd() {
emit('touchend')
pulling.value = false
if (pullDistance.value >= CONFIG.PULL_TRIGGER) {
refreshStartRotation.value =
(pullDistance.value / Math.max(1, props.pullMax)) * 360 * CONFIG.ROTATION_DAMPING
emit('refresh')
} else {
pullDistance.value = 0
}
}
function onScroll(e: any) {
scrollTop.value = e?.detail?.scrollTop ?? 0
emit('scroll', e)
}
function onScrollToLower(e: any) {
emit('scrolltolower', e)
}
// --- ---
watch(
() => props.isRefreshing,
(cur, prev) => {
if (cur && !prev) {
spinDeg.value = refreshStartRotation.value
spinTimerId = setInterval(() => {
spinDeg.value += SPIN_STEP
}, CONFIG.SPIN_INTERVAL_MS)
} else if (!cur && prev) {
clearSpinTimer()
pullDistance.value = 0
}
}
)
onUnmounted(clearSpinTimer)
</script>
<style lang="scss" scoped>
.pull-refresh {
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.pull-refresh__zone {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100rpx;
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 10;
pointer-events: none;
transition: transform 0.25s ease-out;
&--no-transition {
transition: none;
}
}
.pull-refresh__circle-wrap {
transition: opacity 0.2s ease, margin-top 0.25s ease-out, transform 0.2s ease-out;
&--no-transition {
transition: none;
}
}
.pull-refresh__circle {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 6rpx solid var(--dv-border);
border-top-color: var(--dv-accent);
}
.pull-refresh__scroll {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
background-color: var(--dv-bg);
}
.pull-refresh__footer {
text-align: center;
position: relative;
z-index: 0;
}
.pull-refresh__footer-text {
font-size: var(--dv-font-muted);
color: var(--dv-text-muted);
}
</style>

671
VideoViewer/VideoViewer.vue Normal file
View File

@ -0,0 +1,671 @@
<template>
<view
v-if="visible"
class="vv-root"
:class="{ 'vv-root--closing': animPhase === 'exiting' }"
:style="{ ...rootStyle, ...animationStyles.root }"
@click="animPhase !== 'exiting' && onOverlayClick()"
>
<view
class="vv-backdrop"
:class="{ 'vv-backdrop--expanded': expanded, 'vv-backdrop--closing': animPhase === 'exiting' }"
:style="animationStyles.backdrop"
@click.stop
/>
<view
class="vv-transform-wrap"
:style="{ ...transformWrapStyle, ...animationStyles.transformWrap }"
@click.stop="animPhase !== 'exiting' && requestClose()"
>
<!-- 结构稳如磐石不再依据 expanded 动态销毁彻底根除闪屏 -->
<view
v-if="showSingleContent"
class="vv-pinch-wrap"
:style="pinchWrapStyle"
>
<!-- 封面图坚挺防线直到视频放出第一帧才隐退 -->
<image
v-if="currentItem?.thumb_url && !isVideoStarted"
class="vv-thumb"
:src="currentItem.thumb_url"
mode="aspectFit"
/>
<!-- 视频常驻 DOM但用隐藏类名代替销毁 -->
<video
:id="getVideoId(currentIndex)"
class="vv-video"
:class="{ 'vv-video--hide': animPhase !== 'displaying' }"
:src="currentItem?.media_url"
object-fit="contain"
:autoplay="false"
playsinline="true"
webkit-playsinline="true"
x5-playsinline="true"
x5-video-player-type="h5"
x5-video-player-fullscreen="false"
:controls="false"
:show-center-play-btn="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-progress="false"
:enable-progress-gesture="false"
:loop="false"
:muted="false"
@play="onVideoPlay"
@pause="onVideoPause"
@ended="onVideoEnded"
@waiting="onVideoWaiting"
@error="onVideoError"
/>
</view>
<view v-if="showSwiper" class="vv-slides-view">
<view class="vv-slides-track" :style="slidesTrackStyle">
<view
v-for="(item, i) in items"
:key="i"
class="vv-slide"
:style="{ width: slideWidthPx + 'px' }"
v-show="isSlideVisible(i)"
@touchstart="onSlideTouchStart"
@touchmove="onSlideTouchMove"
@touchend="onSlideTouchEnd"
@touchcancel="onSlideTouchEnd"
>
<view class="vv-pinch-wrap">
<image
v-if="item?.thumb_url && (i !== currentIndex || !isVideoStarted)"
class="vv-thumb"
:src="item.thumb_url"
mode="aspectFit"
/>
<video
v-if="i === currentIndex"
:id="getVideoId(i)"
class="vv-video"
:class="{ 'vv-video--hide': animPhase !== 'displaying' }"
:src="item.media_url"
object-fit="contain"
:autoplay="false"
playsinline="true"
webkit-playsinline="true"
x5-playsinline="true"
x5-video-player-type="h5"
x5-video-player-fullscreen="false"
:controls="false"
:show-center-play-btn="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-progress="false"
:enable-progress-gesture="false"
:loop="false"
@play="onVideoPlay"
@pause="onVideoPause"
@ended="onVideoEnded"
@waiting="onVideoWaiting"
@error="onVideoError"
/>
</view>
</view>
</view>
</view>
<view v-show="showLoading" class="vv-loading">
<view class="vv-loading-spinner"></view>
</view>
</view>
<view v-show="showSwiper" class="vv-dots-wrap" @click.stop="requestClose">
<view class="vv-dots">
<text class="vv-dots__text">{{ currentIndex + 1 }} / {{ items.length }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, inject, nextTick, onUnmounted, shallowRef } from 'vue'
export type InitialRect = { left: number; top: number; width: number; height: number }
export type VideoItem = { media_url: string; thumb_url?: string }
const SLIDE_DRAG_THRESHOLD = 60
const SLIDE_ANIMATION_MS = 200
const SLIDE_EASE = 'cubic-bezier(0.16, 1, 0.3, 1)'
const SLIDE_DRAG_FACTOR = 1.35
const OPEN_BLUR_S = 0.25
const OPEN_ANIMATION_EASE = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
const EXIT_BLUR_S = 0.25
const props = withDefaults(
defineProps<{
visible: boolean
items: VideoItem[]
current?: number
initialRect?: InitialRect | null
zIndex?: number
}>(),
{ current: 0, initialRect: null, zIndex: 9999 }
)
const emit = defineEmits<{ close: [] }>()
const tabRootBackPress = inject<{
register: (h: { check: () => boolean; close: () => void }) => () => void
}>('tabRootBackPress', undefined as any)
type AnimPhase = 'entering' | 'displaying' | 'exiting'
const animPhase = ref<AnimPhase | null>(null)
const enterPaintReady = ref(false)
const enterTargetPainted = ref(false)
let enterCompleteTimer: ReturnType<typeof setTimeout> | null = null
let exitCompleteTimer: ReturnType<typeof setTimeout> | null = null
const expanded = ref(false)
const currentIndex = ref(0)
const windowSize = ref({ width: 375, height: 667 })
const isVideoPlaying = ref(false)
const isVideoLoading = ref(false)
const isVideoStarted = ref(false)
const videoContextRef = shallowRef<UniApp.VideoContext | null>(null)
let unregisterBack: (() => void) | null = null
let videoContextMap = new Map<number, UniApp.VideoContext>()
const uid = Date.now()
function getVideoId(index: number): string {
return `vv-video-${uid}-${index}`
}
function getVideoContext(index: number): UniApp.VideoContext | null {
if (!videoContextMap.has(index)) {
const id = getVideoId(index)
videoContextMap.set(index, uni.createVideoContext(id))
}
return videoContextMap.get(index) || null
}
const showLoading = computed(() => isVideoLoading.value && !isVideoPlaying.value)
function clearAnimTimers() {
if (enterCompleteTimer) {
clearTimeout(enterCompleteTimer)
enterCompleteTimer = null
}
if (exitCompleteTimer) {
clearTimeout(exitCompleteTimer)
exitCompleteTimer = null
}
}
function nextFrame(fn: () => void) {
if (typeof requestAnimationFrame !== 'undefined') requestAnimationFrame(fn)
else setTimeout(fn, 0)
}
function startEnterPhase() {
animPhase.value = 'entering'
enterPaintReady.value = false
enterTargetPainted.value = false
clearAnimTimers()
nextFrame(() => {
enterPaintReady.value = true
nextFrame(() => { enterTargetPainted.value = true })
})
enterCompleteTimer = setTimeout(() => {
enterCompleteTimer = null
animPhase.value = 'displaying'
//
playCurrentVideo()
}, OPEN_BLUR_S * 1000)
}
function startExitPhase(onComplete: () => void) {
animPhase.value = 'exiting'
clearAnimTimers()
exitCompleteTimer = setTimeout(() => {
exitCompleteTimer = null
onComplete()
}, EXIT_BLUR_S * 1000)
}
function playCurrentVideo() {
nextTick(() => {
const ctx = getVideoContext(currentIndex.value)
if (ctx) {
ctx.play()
}
})
}
function stopAllVideo() {
videoContextMap.forEach(ctx => {
if (ctx) {
try { ctx.pause() } catch {}
}
})
}
function onVideoPlay() {
isVideoPlaying.value = true
isVideoLoading.value = false
isVideoStarted.value = true
}
function onVideoPause() {
isVideoPlaying.value = false
}
function onVideoEnded() {
isVideoPlaying.value = false
}
function onVideoWaiting() {
isVideoLoading.value = true
}
function onVideoError(e: any) {
console.error('[VideoViewer] 视频播放错误:', e)
isVideoLoading.value = false
isVideoPlaying.value = false
}
const dragOffsetPx = ref(0)
const slideTransitionOn = ref(false)
let panStart: { startX: number; baseDragOffset: number } | null = null
const slideWidthPx = computed(() => windowSize.value.width)
const slidesTrackStyle = computed(() => {
const w = windowSize.value.width
const n = props.items?.length ?? 0
const tx = n <= 0 ? 0 : -currentIndex.value * w + dragOffsetPx.value
return {
width: `${n * w}px`,
transform: `translateX(${tx}px)`,
transition: slideTransitionOn.value ? `transform ${SLIDE_ANIMATION_MS}ms ${SLIDE_EASE}` : 'none'
}
})
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v))
}
function isSlideVisible(i: number) {
const c = currentIndex.value
return i >= c - 1 && i <= c + 1
}
const pinchWrapStyle = computed(() => ({ transform: 'translate(0, 0) scale(1)' }))
function handleSlideDrag(t: { clientX: number }) {
if (!panStart) return
const dx = t.clientX - panStart.startX
const w = windowSize.value.width
dragOffsetPx.value = clamp((panStart.baseDragOffset ?? 0) + dx * SLIDE_DRAG_FACTOR, -w, w)
}
function goToSlide(delta: -1 | 1) {
const n = props.items?.length || 0
if (n <= 1) return
const oldIdx = currentIndex.value
const nextIdx = delta < 0 ? Math.max(0, oldIdx - 1) : Math.min(n - 1, oldIdx + 1)
if (nextIdx === oldIdx) return
stopAllVideo()
isVideoPlaying.value = false
isVideoStarted.value = false
currentIndex.value = nextIdx
slideTransitionOn.value = true
dragOffsetPx.value = 0
setTimeout(() => {
slideTransitionOn.value = false
if (animPhase.value === 'displaying') {
playCurrentVideo()
}
}, SLIDE_ANIMATION_MS)
}
function onSlideTouchStart(e: any) {
if (e.touches.length === 1) {
panStart = { startX: e.touches[0].clientX, baseDragOffset: dragOffsetPx.value }
}
}
function onSlideTouchMove(e: any) {
if (e.touches.length === 1 && panStart) {
e.preventDefault()
handleSlideDrag(e.touches[0])
}
}
function onSlideTouchEnd(e: any) {
if (e.touches.length === 0) {
const n = props.items?.length ?? 0
if (n <= 1) { panStart = null; return }
const th = SLIDE_DRAG_THRESHOLD
if (dragOffsetPx.value > th && currentIndex.value > 0) goToSlide(-1)
else if (dragOffsetPx.value < -th && currentIndex.value < n - 1) goToSlide(1)
else {
slideTransitionOn.value = true
dragOffsetPx.value = 0
setTimeout(() => { slideTransitionOn.value = false }, SLIDE_ANIMATION_MS)
}
panStart = null
} else if (e.touches.length === 1 && panStart) {
panStart = { ...panStart, baseDragOffset: dragOffsetPx.value }
}
}
const isMulti = computed(() => (props.items?.length || 0) > 1)
const currentItem = computed(() => {
const list = props.items || []
const i = Math.max(0, Math.min(currentIndex.value, list.length - 1))
return list[i] || null
})
// OS
const showSwiper = computed(() => isMulti.value)
const showSingleContent = computed(() => !isMulti.value)
const rootStyle = computed(() => ({ zIndex: props.zIndex }))
const animationStyles = computed(() => {
const phase = animPhase.value
const transOpen = `opacity ${OPEN_BLUR_S}s ${OPEN_ANIMATION_EASE}`
const transExit = `opacity ${EXIT_BLUR_S}s ease-out`
if (phase === 'exiting') {
return {
root: { transition: transExit, opacity: 0, pointerEvents: 'none' as const },
backdrop: { transition: `opacity ${EXIT_BLUR_S}s ease-out`, opacity: 0 },
transformWrap: { transition: transExit, opacity: 0, pointerEvents: 'none' as const }
}
}
if (phase === 'entering' || phase === 'displaying') {
const applyTarget = phase === 'displaying' || (phase === 'entering' && enterPaintReady.value)
if (!applyTarget) {
return { root: { opacity: 0 }, backdrop: { opacity: 0 }, transformWrap: { opacity: 0 } }
}
return {
root: { transition: transOpen, opacity: 1 },
backdrop: { transition: transOpen, opacity: 1 },
transformWrap: { transition: transOpen, opacity: 1 }
}
}
return { root: {}, backdrop: {}, transformWrap: {} }
})
const transformWrapStyle = computed(() => {
return {
left: '0',
top: '0',
right: '0',
bottom: '0',
width: '100%',
height: '100%',
opacity: 0
}
})
function registerBack() {
if (tabRootBackPress) {
unregisterBack = tabRootBackPress.register({
check: () => props.visible,
close
})
}
}
function unregisterBackHandler() {
if (unregisterBack) {
unregisterBack()
unregisterBack = null
}
}
function onOverlayClick() {
requestClose()
}
function requestClose() {
if (animPhase.value === 'exiting') return
stopAllVideo()
isVideoPlaying.value = false
isVideoLoading.value = false
isVideoStarted.value = false
nextFrame(() => {
startExitPhase(() => {
expanded.value = false
animPhase.value = null
emit('close')
})
})
}
function close() {
requestClose()
}
watch(
() => props.visible,
(v) => {
if (v) {
try {
const sys = uni.getSystemInfoSync()
windowSize.value = {
width: sys.windowWidth ?? sys.screenWidth ?? 375,
height: sys.windowHeight ?? sys.screenHeight ?? 667
}
} catch {
windowSize.value = { width: 375, height: 667 }
}
currentIndex.value = Math.max(0, Math.min(props.current, (props.items?.length || 1) - 1))
isVideoPlaying.value = false
isVideoLoading.value = false
isVideoStarted.value = false
videoContextMap.clear()
expanded.value = !props.initialRect || props.initialRect.width <= 0 || props.initialRect.height <= 0
registerBack()
if (props.initialRect && props.initialRect.width > 0 && props.initialRect.height > 0) {
setTimeout(() => {
expanded.value = true
startEnterPhase()
}, 50)
} else {
expanded.value = true
startEnterPhase()
}
} else {
expanded.value = false
animPhase.value = null
enterPaintReady.value = false
enterTargetPainted.value = false
isVideoPlaying.value = false
isVideoLoading.value = false
isVideoStarted.value = false
clearAnimTimers()
dragOffsetPx.value = 0
slideTransitionOn.value = false
panStart = null
stopAllVideo()
videoContextMap.clear()
unregisterBackHandler()
}
},
{ immediate: true }
)
watch(() => props.current, (v) => {
currentIndex.value = Math.max(0, Math.min(v, (props.items?.length || 1) - 1))
})
onUnmounted(() => {
clearAnimTimers()
unregisterBackHandler()
stopAllVideo()
videoContextMap.clear()
})
</script>
<style scoped>
.vv-root {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.vv-root--closing {
opacity: 0;
}
.vv-backdrop {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.3s ease;
}
.vv-backdrop--expanded {
background: rgba(0, 0, 0, 0.94);
opacity: 1;
}
.vv-backdrop--closing {
opacity: 0;
}
.vv-transform-wrap {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
z-index: 1;
}
.vv-pinch-wrap {
width: 100%;
height: 100%;
position: relative;
display: block;
touch-action: none;
}
.vv-video {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
z-index: 1;
}
/* ✨ 修复:进入和退出期间,利用极端的缩放和位移将 video 丢出屏幕 */
.vv-video--hide {
transform: translateX(-9999px) scale(0.001);
opacity: 0;
pointer-events: none;
}
.vv-thumb {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
z-index: 2;
}
.vv-slides-view {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.vv-slides-track {
display: flex;
height: 100%;
flex-shrink: 0;
will-change: transform;
}
.vv-slide {
flex: 0 0 auto;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.vv-dots-wrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
pointer-events: none;
}
.vv-dots {
display: flex;
justify-content: center;
}
.vv-dots__text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
background: rgba(0, 0, 0, 0.4);
padding: 8rpx 24rpx;
border-radius: 24rpx;
}
.vv-loading {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10;
pointer-events: none;
}
.vv-loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: vv-spin 0.8s linear infinite;
}
@keyframes vv-spin {
to { transform: rotate(360deg); }
}
</style>