p2p-explorer-web/src/pages/file/index.vue
2026-05-11 15:51:41 +08:00

811 lines
20 KiB
Vue

<template>
<DesktopView :peerId="sign2peerid(targetId)" />
<div class="container">
<!-- 连接状态 -->
<div class="status-bar">
<div class="peer-info">
{{ $t("index.myId") }}:
<span class="id-text">{{
myId || $t("index.waitingForConnection")
}}</span>
<Button type="primary" @click="copyId" v-if="myId">{{
$t("index.copy")
}}</Button>
<Button type="link" @click="shareUrl" v-if="myId">{{
$t("index.share")
}}</Button>
<span
v-if="connectedPeerId"
class="connection-badge"
:class="{ inbound: isInboundConnected }"
>
{{
isInboundConnected
? $t("index.connectedBy")
: $t("index.connected")
}}:
<span class="connected-peer">{{ connectedPeerLabel }}</span>
<Button
v-if="isInboundConnected && connectedPeerSign"
type="link"
size="small"
@click="copyPeerSign"
>{{ $t("index.copy") }}</Button
>
</span>
</div>
<!-- 显示流量 丢包率-->
<div class="status-info">
<div class="status-item">
<span>{{ $t("index.traffic") }}:</span>
<span>{{ formatSize(transInfo.bytes) }}/s</span>
</div>
<div class="status-item">
<span>{{ $t("index.packets") }}:</span>
<span>{{ transInfo.packets }}/s</span>
</div>
</div>
<div class="status-actions">
<div class="connect-section">
<div class="connect-actions">
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isDesktopActive }"
:title="$t('index.desktopPreview')"
@click="requestDesktop"
>
<img
src="/static/desktop.png"
:alt="$t('index.desktopPreview')"
/>
</div>
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isCallActive }"
:title="$t('index.voiceCall')"
@click="requestCall"
>
<img src="/static/phone.png" :alt="$t('index.voiceCall')" />
</div>
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isCameraActive }"
:title="$t('index.camera')"
@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 ? $t('common.pleaseWait') : $t('index.enterPeerId')
"
:disabled="isConnected || !myId"
/>
<Button
@click="handleConnect"
:disabled="!targetId || isConnected"
:loading="isConnecting"
>
{{ isConnected ? $t("index.connectedBtn") : $t("index.connect") }}
</Button>
</div>
<a-dropdown>
<div class="lang-switcher">
<svg
t="1778484837023"
class="icon lang-switcher-icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="1747"
>
<path
d="M846.04 866.77c-17.08 2.03-32.57-10.18-34.59-27.26-0.22-1.9-0.27-3.81-0.15-5.71v-123c0-33.73-22.17-33.73-30.53-33.73-21.28-0.46-38.91 16.43-39.36 37.72-0.01 0.46-0.01 0.92 0 1.37v117.66c-0.76 18.9-16.71 33.61-35.61 32.84-17.83-0.72-32.12-15.01-32.84-32.84V647.68c-1.23-17.23 11.74-32.19 28.97-33.41 1.69-0.12 3.39-0.1 5.08 0.05a31.953 31.953 0 0 1 31.33 17.76 89.435 89.435 0 0 1 54.99-17.76c54.11 0 86.45 33.59 86.45 90.03V833.8a32.25 32.25 0 0 1-8.88 23.72 34.026 34.026 0 0 1-24.82 9.33l-0.04-0.08z m-233.12-7.46h-134.7c-42.77 0-61.85-18.96-61.85-61.57V608.07c0-42.52 19.09-61.57 61.85-61.57h128.74c17.92 0 32.45 14.53 32.45 32.45s-14.53 32.45-32.45 32.45H490.73c-1.22-0.08-2.45 0.09-3.6 0.5 0.13 0-0.15 0.8-0.15 2.89v52.58h106c16.33-1.66 30.91 10.24 32.57 26.57 0.17 1.68 0.2 3.37 0.08 5.06 0.98 16.66-11.73 30.97-28.4 31.95-1.41 0.08-2.83 0.07-4.24-0.05H486.9V791c-0.04 1.06 0.08 2.13 0.35 3.15 1.12 0.15 2.25 0.23 3.38 0.24h122.31c16.96-1.07 31.58 11.81 32.65 28.76 0.07 1.16 0.08 2.33 0.02 3.5 1.35 16.68-11.07 31.3-27.75 32.65-1.64 0.13-3.28 0.13-4.92 0h-0.02zM327.54 482.85c-17.36 2.36-33.34-9.8-35.7-27.16-0.3-2.21-0.37-4.44-0.2-6.67V370.5h-85.27c-45.86 0-66.31-20.52-66.31-66.31v-93.87c0-45.58 20.52-65.9 66.31-65.9h85.27v-31.53c-1.38-17.11 11.37-32.11 28.48-33.49 1.92-0.15 3.84-0.13 5.76 0.07 30.26 0 36.63 18.17 36.63 33.42v31.59h86.09c45.86 0 66.33 20.34 66.33 65.88v93.89c0 45.86-20.52 66.29-66.33 66.29h-86.05v78.52c1.25 17.47-11.9 32.65-29.37 33.91-1.88 0.13-3.76 0.1-5.63-0.1v-0.02zM217.21 211.27c-6.47 0-7.07 0.6-7.07 7.07v78.2c0 6.53 0.6 7.15 7.07 7.15h74.43v-92.42h-74.43z m145.35 92.38h75.29c6.29 0 7.09-0.8 7.09-7.07v-78.25c0-6.29-0.8-7.09-7.09-7.09h-75.31v92.42h0.02z m151.42 655.91C266.43 958.82 66.36 757.55 67.1 510c0.1-35 4.31-69.86 12.52-103.88 4.81-19 23.92-30.68 43.03-26.29 19.1 4.61 30.86 23.81 26.29 42.91-48.93 202.33 75.42 406.01 277.75 454.94a376.924 376.924 0 0 0 87.29 10.56c19.69 0.02 35.64 15.99 35.63 35.69-0.02 19.67-15.96 35.61-35.63 35.63z m398.49-310.05c-19.69 0-35.66-15.96-35.66-35.65 0-2.95 0.37-5.9 1.09-8.76 51.31-201.82-70.7-407.02-272.52-458.33-29.89-7.6-60.59-11.5-91.43-11.62-19.68 0-35.64-15.95-35.64-35.63 0-19.68 15.95-35.64 35.63-35.64h0.01c247.57 0.76 447.65 202.08 446.89 449.65-0.11 36.8-4.76 73.44-13.83 109.1-4 15.8-18.23 26.88-34.54 26.88z"
fill="currentColor"
p-id="1748"
></path>
</svg>
</div>
<template #overlay>
<a-menu @click="handleLangMenuClick">
<a-menu-item key="zh-CN">中文</a-menu-item>
<a-menu-item key="en-US">English</a-menu-item>
<a-menu-item key="ja-JP">日本語</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</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"
>
{{ $t("index.send") }}
</Button>
<Button
type="primary"
:disabled="!isConnected || selectedRemoteFiles.length === 0"
@click="handleReceive"
:loading="receiveLoading"
>
{{ $t("index.receive") }}
</Button>
</div>
<!-- 远程文件区域 -->
<FileView :isRemote="true" :selectedFiles="selectedRemoteFiles" />
</div>
</div>
<div class="author-footer">
<a href="https://kuraa.cc" target="_blank" rel="noopener">{{
$t("index.kuraaFooter")
}}</a>
</div>
<FileTransferView />
</template>
<script setup lang="ts">
import Clipboard from "./item/clipboard.vue";
import { ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
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 { locale, t } = useI18n();
const handleLangMenuClick = ({ key }: { key: string }) => {
locale.value = key;
const url = new URL(window.location.href);
url.searchParams.set("lang", key);
window.history.replaceState({}, "", url.href);
};
const isPhone = ref(false);
const receiveLoading = ref(false);
const myId = ref("");
const targetId = ref("");
const connectedPeerId = ref("");
const connectedPeerLabel = ref("");
const connectedPeerSign = ref("");
const isInboundConnected = ref(false);
const isConnected = ref(false);
const isConnecting = 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 +
"&lang=" +
locale.value;
await copyToClipboard(url);
notification.success({
message: t("index.linkCopied"),
description: t("index.linkCopiedDesc"),
});
}
};
// 复制ID
const copyId = async () => {
if (myId.value) {
try {
await copyToClipboard(myId.value);
notification.success({
message: t("index.idCopied"),
description: t("index.idCopiedDesc"),
});
} catch (error) {
notification.error({
message: t("index.copyFailed"),
icon: "error",
});
}
}
};
const copyPeerSign = async () => {
if (connectedPeerSign.value) {
try {
await copyToClipboard(connectedPeerSign.value);
notification.success({
message: t("index.idCopied"),
description: t("index.idCopiedConnectDesc"),
});
} catch {
notification.error({ message: t("index.copyFailed") });
}
}
};
//复制到剪贴板
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
};
// 连接处理
const handleConnect = () => {
if (!targetId.value) return;
isConnecting.value = true;
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;
isConnecting.value = false;
};
const clearConnectedPeer = (peerId?: string) => {
if (peerId && connectedPeerId.value && connectedPeerId.value !== peerId)
return;
connectedPeerId.value = "";
connectedPeerLabel.value = "";
connectedPeerSign.value = "";
isInboundConnected.value = false;
isConnected.value = false;
isConnecting.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: t("index.notCompleted"),
description: t("index.inDevelopment"),
});
};
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: t("index.receiveFilesFailed"),
});
}
receiveLoading.value = false;
await localCurrentFile.value.loadLocalDirectory();
};
// 事件监听
onMounted(async () => {
await 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: t("index.connectSuccess"),
});
} else {
updateConnectedPeer(event.detail.peer, true);
notification.success({
message: t("index.newConnection"),
});
}
}) 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 + t("index.disconnected"),
});
}) as EventListener);
peer.on("exchange-sign", ((event: CustomEvent) => {
if (event.detail.peerId === connectedPeerId.value) {
connectedPeerSign.value = event.detail.sign;
if (isInboundConnected.value) {
connectedPeerLabel.value = event.detail.sign;
}
}
}) as EventListener);
peer.on("error", ((event: CustomEvent) => {
isConnecting.value = false;
notification.error({
message: t("index.errorOccurred"),
});
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;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding: 10px;
background: #f5f5f5;
border-radius: 8px;
}
.peer-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.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: 35px;
height: 35px;
margin: auto;
color: #1677ff;
}
.status-info {
display: flex;
align-items: center;
gap: 12px;
}
.status-actions,
.connect-actions,
.connect-section {
display: flex;
align-items: center;
gap: 10px;
}
.status-actions {
margin-left: auto;
flex-wrap: wrap;
justify-content: flex-end;
}
.connect-section {
flex-wrap: wrap;
}
.connect-section input {
min-width: 220px;
}
.lang-switcher {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid #d9e6f7;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
color: #1677ff;
transition:
background 0.2s,
border-color 0.2s;
}
.lang-switcher:hover {
background: #f0f6ff;
border-color: #91caff;
}
.lang-switcher-icon {
width: 22px;
height: 22px;
}
.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;
}
.author-footer {
position: fixed;
right: 16px;
bottom: 12px;
z-index: 999;
line-height: 1;
}
.author-footer a {
color: #bbb;
font-size: 12px;
text-decoration: none;
transition: color 0.2s;
}
.author-footer a:hover {
color: #007aff;
}
/* 移动端适配样式 */
@media screen and (max-width: 768px) {
.status-bar {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.peer-info,
.status-info,
.status-actions,
.connect-section {
width: 100%;
}
.status-actions {
margin-left: 0;
justify-content: space-between;
}
.connect-section input {
flex: 1;
min-width: 0;
}
.status-info {
justify-content: space-around;
}
.lang-switcher {
margin-left: auto;
}
}
</style>