411 lines
8.5 KiB
Vue
411 lines
8.5 KiB
Vue
<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" :class="{ 'mobile-layout': isPhone }">
|
|
<!-- 本地文件区域 -->
|
|
<FileView
|
|
v-if="!isPhone"
|
|
:isRemote="false"
|
|
:selectedFiles="selectedLocalFiles"
|
|
/>
|
|
|
|
<!-- 传输控制 -->
|
|
<div
|
|
v-if="!isPhone"
|
|
class="transfer-controls"
|
|
:class="{ 'mobile-controls': isPhone }"
|
|
>
|
|
<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 isPhone = ref(false);
|
|
const receiveLoading = ref(false);
|
|
const myId = ref("");
|
|
const targetId = ref("");
|
|
const isConnected = ref(false);
|
|
// 文件系统相关
|
|
const selectedLocalFiles = ref<string[]>([]);
|
|
const selectedRemoteFiles = ref<string[]>([]);
|
|
//根据文档宽度
|
|
onMounted(() => {
|
|
isPhone.value = document.body.clientWidth < 768;
|
|
});
|
|
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.init();
|
|
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-transfer.mobile-layout {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* 移动端适配样式 */
|
|
@media screen and (max-width: 768px) {
|
|
.status-bar {
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.connect-section {
|
|
width: 100%;
|
|
}
|
|
|
|
.connect-section input {
|
|
flex: 1;
|
|
}
|
|
|
|
.status-info {
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: space-around;
|
|
}
|
|
}
|
|
</style>
|