commit 1cd6e2a253fade0f38fea638dc43e355e7164a66 Author: kura Date: Thu Jan 2 11:20:43 2025 +0800 init diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..8e03d3e --- /dev/null +++ b/src/App.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..d27eb5a --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,8 @@ +/// + +declare module '*.vue' { + import { DefineComponent } from 'vue' + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..cbe8de3 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import App from './App.vue' +import 'ant-design-vue/dist/reset.css'; +import Antd from 'ant-design-vue'; +const app = createApp(App); +app.use(Antd); +app.mount('#app') diff --git a/src/pages/file/index.vue b/src/pages/file/index.vue new file mode 100644 index 0000000..ba8ec11 --- /dev/null +++ b/src/pages/file/index.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/src/pages/file/item/clipboard.vue b/src/pages/file/item/clipboard.vue new file mode 100644 index 0000000..6cedf8a --- /dev/null +++ b/src/pages/file/item/clipboard.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/src/pages/file/item/fileIcon.vue b/src/pages/file/item/fileIcon.vue new file mode 100644 index 0000000..c28c2bb --- /dev/null +++ b/src/pages/file/item/fileIcon.vue @@ -0,0 +1,100 @@ + + + + diff --git a/src/pages/file/item/fileItem.vue b/src/pages/file/item/fileItem.vue new file mode 100644 index 0000000..8bd3459 --- /dev/null +++ b/src/pages/file/item/fileItem.vue @@ -0,0 +1,50 @@ + + + + diff --git a/src/pages/file/item/filePermissionSetDialog.vue b/src/pages/file/item/filePermissionSetDialog.vue new file mode 100644 index 0000000..ee290c9 --- /dev/null +++ b/src/pages/file/item/filePermissionSetDialog.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/pages/file/item/fileReader.vue b/src/pages/file/item/fileReader.vue new file mode 100644 index 0000000..c87401b --- /dev/null +++ b/src/pages/file/item/fileReader.vue @@ -0,0 +1,465 @@ + + + + + diff --git a/src/pages/file/item/fileTranserView.vue b/src/pages/file/item/fileTranserView.vue new file mode 100644 index 0000000..67276ee --- /dev/null +++ b/src/pages/file/item/fileTranserView.vue @@ -0,0 +1,220 @@ + + + diff --git a/src/pages/file/item/fileView.vue b/src/pages/file/item/fileView.vue new file mode 100644 index 0000000..9238270 --- /dev/null +++ b/src/pages/file/item/fileView.vue @@ -0,0 +1,555 @@ + + + + + diff --git a/src/pages/file/utils/common.ts b/src/pages/file/utils/common.ts new file mode 100644 index 0000000..cf2b401 --- /dev/null +++ b/src/pages/file/utils/common.ts @@ -0,0 +1,68 @@ +import { type Ref, ref } from "vue"; +import { FileInfo } from "./fileMgr"; +import { Permission } from "./peer"; + +export const formatSize = (size: number): string => { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`; + return `${(size / (1024 * 1024)).toFixed(2)} MB`; +}; + +export let localCurrentFile: Ref = ref(null); +export let remoteCurrentFile: Ref = ref(null); +export const stringToBase64 = (str: string) => { + //汉字转base64 + return btoa(encodeURIComponent(str)) +} +export const base64ToString = (base64: string) => { + //base64转汉字 + return decodeURIComponent(atob(base64)) +} +const commonCharsCN = '的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多点行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当西从广业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决皮被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该冯价严龙飞'; +const commonCharsEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; +export const randomChars = (num: number = 3, chars: string = commonCharsEN) => { + const result = []; + for (let i = 0; i < num; i++) { + const randomIndex = Math.floor(Math.random() * chars.length); + result.push(chars[randomIndex]); + } + return result.join(''); +} +export const sign2peerid = (sign: string) => { + sign = sign.replace(/[^a-zA-Z0-9]/g, '') + sign = sign + '+kuraa.cc+explorer' + return stringToBase64(sign).slice(0, 32) +} +//获取url query +export const getUrlParam = (key: string) => { + var query = window.location.search.substring(1); + var vars = query.split("&"); + for (var i = 0; i < vars.length; i++) { + var pair = vars[i].split("="); + if (pair[0] == key) { return pair[1]; } + } + return null; +} +// 权限设置 +let permissionSet: { [key in Permission]: boolean } = null; +export function getPermissionSet(): { [key in Permission]: boolean } { + if (!permissionSet) { + permissionSet = getCache('permissionSet') || { + edit: false, + view: true, + download: true, + }; + } + return permissionSet; +} +export function setPermissionSet(set: { [key in Permission]: boolean }) { + cacheIt('permissionSet', set); + permissionSet = set; +} +//缓存 +export function cacheIt(key: string, value: any) { + localStorage.setItem(key, JSON.stringify(value)); +} +export function getCache(key: string) { + return JSON.parse(localStorage.getItem(key) || null); +} \ No newline at end of file diff --git a/src/pages/file/utils/emitter.ts b/src/pages/file/utils/emitter.ts new file mode 100644 index 0000000..df69fa2 --- /dev/null +++ b/src/pages/file/utils/emitter.ts @@ -0,0 +1,24 @@ +class Emitter { + private events: { [key: string]: ((...args: any[]) => void)[] } = {}; + on(event: string, listener: (...args: any[]) => void) { + if (!this.events[event]) { + this.events[event] = []; + } + this.events[event].push(listener); + } + emit(event: string, ...args: any[]) { + if (this.events[event]) { + this.events[event].forEach(listener => listener(...args)); + } + } + off(event: string, listener: (...args: any[]) => void) { + if (this.events[event]) { + this.events[event] = this.events[event].filter(l => l !== listener); + } + } +} +export default new Emitter(); +export enum EmitterEvent { + choosed_local_directory = 'choosed_local_directory', + update_directory = 'update_directory', +} diff --git a/src/pages/file/utils/fileMgr.ts b/src/pages/file/utils/fileMgr.ts new file mode 100644 index 0000000..e4da670 --- /dev/null +++ b/src/pages/file/utils/fileMgr.ts @@ -0,0 +1,517 @@ +import { type DataConnection } from "peerjs"; +import { FileTransfer, TransferStatus, TransferTask } from "./fileTransfer"; +import { peer } from "./peer"; +import { MessageType } from "./peer"; +import { notification } from "ant-design-vue"; +import emitter, { EmitterEvent } from "./emitter"; + + +export class FileInfo { + /**文件句柄 */ + public fileDirHandler: FileSystemDirectoryHandle | FileSystemFileHandle; + /**父级文件夹 */ + public parentFileInfo: FileInfo | null = null; + /**文件名 */ + public name: string = ''; + /**是否是文件夹 */ + public isDirectory: boolean = false; + /**文件路径 */ + public path: string = ''; + /**文件大小 */ + public size: number = 0; + /**文件最后修改时间 */ + public lastModified: number = 0; + /**子(文件/目录) */ + public files: FileInfo[] = []; + /**是否是远程文件 */ + public isRemote: boolean = false; + /**是否初始化 */ + public isInit: boolean = false; + /**传输任务 */ + public transfer: FileTransfer | null = null; + constructor(isRemote: boolean = false) { + this.isRemote = isRemote; + } + public async init(fileHandler: FileSystemDirectoryHandle | FileSystemFileHandle, parentFileInfo: FileInfo | null = null) { + this.fileDirHandler = fileHandler; + this.name = fileHandler.name; + this.isDirectory = fileHandler.kind === "directory"; + this.parentFileInfo = parentFileInfo; + this.path = parentFileInfo ? `${parentFileInfo.path}/${this.name}` : this.name; + this.size = await this.getFileSize(); + this.lastModified = await this.getFileLastModified(); + this.isInit = true; + } + public async updateRemote(fileInfo: FileInfo) { + this.isRemote = true; + this.name = fileInfo.name; + this.isDirectory = fileInfo.isDirectory; + this.path = fileInfo.path; + this.size = fileInfo.size; + this.lastModified = fileInfo.lastModified; + this.files = fileInfo.files.map(file => { + const newFile = new FileInfo(); + newFile.updateRemote(file); + newFile.parentFileInfo = this; + return newFile; + }); + this.isInit = true; + } + public addTransfer(transfer: FileTransfer) { + this.transfer = transfer; + } + public getTransfer(conn: DataConnection): FileTransfer { + if (this.transfer) { + if (this.transfer.conn !== conn) { + this.transfer.conn = conn; + } + return this.transfer; + } + return new FileTransfer(conn, this); + } + public getFileInfo(path: string): FileInfo | null { + if (path === '') return this; + if (this.path === path) return this; + if (this.files.length > 0) { + for (const file of this.files) { + const info = file.getFileInfo(path); + if (info) return info; + } + } + return null; + } + + public toJson(): object { + return { + name: this.name, + isDirectory: this.isDirectory, + size: this.size, + lastModified: this.lastModified, + path: this.path, + files: this.files.map(file => file.toJson()), + } + } + + /**加载目录 */ + public async loadLocalDirectory(sortType: 'name' | 'size' | 'type' | 'date' = 'type', sortOrder: 'asc' | 'desc' = 'asc'): Promise { + if (this.isRemote) { + await peer.send({ + type: MessageType.request_fileInfo, + data: { + path: this.path, + } + }).then((data: FileInfo) => { + this.updateRemote(data) + }) + } else { + if (!this.isDirectory) return []; + this.files = []; + const fileList: FileInfo[] = []; + try { + for await (const entry of (this.fileDirHandler as FileSystemDirectoryHandle).values()) { + const file = new FileInfo(); + await file.init(entry, this); + fileList.push(file); + } + } catch (error) { + console.error('加载目录失败:', error); + return []; + } + this.files = fileList; + } + + this.files.sort((a, b) => { + if (sortType === 'name') return sortOrder === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name); + if (sortType === 'size') return sortOrder === 'asc' ? a.size - b.size : b.size - a.size; + if (sortType === 'type') return sortOrder === 'asc' ? (a.isDirectory ? -1 : 1) : (a.isDirectory ? 1 : -1); + if (sortType === 'date') return sortOrder === 'asc' ? a.lastModified - b.lastModified : b.lastModified - a.lastModified; + return 0; + }); + emitter.emit(EmitterEvent.update_directory, this); + return this.files; + + } + + /**获取文件大小 */ + public async getFileSize(): Promise { + if (this.isDirectory) return 0; + try { + if (this.isRemote) { + await peer.send({ + type: MessageType.request_fileSize, + data: { + path: this.path, + } + }).then((data: number) => { + this.size = data; + }) + } else { + const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); + this.size = file.size; + + } + return this.size; + } catch (error) { + console.error('获取文件大小失败:', error); + return 0; + } + } + + /**获取文件的MD5值 */ + public async getFileMD5(): Promise { + if (this.isDirectory) return ''; + const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); + // 获取文件的MD5值 + // todo + return ''; + } + + /**删除文件 */ + public async deleteItself() { + try { + if (this.parentFileInfo) { + if (this.isRemote) { + await peer.send({ + type: MessageType.request_deleteFile, + data: { + path: this.path, + } + }).then(async () => { + await this.parentFileInfo.loadLocalDirectory(); + }).catch((err) => { + throw err; + }) + } else { + await (this.parentFileInfo.fileDirHandler as FileSystemDirectoryHandle) + .removeEntry(this.name, { recursive: true }); + await this.parentFileInfo.loadLocalDirectory(); + } + } + } catch (error) { + console.error('删除文件失败:', error); + throw error; + } + } + + /**文件重命名 */ + public async renameFile(newName: string) { + try { + if (this.parentFileInfo) { + if (this.isRemote) { + await peer.send({ + type: MessageType.request_renameFile, + data: { + path: this.path, + newName: newName + } + }).then(async () => { + await this.parentFileInfo.loadLocalDirectory(); + }).catch((err) => { + throw err; + }) + } else { + const parentDir = this.parentFileInfo.fileDirHandler as FileSystemDirectoryHandle; + // 创建新文件 + if (this.isDirectory) { + const newDir = await parentDir.getDirectoryHandle(newName, { create: true }); + // 复制所有文件到新目录 + await this.copyDirectory(this.fileDirHandler as FileSystemDirectoryHandle, newDir); + } else { + const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); + const newFile = await parentDir.getFileHandle(newName, { create: true }); + const writable = await newFile.createWritable(); + await writable.write(await file.arrayBuffer()); + await writable.close(); + } + // 删除旧文件 + await parentDir.removeEntry(this.name, { recursive: true }); + // 更新当前实例 + this.name = newName; + this.path = `${this.parentFileInfo.path}/${newName}`; + await this.parentFileInfo.loadLocalDirectory(); + } + } + } catch (error) { + console.error('重命名文件失败:', error); + throw error; + } + } + + /**文件夹复制 */ + private async copyDirectory(source: FileSystemDirectoryHandle, target: FileSystemDirectoryHandle) { + if (this.isRemote) throw new Error('不能复制远程文件夹'); + for await (const entry of source.values()) { + if (entry.kind === 'file') { + const file = await entry.getFile(); + const newFile = await target.getFileHandle(entry.name, { create: true }); + const writable = await newFile.createWritable(); + await writable.write(await file.arrayBuffer()); + await writable.close(); + } else { + const newDir = await target.getDirectoryHandle(entry.name, { create: true }); + await this.copyDirectory(entry, newDir); + } + } + } + /**创建路径 */ + public async createPath(path: string): Promise { + const pathArr = path.split('/'); + let currentPath = this.fileDirHandler as FileSystemDirectoryHandle; + for (const dir of pathArr) { + if (dir === '') continue; + currentPath = await currentPath.getDirectoryHandle(dir, { create: true }); + } + return currentPath; + } + /**创建文件夹 */ + public async createDirectory(name: string) { + if (!this.isDirectory) throw new Error('不能创建文件夹'); + if (this.isRemote) { + await peer.send({ + type: MessageType.request_createDirectory, + data: { + path: this.path, + name: name + } + }).then(async () => { + await this.loadLocalDirectory(); + }).catch((err) => { + throw err; + }) + } else { + await (this.fileDirHandler as FileSystemDirectoryHandle).getDirectoryHandle(name, { create: true }); + await this.loadLocalDirectory(); + } + } + /**预览缓存 */ + public previewCache: Uint8Array; + /**添加预览缓存 */ + public addPreviewCacheBuffer(buffer: ArrayBuffer, offset: number = 0, totalSize: number = 0) { + if (!this.previewCache) { + this.previewCache = new Uint8Array(totalSize); + } + this.previewCache.set(new Uint8Array(buffer), offset); + } + /**清除预览缓存 */ + public clearPreviewCache() { + this.previewCache = null; + } + /**创建文件 + * @param name 文件名或路径+文件名 + * @param buffer 文件内容 + * @param offset 偏移量 + */ + public async createFile(name: string, buffer: ArrayBuffer, offset: number = 0) { + if (!this.isDirectory) throw new Error('不能创建文件'); + if (this.isRemote) { + await peer.send({ + type: MessageType.request_createFile, + data: { + path: this.path, + name: name, + buffer: buffer, + offset: offset + } + }).then(async () => { + await this.loadLocalDirectory(); + }).catch((err) => { + throw err; + }) + } else { + let dir = this.fileDirHandler as FileSystemDirectoryHandle; + if (name.split('/').length > 1) { + // 创建深路径文件 + const path = name.split('/').slice(0, -1).join('/'); + name = name.split('/').slice(-1).join('/'); + dir = await this.createPath(path); + } + if (offset > 0) { + const fileHandler = await dir.getFileHandle(name, { create: true }); + const arrayBuffer = await (await fileHandler.getFile()).arrayBuffer(); + const newSize = buffer.byteLength + offset; + const newArrayBuffer = arrayBuffer.byteLength >= newSize ? new Uint8Array(arrayBuffer.byteLength) : new Uint8Array(newSize); + newArrayBuffer.set(new Uint8Array(arrayBuffer), 0); + newArrayBuffer.set(new Uint8Array(buffer), offset); + + const writable = await fileHandler.createWritable(); + await writable.write(newArrayBuffer); + await writable.close(); + } else { + + const file = await dir.getFileHandle(name, { create: true }); + const writable = await file.createWritable(); + await writable.write(buffer); + await writable.close(); + } + } + } + /**保存文件 */ + public async saveFile(buffer: ArrayBuffer, offset: number = 0) { + if (this.isDirectory) { + throw new Error('不能保存文件夹'); + } + if (this.isRemote) { + await peer.send({ + type: MessageType.request_saveFile, + data: { + path: this.path, + buffer: buffer, + offset: offset + } + }).then(async () => { + await this.loadLocalDirectory(); + }).catch((err) => { + throw err; + }) + } else { + if (offset > 0) { + const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); + const arrayBuffer = await file.arrayBuffer(); + const newSize = buffer.byteLength + offset; + const newArrayBuffer = arrayBuffer.byteLength >= newSize ? new Uint8Array(arrayBuffer.byteLength) : new Uint8Array(newSize); + newArrayBuffer.set(new Uint8Array(arrayBuffer), 0); + newArrayBuffer.set(new Uint8Array(buffer), offset); + + const writable = await (this.fileDirHandler as FileSystemFileHandle).createWritable(); + await writable.write(newArrayBuffer); + await writable.close(); + } else { + const writable = await (this.fileDirHandler as FileSystemFileHandle).createWritable(); + await writable.write(buffer); + await writable.close(); + } + } + } + + /**获取文件最后修改时间 */ + public async getFileLastModified(): Promise { + if (this.isDirectory) return 0; + if (this.isRemote) { + await peer.send({ + type: MessageType.request_getFileLastModified, + data: { + path: this.path, + } + }).then((data: number) => { + this.lastModified = data; + }) + } else { + const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); + this.lastModified = file.lastModified; + } + return this.lastModified; + } + + /**获取文件 + * @param preView 是否预览 + * @param savePath 在当前路径保存内容(文件/文件夹) + * @param excludePath 排除路径(生成文件夹路径时排除的选项) + */ + public async getFile(preView: boolean = false, savePath: string = '', excludePath: string = ''): Promise { + if (this.isDirectory) { + const fileDatas: FileData[] = []; + await this.loadLocalDirectory(); + this.files.forEach(async file => { + const fData = await file.getFile(preView, savePath, excludePath); + if (Array.isArray(fData)) { + fileDatas.push(...fData); + } else { + fileDatas.push(fData); + } + }) + return fileDatas; + } else { + if (this.isRemote) { + return await peer.send({ + type: MessageType.request_getFile, + data: { + path: this.path, + preView: preView, + savePath: savePath + '/' + this.path.replace(excludePath, '').replace(this.name, '') + } + }).then(async (data: FileData) => { + if (data.buffer && !data.preView) { + (await fileMgrInstance.getRootFile()).createFile(savePath + '/' + this.path.replace(excludePath, ''), data.buffer).then(() => { + this.getTransfer(peer.remoteConnection).addTask(new TransferTask(data, this)).updateFileData(data, TransferStatus.COMPLETED) + notification.success({ + message: this.name + "文件接收成功", + }); + }) + .catch((err) => { + notification.error({ + message: this.name + "文件接收失败", + description: err.message, + }); + });; + } + return data; + }).catch((err) => { + notification.error({ + message: this.name + "文件接收失败", + description: err.message || err, + }); + throw err; + }) + } else { + const file = await (this.fileDirHandler as FileSystemFileHandle).getFile(); + return { + type: file.type, + buffer: await file.arrayBuffer(), + name: file.name, + size: file.size, + lastModified: file.lastModified, + path: this.path, + MD5: await this.getFileMD5(), + preView: preView + } + } + } + } + + //分片传输 + public async sendFileChunk(conn: DataConnection, preView: boolean = false, savePath: string = ''): Promise { + return this.getTransfer(conn).init(preView).sendFile(savePath) + } +} +class fileMgr { + private _rootFile: FileInfo; + public remoteRootFile: FileInfo = new FileInfo(true); + public async selectLocalDirectory() { + try { + const dirHandle = await window.showDirectoryPicker({ + id: 'p2p-explorer-web', + mode: 'readwrite', + startIn: 'downloads' + }); + this._rootFile = new FileInfo(); + await this._rootFile.init(dirHandle); + emitter.emit(EmitterEvent.choosed_local_directory); + } catch (error) { + console.error('选择目录失败:', error); + throw error; + } + } + public async getRootFile(): Promise { + if (!this._rootFile) { + await this.selectLocalDirectory(); + } + return this._rootFile; + } +} +export const fileMgrInstance = new fileMgr(); +export interface FileData { + type: string; + buffer: ArrayBuffer; + name: string; + size: number; + lastModified: number; + path: string; + MD5?: string; + preView?: boolean; + savePath?: string; + chunkData?: { + offset: number; + totalSize: number; + buffer: ArrayBuffer; + } +} \ No newline at end of file diff --git a/src/pages/file/utils/fileTransfer.ts b/src/pages/file/utils/fileTransfer.ts new file mode 100644 index 0000000..1222723 --- /dev/null +++ b/src/pages/file/utils/fileTransfer.ts @@ -0,0 +1,343 @@ +import { type FileData, type FileInfo } from "./fileMgr"; +import { MessageType, peer } from "./peer"; +import { type DataConnection } from "peerjs"; +// 传输状态枚举 +export enum TransferStatus { + WAITING = "waiting", // 等待传输 + SENDING = "sending", // 发送中 + RECEIVING = "receiving", // 接收中 + PAUSED = "paused", // 已暂停 + COMPLETED = "completed", // 已完成 + ERROR = "error", // 错误 +} + +// 传输进度接口 +export interface TransferProgress { + transferredSize: number; // 已传输大小 + totalSize: number; // 总大小 + speed: number; // 传输速度 (bytes/s) + status: TransferStatus; // 传输状态 + percent: number; // 进度百分比 + costTime: number; // 传输时间 + updateTime: number; // 更新时间 +} + +// 分片配置 +const CHUNK_SIZE = 64 * 1024; // 64KB +const MAX_CHUNK_SIZE = 200 * 1024; // 200KB +//多大的文件需要分片 +export const NEED_CHUNK_FILE_SIZE = 200 * 1024; // 200KB +export const NEED_CHUNK_FILE_SIZE_PREVIEW = 50 * 1024 * 1024; // 50MB + +export class FileTransfer { + public conn: DataConnection; + private file: FileInfo; + private chunkSize: number; + private transferredSize: number = 0; + private lastTransferredSize: number = 0; + private startTime: number = 0; + private status: TransferStatus = TransferStatus.WAITING; + private pausePromise?: Promise; + private pauseResolve?: () => void; + private aborted: boolean = false; + private preView: boolean = false; + // 进度回调 + private onProgressCallback?: (transfer: FileTransfer) => void; + public getFile(): FileInfo { + return this.file; + } + constructor(conn: DataConnection, file: FileInfo, chunkSize: number = CHUNK_SIZE) { + this.conn = conn; + this.file = file; + this.chunkSize = Math.min(chunkSize, MAX_CHUNK_SIZE); + file.addTransfer(this) + fileTransferMgrInstance.addFileTransfer(this) + } + public init(preView: boolean = false) { + this.clear(); + this.status = TransferStatus.WAITING; + this.preView = preView; + + return this; + } + public clear() { + this.offset = 0; + this.totalSize = 0; + this.fileBuffer = null; + this.fileData = null; + this.transferredSize = 0; + this.lastTransferredSize = 0; + this.startTime = 0; + this.pausePromise = undefined; + this.pauseResolve = undefined; + this.aborted = false; + } + + // 设置进度回调 + public onProgress(callback: (transfer: FileTransfer) => void) { + this.onProgressCallback = callback; + } + public currentProgress(): TransferProgress { + return this.getProgress(); + } + // 获取当前进度 + private getProgress(): TransferProgress { + const now = Date.now(); + const timeElapsed = (now - this.startTime) / 1000; // 转换为秒 + const speed = timeElapsed > 0 ? (this.transferredSize - this.lastTransferredSize) / timeElapsed : 0; + let totalSize = this.fileData?.chunkData?.totalSize || this.file.size; + const progress: TransferProgress = { + transferredSize: this.transferredSize, + totalSize: totalSize, + speed, + status: this.status, + percent: (this.transferredSize / totalSize) * 100, + costTime: timeElapsed, + updateTime: now + }; + + // 更新上次传输大小和开始时间 + this.lastTransferredSize = this.transferredSize; + this.startTime = now; + return progress; + } + public fileData: FileData + public offset: number = 0; + public totalSize: number = 0; + private fileBuffer: ArrayBuffer | null = null; + // 发送文件 + public async sendFile(savePath: string = ''): Promise { + try { + if (this.status == TransferStatus.WAITING) { + this.startTime = Date.now(); + this.fileData = await this.file.getFile() as FileData; + this.totalSize = this.fileData.size; + this.fileBuffer = this.fileData.buffer; + this.fileData.preView = this.preView; + this.fileData.savePath = savePath; + this.addTask(new TransferTask(this.fileData, this.file)); + this.status = TransferStatus.SENDING; + this.updateProgress(); + } + + if (this.offset < this.totalSize && !this.aborted) { + // 检查是否暂停 + if (this.pausePromise) { + this.status = TransferStatus.PAUSED; + this.updateProgress(); + await this.pausePromise; + this.status = TransferStatus.SENDING; + } + + // 发送分片 + const chunk = this.fileBuffer.slice(this.offset, this.offset + this.chunkSize); + this.fileData.buffer = null; + this.fileData.chunkData = { + offset: this.offset, + totalSize: this.totalSize, + buffer: chunk + } + return this.sendFileChunk(this.fileData).then((): Promise => { + this.offset += chunk.byteLength; + this.transferredSize = this.offset; + if (this.offset >= this.totalSize) { + return this.sendFileComplete(); + } else { + return this.sendFile(); + } + }); + } + + if (!this.aborted) { + this.status = TransferStatus.COMPLETED; + this.updateProgress(); + return true; + } else { + return false; + } + + } catch (error) { + this.status = TransferStatus.ERROR; + this.updateProgress(); + throw error; + } + } + private sendFileChunk(chunk: FileData): Promise { + this.updateProgress() + return peer.send({ + type: MessageType.push_file_chunk, + data: chunk, + }, this.conn); + } + private async sendFileComplete(): Promise { + await peer.send({ + type: MessageType.push_file_complete, + data: this.fileData, + }, this.conn); + this.status = TransferStatus.COMPLETED; + this.updateProgress(); + return true; + } + // 接收文件 + public async receiveFile(fData: FileData, preView: boolean = false): Promise { + try { + let task = this.getTask(fData) + if (!task) { + this.addTask(new TransferTask(fData, this.file)); + } else { + task.updateFileData(fData, TransferStatus.RECEIVING); + } + this.status = TransferStatus.RECEIVING; + this.transferredSize = fData.chunkData.offset; + this.startTime = Date.now(); + this.totalSize = fData.chunkData.totalSize; + if (preView) { + await this.file.addPreviewCacheBuffer(fData.chunkData.buffer, fData.chunkData.offset, fData.chunkData.totalSize); + } else { + await this.file.createFile(fData.savePath + '/' + fData.name, fData.chunkData.buffer, fData.chunkData.offset) + } + if (fData.chunkData.totalSize <= fData.chunkData.buffer.byteLength + fData.chunkData.offset) { + this.status = TransferStatus.COMPLETED; + } + this.updateProgress(fData); + return + } catch (error) { + this.status = TransferStatus.ERROR; + this.updateProgress(fData); + throw error; + } + } + + // 暂停传输 + public pause() { + if (this.status === TransferStatus.SENDING || this.status === TransferStatus.RECEIVING) { + this.pausePromise = new Promise(resolve => { + this.pauseResolve = resolve; + }); + } + } + + // 恢复传输 + public resume() { + if (this.pauseResolve) { + this.pauseResolve(); + this.pausePromise = undefined; + this.pauseResolve = undefined; + } + } + + // 取消传输 + public abort() { + this.aborted = true; + this.resume(); // 恢复暂停的传输以便能够正确退出 + } + + // 更新进度 + private updateProgress(fData: FileData = this.fileData) { + this.updateTask(fData); + if (this.onProgressCallback) { + this.onProgressCallback(this); + } + } + private tasks: TransferTask[] = []; + //记录传输任务 + public addTask(task: TransferTask) { + this.tasks.push(task); + this.updateProgress(task.fileData); + return task; + } + public getTasks() { + this.tasks.forEach(task => task.updateProgress()); + return this.tasks; + } + public updateTask(fData: FileData) { + const task = this.tasks.find(task => task.fileData.path === fData.path); + task.updateFileData(fData, this.status); + } + public getTask(fData: FileData) { + return this.tasks.find(task => task.fileData.path === fData.path); + } + //清除已完成任务 + public clearCompletedTasks() { + this.tasks = this.tasks.filter(t => t.status !== TransferStatus.COMPLETED); + } +} +class FileTransferMgr { + private fileTransfers: Map = new Map(); + public addFileTransfer(transfer: FileTransfer) { + transfer.onProgress(this.notifyTransferChanged.bind(this)); + this.fileTransfers.set(transfer.getFile().path, transfer); + + } + public getFileTransfer(path: string) { + return this.fileTransfers.get(path); + } + public getAllFileTransfers() { + return Array.from(this.fileTransfers.values()); + } + // 传输进度变化回调 + private onTransferChangedHandler: ((transfer: FileTransfer) => void)[] = [] + public onTransferChanged(handler: (transfer: FileTransfer) => void) { + this.onTransferChangedHandler.push(handler); + } + public removeTransferChangedHandler(handler: (transfer: FileTransfer) => void) { + this.onTransferChangedHandler = this.onTransferChangedHandler.filter(h => h !== handler); + } + public notifyTransferChanged(transfer: FileTransfer) { + this.onTransferChangedHandler.forEach(handler => handler(transfer)); + } + + +} +export class TransferTask { + //传输文件数据 + fileData: FileData; + //接收载体目录 + file: FileInfo; + //开始时间 + startTime: number = 0; + //传输状态 + status: TransferStatus = TransferStatus.WAITING; + //传输进度 + progress: TransferProgress = { + transferredSize: 0, + totalSize: 0, + speed: 0, + status: TransferStatus.WAITING, + percent: 0, + costTime: 0, + updateTime: 0 + }; + constructor(fileData: FileData, file: FileInfo) { + this.fileData = fileData; + this.file = file; + this.startTime = Date.now(); + } + public updateFileData(fileData: FileData, status: TransferStatus = TransferStatus.SENDING) { + this.fileData = fileData; + this.status = status; + this.updateProgress(); + } + public updateProgress(): TransferProgress { + if (this.status == TransferStatus.COMPLETED) { + this.progress.updateTime = Date.now(); + return this.progress; + } + const totalSize = this.fileData.chunkData?.totalSize || this.fileData.size; + const transferredSize = this.fileData.chunkData?.offset || this.fileData.size; + const speed = this.fileData.chunkData ? (transferredSize + this.fileData.chunkData.buffer.byteLength) / (Date.now() - this.startTime) * 1000 : this.fileData.size / (Date.now() - this.startTime) * 1000; + const percent = this.fileData.chunkData ? (transferredSize + this.fileData.chunkData.buffer.byteLength) / totalSize * 100 : transferredSize / totalSize * 100; + this.status = transferredSize >= totalSize ? TransferStatus.COMPLETED : this.status; + this.progress = { + transferredSize: transferredSize, + totalSize: totalSize, + speed: speed == Infinity ? totalSize : speed, + status: this.status, + percent: percent, + costTime: Date.now() - this.startTime, + updateTime: Date.now() + } + return this.progress; + } +} +export const fileTransferMgrInstance = new FileTransferMgr() diff --git a/src/pages/file/utils/peer.ts b/src/pages/file/utils/peer.ts new file mode 100644 index 0000000..0e9c3e6 --- /dev/null +++ b/src/pages/file/utils/peer.ts @@ -0,0 +1,362 @@ +import { Peer as PeerJs } from 'peerjs' +import type { DataConnection } from 'peerjs' +import { fileMgrInstance, type FileData, type FileInfo } from './fileMgr' +import { NEED_CHUNK_FILE_SIZE, NEED_CHUNK_FILE_SIZE_PREVIEW, TransferStatus, TransferTask } from './fileTransfer' +import { notification } from 'ant-design-vue' +import { randomChars, sign2peerid, getPermissionSet } from './common' + +// 发送超时时间(毫秒) +const SEND_TIMEOUT = 10000; + +class Peer extends EventTarget { + peer: PeerJs + //被动连接 + connections: Map = new Map() + //主动连接 + remoteConnection: DataConnection | null = null + //我的id + id: string | null = null + //自定义标识 + sign: string | null = null + constructor(sign: string) { + super() + this.sign = sign + this.peer = new PeerJs(sign2peerid(sign)) + this.peer.on('open', (id) => { + console.log('peer open', id) + this.id = id + this.dispatchEvent(new CustomEvent('open', { detail: this.sign })) + }) + + this.peer.on('connection', (conn) => { + console.log('peer connection', conn) + this.setupConnection(conn) + }) + + this.peer.on('error', (err) => { + console.log('peer error', err) + this.dispatchEvent(new CustomEvent('error', { detail: err })) + }) + + this.peer.on('close', () => { + console.log('peer close') + this.dispatchEvent(new CustomEvent('close')) + }) + + this.peer.on('disconnected', () => { + console.log('peer disconnected') + this.dispatchEvent(new CustomEvent('disconnected')) + }) + } + + private setupConnection(conn: DataConnection) { + this.connections.set(conn.peer, conn) + conn.on('data', (data: unknown) => { + const typedData = data as Message + + if (this.callbackMap.has(typedData.id)) { + this.callbackMap.get(typedData.id)?.(typedData) + return + } + + this.handleMessage(typedData, conn) + + this.dispatchEvent(new CustomEvent(typedData.type, { + detail: { peerId: conn.peer, data: typedData } + })) + }) + conn.on('open', () => { + this.dispatchEvent(new CustomEvent('connection-open', { + detail: { peer: conn.peer, conn: conn } + })) + }) + conn.on('close', () => { + this.connections.delete(conn.peer) + this.dispatchEvent(new CustomEvent('peer-disconnected', { + detail: { peer: conn.peer, conn: conn } + })) + }) + } + + connect(id: string) { + const conn = this.peer.connect(sign2peerid(id)) + this.setupConnection(conn) + this.remoteConnection = conn + this.checkConnection() + return conn + } + private callbackMap: Map void> = new Map() + async send(data: Message, conn: DataConnection = this.remoteConnection, isHandleResponse: boolean = false): Promise { + data.id = data.id || uuidv4() + if (!conn) { + notification.error({ + message: '请创建连接', + }) + return Promise.reject('连接不存在') + } + conn.send(data, true) + if (isHandleResponse) { + return Promise.resolve(data.data) + } else { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.callbackMap.delete(data.id) + notification.error({ + message: '返回超时', + description: '对方可能已离线或网络异常' + }) + reject(new Error('返回超时')) + }, SEND_TIMEOUT) + this.callbackMap.set(data.id, (data: Message) => { + clearTimeout(timeoutId) + if (data.type === MessageType.response_getFile && data.data.sendType == 'chunk') { + //分片传输 特殊处理 可以长时间等待进度 + return + } + if (data.type === MessageType.error) { + reject(data.data) + } else { + resolve(data.data) + } + this.callbackMap.delete(data.id) + }) + }) + } + } + /**共交换的字节数 */ + public transbytesNum: number = 0; + /**共交换的包数 */ + public transpackNum: number = 0; + private checkConnection() { + if (!this.remoteConnection) { + return + } + const rtcp: RTCPeerConnection = this.remoteConnection.peerConnection; + rtcp.getStats().then((stats) => { + stats.forEach((stat) => { + if (stat.type == "data-channel") { + //流量 + this.transbytesNum = stat.bytesReceived + stat.bytesSent; + //包数 + this.transpackNum = stat.messagesReceived + stat.messagesSent; + } + }) + }) + setTimeout(() => { + this.checkConnection() + }, 1000); + } + + // 便捷方法用于添加事件监听器 + on(event: string, callback: EventListener) { + this.addEventListener(event, callback) + } + + // 便捷方法用于移除事件监听器 + off(event: string, callback: EventListener) { + this.removeEventListener(event, callback) + } + + async handleMessage(data: Message, conn: DataConnection) { + const remoteD = data.data; + let file: FileInfo | null = null; + let resData: Message = { + type: MessageType.error, + data: null, + id: data.id + } + try { + // 权限校验 + /** 如果通过权限校验,则permissionNo为null */ + let permissionNo: Permission | null = null; + for (const [permission, allowed] of Object.entries(getPermissionSet())) { + if (PermissionLimit[permission as Permission].includes(data.type)) { + if (allowed) { + permissionNo = null; + break; + } else { + permissionNo = permission as Permission; + } + } + } + if (permissionNo) { + throw new Error(`没有权限执行该操作:${data.type},请检查权限${permissionNo}设置`) + } + + switch (data.type) { + case MessageType.request_copyClipboard: + resData.type = MessageType.response_copyClipboard; + //读取粘贴板数据 + resData.data = await navigator.clipboard.readText().then(text => { + return text + }).catch(err => { + return '对方窗口未聚焦,无法复制' + }); + break; + case MessageType.request_fileInfo: + resData.type = MessageType.response_fileInfo; + file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path); + if (file) { + await file.loadLocalDirectory() + const json = file.toJson() + resData.data = json + } else { + resData.data = null + } + break; + case MessageType.request_fileSize: + resData.type = MessageType.response_fileSize + file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) + resData.data = await file.getFileSize() + break; + case MessageType.request_deleteFile: + resData.type = MessageType.response_deleteFile + file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) + await file.deleteItself() + break; + case MessageType.request_renameFile: + resData.type = MessageType.response_renameFile + file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) + await file.renameFile(remoteD.newName) + break; + case MessageType.request_createDirectory: + resData.type = MessageType.response_createDirectory + file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) + await file.createDirectory(remoteD.name) + break; + case MessageType.request_createFile: + resData.type = MessageType.response_createFile + file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) + await file.createFile(remoteD.name, remoteD.buffer) + break; + case MessageType.request_saveFile: + resData.type = MessageType.response_saveFile + file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) + await file.saveFile(remoteD.buffer) + break; + case MessageType.request_getFileLastModified: + resData.type = MessageType.response_getFileLastModified + file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) + resData.data = await file.getFileLastModified() + break; + case MessageType.request_getFile: + if (!remoteD.preView) { + //特殊处理下载权限 + if (!getPermissionSet()[Permission.download]) { + throw new Error(`没有权限执行该操作:${data.type},请检查权限${Permission.download}设置`) + } + } + resData.type = MessageType.response_getFile + file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) + let fileData = await file.getFile(remoteD.preView) as FileData + if (fileData.size > NEED_CHUNK_FILE_SIZE) { + this.send({ + type: MessageType.response_getFile, + data: { + sendType: 'chunk' + }, + id: data.id + }, conn, true) + //分片传输 + if (remoteD.preView) { + if (fileData.size < NEED_CHUNK_FILE_SIZE_PREVIEW) { + await file.sendFileChunk(conn, remoteD.preView) + } + } else { + await file.sendFileChunk(conn, false, remoteD.savePath) + } + fileData.buffer = null; + } else { + //直接传输 + file.getTransfer(conn).addTask(new TransferTask(fileData, file)).updateFileData(fileData, TransferStatus.COMPLETED) + } + resData.data = fileData + break; + case MessageType.push_file_chunk: + resData.type = MessageType.response_push_file_chunk + let fData = remoteD as FileData + if (fData.preView) { + await fileMgrInstance.remoteRootFile.getFileInfo(fData.path).getTransfer(conn).receiveFile(fData, true) + } else { + await (await fileMgrInstance.getRootFile()).getTransfer(conn).receiveFile(fData) + } + resData.data = 'ok' + break; + case MessageType.push_file_complete: + resData.type = MessageType.response_push_file_complete + console.log('push_file_complete', remoteD) + if (remoteD.preView) { + notification.success({ + message: '文件缓存建立完成', + }) + } else { + notification.success({ + message: '文件流式传输完成', + description: remoteD.name + }) + } + resData.data = 'ok' + break; + default: + resData.type = MessageType.error + resData.data = '未知消息类型' + break; + } + } catch (err: any) { + console.error('handleMessage error', err) + resData.type = MessageType.error + resData.data = err.message || JSON.stringify(err) + } + this.send(resData, conn, true) + } +} + +export const peer = new Peer(randomChars(6)) +export interface Message { + type: MessageType + data: any + id?: string //uuid +} +export enum MessageType { + request_copyClipboard = 'request_copyClipboard', + response_copyClipboard = 'response_copyClipboard', + request_getFile = 'request_getFile', + response_getFile = 'response_getFile', + request_getFileLastModified = 'request_getFileLastModified', + response_getFileLastModified = 'response_getFileLastModified', + request_saveFile = 'request_saveFile', + response_saveFile = 'response_saveFile', + request_createFile = 'request_createFile', + response_createFile = 'response_createFile', + request_createDirectory = 'request_createDirectory', + response_createDirectory = 'response_createDirectory', + request_renameFile = 'request_renameFile', + response_renameFile = 'response_renameFile', + request_deleteFile = 'request_deleteFile', + response_deleteFile = 'response_deleteFile', + error = 'handle_error', + request_fileSize = 'request_fileSize', + response_fileSize = 'response_fileSize', + response_fileInfo = 'response_fileInfo', + request_fileInfo = 'request_fileInfo', + push_file_chunk = 'push_file_chunk', + push_file_complete = 'push_file_complete', + response_push_file_chunk = 'response_push_file_chunk', + response_push_file_complete = 'response_push_file_complete', +} +export enum Permission { + edit = 'edit', + view = 'view', + download = 'download', +} +export const PermissionLimit = { + [Permission.edit]: [MessageType.request_saveFile, MessageType.request_createFile, MessageType.request_createDirectory, MessageType.request_renameFile, MessageType.request_deleteFile], + [Permission.view]: [MessageType.request_copyClipboard, MessageType.request_getFile, MessageType.request_getFileLastModified, MessageType.request_fileInfo, MessageType.request_fileSize], + [Permission.download]: [MessageType.request_getFile], +} +export function uuidv4(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} \ No newline at end of file diff --git a/src/shime-uni.d.ts b/src/shime-uni.d.ts new file mode 100644 index 0000000..7b3f972 --- /dev/null +++ b/src/shime-uni.d.ts @@ -0,0 +1,6 @@ +export {} + +declare module 'vue' { + type Hooks = App.AppInstance & Page.PageInstance + interface ComponentCustomOptions extends Hooks {} +}