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

View File

@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

18
frontend/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support For `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

View File

@@ -0,0 +1,23 @@
//@ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as discovery$0 from "../../../../../mesh-drop/internal/discovery/models.js";
function configure() {
Object.freeze(Object.assign($Create.Events, {
"peers:update": $$createType1,
}));
}
// Private type creation functions
const $$createType0 = discovery$0.Peer.createFrom;
const $$createType1 = $Create.Array($$createType0);
configure();

View File

@@ -0,0 +1,19 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import type { Events } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import type * as discovery$0 from "../../../../../mesh-drop/internal/discovery/models.js";
declare module "@wailsio/runtime" {
namespace Events {
interface CustomEvents {
"peers:update": discovery$0.Peer[];
"transfer:refreshList": void;
}
}
}

View File

@@ -0,0 +1,13 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Service from "./service.js";
export {
Service
};
export {
OS,
Peer,
RouteState
} from "./models.js";

View File

@@ -0,0 +1,128 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as time$0 from "../../../time/models.js";
export enum OS {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
OSLinux = "linux",
OSWindows = "windows",
OSMac = "darwin",
};
/**
* Peer 代表一个可达的网络端点 (Network Endpoint)。
* 注意:一个物理设备 (Device) 可能通过多个网络接口广播,因此会对应多个 Peer 结构体。
*/
export class Peer {
/**
* ID 是物理设备的全局唯一标识 (UUID/MachineID)。
* 具有相同 ID 的 Peer 属于同一台物理设备。
*/
"id": string;
/**
* Name 是设备的主机名或用户设置的显示名称 (如 "Nite's Arch")。
*/
"name": string;
/**
* Routes 记录了设备的 IP 地址和状态。
* Key: ip, Value: *RouteState
*/
"routes": { [_: string]: RouteState | null };
/**
* Port 是文件传输服务的监听端口。
*/
"port": number;
/**
* IsOnline 标记该端点当前是否活跃 (UI 渲染用)。
*/
"is_online": boolean;
"os": OS;
/** Creates a new Peer instance. */
constructor($$source: Partial<Peer> = {}) {
if (!("id" in $$source)) {
this["id"] = "";
}
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("routes" in $$source)) {
this["routes"] = {};
}
if (!("port" in $$source)) {
this["port"] = 0;
}
if (!("is_online" in $$source)) {
this["is_online"] = false;
}
if (!("os" in $$source)) {
this["os"] = OS.$zero;
}
Object.assign(this, $$source);
}
/**
* Creates a new Peer instance from a string or object.
*/
static createFrom($$source: any = {}): Peer {
const $$createField2_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("routes" in $$parsedSource) {
$$parsedSource["routes"] = $$createField2_0($$parsedSource["routes"]);
}
return new Peer($$parsedSource as Partial<Peer>);
}
}
/**
* RouteState 记录单条路径的状态
*/
export class RouteState {
"ip": string;
/**
* 该特定 IP 最后一次响应的时间
*/
"last_seen": time$0.Time;
/** Creates a new RouteState instance. */
constructor($$source: Partial<RouteState> = {}) {
if (!("ip" in $$source)) {
this["ip"] = "";
}
if (!("last_seen" in $$source)) {
this["last_seen"] = null;
}
Object.assign(this, $$source);
}
/**
* Creates a new RouteState instance from a string or object.
*/
static createFrom($$source: any = {}): RouteState {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new RouteState($$parsedSource as Partial<RouteState>);
}
}
// Private type creation functions
const $$createType0 = RouteState.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Map($Create.Any, $$createType1);

View File

@@ -0,0 +1,39 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
export function GetID(): $CancellablePromise<string> {
return $Call.ByID(1539451205);
}
export function GetName(): $CancellablePromise<string> {
return $Call.ByID(1578367131);
}
export function GetPeerByIP(ip: string): $CancellablePromise<$models.Peer | null> {
return $Call.ByID(1626825408, ip).then(($result: any) => {
return $$createType1($result);
});
}
export function GetPeers(): $CancellablePromise<$models.Peer[]> {
return $Call.ByID(3041084029).then(($result: any) => {
return $$createType2($result);
});
}
export function Start(): $CancellablePromise<void> {
return $Call.ByID(1014177536);
}
// Private type creation functions
const $$createType0 = $models.Peer.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Array($$createType0);

View File

@@ -0,0 +1,16 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Service from "./service.js";
export {
Service
};
export {
ContentType,
Progress,
Sender,
Transfer,
TransferStatus,
TransferType
} from "./models.js";

View File

@@ -0,0 +1,244 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
export enum ContentType {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
ContentTypeFile = "file",
ContentTypeText = "text",
ContentTypeFolder = "folder",
};
/**
* Progress 用户前端传输进度
*/
export class Progress {
/**
* 当前进度
*/
"current": number;
/**
* 总进度
*/
"total": number;
/**
* 速度
*/
"speed": number;
/** Creates a new Progress instance. */
constructor($$source: Partial<Progress> = {}) {
if (!("current" in $$source)) {
this["current"] = 0;
}
if (!("total" in $$source)) {
this["total"] = 0;
}
if (!("speed" in $$source)) {
this["speed"] = 0;
}
Object.assign(this, $$source);
}
/**
* Creates a new Progress instance from a string or object.
*/
static createFrom($$source: any = {}): Progress {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Progress($$parsedSource as Partial<Progress>);
}
}
export class Sender {
/**
* 发送者 ID
*/
"id": string;
/**
* 发送者名称
*/
"name": string;
/** Creates a new Sender instance. */
constructor($$source: Partial<Sender> = {}) {
if (!("id" in $$source)) {
this["id"] = "";
}
if (!("name" in $$source)) {
this["name"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new Sender instance from a string or object.
*/
static createFrom($$source: any = {}): Sender {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Sender($$parsedSource as Partial<Sender>);
}
}
/**
* Transfer
*/
export class Transfer {
/**
* 传输会话 ID
*/
"id": string;
/**
* 发送者
*/
"sender": Sender;
/**
* 文件名
*/
"file_name": string;
/**
* 文件大小 (字节)
*/
"file_size": number;
/**
* 保存路径
*/
"savePath": string;
/**
* 传输状态
*/
"status": TransferStatus;
/**
* 传输进度
*/
"progress": Progress;
/**
* 进度类型
*/
"type": TransferType;
/**
* 内容类型
*/
"content_type": ContentType;
/**
* 文本内容
*/
"text": string;
/**
* 错误信息
*/
"error_msg": string;
/**
* 用于上传的凭证
*/
"token": string;
/** Creates a new Transfer instance. */
constructor($$source: Partial<Transfer> = {}) {
if (!("id" in $$source)) {
this["id"] = "";
}
if (!("sender" in $$source)) {
this["sender"] = (new Sender());
}
if (!("file_name" in $$source)) {
this["file_name"] = "";
}
if (!("file_size" in $$source)) {
this["file_size"] = 0;
}
if (!("savePath" in $$source)) {
this["savePath"] = "";
}
if (!("status" in $$source)) {
this["status"] = TransferStatus.$zero;
}
if (!("progress" in $$source)) {
this["progress"] = (new Progress());
}
if (!("type" in $$source)) {
this["type"] = TransferType.$zero;
}
if (!("content_type" in $$source)) {
this["content_type"] = ContentType.$zero;
}
if (!("text" in $$source)) {
this["text"] = "";
}
if (!("error_msg" in $$source)) {
this["error_msg"] = "";
}
if (!("token" in $$source)) {
this["token"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new Transfer instance from a string or object.
*/
static createFrom($$source: any = {}): Transfer {
const $$createField1_0 = $$createType0;
const $$createField6_0 = $$createType1;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("sender" in $$parsedSource) {
$$parsedSource["sender"] = $$createField1_0($$parsedSource["sender"]);
}
if ("progress" in $$parsedSource) {
$$parsedSource["progress"] = $$createField6_0($$parsedSource["progress"]);
}
return new Transfer($$parsedSource as Partial<Transfer>);
}
}
export enum TransferStatus {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
TransferStatusPending = "pending",
TransferStatusAccepted = "accepted",
TransferStatusRejected = "rejected",
TransferStatusCompleted = "completed",
TransferStatusError = "error",
TransferStatusCanceled = "canceled",
TransferStatusActive = "active",
};
export enum TransferType {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
TransferTypeSend = "send",
TransferTypeReceive = "receive",
};
// Private type creation functions
const $$createType0 = Sender.createFrom;
const $$createType1 = Progress.createFrom;

View File

@@ -0,0 +1,48 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as discovery$0 from "../discovery/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
export function GetPort(): $CancellablePromise<number> {
return $Call.ByID(4195335736);
}
export function GetTransferList(): $CancellablePromise<$models.Transfer[]> {
return $Call.ByID(584162076).then(($result: any) => {
return $$createType1($result);
});
}
/**
* ResolvePendingRequest 外部调用,解决待处理的传输请求
* 返回 true 表示成功处理false 表示未找到该 ID 的请求
*/
export function ResolvePendingRequest(id: string, accept: boolean, savePath: string): $CancellablePromise<boolean> {
return $Call.ByID(207902967, id, accept, savePath);
}
export function SendFile(target: discovery$0.Peer | null, targetIP: string, filePath: string): $CancellablePromise<void> {
return $Call.ByID(2954589433, target, targetIP, filePath);
}
export function SendText(target: discovery$0.Peer | null, targetIP: string, text: string): $CancellablePromise<void> {
return $Call.ByID(1497421440, target, targetIP, text);
}
export function Start(): $CancellablePromise<void> {
return $Call.ByID(3611800535);
}
// Private type creation functions
const $$createType0 = $models.Transfer.createFrom;
const $$createType1 = $Create.Array($$createType0);

View File

@@ -0,0 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export type {
Time
} from "./models.js";

View File

@@ -0,0 +1,51 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
/**
* A Time represents an instant in time with nanosecond precision.
*
* Programs using times should typically store and pass them as values,
* not pointers. That is, time variables and struct fields should be of
* type [time.Time], not *time.Time.
*
* A Time value can be used by multiple goroutines simultaneously except
* that the methods [Time.GobDecode], [Time.UnmarshalBinary], [Time.UnmarshalJSON] and
* [Time.UnmarshalText] are not concurrency-safe.
*
* Time instants can be compared using the [Time.Before], [Time.After], and [Time.Equal] methods.
* The [Time.Sub] method subtracts two instants, producing a [Duration].
* The [Time.Add] method adds a Time and a Duration, producing a Time.
*
* The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.
* As this time is unlikely to come up in practice, the [Time.IsZero] method gives
* a simple way of detecting a time that has not been initialized explicitly.
*
* Each time has an associated [Location]. The methods [Time.Local], [Time.UTC], and Time.In return a
* Time with a specific Location. Changing the Location of a Time value with
* these methods does not change the actual instant it represents, only the time
* zone in which to interpret it.
*
* Representations of a Time value saved by the [Time.GobEncode], [Time.MarshalBinary], [Time.AppendBinary],
* [Time.MarshalJSON], [Time.MarshalText] and [Time.AppendText] methods store the [Time.Location]'s offset,
* but not the location name. They therefore lose information about Daylight Saving Time.
*
* In addition to the required “wall clock” reading, a Time may contain an optional
* reading of the current process's monotonic clock, to provide additional precision
* for comparison or subtraction.
* See the “Monotonic Clocks” section in the package documentation for details.
*
* Note that the Go == operator compares not just the time instant but also the
* Location and the monotonic clock reading. Therefore, Time values should not
* be used as map or database keys without first guaranteeing that the
* identical Location has been set for all values, which can be achieved
* through use of the UTC or Local method, and that the monotonic clock reading
* has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u)
* to t == u, since t.Equal uses the most accurate comparison available and
* correctly handles the case when only one of its arguments has a monotonic
* clock reading.
*/
export type Time = any;

View File

@@ -0,0 +1,9 @@
//@ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
Object.freeze($Create.Events);

View File

@@ -0,0 +1,2 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MeshDrop</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1683
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build:dev": "vue-tsc && vite build --minify false --mode development",
"build": "vue-tsc && vite build --mode production",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"@wailsio/runtime": "^3.0.0-alpha.79",
"naive-ui": "^2.43.2",
"vfonts": "^0.0.3",
"vue": "^3.2.45"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"typescript": "^4.9.3",
"vite": "^5.0.0",
"vue-tsc": "^1.0.11"
}
}

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" />

19
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"noUnusedParameters": false,
"noImplicitAny": false,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "bindings"],
}

8
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import wails from "@wailsio/runtime/plugins/vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), wails("./bindings")],
});