This commit is contained in:
2026-02-04 02:21:23 +08:00
commit 208786aa90
112 changed files with 9571 additions and 0 deletions

39
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import {
NConfigProvider,
NGlobalStyle,
NMessageProvider,
NDialogProvider,
darkTheme,
} from "naive-ui";
import MainLayout from "./components/MainLayout.vue";
const themeOverrides = {
common: {
primaryColor: "#38bdf8",
primaryColorHover: "#0ea5e9",
},
Card: {
borderColor: "#334155",
},
};
</script>
<template>
<n-config-provider :theme="darkTheme" :theme-overrides="themeOverrides">
<n-global-style />
<n-dialog-provider>
<n-message-provider>
<MainLayout />
</n-message-provider>
</n-dialog-provider>
</n-config-provider>
</template>
<style>
body,
#app,
.n-config-provider {
font-family: "Noto Sans", "Roboto", "Segoe UI", sans-serif !important;
}
</style>

View File

@@ -0,0 +1,333 @@
<script lang="ts" setup>
import { onMounted, ref, computed, h } from "vue";
import PeerCard from "./PeerCard.vue";
import TransferItem from "./TransferItem.vue";
import {
NLayout,
NLayoutHeader,
NLayoutContent,
NLayoutSider,
NSpace,
NText,
NEmpty,
NGrid,
NGi,
NMenu,
NBadge,
NButton,
NIcon,
NDrawer,
NDrawerContent,
useDialog,
NInput,
} from "naive-ui";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faSatelliteDish,
faInbox,
faBars,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import { type MenuOption } from "naive-ui";
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
import {
GetPeers,
GetPeerByIP,
} from "../../bindings/mesh-drop/internal/discovery/service";
import { Events } from "@wailsio/runtime";
import {
GetTransferList,
SendFile,
SendText,
} from "../../bindings/mesh-drop/internal/transfer/service";
import { Dialogs, Clipboard } from "@wailsio/runtime";
const peers = ref<Peer[]>([]);
const transferList = ref<Transfer[]>([]);
const activeKey = ref("discover");
const showMobileMenu = ref(false);
const isMobile = ref(false);
// 监听窗口大小变化更新 isMobile
onMounted(() => {
checkMobile();
window.addEventListener("resize", checkMobile);
});
const checkMobile = () => {
isMobile.value = window.innerWidth < 768;
if (!isMobile.value) showMobileMenu.value = false;
};
// --- 菜单选项 ---
const renderIcon = (icon: any) => {
return () => h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon }) });
};
const menuOptions = computed<MenuOption[]>(() => [
{
label: "Discover",
key: "discover",
icon: renderIcon(faSatelliteDish),
},
{
label: () =>
h(
"div",
{
style:
"display: flex; align-items: center; justify-content: space-between; width: 100%",
},
[
"Transfers",
pendingCount.value > 0 ?
h(NBadge, { value: pendingCount.value, max: 99, type: "error" })
: null,
],
),
key: "transfers",
icon: renderIcon(faInbox),
},
]);
// --- 后端集成 ---
onMounted(async () => {
peers.value = await GetPeers();
});
// --- 事件监听 ---
Events.On("peers:update", (event) => {
peers.value = event.data;
});
Events.On("transfer:refreshList", async () => {
transferList.value = await GetTransferList();
});
// --- 计算属性 ---
const pendingCount = computed(() => {
return transferList.value.filter(
(t) => t.type === "receive" && t.status === "pending",
).length;
});
// --- 操作 ---
const dialog = useDialog();
const handleSendFile = async (ip: string) => {
try {
const filePath = await Dialogs.OpenFile({
Title: "Select file to send",
});
if (!filePath) return;
const peer = await GetPeerByIP(ip);
if (!peer) return;
await SendFile(peer, ip, filePath);
activeKey.value = "transfers";
} catch (e: any) {
console.error(e);
alert("Failed to send file: " + e);
}
};
const handleSendFolder = async (ip: string) => {
// TODO
};
const handleSendText = (ip: string) => {
const textContent = ref("");
const d = dialog.create({
title: "Send Text",
content: () =>
h(NInput, {
value: textContent.value,
"onUpdate:value": (v) => (textContent.value = v),
type: "textarea",
placeholder: "Type something to send...",
autosize: { minRows: 3, maxRows: 8 },
}),
positiveText: "Send",
negativeText: "Cancel",
onPositiveClick: async () => {
if (!textContent.value) return;
try {
const peer = await GetPeerByIP(ip);
if (!peer) return;
await SendText(peer, ip, textContent.value);
activeKey.value = "transfers";
} catch (e: any) {
console.error(e);
alert("Failed to send text: " + e);
}
},
});
};
const handleSendClipboard = async (ip: string) => {
const text = await Clipboard.Text();
if (!text) {
alert("Clipboard is empty");
return;
}
const peer = await GetPeerByIP(ip);
if (!peer) return;
await SendText(peer, ip, text);
activeKey.value = "transfers";
};
const removeTransfer = (id: string) => {
transferList.value = transferList.value.filter((t) => t.id !== id);
};
const handleMenuUpdate = (key: string) => {
activeKey.value = key;
showMobileMenu.value = false;
};
</script>
<template>
<!-- 小尺寸头部 -->
<n-layout-header v-if="isMobile" bordered class="mobile-header">
<n-space
align="center"
justify="space-between"
style="height: 100%; padding: 0 16px">
<n-text class="logo">Mesh Drop</n-text>
<n-button
text
style="font-size: 24px"
@click="showMobileMenu = !showMobileMenu">
<n-icon>
<FontAwesomeIcon :icon="showMobileMenu ? faXmark : faBars" />
</n-icon>
</n-button>
</n-space>
</n-layout-header>
<!-- 小尺寸抽屉菜单 -->
<n-drawer
v-model:show="showMobileMenu"
placement="top"
height="200"
v-if="isMobile">
<n-drawer-content>
<n-menu
:value="activeKey"
:options="menuOptions"
@update:value="handleMenuUpdate" />
</n-drawer-content>
</n-drawer>
<n-layout
has-sider
position="absolute"
:style="{ top: isMobile ? '64px' : '0' }">
<!-- 桌面端侧边栏 -->
<n-layout-sider
v-if="!isMobile"
bordered
width="240"
content-style="padding: 24px;">
<div class="desktop-logo">
<n-text class="logo">Mesh Drop</n-text>
</div>
<n-menu
:value="activeKey"
:options="menuOptions"
@update:value="handleMenuUpdate" />
</n-layout-sider>
<n-layout-content class="content">
<div class="content-container">
<!-- 发现页视图 -->
<div v-if="activeKey === 'discover'">
<n-space vertical size="large" v-if="peers.length > 0">
<n-grid x-gap="16" y-gap="16" cols="1 500:2 700:3">
<n-gi v-for="peer in peers" :key="peer.id">
<PeerCard
:peer="peer"
@sendFile="handleSendFile"
@sendFolder="handleSendFolder"
@sendText="handleSendText"
@sendClipboard="handleSendClipboard" />
</n-gi>
</n-grid>
</n-space>
<div v-else class="empty-state">
<n-empty description="Scanning for peers...">
<template #icon>
<n-icon class="radar-icon">
<FontAwesomeIcon :icon="faSatelliteDish" />
</n-icon>
</template>
</n-empty>
</div>
</div>
<!-- 传输列表视图 -->
<div v-else-if="activeKey === 'transfers'">
<div v-if="transferList.length > 0">
<TransferItem
v-for="transfer in transferList"
:key="transfer.id"
:transfer="transfer" />
</div>
<div v-else class="empty-state">
<n-empty style="user-select: none" description="No transfers yet">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faInbox" />
</n-icon>
</template>
</n-empty>
</div>
</div>
</div>
</n-layout-content>
</n-layout>
</template>
<style scoped>
.mobile-header {
height: 64px;
z-index: 1000;
}
.desktop-logo {
margin-bottom: 24px;
padding-left: 8px;
}
.logo {
font-size: 1.25rem;
font-weight: 700;
color: #38bdf8;
}
.content-container {
padding: 24px;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 90vh;
}
.radar-icon {
animation: spin 3s linear infinite;
color: #38bdf8;
opacity: 0.5;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
import { computed, ref, watch, h } from "vue";
import {
NCard,
NButton,
NIcon,
NTag,
NSpace,
NDropdown,
NSelect,
type DropdownOption,
} from "naive-ui";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faLinux,
faWindows,
faApple,
} from "@fortawesome/free-brands-svg-icons";
import {
faDesktop,
faGlobe,
faPaperPlane,
faChevronDown,
faFile,
faFolder,
faFont,
faClipboard,
} from "@fortawesome/free-solid-svg-icons";
import { Peer } from "../../bindings/mesh-drop/internal/discovery";
const props = defineProps<{
peer: Peer;
}>();
const emit = defineEmits<{
(e: "sendFile", ip: string): void;
(e: "sendFolder", ip: string): void;
(e: "sendText", ip: string): void;
(e: "sendClipboard", ip: string): void;
}>();
const ips = computed(() => {
if (!props.peer.routes) return [];
return Object.keys(props.peer.routes);
});
const selectedIp = ref<string>("");
watch(
ips,
(newIps) => {
if (newIps.length > 0) {
if (!selectedIp.value || !newIps.includes(selectedIp.value)) {
selectedIp.value = newIps[0];
}
} else {
selectedIp.value = "";
}
},
{ immediate: true },
);
const ipOptions = computed(() => {
return ips.value.map((ip) => ({
label: ip,
value: ip,
}));
});
const osIcon = computed(() => {
switch (props.peer.os) {
case "linux":
return faLinux;
case "windows":
return faWindows;
case "darwin":
return faApple;
default:
return faDesktop;
}
});
const sendOptions: DropdownOption[] = [
{
label: "Send File",
key: "file",
icon: () =>
h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon: faFile }) }),
},
{
label: "Send Folder",
key: "folder",
icon: () =>
h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon: faFolder }) }),
},
{
label: "Send Text",
key: "text",
icon: () =>
h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon: faFont }) }),
},
{
label: "Send Clipboard",
key: "clipboard",
icon: () =>
h(NIcon, null, {
default: () => h(FontAwesomeIcon, { icon: faClipboard }),
}),
},
];
const handleAction = (key: string) => {
if (!selectedIp.value) return;
switch (key) {
case "file":
emit("sendFile", selectedIp.value);
break;
case "folder":
emit("sendFolder", selectedIp.value);
break;
case "text":
emit("sendText", selectedIp.value);
break;
case "clipboard":
emit("sendClipboard", selectedIp.value);
break;
}
};
</script>
<template>
<n-card hoverable class="peer-card">
<template #header>
<div style="display: flex; align-items: center; gap: 8px">
<n-icon size="24">
<FontAwesomeIcon :icon="osIcon" />
</n-icon>
<span style="user-select: none">{{ peer.name }}</span>
</div>
</template>
<n-space vertical>
<div style="display: flex; align-items: center; gap: 8px">
<n-icon>
<FontAwesomeIcon :icon="faGlobe" />
</n-icon>
<!-- Single IP Display -->
<n-tag
v-if="ips.length === 1"
:bordered="false"
type="info"
size="small">
{{ ips[0] }}
</n-tag>
<!-- Multiple IP Selector -->
<n-select
v-else-if="ips.length > 1"
v-model:value="selectedIp"
:options="ipOptions"
size="small"
style="width: 140px" />
<!-- No Route -->
<n-tag v-else :bordered="false" type="warning" size="small">
No Route
</n-tag>
</div>
</n-space>
<template #action>
<div style="display: flex; gap: 8px">
<n-dropdown
trigger="click"
:options="sendOptions"
@select="handleAction"
:disabled="ips.length === 0">
<n-button type="primary" block dashed style="width: 100%">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faPaperPlane" />
</n-icon>
</template>
Send...
<n-icon style="margin-left: 4px">
<FontAwesomeIcon :icon="faChevronDown" />
</n-icon>
</n-button>
</n-dropdown>
</div>
</template>
</n-card>
</template>
<style scoped></style>

