This commit is contained in:
2026-02-05 02:39:18 +08:00
parent 4b7a4eb36d
commit c45bc5611f
19 changed files with 737 additions and 505 deletions

View File

@@ -8,8 +8,4 @@ import MainLayout from "./components/MainLayout.vue";
</v-app>
</template>
<style>
:root {
font-family: "Inter Variable", sans-serif;
}
</style>
<style></style>

View File

@@ -1,79 +1,28 @@
<script lang="ts" setup>
// --- Vue 核心 ---
import { onMounted, ref, computed } from "vue";
// --- 组件 ---
import PeerCard from "./PeerCard.vue";
import TransferItem from "./TransferItem.vue";
import SettingsView from "./SettingsView.vue";
// --- 类型 & 模型 ---
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
import { GetPeers } from "../../bindings/mesh-drop/internal/discovery/service";
import { Events } from "@wailsio/runtime";
import { GetTransferList } from "../../bindings/mesh-drop/internal/transfer/service";
import {
GetSavePath,
SetSavePath,
GetHostName,
SetHostName,
GetAutoAccept,
SetAutoAccept,
GetSaveHistory,
SetSaveHistory,
GetVersion,
} from "../../bindings/mesh-drop/internal/config/config";
import { Dialogs } from "@wailsio/runtime";
// --- Service & 后端绑定 ---
import { Events } from "@wailsio/runtime";
import { GetPeers } from "../../bindings/mesh-drop/internal/discovery/service";
import { GetTransferList } from "../../bindings/mesh-drop/internal/transfer/service";
// --- 状态 ---
const peers = ref<Peer[]>([]);
const transferList = ref<Transfer[]>([]);
const activeKey = ref("discover");
const drawer = ref(true);
const isMobile = ref(false);
// 监听窗口大小变化更新 isMobile
onMounted(async () => {
checkMobile();
window.addEventListener("resize", checkMobile);
const list = await GetTransferList();
transferList.value = (
(list || []).filter((t) => t !== null) as Transfer[]
).sort((a, b) => b.create_time - a.create_time);
if (isMobile.value) {
drawer.value = false;
}
// 加载配置
savePath.value = await GetSavePath();
hostName.value = await GetHostName();
autoAccept.value = await GetAutoAccept();
saveHistory.value = await GetSaveHistory();
version.value = await GetVersion();
});
const checkMobile = () => {
const mobile = window.innerWidth < 768;
if (mobile !== isMobile.value) {
isMobile.value = mobile;
drawer.value = !mobile;
}
};
// --- 后端集成 ---
onMounted(async () => {
peers.value = await GetPeers();
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
});
// --- 事件监听 ---
Events.On("peers:update", (event) => {
peers.value = event.data;
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
});
Events.On("transfer:refreshList", async () => {
const list = await GetTransferList();
transferList.value = (
(list || []).filter((t) => t !== null) as Transfer[]
).sort((a, b) => b.create_time - a.create_time);
});
// --- 计算属性 ---
const pendingCount = computed(() => {
return transferList.value.filter(
@@ -100,30 +49,47 @@ const menuItems = computed(() => [
},
]);
// --- 设置 ---
const savePath = ref("");
// --- 生命周期 ---
onMounted(async () => {
checkMobile();
window.addEventListener("resize", checkMobile);
const list = await GetTransferList();
transferList.value = (
(list || []).filter((t) => t !== null) as Transfer[]
).sort((a, b) => b.create_time - a.create_time);
const changeSavePath = async () => {
const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select Save Path",
CanChooseDirectories: true,
CanChooseFiles: false,
AllowsMultipleSelection: false,
};
const path = await Dialogs.OpenFile(opts);
if (path && typeof path === "string") {
await SetSavePath(path);
savePath.value = path;
if (isMobile.value) {
drawer.value = false;
}
});
// --- 后端集成 & 事件监听 ---
onMounted(async () => {
peers.value = await GetPeers();
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
});
Events.On("peers:update", (event) => {
peers.value = event.data;
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
});
Events.On("transfer:refreshList", async () => {
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 mobile = window.innerWidth < 768;
if (mobile !== isMobile.value) {
isMobile.value = mobile;
drawer.value = !mobile;
}
};
const hostName = ref("");
const autoAccept = ref(false);
const saveHistory = ref(false);
const version = ref("");
// --- 操作 ---
const handleMenuClick = (key: string) => {
activeKey.value = key;
if (isMobile.value) {
@@ -146,7 +112,7 @@ const handleMenuClick = (key: string) => {
<!-- 导航抽屉 -->
<v-navigation-drawer v-model="drawer" :permanent="!isMobile">
<div class="pa-4" v-if="!isMobile">
<div class="pa-4 d-flex align-center justify-center" v-if="!isMobile">
<div class="text-h6 text-primary font-weight-bold">Mesh Drop</div>
</div>
@@ -164,7 +130,7 @@ const handleMenuClick = (key: string) => {
<v-icon :icon="item.icon"></v-icon>
</template>
<v-list-item-title>
<v-list-item-title class="text-body-2">
{{ item.title }}
<v-badge
v-if="item.badge"
@@ -227,73 +193,7 @@ const handleMenuClick = (key: string) => {
<!-- 设置视图 -->
<div v-show="activeKey === 'settings'">
<v-list lines="one" bg-color="transparent">
<v-list-item title="Save Path" :subtitle="savePath">
<template #prepend>
<v-icon icon="mdi-folder-download"></v-icon>
</template>
<template #append>
<v-btn
variant="text"
color="primary"
@click="changeSavePath"
prepend-icon="mdi-pencil"
>
Change
</v-btn>
</template>
</v-list-item>
<v-list-item title="HostName">
<template #prepend>
<v-icon icon="mdi-laptop"></v-icon>
</template>
<template #append
><v-text-field
clearable
variant="underlined"
v-model="hostName"
width="200"
@update:modelValue="SetHostName"
></v-text-field
></template>
</v-list-item>
<v-list-item title="Save History">
<template #prepend>
<v-icon icon="mdi-history"></v-icon>
</template>
<template #append
><v-switch
v-model="saveHistory"
color="primary"
inset
hide-details
@update:modelValue="SetSaveHistory(saveHistory)"
></v-switch
></template>
</v-list-item>
<v-list-item title="Auto Accept">
<template #prepend>
<v-icon icon="mdi-content-save"></v-icon>
</template>
<template #append
><v-switch
v-model="autoAccept"
color="primary"
inset
hide-details
@update:modelValue="SetAutoAccept(autoAccept)"
></v-switch
></template>
</v-list-item>
<v-list-item title="Version">
<template #prepend>
<v-icon icon="mdi-information"></v-icon>
</template>
<template #append
><div class="text-grey">{{ version }}</div></template
>
</v-list-item>
</v-list>
<SettingsView />
</div>
</v-container>
</v-main>

View File

@@ -1,13 +1,20 @@
<script setup lang="ts">
// --- Vue 核心 ---
import { computed, ref, watch } from "vue";
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
import { Dialogs, Events, Clipboard } from "@wailsio/runtime";
// --- 组件 ---
import FileSendModal from "./modals/FileSendModal.vue";
import TextSendModal from "./modals/TextSendModal.vue";
// --- Wails & 后端绑定 ---
import { Dialogs, Clipboard } from "@wailsio/runtime";
import {
SendFiles,
SendFolder,
SendText,
} from "../../bindings/mesh-drop/internal/transfer/service";
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
// --- 属性 & 事件 ---
const props = defineProps<{
peer: Peer;
}>();
@@ -16,39 +23,10 @@ const emit = defineEmits<{
(e: "transferStarted"): void;
}>();
const ips = computed(() => {
if (!props.peer.routes) return [];
return Object.keys(props.peer.routes);
});
// --- 状态 ---
const selectedIp = ref<string>("");
watch(
ips,
(newIps) => {
if (newIps.length > 0) {
if (!selectedIp.value || !newIps.includes(selectedIp.value)) {
selectedIp.value = newIps[0];
}
} else {
selectedIp.value = "";
}
},
{ immediate: true },
);
const osIcon = computed(() => {
switch (props.peer.os) {
case "linux":
return "mdi-linux";
case "windows":
return "mdi-microsoft-windows";
case "darwin":
return "mdi-apple";
default:
return "mdi-desktop-classic";
}
});
const showFileModal = ref(false);
const showTextModal = ref(false);
const sendOptions = [
{
@@ -73,6 +51,41 @@ const sendOptions = [
},
];
// --- 计算属性 ---
const ips = computed(() => {
if (!props.peer.routes) return [];
return Object.keys(props.peer.routes);
});
const osIcon = computed(() => {
switch (props.peer.os) {
case "linux":
return "mdi-linux";
case "windows":
return "mdi-microsoft-windows";
case "darwin":
return "mdi-apple";
default:
return "mdi-desktop-classic";
}
});
// --- 监听 ---
watch(
ips,
(newIps) => {
if (newIps.length > 0) {
if (!selectedIp.value || !newIps.includes(selectedIp.value)) {
selectedIp.value = newIps[0];
}
} else {
selectedIp.value = "";
}
},
{ immediate: true },
);
// --- 方法 ---
const handleAction = (key: string) => {
if (!selectedIp.value) return;
@@ -92,8 +105,6 @@ const handleAction = (key: string) => {
}
};
// --- 发送逻辑 ---
const handleSendFolder = async () => {
if (!selectedIp.value) return;
const opts: Dialogs.OpenFileDialogOptions = {
@@ -125,85 +136,6 @@ const handleSendClipboard = async () => {
});
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>
@@ -289,122 +221,18 @@ const handleSendFiles = () => {
</template>
</v-card>
<!-- 文件发送 Modal -->
<v-dialog v-model="showFileModal" width="600" persistent eager>
<v-card title="Send Files">
<v-card-text>
<div
v-if="fileList.length === 0"
class="drop-zone pa-10 text-center rounded-lg border-dashed"
@click="openFileDialog"
data-file-drop-target
>
<v-icon
icon="mdi-cloud-upload"
size="48"
color="primary"
class="mb-2"
></v-icon>
<div class="text-body-1 text-medium-emphasis">
Click to select files
</div>
</div>
<!-- Modals -->
<FileSendModal
v-model="showFileModal"
:peer="peer"
:selectedIp="selectedIp"
@transferStarted="emit('transferStarted')"
/>
<div v-else>
<v-list
class="mb-4 text-left"
border
rounded
max-height="400"
style="overflow-y: auto"
data-file-drop-target
>
<v-list-item
v-for="(file, index) in fileList"
:key="file.path"
:title="file.name"
:subtitle="file.path"
lines="two"
>
<template #append>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="handleRemoveFile(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
<v-btn
block
variant="outlined"
style="border-style: dashed"
prepend-icon="mdi-plus"
@click="openFileDialog"
class="mt-2"
>
Add more files
</v-btn>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="handleCancelFiles">Cancel</v-btn>
<v-btn
color="primary"
@click="handleSendFiles"
:disabled="fileList.length === 0"
>
Send {{ fileList.length > 0 ? `(${fileList.length})` : "" }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 文本发送 Modal -->
<v-dialog v-model="showTextModal" width="500" persistent eager>
<v-card title="Send Text">
<v-card-text>
<v-textarea
v-model="textContent"
label="Content"
placeholder="Type something to send..."
rows="4"
auto-grow
></v-textarea>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="showTextModal = false">Cancel</v-btn>
<v-btn
color="primary"
@click="executeSendText"
:disabled="!textContent"
>
Send
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<TextSendModal
v-model="showTextModal"
:peer="peer"
:selectedIp="selectedIp"
@transferStarted="emit('transferStarted')"
/>
</template>
<style scoped>
.drop-zone {
border: 2px dashed #666; /* Use a darker color or theme var */
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

@@ -0,0 +1,119 @@
<script lang="ts" setup>
// --- Vue 核心 ---
import { onMounted, ref } from "vue";
// --- Wails & 后端绑定 ---
import { Dialogs } from "@wailsio/runtime";
import {
GetSavePath,
SetSavePath,
GetHostName,
SetHostName,
GetAutoAccept,
SetAutoAccept,
GetSaveHistory,
SetSaveHistory,
GetVersion,
} from "../../bindings/mesh-drop/internal/config/config";
// --- 状态 ---
const savePath = ref("");
const hostName = ref("");
const autoAccept = ref(false);
const saveHistory = ref(false);
const version = ref("");
// ---生命周期 ---
onMounted(async () => {
savePath.value = await GetSavePath();
hostName.value = await GetHostName();
autoAccept.value = await GetAutoAccept();
saveHistory.value = await GetSaveHistory();
version.value = await GetVersion();
});
// --- 方法 ---
const changeSavePath = async () => {
const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select Save Path",
CanChooseDirectories: true,
CanChooseFiles: false,
AllowsMultipleSelection: false,
};
const path = await Dialogs.OpenFile(opts);
if (path && typeof path === "string") {
await SetSavePath(path);
savePath.value = path;
}
};
</script>
<template>
<v-list lines="one" bg-color="transparent">
<v-list-item title="Save Path" :subtitle="savePath">
<template #prepend>
<v-icon icon="mdi-folder-download"></v-icon>
</template>
<template #append>
<v-btn
variant="text"
color="primary"
@click="changeSavePath"
prepend-icon="mdi-pencil"
>
Change
</v-btn>
</template>
</v-list-item>
<v-list-item title="HostName">
<template #prepend>
<v-icon icon="mdi-laptop"></v-icon>
</template>
<template #append>
<v-text-field
clearable
variant="underlined"
v-model="hostName"
width="200"
@update:modelValue="SetHostName"
></v-text-field>
</template>
</v-list-item>
<v-list-item title="Save History">
<template #prepend>
<v-icon icon="mdi-history"></v-icon>
</template>
<template #append>
<v-switch
v-model="saveHistory"
color="primary"
inset
hide-details
@update:modelValue="SetSaveHistory(saveHistory)"
></v-switch>
</template>
</v-list-item>
<v-list-item title="Auto Accept">
<template #prepend>
<v-icon icon="mdi-content-save"></v-icon>
</template>
<template #append>
<v-switch
v-model="autoAccept"
color="primary"
inset
hide-details
@update:modelValue="SetAutoAccept(autoAccept)"
></v-switch>
</template>
</v-list-item>
<v-list-item title="Version">
<template #prepend>
<v-icon icon="mdi-information"></v-icon>
</template>
<template #append>
<div class="text-grey">{{ version }}</div>
</template>
</v-list-item>
</v-list>
</template>

View File

@@ -1,35 +1,25 @@
<script setup lang="ts">
import { computed, ref, h } from "vue";
// --- Vue 核心 ---
import { computed, ref } from "vue";
// --- Wails & 后端绑定 ---
import { Dialogs, Clipboard } from "@wailsio/runtime";
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";
// --- 属性 & 事件 ---
const props = defineProps<{
transfer: Transfer;
}>();
const formatSize = (bytes?: number) => {
if (bytes === undefined) return "";
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const formatSpeed = (speed?: number) => {
if (!speed) return "";
return formatSize(speed) + "/s";
};
const formatTime = (time: number): string => {
return new Date(time).toLocaleString();
};
// --- 状态 ---
const showContentDialog = ref(false);
// --- 计算属性 ---
const percentage = computed(() =>
Math.min(
100,
@@ -38,63 +28,13 @@ const percentage = computed(() =>
),
),
);
const progressColor = computed(() => {
if (props.transfer.status === "error") return "error";
if (props.transfer.status === "completed") return "success";
return "primary";
});
const acceptTransfer = () => {
ResolvePendingRequest(props.transfer.id, true, "");
};
const rejectTransfer = () => {
ResolvePendingRequest(props.transfer.id, false, "");
};
const acceptToFolder = async () => {
const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select Folder to save the file",
CanChooseDirectories: true,
CanChooseFiles: false,
AllowsMultipleSelection: false,
};
const path = await Dialogs.OpenFile(opts);
if (path !== "") {
ResolvePendingRequest(props.transfer.id, true, path as string);
}
};
const dropdownItems = [
{
title: "Accept To Folder",
value: "folder",
},
];
const handleSelect = (key: string | number) => {
if (key === "folder") {
acceptToFolder();
}
};
const handleDelete = () => {
DeleteTransfer(props.transfer.id);
};
const handleCopy = async () => {
Clipboard.SetText(props.transfer.text)
// .then(() => {
// message.success("Copied to clipboard");
// })
.catch(() => {
// message.error("Failed to copy to clipboard");
console.error("Failed to copy");
});
};
const showContentDialog = ref(false);
const canCancel = computed(() => {
if (
props.transfer.status === "completed" ||
@@ -136,10 +76,60 @@ const canAccept = computed(() => {
}
return false;
});
// --- 方法 ---
const formatSize = (bytes?: number) => {
if (bytes === undefined) return "";
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const formatSpeed = (speed?: number) => {
if (!speed) return "";
return formatSize(speed) + "/s";
};
const formatTime = (time: number): string => {
return new Date(time).toLocaleString();
};
const acceptTransfer = () => {
ResolvePendingRequest(props.transfer.id, true, "");
};
const rejectTransfer = () => {
ResolvePendingRequest(props.transfer.id, false, "");
};
const acceptToFolder = async () => {
const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select Folder to save the file",
CanChooseDirectories: true,
CanChooseFiles: false,
AllowsMultipleSelection: false,
};
const path = await Dialogs.OpenFile(opts);
if (path !== "") {
ResolvePendingRequest(props.transfer.id, true, path as string);
}
};
const handleDelete = () => {
DeleteTransfer(props.transfer.id);
};
const handleCopy = async () => {
Clipboard.SetText(props.transfer.text).catch(() => {
console.error("Failed to copy");
});
};
</script>
<template>
<v-card class="transfer-item mb-2" variant="outlined">
<v-card class="transfer-item mb-2">
<v-card-text class="py-2 px-3">
<div class="d-flex align-center flex-wrap ga-2">
<!-- 图标 -->
@@ -257,54 +247,38 @@ const canAccept = computed(() => {
<!-- 操作按钮 -->
<div class="actions-wrapper">
<v-btn-group density="compact" variant="outlined" divided>
<v-btn
v-if="canAccept"
color="success"
icon="mdi-content-save"
@click="acceptTransfer"
>
<v-tooltip activator="parent" location="top">Accept</v-tooltip>
<v-btn-group density="compact" variant="tonal" divided rounded="xl">
<v-btn v-if="canAccept" color="success" @click="acceptTransfer">
<v-icon icon="mdi-content-save"></v-icon>
<v-tooltip activator="parent" location="bottom">Accept</v-tooltip>
</v-btn>
<v-btn
v-if="canAccept"
color="success"
icon="mdi-folder-arrow-right"
@click="acceptToFolder"
>
<v-tooltip activator="parent" location="top">
<v-btn v-if="canAccept" color="success" @click="acceptToFolder">
<v-icon icon="mdi-folder-arrow-right"></v-icon>
<v-tooltip activator="parent" location="bottom">
Save to Folder
</v-tooltip>
</v-btn>
<v-btn
v-if="canAccept"
color="error"
icon="mdi-close"
@click="rejectTransfer"
>
<v-tooltip activator="parent" location="top">Reject</v-tooltip>
<v-btn v-if="canAccept" color="error" @click="rejectTransfer">
<v-icon icon="mdi-close"></v-icon>
<v-tooltip activator="parent" location="bottom">Reject</v-tooltip>
</v-btn>
<v-btn
v-if="canCopy"
color="success"
icon="mdi-eye"
@click="showContentDialog = true"
>
<v-tooltip activator="parent" location="top">
<v-icon icon="mdi-eye"></v-icon>
<v-tooltip activator="parent" location="bottom">
View Content
</v-tooltip>
</v-btn>
<v-btn
v-if="canCopy"
color="success"
icon="mdi-content-copy"
@click="handleCopy"
>
<v-tooltip activator="parent" location="top">Copy</v-tooltip>
<v-btn v-if="canCopy" color="success" @click="handleCopy">
<v-icon icon="mdi-content-copy"></v-icon>
<v-tooltip activator="parent" location="bottom">Copy</v-tooltip>
</v-btn>
<v-btn
@@ -315,19 +289,19 @@ const canAccept = computed(() => {
props.transfer.status === 'rejected'
"
color="info"
icon="mdi-delete"
@click="handleDelete"
>
<v-tooltip activator="parent" location="top">Delete</v-tooltip>
<v-icon icon="mdi-delete"></v-icon>
<v-tooltip activator="parent" location="bottom">Delete</v-tooltip>
</v-btn>
<v-btn
v-if="canCancel"
color="error"
icon="mdi-stop"
@click="CancelTransfer(props.transfer.id)"
>
<v-tooltip activator="parent" location="top">Cancel</v-tooltip>
<v-icon icon="mdi-stop"></v-icon>
<v-tooltip activator="parent" location="bottom">Cancel</v-tooltip>
</v-btn>
</v-btn-group>
</div>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
// --- Vue 核心 ---
import { computed, ref, watch } from "vue";
// --- Wails & 后端绑定 ---
import { Events, Dialogs } from "@wailsio/runtime";
import { SendFiles } from "../../../bindings/mesh-drop/internal/transfer/service";
import { Peer } from "../../../bindings/mesh-drop/internal/discovery/models";
// --- 属性 & 事件 ---
const props = defineProps<{
modelValue: boolean;
peer: Peer;
selectedIp: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
(e: "transferStarted"): void;
}>();
// --- 状态 ---
const fileList = ref<{ name: string; path: string }[]>([]);
// --- 计算属性 ---
const show = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
// --- 监听 ---
watch(show, (newVal) => {
if (newVal) {
Events.On("files-dropped", (event) => {
const files: string[] = event.data.files || [];
files.forEach((f) => {
if (!fileList.value.find((existing) => existing.path === f)) {
fileList.value.push({
name: f.split(/[\/]/).pop() || f,
path: f,
});
}
});
});
} else {
Events.Off("files-dropped");
fileList.value = [];
}
});
// --- 方法 ---
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 handleSendFiles = async () => {
if (fileList.value.length === 0 || !props.selectedIp) return;
const paths = fileList.value.map((f) => f.path);
try {
await SendFiles(props.peer, props.selectedIp, paths);
emit("transferStarted");
show.value = false;
} catch (e) {
console.error(e);
alert("Failed to send files: " + e);
}
};
</script>
<template>
<v-dialog v-model="show" width="600" persistent eager>
<v-card title="Send Files">
<v-card-text>
<div
v-if="fileList.length === 0"
class="drop-zone pa-10 text-center rounded-lg border-dashed"
@click="openFileDialog"
data-file-drop-target
>
<v-icon
icon="mdi-cloud-upload"
size="48"
color="primary"
class="mb-2"
></v-icon>
<div class="text-body-1 text-medium-emphasis">
Click to select files
</div>
</div>
<div v-else>
<v-list
class="mb-4 text-left"
border
rounded
max-height="400"
style="overflow-y: auto"
data-file-drop-target
>
<v-list-item
v-for="(file, index) in fileList"
:key="file.path"
:title="file.name"
:subtitle="file.path"
lines="two"
>
<template #append>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="handleRemoveFile(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
<v-btn
block
variant="outlined"
style="border-style: dashed"
prepend-icon="mdi-plus"
@click="openFileDialog"
class="mt-2"
>
Add more files
</v-btn>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="show = false">Cancel</v-btn>
<v-btn
color="primary"
@click="handleSendFiles"
:disabled="fileList.length === 0"
>
Send {{ fileList.length > 0 ? `(${fileList.length})` : "" }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.drop-zone {
border: 2px dashed #666; /* Use a darker color or theme var */
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

@@ -0,0 +1,71 @@
<script setup lang="ts">
// --- Vue 核心 ---
import { computed, ref } from "vue";
// --- Wails & 后端绑定 ---
import { SendText } from "../../../bindings/mesh-drop/internal/transfer/service";
import { Peer } from "../../../bindings/mesh-drop/internal/discovery/models";
// --- 属性 & 事件 ---
const props = defineProps<{
modelValue: boolean;
peer: Peer;
selectedIp: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
(e: "transferStarted"): void;
}>();
// --- 状态 ---
const textContent = ref("");
// --- 计算属性 ---
const show = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
// --- 方法 ---
const executeSendText = async () => {
if (!props.selectedIp || !textContent.value) return;
try {
await SendText(props.peer, props.selectedIp, textContent.value);
emit("transferStarted");
show.value = false;
textContent.value = "";
} catch (e) {
console.error(e);
alert("Failed to send text: " + e);
}
};
</script>
<template>
<v-dialog v-model="show" width="500" persistent eager>
<v-card title="Send Text">
<v-card-text>
<v-textarea
v-model="textContent"
label="Content"
placeholder="Type something to send..."
rows="4"
auto-grow
></v-textarea>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="show = false">Cancel</v-btn>
<v-btn
color="primary"
@click="executeSendText"
:disabled="!textContent"
>
Send
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@@ -14,9 +14,6 @@ import App from "./App.vue";
import { createApp } from "vue";
// Styles
// import "unfonts.css";
// Fonts
import "@fontsource-variable/inter";
const app = createApp(App);

View File

@@ -0,0 +1,10 @@
@use "vuetify/settings" with (
$body-font-family: (
"Inter Variable",
sans-serif,
),
$heading-font-family: (
"Inter Variable",
sans-serif,
)
);

View File

@@ -0,0 +1,18 @@
html,
body,
#app {
-webkit-user-select: none;
/* Chrome/Safari/Wails 必须 */
user-select: none;
/* 标准属性 */
cursor: default;
/* 鼠标指针变为默认箭头,而不是文本输入的 I 形 */
}
input,
textarea,
[contenteditable] {
-webkit-user-select: text;
user-select: text;
cursor: text;
}