[入库]初次入库各种工具
- DBManager DB创建管理器 - FormatTimeTool - ImageViewer工具 - InputArear工具 - OverlayPage工具 - 下拉刷新容器工具 - 视频查看器工具
This commit is contained in:
commit
3ab1f2db24
487
CacheManager.ts
Normal file
487
CacheManager.ts
Normal 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;
|
||||
}
|
||||
|
||||
// 下载完立即用 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<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================= 导出单例 ======================= */
|
||||
|
||||
export default new CacheManager();
|
||||
349
DBManager/DBManager.ts
Normal file
349
DBManager/DBManager.ts
Normal 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 [];
|
||||
|
||||
// 过滤掉无效的 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<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)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
216
DBManager/DBManagerReadme.md
Normal file
216
DBManager/DBManagerReadme.md
Normal 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
119
FormatTimeTool.ts
Normal 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
1126
ImageViewer/ImageViewer.vue
Normal file
File diff suppressed because it is too large
Load Diff
742
InputArea/InputArea.vue
Normal file
742
InputArea/InputArea.vue
Normal 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
155
InputArea/Readme.md
Normal file
@ -0,0 +1,155 @@
|
||||
Make By. RainyTears
|
||||
基于 Vue移动端打造的「悬浮面板 + 输入框」
|
||||
```sh
|
||||
├── floatPanel.vue # 横向滚动按钮组 / 插槽面板
|
||||
└── InputArea.vue # 语音/文字双模式输入容器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 特性总览
|
||||
|
||||
| 层级 | 可插拔 | |
|
||||
| ------ | -------------------------------------- | --- |
|
||||
| **外层** | `InputArea.vue` 负责长录音、键盘切换、主题色、v-model | |
|
||||
| **面板** | `floatPanel.vue` 提供按钮渲染 + 两种插槽,零逻辑依赖 | |
|
||||
|
||||
> ✅ 支持 uni-app、hbuilder、vue3+setup
|
||||
|
||||
---
|
||||
|
||||
## 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. Props(index.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. Events(index.Vue)
|
||||
|
||||
| 事件 | 参数 | 说明 |
|
||||
| ----------------- | ---------------------------- | -------------- |
|
||||
| update:modelValue | text:String | 文本变化 |
|
||||
| update:showPanel | show:Boolean | 面板显隐变化 |
|
||||
| LongPressStart | - | 开始长按输入框(可用于录音) |
|
||||
| LongPressEnd | - | 正常松手结束 |
|
||||
| LongPressCancel | - | 上滑取消 |
|
||||
| Send | {type:'text',content:String} | 点击发送触发 |
|
||||
| PanelClick | {button,index} | 面板按钮/插槽点击 |
|
||||
| LeftButtonClick | - | 左侧按钮触发 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Slots — 高自由度
|
||||
|
||||
| 插槽 | 作用域 | 用法 |
|
||||
| ------------------------------ | -------------- | ------------------------ |
|
||||
| panel-content(index.Vue) | - | **外层整体替换面板**,常用于自定义复杂 ui |
|
||||
| -(floatPanel.Vue 默认插槽) | - | **完全覆盖按钮区**,保留滚动和动效骨架 |
|
||||
| button-content(floatPanel.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
182
InputArea/floatPanel.vue
Normal 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
326
OverlayPage/OverlayPage.vue
Normal 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
305
PullRefresh/PullRefresh.vue
Normal 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 下拉最大 px;circleOffsetStart / 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
671
VideoViewer/VideoViewer.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user