Compare commits

...

4 Commits

Author SHA1 Message Date
liumangmang
9f133bd337 feat: update monitor, terminal, and SFTP interaction flow 2026-04-01 15:22:51 +08:00
liumangmang
832d55c722 feat: 新增终端顶部实时服务器监控面板 2026-03-30 16:54:56 +08:00
liumangmang
ba1acdc2dd fix: 修复懒加载组件keep-alive不生效问题 2026-03-30 16:41:16 +08:00
liumangmang
e895124831 fix: SFTP标签页离开后返回刷新状态丢失问题 2026-03-30 16:18:00 +08:00
9 changed files with 1065 additions and 602 deletions

View File

@@ -0,0 +1,63 @@
# SFTP标签页状态保持修复实施计划
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复SFTP标签页离开后再返回会刷新页面、丢失浏览状态的问题
**Architecture:** 为SftpView组件添加keep-alive缓存仅缓存SFTP相关页面最大缓存10个实例避免内存占用过高每个路由实例通过fullPath作为唯一key区分
**Tech Stack:** Vue 3、Vue Router 4、Pinia
---
### Task 1: 为SftpView组件添加名称标识
**Files:**
- Modify: `frontend/src/views/SftpView.vue`
- [ ] **Step 1: 添加组件名称**
在script setup开头添加
```typescript
defineOptions({ name: 'SftpView' })
```
---
### Task 2: 修改MainLayout添加keep-alive缓存
**Files:**
- Modify: `frontend/src/layouts/MainLayout.vue:193-195`
- [ ] **Step 1: 替换原RouterView代码**
原代码:
```vue
<RouterView v-slot="{ Component }">
<component :is="Component" v-if="!showTerminalWorkspace" />
</RouterView>
```
替换为:
```vue
<RouterView v-slot="{ Component }">
<keep-alive :include="['SftpView']" :max="10">
<component :is="Component" v-if="!showTerminalWorkspace" :key="$route.fullPath" />
</keep-alive>
</RouterView>
```
---
### Task 3: 验证修复效果
**Files:**
- Test: 手动验证 + 构建验证
- [ ] **Step 1: 运行类型检查**
Run: `cd frontend && npm run build`
Expected: 构建成功,无类型错误
- [ ] **Step 2: 手动测试功能**
1. 启动开发服务,登录系统
2. 打开任意连接的SFTP标签
3. 浏览到任意子目录
4. 切换到其他页面(如连接列表、终端)
5. 切回SFTP标签确认仍停留在之前浏览的子目录状态未丢失
6. 打开多个不同连接的SFTP标签切换时确认各自状态独立保存
---

View File

