Compare commits
4 Commits
78e6fc3e47
...
9f133bd337
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f133bd337 | ||
|
|
832d55c722 | ||
|
|
ba1acdc2dd | ||
|
|
e895124831 |
63
.opencode/plans/2026-03-30-sftp-tab-cache-fix-plan.md
Normal file
63
.opencode/plans/2026-03-30-sftp-tab-cache-fix-plan.md
Normal 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标签,切换时确认各自状态独立保存
|
||||
|
||||
---
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.sshmanager.service;
|
||||
|
||||
import com.jcraft.jsch.ChannelExec;
|
||||
import com.jcraft.jsch.ChannelShell;
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.Session;
|
||||
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.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
@@ -68,6 +71,48 @@ public class SshService {
|
||||
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 {
|
||||
private final Session session;
|
||||
private final ChannelShell channel;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
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'
|
||||
|
||||
const CONTROL_PREFIX = '__SSHMANAGER__:'
|
||||
@@ -15,6 +17,19 @@ const props = defineProps<{
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
|
||||
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 fitAddon: FitAddon | null = null
|
||||
@@ -41,6 +56,46 @@ function refreshTerminalLayout() {
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
const authStore = useAuthStore()
|
||||
const token = authStore.token
|
||||
@@ -53,6 +108,8 @@ function getWsUrl(): string {
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
stopMonitorPoll()
|
||||
|
||||
if (resizeObserver && containerRef.value) {
|
||||
resizeObserver.unobserve(containerRef.value)
|
||||
}
|
||||
@@ -158,6 +215,9 @@ onMounted(() => {
|
||||
status.value = 'connected'
|
||||
refreshTerminalLayout()
|
||||
scheduleResize(term!.cols, term!.rows)
|
||||
if (props.active !== false) {
|
||||
startMonitorPoll()
|
||||
}
|
||||
}
|
||||
|
||||
onDataDisposable = term.onData((data) => {
|
||||
@@ -198,6 +258,11 @@ onMounted(() => {
|
||||
watch(() => props.active, (active) => {
|
||||
if (active) {
|
||||
refreshTerminalLayout()
|
||||
if (status.value === 'connected') {
|
||||
startMonitorPoll()
|
||||
}
|
||||
} else {
|
||||
stopMonitorPoll()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -208,6 +273,70 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<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
|
||||
ref="containerRef"
|
||||
class="flex-1 min-h-0 p-4 xterm-container"
|
||||
|
||||
@@ -190,8 +190,13 @@ function handleSftpTabClose(tabId: string, connectionId: number, event: Event) {
|
||||
<div v-if="keepTerminalWorkspaceMounted" v-show="showTerminalWorkspace" class="h-full">
|
||||
<TerminalWorkspaceView :visible="showTerminalWorkspace" />
|
||||
</div>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" v-if="!showTerminalWorkspace" />
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'sftp/:id',
|
||||
name: 'Sftp',
|
||||
component: () => import('../views/SftpView.vue'),
|
||||
meta: { keepAlive: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface SftpTab {
|
||||
export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
||||
const tabs = ref<SftpTab[]>([])
|
||||
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)
|
||||
|
||||
@@ -60,7 +61,14 @@ export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
||||
const index = tabs.value.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return
|
||||
|
||||
const tab = tabs.value[index]
|
||||
if (!tab) return
|
||||
|
||||
const wasActive = activeTabId.value === tabId
|
||||
|
||||
// Clean up connection state
|
||||
connectionLoadState.value.delete(tab.connectionId)
|
||||
|
||||
tabs.value.splice(index, 1)
|
||||
|
||||
if (wasActive && tabs.value.length > 0) {
|
||||
@@ -73,6 +81,18 @@ export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
||||
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 {
|
||||
tabs,
|
||||
activeTabId,
|
||||
@@ -80,5 +100,8 @@ export const useSftpTabsStore = defineStore('sftpTabs', () => {
|
||||
openOrFocus,
|
||||
activate,
|
||||
close,
|
||||
setConnectionLoaded,
|
||||
getLastLoadedConnectionId,
|
||||
clearConnectionState,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -28,10 +28,11 @@ const sftpTabsStore = useSftpTabsStore()
|
||||
const showForm = ref(false)
|
||||
const editingConn = ref<Connection | null>(null)
|
||||
const searchQuery = ref('')
|
||||
let searchDebounceTimer = 0
|
||||
|
||||
const keyword = computed(() => route.query.q as string || '')
|
||||
const filteredConnections = computed(() => {
|
||||
const q = keyword.value.trim().toLowerCase()
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
|
||||
if (!q) {
|
||||
return store.connections
|
||||
@@ -59,7 +60,10 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
watch(searchQuery, (val) => {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
updateSearchParam(val)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
@@ -107,7 +111,7 @@ function clearSearch() {
|
||||
}
|
||||
|
||||
function highlightMatch(text: string): string {
|
||||
const q = keyword.value.trim()
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q) return text
|
||||
|
||||
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SftpView' })
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
@@ -211,7 +212,7 @@ function resetVolatileSftpState() {
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (routeId, _, onCleanup) => {
|
||||
async (routeId, _oldRouteId, onCleanup) => {
|
||||
const requestId = ++routeInitRequestId
|
||||
let cleanedUp = false
|
||||
onCleanup(() => {
|
||||
@@ -219,11 +220,18 @@ watch(
|
||||
})
|
||||
|
||||
const isRouteInitCancelled = () => cleanedUp
|
||||
resetVolatileSftpState()
|
||||
|
||||
const rawId = Array.isArray(routeId) ? routeId[0] : routeId
|
||||
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 (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||||
conn.value = undefined
|
||||
@@ -254,7 +262,12 @@ watch(
|
||||
if (isStaleRouteInit(requestId, isRouteInitCancelled)) return
|
||||
conn.value = targetConnection
|
||||
sftpTabsStore.openOrFocus(targetConnection)
|
||||
|
||||
// 只在切换到不同连接时初始化路径
|
||||
if (isConnectionChanged) {
|
||||
sftpTabsStore.setConnectionLoaded(parsedId)
|
||||
await initPath(parsedId, requestId, isRouteInitCancelled)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user