整体流程
scss
用户操作
│
▼
┌───────────────┐ ┌──────────────┐ ┌────────────────┐ ┌───────────────────┐ ┌──────────────┐
│ 选择文件夹 │────▶│ 解析视频 │────▶│ 存入IndexedDB │────▶│ 后台自动上传 │────▶│ 发送通知 │
└───────────────┘ └──────────────┘ └────────────────┘ └───────────────────┘ └──────────────┘
│ │ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 全程进度实时反馈系统 │
│ (进度条/百分比/状态提示) │
└───────────────────────────────────────────────────────────────────────────────────────────────────────────┘
IndexedDB 存储封装
ini
/* global IDBKeyRange */
import { useState, useCallback, useEffect } from 'react';
import Debug from 'debug';
import { openDB } from 'idb';
import mitt from 'mitt';
const debug = Debug('admin:idbStore');
export default class IdbStore {
//初始化 IndexedDB 连接和缓存系统
constructor({ dbName, storeName }) {
this.emitter = mitt(); // 事件总线
this.cache = Object.create(null); // 内存缓存
this.storeName = storeName; // 存储表名
this.dbName = dbName; // 数据库名
this.dbPromise = openDB(dbName, 1, { // IndexedDB 连接
upgrade(db) {
const s = db.createObjectStore(storeName, {
keyPath: 'key',
});
s.createIndex('expiredAt', 'expiredAt');
},
});
setTimeout(() => {
this.clean(); // 延迟清理过期数据
}, 10000);
}
// 清理缓存
async clean() {
const {
dbName, storeName, dbPromise, cache,
} = this;
debug('clean idb ', dbName, storeName);
const db = await dbPromise;
const keyRangeValue = IDBKeyRange.upperBound(Date.now());
let cursor = await db.transaction(storeName, 'readwrite')
.objectStore(storeName)
.index('expiredAt')
.openCursor(keyRangeValue);
while (cursor) {
debug('delete', cursor.key, cursor);
cursor.delete();
if (cache[cursor.value.key] && cache[cursor.value.key].expiredAt === cursor.value.expiredAt) {
delete cache[cursor.value.key];
}
// eslint-disable-next-line no-await-in-loop
cursor = await cursor.continue();
}
setTimeout(() => {
this.clean();
}, 120000);
}
useStore(key, fn, expire = 1000) {
const { emitter } = this;
const [state, setState] = useState(() => this.cache[key] || {});
const onChange = useCallback((dataInfo) => {
debug('setData', dataInfo);
setState(dataInfo);
}, []);
useEffect(() => {
if (fn) {
this.getAsync(key, fn, expire);
}
emitter.on(`change:${key}`, onChange);
return () => emitter.off(`change:${key}`, onChange);
}, []);
debug('useStore', state);
return {
loading: !state || !!state.dataPromise,
data: state.data,
};
}
// 写入数据
async setData(key, data, expire = 1000) {
const db = await this.dbPromise;
const info = {
key,
data,
};
if (expire !== -1) {
info.expiredAt = Date.now() + expire;
}
this.cache[key] = info;
db.put(this.storeName, info);
debug(`change:${key}`, info);
this.emitter.emit(`change:${key}`, info);
}
getNotExpireData(info, expire) {
if (!info) {
return undefined;
}
if (expire === -1 || !info.expiredAt || info.expiredAt > Date.now()) {
return info.data || info.dataPromise;
}
return undefined;
}
// 读取数据
async getAsync(key, fn, expire = 1000) {
const {
cache, dbPromise, storeName,
} = this;
let info = cache[key];
const cacheInfoData = this.getNotExpireData(info, expire);
if (cacheInfoData !== undefined) {
return cacheInfoData;
}
info = {};
if (expire !== -1) {
info.expiredAt = Date.now() + expire;
}
info.dataPromise = dbPromise.then(async (db) => {
debug('getDataFromDB', key);
info = await db.get(storeName, key);
const cacheData = this.getNotExpireData(info, expire);
if (cacheData !== undefined) {
cache[key] = info;
this.emitter.emit(`change:${key}`, info);
return cacheData;
}
if (!fn) {
return undefined;
}
debug('getDataFromFn', key);
return fn().then((data) => {
this.setData(key, data, expire);
return data;
}).catch((err) => {
if (cache[key]) {
delete cache[key];
}
return Promise.resolve(err);
});
});
cache[key] = info;
return info.dataPromise;
}
}
Service Worker 注册与通知
ini
/* global window, Notification */
/* eslint-disable */
import rest from '@/services/rest';
import { idbStore } from '@/utils/store';
import { multiPartUploadToTOS } from '@/services/tos';
import { loadVideoSignature } from '@/utils/video';
import { formatDate } from '@/filter/date';
import moment from 'moment';
import eventBus from 'utils/eventBus';
import { uploadImgByVideoUrl } from '@/utils/common';
import { getHandle } from '@/utils/file';
const getFileByName = async (fileName) => {
try {
const savedHandle = await getHandle('videoFolder');
if (!savedHandle) {
return null;
}
const permission = await savedHandle.queryPermission({ mode: 'read' });
if (permission !== 'granted') {
return null;
}
let foundFile = null;
async function traverse(dir) {
for await (const entry of dir.values()) {
if (foundFile)
return;
if (entry.kind === 'file') {
if (entry.name === fileName) {
foundFile = await entry.getFile();
return;
}
} else if (entry.kind === 'directory') {
await traverse(entry);
}
}
}
await traverse(savedHandle);
return foundFile;
} catch (error) {
console.error('获取文件失败:', error);
return null;
}
};
const getVideoFileInfo = file => new Promise((resolve) => {
const url = window.URL.createObjectURL(file);
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
window.URL.revokeObjectURL(url);
resolve({
width: video.videoWidth,
height: video.videoHeight,
videoSize: file.size,
duration: video.duration,
});
};
video.onerror = () => {
window.URL.revokeObjectURL(url);
resolve({
width: 0,
height: 0,
videoSize: file.size,
duration: 0,
});
};
video.src = url;
});
class BatchUploadVideosWorker {
constructor() {
this.registration = null;
this.timer = null;
this.isRunning = false;
}
// 注册 Service Worker
async register() {
if (!('serviceWorker' in window.navigator)) {
console.warn('Service Worker 不支持');
return false;
}
try {
this.registration = await window.navigator.serviceWorker.register('/sw.js');
// 等待 Service Worker 激活
return true;
} catch (error) {
console.error('Service Worker 注册失败:', error);
return false;
}
}
// 发送通知
async showNotification(notificationData) {
if (!this.registration) {
console.warn('Service Worker 未注册');
return;
}
const { fileName, title } = notificationData;
this.registration.showNotification(title, {
body: fileName,
icon: '/favicon.ico',
requireInteraction: true,
data: {
type: 'upload_complete',
url: '#/admin/MuVideo',
},
});
}
// 检查并发送通知
// 检查并发送通知
async checkAndSendNotifications() {
const cacheVideoData = await idbStore.getAsync('MuVideo/videoFiles');
if (!cacheVideoData?.videoInfos?.length) {
stopBatchUploadVideosWorker();
return;
}
let completedCount = 0;
let repeatCount = 0;
let failedCount = 0;
const totalCount = cacheVideoData.videoInfos.length;
// 创建新数组来存储未处理完的文件
const remainingInfos = [...cacheVideoData.videoInfos];
for (let i = 0; i < remainingInfos.length; i++) {
const videoInfo = remainingInfos[i];
const {
name, seq, normalProductName, editorName, editorOneName,
editorTwoName,editType, platforms, createTime,
editMonth, editTimes, editorSuffix, extendOne, extendTwo,
fileName,
} = videoInfo;
const file = await getFileByName(fileName);
// 如果文件不存在,跳过
if (!file) {
remainingInfos.splice(i, 1);
i--; // 调整索引
continue;
}
// 跳过字段不完整的
if (!(name && seq && normalProductName && editorName && editType
&& editMonth && editTimes && platforms?.length > 0)) {
// 字段不完整,移除这个文件
remainingInfos.splice(i, 1);
i--; // 调整索引
continue;
}
try {
const dateStr = formatDate(createTime, 'YYYY/MM/DD');
const { url } = await multiPartUploadToTOS(file, {
startsWith: `video/${dateStr}/`,
bucket: 'myt-videos',
videoServer: 'myt-videos.lab.weidiango.com',
hasVle: false,
});
const video = await rest.find('MuVideo', {
where: { url },
}).then(res => res.records.find(v => v));
if (video) {
repeatCount++;
// 重复的也移除
remainingInfos.splice(i, 1);
i--; // 调整索引
} else {
const fileInfo = await getVideoFileInfo(file);
const { origin, pathname } = new URL(url);
const sourceUrl = `${origin}${pathname}?x-oss-process=video/snapshot,t_0,f_jpg,m_fast`;
const coverUrl = await uploadImgByVideoUrl(sourceUrl);
const signature = await loadVideoSignature(url);
await rest.save('MuVideo', {
url,
signature,
name,
seq: Number(seq),
editMonth,
editTimes: Number(editTimes),
editorSuffix,
normalProductName,
editorName,
editorOneName,
editorTwoName,
editType,
platforms,
extendOne,
extendTwo,
createTime,
coverUrl,
width: fileInfo.width,
height: fileInfo.height,
videoSize: fileInfo.videoSize,
duration: Number(fileInfo.duration.toFixed(2)),
}).then((res) => {
if(res.error) {
failedCount++;
} else {
completedCount++;
}
})
// 上传成功的移除
remainingInfos.splice(i, 1);
i--; // 调整索引
}
} catch (error) {
console.error('上传失败:', error);
failedCount++;
// 上传失败的也移除,避免无限重试(或者可以选择保留)
remainingInfos.splice(i, 1);
i--; // 调整索引
}
eventBus.emit('BATCH_UPLOAD_VIDEOS_WORKER', {
parseCount: cacheVideoData.parseCount,
failedCount,
successCount: completedCount,
repeatCount,
remainingCount: remainingInfos.length,
});
}
const expireTime = moment().endOf('day').valueOf() - moment().valueOf();
await idbStore.setData('MuVideo/videoFiles', {
videoInfos: remainingInfos,
parseCount: cacheVideoData.parseCount,
failedCount,
successCount: completedCount,
repeatCount,
}, expireTime);
// 发送通知(只在有文件被处理时发送)
if (completedCount > 0 || repeatCount > 0 || failedCount > 0) {
if (completedCount === totalCount && repeatCount === 0 && failedCount === 0) {
this.showNotification({
title: '素材同步完成',
fileName: `共成功上传 ${completedCount} 个视频,点击查看素材库`,
isComplete: true,
});
} else if (completedCount > 0 && remainingInfos.length === 0) {
this.showNotification({
title: '素材同步完成',
fileName: `成功 ${completedCount} 个,重复 ${repeatCount} 个,点击查看素材库`,
});
} else if (repeatCount > 0 && completedCount === 0 && remainingInfos.length === 0) {
this.showNotification({
title: '所有素材已存在',
fileName: `共发现 ${repeatCount} 个重复视频,已过滤上传`,
});
} else if (totalCount > 0 && failedCount > 0) {
this.showNotification({
title: '素材同步失败',
fileName: `共上传${totalCount}个,失败 ${failedCount} 个,点击查看素材库`,
});
}
}
}
// 启动定时通知
async start(intervalMs = 30000) {
if (this.isRunning) {
return;
}
if (!this.registration) {
const registered = await this.register();
if (!registered) return;
}
const hasPermission = await this.requestPermission();
if (!hasPermission) return;
this.isRunning = true;
await this.checkAndSendNotifications();
this.timer = setInterval(() => {
this.checkAndSendNotifications();
}, intervalMs);
}
// 停止定时通知
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.isRunning = false;
}
// 请求通知权限
async requestPermission() {
if (!('Notification' in window)) {
console.warn('浏览器不支持通知功能');
return false;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
}
}
// 导出单例
export const batchUploadVideosWorker = new BatchUploadVideosWorker();
export const starBatchUploadVideosWorker = () => batchUploadVideosWorker.start(1000);
export const stopBatchUploadVideosWorker = () => batchUploadVideosWorker.stop();
// 项目入口处可添加轮询机制
setTimeout(() => {
starBatchUploadVideosWorker();
}, 5000);
EventBus 实时通信
php
// 发送进度更新
eventBus.emit('BATCH_UPLOAD_VIDEOS_WORKER', {
parseCount: cacheVideoData.parseCount,
failedCount,
successCount: completedCount,
repeatCount,
remainingCount: remainingInfos.length,
});
// UI 监听进度
eventBus.on('BATCH_UPLOAD_VIDEOS_WORKER', (data) => {
// 更新UI进度条
updateProgress(data);
});
存储文件句柄
ini
const handleFolderSelect = async () => {
try {
// 1. 选择文件夹
const dirHandle = await window.showDirectoryPicker();
await saveHandle('videoFolder', dirHandle);
// 2. 存储所有找到的文件
const allFiles = [];
// 3. 递归遍历函数(改进版)
const traverse = async (dir, path = '') => {
// 将异步迭代器转换为数组
const iterator = dir.values();
const entries = [];
const collectNext = async () => {
const result = await iterator.next();
if (!result.done) {
entries.push(result.value);
return collectNext();
}
return undefined;
};
await collectNext();
// 遍历收集到的所有条目
for (const entry of entries) {
const currentPath = path ? `${path}/${entry.name}` : entry.name;
if (entry.kind === 'file') {
// 处理文件
const file = await entry.getFile();
allFiles.push({
name: entry.name,
path: currentPath,
file: file,
size: file.size,
type: file.type,
lastModified: file.lastModified
});
} else if (entry.kind === 'directory') {
// 🔥 新增:递归遍历子目录
await traverse(entry, currentPath);
}
}
};
// 4. 开始遍历
await traverse(dirHandle);
console.log(`总共找到 ${allFiles.length} 个文件`);
// 5. 过滤视频文件
const videoExtensions = ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm', '.m4v'];
const videoFiles = allFiles.filter(({ name }) => {
const ext = name.substring(name.lastIndexOf('.')).toLowerCase();
return videoExtensions.includes(ext);
});
console.log(`找到 ${videoFiles.length} 个视频文件`);
// 6. 提取视频信息
const videoInfos = await Promise.all(
videoFiles.map(async ({ name, path, file }) => {
const info = await getVideoFileInfo(file);
return {
fileName: name,
filePath: path,
file: file,
width: info.width,
height: info.height,
videoSize: file.size,
duration: info.duration,
};
})
);
return videoInfos;
} catch (error) {
if (error.name === 'AbortError') {
console.log('用户取消了文件夹选择');
} else {
console.error('选择文件夹失败:', error);
}
return [];
}
};
| 对比维度 | 🔗 存储句柄 (File Handle) | 📦 存储文件内容 (File Content) |
|---|---|---|
| 核心定义 | 文件/目录的持久化引用指针(轻量级元数据对象) | 文件的完整二进制数据(原始字节流) |
| 存储对象本质 | 操作系统级文件系统入口点(含路径、权限、类型等元数据) | 文件的实际二进制内容(如图片像素、文档文本等原始数据) |
| 典型存储位置 | IndexedDB(主存储) + SessionStorage(临时缓存路径) |
仅 IndexedDB(需手动序列化为 Blob/ArrayBuffer) |
| 数据大小范围 | ~1-5 KB (仅存储元数据:路径哈希、权限标志、类型标识) | KB → GB 级 (等于文件实际大小,受浏览器配额限制) |
| 获取方式 | ✅ 必须重新授权 • 需调用 showDirectoryPicker()/showOpenFilePicker() • 用户需手动选择目标 • 句柄可能因权限过期失效 |
✅ 直接同步读取 • 从 IndexedDB 按 Key 获取已存数据 • 无需用户交互 • 可直接转为 Blob/ArrayBuffer |
| 网络传输特性 | ⚡ 极低开销 • 仅传输加密句柄标识(如 handle.id) • 数据量 ≈ 1KB • 适用于跨设备同步引用 |
📦 高带宽消耗 • 需传输完整二进制流 • 100MB 文件 ≈ 传输 100MB 数据 • 受网络稳定性影响大 |
| 用户交互要求 | ❗ 强制交互 • 每次使用需用户重新授权 • 无法后台静默访问 • 权限有效期通常 ≤ 24 小时 | ✅ 零交互 • 读写完全在后台进行 • 用户无感知 • 适用于离线场景 |
| 典型技术限制 | • 受 Storage Access API 权限策略约束 • 跨域/跨会话需重新授权 • 无法直接获取文件内容 | • 受 IndexedDB 配额限制(通常为磁盘空间的 50%) • 大文件需分块存储 • 序列化/反序列化消耗 CPU |
| 适用场景示例 | • 文件管理器路径记忆 • 用户授权目录的长期引用 • 跨页面传递文件操作权限 | • 离线文档编辑器缓存 • 大文件分片上传预处理 • 端到端加密文件存储 |
关键补充说明
-
SessionStorage的作用- 仅临时缓存句柄的路径标识(非句柄本身),用于快速重建句柄引用
- 实际句柄仍需通过
IndexedDB存储的FileSystemHandle对象 + 用户重新授权获取
-
权限失效机制
-
File Handle 在以下情况立即失效:
- 浏览器重启
- 用户清除站点数据
- 超过 24 小时未使用(浏览器策略)
-
此时必须触发
show*Picker()重新获取
-
-
大文件存储警示
| 方案 | 1GB 文件存储影响 |
|---|---|
| 仅存句柄 | 占用 ~3KB 存储空间 |
| 存储文件内容 | • 占用 1GB IndexedDB 空间 • 可能触发配额警告(Chrome 默认配额 = 磁盘 80%) • 写入耗时 ≥ 15 秒(SSD 环境) |