@@ -0,0 +1,180 @@
package com.sshmanager.controller;
import com.sshmanager.entity.Connection;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.ConnectionService;
import com.sshmanager.service.SshService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/monitor")
public class MonitorController {
private final ConnectionService connectionService;
private final SshService sshService;
private final UserRepository userRepository;
public MonitorController(ConnectionService connectionService, SshService sshService, UserRepository userRepository) {
this.connectionService = connectionService;
this.sshService = sshService;
this.userRepository = userRepository;
}
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
return user.getId();
}
@GetMapping("/{connectionId}")
public ResponseEntity<Map<String, Object>> getServerMetrics(@PathVariable Long connectionId, Authentication authentication) {
try {
Long userId = getCurrentUserId(authentication);
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
if (conn == null) {
return ResponseEntity.notFound().build();
}
String password = connectionService.getDecryptedPassword(conn);
String privateKey = connectionService.getDecryptedPrivateKey(conn);
String passphrase = connectionService.getDecryptedPassphrase(conn);
Map<String, Object> metrics = new HashMap<>();
// 获取CPU使用率兼容多种系统
try {
double cpuUsage = 0;
try {
// 优先使用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);
} catch (Exception e) {
metrics.put("cpuUsage", null);
}
// 获取内存信息(兼容多种系统)
try {
long totalMem = 0;
long usedMem = 0;
try {
// 优先使用free -b
String memOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
"free -b 2>/dev/null | grep Mem");
if (!memOutput.trim().isEmpty()) {
String[] memParts = memOutput.trim().split("\\s+");
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) {
metrics.put("memTotal", null);
metrics.put("memUsed", null);
metrics.put("memUsage", null);
}
// 获取磁盘使用率
try {
String diskOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
"df -P / | tail -1");
String[] diskParts = diskOutput.trim().split("\\s+");
int diskUsage = Integer.parseInt(diskParts[4].replace("%", ""));
metrics.put("diskUsage", diskUsage);
} catch (Exception e) {
metrics.put("diskUsage", null);
}
// 获取系统负载
try {
String loadOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
"cat /proc/loadavg");
String[] loadParts = loadOutput.trim().split("\\s+");
double load1 = Double.parseDouble(loadParts[0]);
metrics.put("load1", load1);
} catch (Exception e) {
metrics.put("load1", null);
}
// 获取CPU核数
try {
String cpuCountOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
"grep -c ^processor /proc/cpuinfo");
int cpuCores = Integer.parseInt(cpuCountOutput.trim());
metrics.put("cpuCores", cpuCores);
} catch (Exception e) {
metrics.put("cpuCores", null);
}
// 获取系统运行时间
try {
String uptimeOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
"uptime -p");
metrics.put("uptime", uptimeOutput.trim().replace("up ", ""));
} catch (Exception e) {
// 尝试另一种uptime格式
try {
String uptimeOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
"cat /proc/uptime | awk '{print $1}'");
double uptimeSeconds = Double.parseDouble(uptimeOutput.trim());
long days = (long) (uptimeSeconds / 86400);
long hours = (long) ((uptimeSeconds % 86400) / 3600);
long minutes = (long) ((uptimeSeconds % 3600) / 60);
StringBuilder uptimeStr = new StringBuilder();
if (days > 0) uptimeStr.append(days).append("d ");
if (hours > 0) uptimeStr.append(hours).append("h ");
uptimeStr.append(minutes).append("m");
metrics.put("uptime", uptimeStr.toString().trim());
} catch (Exception e2) {
metrics.put("uptime", null);
}
}
return ResponseEntity.ok(metrics);
} catch (Exception e) {
Map<String, Object> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
}

View File

@@ -1,22 +1,25 @@
package com.sshmanager.service; package com.sshmanager.service;
import com.jcraft.jsch.ChannelShell; import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch; import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.Session; import com.jcraft.jsch.JSch;
import com.sshmanager.entity.Connection; import com.jcraft.jsch.Session;
import org.springframework.stereotype.Service; import com.sshmanager.entity.Connection;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.PipedInputStream; import java.io.PipedInputStream;
import java.io.PipedOutputStream; import java.io.PipedOutputStream;
@Service @Service
public class SshService { public class SshService {
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase) public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
throws Exception { throws Exception {
JSch jsch = new JSch(); JSch jsch = new JSch();
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
@@ -65,7 +68,49 @@ public class SshService {
} }
}).start(); }).start();
return new SshSession(session, channel, channelOut, pipeToChannel); return new SshSession(session, channel, channelOut, pipeToChannel);
}
// 执行单次命令并返回输出
public String executeCommand(Connection conn, String password, String privateKey, String passphrase, String command) throws Exception {
JSch jsch = new JSch();
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
}
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
session.setConfig("StrictHostKeyChecking", "no");
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
session.setConfig("PreferredAuthentications", conn.getAuthType() == Connection.AuthType.PASSWORD ? "password" : "publickey");
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
session.setPassword(password);
}
session.connect(8000);
ChannelExec channel = (ChannelExec) session.openChannel("exec");
channel.setCommand(command);
channel.setErrStream(System.err);
InputStream in = channel.getInputStream();
channel.connect(3000);
BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
result.append(line).append("\n");
}
channel.disconnect();
session.disconnect();
return result.toString().trim();
} }
public static class SshSession { public static class SshSession {

View File

@@ -1,20 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue' 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 client from '../api/client'
import { Activity, Cpu, HardDrive, MemoryStick, Clock, ChevronUp, ChevronDown } from 'lucide-vue-next'
import 'xterm/css/xterm.css' import 'xterm/css/xterm.css'
const CONTROL_PREFIX = '__SSHMANAGER__:' const CONTROL_PREFIX = '__SSHMANAGER__:'
const props = defineProps<{ const props = defineProps<{
connectionId: number connectionId: number
active?: boolean active?: boolean
}>() }>()
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const status = ref<'connecting' | 'connected' | 'error'>('connecting') const status = ref<'connecting' | 'connected' | 'error'>('connecting')
const errorMessage = ref('') const errorMessage = ref('')
const showMonitor = ref(true)
const monitorLoading = ref(false)
const monitorData = ref<{
cpuUsage?: number
memTotal?: number
memUsed?: number
memUsage?: number
diskUsage?: number
load1?: number
cpuCores?: number
uptime?: string
}>({})
let monitorPollTimer: number | null = null
let term: Terminal | null = null let term: Terminal | null = null
let fitAddon: FitAddon | null = null let fitAddon: FitAddon | null = null
@@ -34,11 +49,51 @@ function focusTerminal() {
term?.focus() term?.focus()
} }
function refreshTerminalLayout() { function refreshTerminalLayout() {
nextTick(() => { nextTick(() => {
fitTerminal() fitTerminal()
focusTerminal() focusTerminal()
}) })
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
}
function getUsageColor(usage: number | undefined, threshold: number = 70, critical: number = 90): string {
if (usage === undefined) return 'text-slate-400'
if (usage >= critical) return 'text-red-400'
if (usage >= threshold) return 'text-amber-400'
return 'text-green-400'
}
async function fetchMonitorData() {
if (!props.active || status.value !== 'connected') return
monitorLoading.value = true
try {
const res = await client.get(`/monitor/${props.connectionId}`)
monitorData.value = res.data
} catch {
// 静默失败,不影响终端使用
} finally {
monitorLoading.value = false
}
}
function startMonitorPoll() {
stopMonitorPoll()
fetchMonitorData()
monitorPollTimer = window.setInterval(fetchMonitorData, 5000)
}
function stopMonitorPoll() {
if (monitorPollTimer) {
clearInterval(monitorPollTimer)
monitorPollTimer = null
}
} }
function getWsUrl(): string { function getWsUrl(): string {
@@ -52,32 +107,34 @@ function getWsUrl(): string {
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}` return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
} }
function cleanup() { function cleanup() {
if (resizeObserver && containerRef.value) { stopMonitorPoll()
resizeObserver.unobserve(containerRef.value)
} if (resizeObserver && containerRef.value) {
resizeObserver = null resizeObserver.unobserve(containerRef.value)
}
if (onDataDisposable) { resizeObserver = null
onDataDisposable.dispose()
onDataDisposable = null if (onDataDisposable) {
} onDataDisposable.dispose()
if (onResizeDisposable) { onDataDisposable = null
onResizeDisposable.dispose() }
onResizeDisposable = null if (onResizeDisposable) {
} onResizeDisposable.dispose()
clearTimeout(resizeDebounceTimer) onResizeDisposable = null
resizeDebounceTimer = 0 }
clearTimeout(resizeDebounceTimer)
if (ws) { resizeDebounceTimer = 0
ws.close()
ws = null if (ws) {
} ws.close()
if (term) { ws = null
term.dispose() }
term = null if (term) {
} term.dispose()
fitAddon = null term = null
}
fitAddon = null
} }
function sendResize(cols: number, rows: number) { function sendResize(cols: number, rows: number) {
@@ -154,10 +211,13 @@ onMounted(() => {
return return
} }
ws.onopen = () => { ws.onopen = () => {
status.value = 'connected' status.value = 'connected'
refreshTerminalLayout() refreshTerminalLayout()
scheduleResize(term!.cols, term!.rows) scheduleResize(term!.cols, term!.rows)
if (props.active !== false) {
startMonitorPoll()
}
} }
onDataDisposable = term.onData((data) => { onDataDisposable = term.onData((data) => {
@@ -195,10 +255,15 @@ onMounted(() => {
} }
}) })
watch(() => props.active, (active) => { watch(() => props.active, (active) => {
if (active) { if (active) {
refreshTerminalLayout() refreshTerminalLayout()
} if (status.value === 'connected') {
startMonitorPoll()
}
} else {
stopMonitorPoll()
}
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -208,6 +273,70 @@ onUnmounted(() => {
<template> <template>
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden"> <div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
<!-- 监控状态栏 -->
<div v-if="status === 'connected'" class="border-b border-slate-700 bg-slate-900/80">
<div class="flex items-center justify-between px-3 py-1.5 text-xs">
<div class="flex items-center gap-4">
<!-- CPU -->
<div class="flex items-center gap-1">
<Cpu class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-400">CPU:</span>
<span :class="getUsageColor(monitorData.cpuUsage)">
{{ monitorData.cpuUsage !== undefined ? monitorData.cpuUsage + '%' : '--' }}
</span>
</div>
<!-- 内存 -->
<div class="flex items-center gap-1">
<MemoryStick class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-400">MEM:</span>
<span :class="getUsageColor(monitorData.memUsage)">
{{ monitorData.memUsage !== undefined ? monitorData.memUsage + '%' : '--' }}
</span>
<span v-if="monitorData.memUsed && monitorData.memTotal" class="text-slate-500 ml-1">
({{ formatBytes(monitorData.memUsed) }}/{{ formatBytes(monitorData.memTotal) }})
</span>
</div>
<!-- 磁盘 -->
<div class="flex items-center gap-1">
<HardDrive class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-400">DISK:</span>
<span :class="getUsageColor(monitorData.diskUsage, 80, 95)">
{{ monitorData.diskUsage !== undefined ? monitorData.diskUsage + '%' : '--' }}
</span>
</div>
<!-- 负载 -->
<div class="flex items-center gap-1">
<Activity class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-400">LOAD:</span>
<span :class="getUsageColor(monitorData.load1, monitorData.cpuCores || 4, (monitorData.cpuCores || 4) * 1.5)">
{{ monitorData.load1 !== undefined ? monitorData.load1.toFixed(2) : '--' }}
</span>
</div>
<!-- 运行时间 -->
<div class="flex items-center gap-1">
<Clock class="w-3.5 h-3.5 text-slate-400" />
<span class="text-slate-400">UP:</span>
<span class="text-slate-300">
{{ monitorData.uptime || '--' }}
</span>
</div>
</div>
<!-- 折叠按钮 -->
<button
@click="showMonitor = !showMonitor"
class="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
:title="showMonitor ? '隐藏监控栏' : '显示监控栏'"
>
<component :is="showMonitor ? ChevronUp : ChevronDown" class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div <div
ref="containerRef" ref="containerRef"
class="flex-1 min-h-0 p-4 xterm-container" class="flex-1 min-h-0 p-4 xterm-container"

View File

@@ -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,29 +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 }"> <RouterView v-slot="{ Component, route }">
<component :is="Component" v-if="!showTerminalWorkspace" /> <template v-if="!showTerminalWorkspace">
<keep-alive :max="10" v-if="route.meta.keepAlive">
<component :is="Component" :key="route.params.id" />
</keep-alive>
<component :is="Component" :key="route.fullPath" v-else />
</template>
</RouterView> </RouterView>
</main> </main>
</div> </div>

View File

@@ -39,10 +39,11 @@ const routes: RouteRecordRaw[] = [
name: 'Terminal', name: 'Terminal',
component: () => import('../views/TerminalView.vue'), component: () => import('../views/TerminalView.vue'),
}, },
{ {
path: 'sftp/:id', path: 'sftp/:id',
name: 'Sftp', name: 'Sftp',
component: () => import('../views/SftpView.vue'), component: () => import('../views/SftpView.vue'),
meta: { keepAlive: true },
}, },
], ],
}, },

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
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'
@@ -6,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()
@@ -32,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
@@ -78,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)
@@ -176,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) {
@@ -211,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(() => {
@@ -219,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
@@ -254,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 }
) )
@@ -324,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
@@ -343,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 === '/') {
@@ -360,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
@@ -394,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) {
@@ -447,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))
} }
@@ -487,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()
@@ -533,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
@@ -559,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
@@ -619,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"
@@ -704,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">
@@ -814,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>