Compare commits

...

2 Commits

Author SHA1 Message Date
a3989aeedd fix: discovery.peers resource competition problem 2026-02-10 23:37:00 +08:00
ea40aa76d0 feat: peercard accept drag event 2026-02-10 22:44:18 +08:00
10 changed files with 234 additions and 51 deletions

View File

@@ -2,5 +2,6 @@
// This file is automatically generated. DO NOT EDIT
export {
File,
FilesDroppedEvent
} from "./models.js";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,8 @@
"trustPeer": "信任设备",
"untrustPeer": "取消信任",
"sendFolderFailed": "发送文件夹失败: {error}",
"sendClipboardFailed": "发送剪贴板失败: {error}"
"sendClipboardFailed": "发送剪贴板失败: {error}",
"dragDropHint": "拖放文件到此处快速发送"
},
"transfers": {
"noTransfers": "暂无传输记录",

View File

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

View File

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

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