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

@@ -16,10 +16,6 @@ import {
NBadge,
NButton,
NIcon,
NDrawer,
NDrawerContent,
useDialog,
NInput,
} from "naive-ui";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
@@ -31,18 +27,9 @@ import {
import { type MenuOption } from "naive-ui";
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
import {
GetPeers,
GetPeerByIP,
} from "../../bindings/mesh-drop/internal/discovery/service";
import { GetPeers } from "../../bindings/mesh-drop/internal/discovery/service";
import { Events } from "@wailsio/runtime";
import {
GetTransferList,
SendFile,
SendText,
SendFolder,
} from "../../bindings/mesh-drop/internal/transfer/service";
import { Dialogs, Clipboard } from "@wailsio/runtime";
import { GetTransferList } from "../../bindings/mesh-drop/internal/transfer/service";
const peers = ref<Peer[]>([]);
const transferList = ref<Transfer[]>([]);
@@ -54,7 +41,10 @@ const isMobile = ref(false);
onMounted(async () => {
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 = () => {
@@ -110,7 +100,10 @@ Events.On("peers:update", (event) => {
});
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) => {
activeKey.value = key;
showMobileMenu.value = false;
@@ -266,10 +182,7 @@ const handleMenuUpdate = (key: string) => {
<n-gi v-for="peer in peers" :key="peer.id">
<PeerCard
:peer="peer"
@sendFile="handleSendFile"
@sendFolder="handleSendFolder"
@sendText="handleSendText"
@sendClipboard="handleSendClipboard" />
@transferStarted="activeKey = 'transfers'" />
</n-gi>
</n-grid>
</n-space>

View File

@@ -9,6 +9,12 @@ import {
NDropdown,
NSelect,
type DropdownOption,
NModal,
NList,
NListItem,
NThing,
NEmpty,
NInput,
} from "naive-ui";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
@@ -25,18 +31,24 @@ import {
faFolder,
faFont,
faClipboard,
faTrash,
faPlus,
faCloudArrowUp,
} 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<{
peer: Peer;
}>();
const emit = defineEmits<{
(e: "sendFile", ip: string): void;
(e: "sendFolder", ip: string): void;
(e: "sendText", ip: string): void;
(e: "sendClipboard", ip: string): void;
(e: "transferStarted"): void;
}>();
const ips = computed(() => {
@@ -82,8 +94,8 @@ const osIcon = computed(() => {
const sendOptions: DropdownOption[] = [
{
label: "Send File",
key: "file",
label: "Send Files",
key: "files",
icon: () =>
h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon: faFile }) }),
},
@@ -113,20 +125,133 @@ const handleAction = (key: string) => {
if (!selectedIp.value) return;
switch (key) {
case "file":
emit("sendFile", selectedIp.value);
case "files":
showFileModal.value = true;
break;
case "folder":
emit("sendFolder", selectedIp.value);
handleSendFolder();
break;
case "text":
emit("sendText", selectedIp.value);
showTextModal.value = true;
break;
case "clipboard":
emit("sendClipboard", selectedIp.value);
handleSendClipboard();
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>
<template>
@@ -189,6 +314,107 @@ const handleAction = (key: string) => {
</div>
</template>
</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>
<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,
useMessage,
NInput,
NDropdown,
NButtonGroup,
} from "naive-ui";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
@@ -20,12 +22,21 @@ import {
faFile,
faFileLines,
faFolder,
faClock,
faChevronDown,
faEye,
faCopy,
faTrash,
faXmark,
faStop,
faCheck,
} from "@fortawesome/free-solid-svg-icons";
import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
import {
ResolvePendingRequest,
CancelTransfer,
DeleteTransfer,
} from "../../bindings/mesh-drop/internal/transfer/service";
import { Dialogs, Clipboard } from "@wailsio/runtime";
@@ -49,6 +60,10 @@ const formatSpeed = (speed?: number) => {
return formatSize(speed) + "/s";
};
const formatTime = (time: number): string => {
return new Date(time).toLocaleString();
};
const percentage = computed(() =>
Math.min(
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 handleCopy = async () => {
@@ -130,6 +162,27 @@ const canCancel = computed(() => {
}
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>
<template>
@@ -183,7 +236,9 @@ const canCancel = computed(() => {
<n-tag
size="small"
:bordered="false"
v-if="props.transfer.sender.name">
v-if="
props.transfer.sender.name && props.transfer.type === 'receive'
">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faUser" />
@@ -191,6 +246,17 @@ const canCancel = computed(() => {
</template>
{{ props.transfer.sender.name }}
</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 class="meta-line">
@@ -218,7 +284,7 @@ const canCancel = computed(() => {
<n-text
depth="3"
v-if="props.transfer.status === 'canceled'"
type="error">
type="info">
&nbsp;- Canceled</n-text
>
<n-text
@@ -227,19 +293,15 @@ const canCancel = computed(() => {
type="error">
&nbsp;- Rejected</n-text
>
<n-text
depth="3"
v-if="props.transfer.status === 'pending'"
type="warning">
&nbsp;- Waiting for accept</n-text
>
</span>
</div>
<!-- 文字内容 -->
<n-text
v-if="
props.transfer.type === 'send' &&
props.transfer.status === 'pending'
"
depth="3"
>Waiting for accept</n-text
>
<!-- 进度条 -->
<n-progress
v-if="props.transfer.status === 'active'"
@@ -252,58 +314,83 @@ const canCancel = computed(() => {
style="margin-top: 4px" />
</div>
<!-- 接受/拒绝操作按钮 -->
<div
class="actions-wrapper"
v-if="
props.transfer.type === 'receive' &&
props.transfer.status === 'pending'
">
<!-- 操作按钮 -->
<div class="actions-wrapper">
<n-space>
<n-button size="small" type="success" @click="acceptTransfer">
Accept
</n-button>
<n-button
v-if="props.transfer.content_type !== 'text'"
size="small"
type="success"
@click="acceptToFolder">
Accept To Folder
</n-button>
<n-button size="small" type="error" ghost @click="rejectTransfer">
Reject
</n-button>
</n-space>
</div>
<n-button-group size="small">
<n-button v-if="canAccept" type="success" @click="acceptTransfer">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faCheck" />
</n-icon>
</template>
</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
v-if="canAccept"
size="small"
type="error"
@click="rejectTransfer">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faXmark" />
</n-icon>
</template>
</n-button>
<!-- 文本传输按钮 -->
<div
class="actions-wrapper"
v-if="
props.transfer.type === 'receive' &&
props.transfer.status === 'completed' &&
props.transfer.content_type === 'text'
">
<n-space>
<n-button size="small" type="success" @click="handleOpen"
>Open</n-button
>
<n-button size="small" type="success" @click="handleCopy"
>Copy</n-button
>
</n-space>
</div>
<!-- 取消按钮 -->
<div class="actions-wrapper" v-if="canCancel">
<n-space>
<n-button
size="small"
type="error"
ghost
@click="CancelTransfer(props.transfer.id)"
>Cancel</n-button
>
<n-button type="success" @click="handleOpen" v-if="canCopy"
><template #icon>
<n-icon>
<FontAwesomeIcon :icon="faEye" />
</n-icon>
</template>
</n-button>
<n-button type="success" @click="handleCopy" v-if="canCopy"
><template #icon>
<n-icon>
<FontAwesomeIcon :icon="faCopy" />
</n-icon>
</template>
</n-button>
<n-button
type="success"
@click="handleDelete"
v-if="
props.transfer.status === 'completed' ||
props.transfer.status === 'error' ||
props.transfer.status === 'canceled' ||
props.transfer.status === 'rejected'
">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faTrash" />
</n-icon>
</template>
</n-button>
<n-button
v-if="canCancel"
size="small"
type="error"
@click="CancelTransfer(props.transfer.id)"
><template #icon>
<n-icon>
<FontAwesomeIcon :icon="faStop" />
</n-icon>
</template>
</n-button>
</n-button-group>
</n-space>
</div>
</div>
@@ -319,6 +406,7 @@ const canCancel = computed(() => {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.icon-wrapper {
@@ -350,4 +438,17 @@ const canCancel = computed(() => {
display: flex;
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>