Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a3989aeedd
|
|||
|
ea40aa76d0
|
@@ -2,5 +2,6 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export {
|
||||
File,
|
||||
FilesDroppedEvent
|
||||
} from "./models.js";
|
||||
|
||||
@@ -5,8 +5,33 @@
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
export class File {
|
||||
"name": string;
|
||||
"path": string;
|
||||
|
||||
/** Creates a new File instance. */
|
||||
constructor($$source: Partial<File> = {}) {
|
||||
if (!("name" in $$source)) {
|
||||
this["name"] = "";
|
||||
}
|
||||
if (!("path" in $$source)) {
|
||||
this["path"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new File instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): File {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new File($$parsedSource as Partial<File>);
|
||||
}
|
||||
}
|
||||
|
||||
export class FilesDroppedEvent {
|
||||
"files": string[];
|
||||
"files": File[];
|
||||
"target": string;
|
||||
|
||||
/** Creates a new FilesDroppedEvent instance. */
|
||||
@@ -25,7 +50,7 @@ export class FilesDroppedEvent {
|
||||
* Creates a new FilesDroppedEvent instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): FilesDroppedEvent {
|
||||
const $$createField0_0 = $$createType0;
|
||||
const $$createField0_0 = $$createType1;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("files" in $$parsedSource) {
|
||||
$$parsedSource["files"] = $$createField0_0($$parsedSource["files"]);
|
||||
@@ -35,4 +60,5 @@ export class FilesDroppedEvent {
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $Create.Array($Create.Any);
|
||||
const $$createType0 = File.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
|
||||
@@ -150,7 +150,18 @@ const handleCleanFinished = async () => {
|
||||
<v-container fluid class="pa-4">
|
||||
<!-- 发现视图 -->
|
||||
<div v-show="activeKey === 'discover'">
|
||||
<div v-if="peers.length > 0" class="peer-grid">
|
||||
<div v-if="peers.length > 0">
|
||||
<v-alert
|
||||
icon="mdi-information-outline"
|
||||
density="compact"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="mb-4 text-body-2"
|
||||
closable
|
||||
>
|
||||
{{ t("discover.dragDropHint") }}
|
||||
</v-alert>
|
||||
<div class="peer-grid">
|
||||
<div v-for="peer in peers" :key="peer.id">
|
||||
<PeerCard
|
||||
:peer="peer"
|
||||
@@ -158,6 +169,7 @@ const handleCleanFinished = async () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// --- Vue 核心 ---
|
||||
import { computed, ref, watch, onMounted } from "vue";
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
// --- 组件 ---
|
||||
@@ -8,7 +8,7 @@ import FileSendModal from "./modals/FileSendModal.vue";
|
||||
import TextSendModal from "./modals/TextSendModal.vue";
|
||||
|
||||
// --- Wails & 后端绑定 ---
|
||||
import { Dialogs, Clipboard } from "@wailsio/runtime";
|
||||
import { Dialogs, Clipboard, Events } from "@wailsio/runtime";
|
||||
import {
|
||||
SendFolder,
|
||||
SendText,
|
||||
@@ -19,14 +19,24 @@ import {
|
||||
AddTrust,
|
||||
RemoveTrust,
|
||||
} from "../../bindings/mesh-drop/internal/config/config";
|
||||
import { File } from "bindings/mesh-drop/models";
|
||||
|
||||
// --- 生命周期 ---
|
||||
const droppedFiles = ref<File[]>([]);
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isTrusted.value = await IsTrusted(props.peer.id);
|
||||
} catch (err) {
|
||||
console.error("Failed to check trusted peer status:", err);
|
||||
}
|
||||
Events.On("files-dropped", (event) => {
|
||||
droppedFiles.value = event.data.files;
|
||||
showFileModal.value = true;
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
Events.Off("files-dropped");
|
||||
});
|
||||
|
||||
// --- 属性 & 事件 ---
|
||||
@@ -171,7 +181,14 @@ const handleUntrust = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card hover link class="peer-card pa-2" :ripple="false">
|
||||
<v-card
|
||||
hover
|
||||
link
|
||||
class="peer-card pa-2"
|
||||
:ripple="false"
|
||||
data-file-drop-target
|
||||
:id="`drop-zone-peer-${peer.id}`"
|
||||
>
|
||||
<template #title>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon :icon="osIcon" size="24" class="mr-2"></v-icon>
|
||||
@@ -219,6 +236,16 @@ const handleUntrust = () => {
|
||||
{{ t("discover.noRoute") }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- 拖放提示覆盖层 -->
|
||||
<div class="drag-drop-overlay">
|
||||
<v-icon
|
||||
icon="mdi-file-upload-outline"
|
||||
size="48"
|
||||
color="primary"
|
||||
style="opacity: 0.8"
|
||||
></v-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-card-actions>
|
||||
@@ -304,6 +331,7 @@ const handleUntrust = () => {
|
||||
v-model="showFileModal"
|
||||
:peer="peer"
|
||||
:selectedIp="selectedIp"
|
||||
:files="droppedFiles"
|
||||
@transferStarted="emit('transferStarted')"
|
||||
/>
|
||||
|
||||
@@ -314,3 +342,70 @@ const handleUntrust = () => {
|
||||
@transferStarted="emit('transferStarted')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.peer-card {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.peer-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.peer-card.file-drop-target-active {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px -4px rgba(var(--v-theme-primary), 0.24) !important;
|
||||
border-color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
.peer-card.file-drop-target-active::after {
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
.drag-drop-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
background: rgba(var(--v-theme-surface), 0.8);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.peer-card.file-drop-target-active .drag-drop-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-drop-content {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transform: translateY(10px);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.peer-card.file-drop-target-active .drag-drop-content {
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// --- Vue 核心 ---
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
// --- Wails & 后端绑定 ---
|
||||
import { Events, Dialogs } from "@wailsio/runtime";
|
||||
import { Events, Dialogs, Window } from "@wailsio/runtime";
|
||||
import { SendFiles } from "../../../bindings/mesh-drop/internal/transfer/service";
|
||||
import { Peer } from "../../../bindings/mesh-drop/internal/discovery/models";
|
||||
import { File } from "bindings/mesh-drop/models";
|
||||
|
||||
onMounted(() => {});
|
||||
|
||||
// --- 属性 & 事件 ---
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
peer: Peer;
|
||||
selectedIp: string;
|
||||
files: File[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -22,7 +26,6 @@ const emit = defineEmits<{
|
||||
|
||||
// --- 状态 ---
|
||||
const { t } = useI18n();
|
||||
const fileList = ref<{ name: string; path: string }[]>([]);
|
||||
|
||||
// --- 计算属性 ---
|
||||
const show = computed({
|
||||
@@ -34,19 +37,15 @@ const show = computed({
|
||||
watch(show, (newVal) => {
|
||||
if (newVal) {
|
||||
Events.On("files-dropped", (event) => {
|
||||
const files: string[] = event.data.files || [];
|
||||
const files: File[] = event.data.files || [];
|
||||
files.forEach((f) => {
|
||||
if (!fileList.value.find((existing) => existing.path === f)) {
|
||||
fileList.value.push({
|
||||
name: f.split(/[\/]/).pop() || f,
|
||||
path: f,
|
||||
});
|
||||
if (!props.files.find((existing) => existing.path === f.path)) {
|
||||
props.files.push(f);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
Events.Off("files-dropped");
|
||||
fileList.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,17 +59,17 @@ const openFileDialog = async () => {
|
||||
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,
|
||||
if (!props.files.find((existing) => existing.path === f)) {
|
||||
props.files.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({
|
||||
if (!props.files.find((existing) => existing.path === f)) {
|
||||
props.files.push({
|
||||
name: f.split(/[\\/]/).pop() || f,
|
||||
path: f,
|
||||
});
|
||||
@@ -80,12 +79,12 @@ const openFileDialog = async () => {
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
fileList.value.splice(index, 1);
|
||||
props.files.splice(index, 1);
|
||||
};
|
||||
|
||||
const handleSendFiles = async () => {
|
||||
if (fileList.value.length === 0 || !props.selectedIp) return;
|
||||
const paths = fileList.value.map((f) => f.path);
|
||||
if (props.files.length === 0 || !props.selectedIp) return;
|
||||
const paths = props.files.map((f) => f.path);
|
||||
|
||||
try {
|
||||
await SendFiles(props.peer, props.selectedIp, paths);
|
||||
@@ -103,10 +102,11 @@ const handleSendFiles = async () => {
|
||||
<v-card :title="$t('modal.fileSend.title')">
|
||||
<v-card-text>
|
||||
<div
|
||||
v-if="fileList.length === 0"
|
||||
class="drop-zone pa-10 text-center rounded-lg border-dashed"
|
||||
v-if="props.files.length === 0"
|
||||
class="drop-zone pa-10 text-center rounded-lg"
|
||||
@click="openFileDialog"
|
||||
data-file-drop-target
|
||||
id="drop-zone-area"
|
||||
>
|
||||
<v-icon
|
||||
icon="mdi-cloud-upload"
|
||||
@@ -127,9 +127,10 @@ const handleSendFiles = async () => {
|
||||
max-height="400"
|
||||
style="overflow-y: auto"
|
||||
data-file-drop-target
|
||||
id="drop-zone-list"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(file, index) in fileList"
|
||||
v-for="(file, index) in props.files"
|
||||
:key="file.path"
|
||||
:title="file.name"
|
||||
:subtitle="file.path"
|
||||
@@ -149,8 +150,7 @@ const handleSendFiles = async () => {
|
||||
|
||||
<v-btn
|
||||
block
|
||||
variant="outlined"
|
||||
style="border-style: dashed"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="openFileDialog"
|
||||
class="mt-2"
|
||||
@@ -168,10 +168,10 @@ const handleSendFiles = async () => {
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="handleSendFiles"
|
||||
:disabled="fileList.length === 0"
|
||||
:disabled="props.files.length === 0"
|
||||
>
|
||||
{{ $t("modal.fileSend.sendSrc") }}
|
||||
{{ fileList.length > 0 ? `(${fileList.length})` : "" }}
|
||||
{{ props.files.length > 0 ? `(${props.files.length})` : "" }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
@@ -180,17 +180,32 @@ const handleSendFiles = async () => {
|
||||
|
||||
<style scoped>
|
||||
.drop-zone {
|
||||
border: 2px dashed #666; /* Use a darker color or theme var */
|
||||
border: 2px solid transparent;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
border-color: #38bdf8;
|
||||
background-color: rgba(56, 189, 248, 0.05);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.drop-zone.file-drop-target-active {
|
||||
border-color: #38bdf8;
|
||||
background-color: rgba(56, 189, 248, 0.1);
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
transform: scale(1.01);
|
||||
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.15);
|
||||
}
|
||||
|
||||
#drop-zone-list {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#drop-zone-list.file-drop-target-active {
|
||||
box-shadow: inset 0 0 0 2px rgb(var(--v-theme-primary));
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"trustPeer": "Trust Peer",
|
||||
"untrustPeer": "Untrust Peer",
|
||||
"sendFolderFailed": "Failed to send folder: {error}",
|
||||
"sendClipboardFailed": "Failed to send clipboard: {error}"
|
||||
"sendClipboardFailed": "Failed to send clipboard: {error}",
|
||||
"dragDropHint": "Drag and drop files here to send"
|
||||
},
|
||||
"transfers": {
|
||||
"noTransfers": "No transfers yet",
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"trustPeer": "信任设备",
|
||||
"untrustPeer": "取消信任",
|
||||
"sendFolderFailed": "发送文件夹失败: {error}",
|
||||
"sendClipboardFailed": "发送剪贴板失败: {error}"
|
||||
"sendClipboardFailed": "发送剪贴板失败: {error}",
|
||||
"dragDropHint": "拖放文件到此处快速发送"
|
||||
},
|
||||
"transfers": {
|
||||
"noTransfers": "暂无传输记录",
|
||||
|
||||
@@ -61,3 +61,20 @@ func (p *PresencePacket) SignPayload() []byte {
|
||||
// 格式: id|name|port|os|pk
|
||||
return fmt.Appendf(nil, "%s|%s|%d|%s|%s", p.ID, p.Name, p.Port, p.OS, p.PublicKey)
|
||||
}
|
||||
|
||||
// DeepCopy 返回 Peer 的深拷贝
|
||||
func (p Peer) DeepCopy() *Peer {
|
||||
newPeer := p // 结构体浅拷贝 (值类型字段已复制)
|
||||
|
||||
// 手动深拷贝引用类型字段 (Routes)
|
||||
if p.Routes != nil {
|
||||
newPeer.Routes = make(map[string]*RouteState, len(p.Routes))
|
||||
for k, v := range p.Routes {
|
||||
// RouteState 只有值类型字段,但它是指针,所以需要新建对象并解引用赋值
|
||||
stateCopy := *v
|
||||
newPeer.Routes[k] = &stateCopy
|
||||
}
|
||||
}
|
||||
|
||||
return &newPeer
|
||||
}
|
||||
|
||||
@@ -337,7 +337,7 @@ func (s *Service) GetPeerByIP(ip string) (*Peer, bool) {
|
||||
|
||||
for _, p := range s.peers {
|
||||
if p.Routes[ip] != nil {
|
||||
return p, true
|
||||
return p.DeepCopy(), true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
@@ -348,7 +348,10 @@ func (s *Service) GetPeerByID(id string) (*Peer, bool) {
|
||||
defer s.peersMutex.RUnlock()
|
||||
|
||||
peer, ok := s.peers[id]
|
||||
return peer, ok
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return peer.DeepCopy(), true
|
||||
}
|
||||
|
||||
func (s *Service) GetPeers() []Peer {
|
||||
@@ -357,7 +360,7 @@ func (s *Service) GetPeers() []Peer {
|
||||
|
||||
list := make([]Peer, 0)
|
||||
for _, p := range s.peers {
|
||||
list = append(list, *p)
|
||||
list = append(list, *p.DeepCopy())
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].Name < list[j].Name
|
||||
|
||||
16
main.go
16
main.go
@@ -7,6 +7,7 @@ import (
|
||||
"mesh-drop/internal/discovery"
|
||||
"mesh-drop/internal/transfer"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
@@ -19,8 +20,13 @@ var assets embed.FS
|
||||
//go:embed build/appicon.png
|
||||
var icon []byte
|
||||
|
||||
type File struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type FilesDroppedEvent struct {
|
||||
Files []string `json:"files"`
|
||||
Files []File `json:"files"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
@@ -132,7 +138,13 @@ func (a *App) registerCustomEvents() {
|
||||
func (a *App) setupEvents() {
|
||||
// 窗口文件拖拽事件
|
||||
a.mainWindows.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
|
||||
files := event.Context().DroppedFiles()
|
||||
files := make([]File, 0)
|
||||
for _, file := range event.Context().DroppedFiles() {
|
||||
files = append(files, File{
|
||||
Name: filepath.Base(file),
|
||||
Path: file,
|
||||
})
|
||||
}
|
||||
details := event.Context().DropTargetDetails()
|
||||
a.app.Event.Emit("files-dropped", FilesDroppedEvent{
|
||||
Files: files,
|
||||
|
||||
Reference in New Issue
Block a user