811 lines
20 KiB
Vue
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>
|