p2p-explorer-web/src/pages/file/index.vue
2026-05-07 22:42:54 +08:00

622 lines
14 KiB
Vue

<template>
<DesktopView :peerId="sign2peerid(targetId)" />
<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>
<span
v-if="connectedPeerId"
class="connection-badge"
:class="{ inbound: isInboundConnected }"
>
{{ isInboundConnected ? "被连接" : "已连接" }}:
<span class="connected-peer">{{ connectedPeerLabel }}</span>
</span>
</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">
<div class="connect-actions">
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isDesktopActive }"
title="桌面预览"
@click="requestDesktop"
>
<img src="/static/desktop.png" alt="桌面" />
</div>
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isCallActive }"
title="语音通话"
@click="requestCall"
>
<img src="/static/phone.png" alt="通话" />
</div>
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isCameraActive }"
title="摄像头"
@click="requestCamera"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
</div>
</div>
<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 DesktopView from "./item/desptopView.vue";
import {
formatSize,
getUrlParam,
localCurrentFile,
remoteCurrentFile,
sign2peerid,
} from "./utils/common";
import FileTransferView from "./item/fileTranserView.vue";
const isPhone = ref(false);
const receiveLoading = ref(false);
const myId = ref("");
const targetId = ref("");
const connectedPeerId = ref("");
const connectedPeerLabel = ref("");
const isInboundConnected = ref(false);
const isConnected = ref(false);
const isDesktopActive = ref(false);
const isCallActive = ref(false);
const isCameraActive = ref(false);
// 文件系统相关
const selectedLocalFiles = ref<string[]>([]);
const selectedRemoteFiles = ref<string[]>([]);
//根据文档宽度
onMounted(() => {
isPhone.value = document.body.clientWidth < 768;
});
const requestDesktop = () => {
if (!isConnected.value) return;
if (isDesktopActive.value) {
peer.endMedia(sign2peerid(targetId.value), "desktop");
return;
}
peer.requestDesktop(targetId.value);
};
const requestCall = () => {
if (!isConnected.value) return;
if (isCallActive.value) {
peer.endMedia(sign2peerid(targetId.value), "call");
return;
}
peer.requestCall(targetId.value);
};
const requestCamera = () => {
if (!isConnected.value) return;
if (isCameraActive.value) {
peer.endMedia(sign2peerid(targetId.value), "camera");
return;
}
peer.requestCamera(targetId.value);
};
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);
};
const updateConnectedPeer = (peerId: string, inbound: boolean) => {
connectedPeerId.value = peerId;
connectedPeerLabel.value = inbound ? `${peerId.slice(0, 8)}...` : targetId.value;
isInboundConnected.value = inbound;
isConnected.value = !inbound;
};
const clearConnectedPeer = (peerId?: string) => {
if (peerId && connectedPeerId.value && connectedPeerId.value !== peerId) return;
connectedPeerId.value = "";
connectedPeerLabel.value = "";
isInboundConnected.value = false;
isConnected.value = false;
};
const isActivePeer = (peerId: string) => {
const targetPeerId = targetId.value ? sign2peerid(targetId.value) : "";
return peerId === connectedPeerId.value || peerId === targetPeerId;
};
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();
updateConnectedPeer(event.detail.peer, false);
fileMgrInstance.remoteRootFile.loadLocalDirectory();
notification.success({
message: "连接成功",
});
} else {
updateConnectedPeer(event.detail.peer, true);
notification.success({
message: "出现新的连接",
});
}
}) as EventListener);
peer.on("peer-disconnected", ((event: CustomEvent) => {
clearConnectedPeer(event.detail.peer);
isDesktopActive.value = false;
isCallActive.value = false;
isCameraActive.value = false;
notification.error({
message: event.detail.peer + "连接已断开",
});
}) as EventListener);
peer.on("error", ((event: CustomEvent) => {
notification.error({
message: "发生错误",
});
console.error("连接错误:", event.detail);
}) as EventListener);
peer.on("stream", ((event: CustomEvent) => {
if (!isActivePeer(event.detail.peerId)) return;
if (event.detail.type === "desktop") {
isDesktopActive.value = true;
} else if (event.detail.type === "call") {
isCallActive.value = true;
} else if (event.detail.type === "camera") {
isCameraActive.value = true;
}
}) as EventListener);
peer.on("desktop-started", ((event: CustomEvent) => {
if (isActivePeer(event.detail.peerId)) {
isDesktopActive.value = true;
}
}) as EventListener);
peer.on("call-started", ((event: CustomEvent) => {
if (isActivePeer(event.detail.peerId)) {
isCallActive.value = true;
}
}) as EventListener);
peer.on("camera-started", ((event: CustomEvent) => {
if (isActivePeer(event.detail.peerId)) {
isCameraActive.value = true;
}
}) as EventListener);
peer.on("media-ended", ((event: CustomEvent) => {
if (!isActivePeer(event.detail.peerId)) return;
if (event.detail.type === "desktop") {
isDesktopActive.value = false;
} else if (event.detail.type === "call") {
isCallActive.value = false;
} else if (event.detail.type === "camera") {
isCameraActive.value = false;
}
}) 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;
}
.connection-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid #95de64;
border-radius: 999px;
background: #f6ffed;
color: #389e0d;
font-size: 12px;
white-space: nowrap;
}
.connection-badge.inbound {
border-color: #91caff;
background: #e6f4ff;
color: #0958d9;
}
.connected-peer {
font-family: monospace;
}
.connect-item {
display: flex;
align-items: center;
gap: 10px;
width: 30px;
height: 30px;
background: #dff1ff;
border-radius: 8px;
cursor: pointer;
border: 1px solid #82c7fb;
}
.connect-item:hover {
background: #cae5f9;
}
.connect-item.active {
background: #b7eb8f;
border-color: #52c41a;
}
.connect-item.active:hover {
background: #95de64;
}
.connect-item.disabled {
background: #f0f0f0;
border-color: #d9d9d9;
cursor: not-allowed;
opacity: 0.55;
}
.connect-item.disabled:hover {
background: #f0f0f0;
}
.connect-item.disabled img {
filter: grayscale(1);
}
.connect-item img {
width: 100%;
height: 100%;
}
.connect-item svg {
width: 22px;
height: 22px;
margin: auto;
color: #1677ff;
}
.connect-actions,
.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>