View File

@@ -0,0 +1,283 @@
<script setup lang="ts">
import { computed } from "vue";
import {
NCard,
NButton,
NIcon,
NProgress,
NSpace,
NText,
NTag,
useMessage,
} from "naive-ui";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faArrowUp,
faArrowDown,
faCircleExclamation,
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
import { ResolvePendingRequest } from "../../bindings/mesh-drop/internal/transfer/service";
import { Dialogs, Clipboard } from "@wailsio/runtime";
const props = defineProps<{
transfer: Transfer;
}>();
const formatSize = (bytes?: number) => {
if (bytes === undefined) return "";
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const formatSpeed = (speed?: number) => {
if (!speed) return "";
return formatSize(speed) + "/s";
};
const percentage = computed(() =>
Math.min(
100,
Math.round(
(props.transfer.progress.current / props.transfer.progress.total) * 100,
),
),
);
const progressStatus = computed(() => {
if (props.transfer.status === "error") return "error";
if (props.transfer.status === "completed") return "success";
return "default";
});
const acceptTransfer = () => {
ResolvePendingRequest(props.transfer.id, true, "");
};
const rejectTransfer = () => {
ResolvePendingRequest(props.transfer.id, false, "");
};
const acceptToFolder = async () => {
const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select Folder to save the file",
CanChooseDirectories: true,
CanChooseFiles: false,
AllowsMultipleSelection: false,
};
const path = await Dialogs.OpenFile(opts);
if (path !== "") {
ResolvePendingRequest(props.transfer.id, true, path as string);
}
};
const message = useMessage();
const handleCopy = async () => {
Clipboard.SetText(props.transfer.text)
.then(() => {
message.success("Copied to clipboard");
})
.catch(() => {
message.error("Failed to copy to clipboard");
});
};
</script>
<template>
<n-card size="small" class="transfer-item">
<div class="transfer-row">
<!-- 图标 -->
<div class="icon-wrapper">
<n-icon size="24" v-if="props.transfer.type === 'send'" color="#38bdf8">
<FontAwesomeIcon :icon="faArrowUp" />
</n-icon>
<n-icon
size="24"
v-else-if="props.transfer.type === 'receive'"
color="#22c55e">
<FontAwesomeIcon :icon="faArrowDown" />
</n-icon>
<n-icon size="24" v-else color="#f59e0b">
<FontAwesomeIcon :icon="faCircleExclamation" />
</n-icon>
</div>
<!-- 信息 -->
<div class="info-wrapper">
<div class="header-line">
<n-text
v-if="props.transfer.content_type === 'file'"
strong
class="filename"
:title="props.transfer.file_name"
>{{ props.transfer.file_name }}</n-text
>
<n-text
v-else-if="props.transfer.content_type === 'text'"
strong
class="filename"
title="Text"
>Text</n-text
>
<n-tag
size="small"
:bordered="false"
v-if="props.transfer.sender.name">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faUser" />
</n-icon>
</template>
{{ props.transfer.sender.name }}
</n-tag>
</div>
<div class="meta-line">
<n-text depth="3" class="size">{{
formatSize(props.transfer.file_size)
}}</n-text>
<!-- 状态文本进行中/已完成 -->
<span>
<n-text depth="3" v-if="props.transfer.status === 'active'">
- {{ formatSpeed(props.transfer.progress.speed) }}</n-text
>
<n-text
depth="3"
v-if="props.transfer.status === 'completed'"
type="success">
- Completed</n-text
>
<n-text
depth="3"
v-if="props.transfer.status === 'error'"
type="error">
- {{ props.transfer.error_msg || "Error" }}</n-text
>
</span>
</div>
<!-- 文字内容 -->
<n-text
v-if="
props.transfer.type === 'send' &&
props.transfer.status === 'pending'
"
depth="3"
>Waiting for accept</n-text
>
<!-- 进度条 -->
<n-progress
v-if="props.transfer.status === 'active'"
type="line"
:percentage="percentage"
:status="progressStatus"
:height="4"
:show-indicator="false"
processing
style="margin-top: 4px" />
</div>
<!-- 接受/拒绝操作按钮 -->
<div
class="actions-wrapper"
v-if="
props.transfer.type === 'receive' &&
props.transfer.status === 'pending'
">
<n-space>
<n-button size="small" type="success" @click="acceptTransfer">
Accept
</n-button>
<n-button
v-if="props.transfer.content_type !== 'text'"
size="small"
type="success"
@click="acceptToFolder">
Accept To Folder
</n-button>
<n-button size="small" type="error" ghost @click="rejectTransfer">
Reject
</n-button>
</n-space>
</div>
<!-- 复制按钮 -->
<div
class="actions-wrapper"
v-if="
props.transfer.type === 'receive' &&
props.transfer.status === 'completed' &&
props.transfer.content_type === 'text'
">
<n-space>
<n-button size="small" type="success" @click="handleCopy"
>Copy</n-button
>
</n-space>
</div>
<!-- 发送方取消按钮 -->
<div
class="actions-wrapper"
v-if="
props.transfer.type === 'send' &&
props.transfer.status !== 'completed'
">
<n-space>
<n-button size="small" type="error" ghost @click="">
Cancel
</n-button>
</n-space>
</div>
</div>
</n-card>
</template>
<style scoped>
.transfer-item {
margin-bottom: 0.5rem;
}
.transfer-row {
display: flex;
align-items: center;
gap: 12px;
}
.icon-wrapper {
display: flex;
align-items: center;
}
.info-wrapper {
flex: 1;
min-width: 0;
}
.header-line {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.filename {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
.meta-line {
font-size: 12px;
display: flex;
align-items: center;
}
</style>

4
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />