refactor: replace naive-ui with vuetify

This commit is contained in:
2026-02-04 22:41:22 +08:00
parent a4173c327d
commit f7a881358f
26 changed files with 2853 additions and 1379 deletions

View File

@@ -1,39 +1,16 @@
<script lang="ts" setup>
import {
NConfigProvider,
NGlobalStyle,
NMessageProvider,
NDialogProvider,
darkTheme,
} from "naive-ui";
import MainLayout from "./components/MainLayout.vue";
const themeOverrides = {
common: {
primaryColor: "#38bdf8",
primaryColorHover: "#0ea5e9",
},
Card: {
borderColor: "#334155",
},
};
</script>
<template>
<n-config-provider :theme="darkTheme" :theme-overrides="themeOverrides">
<n-global-style />
<n-dialog-provider>
<n-message-provider>
<MainLayout />
</n-message-provider>
</n-dialog-provider>
</n-config-provider>
<v-app theme="dark">
<MainLayout />
</v-app>
</template>
<style>
body,
#app,
.n-config-provider {
#app {
font-family: "Noto Sans", "Roboto", "Segoe UI", sans-serif !important;
}
</style>

View File

@@ -1,28 +1,7 @@
<script lang="ts" setup>
import { onMounted, ref, computed, h } from "vue";
import { onMounted, ref, computed } from "vue";
import PeerCard from "./PeerCard.vue";
import TransferItem from "./TransferItem.vue";
import {
NLayout,
NLayoutHeader,
NLayoutContent,
NLayoutSider,
NSpace,
NText,
NEmpty,
NMenu,
NBadge,
NButton,
NIcon,
} from "naive-ui";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faSatelliteDish,
faInbox,
faBars,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
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 } from "../../bindings/mesh-drop/internal/discovery/service";
@@ -32,7 +11,7 @@ import { GetTransferList } from "../../bindings/mesh-drop/internal/transfer/serv
const peers = ref<Peer[]>([]);
const transferList = ref<Transfer[]>([]);
const activeKey = ref("discover");
const showMobileMenu = ref(false);
const drawer = ref(true); // Control drawer visibility
const isMobile = ref(false);
// 监听窗口大小变化更新 isMobile
@@ -43,49 +22,20 @@ onMounted(async () => {
transferList.value = (
(list || []).filter((t) => t !== null) as Transfer[]
).sort((a, b) => b.create_time - a.create_time);
if (isMobile.value) {
drawer.value = false;
}
});
const checkMobile = () => {
isMobile.value = window.innerWidth < 768;
if (!isMobile.value) showMobileMenu.value = false;
const mobile = window.innerWidth < 768;
if (mobile !== isMobile.value) {
isMobile.value = mobile;
drawer.value = !mobile;
}
};
// --- 菜单选项 ---
const renderIcon = (icon: any) => {
return () => h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon }) });
};
const menuOptions = computed<MenuOption[]>(() => [
{
label: "Discover",
key: "discover",
icon: renderIcon(faSatelliteDish),
},
{
label: () =>
h(
"div",
{
style:
"display: flex; align-items: center; justify-content: space-between; width: 100%",
},
[
"Transfers",
pendingCount.value > 0 ?
h(NBadge, {
style: "display: inline-flex; align-items: center",
value: pendingCount.value,
max: 99,
type: "error",
})
: null,
],
),
key: "transfers",
icon: renderIcon(faInbox),
},
]);
// --- 后端集成 ---
onMounted(async () => {
peers.value = await GetPeers();
@@ -111,146 +61,134 @@ const pendingCount = computed(() => {
).length;
});
const menuItems = computed(() => [
{
title: "Discover",
value: "discover",
icon: "mdi-radar",
},
{
title: "Transfers",
value: "transfers",
icon: "mdi-inbox",
badge: pendingCount.value > 0 ? pendingCount.value : null,
},
]);
// --- 操作 ---
const handleMenuUpdate = (key: string) => {
const handleMenuClick = (key: string) => {
activeKey.value = key;
showMobileMenu.value = false;
if (isMobile.value) {
drawer.value = false;
}
};
</script>
<template>
<!-- 小尺寸头部 -->
<n-layout-header v-if="isMobile" bordered class="mobile-header">
<n-space
align="center"
justify="space-between"
style="height: 100%; padding: 0 16px">
<n-text class="logo">Mesh Drop</n-text>
<n-button
text
style="font-size: 24px"
@click="showMobileMenu = !showMobileMenu">
<n-icon>
<FontAwesomeIcon :icon="showMobileMenu ? faXmark : faBars" />
</n-icon>
</n-button>
</n-space>
</n-layout-header>
<v-layout>
<!-- App Bar for Mobile -->
<v-app-bar v-if="isMobile" border flat>
<v-toolbar-title class="text-primary font-weight-bold"
>Mesh Drop</v-toolbar-title
>
<template v-slot:append>
<v-btn icon="mdi-menu" @click="drawer = !drawer"></v-btn>
</template>
</v-app-bar>
<!-- 小尺寸抽屉菜单 -->
<n-drawer
v-model:show="showMobileMenu"
placement="top"
height="200"
v-if="isMobile">
<n-drawer-content>
<n-menu
:value="activeKey"
:options="menuOptions"
@update:value="handleMenuUpdate" />
</n-drawer-content>
</n-drawer>
<n-layout
has-sider
position="absolute"
:style="{ top: isMobile ? '64px' : '0' }">
<!-- 桌面端侧边栏 -->
<n-layout-sider
v-if="!isMobile"
bordered
width="240"
content-style="padding: 24px;">
<div class="desktop-logo">
<n-text class="logo">Mesh Drop</n-text>
<!-- Navigation Drawer -->
<v-navigation-drawer v-model="drawer" :permanent="!isMobile">
<div class="pa-4" v-if="!isMobile">
<div class="text-h6 text-primary font-weight-bold">Mesh Drop</div>
</div>
<n-menu
:value="activeKey"
:options="menuOptions"
@update:value="handleMenuUpdate" />
</n-layout-sider>
<n-layout-content class="content">
<div class="content-container">
<!-- 发现页视图 -->
<v-list nav>
<v-list-item
v-for="item in menuItems"
:key="item.value"
:value="item.value"
:active="activeKey === item.value"
@click="handleMenuClick(item.value)"
rounded="xl"
color="primary"
>
<template v-slot:prepend>
<v-icon :icon="item.icon"></v-icon>
</template>
<v-list-item-title>
{{ item.title }}
<v-badge
v-if="item.badge"
:content="item.badge"
color="error"
inline
class="ml-2"
></v-badge>
</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
<!-- Main Content -->
<v-main>
<v-container fluid class="pa-4">
<!-- Discover View -->
<div v-show="activeKey === 'discover'">
<n-space vertical size="large" v-if="peers.length > 0">
<div class="peer-grid">
<div v-for="peer in peers" :key="peer.id">
<PeerCard
:peer="peer"
@transferStarted="activeKey = 'transfers'" />
</div>
<div v-if="peers.length > 0" class="peer-grid">
<div v-for="peer in peers" :key="peer.id">
<PeerCard
:peer="peer"
@transferStarted="activeKey = 'transfers'"
/>
</div>
</n-space>
</div>
<div v-else class="empty-state">
<n-empty description="Scanning for peers...">
<template #icon>
<n-icon class="radar-icon">
<FontAwesomeIcon :icon="faSatelliteDish" />
</n-icon>
</template>
</n-empty>
<div
v-else
class="empty-state d-flex flex-column justify-center align-center"
>
<v-icon
icon="mdi-radar"
size="100"
color="primary"
class="mb-4 radar-icon"
style="opacity: 0.5"
></v-icon>
<div class="text-grey">Scanning for peers...</div>
</div>
</div>
<!-- 传输列表视图 -->
<!-- Transfers View -->
<div v-show="activeKey === 'transfers'">
<div v-if="transferList.length > 0">
<TransferItem
v-for="transfer in transferList"
:key="transfer.id"
:transfer="transfer" />
:transfer="transfer"
/>
</div>
<div v-else class="empty-state">
<n-empty style="user-select: none" description="No transfers yet">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faInbox" />
</n-icon>
</template>
</n-empty>
<div
v-else
class="empty-state d-flex flex-column justify-center align-center"
>
<v-icon icon="mdi-inbox" size="100" class="mb-4 text-grey"></v-icon>
<div class="text-grey">No transfers yet</div>
</div>
</div>
</div>
</n-layout-content>
</n-layout>
</v-container>
</v-main>
</v-layout>
</template>
<style scoped>
.mobile-header {
height: 64px;
z-index: 1000;
}
.desktop-logo {
margin-bottom: 24px;
padding-left: 8px;
}
.logo {
font-size: 1.25rem;
font-weight: 700;
color: #38bdf8;
}
.content-container {
padding: 24px;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 90vh;
height: 80vh;
}
.radar-icon {
animation: spin 3s linear infinite;
color: #38bdf8;
opacity: 0.5;
}
@keyframes spin {
@@ -271,7 +209,7 @@ const handleMenuUpdate = (key: string) => {
}
}
@media (min-width: 700px) {
@media (min-width: 960px) {
.peer-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}

View File

@@ -1,40 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch, h } from "vue";
import {
NCard,
NButton,
NIcon,
NTag,
NSpace,
NDropdown,
NSelect,
type DropdownOption,
NModal,
NList,
NListItem,
NThing,
NEmpty,
NInput,
} from "naive-ui";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faLinux,
faWindows,
faApple,
} from "@fortawesome/free-brands-svg-icons";
import {
faDesktop,
faGlobe,
faPaperPlane,
faChevronDown,
faFile,
faFolder,
faFont,
faClipboard,
faTrash,
faPlus,
faCloudArrowUp,
} from "@fortawesome/free-solid-svg-icons";
import { computed, ref, watch } from "vue";
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
import { Dialogs, Events, Clipboard } from "@wailsio/runtime";
import {
@@ -72,52 +37,39 @@ watch(
{ immediate: true },
);
const ipOptions = computed(() => {
return ips.value.map((ip) => ({
label: ip,
value: ip,
}));
});
const osIcon = computed(() => {
switch (props.peer.os) {
case "linux":
return faLinux;
return "mdi-linux";
case "windows":
return faWindows;
return "mdi-microsoft-windows";
case "darwin":
return faApple;
return "mdi-apple";
default:
return faDesktop;
return "mdi-desktop-classic";
}
});
const sendOptions: DropdownOption[] = [
const sendOptions = [
{
label: "Send Files",
key: "files",
icon: () =>
h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon: faFile }) }),
title: "Send Files",
value: "files",
icon: "mdi-file",
},
{
label: "Send Folder",
key: "folder",
icon: () =>
h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon: faFolder }) }),
title: "Send Folder",
value: "folder",
icon: "mdi-folder",
},
{
label: "Send Text",
key: "text",
icon: () =>
h(NIcon, null, { default: () => h(FontAwesomeIcon, { icon: faFont }) }),
title: "Send Text",
value: "text",
icon: "mdi-format-font",
},
{
label: "Send Clipboard",
key: "clipboard",
icon: () =>
h(NIcon, null, {
default: () => h(FontAwesomeIcon, { icon: faClipboard }),
}),
title: "Send Clipboard",
value: "clipboard",
icon: "mdi-clipboard",
},
];
@@ -255,159 +207,180 @@ const handleSendFiles = () => {
</script>
<template>
<n-card hoverable class="peer-card">
<template #header>
<div style="display: flex; align-items: center; gap: 8px">
<n-icon size="24">
<FontAwesomeIcon :icon="osIcon" />
</n-icon>
<span style="user-select: none">{{ peer.name }}</span>
<v-card hover link class="peer-card pa-2">
<template v-slot:title>
<div class="d-flex align-center">
<v-icon :icon="osIcon" size="24" class="mr-2"></v-icon>
<span class="text-subtitle-1 font-weight-bold">{{ peer.name }}</span>
</div>
</template>
<n-space vertical>
<div style="display: flex; align-items: center; gap: 8px">
<n-icon>
<FontAwesomeIcon :icon="faGlobe" />
</n-icon>
<!-- Single IP Display -->
<n-tag
v-if="ips.length === 1"
:bordered="false"
type="info"
size="small">
{{ ips[0] }}
</n-tag>
<!-- Multiple IP Selector -->
<n-select
v-else-if="ips.length > 1"
v-model:value="selectedIp"
:options="ipOptions"
size="small"
style="width: 140px" />
<!-- No Route -->
<n-tag v-else :bordered="false" type="warning" size="small">
No Route
</n-tag>
</div>
</n-space>
<template v-slot:text>
<div class="d-flex align-center flex-wrap ga-2 mt-2">
<v-icon icon="mdi-web" size="20" class="text-medium-emphasis"></v-icon>
<template #action>
<div style="display: flex; gap: 8px">
<n-dropdown
trigger="click"
:options="sendOptions"
@select="handleAction"
:disabled="ips.length === 0">
<n-button type="primary" block dashed style="width: 100%">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faPaperPlane" />
</n-icon>
<!-- Single IP Display -->
<v-chip v-if="ips.length === 1" size="small" color="info" label>
{{ ips[0] }}
</v-chip>
<!-- Multiple IP Selector -->
<div v-else-if="ips.length > 1" style="width: 150px">
<v-select
v-model="selectedIp"
:items="ips"
density="compact"
hide-details
variant="outlined"
single-line
></v-select>
</div>
<!-- No Route -->
<v-chip v-else color="warning" size="small" label> No Route </v-chip>
</div>
</template>
<template v-slot:actions>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
block
color="primary"
variant="tonal"
:disabled="ips.length === 0"
append-icon="mdi-chevron-down"
>
<template v-slot:prepend>
<v-icon icon="mdi-send"></v-icon>
</template>
Send...
<n-icon style="margin-left: 4px">
<FontAwesomeIcon :icon="faChevronDown" />
</n-icon>
</n-button>
</n-dropdown>
</div>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(item, index) in sendOptions"
:key="index"
:value="item.value"
@click="handleAction(item.value)"
>
<template v-slot:prepend>
<v-icon :icon="item.icon"></v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
</n-card>
</v-card>
<!-- 文件发送 Modal -->
<n-modal
:mask-closable="false"
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>
<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>
<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>
<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 v-slot:append>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="handleRemoveFile(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
<template #footer>
<n-space justify="end">
<n-button @click="handleCancelFiles">Cancel</n-button>
<n-button
type="primary"
<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">
:disabled="fileList.length === 0"
>
Send {{ fileList.length > 0 ? `(${fileList.length})` : "" }}
</n-button>
</n-space>
</template>
</n-modal>
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 文本发送 Modal -->
<n-modal
:mask-closable="false"
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"
<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">
:disabled="!textContent"
>
Send
</n-button>
</n-space>
</template>
</n-modal>
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.drop-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 40px;
text-align: center;
border: 2px dashed #666; /* Use a darker color or theme var */
cursor: pointer;
transition: all 0.3s;
}

