This commit is contained in:
kura 2025-01-02 11:20:43 +08:00
commit 1cd6e2a253
17 changed files with 3320 additions and 0 deletions

14
src/App.vue Normal file
View File

@ -0,0 +1,14 @@
<template>
<Index />
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import Index from "./pages/file/index.vue";
onMounted(() => {
console.log("App Launch");
});
onUnmounted(() => {
console.log("App Hide");
});
</script>
<style></style>

8
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
/// <reference types="vite/client" />
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
}

7
src/main.ts Normal file
View File

@ -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')

371
src/pages/file/index.vue Normal file
View File

@ -0,0 +1,371 @@
<template>
<div class="container">
<!-- 连接状态 -->
<div class="status-bar">
<div class="peer-info">
我的ID: <span class="id-text">{{ myId || "等待连接..." }}</span>
<Button type="primary" @click="copyId" v-if="myId">复制</Button>
<Button type="link" @click="shareUrl" v-if="myId">分享</Button>
</div>
<!-- 显示流量 丢包率-->
<div class="status-info">
<div class="status-item">
<span>流量:</span>
<span>{{ formatSize(transInfo.bytes) }}/s</span>
</div>
<div class="status-item">
<span>包数:</span>
<span>{{ transInfo.packets }}/s</span>
</div>
</div>
<div class="connect-section">
<input
v-model="targetId"
:placeholder="!myId ? '请稍后...' : '输入对方ID'"
:disabled="isConnected || !myId"
/>
<Button @click="handleConnect" :disabled="!targetId || isConnected">
{{ isConnected ? "已连接" : "连接" }}
</Button>
</div>
</div>
<!-- 文件传输区域 -->
<div class="file-transfer">
<!-- 本地文件区域 -->
<FileView :isRemote="false" :selectedFiles="selectedLocalFiles" />
<!-- 传输控制 -->
<div class="transfer-controls">
<Clipboard v-if="isConnected" />
<Button
type="primary"
:disabled="!isConnected || selectedLocalFiles.length === 0"
@click="handleSend"
>
发送
</Button>
<Button
type="primary"
:disabled="!isConnected || selectedRemoteFiles.length === 0"
@click="handleReceive"
:loading="receiveLoading"
>
接收
</Button>
</div>
<!-- 远程文件区域 -->
<FileView :isRemote="true" :selectedFiles="selectedRemoteFiles" />
</div>
</div>
<FileTransferView />
</template>
<script setup lang="ts">
import Clipboard from "./item/clipboard.vue";
import { ref, onMounted } from "vue";
import { peer } from "./utils/peer";
import FileView from "./item/fileView.vue";
import { notification } from "ant-design-vue";
import { fileMgrInstance, type FileData } from "./utils/fileMgr";
import { Button } from "ant-design-vue";
import {
formatSize,
getUrlParam,
localCurrentFile,
remoteCurrentFile,
} from "./utils/common";
import FileTransferView from "./item/fileTranserView.vue";
const receiveLoading = ref(false);
const myId = ref("");
const targetId = ref("");
const isConnected = ref(false);
//
const selectedLocalFiles = ref<string[]>([]);
const selectedRemoteFiles = ref<string[]>([]);
const shareUrl = async () => {
if (myId.value) {
const url =
window.location.origin + window.location.pathname + "?sign=" + myId.value;
await copyToClipboard(url);
notification.success({
message: "链接已复制",
description: "已成功复制到剪贴板",
});
}
};
// ID
const copyId = async () => {
if (myId.value) {
try {
await copyToClipboard(myId.value);
notification.success({
message: "ID已复制",
description: "已成功复制到剪贴板",
});
} catch (error) {
notification.error({
message: "复制失败",
icon: "error",
});
}
}
};
//
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
};
//
const handleConnect = () => {
if (!targetId.value) return;
peer.connect(targetId.value);
};
let lastBytes = 0;
let lastPackets = 0;
const transInfo = ref({
bytes: 0,
packets: 0,
});
const checkBytes = () => {
transInfo.value.bytes = peer.transbytesNum - lastBytes;
transInfo.value.packets = peer.transpackNum - lastPackets;
lastBytes = peer.transbytesNum;
lastPackets = peer.transpackNum;
setTimeout(() => {
checkBytes();
}, 1000);
};
const handleSend = async () => {
notification.info({
message: "未完成",
description: "开发中...",
});
};
const handleReceive = async () => {
receiveLoading.value = true;
const rootFile = await fileMgrInstance.getRootFile();
console.log(localCurrentFile.value.path);
try {
let getFileHandles: Promise<FileData | FileData[]>[] = [];
selectedRemoteFiles.value.forEach((filepath) => {
getFileHandles.push(
fileMgrInstance.remoteRootFile
.getFileInfo(filepath)
.getFile(
false,
localCurrentFile.value.path.replace(rootFile.path, ""),
remoteCurrentFile.value.path
)
);
});
await Promise.all(getFileHandles);
} catch (err) {
notification.error({
message: "接收文件失败",
});
}
receiveLoading.value = false;
await localCurrentFile.value.loadLocalDirectory();
};
//
onMounted(() => {
peer.on("open", ((event: CustomEvent) => {
myId.value = event.detail;
const sign = getUrlParam("sign");
if (sign) {
targetId.value = sign;
handleConnect();
}
}) as EventListener);
peer.on("connection-open", ((event: CustomEvent) => {
if (event.detail.peer == peer.remoteConnection?.peer) {
checkBytes();
isConnected.value = peer.remoteConnection?.open;
fileMgrInstance.remoteRootFile.loadLocalDirectory();
notification.success({
message: "连接成功",
});
} else {
notification.success({
message: "出现新的连接",
});
}
}) as EventListener);
peer.on("peer-disconnected", ((event: CustomEvent) => {
isConnected.value = peer.remoteConnection?.open;
notification.error({
message: event.detail.peer + "连接已断开",
});
}) as EventListener);
peer.on("error", ((event: CustomEvent) => {
notification.error({
message: "发生错误",
});
console.error("连接错误:", event.detail);
}) as EventListener);
});
</script>
<style scoped>
.container {
padding: 20px;
display: flex;
flex-direction: column;
height: calc(100vh - 75px);
gap: 20px;
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: #f5f5f5;
border-radius: 8px;
}
.peer-info {
display: flex;
align-items: center;
gap: 10px;
}
.connect-section {
display: flex;
gap: 10px;
}
.file-transfer {
display: flex;
flex: 1;
gap: 20px;
min-height: 0;
}
.file-panel {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.panel-header {
padding: 10px;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.path-nav {
display: flex;
align-items: center;
gap: 10px;
margin-top: 5px;
}
.file-list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.file-item {
display: flex;
align-items: center;
padding: 8px;
cursor: pointer;
border-radius: 4px;
gap: 10px;
}
.file-item:hover {
background: #f5f5f5;
}
.file-item.selected {
background: #e3f2fd;
}
.file-icon {
font-size: 24px;
}
.file-info {
flex: 1;
}
.file-name {
font-weight: 500;
}
.file-meta {
font-size: 12px;
color: #666;
}
.panel-footer {
padding: 10px;
background: #f5f5f5;
border-top: 1px solid #ddd;
display: flex;
gap: 10px;
}
.transfer-controls {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
}
.transfer-progress {
padding: 10px;
background: #f5f5f5;
border-radius: 8px;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
}
.progress-bar {
height: 4px;
background: #ddd;
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #007aff;
transition: width 0.3s ease;
}
input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
input:disabled {
background: #f5f5f5;
}
.id-text {
font-weight: bold;
color: #007aff;
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<div
class="clipboard-container"
@mouseenter="mouseEntering = true"
@mouseleave="mouseEntering = false"
>
<Transition name="fade">
<img
class="copy-icon"
v-if="!mouseEntering"
src="/static/copy.png"
alt="copy"
/>
<div v-else>
<div class="clipboard-header">
<div class="clipboard-actions">
<Button @click="getRemoteClipboard" :loading="loading"
>获取对方粘贴板</Button
>
</div>
</div>
<div class="clipboard-content">
<div class="clipboard-text">
{{ remoteClipboard || "暂无内容" }}
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "ant-design-vue";
import { notification } from "ant-design-vue";
import { peer, MessageType } from "../utils/peer";
const loading = ref(false);
const remoteClipboard = ref("");
const mouseEntering = ref(false);
//
const getRemoteClipboard = async () => {
loading.value = true;
try {
const result = await peer.send({
type: MessageType.request_copyClipboard,
data: null,
});
remoteClipboard.value = result;
notification.success({
message: "获取成功",
});
} catch (error: any) {
notification.error({
message: "获取失败",
description: error?.message || "未知错误",
});
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease, max-height 0.5s ease;
max-height: 200px;
overflow: hidden;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
max-height: 0;
overflow: hidden;
}
.clipboard-container {
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
max-width: 180px;
}
.clipboard-header {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 20px;
}
.clipboard-header h3 {
margin: 0;
}
.clipboard-actions {
display: flex;
justify-content: center;
align-items: center;
}
.clipboard-content::-webkit-scrollbar {
display: none;
}
.clipboard-content::-webkit-scrollbar-thumb {
display: none;
}
.clipboard-text {
background: white;
padding: 10px;
border-radius: 4px;
min-height: 60px;
word-break: break-all;
white-space: pre-wrap;
font-size: 14px;
color: #666;
text-align: center;
}
.copy-icon {
width: 20px;
height: 20px;
position: absolute;
}
</style>

View File

@ -0,0 +1,100 @@
<template>
<div class="file-icon">
<img class="file-icon-img" :src="getIcon(file)" alt="file-icon" />
</div>
</template>
<script setup lang="ts">
import { FileInfo } from "../utils/fileMgr";
const props = defineProps<{
file: FileInfo;
}>();
const iconMap = {
"/static/icons/IMG.webp": [
"jpg",
"png",
"jpeg",
"gif",
"bmp",
"tiff",
"ico",
"webp",
],
"/static/icons/PPT.webp": ["ppt", "pptx"],
"/static/icons/TXT.webp": ["txt", "md", "markdown"],
"/static/icons/WORD.webp": ["doc", "docx"],
"/static/icons/PDF.webp": ["pdf"],
"/static/icons/VIDEO.webp": [
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"rmvb",
"rm",
"asf",
"wmv",
"mpg",
"mpeg",
"mpe",
"m4v",
"3gp",
"3g2",
"flv",
"f4v",
"swf",
"vob",
],
"/static/icons/MUSIC.webp": [
"mp3",
"wav",
"wma",
"aac",
"flac",
"ape",
"m4a",
"ogg",
"m3u",
"m3u8",
"pls",
"qmc",
"qmcflac",
"qmcogg",
"qmcwma",
"rm",
"rmvb",
"wavpack",
],
"/static/icons/ZIP.webp": [
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"iso",
"7zip",
],
"/static/icons/XML.webp": ["xml", "html", "htm", "xhtml", "shtml", "shtm"],
};
//icon
const getIcon = (file: FileInfo): string => {
if (file.isDirectory) {
return "/static/icons/wenjianjia.webp";
}
for (const [iconUrl, extensions] of Object.entries(iconMap)) {
if (extensions.includes(file.name.split(".").pop()!)) {
return iconUrl;
}
}
return "/static/icons/OTHER.webp";
};
</script>
<style scoped>
.file-icon-img {
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,50 @@
<template>
<div class="file-item">
<div class="file-icon">
<FileIcon :file="file" />
</div>
<div class="file-name">{{ file.name }}</div>
<div class="file-info">
<div class="file-last-modified">
{{ new Date(file.lastModified).toLocaleString() }}
</div>
<div class="file-meta">
{{ file.isDirectory ? "" : formatSize(file.size) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FileInfo } from "../utils/fileMgr";
import FileIcon from "./fileIcon.vue";
import { formatSize } from "../utils/common";
const props = defineProps<{
file: FileInfo;
}>();
</script>
<style scoped>
.file-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #ccc;
}
.file-icon {
width: 24px;
height: 24px;
}
.file-name {
flex: 1;
margin-left: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-info {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-left: 10px;
}
</style>

View File

@ -0,0 +1,78 @@
<template>
<Modal
:open="visible"
title="权限设置"
@ok="handleOk"
@cancel="handleCancel"
:maskClosable="false"
>
<div class="permission-container">
<div
class="permission-item"
v-for="(allowed, permission) in localPermissionSet"
:key="permission"
>
<Checkbox v-model:checked="localPermissionSet[permission]">
{{ permissionLabels[permission] }}
</Checkbox>
<div class="permission-desc">{{ permissionDescs[permission] }}</div>
</div>
</div>
</Modal>
</template>
<script setup lang="ts">
import { Modal, Checkbox } from "ant-design-vue";
import { ref, reactive } from "vue";
import { Permission } from "../utils/peer";
import { getPermissionSet, setPermissionSet } from "../utils/common";
const visible = ref(false);
const localPermissionSet = reactive({ ...getPermissionSet() });
const permissionLabels = {
[Permission.edit]: "编辑权限",
[Permission.view]: "查看权限",
[Permission.download]: "下载权限",
};
const permissionDescs = {
[Permission.edit]: "允许创建、修改、删除文件和文件夹",
[Permission.view]: "允许查看文件内容和目录结构",
[Permission.download]: "允许传输文件到本地",
};
const handleOk = () => {
setPermissionSet(localPermissionSet);
visible.value = false;
};
const handleCancel = () => {
Object.assign(localPermissionSet, getPermissionSet());
visible.value = false;
};
const showDialog = () => {
visible.value = true;
};
defineExpose({
showDialog,
});
</script>
<style scoped>
.permission-container {
padding: 10px 0;
}
.permission-item {
margin-bottom: 20px;
}
.permission-desc {
margin-left: 24px;
color: #666;
font-size: 12px;
}
</style>

View File

@ -0,0 +1,465 @@
<template>
<div class="file-reader" v-if="file">
<div class="reader-header">
<h3>{{ file.name }}</h3>
<div class="header-actions">
<button
v-if="file.isRemote && loadedFile.type !== ''"
@click="downloadFile"
>
{{ fileTypeCheck == "unsupported-size" ? "远程传输" : "浏览器下载" }}
</button>
<button v-if="canEdit" @click="toggleEdit">
{{ isEditing ? "保存" : "编辑" }}
</button>
<button @click="$emit('close')">关闭</button>
</div>
</div>
<div class="reader-content">
<!-- 图片预览 -->
<div v-if="fileTypeCheck === 'image'" class="image-viewer">
<img :src="fileUrl" :alt="file.name" />
</div>
<!-- 视频预览 -->
<div v-else-if="fileTypeCheck === 'video'" class="video-viewer">
<video controls>
<source :src="fileUrl" :type="mimeType" />
您的浏览器不支持视频播放
</video>
</div>
<!-- 代码预览 -->
<div v-else-if="fileTypeCheck === 'code'" class="code-viewer">
<div class="code-header">
<select v-model="selectedLanguage">
<option value="auto">自动检测</option>
<option value="javascript">JavaScript</option>
<option value="typescript">TypeScript</option>
<option value="html">HTML</option>
<option value="css">CSS</option>
<option value="python">Python</option>
<option value="java">Java</option>
<option value="cpp">C++</option>
<option value="go">Go</option>
<option value="rust">Rust</option>
<option value="lua">Lua</option>
<option value="sql">SQL</option>
<option value="shell">Shell</option>
<option value="json">JSON</option>
<option value="other">其他</option>
</select>
</div>
<div v-if="isEditing" class="editor">
<textarea
v-model="editingContent"
:style="{ height: editorHeight + 'px', minHeight: '100%' }"
@input="adjustEditorHeight"
></textarea>
</div>
<pre v-else><code v-html="highlightedCode"></code></pre>
</div>
<!-- 文本预览 -->
<div
v-else-if="
fileTypeCheck === 'text' || fileTypeCheck === 'unsupported-type'
"
class="text-viewer"
>
<div v-if="isEditing" class="editor">
<textarea
v-model="editingContent"
:style="{ height: editorHeight + 'px' }"
@input="adjustEditorHeight"
></textarea>
</div>
<pre v-else>{{ fileContent }}</pre>
</div>
<!-- 不支持的文件类型 -->
<div v-else class="unsupported">
{{
fileTypeCheck === "unsupported-size"
? "文件过大,无法预览,请传输后查看"
: "不支持预览该类型的文件"
}}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from "vue";
import {
fileMgrInstance,
type FileData,
type FileInfo,
} from "../utils/fileMgr";
import hljs from "highlight.js";
import "highlight.js/styles/github.css";
import { Modal } from "ant-design-vue";
import { localCurrentFile, remoteCurrentFile } from "../utils/common";
const props = defineProps<{
file: FileInfo;
}>();
const emit = defineEmits<{
(e: "close"): void;
}>();
const fileUrl = ref("");
const fileContent = ref("");
const mimeType = ref("");
const selectedLanguage = ref("auto");
const isEditing = ref(false);
const editingContent = ref("");
const editorHeight = ref(300);
//
const downloadFile = async () => {
if (fileTypeCheck.value == "unsupported-size") {
await props.file.getFile(
false,
localCurrentFile.value.path.replace(
(
await fileMgrInstance.getRootFile()
).path,
""
),
remoteCurrentFile.value.path
);
localCurrentFile.value.loadLocalDirectory();
return;
}
Modal.confirm({
title: "下载文件",
content: `确定要下载${props.file.name}吗?`,
onOk: () => {
const blob = new Blob([loadedFile.value.buffer], {
type: loadedFile.value.type || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = props.file.name;
a.click();
URL.revokeObjectURL(url);
},
});
};
//
const canEdit = computed(() => {
return fileTypeCheck.value === "text" || fileTypeCheck.value === "code";
});
//
const fileTypeCheck = computed(() => {
//30M
if (props.file.size > 30 * 1024 * 1024) {
return "unsupported-size";
}
const ext = props.file.name.split(".").pop()?.toLowerCase() || "";
//
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
return "image";
}
//
if (["mp4", "webm", "ogg"].includes(ext)) {
return "video";
}
//
if (
[
"js",
"ts",
"json",
"html",
"css",
"vue",
"jsx",
"tsx",
"py",
"java",
"cpp",
"c",
"go",
"rs",
"lua",
"sql",
"shell",
].includes(ext)
) {
return "code";
}
//
if (["txt", "md", "log"].includes(ext)) {
return "text";
}
return "unsupported-type";
});
//
const highlightedCode = computed(() => {
if (!fileContent.value) return "";
try {
if (selectedLanguage.value && selectedLanguage.value !== "auto") {
// 使
const result = hljs.highlight(fileContent.value, {
language: selectedLanguage.value,
});
return result.value;
} else {
//
const result = hljs.highlightAuto(fileContent.value);
selectedLanguage.value = result.language || "auto";
return result.value;
}
} catch (err) {
console.error("代码高亮失败:", err);
return fileContent.value;
}
});
//
const toggleEdit = async () => {
if (isEditing.value) {
//
try {
const encoder = new TextEncoder();
const buffer = encoder.encode(editingContent.value);
await props.file.saveFile(buffer);
fileContent.value = editingContent.value;
isEditing.value = false;
} catch (err) {
console.error("保存文件失败:", err);
alert("保存文件失败: " + (err as Error).message);
}
} else {
//
editingContent.value = fileContent.value;
isEditing.value = true;
adjustEditorHeight();
}
};
//
const adjustEditorHeight = () => {
const textarea = document.querySelector(
".editor textarea"
) as HTMLTextAreaElement;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";
editorHeight.value = textarea.scrollHeight;
}
};
const loadedFile = ref<{
type: string;
buffer: ArrayBuffer;
name: string;
size: number;
lastModified: number;
}>({
type: "",
buffer: undefined,
name: "",
size: 0,
lastModified: 0,
});
//
const loadFile = async () => {
try {
const file: FileData = (await props.file.getFile(true)) as FileData;
loadedFile.value = file;
if (file.buffer == null) {
file.buffer = props.file.previewCache;
}
if (fileTypeCheck.value === "image" || fileTypeCheck.value === "video") {
fileUrl.value = URL.createObjectURL(new Blob([file.buffer]));
mimeType.value = file.type;
} else if (
fileTypeCheck.value === "code" ||
fileTypeCheck.value === "text"
) {
fileContent.value = new TextDecoder().decode(file.buffer);
} else {
fileContent.value = new TextDecoder().decode(file.buffer);
}
} catch (err) {
console.error("加载文件失败:", err);
}
};
onMounted(() => {
loadFile();
});
//
onUnmounted(() => {
if (fileUrl.value) {
URL.revokeObjectURL(fileUrl.value);
props.file.clearPreviewCache();
}
});
</script>
<style scoped>
.file-reader {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.98);
z-index: 1000;
display: flex;
flex-direction: column;
padding: 20px;
}
.reader-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.reader-content {
flex: 1;
overflow: auto;
padding: 20px 0;
}
.image-viewer {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.image-viewer img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.video-viewer {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.video-viewer video {
max-width: 100%;
max-height: 100%;
}
.code-viewer,
.text-viewer {
height: 100%;
overflow: auto;
}
.code-viewer pre,
.text-viewer pre {
margin: 0;
padding: 20px;
background: #f5f5f5;
border-radius: 4px;
white-space: pre-wrap;
word-wrap: break-word;
}
.unsupported {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: #666;
}
button {
padding: 6px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
button:hover {
background: #f5f5f5;
}
.code-header {
padding: 10px;
background: #f8f8f8;
border-bottom: 1px solid #eee;
}
.code-header select {
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
}
.code-viewer pre {
margin: 0;
padding: 20px;
background: #f8f8f8;
border-radius: 4px;
overflow-x: auto;
}
.code-viewer code {
font-family: "Fira Code", "Consolas", monospace;
font-size: 14px;
line-height: 1.5;
}
/* 取消 scoped 样式对 highlight.js 的影响 */
:deep(.hljs) {
background: transparent;
padding: 0;
}
.header-actions {
display: flex;
gap: 10px;
}
.editor {
height: 100%;
padding: 20px;
background: #f8f8f8;
border-radius: 4px;
}
.editor textarea {
width: 100%;
min-height: 300px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: "Fira Code", "Consolas", monospace;
font-size: 14px;
line-height: 1.5;
resize: none;
background: white;
}
.editor textarea:focus {
outline: none;
border-color: #4a9eff;
}
</style>

View File

@ -0,0 +1,220 @@
<template>
<div class="transfer-container">
<div class="transfer-header">
传输列表 ({{ tasks.length }})
<div class="transfer-header-btns">
<Button type="ghost" class="clear-btn" @click="clearTasks">清理</Button>
<Button
type="ghost"
class="toggle-btn"
@click="isExpanded = !isExpanded"
>{{ isExpanded ? "▼" : "▲" }}</Button
>
</div>
</div>
<Transition name="file-transfer-view">
<div v-show="isExpanded" class="transfer-content">
<div
v-for="task in tasks"
:key="task.fileData.path"
class="transfer-item"
>
<div class="task-header">
<div class="file-name">{{ task.fileData.name }}</div>
<div class="task-status" :class="task.progress.status">
{{ getStatusText(task.progress.status) }}
</div>
</div>
<div class="file-info">
<span>{{ formatSize(task.fileData.size) }}</span>
<span v-if="task.progress.speed"
>· {{ formatSize(task.progress.speed) }}/s</span
>
</div>
<Progress
:percent="task.progress.percent"
:status="getProgressStatus(task.progress.status)"
/>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { Button } from "ant-design-vue";
import { Progress } from "ant-design-vue";
import {
fileTransferMgrInstance,
type TransferTask,
TransferStatus,
} from "../utils/fileTransfer";
import { type Ref, ref } from "vue";
import { formatSize } from "../utils/common";
const isExpanded = ref(true);
const tasks: Ref<TransferTask[]> = ref([]);
const clearTasks = () => {
fileTransferMgrInstance.getAllFileTransfers().forEach((transfer) => {
transfer.clearCompletedTasks();
});
updateTasks();
};
const getStatusText = (status: TransferStatus) => {
const statusMap: Record<TransferStatus, string> = {
[TransferStatus.WAITING]: "等待中",
[TransferStatus.SENDING]: "发送中",
[TransferStatus.RECEIVING]: "接收中",
[TransferStatus.COMPLETED]: "已完成",
[TransferStatus.ERROR]: "失败",
[TransferStatus.PAUSED]: "已暂停",
};
return statusMap[status] || status;
};
const getProgressStatus = (status: TransferStatus) => {
if (status === TransferStatus.ERROR) return "exception";
if (status === TransferStatus.COMPLETED) return "success";
return "active";
};
const updateTasks = () => {
tasks.value = Array.from(
fileTransferMgrInstance
.getAllFileTransfers()
.flatMap((transfer) => transfer.getTasks())
);
// newTasks.forEach((task) => {
// const index = tasks.value.findIndex(
// (t) => t.fileData.path === task.fileData.path
// );
// if (index === -1) {
// tasks.value.push(task);
// }
// });
};
fileTransferMgrInstance.onTransferChanged((transfer) => {
updateTasks();
});
updateTasks();
</script>
<style scoped>
.transfer-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.transfer-header {
padding: 10px 20px;
background: #f5f5f5;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #ddd;
font-weight: 500;
}
.transfer-content {
overflow-y: auto;
overflow-x: hidden;
max-height: 600px;
}
.transfer-item {
padding: 12px;
padding-left: 20px;
padding-right: 20px;
border-bottom: 1px solid #eee;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.file-name {
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: #f0f0f0;
}
.task-status.completed {
background: #e6f7e6;
color: #52c41a;
}
.task-status.error {
background: #fff1f0;
color: #ff4d4f;
}
.task-status.sending,
.task-status.receiving {
background: #e6f7ff;
color: #1890ff;
}
.task-status.waiting {
background: #f5f5f5;
color: #8c8c8c;
}
.task-status.paused {
background: #fff7e6;
color: #faad14;
}
.file-info {
margin-bottom: 8px;
font-size: 12px;
color: #666;
}
.toggle-icon {
font-size: 12px;
}
.transfer-header-btns {
display: flex;
align-items: flex-end;
}
.clear-btn {
margin-left: 10px;
}
/* 过渡动画 */
.file-transfer-view-enter-active,
.file-transfer-view-leave-active {
transition: max-height 0.5s ease-in-out;
overflow: hidden;
}
.file-transfer-view-enter-from,
.file-transfer-view-leave-to {
max-height: 0;
}
.file-transfer-view-enter-to,
.file-transfer-view-leave-from {
max-height: 600px;
}
</style>

View File

@ -0,0 +1,555 @@
<template>
<div class="file-panel local">
<div class="panel-header">
<div class="path-nav">
<Button @click="navigateLocal('..')" :disabled="!canNavigateUpLocal">
上级目录
</Button>
<Select class="sort-select" :value="sortType" @change="refreshLocal">
<SelectOption value="name">按名称</SelectOption>
<SelectOption value="size">按大小</SelectOption>
<SelectOption value="type">按类型</SelectOption>
<SelectOption value="date">按日期</SelectOption>
</Select>
<Button danger v-if="!isRemote" @click="showPermissionSetting"
>权限设置</Button
>
</div>
<div>
<Breadcrumb>
<BreadcrumbItem v-for="(path, index) in pathSegments" :key="index">
<a @click="navigateToPath(index)">{{ path }}</a>
</BreadcrumbItem>
</Breadcrumb>
</div>
</div>
<div class="file-list" @contextmenu.prevent="showContextMenu($event, null)">
<div
style="text-align: center"
v-if="isRemote && !currentDirInfo?.isInit"
>
<Button @click="refreshLocal">初始化</Button>
</div>
<div v-else-if="!currentDirInfo?.isInit">
<div @click="selectLocalDirectory" class="file-item">
<div class="file-icon">📁</div>
<div class="file-info">
<div class="file-name">请选择一个目录</div>
</div>
</div>
</div>
<FileItem
v-for="file in localFiles"
:key="file.name"
:file="file"
@dblclick="handleLocalFileClick(file)"
:class="{ selected: selectedLocalFiles.includes(file.path) }"
@click="toggleLocalFileSelect(file)"
@contextmenu.stop.prevent="showContextMenu($event, file)"
/>
</div>
<div v-if="currentDirInfo?.isInit" class="panel-footer">
<Button v-if="!isRemote" @click="selectLocalDirectory">选择目录</Button>
<Button @click="refreshLocal">刷新</Button>
<Button @click="createNewFolder">新建文件夹</Button>
<Button @click="createNewFile">新建文件</Button>
<Button @click="deleteSelected" :disabled="!hasSelectedFiles">
删除
</Button>
</div>
<!-- 右键菜单 -->
<div v-if="showMenu" class="context-menu" :style="menuPosition">
<div v-if="activeFile">
<div class="menu-item" @click="handleRename">重命名</div>
<div class="menu-item" @click="handleDelete">删除</div>
<div
class="menu-item"
v-if="props.isRemote && remoteCurrentFile?.isInit"
@click="handleDownload"
>
下载
</div>
</div>
<div v-else-if="!currentDirInfo?.isInit">
<div class="menu-item" @click="selectLocalDirectory">选择本地目录</div>
</div>
<div v-else>
<div class="menu-item" @click="refreshLocal">刷新</div>
<div class="menu-item" @click="createNewFolder">新建文件夹</div>
<div class="menu-item" @click="createNewFile">新建文件</div>
<div class="menu-item" @click="deleteSelected" v-if="hasSelectedFiles">
删除
</div>
</div>
</div>
<!-- 文件预览 -->
<FileReader
v-if="previewFile"
:file="previewFile"
@close="previewFile = null"
/>
<!-- 权限设置对话框 -->
<FilePermissionSetDialog ref="permissionDialogRef" />
</div>
</template>
<script setup lang="ts">
import {
ref,
onMounted,
computed,
onUnmounted,
type Ref,
h,
reactive,
} from "vue";
import { Button, Select, SelectOption, Modal } from "ant-design-vue";
import { FileInfo, fileMgrInstance } from "../utils/fileMgr";
import FileItem from "./fileItem.vue";
import FileReader from "./fileReader.vue";
import { Breadcrumb, BreadcrumbItem } from "ant-design-vue";
import emitter, { EmitterEvent } from "../utils/emitter";
import { localCurrentFile, remoteCurrentFile } from "../utils/common";
import FilePermissionSetDialog from "./filePermissionSetDialog.vue";
const props = defineProps<{
isRemote: boolean;
selectedFiles: string[];
}>();
//
const currentLocalPath = ref("/");
const localFiles: Ref<FileInfo[]> = ref([]);
const selectedLocalFiles = ref(props.selectedFiles);
const newFileName = ref("");
const folderName = ref("");
const fileName = ref("");
const currentDirInfo = props.isRemote ? remoteCurrentFile : localCurrentFile;
const sortType = ref<"name" | "size" | "type" | "date">("type");
currentDirInfo.value = props.isRemote ? fileMgrInstance.remoteRootFile : null;
//
const showMenu = ref(false);
const menuPosition = ref({ top: "0px", left: "0px" });
const activeFile: Ref<FileInfo> = ref(null);
//
const previewFile: Ref<FileInfo> = ref(null);
//
const canNavigateUpLocal = ref(false);
const hasSelectedFiles = computed(() => selectedLocalFiles.value.length > 0);
//
const pathSegments = computed(() => {
return currentLocalPath.value.split("/").filter((p) => p);
});
//
const navigateToPath = (index: number) => {
const targetPath = pathSegments.value.slice(0, index + 1).join("/");
navigateLocal(targetPath);
};
//
!props.isRemote &&
emitter.on(EmitterEvent.choosed_local_directory, async (file: FileInfo) => {
currentDirInfo.value = await fileMgrInstance.getRootFile();
await refreshLocal();
});
//
const selectLocalDirectory = async () => {
try {
await fileMgrInstance.selectLocalDirectory();
} catch (err) {
console.error("选择目录失败:", err);
}
};
const handleDownload = async () => {
if (!activeFile.value) return;
const file = activeFile.value;
await fileMgrInstance.getRootFile();
await file.getFile(
false,
localCurrentFile.value.path.replace(
(
await fileMgrInstance.getRootFile()
).path,
""
),
remoteCurrentFile.value.path
);
};
//
emitter.on(EmitterEvent.update_directory, async (file: FileInfo) => {
if (file.isRemote != props.isRemote) return;
if (currentDirInfo.value.path == file.path) {
const files = currentDirInfo.value.files;
for (const file of files) {
if (!file.isDirectory) {
file.size = await file.getFileSize();
}
}
currentLocalPath.value = currentDirInfo.value.path;
localFiles.value = files;
canNavigateUpLocal.value = currentDirInfo.value.parentFileInfo != null;
}
});
const refreshLocal = async () => {
try {
if (!currentDirInfo.value) {
throw new Error("当前目录为空");
}
await currentDirInfo.value.loadLocalDirectory(sortType.value);
} catch (err) {
console.error("刷新目录失败:", err);
}
};
const navigateLocal = async (path: string) => {
try {
if (path === "..") {
if (currentDirInfo.value?.parentFileInfo) {
currentDirInfo.value = currentDirInfo.value.parentFileInfo;
currentLocalPath.value = currentDirInfo.value.path;
}
} else {
let root = currentDirInfo.value?.isRemote
? fileMgrInstance.remoteRootFile
: await fileMgrInstance.getRootFile();
const targetDir = root.getFileInfo(path);
if (targetDir) {
currentDirInfo.value = targetDir;
currentLocalPath.value = targetDir.path;
}
}
selectedLocalFiles.value.length = 0;
await refreshLocal();
} catch (err) {
console.error("导航失败:", err);
}
};
const handleLocalFileClick = async (file: FileInfo) => {
if (file.isDirectory) {
await navigateLocal(file.path);
} else {
previewFile.value = file;
}
};
const toggleLocalFileSelect = (file: FileInfo) => {
const index = selectedLocalFiles.value.indexOf(file.path);
if (index === -1) {
selectedLocalFiles.value.push(file.path);
} else {
selectedLocalFiles.value.splice(index, 1);
}
};
//
const showContextMenu = (event: MouseEvent, file: FileInfo) => {
event.preventDefault();
showMenu.value = true;
menuPosition.value = {
top: `${event.clientY}px`,
left: `${event.clientX}px`,
};
activeFile.value = file;
console.log(props.isRemote && localCurrentFile.value?.isInit);
};
//
const closeContextMenu = () => {
showMenu.value = false;
activeFile.value = null;
};
//
const handleRename = async () => {
if (!activeFile.value) return;
newFileName.value = activeFile.value.name;
const acf = activeFile.value;
Modal.confirm({
title: "重命名",
class: "custom-modal",
content: h("div", [
h("input", {
class: "ant-input",
placeholder: "请输入新的名称",
value: newFileName.value,
onInput: (e: Event) => {
newFileName.value = (e.target as HTMLInputElement).value;
},
style: {
width: "100%",
marginTop: "8px",
},
}),
]),
okText: "确定",
cancelText: "取消",
async onOk() {
if (!newFileName.value || newFileName.value === acf.name) {
return Promise.reject();
}
try {
await acf.renameFile(newFileName.value);
await refreshLocal();
} catch (err) {
console.error("重命名失败:", err);
Modal.error({
title: "重命名失败",
content: (err as Error).message || (err as string),
});
}
},
});
closeContextMenu();
};
//
const handleDelete = async () => {
if (activeFile.value) {
if (!selectedLocalFiles.value.includes(activeFile.value.name)) {
selectedLocalFiles.value.push(activeFile.value.name);
}
}
deleteSelected();
closeContextMenu();
};
//
const deleteSelected = async () => {
if (!selectedLocalFiles.value.length) return;
Modal.confirm({
title: "确认删除",
content: `确定要删除以下文件吗?<br>${selectedLocalFiles.value.join(
"<br>"
)}<br> ${selectedLocalFiles.value.length} 个文件`,
okText: "删除",
okType: "danger",
cancelText: "取消",
async onOk() {
try {
for (const fileName of selectedLocalFiles.value) {
const file = localFiles.value.find((f) => f.path === fileName);
if (file) {
await file.deleteItself();
}
}
selectedLocalFiles.value = [];
await refreshLocal();
} catch (err) {
console.error("批量删除失败:", err);
Modal.error({
title: "删除失败",
content: (err as Error).message,
});
}
},
});
};
//
const createNewFolder = async () => {
if (!currentDirInfo) return;
folderName.value = "";
Modal.confirm({
title: "新建文件夹",
class: "custom-modal",
content: h("div", [
h("input", {
class: "ant-input",
placeholder: "请输入文件夹名称",
value: folderName.value,
onInput: (e: Event) => {
folderName.value = (e.target as HTMLInputElement).value;
},
style: {
width: "100%",
marginTop: "8px",
},
}),
]),
okText: "创建",
cancelText: "取消",
async onOk() {
if (!folderName.value) {
Modal.warning({
title: "提示",
content: "请输入文件夹名称",
});
return Promise.reject();
}
try {
await currentDirInfo.value.createDirectory(folderName.value);
await refreshLocal();
} catch (err) {
console.error("创建文件夹失败:", err);
Modal.error({
title: "创建失败",
content: (err as Error).message,
});
}
},
});
};
//
const createNewFile = async () => {
if (!currentDirInfo) return;
fileName.value = "";
Modal.confirm({
title: "新建文件",
class: "custom-modal",
content: h("div", [
h("input", {
class: "ant-input",
placeholder: "请输入文件名称",
value: fileName.value,
onInput: (e: Event) => {
fileName.value = (e.target as HTMLInputElement).value;
},
style: {
width: "100%",
marginTop: "8px",
},
}),
]),
okText: "创建",
cancelText: "取消",
async onOk() {
if (!fileName.value) {
Modal.warning({
title: "提示",
content: "请输入文件名称",
});
return Promise.reject();
}
try {
const emptyBuffer = new ArrayBuffer(0);
await currentDirInfo.value.createFile(fileName.value, emptyBuffer);
await refreshLocal();
} catch (err) {
console.error("创建文件失败:", err);
Modal.error({
title: "创建失败",
content: (err as Error).message,
});
}
},
});
};
//
onMounted(() => {
document.addEventListener("click", closeContextMenu);
});
onUnmounted(() => {
document.removeEventListener("click", closeContextMenu);
});
const permissionDialogRef = ref();
const showPermissionSetting = () => {
permissionDialogRef.value?.showDialog();
};
</script>
<style scoped>
.file-panel {
height: 100%;
display: flex;
flex-direction: column;
border: 1px solid #ddd;
}
.panel-header {
padding: 10px;
border-bottom: 1px solid #ddd;
}
.path-nav {
display: flex;
align-items: center;
gap: 10px;
}
.file-list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.file-item {
display: flex;
align-items: center;
padding: 8px;
cursor: pointer;
border-radius: 4px;
margin-bottom: 4px;
}
.file-item:hover {
background-color: #f5f5f5;
}
.file-item.selected {
background-color: #e3f2fd;
}
.file-icon {
margin-right: 10px;
font-size: 1.2em;
}
.file-info {
flex: 1;
}
.file-name {
font-weight: 500;
}
.file-meta {
font-size: 0.8em;
color: #666;
}
.panel-footer {
padding: 10px;
border-top: 1px solid #ddd;
display: flex;
gap: 10px;
}
.context-menu {
position: fixed;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 4px 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.menu-item {
padding: 8px 16px;
cursor: pointer;
}
.menu-item:hover {
background-color: #f5f5f5;
}
.sort-select {
width: 100px;
}
</style>

View File

@ -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<FileInfo> = ref(null);
export let remoteCurrentFile: Ref<FileInfo> = 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);
}

View File

@ -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',
}

View File

@ -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<FileInfo[]> {
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<number> {
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<string> {
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<FileSystemDirectoryHandle> {
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<number> {
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<FileData | FileData[]> {
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<boolean> {
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<FileInfo> {
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;
}
}

View File

@ -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<void>;
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<boolean> {
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<boolean> => {
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<void> {
this.updateProgress()
return peer.send({
type: MessageType.push_file_chunk,
data: chunk,
}, this.conn);
}
private async sendFileComplete(): Promise<boolean> {
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<void> {
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<string, FileTransfer> = 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()

View File

@ -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<string, DataConnection> = 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<string, (data: any) => void> = new Map()
async send(data: Message, conn: DataConnection = this.remoteConnection, isHandleResponse: boolean = false): Promise<any> {
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);
});
}

6
src/shime-uni.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
export {}
declare module 'vue' {
type Hooks = App.AppInstance & Page.PageInstance
interface ComponentCustomOptions extends Hooks {}
}