add: send files

This commit is contained in:
2026-02-04 17:57:56 +08:00
parent 68533dad31
commit 0e94ae3220
17 changed files with 725 additions and 315 deletions

View File

@@ -5,4 +5,4 @@ TODO
- [x] 多样化图标 - [x] 多样化图标
- [ ] 加密传输 - [ ] 加密传输
- [x] 取消传输 - [x] 取消传输
- [ ] 多文件发送 - [x] 多文件发送

View File

@@ -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();

View File

@@ -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;
} }

View 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";

View File

@@ -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>);
} }

View File

@@ -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);

View 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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">
&nbsp;- Canceled</n-text &nbsp;- Canceled</n-text
> >
<n-text <n-text
@@ -227,19 +293,15 @@ const canCancel = computed(() => {
type="error"> type="error">
&nbsp;- Rejected</n-text &nbsp;- Rejected</n-text
> >
<n-text
depth="3"
v-if="props.transfer.status === 'pending'"
type="warning">
&nbsp;- 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
View File

@@ -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
View File

@@ -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=

View File

@@ -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 {

View File

@@ -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"` // 发送者名称

View File

@@ -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")
} }

View File

@@ -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
View File

@@ -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,