feat: update monitor, terminal, and SFTP interaction flow
This commit is contained in:
@@ -46,27 +46,68 @@ public class MonitorController {
|
|||||||
|
|
||||||
Map<String, Object> metrics = new HashMap<>();
|
Map<String, Object> metrics = new HashMap<>();
|
||||||
|
|
||||||
// 获取CPU使用率
|
// 获取CPU使用率(兼容多种系统)
|
||||||
try {
|
try {
|
||||||
String cpuOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
double cpuUsage = 0;
|
||||||
"top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - $1}'");
|
try {
|
||||||
double cpuUsage = Double.parseDouble(cpuOutput.trim());
|
// 优先使用top
|
||||||
|
String cpuOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
|
"top -bn1 2>/dev/null | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - $1}'");
|
||||||
|
if (!cpuOutput.trim().isEmpty()) {
|
||||||
|
cpuUsage = Double.parseDouble(cpuOutput.trim());
|
||||||
|
} else {
|
||||||
|
throw new Exception("top command failed");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 备用方案:使用/proc/stat计算(需要两次采样,这里简化处理用vmstat)
|
||||||
|
try {
|
||||||
|
String vmstatOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
|
"vmstat 1 2 | tail -1 | awk '{print 100 - $15}'");
|
||||||
|
cpuUsage = Double.parseDouble(vmstatOutput.trim());
|
||||||
|
} catch (Exception e2) {
|
||||||
|
throw new Exception("Both top and vmstat failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
metrics.put("cpuUsage", Math.round(cpuUsage * 10.0) / 10.0);
|
metrics.put("cpuUsage", Math.round(cpuUsage * 10.0) / 10.0);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
metrics.put("cpuUsage", null);
|
metrics.put("cpuUsage", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取内存信息
|
// 获取内存信息(兼容多种系统)
|
||||||
try {
|
try {
|
||||||
String memOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
long totalMem = 0;
|
||||||
"free -b | grep Mem");
|
long usedMem = 0;
|
||||||
String[] memParts = memOutput.trim().split("\\s+");
|
try {
|
||||||
long totalMem = Long.parseLong(memParts[1]);
|
// 优先使用free -b
|
||||||
long usedMem = Long.parseLong(memParts[2]);
|
String memOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
double memUsage = (double) usedMem / totalMem * 100;
|
"free -b 2>/dev/null | grep Mem");
|
||||||
metrics.put("memTotal", totalMem);
|
if (!memOutput.trim().isEmpty()) {
|
||||||
metrics.put("memUsed", usedMem);
|
String[] memParts = memOutput.trim().split("\\s+");
|
||||||
metrics.put("memUsage", Math.round(memUsage * 10.0) / 10.0);
|
if (memParts.length >= 3) {
|
||||||
|
totalMem = Long.parseLong(memParts[1]);
|
||||||
|
usedMem = Long.parseLong(memParts[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 备用方案:从/proc/meminfo读取
|
||||||
|
String meminfoOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
|
"cat /proc/meminfo | grep -E 'MemTotal|MemAvailable'");
|
||||||
|
String[] lines = meminfoOutput.trim().split("\n");
|
||||||
|
if (lines.length >= 2) {
|
||||||
|
totalMem = Long.parseLong(lines[0].replaceAll("\\D+", "")) * 1024;
|
||||||
|
long availableMem = Long.parseLong(lines[1].replaceAll("\\D+", "")) * 1024;
|
||||||
|
usedMem = totalMem - availableMem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalMem > 0 && usedMem >= 0) {
|
||||||
|
double memUsage = (double) usedMem / totalMem * 100;
|
||||||
|
metrics.put("memTotal", totalMem);
|
||||||
|
metrics.put("memUsed", usedMem);
|
||||||
|
metrics.put("memUsage", Math.round(memUsage * 10.0) / 10.0);
|
||||||
|
} else {
|
||||||
|
throw new Exception("Failed to parse memory info");
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
metrics.put("memTotal", null);
|
metrics.put("memTotal", null);
|
||||||
metrics.put("memUsed", null);
|
metrics.put("memUsed", null);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
|
|||||||
import { Terminal } from 'xterm'
|
import { Terminal } from 'xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import axios from 'axios'
|
import client from '../api/client'
|
||||||
import { Activity, Cpu, HardDrive, MemoryStick, Clock, ChevronUp, ChevronDown } from 'lucide-vue-next'
|
import { Activity, Cpu, HardDrive, MemoryStick, Clock, ChevronUp, ChevronDown } from 'lucide-vue-next'
|
||||||
import 'xterm/css/xterm.css'
|
import 'xterm/css/xterm.css'
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ async function fetchMonitorData() {
|
|||||||
if (!props.active || status.value !== 'connected') return
|
if (!props.active || status.value !== 'connected') return
|
||||||
monitorLoading.value = true
|
monitorLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`/api/monitor/${props.connectionId}`)
|
const res = await client.get(`/monitor/${props.connectionId}`)
|
||||||
monitorData.value = res.data
|
monitorData.value = res.data
|
||||||
} catch {
|
} catch {
|
||||||
// 静默失败,不影响终端使用
|
// 静默失败,不影响终端使用
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { useSftpTabsStore } from '../stores/sftpTabs'
|
|||||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||||
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
|
import TerminalWorkspaceView from '../views/TerminalWorkspaceView.vue'
|
||||||
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen } from 'lucide-vue-next'
|
import { ArrowLeftRight, Server, LogOut, Menu, X, Terminal, FolderOpen } from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const connectionsStore = useConnectionsStore()
|
const connectionsStore = useConnectionsStore()
|
||||||
const sftpTabsStore = useSftpTabsStore()
|
const sftpTabsStore = useSftpTabsStore()
|
||||||
@@ -20,19 +20,19 @@ const terminalTabs = computed(() => tabsStore.tabs)
|
|||||||
const sftpTabs = computed(() => sftpTabsStore.tabs)
|
const sftpTabs = computed(() => sftpTabsStore.tabs)
|
||||||
const showTerminalWorkspace = computed(() => route.path === '/terminal')
|
const showTerminalWorkspace = computed(() => route.path === '/terminal')
|
||||||
const keepTerminalWorkspaceMounted = computed(() => showTerminalWorkspace.value || terminalTabs.value.length > 0)
|
const keepTerminalWorkspaceMounted = computed(() => showTerminalWorkspace.value || terminalTabs.value.length > 0)
|
||||||
|
|
||||||
connectionsStore.fetchConnections().catch(() => {})
|
connectionsStore.fetchConnections().catch(() => {})
|
||||||
|
|
||||||
function closeSidebar() {
|
function closeSidebar() {
|
||||||
sidebarOpen.value = false
|
sidebarOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTabClick(tabId: string) {
|
function handleTabClick(tabId: string) {
|
||||||
tabsStore.activate(tabId)
|
tabsStore.activate(tabId)
|
||||||
router.push('/terminal')
|
router.push('/terminal')
|
||||||
closeSidebar()
|
closeSidebar()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTabClose(tabId: string, event: Event) {
|
function handleTabClose(tabId: string, event: Event) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
tabsStore.close(tabId)
|
tabsStore.close(tabId)
|
||||||
@@ -67,36 +67,36 @@ function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
|
|||||||
router.push('/connections')
|
router.push('/connections')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen bg-slate-900">
|
<div class="flex h-screen bg-slate-900">
|
||||||
<button
|
<button
|
||||||
@click="sidebarOpen = !sidebarOpen"
|
@click="sidebarOpen = !sidebarOpen"
|
||||||
class="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 cursor-pointer"
|
class="lg:hidden fixed top-4 left-4 z-30 p-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 cursor-pointer"
|
||||||
aria-label="切换侧边栏"
|
aria-label="切换侧边栏"
|
||||||
>
|
>
|
||||||
<Menu v-if="!sidebarOpen" class="w-6 h-6" aria-hidden="true" />
|
<Menu v-if="!sidebarOpen" class="w-6 h-6" aria-hidden="true" />
|
||||||
<X v-else class="w-6 h-6" aria-hidden="true" />
|
<X v-else class="w-6 h-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<aside
|
<aside
|
||||||
:class="[
|
:class="[
|
||||||
'w-64 bg-slate-800 border-r border-slate-700 flex flex-col transition-transform duration-200 z-20',
|
'w-64 bg-slate-800 border-r border-slate-700 flex flex-col transition-transform duration-200 z-20',
|
||||||
'fixed lg:static inset-y-0 left-0',
|
'fixed lg:static inset-y-0 left-0',
|
||||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="p-4 border-b border-slate-700">
|
<div class="p-4 border-b border-slate-700">
|
||||||
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
|
<h1 class="text-lg font-semibold text-slate-100">SSH 传输控制台</h1>
|
||||||
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
<p class="text-sm text-slate-400">{{ authStore.displayName || authStore.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4 overflow-y-auto">
|
<nav class="flex-1 p-4 space-y-1 pt-16 lg:pt-4 overflow-y-auto">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/connections"
|
to="/connections"
|
||||||
@click="closeSidebar"
|
@click="closeSidebar"
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||||
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
:class="{ 'bg-slate-700 text-cyan-400': route.path === '/connections' }"
|
||||||
aria-label="连接列表"
|
aria-label="连接列表"
|
||||||
>
|
>
|
||||||
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
<Server class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||||
<span>连接列表</span>
|
<span>连接列表</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -110,29 +110,29 @@ function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
|
|||||||
<ArrowLeftRight class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
<ArrowLeftRight class="w-5 h-5 flex-shrink-0" aria-hidden="true" />
|
||||||
<span>传输</span>
|
<span>传输</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<!-- 终端标签区域 -->
|
<!-- 终端标签区域 -->
|
||||||
<div v-if="terminalTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
|
<div v-if="terminalTabs.length > 0" class="pt-4 mt-4 border-t border-slate-700">
|
||||||
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
<Terminal class="w-4 h-4" aria-hidden="true" />
|
<Terminal class="w-4 h-4" aria-hidden="true" />
|
||||||
<span>终端</span>
|
<span>终端</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1 mt-2">
|
<div class="space-y-1 mt-2">
|
||||||
<button
|
<button
|
||||||
v-for="tab in terminalTabs"
|
v-for="tab in terminalTabs"
|
||||||
:key="tab.id"
|
:key="tab.id"
|
||||||
@click="handleTabClick(tab.id)"
|
@click="handleTabClick(tab.id)"
|
||||||
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset group"
|
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset group"
|
||||||
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === '/terminal' }"
|
:class="{ 'bg-slate-700 text-cyan-400': tab.active && route.path === '/terminal' }"
|
||||||
>
|
>
|
||||||
<span class="truncate text-sm">{{ tab.title }}</span>
|
<span class="truncate text-sm">{{ tab.title }}</span>
|
||||||
<button
|
<button
|
||||||
@click="(e) => handleTabClose(tab.id, e)"
|
@click="(e) => handleTabClose(tab.id, e)"
|
||||||
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-slate-600 transition-all duration-200 flex-shrink-0"
|
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-slate-600 transition-all duration-200 flex-shrink-0"
|
||||||
aria-label="关闭标签"
|
aria-label="关闭标签"
|
||||||
>
|
>
|
||||||
<X class="w-3 h-3" aria-hidden="true" />
|
<X class="w-3 h-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,34 +169,34 @@ function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="p-4 border-t border-slate-700">
|
<div class="p-4 border-t border-slate-700">
|
||||||
<button
|
<button
|
||||||
@click="authStore.logout(); $router.push('/login')"
|
@click="authStore.logout(); $router.push('/login')"
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-inset"
|
||||||
aria-label="退出登录"
|
aria-label="退出登录"
|
||||||
>
|
>
|
||||||
<LogOut class="w-5 h-5" aria-hidden="true" />
|
<LogOut class="w-5 h-5" aria-hidden="true" />
|
||||||
<span>退出登录</span>
|
<span>退出登录</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div
|
<div
|
||||||
v-if="sidebarOpen"
|
v-if="sidebarOpen"
|
||||||
class="lg:hidden fixed inset-0 bg-black/50 z-10"
|
class="lg:hidden fixed inset-0 bg-black/50 z-10"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@click="sidebarOpen = false"
|
@click="sidebarOpen = false"
|
||||||
/>
|
/>
|
||||||
<main class="flex-1 overflow-auto min-w-0">
|
<main class="flex-1 overflow-auto min-w-0">
|
||||||
<div v-if="keepTerminalWorkspaceMounted" v-show="showTerminalWorkspace" class="h-full">
|
<div v-if="keepTerminalWorkspaceMounted" v-show="showTerminalWorkspace" class="h-full">
|
||||||
<TerminalWorkspaceView :visible="showTerminalWorkspace" />
|
<TerminalWorkspaceView :visible="showTerminalWorkspace" />
|
||||||
</div>
|
</div>
|
||||||
<RouterView v-slot="{ Component, route }">
|
<RouterView v-slot="{ Component, route }">
|
||||||
<template v-if="!showTerminalWorkspace">
|
<template v-if="!showTerminalWorkspace">
|
||||||
<keep-alive :max="10" v-if="route.meta.keepAlive">
|
<keep-alive :max="10" v-if="route.meta.keepAlive">
|
||||||
<component :is="Component" :key="route.fullPath" />
|
<component :is="Component" :key="route.params.id" />
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
<component :is="Component" :key="route.fullPath" v-else />
|
<component :is="Component" :key="route.fullPath" v-else />
|
||||||
</template>
|
</template>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface SftpTab {
|
|||||||
export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
||||||
const tabs = ref<SftpTab[]>([])
|
const tabs = ref<SftpTab[]>([])
|
||||||
const activeTabId = ref<string | null>(null)
|
const activeTabId = ref<string | null>(null)
|
||||||
|
const connectionLoadState = ref<Map<number, number>>(new Map())
|
||||||
|
|
||||||
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) || null)
|
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) || null)
|
||||||
|
|
||||||
@@ -60,7 +61,14 @@ export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
|||||||
const index = tabs.value.findIndex(t => t.id === tabId)
|
const index = tabs.value.findIndex(t => t.id === tabId)
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
|
|
||||||
|
const tab = tabs.value[index]
|
||||||
|
if (!tab) return
|
||||||
|
|
||||||
const wasActive = activeTabId.value === tabId
|
const wasActive = activeTabId.value === tabId
|
||||||
|
|
||||||
|
// Clean up connection state
|
||||||
|
connectionLoadState.value.delete(tab.connectionId)
|
||||||
|
|
||||||
tabs.value.splice(index, 1)
|
tabs.value.splice(index, 1)
|
||||||
|
|
||||||
if (wasActive && tabs.value.length > 0) {
|
if (wasActive && tabs.value.length > 0) {
|
||||||
@@ -73,6 +81,18 @@ export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
|||||||
syncActiveState()
|
syncActiveState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setConnectionLoaded(connectionId: number) {
|
||||||
|
connectionLoadState.value.set(connectionId, connectionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastLoadedConnectionId(connectionId: number): number | null {
|
||||||
|
return connectionLoadState.value.get(connectionId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConnectionState(connectionId: number) {
|
||||||
|
connectionLoadState.value.delete(connectionId)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tabs,
|
tabs,
|
||||||
activeTabId,
|
activeTabId,
|
||||||
@@ -80,5 +100,8 @@ export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
|||||||
openOrFocus,
|
openOrFocus,
|
||||||
activate,
|
activate,
|
||||||
close,
|
close,
|
||||||
|
setConnectionLoaded,
|
||||||
|
getLastLoadedConnectionId,
|
||||||
|
clearConnectionState,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,38 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, watch } from 'vue'
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useConnectionsStore } from '../stores/connections'
|
import { useConnectionsStore } from '../stores/connections'
|
||||||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||||
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
import { useTerminalTabsStore } from '../stores/terminalTabs'
|
||||||
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
import type { Connection, ConnectionCreateRequest } from '../api/connections'
|
||||||
import ConnectionForm from '../components/ConnectionForm.vue'
|
import ConnectionForm from '../components/ConnectionForm.vue'
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
Plus,
|
Plus,
|
||||||
Terminal,
|
Terminal,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
Key,
|
Key,
|
||||||
Lock,
|
Lock,
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useConnectionsStore()
|
const store = useConnectionsStore()
|
||||||
const tabsStore = useTerminalTabsStore()
|
const tabsStore = useTerminalTabsStore()
|
||||||
const sftpTabsStore = useSftpTabsStore()
|
const sftpTabsStore = useSftpTabsStore()
|
||||||
|
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const editingConn = ref<Connection | null>(null)
|
const editingConn = ref<Connection | null>(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
let searchDebounceTimer = 0
|
||||||
|
|
||||||
const keyword = computed(() => route.query.q as string || '')
|
const keyword = computed(() => route.query.q as string || '')
|
||||||
const filteredConnections = computed(() => {
|
const filteredConnections = computed(() => {
|
||||||
const q = keyword.value.trim().toLowerCase()
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return store.connections
|
return store.connections
|
||||||
}
|
}
|
||||||
@@ -59,8 +60,11 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(searchQuery, (val) => {
|
watch(searchQuery, (val) => {
|
||||||
updateSearchParam(val)
|
clearTimeout(searchDebounceTimer)
|
||||||
})
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
updateSearchParam(val)
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingConn.value = null
|
editingConn.value = null
|
||||||
@@ -91,97 +95,97 @@ async function handleDelete(conn: Connection) {
|
|||||||
await store.deleteConnection(conn.id)
|
await store.deleteConnection(conn.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTerminal(conn: Connection) {
|
function openTerminal(conn: Connection) {
|
||||||
tabsStore.openTab(conn)
|
tabsStore.openTab(conn)
|
||||||
router.push('/terminal')
|
router.push('/terminal')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSftp(conn: Connection) {
|
||||||
|
sftpTabsStore.openOrFocus(conn)
|
||||||
|
router.push(`/sftp/${conn.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
function openSftp(conn: Connection) {
|
|
||||||
sftpTabsStore.openOrFocus(conn)
|
|
||||||
router.push(`/sftp/${conn.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSearch() {
|
function clearSearch() {
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
updateSearchParam('')
|
updateSearchParam('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightMatch(text: string): string {
|
function highlightMatch(text: string): string {
|
||||||
const q = keyword.value.trim()
|
const q = searchQuery.value.trim()
|
||||||
if (!q) return text
|
if (!q) return text
|
||||||
|
|
||||||
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
const regex = new RegExp(escaped, 'gi')
|
const regex = new RegExp(escaped, 'gi')
|
||||||
|
|
||||||
return text.replace(regex, (match) => `<span class="text-cyan-300 font-semibold">${match}</span>`)
|
return text.replace(regex, (match) => `<span class="text-cyan-300 font-semibold">${match}</span>`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-slate-100">连接列表</h2>
|
<h2 class="text-xl font-semibold text-slate-100">连接列表</h2>
|
||||||
<button
|
<button
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 cursor-pointer"
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white font-medium transition-colors duration-200 cursor-pointer"
|
||||||
aria-label="添加连接"
|
aria-label="添加连接"
|
||||||
>
|
>
|
||||||
<Plus class="w-5 h-5" aria-hidden="true" />
|
<Plus class="w-5 h-5" aria-hidden="true" />
|
||||||
添加连接
|
添加连接
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative mb-6">
|
<div class="relative mb-6">
|
||||||
<Search class="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" aria-hidden="true" />
|
<Search class="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索名称、主机、用户名或端口"
|
placeholder="搜索名称、主机、用户名或端口"
|
||||||
class="w-full rounded-xl border border-slate-700 bg-slate-800/70 py-3 pl-10 pr-11 text-slate-100 placeholder:text-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
class="w-full rounded-xl border border-slate-700 bg-slate-800/70 py-3 pl-10 pr-11 text-slate-100 placeholder:text-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||||
aria-label="搜索连接"
|
aria-label="搜索连接"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="searchQuery"
|
v-if="searchQuery"
|
||||||
@click="clearSearch"
|
@click="clearSearch"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
class="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||||
aria-label="清空搜索"
|
aria-label="清空搜索"
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" aria-hidden="true" />
|
<X class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="store.connections.length === 0" class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700">
|
<div v-if="store.connections.length === 0" class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700">
|
||||||
<Server class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
|
<Server class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
|
||||||
<p class="text-slate-400 mb-4">暂无连接</p>
|
<p class="text-slate-400 mb-4">暂无连接</p>
|
||||||
<button
|
<button
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white transition-colors duration-200 cursor-pointer"
|
class="px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white transition-colors duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
添加第一个连接
|
添加第一个连接
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="filteredConnections.length === 0"
|
v-else-if="filteredConnections.length === 0"
|
||||||
class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700"
|
class="text-center py-16 bg-slate-800/50 rounded-xl border border-slate-700"
|
||||||
>
|
>
|
||||||
<Search class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
|
<Search class="w-16 h-16 text-slate-500 mx-auto mb-4" aria-hidden="true" />
|
||||||
<p class="text-slate-300 mb-2">未找到匹配的连接</p>
|
<p class="text-slate-300 mb-2">未找到匹配的连接</p>
|
||||||
<p class="text-sm text-slate-500 mb-4">试试搜索名称、主机、用户名或端口</p>
|
<p class="text-sm text-slate-500 mb-4">试试搜索名称、主机、用户名或端口</p>
|
||||||
<button
|
<button
|
||||||
@click="clearSearch"
|
@click="clearSearch"
|
||||||
class="px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-200 transition-colors duration-200 cursor-pointer"
|
class="px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-200 transition-colors duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
清空搜索
|
清空搜索
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
v-for="conn in filteredConnections"
|
v-for="conn in filteredConnections"
|
||||||
:key="conn.id"
|
:key="conn.id"
|
||||||
class="bg-slate-800 rounded-xl border border-slate-700 p-4 hover:border-slate-600 transition-colors duration-200"
|
class="bg-slate-800 rounded-xl border border-slate-700 p-4 hover:border-slate-600 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
|
<div class="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
|
||||||
@@ -217,24 +221,24 @@ function highlightMatch(text: string): string {
|
|||||||
/>
|
/>
|
||||||
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
|
<span class="text-sm text-slate-500">{{ conn.authType === 'PRIVATE_KEY' ? '私钥' : '密码' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@click="openTerminal(conn)"
|
@click="openTerminal(conn)"
|
||||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
||||||
>
|
>
|
||||||
<Terminal class="w-4 h-4" aria-hidden="true" />
|
<Terminal class="w-4 h-4" aria-hidden="true" />
|
||||||
终端
|
终端
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="openSftp(conn)"
|
@click="openSftp(conn)"
|
||||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-300 hover:text-slate-100 transition-colors duration-200 cursor-pointer text-sm"
|
||||||
>
|
>
|
||||||
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
<FolderOpen class="w-4 h-4" aria-hidden="true" />
|
||||||
文件
|
文件
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConnectionForm
|
<ConnectionForm
|
||||||
v-if="showForm"
|
v-if="showForm"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ name: 'SftpView' })
|
defineOptions({ name: 'SftpView' })
|
||||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toast-notification'
|
import { useToast } from 'vue-toast-notification'
|
||||||
@@ -7,25 +7,25 @@ import { useConnectionsStore } from '../stores/connections'
|
|||||||
import { useSftpTabsStore } from '../stores/sftpTabs'
|
import { useSftpTabsStore } from '../stores/sftpTabs'
|
||||||
import * as sftpApi from '../api/sftp'
|
import * as sftpApi from '../api/sftp'
|
||||||
import type { SftpFileInfo } from '../api/sftp'
|
import type { SftpFileInfo } from '../api/sftp'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
File,
|
File,
|
||||||
Upload,
|
Upload,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Download,
|
Download,
|
||||||
Trash2,
|
Trash2,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Loader,
|
Loader,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const store = useConnectionsStore()
|
const store = useConnectionsStore()
|
||||||
@@ -33,16 +33,16 @@ const sftpTabsStore = useSftpTabsStore()
|
|||||||
|
|
||||||
const connectionId = computed(() => Number(route.params.id))
|
const connectionId = computed(() => Number(route.params.id))
|
||||||
const conn = ref(store.getConnection(connectionId.value))
|
const conn = ref(store.getConnection(connectionId.value))
|
||||||
|
|
||||||
const currentPath = ref('.')
|
const currentPath = ref('.')
|
||||||
const pathParts = ref<string[]>([])
|
const pathParts = ref<string[]>([])
|
||||||
const files = ref<SftpFileInfo[]>([])
|
const files = ref<SftpFileInfo[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const selectedFile = ref<string | null>(null)
|
const selectedFile = ref<string | null>(null)
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const showHiddenFiles = ref(false)
|
const showHiddenFiles = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
let searchDebounceTimer = 0
|
let searchDebounceTimer = 0
|
||||||
@@ -79,32 +79,32 @@ function createUploadContext() {
|
|||||||
function invalidateUploadContext() {
|
function invalidateUploadContext() {
|
||||||
activeUploadContextId += 1
|
activeUploadContextId += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalProgress = computed(() => {
|
const totalProgress = computed(() => {
|
||||||
if (uploadProgressList.value.length === 0) return 0
|
if (uploadProgressList.value.length === 0) return 0
|
||||||
const totalSize = uploadProgressList.value.reduce((sum, item) => sum + item.size, 0)
|
const totalSize = uploadProgressList.value.reduce((sum, item) => sum + item.size, 0)
|
||||||
const uploadedSize = uploadProgressList.value.reduce((sum, item) => {
|
const uploadedSize = uploadProgressList.value.reduce((sum, item) => {
|
||||||
if (item.status === 'success') return sum + item.size
|
if (item.status === 'success') return sum + item.size
|
||||||
if (item.status === 'uploading') return sum + item.uploaded
|
if (item.status === 'uploading') return sum + item.uploaded
|
||||||
return sum
|
return sum
|
||||||
}, 0)
|
}, 0)
|
||||||
return totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
|
return totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentUploadingFile = computed(() => {
|
const currentUploadingFile = computed(() => {
|
||||||
return uploadProgressList.value.find(item => item.status === 'uploading')?.name || ''
|
return uploadProgressList.value.find(item => item.status === 'uploading')?.name || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return bytes + ' B'
|
if (bytes < 1024) return bytes + ' B'
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(ts: number): string {
|
function formatDate(ts: number): string {
|
||||||
return new Date(ts).toLocaleString()
|
return new Date(ts).toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showTransferModal = ref(false)
|
const showTransferModal = ref(false)
|
||||||
const transferFile = ref<SftpFileInfo | null>(null)
|
const transferFile = ref<SftpFileInfo | null>(null)
|
||||||
const transferTargetConnectionId = ref<number | null>(null)
|
const transferTargetConnectionId = ref<number | null>(null)
|
||||||
@@ -177,7 +177,7 @@ async function cancelTransfer() {
|
|||||||
transferError.value = res?.response?.data?.error ?? '取消传输失败'
|
transferError.value = res?.response?.data?.error ?? '取消传输失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let routeInitRequestId = 0
|
let routeInitRequestId = 0
|
||||||
|
|
||||||
function isStaleRouteInit(requestId?: number, isCancelled?: () => boolean) {
|
function isStaleRouteInit(requestId?: number, isCancelled?: () => boolean) {
|
||||||
@@ -212,7 +212,7 @@ function resetVolatileSftpState() {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
async (routeId, _, onCleanup) => {
|
async (routeId, _oldRouteId, onCleanup) => {
|
||||||
const requestId = ++routeInitRequestId
|
const requestId = ++routeInitRequestId
|
||||||
let cleanedUp = false
|
let cleanedUp = false
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -220,11 +220,18 @@ watch(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isRouteInitCancelled = () => cleanedUp
|
const isRouteInitCancelled = () => cleanedUp
|
||||||
resetVolatileSftpState()
|
|
||||||
|
|
||||||
const rawId = Array.isArray(routeId) ? routeId[0] : routeId
|
const rawId = Array.isArray(routeId) ? routeId[0] : routeId
|
||||||
const parsedId = Number(rawId)
|
const parsedId = Number(rawId)
|
||||||
|
|
||||||
|
// 只在切换到不同连接时重置状态
|
||||||
|
// 使用 store 中的状态跟踪,确保在组件重建后仍能正确判断
|
||||||
|
const lastLoaded = sftpTabsStore.getLastLoadedConnectionId(parsedId)
|
||||||
|
const isConnectionChanged = lastLoaded !== parsedId
|
||||||
|
if (isConnectionChanged) {
|
||||||
|
resetVolatileSftpState()
|
||||||
|
}
|
||||||
|
|
||||||
if (!rawId || !Number.isInteger(parsedId) || parsedId <= 0) {
|
if (!rawId || !Number.isInteger(parsedId) || parsedId <= 0) {
|
||||||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||||||
conn.value = undefined
|
conn.value = undefined
|
||||||
@@ -255,7 +262,12 @@ watch(
|
|||||||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||||||
conn.value = targetConnection
|
conn.value = targetConnection
|
||||||
sftpTabsStore.openOrFocus(targetConnection)
|
sftpTabsStore.openOrFocus(targetConnection)
|
||||||
await initPath(parsedId, requestId, isRouteInitCancelled)
|
|
||||||
|
// 只在切换到不同连接时初始化路径
|
||||||
|
if (isConnectionChanged) {
|
||||||
|
sftpTabsStore.setConnectionLoaded(parsedId)
|
||||||
|
await initPath(parsedId, requestId, isRouteInitCancelled)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -325,7 +337,7 @@ async function loadPath(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToDir(name: string) {
|
function navigateToDir(name: string) {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
|
const base = currentPath.value === '.' || currentPath.value === '' ? '' : currentPath.value
|
||||||
@@ -344,7 +356,7 @@ function navigateToIndex(i: number) {
|
|||||||
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
pathParts.value = currentPath.value === '/' ? [''] : currentPath.value.split('/').filter(Boolean)
|
||||||
loadPath()
|
loadPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
function goUp() {
|
function goUp() {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
|
if (currentPath.value === '.' || currentPath.value === '' || currentPath.value === '/') {
|
||||||
@@ -361,28 +373,28 @@ function goUp() {
|
|||||||
}
|
}
|
||||||
loadPath()
|
loadPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileClick(file: SftpFileInfo) {
|
function handleFileClick(file: SftpFileInfo) {
|
||||||
if (file.directory) {
|
if (file.directory) {
|
||||||
navigateToDir(file.name)
|
navigateToDir(file.name)
|
||||||
} else {
|
} else {
|
||||||
selectedFile.value = file.name
|
selectedFile.value = file.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDownload(file: SftpFileInfo) {
|
function handleDownload(file: SftpFileInfo) {
|
||||||
if (file.directory) return
|
if (file.directory) return
|
||||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||||
const path = base ? base + '/' + file.name : file.name
|
const path = base ? base + '/' + file.name : file.name
|
||||||
sftpApi.downloadFile(connectionId.value, path).catch(() => {
|
sftpApi.downloadFile(connectionId.value, path).catch(() => {
|
||||||
error.value = '下载失败'
|
error.value = '下载失败'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerUpload() {
|
function triggerUpload() {
|
||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileSelect(e: Event) {
|
async function handleFileSelect(e: Event) {
|
||||||
const uploadContextId = createUploadContext()
|
const uploadContextId = createUploadContext()
|
||||||
const isUploadStale = () => uploadContextId !== activeUploadContextId
|
const isUploadStale = () => uploadContextId !== activeUploadContextId
|
||||||
@@ -395,25 +407,25 @@ async function handleFileSelect(e: Event) {
|
|||||||
uploading.value = true
|
uploading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
const path = currentPath.value === '.' ? '' : currentPath.value
|
const path = currentPath.value === '.' ? '' : currentPath.value
|
||||||
|
|
||||||
const uploadTasks: { id: string; file: File; taskId?: string }[] = []
|
const uploadTasks: { id: string; file: File; taskId?: string }[] = []
|
||||||
for (let i = 0; i < selected.length; i++) {
|
for (let i = 0; i < selected.length; i++) {
|
||||||
const file = selected[i]
|
const file = selected[i]
|
||||||
if (!file) continue
|
if (!file) continue
|
||||||
uploadTasks.push({ id: `${Date.now()}-${i}`, file })
|
uploadTasks.push({ id: `${Date.now()}-${i}`, file })
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadProgressList.value = uploadTasks.map(({ id, file }) => ({
|
uploadProgressList.value = uploadTasks.map(({ id, file }) => ({
|
||||||
id,
|
id,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
uploaded: 0,
|
uploaded: 0,
|
||||||
total: file.size,
|
total: file.size,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
showUploadProgress.value = true
|
showUploadProgress.value = true
|
||||||
|
|
||||||
const MAX_PARALLEL = 5
|
const MAX_PARALLEL = 5
|
||||||
|
|
||||||
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
|
for (let i = 0; i < uploadTasks.length; i += MAX_PARALLEL) {
|
||||||
@@ -448,17 +460,17 @@ async function handleFileSelect(e: Event) {
|
|||||||
const taskStatus = statusRes.data
|
const taskStatus = statusRes.data
|
||||||
|
|
||||||
item.uploaded = taskStatus.transferredBytes
|
item.uploaded = taskStatus.transferredBytes
|
||||||
item.total = taskStatus.totalBytes
|
item.total = taskStatus.totalBytes
|
||||||
|
|
||||||
if (taskStatus.status === 'success') {
|
if (taskStatus.status === 'success') {
|
||||||
item.status = 'success'
|
item.status = 'success'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (taskStatus.status === 'error') {
|
if (taskStatus.status === 'error') {
|
||||||
item.status = 'error'
|
item.status = 'error'
|
||||||
item.message = taskStatus.error || 'Upload failed'
|
item.message = taskStatus.error || 'Upload failed'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 200))
|
await new Promise(resolve => setTimeout(resolve, 200))
|
||||||
}
|
}
|
||||||
@@ -488,42 +500,42 @@ async function handleFileSelect(e: Event) {
|
|||||||
if (isUploadStale()) return
|
if (isUploadStale()) return
|
||||||
toast.success(`成功上传 ${successCount} 个文件`)
|
toast.success(`成功上传 ${successCount} 个文件`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMkdir() {
|
function handleMkdir() {
|
||||||
const name = prompt('文件夹名称:')
|
const name = prompt('文件夹名称:')
|
||||||
if (!name?.trim()) return
|
if (!name?.trim()) return
|
||||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||||
const path = base ? base + '/' + name : name
|
const path = base ? base + '/' + name : name
|
||||||
sftpApi.createDir(connectionId.value, path).then(() => loadPath()).catch(() => {
|
sftpApi.createDir(connectionId.value, path).then(() => loadPath()).catch(() => {
|
||||||
error.value = '创建文件夹失败'
|
error.value = '创建文件夹失败'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(file: SftpFileInfo) {
|
function handleDelete(file: SftpFileInfo) {
|
||||||
if (!confirm(`确定删除「${file.name}」?`)) return
|
if (!confirm(`确定删除「${file.name}」?`)) return
|
||||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||||
const path = base ? base + '/' + file.name : file.name
|
const path = base ? base + '/' + file.name : file.name
|
||||||
sftpApi.deleteFile(connectionId.value, path, file.directory).then(() => loadPath()).catch(() => {
|
sftpApi.deleteFile(connectionId.value, path, file.directory).then(() => loadPath()).catch(() => {
|
||||||
error.value = '删除失败'
|
error.value = '删除失败'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetConnectionOptions = computed(() => {
|
const targetConnectionOptions = computed(() => {
|
||||||
const list = store.connections.filter((c) => c.id !== connectionId.value)
|
const list = store.connections.filter((c) => c.id !== connectionId.value)
|
||||||
return list
|
return list
|
||||||
})
|
})
|
||||||
|
|
||||||
async function openTransferModal(file: SftpFileInfo) {
|
async function openTransferModal(file: SftpFileInfo) {
|
||||||
if (file.directory) return
|
if (file.directory) return
|
||||||
if (store.connections.length === 0) await store.fetchConnections()
|
if (store.connections.length === 0) await store.fetchConnections()
|
||||||
transferFile.value = file
|
transferFile.value = file
|
||||||
transferTargetConnectionId.value = targetConnectionOptions.value[0]?.id ?? null
|
transferTargetConnectionId.value = targetConnectionOptions.value[0]?.id ?? null
|
||||||
transferTargetPath.value = currentPath.value === '.' || !currentPath.value ? '/' : currentPath.value
|
transferTargetPath.value = currentPath.value === '.' || !currentPath.value ? '/' : currentPath.value
|
||||||
if (!transferTargetPath.value.endsWith('/')) transferTargetPath.value += '/'
|
if (!transferTargetPath.value.endsWith('/')) transferTargetPath.value += '/'
|
||||||
transferError.value = ''
|
transferError.value = ''
|
||||||
showTransferModal.value = true
|
showTransferModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTransferModal() {
|
function closeTransferModal() {
|
||||||
if (transferring.value) return
|
if (transferring.value) return
|
||||||
stopTransferProgress()
|
stopTransferProgress()
|
||||||
@@ -534,13 +546,13 @@ function closeTransferModal() {
|
|||||||
transferError.value = ''
|
transferError.value = ''
|
||||||
resetTransferProgress()
|
resetTransferProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitTransfer() {
|
async function submitTransfer() {
|
||||||
const file = transferFile.value
|
const file = transferFile.value
|
||||||
const targetId = transferTargetConnectionId.value
|
const targetId = transferTargetConnectionId.value
|
||||||
if (!file || targetId == null) return
|
if (!file || targetId == null) return
|
||||||
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
const base = currentPath.value === '.' || !currentPath.value ? '' : currentPath.value
|
||||||
const sourcePath = base ? base + '/' + file.name : file.name
|
const sourcePath = base ? base + '/' + file.name : file.name
|
||||||
let targetPath = transferTargetPath.value.trim()
|
let targetPath = transferTargetPath.value.trim()
|
||||||
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
|
if (targetPath.endsWith('/') || !targetPath) targetPath = targetPath + file.name
|
||||||
transferring.value = true
|
transferring.value = true
|
||||||
@@ -560,43 +572,43 @@ async function submitTransfer() {
|
|||||||
transferring.value = false
|
transferring.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
|
<div class="flex items-center gap-4 p-4 border-b border-slate-700 bg-slate-800/50">
|
||||||
<button
|
<button
|
||||||
@click="router.push('/connections')"
|
@click="router.push('/connections')"
|
||||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||||
aria-label="返回"
|
aria-label="返回"
|
||||||
>
|
>
|
||||||
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
|
<ArrowLeft class="w-5 h-5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<h2 class="text-lg font-semibold text-slate-100">
|
<h2 class="text-lg font-semibold text-slate-100">
|
||||||
{{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }}
|
{{ conn?.name || '文件' }} - {{ conn?.username }}@{{ conn?.host }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-auto p-4">
|
<div class="flex-1 overflow-auto p-4">
|
||||||
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
<div class="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 p-3 border-b border-slate-700 bg-slate-800/80">
|
<div class="flex flex-col sm:flex-row sm:items-center gap-2 p-3 border-b border-slate-700 bg-slate-800/80">
|
||||||
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 w-full sm:flex-1">
|
<nav class="flex items-center gap-1 text-sm text-slate-400 min-w-0 w-full sm:flex-1">
|
||||||
<button
|
<button
|
||||||
@click="navigateToIndex(-1)"
|
@click="navigateToIndex(-1)"
|
||||||
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
|
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate"
|
||||||
>
|
>
|
||||||
/
|
/
|
||||||
</button>
|
</button>
|
||||||
<template v-for="(part, i) in pathParts" :key="i">
|
<template v-for="(part, i) in pathParts" :key="i">
|
||||||
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" aria-hidden="true" />
|
<ChevronRight class="w-4 h-4 flex-shrink-0 text-slate-600" aria-hidden="true" />
|
||||||
<button
|
<button
|
||||||
@click="navigateToIndex(i)"
|
@click="navigateToIndex(i)"
|
||||||
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[120px]"
|
class="px-2 py-1 rounded hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer truncate max-w-[120px]"
|
||||||
>
|
>
|
||||||
{{ part || '/' }}
|
{{ part || '/' }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="w-full sm:w-auto flex items-center gap-2 justify-end">
|
<div class="w-full sm:w-auto flex items-center gap-2 justify-end">
|
||||||
<div class="flex-1 sm:flex-none">
|
<div class="flex-1 sm:flex-none">
|
||||||
<input
|
<input
|
||||||
@@ -620,84 +632,84 @@ async function submitTransfer() {
|
|||||||
:disabled="uploading"
|
:disabled="uploading"
|
||||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
||||||
aria-label="上传"
|
aria-label="上传"
|
||||||
>
|
>
|
||||||
<Upload class="w-4 h-4" aria-hidden="true" />
|
<Upload class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleMkdir"
|
@click="handleMkdir"
|
||||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer"
|
||||||
aria-label="新建文件夹"
|
aria-label="新建文件夹"
|
||||||
>
|
>
|
||||||
<FolderPlus class="w-4 h-4" aria-hidden="true" />
|
<FolderPlus class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="loadPath()"
|
@click="loadPath()"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
class="p-2 rounded-lg text-slate-400 hover:bg-slate-700 hover:text-slate-100 transition-colors duration-200 cursor-pointer disabled:opacity-50"
|
||||||
aria-label="刷新"
|
aria-label="刷新"
|
||||||
>
|
>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showUploadProgress" class="bg-slate-800/50 border-b border-slate-700 p-4 space-y-3">
|
<div v-if="showUploadProgress" class="bg-slate-800/50 border-b border-slate-700 p-4 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-slate-300">上传进度: {{ totalProgress }}%</span>
|
<span class="text-sm text-slate-300">上传进度: {{ totalProgress }}%</span>
|
||||||
<span class="text-sm text-slate-400">{{ currentUploadingFile || '准备上传...' }}</span>
|
<span class="text-sm text-slate-400">{{ currentUploadingFile || '准备上传...' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-slate-700 rounded-full h-2 overflow-hidden">
|
<div class="w-full bg-slate-700 rounded-full h-2 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="bg-cyan-600 h-full transition-all duration-300"
|
class="bg-cyan-600 h-full transition-all duration-300"
|
||||||
:style="{ width: totalProgress + '%' }"
|
:style="{ width: totalProgress + '%' }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-2 max-h-40 overflow-y-auto">
|
<div class="grid grid-cols-1 gap-2 max-h-40 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-for="item in uploadProgressList"
|
v-for="item in uploadProgressList"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="flex items-center gap-3 text-sm"
|
class="flex items-center gap-3 text-sm"
|
||||||
>
|
>
|
||||||
<CheckCircle v-if="item.status === 'success'" class="w-4 h-4 flex-shrink-0 text-green-500" aria-hidden="true" />
|
<CheckCircle v-if="item.status === 'success'" class="w-4 h-4 flex-shrink-0 text-green-500" aria-hidden="true" />
|
||||||
<AlertCircle v-else-if="item.status === 'error'" class="w-4 h-4 flex-shrink-0 text-red-500" aria-hidden="true" />
|
<AlertCircle v-else-if="item.status === 'error'" class="w-4 h-4 flex-shrink-0 text-red-500" aria-hidden="true" />
|
||||||
<Loader v-else-if="item.status === 'uploading'" class="w-4 h-4 flex-shrink-0 text-cyan-500 animate-spin" aria-hidden="true" />
|
<Loader v-else-if="item.status === 'uploading'" class="w-4 h-4 flex-shrink-0 text-cyan-500 animate-spin" aria-hidden="true" />
|
||||||
<File v-else class="w-4 h-4 flex-shrink-0 text-slate-500" aria-hidden="true" />
|
<File v-else class="w-4 h-4 flex-shrink-0 text-slate-500" aria-hidden="true" />
|
||||||
<span class="flex-1 truncate text-slate-300">{{ item.name }}</span>
|
<span class="flex-1 truncate text-slate-300">{{ item.name }}</span>
|
||||||
<span class="text-slate-400 text-xs">
|
<span class="text-slate-400 text-xs">
|
||||||
{{ formatSize(item.size) }}
|
{{ formatSize(item.size) }}
|
||||||
<template v-if="item.status === 'uploading'">
|
<template v-if="item.status === 'uploading'">
|
||||||
({{ Math.round((item.uploaded / item.total) * 100) }}%)
|
({{ Math.round((item.uploaded / item.total) * 100) }}%)
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.status === 'success'">
|
<template v-else-if="item.status === 'success'">
|
||||||
✓
|
✓
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
|
<p v-if="error" class="px-4 py-2 text-sm text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
<div v-if="loading" class="p-8 text-center text-slate-400">
|
<div v-if="loading" class="p-8 text-center text-slate-400">
|
||||||
加载中...
|
加载中...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="divide-y divide-slate-700">
|
<div v-else class="divide-y divide-slate-700">
|
||||||
<button
|
<button
|
||||||
v-if="currentPath !== '.' && pathParts.length > 1"
|
v-if="currentPath !== '.' && pathParts.length > 1"
|
||||||
@click="goUp"
|
@click="goUp"
|
||||||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left"
|
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left"
|
||||||
>
|
>
|
||||||
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
|
<FolderOpen class="w-5 h-5 text-slate-500" aria-hidden="true" />
|
||||||
<span class="text-slate-400">..</span>
|
<span class="text-slate-400">..</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="file in filteredFiles"
|
v-for="file in filteredFiles"
|
||||||
:key="file.name"
|
:key="file.name"
|
||||||
@@ -705,90 +717,90 @@ async function submitTransfer() {
|
|||||||
@dblclick="!file.directory && handleDownload(file)"
|
@dblclick="!file.directory && handleDownload(file)"
|
||||||
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left group"
|
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-700/50 transition-colors duration-200 cursor-pointer text-left group"
|
||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
:is="file.directory ? FolderOpen : File"
|
:is="file.directory ? FolderOpen : File"
|
||||||
class="w-5 h-5 flex-shrink-0 text-slate-400"
|
class="w-5 h-5 flex-shrink-0 text-slate-400"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="flex-1 truncate text-slate-200">{{ file.name }}</span>
|
<span class="flex-1 truncate text-slate-200">{{ file.name }}</span>
|
||||||
<span v-if="!file.directory" class="text-sm text-slate-500 flex-shrink-0">
|
<span v-if="!file.directory" class="text-sm text-slate-500 flex-shrink-0">
|
||||||
{{ formatSize(file.size) }}
|
{{ formatSize(file.size) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-slate-500 flex-shrink-0">{{ formatDate(file.mtime) }}</span>
|
<span class="text-sm text-slate-500 flex-shrink-0">{{ formatDate(file.mtime) }}</span>
|
||||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
v-if="!file.directory"
|
v-if="!file.directory"
|
||||||
@click.stop="handleDownload(file)"
|
@click.stop="handleDownload(file)"
|
||||||
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
|
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
|
||||||
aria-label="下载"
|
aria-label="下载"
|
||||||
>
|
>
|
||||||
<Download class="w-4 h-4" aria-hidden="true" />
|
<Download class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!file.directory"
|
v-if="!file.directory"
|
||||||
@click.stop="openTransferModal(file)"
|
@click.stop="openTransferModal(file)"
|
||||||
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
|
class="p-1.5 rounded text-slate-400 hover:bg-slate-600 hover:text-cyan-400 transition-colors duration-200 cursor-pointer"
|
||||||
aria-label="复制到远程"
|
aria-label="复制到远程"
|
||||||
>
|
>
|
||||||
<Copy class="w-4 h-4" aria-hidden="true" />
|
<Copy class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click.stop="handleDelete(file)"
|
@click.stop="handleDelete(file)"
|
||||||
class="p-1.5 rounded text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
|
class="p-1.5 rounded text-slate-400 hover:bg-red-900/50 hover:text-red-400 transition-colors duration-200 cursor-pointer"
|
||||||
aria-label="删除"
|
aria-label="删除"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
<Trash2 class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="filteredFiles.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
<div v-if="filteredFiles.length === 0 && !loading" class="p-8 text-center text-slate-500">
|
||||||
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏文件)') }}
|
{{ files.length === 0 ? '空目录' : (searchQuery.trim() ? '未找到匹配文件' : '无可见文件(已隐藏文件)') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
v-if="showTransferModal"
|
v-if="showTransferModal"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="transfer-modal-title"
|
aria-labelledby="transfer-modal-title"
|
||||||
>
|
>
|
||||||
<div class="w-full max-w-md rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
|
<div class="w-full max-w-md rounded-xl border border-slate-700 bg-slate-800 p-5 shadow-xl">
|
||||||
<h3 id="transfer-modal-title" class="text-lg font-semibold text-slate-100 mb-4">
|
<h3 id="transfer-modal-title" class="text-lg font-semibold text-slate-100 mb-4">
|
||||||
复制到远程
|
复制到远程
|
||||||
</h3>
|
</h3>
|
||||||
<p v-if="transferFile" class="text-sm text-slate-400 mb-3">
|
<p v-if="transferFile" class="text-sm text-slate-400 mb-3">
|
||||||
文件:{{ transferFile.name }}
|
文件:{{ transferFile.name }}
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="transfer-target-conn" class="block text-sm text-slate-400 mb-1">目标连接</label>
|
<label for="transfer-target-conn" class="block text-sm text-slate-400 mb-1">目标连接</label>
|
||||||
<select
|
<select
|
||||||
id="transfer-target-conn"
|
id="transfer-target-conn"
|
||||||
v-model.number="transferTargetConnectionId"
|
v-model.number="transferTargetConnectionId"
|
||||||
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||||
>
|
>
|
||||||
<option v-for="c in targetConnectionOptions" :key="c.id" :value="c.id">
|
<option v-for="c in targetConnectionOptions" :key="c.id" :value="c.id">
|
||||||
{{ c.name }} ({{ c.username }}@{{ c.host }})
|
{{ c.name }} ({{ c.username }}@{{ c.host }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p v-if="targetConnectionOptions.length === 0" class="mt-1 text-xs text-amber-400">
|
<p v-if="targetConnectionOptions.length === 0" class="mt-1 text-xs text-amber-400">
|
||||||
暂无其他连接,请先在连接管理中添加
|
暂无其他连接,请先在连接管理中添加
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="transfer-target-path" class="block text-sm text-slate-400 mb-1">目标路径(目录以 / 结尾)</label>
|
<label for="transfer-target-path" class="block text-sm text-slate-400 mb-1">目标路径(目录以 / 结尾)</label>
|
||||||
<input
|
<input
|
||||||
id="transfer-target-path"
|
id="transfer-target-path"
|
||||||
v-model="transferTargetPath"
|
v-model="transferTargetPath"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
class="w-full rounded-lg border border-slate-600 bg-slate-700 px-3 py-2 text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||||
placeholder="/"
|
placeholder="/"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
|
<p v-if="transferError" class="mt-3 text-sm text-red-400">{{ transferError }}</p>
|
||||||
<div v-if="transferring" class="mt-3 space-y-2">
|
<div v-if="transferring" class="mt-3 space-y-2">
|
||||||
@@ -815,17 +827,17 @@ async function submitTransfer() {
|
|||||||
>
|
>
|
||||||
{{ transferring ? '取消传输' : '取消' }}
|
{{ transferring ? '取消传输' : '取消' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="submitTransfer"
|
@click="submitTransfer"
|
||||||
:disabled="transferring || transferTargetConnectionId == null"
|
:disabled="transferring || transferTargetConnectionId == null"
|
||||||
class="rounded-lg bg-cyan-600 px-4 py-2 text-white hover:bg-cyan-500 disabled:opacity-50 cursor-pointer"
|
class="rounded-lg bg-cyan-600 px-4 py-2 text-white hover:bg-cyan-500 disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{{ transferring ? '传输中...' : '确定' }}
|
{{ transferring ? '传输中...' : '确定' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user