View File

@@ -1,37 +1,5 @@
<script setup lang="ts">
import { computed, h } from "vue";
import {
NCard,
NButton,
NIcon,
NProgress,
NSpace,
NText,
NTag,
useMessage,
NInput,
NDropdown,
NButtonGroup,
} from "naive-ui";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faArrowUp,
faArrowDown,
faCircleExclamation,
faUser,
faFile,
faFileLines,
faFolder,
faClock,
faChevronDown,
faEye,
faCopy,
faTrash,
faXmark,
faStop,
faCheck,
} from "@fortawesome/free-solid-svg-icons";
import { computed, ref, h } from "vue";
import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
import {
ResolvePendingRequest,
@@ -40,8 +8,6 @@ import {
} from "../../bindings/mesh-drop/internal/transfer/service";
import { Dialogs, Clipboard } from "@wailsio/runtime";
import { useDialog } from "naive-ui";
const props = defineProps<{
transfer: Transfer;
}>();
@@ -72,10 +38,10 @@ const percentage = computed(() =>
),
),
);
const progressStatus = computed(() => {
const progressColor = computed(() => {
if (props.transfer.status === "error") return "error";
if (props.transfer.status === "completed") return "success";
return "default";
return "primary";
});
const acceptTransfer = () => {
@@ -99,10 +65,10 @@ const acceptToFolder = async () => {
}
};
const dropdownOptions = [
const dropdownItems = [
{
label: "Accept To Folder",
key: "folder",
title: "Accept To Folder",
value: "folder",
},
];
@@ -116,31 +82,18 @@ const handleDelete = () => {
DeleteTransfer(props.transfer.id);
};
const message = useMessage();
const handleCopy = async () => {
Clipboard.SetText(props.transfer.text)
.then(() => {
message.success("Copied to clipboard");
})
// .then(() => {
// message.success("Copied to clipboard");
// })
.catch(() => {
message.error("Failed to copy to clipboard");
// message.error("Failed to copy to clipboard");
console.error("Failed to copy");
});
};
const dialog = useDialog();
const handleOpen = async () => {
const d = dialog.create({
title: "Text Content",
content: () =>
h(NInput, {
value: props.transfer.text,
readonly: true,
type: "textarea",
rows: 10,
}),
});
};
const showContentDialog = ref(false);
const canCancel = computed(() => {
if (
@@ -186,269 +139,212 @@ const canAccept = computed(() => {
</script>
<template>
<n-card size="small" class="transfer-item">
<div class="transfer-row">
<!-- 图标 -->
<div class="icon-wrapper">
<n-icon size="24" v-if="props.transfer.type === 'send'" color="#38bdf8">
<FontAwesomeIcon :icon="faArrowUp" />
</n-icon>
<n-icon
size="24"
v-else-if="props.transfer.type === 'receive'"
color="#22c55e">
<FontAwesomeIcon :icon="faArrowDown" />
</n-icon>
<n-icon size="24" v-else color="#f59e0b">
<FontAwesomeIcon :icon="faCircleExclamation" />
</n-icon>
</div>
<!-- 信息 -->
<div class="info-wrapper">
<div class="header-line">
<n-text
v-if="props.transfer.content_type === 'file'"
strong
class="filename"
:title="props.transfer.file_name">
<n-icon>
<FontAwesomeIcon :icon="faFile" />
</n-icon>
{{ props.transfer.file_name }}
</n-text>
<n-text
v-else-if="props.transfer.content_type === 'text'"
strong
class="filename"
title="Text">
<n-icon> <FontAwesomeIcon :icon="faFileLines" /> </n-icon>
Text</n-text
>
<n-text
v-else-if="props.transfer.content_type === 'folder'"
strong
class="filename"
title="Folder">
<n-icon> <FontAwesomeIcon :icon="faFolder" /> </n-icon>
{{ props.transfer.file_name || "Folder" }}</n-text
>
<n-tag
size="small"
:bordered="false"
v-if="
props.transfer.sender.name && props.transfer.type === 'receive'
">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faUser" />
</n-icon>
</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>
<v-card class="transfer-item mb-2" variant="outlined">
<v-card-text class="py-2 px-3">
<div class="d-flex align-center flex-wrap ga-2">
<!-- 图标 -->
<div>
<v-icon
size="24"
v-if="props.transfer.type === 'send'"
color="info"
icon="mdi-arrow-up"
></v-icon>
<v-icon
size="24"
v-else-if="props.transfer.type === 'receive'"
color="success"
icon="mdi-arrow-down"
></v-icon>
<v-icon
size="24"
v-else
color="warning"
icon="mdi-alert-circle"
></v-icon>
</div>
<div class="meta-line">
<n-text depth="3" class="size">{{
formatSize(props.transfer.file_size)
}}</n-text>
<!-- 信息 -->
<div class="info-wrapper flex-grow-1" style="min-width: 0">
<div class="d-flex align-center ga-2 mb-1 flex-wrap">
<div class="font-weight-bold text-truncate d-flex align-center">
<v-icon
size="small"
class="mr-1"
v-if="props.transfer.content_type === 'file'"
icon="mdi-file"
></v-icon>
<v-icon
size="small"
class="mr-1"
v-else-if="props.transfer.content_type === 'text'"
icon="mdi-file-document"
></v-icon>
<v-icon
size="small"
class="mr-1"
v-else-if="props.transfer.content_type === 'folder'"
icon="mdi-folder"
></v-icon>
{{
props.transfer.file_name ||
(props.transfer.content_type === "text" ? "Text" : "Folder")
}}
</div>
<!-- 状态文本(进行中/已完成) -->
<span>
<n-text depth="3" v-if="props.transfer.status === 'active'">
&nbsp;- {{ formatSpeed(props.transfer.progress.speed) }}</n-text
<v-chip
size="x-small"
v-if="
props.transfer.sender.name && props.transfer.type === 'receive'
"
prepend-icon="mdi-account"
>
<n-text
depth="3"
{{ props.transfer.sender.name }}
</v-chip>
<v-chip
size="x-small"
v-if="props.transfer.create_time"
prepend-icon="mdi-clock-outline"
>
{{ formatTime(props.transfer.create_time) }}
</v-chip>
</div>
<div class="text-caption text-medium-emphasis d-flex align-center">
<span>{{ formatSize(props.transfer.file_size) }}</span>
<!-- 状态文本 -->
<span v-if="props.transfer.status === 'active'">
&nbsp;- {{ formatSpeed(props.transfer.progress.speed) }}
</span>
<span
v-if="props.transfer.status === 'completed'"
type="success">
&nbsp;- Completed</n-text
class="text-success"
>
<n-text
depth="3"
v-if="props.transfer.status === 'error'"
type="error">
&nbsp;- {{ props.transfer.error_msg || "Error" }}</n-text
>
<n-text
depth="3"
v-if="props.transfer.status === 'canceled'"
type="info">
&nbsp;- Canceled</n-text
>
<n-text
depth="3"
&nbsp;- Completed
</span>
<span v-if="props.transfer.status === 'error'" class="text-error">
&nbsp;- {{ props.transfer.error_msg || "Error" }}
</span>
<span v-if="props.transfer.status === 'canceled'" class="text-info">
&nbsp;- Canceled
</span>
<span
v-if="props.transfer.status === 'rejected'"
type="error">
&nbsp;- Rejected</n-text
class="text-error"
>
<n-text
depth="3"
&nbsp;- Rejected
</span>
<span
v-if="props.transfer.status === 'pending'"
type="warning">
&nbsp;- Waiting for accept</n-text
class="text-warning"
>
</span>
&nbsp;- Waiting for accept
</span>
</div>
<!-- 进度条 -->
<v-progress-linear
v-if="props.transfer.status === 'active'"
:model-value="percentage"
:color="progressColor"
height="4"
striped
class="mt-1"
></v-progress-linear>
</div>
<!-- 进度条 -->
<n-progress
v-if="props.transfer.status === 'active'"
type="line"
:percentage="percentage"
:status="progressStatus"
:height="4"
:show-indicator="false"
processing
style="margin-top: 4px" />
</div>
<!-- 操作按钮 -->
<div class="actions-wrapper">
<n-space>
<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
<!-- 操作按钮 -->
<div class="actions-wrapper">
<v-btn-group density="compact" variant="outlined" divided>
<v-btn
v-if="canAccept"
size="small"
type="error"
@click="rejectTransfer">
<template #icon>
<n-icon>
<FontAwesomeIcon :icon="faXmark" />
</n-icon>
</template>
</n-button>
color="success"
icon="mdi-check"
@click="acceptTransfer"
></v-btn>
<n-button type="success" @click="handleOpen" v-if="canCopy"
><template #icon>
<n-icon>
<FontAwesomeIcon :icon="faEye" />
</n-icon>
<v-menu v-if="canAccept && props.transfer.content_type !== 'text'">
<template v-slot:activator="{ props }">
<v-btn
color="success"
icon="mdi-chevron-down"
v-bind="props"
></v-btn>
</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-list>
<v-list-item
v-for="(item, index) in dropdownItems"
:key="index"
:value="item.value"
@click="handleSelect(item.value)"
>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn
v-if="canAccept"
color="error"
icon="mdi-close"
@click="rejectTransfer"
></v-btn>
<v-btn
v-if="canCopy"
color="success"
icon="mdi-eye"
@click="showContentDialog = true"
></v-btn>
<v-btn
v-if="canCopy"
color="success"
icon="mdi-content-copy"
@click="handleCopy"
></v-btn>
<v-btn
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
"
color="info"
icon="mdi-delete"
@click="handleDelete"
></v-btn>
<v-btn
v-if="canCancel"
size="small"
type="error"
color="error"
icon="mdi-stop"
@click="CancelTransfer(props.transfer.id)"
><template #icon>
<n-icon>
<FontAwesomeIcon :icon="faStop" />
</n-icon>
</template>
</n-button>
</n-button-group>
</n-space>
></v-btn>
</v-btn-group>
</div>
</div>
</div>
</n-card>
</v-card-text>
</v-card>
<v-dialog v-model="showContentDialog" width="600">
<v-card title="Text Content">
<v-card-text>
<v-textarea
:model-value="props.transfer.text"
readonly
rows="10"
></v-textarea>
</v-card-text>
</v-card>
</v-dialog>
</template>
<style scoped>
.transfer-item {
margin-bottom: 0.5rem;
}
.transfer-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.icon-wrapper {
display: flex;
align-items: center;
}
.info-wrapper {
flex: 1;
min-width: 0;
}
.header-line {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.filename {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
.meta-line {
font-size: 12px;
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>

View File

@@ -1,4 +1,23 @@
import { createApp } from 'vue'
/**
* main.ts
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Plugins
import { registerPlugins } from '@/plugins'
// Components
import App from './App.vue'
createApp(App).mount('#app')
// Composables
import { createApp } from 'vue'
// Styles
import 'unfonts.css'
const app = createApp(App)
registerPlugins(app)
app.mount('#app')

View File

@@ -0,0 +1,15 @@
/**
* plugins/index.ts
*
* Automatically included in `./src/main.ts`
*/
// Plugins
import vuetify from './vuetify'
// Types
import type { App } from 'vue'
export function registerPlugins (app: App) {
app.use(vuetify)
}

View File

@@ -0,0 +1,19 @@
/**
* plugins/vuetify.ts
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
// Composables
import { createVuetify } from 'vuetify'
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
defaultTheme: 'system',
},
})