add: send files
This commit is contained in:
@@ -5,4 +5,4 @@ TODO
|
|||||||
- [x] 多样化图标
|
- [x] 多样化图标
|
||||||
- [ ] 加密传输
|
- [ ] 加密传输
|
||||||
- [x] 取消传输
|
- [x] 取消传输
|
||||||
- [ ] 多文件发送
|
- [x] 多文件发送
|
||||||
@@ -6,18 +6,23 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import { Create as $Create } from "@wailsio/runtime";
|
import { Create as $Create } from "@wailsio/runtime";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore: Unused imports
|
||||||
|
import * as main$0 from "../../../../../mesh-drop/models.js";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import * as discovery$0 from "../../../../../mesh-drop/internal/discovery/models.js";
|
import * as discovery$0 from "../../../../../mesh-drop/internal/discovery/models.js";
|
||||||
|
|
||||||
function configure() {
|
function configure() {
|
||||||
Object.freeze(Object.assign($Create.Events, {
|
Object.freeze(Object.assign($Create.Events, {
|
||||||
"peers:update": $$createType1,
|
"files-dropped": $$createType0,
|
||||||
|
"peers:update": $$createType2,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private type creation functions
|
// Private type creation functions
|
||||||
const $$createType0 = discovery$0.Peer.createFrom;
|
const $$createType0 = main$0.FilesDroppedEvent.createFrom;
|
||||||
const $$createType1 = $Create.Array($$createType0);
|
const $$createType1 = discovery$0.Peer.createFrom;
|
||||||
|
const $$createType2 = $Create.Array($$createType1);
|
||||||
|
|
||||||
configure();
|
configure();
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import type { Events } from "@wailsio/runtime";
|
import type { Events } from "@wailsio/runtime";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore: Unused imports
|
||||||
|
import type * as main$0 from "../../../../../mesh-drop/models.js";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import type * as discovery$0 from "../../../../../mesh-drop/internal/discovery/models.js";
|
import type * as discovery$0 from "../../../../../mesh-drop/internal/discovery/models.js";
|
||||||
@@ -12,6 +15,7 @@ import type * as discovery$0 from "../../../../../mesh-drop/internal/discovery/m
|
|||||||
declare module "@wailsio/runtime" {
|
declare module "@wailsio/runtime" {
|
||||||
namespace Events {
|
namespace Events {
|
||||||
interface CustomEvents {
|
interface CustomEvents {
|
||||||
|
"files-dropped": main$0.FilesDroppedEvent;
|
||||||
"peers:update": discovery$0.Peer[];
|
"peers:update": discovery$0.Peer[];
|
||||||
"transfer:refreshList": void;
|
"transfer:refreshList": void;
|
||||||
}
|
}
|
||||||
|
|||||||
6
frontend/bindings/mesh-drop/index.ts
Normal file
6
frontend/bindings/mesh-drop/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export {
|
||||||
|
FilesDroppedEvent
|
||||||
|
} from "./models.js";
|
||||||
@@ -100,6 +100,11 @@ export class Transfer {
|
|||||||
*/
|
*/
|
||||||
"id": string;
|
"id": string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
"create_time": number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送者
|
* 发送者
|
||||||
*/
|
*/
|
||||||
@@ -160,6 +165,9 @@ export class Transfer {
|
|||||||
if (!("id" in $$source)) {
|
if (!("id" in $$source)) {
|
||||||
this["id"] = "";
|
this["id"] = "";
|
||||||
}
|
}
|
||||||
|
if (!("create_time" in $$source)) {
|
||||||
|
this["create_time"] = 0;
|
||||||
|
}
|
||||||
if (!("sender" in $$source)) {
|
if (!("sender" in $$source)) {
|
||||||
this["sender"] = (new Sender());
|
this["sender"] = (new Sender());
|
||||||
}
|
}
|
||||||
@@ -201,14 +209,14 @@ export class Transfer {
|
|||||||
* Creates a new Transfer instance from a string or object.
|
* Creates a new Transfer instance from a string or object.
|
||||||
*/
|
*/
|
||||||
static createFrom($$source: any = {}): Transfer {
|
static createFrom($$source: any = {}): Transfer {
|
||||||
const $$createField1_0 = $$createType0;
|
const $$createField2_0 = $$createType0;
|
||||||
const $$createField6_0 = $$createType1;
|
const $$createField7_0 = $$createType1;
|
||||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||||
if ("sender" in $$parsedSource) {
|
if ("sender" in $$parsedSource) {
|
||||||
$$parsedSource["sender"] = $$createField1_0($$parsedSource["sender"]);
|
$$parsedSource["sender"] = $$createField2_0($$parsedSource["sender"]);
|
||||||
}
|
}
|
||||||
if ("progress" in $$parsedSource) {
|
if ("progress" in $$parsedSource) {
|
||||||
$$parsedSource["progress"] = $$createField6_0($$parsedSource["progress"]);
|
$$parsedSource["progress"] = $$createField7_0($$parsedSource["progress"]);
|
||||||
}
|
}
|
||||||
return new Transfer($$parsedSource as Partial<Transfer>);
|
return new Transfer($$parsedSource as Partial<Transfer>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,23 +17,38 @@ export function CancelTransfer(transferID: string): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(900002248, transferID);
|
return $Call.ByID(900002248, transferID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CleanTransferList 清理完成的 transfer
|
||||||
|
*/
|
||||||
|
export function CleanTransferList(): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(3775121017);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteTransfer(transferID: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(4158310409, transferID);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPort(): $CancellablePromise<number> {
|
export function GetPort(): $CancellablePromise<number> {
|
||||||
return $Call.ByID(4195335736);
|
return $Call.ByID(4195335736);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetTransfer(transferID: string): $CancellablePromise<[$models.Transfer, boolean]> {
|
export function GetTransfer(transferID: string): $CancellablePromise<[$models.Transfer | null, boolean]> {
|
||||||
return $Call.ByID(1198637268, transferID).then(($result: any) => {
|
return $Call.ByID(1198637268, transferID).then(($result: any) => {
|
||||||
$result[0] = $$createType0($result[0]);
|
$result[0] = $$createType1($result[0]);
|
||||||
return $result;
|
return $result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetTransferList(): $CancellablePromise<$models.Transfer[]> {
|
export function GetTransferList(): $CancellablePromise<($models.Transfer | null)[]> {
|
||||||
return $Call.ByID(584162076).then(($result: any) => {
|
return $Call.ByID(584162076).then(($result: any) => {
|
||||||
return $$createType1($result);
|
return $$createType2($result);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function NotifyTransferListUpdate(): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(1220032142);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResolvePendingRequest 外部调用,解决待处理的传输请求
|
* ResolvePendingRequest 外部调用,解决待处理的传输请求
|
||||||
* 返回 true 表示成功处理,false 表示未找到该 ID 的请求
|
* 返回 true 表示成功处理,false 表示未找到该 ID 的请求
|
||||||
@@ -46,6 +61,10 @@ export function SendFile(target: discovery$0.Peer | null, targetIP: string, file
|
|||||||
return $Call.ByID(2954589433, target, targetIP, filePath);
|
return $Call.ByID(2954589433, target, targetIP, filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SendFiles(target: discovery$0.Peer | null, targetIP: string, filePaths: string[]): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(3308811582, target, targetIP, filePaths);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendFolder(target: discovery$0.Peer | null, targetIP: string, folderPath: string): $CancellablePromise<void> {
|
export function SendFolder(target: discovery$0.Peer | null, targetIP: string, folderPath: string): $CancellablePromise<void> {
|
||||||
return $Call.ByID(3258308403, target, targetIP, folderPath);
|
return $Call.ByID(3258308403, target, targetIP, folderPath);
|
||||||
}
|
}
|
||||||
@@ -58,6 +77,11 @@ export function Start(): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(3611800535);
|
return $Call.ByID(3611800535);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StoreTransferToList(transfer: $models.Transfer | null): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(3225941780, transfer);
|
||||||
|
}
|
||||||
|
|
||||||
// Private type creation functions
|
// Private type creation functions
|
||||||
const $$createType0 = $models.Transfer.createFrom;
|
const $$createType0 = $models.Transfer.createFrom;
|
||||||
const $$createType1 = $Create.Array($$createType0);
|
const $$createType1 = $Create.Nullable($$createType0);
|
||||||
|
const $$createType2 = $Create.Array($$createType1);
|
||||||
|
|||||||
38
frontend/bindings/mesh-drop/models.ts
Normal file
38
frontend/bindings/mesh-drop/models.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// 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 class FilesDroppedEvent {
|
||||||
|
"files": string[];
|
||||||
|
"target": string;
|
||||||
|
|
||||||
|
/** Creates a new FilesDroppedEvent instance. */
|
||||||
|
constructor($$source: Partial<FilesDroppedEvent> = {}) {
|
||||||
|
if (!("files" in $$source)) {
|
||||||
|
this["files"] = [];
|
||||||
|
}
|
||||||
|
if (!("target" in $$source)) {
|
||||||
|
this["target"] = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this, $$source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new FilesDroppedEvent instance from a string or object.
|
||||||
|
*/
|
||||||
|
static createFrom($$source: any = {}): FilesDroppedEvent {
|
||||||
|
const $$createField0_0 = $$createType0;
|
||||||
|
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||||
|
if ("files" in $$parsedSource) {
|
||||||
|
$$parsedSource["files"] = $$createField0_0($$parsedSource["files"]);
|
||||||
|
}
|
||||||
|
return new FilesDroppedEvent($$parsedSource as Partial<FilesDroppedEvent>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private type creation functions
|
||||||
|
const $$createType0 = $Create.Array($Create.Any);
|
||||||
@@ -16,10 +16,6 @@ import {
|
|||||||
NBadge,
|
NBadge,
|
||||||
NButton,
|
NButton,
|
||||||
NIcon,
|
NIcon,
|
||||||
NDrawer,
|
|
||||||
NDrawerContent,
|
|
||||||
useDialog,
|
|
||||||
NInput,
|
|
||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
import {
|
import {
|
||||||
@@ -31,18 +27,9 @@ import {
|
|||||||
import { type MenuOption } from "naive-ui";
|
import { type MenuOption } from "naive-ui";
|
||||||
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
|
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
|
||||||
import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
|
import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
|
||||||
import {
|
import { GetPeers } from "../../bindings/mesh-drop/internal/discovery/service";
|
||||||
GetPeers,
|
|
||||||
GetPeerByIP,
|
|
||||||
} from "../../bindings/mesh-drop/internal/discovery/service";
|
|
||||||
import { Events } from "@wailsio/runtime";
|
import { Events } from "@wailsio/runtime";
|
||||||
import {
|
import { GetTransferList } from "../../bindings/mesh-drop/internal/transfer/service";
|
||||||
GetTransferList,
|
|
||||||
SendFile,
|
|
||||||
SendText,
|
|
||||||
SendFolder,
|
|
||||||
} from "../../bindings/mesh-drop/internal/transfer/service";
|
|
||||||
import { Dialogs, Clipboard } from "@wailsio/runtime";
|
|
||||||
|
|
||||||
const peers = ref<Peer[]>([]);
|
const peers = ref<Peer[]>([]);
|
||||||
const transferList = ref<Transfer[]>([]);
|
const transferList = ref<Transfer[]>([]);
|
||||||
@@ -54,7 +41,10 @@ const isMobile = ref(false);
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
checkMobile();
|
checkMobile();
|
||||||
window.addEventListener("resize", checkMobile);
|
window.addEventListener("resize", checkMobile);
|
||||||
transferList.value = await GetTransferList();
|
const list = await GetTransferList();
|
||||||
|
transferList.value = (
|
||||||
|
(list || []).filter((t) => t !== null) as Transfer[]
|
||||||
|
).sort((a, b) => b.create_time - a.create_time);
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
@@ -110,7 +100,10 @@ Events.On("peers:update", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Events.On("transfer:refreshList", async () => {
|
Events.On("transfer:refreshList", async () => {
|
||||||
transferList.value = await GetTransferList();
|
const list = await GetTransferList();
|
||||||
|
transferList.value = (
|
||||||
|
(list || []).filter((t) => t !== null) as Transfer[]
|
||||||
|
).sort((a, b) => b.create_time - a.create_time);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 计算属性 ---
|
// --- 计算属性 ---
|
||||||
@@ -122,83 +115,6 @@ const pendingCount = computed(() => {
|
|||||||
|
|
||||||
// --- 操作 ---
|
// --- 操作 ---
|
||||||
|
|
||||||
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;
|
|
||||||
activeKey.value = "transfers";
|
|
||||||
await SendFile(peer, ip, filePath);
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(e);
|
|
||||||
alert("Failed to send file: " + e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendFolder = async (ip: string) => {
|
|
||||||
const opts: Dialogs.OpenFileDialogOptions = {
|
|
||||||
Title: "Select folder to send",
|
|
||||||
CanChooseDirectories: true,
|
|
||||||
CanChooseFiles: false,
|
|
||||||
AllowsMultipleSelection: false,
|
|
||||||
};
|
|
||||||
const folderPath = await Dialogs.OpenFile(opts);
|
|
||||||
if (!folderPath) return;
|
|
||||||
const peer = await GetPeerByIP(ip);
|
|
||||||
if (!peer) return;
|
|
||||||
activeKey.value = "transfers";
|
|
||||||
await SendFolder(peer, ip, folderPath as string);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dialog = useDialog();
|
|
||||||
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;
|
|
||||||
activeKey.value = "transfers";
|
|
||||||
await SendText(peer, ip, textContent.value);
|
|
||||||
} 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;
|
|
||||||
activeKey.value = "transfers";
|
|
||||||
await SendText(peer, ip, text);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTransfer = (id: string) => {
|
|
||||||
transferList.value = transferList.value.filter((t) => t.id !== id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMenuUpdate = (key: string) => {
|
const handleMenuUpdate = (key: string) => {
|
||||||
activeKey.value = key;
|
activeKey.value = key;
|
||||||
showMobileMenu.value = false;
|
showMobileMenu.value = false;
|
||||||
@@ -266,10 +182,7 @@ const handleMenuUpdate = (key: string) => {
|
|||||||
<n-gi v-for="peer in peers" :key="peer.id">
|
<n-gi v-for="peer in peers" :key="peer.id">
|
||||||
<PeerCard
|
<PeerCard
|
||||||
:peer="peer"
|
:peer="peer"
|
||||||
@sendFile="handleSendFile"
|
@transferStarted="activeKey = 'transfers'" />
|
||||||
@sendFolder="handleSendFolder"
|
|
||||||
@sendText="handleSendText"
|
|
||||||
@sendClipboard="handleSendClipboard" />
|
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
</n-space>
|
</n-space>
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import {
|
|||||||
NDropdown,
|
NDropdown,
|
||||||
NSelect,
|
NSelect,
|
||||||
type DropdownOption,
|
type DropdownOption,
|
||||||
|
NModal,
|
||||||
|
NList,
|
||||||
|
NListItem,
|
||||||
|
NThing,
|
||||||
|
NEmpty,
|
||||||
|
NInput,
|
||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
import {
|
import {
|
||||||
@@ -25,18 +31,24 @@ import {
|
|||||||
faFolder,
|
faFolder,
|
||||||
faFont,
|
faFont,
|
||||||
faClipboard,
|
faClipboard,
|
||||||
|
faTrash,
|
||||||
|
faPlus,
|
||||||
|
faCloudArrowUp,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { Peer } from "../../bindings/mesh-drop/internal/discovery";
|
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
|
||||||
|
import { Dialogs, Events, Clipboard } from "@wailsio/runtime";
|
||||||
|
import {
|
||||||
|
SendFiles,
|
||||||
|
SendFolder,
|
||||||
|
SendText,
|
||||||
|
} from "../../bindings/mesh-drop/internal/transfer/service";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
peer: Peer;
|
peer: Peer;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "sendFile", ip: string): void;
|
(e: "transferStarted"): void;
|
||||||
(e: "sendFolder", ip: string): void;
|
|
||||||
(e: "sendText", ip: string): void;
|
|
||||||
(e: "sendClipboard", ip: string): void;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const ips = computed(() => {
|
const ips = computed(() => {
|
||||||
@@ -82,8 +94,8 @@ const osIcon = computed(() => {
|
|||||||
|
|
||||||
const sendOptions: DropdownOption[] = [
|
const sendOptions: DropdownOption[] = [
|
||||||
{
|
{
|
||||||
label: "Send File",
|
label: "Send Files",
|
||||||
key: "file",
|
key: "files",
|
||||||
icon: () =>
|
icon: () =>
|
||||||
h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon: faFile }) }),
|
h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon: faFile }) }),
|
||||||
},
|
},
|
||||||
@@ -113,20 +125,133 @@ const handleAction = (key: string) => {
|
|||||||
if (!selectedIp.value) return;
|
if (!selectedIp.value) return;
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "file":
|
case "files":
|
||||||
emit("sendFile", selectedIp.value);
|
showFileModal.value = true;
|
||||||
break;
|
break;
|
||||||
case "folder":
|
case "folder":
|
||||||
emit("sendFolder", selectedIp.value);
|
handleSendFolder();
|
||||||
break;
|
break;
|
||||||
case "text":
|
case "text":
|
||||||
emit("sendText", selectedIp.value);
|
showTextModal.value = true;
|
||||||
break;
|
break;
|
||||||
case "clipboard":
|
case "clipboard":
|
||||||
emit("sendClipboard", selectedIp.value);
|
handleSendClipboard();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- 发送逻辑 ---
|
||||||
|
|
||||||
|
const handleSendFolder = async () => {
|
||||||
|
if (!selectedIp.value) return;
|
||||||
|
const opts: Dialogs.OpenFileDialogOptions = {
|
||||||
|
Title: "Select folder to send",
|
||||||
|
CanChooseDirectories: true,
|
||||||
|
CanChooseFiles: false,
|
||||||
|
AllowsMultipleSelection: false,
|
||||||
|
};
|
||||||
|
const folderPath = await Dialogs.OpenFile(opts);
|
||||||
|
if (!folderPath) return;
|
||||||
|
|
||||||
|
SendFolder(props.peer, selectedIp.value, folderPath as string).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
alert("Failed to send folder: " + e);
|
||||||
|
});
|
||||||
|
emit("transferStarted");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendClipboard = async () => {
|
||||||
|
if (!selectedIp.value) return;
|
||||||
|
const text = await Clipboard.Text();
|
||||||
|
if (!text) {
|
||||||
|
alert("Clipboard is empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SendText(props.peer, selectedIp.value, text).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
alert("Failed to send clipboard: " + e);
|
||||||
|
});
|
||||||
|
emit("transferStarted");
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 文本发送 ---
|
||||||
|
const showTextModal = ref(false);
|
||||||
|
const textContent = ref("");
|
||||||
|
|
||||||
|
const executeSendText = async () => {
|
||||||
|
if (!selectedIp.value || !textContent.value) return;
|
||||||
|
SendText(props.peer, selectedIp.value, textContent.value).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
alert("Failed to send text: " + e);
|
||||||
|
});
|
||||||
|
emit("transferStarted");
|
||||||
|
showTextModal.value = false;
|
||||||
|
textContent.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 文件选择 ---
|
||||||
|
const showFileModal = ref(false);
|
||||||
|
watch(showFileModal, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
Events.On("files-dropped", (event) => {
|
||||||
|
fileList.value = event.data.files.map((f) => ({
|
||||||
|
name: f.split(/[\/]/).pop() || f,
|
||||||
|
path: f,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Events.Off("files-dropped");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const fileList = ref<{ name: string; path: string }[]>([]);
|
||||||
|
|
||||||
|
const openFileDialog = async () => {
|
||||||
|
const files = await Dialogs.OpenFile({
|
||||||
|
Title: "Select files to send",
|
||||||
|
AllowsMultipleSelection: true,
|
||||||
|
});
|
||||||
|
if (files) {
|
||||||
|
if (Array.isArray(files)) {
|
||||||
|
files.forEach((f) => {
|
||||||
|
// 去重
|
||||||
|
if (!fileList.value.find((existing) => existing.path === f)) {
|
||||||
|
fileList.value.push({
|
||||||
|
name: f.split(/[\\/]/).pop() || f,
|
||||||
|
path: f,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const f = files as string;
|
||||||
|
if (!fileList.value.find((existing) => existing.path === f)) {
|
||||||
|
fileList.value.push({
|
||||||
|
name: f.split(/[\\/]/).pop() || f,
|
||||||
|
path: f,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (index: number) => {
|
||||||
|
fileList.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelFiles = () => {
|
||||||
|
showFileModal.value = false;
|
||||||
|
fileList.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendFiles = () => {
|
||||||
|
if (fileList.value.length === 0 || !selectedIp.value) return;
|
||||||
|
const paths = fileList.value.map((f) => f.path);
|
||||||
|
SendFiles(props.peer, selectedIp.value, paths).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
alert("Failed to send files: " + e);
|
||||||
|
});
|
||||||
|
emit("transferStarted");
|
||||||
|
handleCancelFiles();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -189,6 +314,107 @@ const handleAction = (key: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showFileModal"
|
||||||
|
preset="card"
|
||||||
|
title="Send Files"
|
||||||
|
style="width: 600px; max-width: 90%"
|
||||||
|
:bordered="false">
|
||||||
|
<div
|
||||||
|
v-if="fileList.length === 0"
|
||||||
|
class="drop-zone"
|
||||||
|
@click="openFileDialog"
|
||||||
|
data-file-drop-target>
|
||||||
|
<n-empty description="Click to select files">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :size="48">
|
||||||
|
<FontAwesomeIcon :icon="faCloudArrowUp" />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
style="max-height: 400px; overflow-y: auto; margin-bottom: 16px"
|
||||||
|
data-file-drop-target>
|
||||||
|
<n-list bordered>
|
||||||
|
<n-list-item v-for="(file, index) in fileList" :key="file.path">
|
||||||
|
<template #suffix>
|
||||||
|
<n-button text type="error" @click="handleRemoveFile(index)">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><FontAwesomeIcon :icon="faTrash" /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<n-thing :title="file.name" :description="file.path"></n-thing>
|
||||||
|
</n-list-item>
|
||||||
|
</n-list>
|
||||||
|
</div>
|
||||||
|
<n-button dashed block @click="openFileDialog">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><FontAwesomeIcon :icon="faPlus" /></n-icon>
|
||||||
|
</template>
|
||||||
|
Add more files
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<n-space justify="end">
|
||||||
|
<n-button @click="handleCancelFiles">Cancel</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleSendFiles"
|
||||||
|
:disabled="fileList.length === 0">
|
||||||
|
Send {{ fileList.length > 0 ? `(${fileList.length})` : "" }}
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- 文本发送 Modal -->
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showTextModal"
|
||||||
|
preset="card"
|
||||||
|
title="Send Text"
|
||||||
|
style="width: 500px; max-width: 90%"
|
||||||
|
:bordered="false">
|
||||||
|
<n-input
|
||||||
|
v-model:value="textContent"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="Type something to send..."
|
||||||
|
:autosize="{ minRows: 4, maxRows: 10 }" />
|
||||||
|
<template #footer>
|
||||||
|
<n-space justify="end">
|
||||||
|
<n-button @click="showTextModal = false">Cancel</n-button>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
@click="executeSendText"
|
||||||
|
:disabled="!textContent">
|
||||||
|
Send
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.drop-zone:hover {
|
||||||
|
border-color: #38bdf8;
|
||||||
|
background-color: rgba(56, 189, 248, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.file-drop-target-active {
|
||||||
|
border-color: #38bdf8;
|
||||||
|
background-color: rgba(56, 189, 248, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
NTag,
|
NTag,
|
||||||
useMessage,
|
useMessage,
|
||||||
NInput,
|
NInput,
|
||||||
|
NDropdown,
|
||||||
|
NButtonGroup,
|
||||||
} from "naive-ui";
|
} from "naive-ui";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
import {
|
import {
|
||||||
@@ -20,12 +22,21 @@ import {
|
|||||||
faFile,
|
faFile,
|
||||||
faFileLines,
|
faFileLines,
|
||||||
faFolder,
|
faFolder,
|
||||||
|
faClock,
|
||||||
|
faChevronDown,
|
||||||
|
faEye,
|
||||||
|
faCopy,
|
||||||
|
faTrash,
|
||||||
|
faXmark,
|
||||||
|
faStop,
|
||||||
|
faCheck,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
|
import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
|
||||||
import {
|
import {
|
||||||
ResolvePendingRequest,
|
ResolvePendingRequest,
|
||||||
CancelTransfer,
|
CancelTransfer,
|
||||||
|
DeleteTransfer,
|
||||||
} from "../../bindings/mesh-drop/internal/transfer/service";
|
} from "../../bindings/mesh-drop/internal/transfer/service";
|
||||||
import { Dialogs, Clipboard } from "@wailsio/runtime";
|
import { Dialogs, Clipboard } from "@wailsio/runtime";
|
||||||
|
|
||||||
@@ -49,6 +60,10 @@ const formatSpeed = (speed?: number) => {
|
|||||||
return formatSize(speed) + "/s";
|
return formatSize(speed) + "/s";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTime = (time: number): string => {
|
||||||
|
return new Date(time).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
const percentage = computed(() =>
|
const percentage = computed(() =>
|
||||||
Math.min(
|
Math.min(
|
||||||
100,
|
100,
|
||||||
@@ -84,6 +99,23 @@ const acceptToFolder = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dropdownOptions = [
|
||||||
|
{
|
||||||
|
label: "Accept To Folder",
|
||||||
|
key: "folder",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSelect = (key: string | number) => {
|
||||||
|
if (key === "folder") {
|
||||||
|
acceptToFolder();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
DeleteTransfer(props.transfer.id);
|
||||||
|
};
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
@@ -130,6 +162,27 @@ const canCancel = computed(() => {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canCopy = computed(() => {
|
||||||
|
if (
|
||||||
|
props.transfer.type === "receive" &&
|
||||||
|
props.transfer.status === "completed" &&
|
||||||
|
props.transfer.content_type === "text"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canAccept = computed(() => {
|
||||||
|
if (
|
||||||
|
props.transfer.type === "receive" &&
|
||||||
|
props.transfer.status === "pending"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -183,7 +236,9 @@ const canCancel = computed(() => {
|
|||||||
<n-tag
|
<n-tag
|
||||||
size="small"
|
size="small"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
v-if="props.transfer.sender.name">
|
v-if="
|
||||||
|
props.transfer.sender.name && props.transfer.type === 'receive'
|
||||||
|
">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon>
|
<n-icon>
|
||||||
<FontAwesomeIcon :icon="faUser" />
|
<FontAwesomeIcon :icon="faUser" />
|
||||||
@@ -191,6 +246,17 @@ const canCancel = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
{{ props.transfer.sender.name }}
|
{{ props.transfer.sender.name }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
|
<n-tag
|
||||||
|
size="small"
|
||||||
|
:bordered="false"
|
||||||
|
v-if="props.transfer.create_time">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<FontAwesomeIcon :icon="faClock" />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
{{ formatTime(props.transfer.create_time) }}
|
||||||
|
</n-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="meta-line">
|
<div class="meta-line">
|
||||||
@@ -218,7 +284,7 @@ const canCancel = computed(() => {
|
|||||||
<n-text
|
<n-text
|
||||||
depth="3"
|
depth="3"
|
||||||
v-if="props.transfer.status === 'canceled'"
|
v-if="props.transfer.status === 'canceled'"
|
||||||
type="error">
|
type="info">
|
||||||
- Canceled</n-text
|
- Canceled</n-text
|
||||||
>
|
>
|
||||||
<n-text
|
<n-text
|
||||||
@@ -227,19 +293,15 @@ const canCancel = computed(() => {
|
|||||||
type="error">
|
type="error">
|
||||||
- Rejected</n-text
|
- Rejected</n-text
|
||||||
>
|
>
|
||||||
|
<n-text
|
||||||
|
depth="3"
|
||||||
|
v-if="props.transfer.status === 'pending'"
|
||||||
|
type="warning">
|
||||||
|
- Waiting for accept</n-text
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文字内容 -->
|
|
||||||
<n-text
|
|
||||||
v-if="
|
|
||||||
props.transfer.type === 'send' &&
|
|
||||||
props.transfer.status === 'pending'
|
|
||||||
"
|
|
||||||
depth="3"
|
|
||||||
>Waiting for accept</n-text
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- 进度条 -->
|
<!-- 进度条 -->
|
||||||
<n-progress
|
<n-progress
|
||||||
v-if="props.transfer.status === 'active'"
|
v-if="props.transfer.status === 'active'"
|
||||||
@@ -252,58 +314,83 @@ const canCancel = computed(() => {
|
|||||||
style="margin-top: 4px" />
|
style="margin-top: 4px" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 接受/拒绝操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div
|
<div class="actions-wrapper">
|
||||||
class="actions-wrapper"
|
|
||||||
v-if="
|
|
||||||
props.transfer.type === 'receive' &&
|
|
||||||
props.transfer.status === 'pending'
|
|
||||||
">
|
|
||||||
<n-space>
|
<n-space>
|
||||||
<n-button size="small" type="success" @click="acceptTransfer">
|
<n-button-group size="small">
|
||||||
Accept
|
<n-button v-if="canAccept" type="success" @click="acceptTransfer">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<FontAwesomeIcon :icon="faCheck" />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-dropdown
|
||||||
|
trigger="click"
|
||||||
|
:options="dropdownOptions"
|
||||||
|
@select="handleSelect"
|
||||||
|
v-if="canAccept && props.transfer.content_type !== 'text'">
|
||||||
|
<n-button type="success">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<FontAwesomeIcon :icon="faChevronDown" />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-dropdown>
|
||||||
<n-button
|
<n-button
|
||||||
v-if="props.transfer.content_type !== 'text'"
|
v-if="canAccept"
|
||||||
size="small"
|
size="small"
|
||||||
type="success"
|
type="error"
|
||||||
@click="acceptToFolder">
|
@click="rejectTransfer">
|
||||||
Accept To Folder
|
<template #icon>
|
||||||
</n-button>
|
<n-icon>
|
||||||
<n-button size="small" type="error" ghost @click="rejectTransfer">
|
<FontAwesomeIcon :icon="faXmark" />
|
||||||
Reject
|
</n-icon>
|
||||||
</n-button>
|
</template>
|
||||||
</n-space>
|
</n-button>
|
||||||
</div>
|
|
||||||
|
<n-button type="success" @click="handleOpen" v-if="canCopy"
|
||||||
<!-- 文本传输按钮 -->
|
><template #icon>
|
||||||
<div
|
<n-icon>
|
||||||
class="actions-wrapper"
|
<FontAwesomeIcon :icon="faEye" />
|
||||||
v-if="
|
</n-icon>
|
||||||
props.transfer.type === 'receive' &&
|
</template>
|
||||||
props.transfer.status === 'completed' &&
|
</n-button>
|
||||||
props.transfer.content_type === 'text'
|
<n-button type="success" @click="handleCopy" v-if="canCopy"
|
||||||
">
|
><template #icon>
|
||||||
<n-space>
|
<n-icon>
|
||||||
<n-button size="small" type="success" @click="handleOpen"
|
<FontAwesomeIcon :icon="faCopy" />
|
||||||
>Open</n-button
|
</n-icon>
|
||||||
>
|
</template>
|
||||||
<n-button size="small" type="success" @click="handleCopy"
|
</n-button>
|
||||||
>Copy</n-button
|
<n-button
|
||||||
>
|
type="success"
|
||||||
</n-space>
|
@click="handleDelete"
|
||||||
</div>
|
v-if="
|
||||||
|
props.transfer.status === 'completed' ||
|
||||||
<!-- 取消按钮 -->
|
props.transfer.status === 'error' ||
|
||||||
<div class="actions-wrapper" v-if="canCancel">
|
props.transfer.status === 'canceled' ||
|
||||||
<n-space>
|
props.transfer.status === 'rejected'
|
||||||
<n-button
|
">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<FontAwesomeIcon :icon="faTrash" />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="canCancel"
|
||||||
size="small"
|
size="small"
|
||||||
type="error"
|
type="error"
|
||||||
ghost
|
|
||||||
@click="CancelTransfer(props.transfer.id)"
|
@click="CancelTransfer(props.transfer.id)"
|
||||||
>Cancel</n-button
|
><template #icon>
|
||||||
>
|
<n-icon>
|
||||||
|
<FontAwesomeIcon :icon="faStop" />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</n-button-group>
|
||||||
</n-space>
|
</n-space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,6 +406,7 @@ const canCancel = computed(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-wrapper {
|
.icon-wrapper {
|
||||||
@@ -350,4 +438,17 @@ const canCancel = computed(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.actions-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-row {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -5,7 +5,7 @@ go 1.25
|
|||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.66
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.67
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -43,7 +43,7 @@ require (
|
|||||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
github.com/leaanthony/u v1.1.1 // indirect
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lmittmann/tint v1.1.2 // indirect
|
github.com/lmittmann/tint v1.1.3 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -98,8 +98,8 @@ github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
|||||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
||||||
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
@@ -156,8 +156,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
|
|||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.66 h1:CfrZVZ+YlbzcMQiVBl74NgptqB8pV8+b5fGWsBThRsQ=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.67 h1:cUpNk00Hvu9DMBI6bpF4xxwwzf3yT1n9l7D1WUvMrQ8=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.66/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.67/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
|
||||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (s *Service) SendFiles(target *discovery.Peer, targetIP string, filePaths []string) {
|
||||||
|
for _, filePath := range filePaths {
|
||||||
|
go s.SendFile(target, targetIP, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath string) {
|
func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath string) {
|
||||||
taskID := uuid.New().String()
|
taskID := uuid.New().String()
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -28,6 +34,7 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
|
|||||||
defer func() {
|
defer func() {
|
||||||
s.cancelMap.Delete(taskID)
|
s.cancelMap.Delete(taskID)
|
||||||
cancel()
|
cancel()
|
||||||
|
s.NotifyTransferListUpdate()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
@@ -42,21 +49,19 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
task := Transfer{
|
task := NewTransfer(
|
||||||
ID: taskID,
|
taskID,
|
||||||
FileName: filepath.Base(filePath),
|
Sender{
|
||||||
FileSize: stat.Size(),
|
|
||||||
Sender: Sender{
|
|
||||||
ID: s.discoveryService.GetID(),
|
ID: s.discoveryService.GetID(),
|
||||||
Name: s.discoveryService.GetName(),
|
Name: s.discoveryService.GetName(),
|
||||||
},
|
},
|
||||||
Type: TransferTypeSend,
|
WithFileName(filepath.Base(filePath)),
|
||||||
Status: TransferStatusPending,
|
WithFileSize(stat.Size()),
|
||||||
ContentType: ContentTypeFile,
|
WithType(TransferTypeSend),
|
||||||
}
|
WithContentType(ContentTypeFile),
|
||||||
|
)
|
||||||
|
|
||||||
s.transferList.Store(task.ID, task)
|
s.StoreTransferToList(task)
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
|
|
||||||
askResp, err := s.ask(ctx, target, targetIP, task)
|
askResp, err := s.ask(ctx, target, targetIP, task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,8 +72,6 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
|
|||||||
task.Status = TransferStatusError
|
task.Status = TransferStatusError
|
||||||
task.ErrorMsg = fmt.Sprintf("Failed to connect to receiver: %v", err)
|
task.ErrorMsg = fmt.Sprintf("Failed to connect to receiver: %v", err)
|
||||||
}
|
}
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if askResp.Accepted {
|
if askResp.Accepted {
|
||||||
@@ -76,8 +79,6 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
|
|||||||
} else {
|
} else {
|
||||||
// 接收方拒绝
|
// 接收方拒绝
|
||||||
task.Status = TransferStatusRejected
|
task.Status = TransferStatusRejected
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,6 +92,7 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
|
|||||||
defer func() {
|
defer func() {
|
||||||
s.cancelMap.Delete(taskID)
|
s.cancelMap.Delete(taskID)
|
||||||
cancel()
|
cancel()
|
||||||
|
s.NotifyTransferListUpdate()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
size, err := calculateTarSize(ctx, folderPath)
|
size, err := calculateTarSize(ctx, folderPath)
|
||||||
@@ -99,29 +101,29 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
task := Transfer{
|
task := NewTransfer(
|
||||||
ID: taskID,
|
taskID,
|
||||||
FileName: filepath.Base(folderPath),
|
Sender{
|
||||||
FileSize: size,
|
|
||||||
Sender: Sender{
|
|
||||||
ID: s.discoveryService.GetID(),
|
ID: s.discoveryService.GetID(),
|
||||||
Name: s.discoveryService.GetName(),
|
Name: s.discoveryService.GetName(),
|
||||||
},
|
},
|
||||||
Type: TransferTypeSend,
|
WithFileName(filepath.Base(folderPath)),
|
||||||
Status: TransferStatusPending,
|
WithFileSize(size),
|
||||||
ContentType: ContentTypeFolder,
|
WithType(TransferTypeSend),
|
||||||
}
|
WithContentType(ContentTypeFolder),
|
||||||
|
)
|
||||||
|
|
||||||
s.transferList.Store(task.ID, task)
|
s.StoreTransferToList(task)
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
|
|
||||||
askResp, err := s.ask(ctx, target, targetIP, task)
|
askResp, err := s.ask(ctx, target, targetIP, task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
task.Status = TransferStatusCanceled
|
||||||
|
} else {
|
||||||
// 如果请求发送失败,更新状态为 Error
|
// 如果请求发送失败,更新状态为 Error
|
||||||
task.Status = TransferStatusError
|
task.Status = TransferStatusError
|
||||||
task.ErrorMsg = fmt.Sprintf("Failed to connect to receiver: %v", err)
|
task.ErrorMsg = fmt.Sprintf("Failed to connect to receiver: %v", err)
|
||||||
s.transferList.Store(task.ID, task)
|
}
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if askResp.Accepted {
|
if askResp.Accepted {
|
||||||
@@ -137,9 +139,6 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
|
|||||||
} else {
|
} else {
|
||||||
// 接收方拒绝
|
// 接收方拒绝
|
||||||
task.Status = TransferStatusRejected
|
task.Status = TransferStatusRejected
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,32 +151,32 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
|
|||||||
defer func() {
|
defer func() {
|
||||||
s.cancelMap.Delete(taskID)
|
s.cancelMap.Delete(taskID)
|
||||||
cancel()
|
cancel()
|
||||||
|
s.NotifyTransferListUpdate()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
r := bytes.NewReader([]byte(text))
|
r := bytes.NewReader([]byte(text))
|
||||||
task := Transfer{
|
task := NewTransfer(
|
||||||
ID: taskID,
|
taskID,
|
||||||
FileName: "",
|
Sender{
|
||||||
FileSize: int64(len(text)),
|
|
||||||
Sender: Sender{
|
|
||||||
ID: s.discoveryService.GetID(),
|
ID: s.discoveryService.GetID(),
|
||||||
Name: s.discoveryService.GetName(),
|
Name: s.discoveryService.GetName(),
|
||||||
},
|
},
|
||||||
Type: TransferTypeSend,
|
WithFileSize(int64(len(text))),
|
||||||
Status: TransferStatusPending,
|
WithType(TransferTypeSend),
|
||||||
ContentType: ContentTypeText,
|
WithContentType(ContentTypeText),
|
||||||
}
|
)
|
||||||
|
|
||||||
s.transferList.Store(task.ID, task)
|
s.StoreTransferToList(task)
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
|
|
||||||
askResp, err := s.ask(ctx, target, targetIP, task)
|
askResp, err := s.ask(ctx, target, targetIP, task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
task.Status = TransferStatusCanceled
|
||||||
|
} else {
|
||||||
// 如果请求发送失败,更新状态为 Error
|
// 如果请求发送失败,更新状态为 Error
|
||||||
task.Status = TransferStatusError
|
task.Status = TransferStatusError
|
||||||
task.ErrorMsg = fmt.Sprintf("Failed to connect to receiver: %v", err)
|
task.ErrorMsg = fmt.Sprintf("Failed to connect to receiver: %v", err)
|
||||||
s.transferList.Store(task.ID, task)
|
}
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if askResp.Accepted {
|
if askResp.Accepted {
|
||||||
@@ -185,14 +184,12 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
|
|||||||
} else {
|
} else {
|
||||||
// 接收方拒绝
|
// 接收方拒绝
|
||||||
task.Status = TransferStatusRejected
|
task.Status = TransferStatusRejected
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask 向接收端发送传输请求
|
// ask 向接收端发送传输请求
|
||||||
func (s *Service) ask(ctx context.Context, target *discovery.Peer, targetIP string, task Transfer) (TransferAskResponse, error) {
|
func (s *Service) ask(ctx context.Context, target *discovery.Peer, targetIP string, task *Transfer) (TransferAskResponse, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return TransferAskResponse{}, err
|
return TransferAskResponse{}, err
|
||||||
}
|
}
|
||||||
@@ -225,7 +222,11 @@ func (s *Service) ask(ctx context.Context, target *discovery.Peer, targetIP stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// processTransfer 传输数据
|
// processTransfer 传输数据
|
||||||
func (s *Service) processTransfer(ctx context.Context, askResp TransferAskResponse, target *discovery.Peer, targetIP string, task Transfer, payload io.Reader) {
|
func (s *Service) processTransfer(ctx context.Context, askResp TransferAskResponse, target *discovery.Peer, targetIP string, task *Transfer, payload io.Reader) {
|
||||||
|
defer func() {
|
||||||
|
s.NotifyTransferListUpdate()
|
||||||
|
}()
|
||||||
|
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -244,8 +245,7 @@ func (s *Service) processTransfer(ctx context.Context, askResp TransferAskRespon
|
|||||||
Speed: speed,
|
Speed: speed,
|
||||||
}
|
}
|
||||||
task.Status = TransferStatusActive
|
task.Status = TransferStatusActive
|
||||||
s.transferList.Store(task.ID, task)
|
s.NotifyTransferListUpdate()
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +265,6 @@ func (s *Service) processTransfer(ctx context.Context, askResp TransferAskRespon
|
|||||||
task.ErrorMsg = fmt.Sprintf("Failed to upload file: %v", err)
|
task.ErrorMsg = fmt.Sprintf("Failed to upload file: %v", err)
|
||||||
slog.Error("Failed to upload file", "url", uploadUrl.String(), "error", err, "component", "transfer-client")
|
slog.Error("Failed to upload file", "url", uploadUrl.String(), "error", err, "component", "transfer-client")
|
||||||
}
|
}
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -279,8 +277,6 @@ func (s *Service) processTransfer(ctx context.Context, askResp TransferAskRespon
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
task.Status = TransferStatusError
|
task.Status = TransferStatusError
|
||||||
task.ErrorMsg = uploadResp.Message
|
task.ErrorMsg = uploadResp.Message
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,15 +284,11 @@ func (s *Service) processTransfer(ctx context.Context, askResp TransferAskRespon
|
|||||||
if uploadResp.Status == TransferStatusCanceled {
|
if uploadResp.Status == TransferStatusCanceled {
|
||||||
task.Status = TransferStatusCanceled
|
task.Status = TransferStatusCanceled
|
||||||
task.ErrorMsg = uploadResp.Message
|
task.ErrorMsg = uploadResp.Message
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 传输成功,任务结束
|
// 传输成功,任务结束
|
||||||
task.Status = TransferStatusCompleted
|
task.Status = TransferStatusCompleted
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type countWriter struct {
|
type countWriter struct {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package transfer
|
package transfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type TransferStatus string
|
type TransferStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -30,6 +34,7 @@ const (
|
|||||||
// Transfer
|
// Transfer
|
||||||
type Transfer struct {
|
type Transfer struct {
|
||||||
ID string `json:"id" binding:"required"` // 传输会话 ID
|
ID string `json:"id" binding:"required"` // 传输会话 ID
|
||||||
|
CreateTime int64 `json:"create_time"` // 创建时间
|
||||||
Sender Sender `json:"sender" binding:"required"` // 发送者
|
Sender Sender `json:"sender" binding:"required"` // 发送者
|
||||||
FileName string `json:"file_name"` // 文件名
|
FileName string `json:"file_name"` // 文件名
|
||||||
FileSize int64 `json:"file_size"` // 文件大小 (字节)
|
FileSize int64 `json:"file_size"` // 文件大小 (字节)
|
||||||
@@ -44,6 +49,77 @@ type Transfer struct {
|
|||||||
DecisionChan chan Decision `json:"-"` // 用户决策通道
|
DecisionChan chan Decision `json:"-"` // 用户决策通道
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TransferOption func(*Transfer)
|
||||||
|
|
||||||
|
func NewTransfer(id string, sender Sender, opts ...TransferOption) *Transfer {
|
||||||
|
t := &Transfer{
|
||||||
|
ID: id,
|
||||||
|
CreateTime: time.Now().UnixMilli(),
|
||||||
|
Sender: sender,
|
||||||
|
Status: TransferStatusPending, // Default status
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithFileName(name string) TransferOption {
|
||||||
|
return func(t *Transfer) {
|
||||||
|
t.FileName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithFileSize(size int64) TransferOption {
|
||||||
|
return func(t *Transfer) {
|
||||||
|
t.FileSize = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSavePath(path string) TransferOption {
|
||||||
|
return func(t *Transfer) {
|
||||||
|
t.SavePath = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithStatus(status TransferStatus) TransferOption {
|
||||||
|
return func(t *Transfer) {
|
||||||
|
t.Status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithType(transType TransferType) TransferOption {
|
||||||
|
return func(t *Transfer) {
|
||||||
|
t.Type = transType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithContentType(contentType ContentType) TransferOption {
|
||||||
|
return func(t *Transfer) {
|
||||||
|
t.ContentType = contentType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithText(text string) TransferOption {
|
||||||
|
return func(t *Transfer) {
|
||||||
|
t.Text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithErrorMsg(msg string) TransferOption {
|
||||||
|
return func(t *Transfer) {
|
||||||
|
t.ErrorMsg = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithToken(token string) TransferOption {
|
||||||
|
return func(t *Transfer) {
|
||||||
|
t.Token = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Sender struct {
|
type Sender struct {
|
||||||
ID string `json:"id" binding:"required"` // 发送者 ID
|
ID string `json:"id" binding:"required"` // 发送者 ID
|
||||||
Name string `json:"name" binding:"required"` // 发送者名称
|
Name string `json:"name" binding:"required"` // 发送者名称
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
// handleAsk 处理接收文件请求
|
// handleAsk 处理接收文件请求
|
||||||
func (s *Service) handleAsk(c *gin.Context) {
|
func (s *Service) handleAsk(c *gin.Context) {
|
||||||
|
defer s.NotifyTransferListUpdate()
|
||||||
var task Transfer
|
var task Transfer
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&task); err != nil {
|
if err := c.ShouldBindJSON(&task); err != nil {
|
||||||
@@ -39,10 +40,9 @@ func (s *Service) handleAsk(c *gin.Context) {
|
|||||||
task.Type = TransferTypeReceive
|
task.Type = TransferTypeReceive
|
||||||
task.Status = TransferStatusPending
|
task.Status = TransferStatusPending
|
||||||
task.DecisionChan = make(chan Decision)
|
task.DecisionChan = make(chan Decision)
|
||||||
s.transferList.Store(task.ID, task)
|
s.StoreTransferToList(&task)
|
||||||
|
|
||||||
// 通知 Wails 前端
|
// 通知 Wails 前端
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
|
|
||||||
// 等待用户决策或发送端放弃
|
// 等待用户决策或发送端放弃
|
||||||
select {
|
select {
|
||||||
@@ -53,34 +53,28 @@ func (s *Service) handleAsk(c *gin.Context) {
|
|||||||
task.SavePath = decision.SavePath
|
task.SavePath = decision.SavePath
|
||||||
token := uuid.New().String()
|
token := uuid.New().String()
|
||||||
task.Token = token
|
task.Token = token
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
} else {
|
} else {
|
||||||
task.Status = TransferStatusRejected
|
task.Status = TransferStatusRejected
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, TransferAskResponse{
|
c.JSON(http.StatusOK, TransferAskResponse{
|
||||||
ID: task.ID,
|
ID: task.ID,
|
||||||
Accepted: decision.Accepted,
|
Accepted: decision.Accepted,
|
||||||
Token: task.Token,
|
Token: task.Token,
|
||||||
})
|
})
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
case <-c.Request.Context().Done():
|
case <-c.Request.Context().Done():
|
||||||
// 发送端放弃
|
// 发送端放弃
|
||||||
task.Status = TransferStatusCanceled
|
task.Status = TransferStatusCanceled
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvePendingRequest 外部调用,解决待处理的传输请求
|
// ResolvePendingRequest 外部调用,解决待处理的传输请求
|
||||||
// 返回 true 表示成功处理,false 表示未找到该 ID 的请求
|
// 返回 true 表示成功处理,false 表示未找到该 ID 的请求
|
||||||
func (s *Service) ResolvePendingRequest(id string, accept bool, savePath string) bool {
|
func (s *Service) ResolvePendingRequest(id string, accept bool, savePath string) bool {
|
||||||
val, ok := s.transferList.Load(id)
|
task, ok := s.GetTransfer(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
task := val.(Transfer)
|
|
||||||
task.DecisionChan <- Decision{
|
task.DecisionChan <- Decision{
|
||||||
ID: id,
|
ID: id,
|
||||||
Accepted: accept,
|
Accepted: accept,
|
||||||
@@ -91,6 +85,7 @@ func (s *Service) ResolvePendingRequest(id string, accept bool, savePath string)
|
|||||||
|
|
||||||
// handleUpload 处理接收文件请求
|
// handleUpload 处理接收文件请求
|
||||||
func (s *Service) handleUpload(c *gin.Context) {
|
func (s *Service) handleUpload(c *gin.Context) {
|
||||||
|
defer s.NotifyTransferListUpdate()
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
token := c.Query("token")
|
token := c.Query("token")
|
||||||
|
|
||||||
@@ -104,7 +99,7 @@ func (s *Service) handleUpload(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取传输任务
|
// 获取传输任务
|
||||||
val, ok := s.transferList.Load(id)
|
task, ok := s.GetTransfer(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.JSON(http.StatusUnauthorized, TransferUploadResponse{
|
c.JSON(http.StatusUnauthorized, TransferUploadResponse{
|
||||||
ID: id,
|
ID: id,
|
||||||
@@ -113,7 +108,6 @@ func (s *Service) handleUpload(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
task := val.(Transfer)
|
|
||||||
ctx, cancel := context.WithCancel(c.Request.Context())
|
ctx, cancel := context.WithCancel(c.Request.Context())
|
||||||
s.cancelMap.Store(task.ID, cancel)
|
s.cancelMap.Store(task.ID, cancel)
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -143,8 +137,6 @@ func (s *Service) handleUpload(c *gin.Context) {
|
|||||||
|
|
||||||
// 更新状态为 active
|
// 更新状态为 active
|
||||||
task.Status = TransferStatusActive
|
task.Status = TransferStatusActive
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
|
|
||||||
savePath := task.SavePath
|
savePath := task.SavePath
|
||||||
if savePath == "" {
|
if savePath == "" {
|
||||||
@@ -170,21 +162,16 @@ func (s *Service) handleUpload(c *gin.Context) {
|
|||||||
slog.Error("Failed to create file", "error", err, "component", "transfer")
|
slog.Error("Failed to create file", "error", err, "component", "transfer")
|
||||||
task.Status = TransferStatusError
|
task.Status = TransferStatusError
|
||||||
task.ErrorMsg = fmt.Errorf("receiver failed to create file: %v", err).Error()
|
task.ErrorMsg = fmt.Errorf("receiver failed to create file: %v", err).Error()
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
// 通知前端传输失败
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
s.receive(c, &task, file, ctxReader)
|
s.receive(c, task, file, ctxReader)
|
||||||
case ContentTypeText:
|
case ContentTypeText:
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
s.receive(c, &task, &buf, ctxReader)
|
s.receive(c, task, &buf, ctxReader)
|
||||||
task.Text = buf.String()
|
task.Text = buf.String()
|
||||||
s.transferList.Store(task.ID, task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
case ContentTypeFolder:
|
case ContentTypeFolder:
|
||||||
s.receiveFolder(c, savePath, &task, ctxReader)
|
s.receiveFolder(c, savePath, task, ctxReader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,8 +186,8 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
|
|||||||
Total: total,
|
Total: total,
|
||||||
Speed: speed,
|
Speed: speed,
|
||||||
}
|
}
|
||||||
s.transferList.Store(task.ID, *task)
|
task.Status = TransferStatusActive
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
s.NotifyTransferListUpdate()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,8 +198,6 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
|
|||||||
slog.Info("Sender canceled transfer (Network/Context disconnected)", "id", task.ID, "raw_err", err)
|
slog.Info("Sender canceled transfer (Network/Context disconnected)", "id", task.ID, "raw_err", err)
|
||||||
task.ErrorMsg = "Sender disconnected"
|
task.ErrorMsg = "Sender disconnected"
|
||||||
task.Status = TransferStatusCanceled
|
task.Status = TransferStatusCanceled
|
||||||
s.transferList.Store(task.ID, *task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +212,6 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
|
|||||||
Message: "File transfer canceled",
|
Message: "File transfer canceled",
|
||||||
Status: TransferStatusCanceled,
|
Status: TransferStatusCanceled,
|
||||||
})
|
})
|
||||||
s.transferList.Store(task.ID, *task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,8 +224,6 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
|
|||||||
slog.Error("Failed to write file", "error", err, "component", "transfer")
|
slog.Error("Failed to write file", "error", err, "component", "transfer")
|
||||||
task.Status = TransferStatusError
|
task.Status = TransferStatusError
|
||||||
task.ErrorMsg = fmt.Errorf("failed to write file: %v", err).Error()
|
task.ErrorMsg = fmt.Errorf("failed to write file: %v", err).Error()
|
||||||
s.transferList.Store(task.ID, *task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,11 +234,11 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
|
|||||||
})
|
})
|
||||||
// 传输成功,任务结束
|
// 传输成功,任务结束
|
||||||
task.Status = TransferStatusCompleted
|
task.Status = TransferStatusCompleted
|
||||||
s.transferList.Store(task.ID, *task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer, ctxReader io.Reader) {
|
func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer, ctxReader io.Reader) {
|
||||||
|
defer s.NotifyTransferListUpdate()
|
||||||
|
|
||||||
// 创建根目录
|
// 创建根目录
|
||||||
destPath := filepath.Join(savePath, task.FileName)
|
destPath := filepath.Join(savePath, task.FileName)
|
||||||
if err := os.MkdirAll(destPath, 0755); err != nil {
|
if err := os.MkdirAll(destPath, 0755); err != nil {
|
||||||
@@ -269,8 +250,6 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
|
|||||||
slog.Error("Failed to create folder", "error", err, "component", "transfer")
|
slog.Error("Failed to create folder", "error", err, "component", "transfer")
|
||||||
task.Status = TransferStatusError
|
task.Status = TransferStatusError
|
||||||
task.ErrorMsg = fmt.Errorf("receiver failed to create folder: %v", err).Error()
|
task.ErrorMsg = fmt.Errorf("receiver failed to create folder: %v", err).Error()
|
||||||
s.transferList.Store(task.ID, *task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,8 +263,8 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
|
|||||||
Total: total,
|
Total: total,
|
||||||
Speed: speed,
|
Speed: speed,
|
||||||
}
|
}
|
||||||
s.transferList.Store(task.ID, *task)
|
task.Status = TransferStatusActive
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
s.NotifyTransferListUpdate()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,8 +277,6 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
|
|||||||
task.Status = TransferStatusCanceled
|
task.Status = TransferStatusCanceled
|
||||||
task.ErrorMsg = "Sender disconnected"
|
task.ErrorMsg = "Sender disconnected"
|
||||||
// 发送端已断开,无需也不应再发送 c.JSON
|
// 发送端已断开,无需也不应再发送 c.JSON
|
||||||
s.transferList.Store(task.ID, *task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,8 +290,6 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
|
|||||||
Message: "File transfer canceled",
|
Message: "File transfer canceled",
|
||||||
Status: TransferStatusCanceled,
|
Status: TransferStatusCanceled,
|
||||||
})
|
})
|
||||||
s.transferList.Store(task.ID, *task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,8 +302,6 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
|
|||||||
Message: fmt.Sprintf("Transfer failed: %v", err),
|
Message: fmt.Sprintf("Transfer failed: %v", err),
|
||||||
Status: TransferStatusError,
|
Status: TransferStatusError,
|
||||||
})
|
})
|
||||||
s.transferList.Store(task.ID, *task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +351,4 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
|
|||||||
task.Progress.Total = task.FileSize
|
task.Progress.Total = task.FileSize
|
||||||
task.Progress.Current = task.FileSize
|
task.Progress.Current = task.FileSize
|
||||||
task.Status = TransferStatusCompleted
|
task.Status = TransferStatusCompleted
|
||||||
s.transferList.Store(task.ID, *task)
|
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type Service struct {
|
|||||||
savePath string // 默认下载目录
|
savePath string // 默认下载目录
|
||||||
|
|
||||||
// pendingRequests 存储等待用户确认的通道
|
// pendingRequests 存储等待用户确认的通道
|
||||||
// Key: TransferID, Value: Transfer
|
// Key: TransferID, Value: *Transfer
|
||||||
transferList sync.Map
|
transferList sync.Map
|
||||||
|
|
||||||
discoveryService *discovery.Service
|
discoveryService *discovery.Service
|
||||||
@@ -63,21 +63,21 @@ func (s *Service) Start() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetTransferList() []Transfer {
|
func (s *Service) GetTransferList() []*Transfer {
|
||||||
var requests []Transfer
|
var requests []*Transfer
|
||||||
s.transferList.Range(func(key, value any) bool {
|
s.transferList.Range(func(key, value any) bool {
|
||||||
requests = append(requests, value.(Transfer))
|
requests = append(requests, value.(*Transfer))
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return requests
|
return requests
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetTransfer(transferID string) (Transfer, bool) {
|
func (s *Service) GetTransfer(transferID string) (*Transfer, bool) {
|
||||||
val, ok := s.transferList.Load(transferID)
|
val, ok := s.transferList.Load(transferID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return Transfer{}, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return val.(Transfer), true
|
return val.(*Transfer), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CancelTransfer(transferID string) {
|
func (s *Service) CancelTransfer(transferID string) {
|
||||||
@@ -87,8 +87,36 @@ func (s *Service) CancelTransfer(transferID string) {
|
|||||||
t, ok := s.GetTransfer(transferID)
|
t, ok := s.GetTransfer(transferID)
|
||||||
if ok {
|
if ok {
|
||||||
t.Status = TransferStatusCanceled
|
t.Status = TransferStatusCanceled
|
||||||
s.transferList.Store(transferID, t)
|
s.StoreTransferToList(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) StoreTransferToList(transfer *Transfer) {
|
||||||
|
s.transferList.Store(transfer.ID, transfer)
|
||||||
|
s.NotifyTransferListUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) NotifyTransferListUpdate() {
|
||||||
s.app.Event.Emit("transfer:refreshList")
|
s.app.Event.Emit("transfer:refreshList")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CleanTransferList 清理完成的 transfer
|
||||||
|
func (s *Service) CleanTransferList() {
|
||||||
|
s.transferList.Range(func(key, value any) bool {
|
||||||
|
task := value.(*Transfer)
|
||||||
|
if task.Status == TransferStatusCompleted ||
|
||||||
|
task.Status == TransferStatusError ||
|
||||||
|
task.Status == TransferStatusCanceled ||
|
||||||
|
task.Status == TransferStatusRejected {
|
||||||
|
s.transferList.Delete(key)
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
s.NotifyTransferListUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteTransfer(transferID string) {
|
||||||
|
s.transferList.Delete(transferID)
|
||||||
|
s.NotifyTransferListUpdate()
|
||||||
}
|
}
|
||||||
|
|||||||
20
main.go
20
main.go
@@ -10,11 +10,17 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
type FilesDroppedEvent struct {
|
||||||
|
Files []string `json:"files"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
state := config.LoadWindowState()
|
state := config.LoadWindowState()
|
||||||
|
|
||||||
@@ -57,14 +63,26 @@ func main() {
|
|||||||
app.RegisterService(application.NewService(discoveryService))
|
app.RegisterService(application.NewService(discoveryService))
|
||||||
app.RegisterService(application.NewService(transferService))
|
app.RegisterService(application.NewService(transferService))
|
||||||
|
|
||||||
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
windows := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
Title: "mesh drop",
|
Title: "mesh drop",
|
||||||
Width: state.Width,
|
Width: state.Width,
|
||||||
Height: state.Height,
|
Height: state.Height,
|
||||||
X: state.X,
|
X: state.X,
|
||||||
Y: state.Y,
|
Y: state.Y,
|
||||||
|
EnableFileDrop: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
windows.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
|
||||||
|
files := event.Context().DroppedFiles()
|
||||||
|
details := event.Context().DropTargetDetails()
|
||||||
|
app.Event.Emit("files-dropped", FilesDroppedEvent{
|
||||||
|
Files: files,
|
||||||
|
Target: details.ElementID,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
application.RegisterEvent[FilesDroppedEvent]("files-dropped")
|
||||||
|
|
||||||
// Initialize structured logging
|
// Initialize structured logging
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
Level: slog.LevelDebug,
|
Level: slog.LevelDebug,
|
||||||
|
|||||||
Reference in New Issue
Block a user