This commit is contained in:
Baobhan Sith
2025-04-16 11:04:29 +08:00
parent 269473c526
commit a83b346956
20 changed files with 922 additions and 350 deletions
+77
View File
@@ -466,6 +466,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@fortawesome/fontawesome-free": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
"integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -1014,6 +1023,42 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/splitpanes": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@types/splitpanes/-/splitpanes-2.2.6.tgz",
"integrity": "sha512-3dV5sO1Ht74iER4jJU03mreL3f+Q2h47ZqXS6Sfbqc6hkCvsDrX1GA0NbYWRdNvZemPyTDzUoApWKeoGbALwkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"vue": "^2.0.0"
}
},
"node_modules/@types/splitpanes/node_modules/@vue/compiler-sfc": {
"version": "2.7.16",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.16.tgz",
"integrity": "sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==",
"dev": true,
"dependencies": {
"@babel/parser": "^7.23.5",
"postcss": "^8.4.14",
"source-map": "^0.6.1"
},
"optionalDependencies": {
"prettier": "^1.18.2 || ^2.0.0"
}
},
"node_modules/@types/splitpanes/node_modules/vue": {
"version": "2.7.16",
"resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz",
"integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==",
"deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-sfc": "2.7.16",
"csstype": "^3.1.0"
}
},
"node_modules/@types/sqlite3": { "node_modules/@types/sqlite3": {
"version": "3.1.11", "version": "3.1.11",
"resolved": "https://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.11.tgz", "resolved": "https://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.11.tgz",
@@ -4434,6 +4479,23 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/prettier": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
"dev": true,
"license": "MIT",
"optional": true,
"bin": {
"prettier": "bin-prettier.js"
},
"engines": {
"node": ">=10.13.0"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -5144,6 +5206,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/splitpanes": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/splitpanes/-/splitpanes-4.0.3.tgz",
"integrity": "sha512-S/f1CoH2JroOib7kzQtTQNtQCa7VzNQ2qKOO5HNj/5EVVcNkfz1eX/sH+X3XKdBdDLihEKDekVGwrLADd2oirA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antoniandre"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/sprintf-js": { "node_modules/sprintf-js": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -6274,11 +6348,13 @@
"name": "@nexus-terminal/frontend", "name": "@nexus-terminal/frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"axios": "^1.8.4", "axios": "^1.8.4",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0", "pinia-plugin-persistedstate": "^4.2.0",
"splitpanes": "^4.0.3",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"vue": "^3.3.0", "vue": "^3.3.0",
"vue-i18n": "^9.14.4", "vue-i18n": "^9.14.4",
@@ -6288,6 +6364,7 @@
"xterm-addon-web-links": "^0.9.0" "xterm-addon-web-links": "^0.9.0"
}, },
"devDependencies": { "devDependencies": {
"@types/splitpanes": "^2.2.6",
"@vitejs/plugin-vue": "^4.2.0", "@vitejs/plugin-vue": "^4.2.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^4.4.0", "vite": "^4.4.0",
@@ -58,7 +58,7 @@ export class StatusMonitorService {
return; return;
} }
//console.warn(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${interval}ms`); //console.warn(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${interval}ms`);
this.fetchAndSendServerStatus(sessionId); // 立即执行一次 // 移除立即执行,让 setInterval 负责第一次调用,给连接更多准备时间
state.statusIntervalId = setInterval(() => { state.statusIntervalId = setInterval(() => {
this.fetchAndSendServerStatus(sessionId); this.fetchAndSendServerStatus(sessionId);
}, interval); }, interval);
@@ -118,11 +118,31 @@ export class StatusMonitorService {
status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown'); status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown');
} catch (err) { console.warn(`[StatusMonitor ${sessionId}] Failed to get OS name:`, err); } } catch (err) { console.warn(`[StatusMonitor ${sessionId}] Failed to get OS name:`, err); }
// --- CPU Model --- // --- CPU Model (Try /proc/cpuinfo first, fallback to lscpu) ---
try { try {
const lscpuOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'"); let cpuModelOutput = '';
status.cpuModel = lscpuOutput.match(/Model name:\s+(.*)/)?.[1].trim() ?? 'Unknown'; try {
} catch (err) { console.warn(`[StatusMonitor ${sessionId}] Failed to get CPU model:`, err); } // Try /proc/cpuinfo first, common on many systems including Alpine
cpuModelOutput = await this.executeSshCommand(sshClient, "cat /proc/cpuinfo | grep 'model name' | head -n 1");
status.cpuModel = cpuModelOutput.match(/model name\s*:\s*(.*)/i)?.[1].trim();
} catch (procErr) {
console.warn(`[StatusMonitor ${sessionId}] Failed to get CPU model from /proc/cpuinfo, trying lscpu...`, procErr);
// Fallback to lscpu if /proc/cpuinfo fails
try {
cpuModelOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'");
status.cpuModel = cpuModelOutput.match(/Model name:\s+(.*)/)?.[1].trim();
} catch (lscpuErr) {
console.warn(`[StatusMonitor ${sessionId}] Failed to get CPU model from lscpu as well:`, lscpuErr);
}
}
// If still no model found after both attempts
if (!status.cpuModel) {
status.cpuModel = 'Unknown';
}
} catch (err) { // Catch any unexpected error during the process
console.warn(`[StatusMonitor ${sessionId}] Error getting CPU model:`, err);
status.cpuModel = 'Unknown';
}
// --- Memory and Swap --- // --- Memory and Swap ---
try { try {
@@ -154,18 +174,25 @@ export class StatusMonitorService {
} else { status.swapTotal = 0; status.swapUsed = 0; status.swapPercent = 0; } } else { status.swapTotal = 0; status.swapUsed = 0; status.swapPercent = 0; }
} catch (err) { console.warn(`[StatusMonitor ${sessionId}] Failed to get memory/swap usage:`, err); } } catch (err) { console.warn(`[StatusMonitor ${sessionId}] Failed to get memory/swap usage:`, err); }
// --- Disk Usage (Root Partition) --- // --- Disk Usage (Root Partition, POSIX format for compatibility) ---
try { try {
const dfOutput = await this.executeSshCommand(sshClient, "df -k / | tail -n 1"); // 使用 df -kP / 获取 POSIX 标准格式输出,更稳定
const parts = dfOutput.split(/\s+/); const dfOutput = await this.executeSshCommand(sshClient, "df -kP /");
const lines = dfOutput.split('\n');
if (lines.length >= 2) {
const parts = lines[1].split(/\s+/); // 解析第二行 (数据行)
// POSIX 格式: Filesystem 1024-blocks Used Available Capacity Mounted on
// parts[1]=Total(KB), parts[2]=Used(KB), parts[4]=Capacity(%)
if (parts.length >= 5) { if (parts.length >= 5) {
const total = parseInt(parts[1], 10); const used = parseInt(parts[2], 10); const total = parseInt(parts[1], 10);
const used = parseInt(parts[2], 10);
const percentMatch = parts[4].match(/(\d+)%/); const percentMatch = parts[4].match(/(\d+)%/);
if (!isNaN(total) && !isNaN(used) && percentMatch) { if (!isNaN(total) && !isNaN(used) && percentMatch) {
status.diskTotal = total; status.diskUsed = used; status.diskTotal = total; status.diskUsed = used;
status.diskPercent = parseFloat(percentMatch[1]); status.diskPercent = parseFloat(percentMatch[1]);
} }
} }
}
} catch (err) { console.warn(`[StatusMonitor ${sessionId}] Failed to get disk usage:`, err); } } catch (err) { console.warn(`[StatusMonitor ${sessionId}] Failed to get disk usage:`, err); }
// --- CPU Usage (Simplified from top) --- // --- CPU Usage (Simplified from top) ---
+3
View File
@@ -8,11 +8,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"axios": "^1.8.4", "axios": "^1.8.4",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0", "pinia-plugin-persistedstate": "^4.2.0",
"splitpanes": "^4.0.3",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"vue": "^3.3.0", "vue": "^3.3.0",
"vue-i18n": "^9.14.4", "vue-i18n": "^9.14.4",
@@ -22,6 +24,7 @@
"xterm-addon-web-links": "^0.9.0" "xterm-addon-web-links": "^0.9.0"
}, },
"devDependencies": { "devDependencies": {
"@types/splitpanes": "^2.2.6",
"@vitejs/plugin-vue": "^4.2.0", "@vitejs/plugin-vue": "^4.2.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^4.4.0", "vite": "^4.4.0",
+8 -3
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'; import { RouterLink, RouterView } from 'vue-router';
import { useI18n } from 'vue-i18n'; // useI18n import { useI18n } from 'vue-i18n';
import { useAuthStore } from './stores/auth.store'; // Auth Store import { useAuthStore } from './stores/auth.store';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
//
import UINotificationDisplay from './components/UINotificationDisplay.vue';
const { t } = useI18n(); // t const { t } = useI18n();
const authStore = useAuthStore(); const authStore = useAuthStore();
const { isAuthenticated } = storeToRefs(authStore); // const { isAuthenticated } = storeToRefs(authStore); //
@@ -34,6 +36,9 @@ const handleLogout = () => {
<RouterView /> <!-- 路由对应的组件将在这里渲染 --> <RouterView /> <!-- 路由对应的组件将在这里渲染 -->
</main> </main>
<!-- 添加全局通知显示 -->
<UINotificationDisplay />
<footer> <footer>
<!-- 使用 t 函数获取应用名称 --> <!-- 使用 t 函数获取应用名称 -->
<p>&copy; 2025 {{ t('appName') }}</p> <p>&copy; 2025 {{ t('appName') }}</p>
@@ -527,7 +527,7 @@ watchEffect((onCleanup) => {
if (message.requestId === requestId && payload.requestedPath === requestedPath) { if (message.requestId === requestId && payload.requestedPath === requestedPath) {
const absolutePath = payload.absolutePath; const absolutePath = payload.absolutePath;
console.log(`[FileManager ${props.sessionId}] 收到 '.' 的绝对路径: ${absolutePath}。开始加载目录。`); console.log(`[FileManager ${props.sessionId}] 收到 '.' 的绝对路径: ${absolutePath}。开始加载目录。`);
currentPath.value = absolutePath; // currentPath.value loadDirectory
loadDirectory(absolutePath); // 使 props loadDirectory loadDirectory(absolutePath); // 使 props loadDirectory
initialLoadDone.value = true; initialLoadDone.value = true;
cleanupListeners(); cleanupListeners();
@@ -700,11 +700,8 @@ const clearError = () => {
@dragleave.prevent="handleDragLeave" @dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop" @drop.prevent="handleDrop"
> >
<!-- Error Alert Box --> <!-- 移除内联错误提示框 -->
<div v-if="error" class="error-alert"> <!-- <div v-if="error" class="error-alert"> ... </div> -->
<span>{{ error }}</span>
<button @click="clearError" class="close-error-btn" :title="t('common.dismiss')">&times;</button> <!-- Use clearSftpError -->
</div>
<!-- 1. Initial Loading Indicator --> <!-- 1. Initial Loading Indicator -->
<div v-if="isLoading && !initialLoadDone" class="loading">{{ t('fileManager.loading') }}</div> <div v-if="isLoading && !initialLoadDone" class="loading">{{ t('fileManager.loading') }}</div>
@@ -851,28 +848,9 @@ const clearError = () => {
.upload-popup .error { color: red; margin-left: 0.5rem; flex-basis: 100%; font-size: 0.8em; } .upload-popup .error { color: red; margin-left: 0.5rem; flex-basis: 100%; font-size: 0.8em; }
.upload-popup .cancel-btn { margin-left: auto; padding: 0.1rem 0.4rem; font-size: 0.8em; background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; cursor: pointer; } .upload-popup .cancel-btn { margin-left: auto; padding: 0.1rem 0.4rem; font-size: 0.8em; background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; cursor: pointer; }
.loading, .no-files { padding: 1rem; text-align: center; color: #666; } .loading, .no-files { padding: 1rem; text-align: center; color: #666; }
/* Removed .error style for the main container */ /* 移除 .error-alert 和 .close-error-btn 样式 */
.error-alert { /* .error-alert { ... } */
background-color: #f8d7da; /* .close-error-btn { ... } */
color: #721c24;
border: 1px solid #f5c6cb;
padding: 0.75rem 1.25rem;
margin: 0.5rem;
border-radius: 0.25rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.close-error-btn {
background: none;
border: none;
color: inherit;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
padding: 0 0.5rem;
line-height: 1;
}
.file-list-container { flex-grow: 1; overflow-y: auto; position: relative; /* Needed for overlay */ } .file-list-container { flex-grow: 1; overflow-y: auto; position: relative; /* Needed for overlay */ }
.file-list-container.drag-over { .file-list-container.drag-over {
outline: 2px dashed #007bff; /* Blue dashed outline */ outline: 2px dashed #007bff; /* Blue dashed outline */
@@ -1,10 +1,15 @@
<template> <template>
<div class="status-monitor"> <div class="status-monitor">
<h4>服务器状态</h4> <h4>服务器状态</h4>
<div v-if="!serverStatus" class="loading-status"> <!-- Corrected state display logic -->
<div v-if="statusError" class="status-error">
错误: {{ statusError }}
</div>
<div v-else-if="!serverStatus" class="loading-status">
等待数据... 等待数据...
</div> </div>
<div v-else class="status-grid"> <div v-else class="status-grid">
<!-- Status items remain here -->
<div class="status-item cpu-model"> <div class="status-item cpu-model">
<label>CPU 型号:</label> <label>CPU 型号:</label>
<!-- 使用 displayCpuModel 计算属性 --> <!-- 使用 displayCpuModel 计算属性 -->
@@ -18,43 +23,54 @@
</div> </div>
<div class="status-item"> <div class="status-item">
<label>CPU:</label> <label>CPU:</label>
<!-- Wrap progress bar and percentage in a div -->
<div class="value-wrapper">
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${serverStatus.cpuPercent ?? 0}%` }"></div> <div class="progress-bar" :style="{ width: `${serverStatus.cpuPercent ?? 0}%` }"></div>
</div> </div>
<span>{{ serverStatus.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span> <span>{{ serverStatus.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span>
</div> </div>
</div>
<div class="status-item"> <div class="status-item">
<label>内存:</label> <label>内存:</label>
<div class="value-wrapper">
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${serverStatus.memPercent ?? 0}%` }"></div> <div class="progress-bar" :style="{ width: `${serverStatus.memPercent ?? 0}%` }"></div>
</div> </div>
<span class="mem-disk-details">{{ memDisplay }}</span> <span class="mem-disk-details">{{ memDisplay }}</span>
</div> </div>
</div>
<!-- Removed v-if, Swap will always show --> <!-- Removed v-if, Swap will always show -->
<div class="status-item"> <div class="status-item">
<label>Swap:</label> <label>Swap:</label>
<div class="value-wrapper">
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar swap-bar" :style="{ width: `${serverStatus.swapPercent ?? 0}%` }"></div> <div class="progress-bar swap-bar" :style="{ width: `${serverStatus.swapPercent ?? 0}%` }"></div>
</div> </div>
<span class="mem-disk-details">{{ swapDisplay }}</span> <span class="mem-disk-details">{{ swapDisplay }}</span>
</div> </div>
</div>
<div class="status-item"> <div class="status-item">
<label>磁盘 (/):</label> <label>磁盘:</label> <!-- 移除 (/) -->
<div class="value-wrapper">
<div class="progress-bar-container"> <div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${serverStatus.diskPercent ?? 0}%` }"></div> <div class="progress-bar" :style="{ width: `${serverStatus.diskPercent ?? 0}%` }"></div>
</div> </div>
<span class="mem-disk-details">{{ diskDisplay }}</span> <span class="mem-disk-details">{{ diskDisplay }}</span>
</div> </div>
</div>
<div class="status-item network-rate"> <div class="status-item network-rate">
<label>网络 ({{ serverStatus.netInterface || '...' }}):</label> <label>网络 ({{ serverStatus.netInterface || '...' }}):</label>
<span class="rate down"> {{ formatBytesPerSecond(serverStatus.netRxRate) }}</span> <!-- Wrap rates in a div for alignment -->
<span class="rate up"> {{ formatBytesPerSecond(serverStatus.netTxRate) }}</span> <div class="value-wrapper network-values">
<span class="rate down">{{ formatBytesPerSecond(serverStatus.netRxRate) }}</span>
<span class="rate up">{{ formatBytesPerSecond(serverStatus.netTxRate) }}</span>
</div> </div>
</div> </div>
<div v-if="statusError" class="status-error">
错误: {{ statusError }}
</div> </div>
<!-- Error display moved up for correct v-if/v-else-if logic -->
</div> </div>
<!-- Removed extra closing div -->
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -207,40 +223,48 @@ const swapDisplay = computed(() => {
.status-item { .status-item {
display: grid; display: grid;
/* Adjusted grid columns for better alignment */ /* Simplified grid columns: Label | Value Area - Further increased label width */
grid-template-columns: 65px 1fr auto; /* Label slightly wider */ grid-template-columns: 100px 1fr;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.8rem; /* Keep increased gap */
} }
/* Specific style for CPU model row */ /* New wrapper for value area (progress bar + text or just text) */
.value-wrapper {
display: flex;
align-items: center;
gap: 0.5rem; /* Space between progress bar and text */
}
/* Specific style for CPU model row - Keep consistent with general status-item */
.status-item.cpu-model { .status-item.cpu-model {
grid-template-columns: 65px 1fr; /* Label, Value */ /* grid-template-columns is inherited */
gap: 0.5rem; /* gap is inherited */
margin-bottom: 0.5rem; /* Add some space below CPU model */ margin-bottom: 0.5rem; /* Add some space below CPU model */
} }
.cpu-model-value { .cpu-model-value {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
grid-column: 2 / 4; /* Span across the value and percentage columns */ /* No longer needs grid-column span */
text-align: left; text-align: left;
color: #333; color: #333;
} }
/* Specific style for OS name row */ /* Specific style for OS name row - Keep consistent with general status-item */
.status-item.os-name { .status-item.os-name {
grid-template-columns: 65px 1fr; /* Label, Value */ /* grid-template-columns is inherited */
/* Ensure the item itself doesn't align right if the parent has text-align */ /* Ensure the item itself doesn't align right if the parent has text-align */
text-align: left; text-align: left;
} }
/* Increased specificity to override generic span rule */ /* Increased specificity to override generic span rule */
/* OS name value should just occupy the second column */
.status-item.os-name .os-name-value { .status-item.os-name .os-name-value {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-align: left; /* Explicitly left align text */ text-align: left; /* Explicitly left align text */
justify-self: start; /* Align grid item to start */ /* justify-self: start; No longer needed with 2-col grid */
color: #333; color: #333;
min-width: auto; /* Override generic min-width */ min-width: auto; /* Override generic min-width */
} }
@@ -249,7 +273,7 @@ const swapDisplay = computed(() => {
.status-item label { .status-item label {
font-weight: bold; font-weight: bold;
color: #555; color: #555;
text-align: right; text-align: left; /* 改为左对齐 */
white-space: nowrap; white-space: nowrap;
} }
@@ -278,32 +302,59 @@ const swapDisplay = computed(() => {
.status-item span:not(.cpu-model-value) { /* Style for percentage spans */ .status-item span:not(.cpu-model-value) { /* Style for percentage spans */
font-variant-numeric: tabular-nums; /* Keep numbers aligned */ font-variant-numeric: tabular-nums; /* Keep numbers aligned */
min-width: 45px; /* Ensure space for percentage */ min-width: 45px; /* Ensure space for percentage */
text-align: right; text-align: left; /* 改为左对齐 */
color: #333; color: #333;
} }
.mem-disk-details { .mem-disk-details {
font-size: 0.9em; /* Slightly smaller font for details */ font-size: 0.9em; /* Slightly smaller font for details */
white-space: nowrap; white-space: nowrap;
text-align: right; text-align: left; /* 改为左对齐 */
} }
/* Network Rate Styles */ /* Network Rate Styles */
/* Network Rate Styles - uses the 2-col grid */
.status-item.network-rate { .status-item.network-rate {
grid-template-columns: 65px auto auto; /* Label, Down Rate, Up Rate */ /* grid-template-columns is inherited */
margin-top: 0.5rem; /* Add space above network */ margin-top: 0.5rem; /* Add space above network */
align-items: center; /* Try centering label and rates vertically */
}
/* Adjust network value wrapper */
.network-values {
justify-content: start; /* Align rates to the start */
gap: 1rem; /* Increase gap between rates */
/* Removed margin-left, rely on grid gap */
/* Ensure the wrapper itself aligns correctly if needed */
/* align-self: center; */ /* Or baseline */
} }
.network-rate .rate { .network-rate .rate {
font-size: 0.9em; font-size: 0.9em;
white-space: nowrap; white-space: nowrap;
text-align: right; text-align: left; /* 改为左对齐 */
min-width: 80px; /* Adjust as needed */ min-width: auto; /* Remove min-width or adjust */
/* Rely on parent flexbox for alignment */
display: inline-flex; /* Ensure pseudo-element is part of flex flow */
align-items: center; /* Vertically align arrow with text */
gap: 0.3em; /* Add space between arrow and text */
} }
.network-rate .rate.down { .network-rate .rate.down {
color: #28a745; /* Green for download */ color: #28a745; /* Green for download */
} }
.network-rate .rate.down::before {
content: '⬇';
/* Removed absolute positioning */
font-size: 1em; /* Match parent font size */
line-height: 1; /* Adjust line-height for better vertical alignment */
}
.network-rate .rate.up { .network-rate .rate.up {
color: #fd7e14; /* Orange for upload */ color: #fd7e14; /* Orange for upload */
} }
.network-rate .rate.up::before {
content: '⬆';
/* Removed absolute positioning */
font-size: 1em; /* Match parent font size */
line-height: 1; /* Adjust line-height for better vertical alignment */
}
</style> </style>
+13 -4
View File
@@ -118,11 +118,20 @@ onMounted(() => {
// isActive prop fit resize // isActive prop fit resize
watch(() => props.isActive, (newValue) => { watch(() => props.isActive, (newValue) => {
if (newValue && terminal && terminalRef.value && terminalRef.value.offsetHeight > 0) { if (newValue && terminal && terminalRef.value) {
console.log(`[Terminal ${props.sessionId}] Tab became active, performing immediate fit and resize.`); // DOM fit
// 使 nextTick DOM console.log(`[Terminal ${props.sessionId}] 标签变为活动状态,准备调整尺寸。`); //
nextTick(() => { nextTick(() => {
fitAndEmitResizeNow(terminal!); //
setTimeout(() => {
//
if (terminal && terminalRef.value && terminalRef.value.offsetHeight > 0) {
console.log(`[Terminal ${props.sessionId}] 执行延时后的 fit 和 resize。`); //
fitAndEmitResizeNow(terminal);
} else {
console.log(`[Terminal ${props.sessionId}] 延时后检查:终端不可见或已销毁,跳过 fit。`); //
}
}, 50); // 50ms
}); });
} }
}); });
@@ -1,16 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { PropType } from 'vue'; import { PropType } from 'vue';
//
// () import type { SessionTabInfoWithStatus } from '../stores/session.store'; //
interface SessionTabInfo {
sessionId: string;
connectionName: string; //
}
// Props // Props
const props = defineProps({ const props = defineProps({
sessions: { sessions: {
type: Array as PropType<SessionTabInfo[]>, type: Array as PropType<SessionTabInfoWithStatus[]>, // 使
required: true, required: true,
}, },
activeSessionId: { activeSessionId: {
@@ -44,6 +40,8 @@ const closeSession = (event: MouseEvent, sessionId: string) => {
@click="activateSession(session.sessionId)" @click="activateSession(session.sessionId)"
:title="session.connectionName" :title="session.connectionName"
> >
<!-- 添加状态点 -->
<span :class="['status-dot', `status-${session.status}`]"></span>
<span class="tab-name">{{ session.connectionName }}</span> <span class="tab-name">{{ session.connectionName }}</span>
<button class="close-tab-button" @click="closeSession($event, session.sessionId)" title="关闭标签页"> <button class="close-tab-button" @click="closeSession($event, session.sessionId)" title="关闭标签页">
&times; <!-- 使用 HTML 实体 '×' --> &times; <!-- 使用 HTML 实体 '×' -->
@@ -67,6 +65,22 @@ const closeSession = (event: MouseEvent, sessionId: string) => {
box-sizing: border-box; /* 确保 padding 不会增加总高度 */ box-sizing: border-box; /* 确保 padding 不会增加总高度 */
} }
/* 状态点样式 */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
display: inline-block;
vertical-align: middle;
flex-shrink: 0; /* 防止被压缩 */
}
.status-dot.status-disconnected { background-color: #dc3545; } /* 红色 */
.status-dot.status-connecting { background-color: #ffc107; } /* 黄色 */
.status-dot.status-connected { background-color: #28a745; } /* 绿色 */
.status-dot.status-error { background-color: #6c757d; } /* 灰色 (或其他表示错误的颜色) */
.tab-list { .tab-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -105,8 +119,10 @@ const closeSession = (event: MouseEvent, sessionId: string) => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
margin-right: 1.5rem; /* 为关闭按钮留出足够空间 */ /* margin-right: 1.5rem; */ /* 调整右边距,因为关闭按钮现在是 flex item */
line-height: normal; /* 默认行高 */ line-height: normal; /* 默认行高 */
flex-grow: 1; /* 允许名称伸展 */
margin-left: 4px; /* 在状态点和名称之间添加一点间距 */
} }
.close-tab-button { .close-tab-button {
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import { storeToRefs } from 'pinia';
const notificationsStore = useUiNotificationsStore();
const { notifications } = storeToRefs(notificationsStore);
const getIconClass = (type: string) => {
switch (type) {
case 'success': return 'fas fa-check-circle';
case 'error': return 'fas fa-times-circle';
case 'info': return 'fas fa-info-circle';
case 'warning': return 'fas fa-exclamation-triangle';
default: return '';
}
};
const getContainerClass = (type: string) => {
return `notification-item notification-${type}`;
};
</script>
<template>
<div class="notification-container">
<transition-group name="notification-fade" tag="div">
<div
v-for="notification in notifications"
:key="notification.id"
:class="getContainerClass(notification.type)"
>
<i :class="['notification-icon', getIconClass(notification.type)]"></i>
<span class="notification-message">{{ notification.message }}</span>
<button
class="notification-close-btn"
@click="notificationsStore.removeNotification(notification.id)"
>
&times;
</button>
</div>
</transition-group>
</div>
</template>
<style scoped>
.notification-container {
position: fixed;
top: 1rem; /* 距离顶部 */
right: 1rem; /* 距离右侧 */
z-index: 1100; /* 比其他元素层级高 */
display: flex;
flex-direction: column;
align-items: flex-end; /* 从右侧对齐 */
}
.notification-item {
background-color: #fff;
color: #fff;
padding: 0.8rem 1.2rem;
margin-bottom: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
min-width: 250px; /* 最小宽度 */
max-width: 400px; /* 最大宽度 */
opacity: 0.95;
transition: all 0.5s ease; /* 添加过渡效果 */
}
.notification-success { background-color: #28a745; } /* 绿色 */
.notification-error { background-color: #dc3545; } /* 红色 */
.notification-info { background-color: #17a2b8; } /* 蓝色 */
.notification-warning { background-color: #ffc107; color: #333; } /* 黄色,文字用深色 */
.notification-icon {
margin-right: 0.8rem;
font-size: 1.2em;
}
.notification-message {
flex-grow: 1;
word-wrap: break-word; /* 允许长单词换行 */
}
.notification-close-btn {
background: none;
border: none;
color: inherit; /* 继承父元素颜色 */
opacity: 0.7;
cursor: pointer;
font-size: 1.2em;
margin-left: 1rem;
padding: 0 0.3rem;
}
.notification-close-btn:hover {
opacity: 1;
}
/* 过渡动画 */
.notification-fade-enter-active,
.notification-fade-leave-active {
transition: all 0.5s ease;
}
.notification-fade-enter-from,
.notification-fade-leave-to {
opacity: 0;
transform: translateX(30px); /* 从右侧滑入 */
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue'; // ref
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
// import { useRouter } from 'vue-router'; // router // import { useRouter } from 'vue-router'; // router
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -22,6 +22,9 @@ const tagsStore = useTagsStore();
const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore); const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore);
const { tags, isLoading: tagsLoading, error: tagsError } = storeToRefs(tagsStore); const { tags, isLoading: tagsLoading, error: tagsError } = storeToRefs(tagsStore);
//
const searchTerm = ref('');
// //
const contextMenuVisible = ref(false); const contextMenuVisible = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 }); const contextMenuPosition = ref({ x: 0, y: 0 });
@@ -30,38 +33,59 @@ const contextTargetConnection = ref<ConnectionInfo | null>(null);
// //
const expandedGroups = ref<Record<string, boolean>>({}); // 使 Record<string, boolean> const expandedGroups = ref<Record<string, boolean>>({}); // 使 Record<string, boolean>
// //
const groupedConnections = computed(() => { const filteredAndGroupedConnections = computed(() => {
const groups: Record<string, ConnectionInfo[]> = {}; const groups: Record<string, ConnectionInfo[]> = {};
const untagged: ConnectionInfo[] = []; const untagged: ConnectionInfo[] = [];
const tagMap = new Map(tags.value.map(tag => [tag.id, tag])); const tagMap = new Map(tags.value.map(tag => [tag.id, tag]));
const lowerSearchTerm = searchTerm.value.toLowerCase();
connections.value.forEach(conn => { // 1.
const filteredConnections = connections.value.filter(conn => {
const nameMatch = conn.name && conn.name.toLowerCase().includes(lowerSearchTerm);
const hostMatch = conn.host.toLowerCase().includes(lowerSearchTerm);
// IP
// const ipMatch = conn.ipAddress && conn.ipAddress.toLowerCase().includes(lowerSearchTerm);
return nameMatch || hostMatch; // || ipMatch;
});
// 2.
filteredConnections.forEach(conn => {
if (conn.tag_ids && conn.tag_ids.length > 0) { if (conn.tag_ids && conn.tag_ids.length > 0) {
let tagged = false; //
conn.tag_ids.forEach(tagId => { conn.tag_ids.forEach(tagId => {
const tag = tagMap.get(tagId); const tag = tagMap.get(tagId);
const groupName = tag ? tag.name : t('workspaceConnectionList.untagged'); // Fallback if tag not found //
if (tag) {
const groupName = tag.name;
if (!groups[groupName]) { if (!groups[groupName]) {
groups[groupName] = []; groups[groupName] = [];
if (expandedGroups.value[groupName] === undefined) { if (expandedGroups.value[groupName] === undefined) {
expandedGroups.value[groupName] = true; // expandedGroups.value[groupName] = true; //
} }
} }
//
if (!groups[groupName].some(c => c.id === conn.id)) {
groups[groupName].push(conn); groups[groupName].push(conn);
}
tagged = true;
}
}); });
//
if (!tagged) {
untagged.push(conn);
}
} else { } else {
untagged.push(conn); untagged.push(conn);
} }
}); });
// // 3.
for (const groupName in groups) { for (const groupName in groups) {
groups[groupName].sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host)); groups[groupName].sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
} }
untagged.sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host)); untagged.sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
//
const sortedGroupNames = Object.keys(groups).sort(); const sortedGroupNames = Object.keys(groups).sort();
const result: { groupName: string; connections: ConnectionInfo[] }[] = sortedGroupNames.map(name => ({ const result: { groupName: string; connections: ConnectionInfo[] }[] = sortedGroupNames.map(name => ({
groupName: name, groupName: name,
@@ -150,21 +174,47 @@ const handleOpenInNewTab = (connectionId: number) => {
</div> </div>
<div v-else-if="connections.length === 0" class="no-connections"> <div v-else-if="connections.length === 0" class="no-connections">
{{ t('connections.noConnections') }} {{ t('connections.noConnections') }}
<button @click="handleMenuAction('add')">{{ t('connections.addConnection') }}</button> <!-- 保留添加按钮即使列表为空 -->
<!-- <button @click="handleMenuAction('add')">{{ t('connections.addConnection') }}</button> -->
</div>
<!-- 搜索和添加栏 -->
<div class="search-add-bar">
<input
type="text"
v-model="searchTerm"
:placeholder="t('workspaceConnectionList.searchPlaceholder')"
class="search-input"
/>
<button class="add-button" @click="handleMenuAction('add')" :title="t('connections.addConnection')">
<i class="fas fa-plus"></i>
</button>
</div>
<!-- 连接列表区域 -->
<div class="connection-list-area">
<div v-if="connectionsLoading || tagsLoading" class="loading">
{{ t('common.loading') }}
</div>
<div v-else-if="connectionsError || tagsError" class="error">
{{ connectionsError || tagsError }}
</div>
<div v-else-if="filteredAndGroupedConnections.length === 0 && connections.length > 0" class="no-results">
{{ t('workspaceConnectionList.noResults') }} "{{ searchTerm }}"
</div>
<div v-else-if="connections.length === 0" class="no-connections">
{{ t('connections.noConnections') }}
</div> </div>
<div v-else> <div v-else>
<!-- 添加连接按钮总是在顶部 --> <!-- 修正: 循环 filteredAndGroupedConnections -->
<button class="add-connection-button" @click="handleMenuAction('add')"> <div v-for="groupData in filteredAndGroupedConnections" :key="groupData.groupName" class="connection-group">
<i class="fas fa-plus"></i> {{ t('connections.addConnection') }} <div class="group-header" @click="toggleGroup(groupData.groupName)">
</button> <i :class="['fas', expandedGroups[groupData.groupName] ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
<div v-for="group in groupedConnections" :key="group.groupName" class="connection-group"> <span>{{ groupData.groupName }}</span>
<div class="group-header" @click="toggleGroup(group.groupName)">
<i :class="['fas', expandedGroups[group.groupName] ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
<span>{{ group.groupName }}</span>
</div> </div>
<ul v-show="expandedGroups[group.groupName]" class="connection-items"> <!-- 修正: 使用 groupData.groupName groupData.connections -->
<ul v-show="expandedGroups[groupData.groupName]" class="connection-items">
<li <li
v-for="conn in group.connections" v-for="conn in groupData.connections"
:key="conn.id" :key="conn.id"
class="connection-item" class="connection-item"
@click.left="handleConnect(conn.id)" @click.left="handleConnect(conn.id)"
@@ -179,6 +229,8 @@ const handleOpenInNewTab = (connectionId: number) => {
</li> </li>
</ul> </ul>
</div> </div>
<!-- 移除重复的 ul -->
</div>
</div> </div>
<!-- 右键菜单 --> <!-- 右键菜单 -->
@@ -200,14 +252,58 @@ const handleOpenInNewTab = (connectionId: number) => {
<style scoped> <style scoped>
.workspace-connection-list { .workspace-connection-list {
padding: 0.5rem 0;
height: 100%; height: 100%;
overflow-y: auto; display: flex;
background-color: #f8f9fa; /* Slightly different background */ flex-direction: column;
overflow: hidden; /* 防止内部滚动条影响布局 */
background-color: #f8f9fa;
font-size: 0.9em; font-size: 0.9em;
} }
.loading, .error, .no-connections { .search-add-bar {
display: flex;
padding: 0.5rem;
border-bottom: 1px solid #dee2e6;
background-color: #e9ecef; /* 给搜索栏一个背景色 */
}
.search-input {
flex-grow: 1;
padding: 0.4rem 0.6rem;
border: 1px solid #ced4da;
border-radius: 4px 0 0 4px; /* 左侧圆角 */
font-size: 0.9em;
outline: none;
}
.search-input:focus {
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.add-button {
padding: 0.4rem 0.8rem;
border: 1px solid #ced4da;
border-left: none; /* 移除左边框,与输入框合并 */
background-color: #f8f9fa;
cursor: pointer;
border-radius: 0 4px 4px 0; /* 右侧圆角 */
color: #495057;
}
.add-button:hover {
background-color: #e2e6ea;
}
.add-button i {
font-size: 1em; /* 图标大小 */
}
.connection-list-area {
flex-grow: 1; /* 占据剩余空间 */
overflow-y: auto; /* 列表内容滚动 */
padding: 0.5rem 0;
}
.loading, .error, .no-connections, .no-results {
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
color: #6c757d; color: #6c757d;
@@ -1,6 +1,8 @@
import { ref, readonly, type Ref, type ComputedRef } from 'vue'; // Removed onUnmounted, added ComputedRef import { ref, readonly, type Ref, type ComputedRef } from 'vue';
import type { FileListItem, EditorFileContent } from '../types/sftp.types'; import type { FileListItem, EditorFileContent } from '../types/sftp.types';
import type { WebSocketMessage, MessagePayload, MessageHandler } from '../types/websocket.types'; // 从类型文件导入 import type { WebSocketMessage, MessagePayload, MessageHandler } from '../types/websocket.types';
// 导入 UI 通知 store
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // 更正导入
/** /**
* @interface WebSocketDependencies * @interface WebSocketDependencies
@@ -49,8 +51,9 @@ export function createSftpActionsManager(
const fileList = ref<FileListItem[]>([]); const fileList = ref<FileListItem[]>([]);
const isLoading = ref<boolean>(false); const isLoading = ref<boolean>(false);
const error = ref<string | null>(null); // const error = ref<string | null>(null); // 不再使用本地 error ref
const instanceSessionId = sessionId; // 保存会话 ID 用于日志 const instanceSessionId = sessionId; // 保存会话 ID 用于日志
const uiNotificationsStore = useUiNotificationsStore(); // 初始化 UI 通知 store
// 用于存储注销函数的数组 // 用于存储注销函数的数组
const unregisterCallbacks: (() => void)[] = []; const unregisterCallbacks: (() => void)[] = [];
@@ -62,25 +65,24 @@ export function createSftpActionsManager(
unregisterCallbacks.length = 0; // 清空数组 unregisterCallbacks.length = 0; // 清空数组
}; };
// 清除错误状态的函数 // 不再需要 clearSftpError 函数
const clearSftpError = () => { // const clearSftpError = () => { ... };
error.value = null;
};
// --- Action Methods --- // --- Action Methods ---
const loadDirectory = (path: string) => { const loadDirectory = (path: string) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); // 使用通知 store 显示错误
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
isLoading.value = false; isLoading.value = false;
fileList.value = []; fileList.value = [];
console.warn(`[SFTP ${instanceSessionId}] Attempted to load directory ${path} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] 尝试加载目录 ${path} SFTP 未就绪。`); // 日志改为中文
return; return;
} }
console.log(`[SFTP ${instanceSessionId}] Loading directory: ${path}`); console.log(`[SFTP ${instanceSessionId}] 正在加载目录: ${path}`); // 日志改为中文
isLoading.value = true; isLoading.value = true;
error.value = null; // error.value = null; // 不再需要
currentPathRef.value = path; // 更新外部 ref currentPathRef.value = path; // 更新外部 ref
const requestId = generateRequestId(); const requestId = generateRequestId();
sendMessage({ type: 'sftp:readdir', requestId: requestId, payload: { path } }); sendMessage({ type: 'sftp:readdir', requestId: requestId, payload: { path } });
@@ -88,8 +90,8 @@ export function createSftpActionsManager(
const createDirectory = (newDirName: string) => { const createDirectory = (newDirName: string) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
console.warn(`[SFTP ${instanceSessionId}] Attempted to create directory ${newDirName} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] 尝试创建目录 ${newDirName} SFTP 未就绪。`); // 日志改为中文
return; return;
} }
const newFolderPath = joinPath(currentPathRef.value, newDirName); const newFolderPath = joinPath(currentPathRef.value, newDirName);
@@ -99,8 +101,8 @@ export function createSftpActionsManager(
const createFile = (newFileName: string) => { const createFile = (newFileName: string) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
console.warn(`[SFTP ${instanceSessionId}] Attempted to create file ${newFileName} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] 尝试创建文件 ${newFileName} SFTP 未就绪。`); // 日志改为中文
return; return;
} }
const newFilePath = joinPath(currentPathRef.value, newFileName); const newFilePath = joinPath(currentPathRef.value, newFileName);
@@ -114,8 +116,8 @@ export function createSftpActionsManager(
const deleteItems = (items: FileListItem[]) => { const deleteItems = (items: FileListItem[]) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
console.warn(`[SFTP ${instanceSessionId}] Attempted to delete items but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] 尝试删除项目但 SFTP 未就绪。`); // 日志改为中文
return; return;
} }
if (items.length === 0) return; if (items.length === 0) return;
@@ -129,8 +131,8 @@ export function createSftpActionsManager(
const renameItem = (item: FileListItem, newName: string) => { const renameItem = (item: FileListItem, newName: string) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
console.warn(`[SFTP ${instanceSessionId}] Attempted to rename item ${item.filename} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] 尝试重命名项目 ${item.filename} SFTP 未就绪。`); // 日志改为中文
return; return;
} }
if (!newName || item.filename === newName) return; if (!newName || item.filename === newName) return;
@@ -142,8 +144,8 @@ export function createSftpActionsManager(
const changePermissions = (item: FileListItem, mode: number) => { const changePermissions = (item: FileListItem, mode: number) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
console.warn(`[SFTP ${instanceSessionId}] Attempted to change permissions for ${item.filename} but SFTP is not ready.`); console.warn(`[SFTP ${instanceSessionId}] 尝试修改 ${item.filename} 的权限但 SFTP 未就绪。`); // 日志改为中文
return; return;
} }
const targetPath = joinPath(currentPathRef.value, item.filename); const targetPath = joinPath(currentPathRef.value, item.filename);
@@ -155,8 +157,10 @@ export function createSftpActionsManager(
const readFile = (path: string): Promise<EditorFileContent> => { const readFile = (path: string): Promise<EditorFileContent> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
console.warn(`[SFTP ${instanceSessionId}] Attempted to read file ${path} but SFTP is not ready.`); const errMsg = t('fileManager.errors.sftpNotReady');
return reject(new Error(t('fileManager.errors.sftpNotReady'))); console.warn(`[SFTP ${instanceSessionId}] 尝试读取文件 ${path} 但 SFTP 未就绪。`); // 日志改为中文
uiNotificationsStore.showError(errMsg, { timeout: 5000 }); // 使用 uiNotificationsStore
return reject(new Error(errMsg));
} }
const requestId = generateRequestId(); const requestId = generateRequestId();
let unregisterSuccess: (() => void) | null = null; let unregisterSuccess: (() => void) | null = null;
@@ -165,7 +169,9 @@ export function createSftpActionsManager(
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
unregisterSuccess?.(); unregisterSuccess?.();
unregisterError?.(); unregisterError?.();
reject(new Error(t('fileManager.errors.readFileTimeout'))); const errMsg = t('fileManager.errors.readFileTimeout');
uiNotificationsStore.showError(errMsg, { timeout: 5000 }); // 使用 uiNotificationsStore
reject(new Error(errMsg));
}, 20000); // 20 秒超时 }, 20000); // 20 秒超时
unregisterSuccess = onMessage('sftp:readfile:success', (payload: MessagePayload, message: WebSocketMessage) => { unregisterSuccess = onMessage('sftp:readfile:success', (payload: MessagePayload, message: WebSocketMessage) => {
@@ -186,7 +192,9 @@ export function createSftpActionsManager(
clearTimeout(timeoutId); clearTimeout(timeoutId);
unregisterSuccess?.(); unregisterSuccess?.();
unregisterError?.(); unregisterError?.();
reject(new Error(errorPayload || 'Failed to read file')); const errorMsg = errorPayload || t('fileManager.errors.readFileFailed'); // 使用 i18n
uiNotificationsStore.showError(`${t('fileManager.errors.readFileError')}: ${errorMsg}`, { timeout: 5000 }); // 使用 uiNotificationsStore
reject(new Error(errorMsg));
} }
}); });
@@ -197,8 +205,10 @@ export function createSftpActionsManager(
const writeFile = (path: string, content: string): Promise<void> => { const writeFile = (path: string, content: string): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!isSftpReady.value) { if (!isSftpReady.value) {
console.warn(`[SFTP ${instanceSessionId}] Attempted to write file ${path} but SFTP is not ready.`); const errMsg = t('fileManager.errors.sftpNotReady');
return reject(new Error(t('fileManager.errors.sftpNotReady'))); console.warn(`[SFTP ${instanceSessionId}] 尝试写入文件 ${path} 但 SFTP 未就绪。`); // 日志改为中文
uiNotificationsStore.showError(errMsg, { timeout: 5000 }); // 使用 uiNotificationsStore
return reject(new Error(errMsg));
} }
const requestId = generateRequestId(); const requestId = generateRequestId();
const encoding: 'utf8' | 'base64' = 'utf8'; // 假设总是 utf8 const encoding: 'utf8' | 'base64' = 'utf8'; // 假设总是 utf8
@@ -208,7 +218,9 @@ export function createSftpActionsManager(
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
unregisterSuccess?.(); unregisterSuccess?.();
unregisterError?.(); unregisterError?.();
reject(new Error(t('fileManager.errors.saveTimeout'))); const errMsg = t('fileManager.errors.saveTimeout');
uiNotificationsStore.showError(errMsg, { timeout: 5000 }); // 使用 uiNotificationsStore
reject(new Error(errMsg));
}, 20000); // 20 秒超时 }, 20000); // 20 秒超时
unregisterSuccess = onMessage('sftp:writefile:success', (payload: MessagePayload, message: WebSocketMessage) => { unregisterSuccess = onMessage('sftp:writefile:success', (payload: MessagePayload, message: WebSocketMessage) => {
@@ -227,7 +239,9 @@ export function createSftpActionsManager(
clearTimeout(timeoutId); clearTimeout(timeoutId);
unregisterSuccess?.(); unregisterSuccess?.();
unregisterError?.(); unregisterError?.();
reject(new Error(errorPayload || 'Failed to write file')); const errorMsg = errorPayload || t('fileManager.errors.saveFailed'); // 使用 i18n
uiNotificationsStore.showError(errorMsg, { timeout: 5000 }); // 使用 uiNotificationsStore
reject(new Error(errorMsg));
} }
}); });
@@ -246,12 +260,12 @@ export function createSftpActionsManager(
// 类型断言,因为我们知道 readdir:success 的 payload 是 FileListItem[] // 类型断言,因为我们知道 readdir:success 的 payload 是 FileListItem[]
const fileListPayload = payload as FileListItem[]; const fileListPayload = payload as FileListItem[];
if (message.path === currentPathRef.value) { if (message.path === currentPathRef.value) {
console.log(`[SFTP ${instanceSessionId}] Received file list for ${message.path}`); console.log(`[SFTP ${instanceSessionId}] 收到目录 ${message.path} 的文件列表`); // 日志改为中文
fileList.value = fileListPayload.sort(sortFiles); fileList.value = fileListPayload.sort(sortFiles);
isLoading.value = false; isLoading.value = false;
error.value = null; // error.value = null; // 不再需要
} else { } else {
console.log(`[SFTP ${instanceSessionId}] Ignoring readdir success for ${message.path} (current: ${currentPathRef.value})`); console.log(`[SFTP ${instanceSessionId}] 忽略目录 ${message.path} 的 readdir 成功消息 (当前: ${currentPathRef.value})`); // 日志改为中文
} }
}; };
@@ -259,16 +273,17 @@ export function createSftpActionsManager(
// 类型断言,因为我们知道 readdir:error 的 payload 是 string // 类型断言,因为我们知道 readdir:error 的 payload 是 string
const errorPayload = payload as string; const errorPayload = payload as string;
if (message.path === currentPathRef.value) { if (message.path === currentPathRef.value) {
console.error(`[SFTP ${instanceSessionId}] Error loading directory ${message.path}:`, errorPayload); console.error(`[SFTP ${instanceSessionId}] 加载目录 ${message.path} 出错:`, errorPayload); // 日志改为中文
error.value = errorPayload; // error.value = errorPayload; // 使用通知
uiNotificationsStore.showError(`${t('fileManager.errors.loadDirectoryFailed')}: ${errorPayload}`, { timeout: 5000 }); // 使用 uiNotificationsStore, 添加 i18n key
isLoading.value = false; isLoading.value = false;
} }
}; };
const onActionSuccessRefresh = (payload: MessagePayload, message: WebSocketMessage) => { const onActionSuccessRefresh = (payload: MessagePayload, message: WebSocketMessage) => {
console.log(`[SFTP ${instanceSessionId}] Action ${message.type} successful. Refreshing current directory: ${currentPathRef.value}`); console.log(`[SFTP ${instanceSessionId}] 操作 ${message.type} 成功。正在刷新当前目录: ${currentPathRef.value}`); // 日志改为中文
loadDirectory(currentPathRef.value); loadDirectory(currentPathRef.value);
error.value = null; // error.value = null; // 不再需要
}; };
const onActionError = (payload: MessagePayload, message: WebSocketMessage) => { const onActionError = (payload: MessagePayload, message: WebSocketMessage) => {
@@ -284,7 +299,8 @@ export function createSftpActionsManager(
'sftp:writefile:error': t('fileManager.errors.saveFailed'), 'sftp:writefile:error': t('fileManager.errors.saveFailed'),
}; };
const prefix = actionTypeMap[message.type] || t('fileManager.errors.generic'); const prefix = actionTypeMap[message.type] || t('fileManager.errors.generic');
error.value = `${prefix}: ${errorPayload}`; // error.value = `${prefix}: ${errorPayload}`; // 使用通知
uiNotificationsStore.showError(`${prefix}: ${errorPayload}`, { timeout: 5000 }); // 使用 uiNotificationsStore
}; };
// --- Register Handlers & Store Unregister Callbacks --- // --- Register Handlers & Store Unregister Callbacks ---
@@ -309,7 +325,7 @@ export function createSftpActionsManager(
// State // State
fileList: readonly(fileList), fileList: readonly(fileList),
isLoading: readonly(isLoading), isLoading: readonly(isLoading),
error: readonly(error), // error: readonly(error), // 移除 error
// Methods // Methods
loadDirectory, loadDirectory,
@@ -321,7 +337,7 @@ export function createSftpActionsManager(
readFile, readFile,
writeFile, writeFile,
joinPath, // 暴露辅助函数 joinPath, // 暴露辅助函数
clearSftpError, // clearSftpError, // 移除 clearSftpError
// Cleanup function // Cleanup function
currentPath: readonly(currentPathRef), // 暴露只读的当前路径 ref currentPath: readonly(currentPathRef), // 暴露只读的当前路径 ref
@@ -91,9 +91,12 @@ export function createStatusMonitorManager(sessionId: string, wsDeps: StatusMoni
unregisterAllStatusHandlers(); unregisterAllStatusHandlers();
// 连接断开时清除状态 // 连接断开时清除状态
serverStatus.value = null; serverStatus.value = null;
// 只有在之前连接成功的情况下才设置断开错误
if (oldValue === true) {
statusError.value = '连接已断开'; // 或者使用 i18n statusError.value = '连接已断开'; // 或者使用 i18n
} }
}, { immediate: true }); // immediate: true 确保初始状态下也会执行一次 }
}); // 移除 immediate: true,避免初始设置错误状态
// --- 清理函数 --- // --- 清理函数 ---
const cleanup = () => { const cleanup = () => {
@@ -1,5 +1,9 @@
import { ref, shallowRef, computed, readonly } from 'vue'; import { ref, shallowRef, computed, readonly } from 'vue';
import type { ConnectionStatus, MessagePayload, WebSocketMessage, MessageHandler } from '../types/websocket.types'; // 从 websocket.types.ts 导入并重新导出 ConnectionStatus
import type { ConnectionStatus as WsConnectionStatusType, MessagePayload, WebSocketMessage, MessageHandler } from '../types/websocket.types';
// 导出类型别名,以便其他模块可以使用
export type WsConnectionStatus = WsConnectionStatusType;
/** /**
* WebSocket * WebSocket
@@ -14,7 +18,7 @@ export function createWebSocketConnectionManager(sessionId: string, dbConnection
// --- Instance State --- // --- Instance State ---
// 每个实例拥有独立的 WebSocket 对象、状态和消息处理器 // 每个实例拥有独立的 WebSocket 对象、状态和消息处理器
const ws = shallowRef<WebSocket | null>(null); // WebSocket 实例 const ws = shallowRef<WebSocket | null>(null); // WebSocket 实例
const connectionStatus = ref<ConnectionStatus>('disconnected'); // 连接状态 const connectionStatus = ref<WsConnectionStatus>('disconnected'); // 连接状态 (使用导出的类型)
const statusMessage = ref<string>(''); // 状态描述文本 const statusMessage = ref<string>(''); // 状态描述文本
const isSftpReady = ref<boolean>(false); // SFTP 是否就绪 const isSftpReady = ref<boolean>(false); // SFTP 是否就绪
const messageHandlers = new Map<string, Set<MessageHandler>>(); // 此实例的消息处理器注册表 const messageHandlers = new Map<string, Set<MessageHandler>>(); // 此实例的消息处理器注册表
+11 -1
View File
@@ -247,7 +247,12 @@
"saving": "Saving", "saving": "Saving",
"saveSuccess": "Save successful", "saveSuccess": "Save successful",
"saveError": "Save error", "saveError": "Save error",
"editPathTooltip": "Click path to edit" "editPathTooltip": "Click path to edit",
"noActiveSession": "No active session",
"loadDirectoryFailed": "Failed to load directory"
},
"statusMonitor": {
"noActiveSession": "No active session"
}, },
"tags": { "tags": {
"title": "Tag Management", "title": "Tag Management",
@@ -506,5 +511,10 @@
"SERVER_ERROR": "Server Error", "SERVER_ERROR": "Server Error",
"DATABASE_MIGRATION": "Database Migration" "DATABASE_MIGRATION": "Database Migration"
} }
},
"workspaceConnectionList": {
"untagged": "Untagged",
"searchPlaceholder": "Search name or host...",
"noResults": "No connections found matching \"{searchTerm}\"."
} }
} }
+8 -2
View File
@@ -251,7 +251,11 @@
"saving": "正在保存", "saving": "正在保存",
"saveSuccess": "保存成功", "saveSuccess": "保存成功",
"saveError": "保存出错", "saveError": "保存出错",
"editPathTooltip": "点击路径进行编辑" "editPathTooltip": "点击路径进行编辑",
"noActiveSession": "无活动会话"
},
"statusMonitor": {
"noActiveSession": "无活动会话"
}, },
"tags": { "tags": {
"title": "标签管理", "title": "标签管理",
@@ -512,6 +516,8 @@
} }
}, },
"workspaceConnectionList": { "workspaceConnectionList": {
"untagged": "未标记" "untagged": "未标记",
"searchPlaceholder": "搜索名称或主机...",
"noResults": "未找到匹配 \"{searchTerm}\" 的连接。"
} }
} }
+4
View File
@@ -6,6 +6,10 @@ import router from './router'; // 引入我们创建的 router
import i18n from './i18n'; // 引入 i18n 实例 import i18n from './i18n'; // 引入 i18n 实例
import { useSettingsStore } from './stores/settings.store'; // 引入 Settings Store import { useSettingsStore } from './stores/settings.store'; // 引入 Settings Store
import './style.css'; import './style.css';
// 导入 Font Awesome CSS
import '@fortawesome/fontawesome-free/css/all.min.css';
// 导入 splitpanes CSS
import 'splitpanes/dist/splitpanes.css';
const pinia = createPinia(); // 创建 Pinia 实例 const pinia = createPinia(); // 创建 Pinia 实例
pinia.use(piniaPluginPersistedstate); // 使用持久化插件 pinia.use(piniaPluginPersistedstate); // 使用持久化插件
+21 -1
View File
@@ -4,7 +4,8 @@ import { useI18n } from 'vue-i18n';
import { useConnectionsStore, type ConnectionInfo } from './connections.store'; import { useConnectionsStore, type ConnectionInfo } from './connections.store';
// 导入管理器工厂函数 (用于创建实例) // 导入管理器工厂函数 (用于创建实例)
import { createWebSocketConnectionManager } from '../composables/useWebSocketConnection'; // 导入 WsConnectionStatus 类型
import { createWebSocketConnectionManager, type WsConnectionStatus } from '../composables/useWebSocketConnection';
import { createSftpActionsManager, type WebSocketDependencies } from '../composables/useSftpActions'; import { createSftpActionsManager, type WebSocketDependencies } from '../composables/useSftpActions';
import { createSshTerminalManager, type SshTerminalDependencies } from '../composables/useSshTerminal'; import { createSshTerminalManager, type SshTerminalDependencies } from '../composables/useSshTerminal';
import { createStatusMonitorManager, type StatusMonitorDependencies } from '../composables/useStatusMonitor'; import { createStatusMonitorManager, type StatusMonitorDependencies } from '../composables/useStatusMonitor';
@@ -31,6 +32,14 @@ export interface SessionState {
currentSftpPath: Ref<string>; // SFTP 当前路径 (可能需要保留在此处或移至 SftpManager 内部) currentSftpPath: Ref<string>; // SFTP 当前路径 (可能需要保留在此处或移至 SftpManager 内部)
} }
// 为标签栏定义包含状态的类型
export interface SessionTabInfoWithStatus {
sessionId: string;
connectionName: string;
status: WsConnectionStatus; // 添加状态字段
}
export const useSessionStore = defineStore('session', () => { export const useSessionStore = defineStore('session', () => {
// --- 依赖 --- // --- 依赖 ---
const { t } = useI18n(); const { t } = useI18n();
@@ -49,6 +58,16 @@ export const useSessionStore = defineStore('session', () => {
})); }));
}); });
// 新增:包含状态的标签页信息
const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] => {
return Array.from(sessions.value.values()).map(session => ({
sessionId: session.sessionId,
connectionName: session.connectionName,
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
}));
});
const activeSession = computed((): SessionState | null => { const activeSession = computed((): SessionState | null => {
if (!activeSessionId.value) return null; if (!activeSessionId.value) return null;
return sessions.value.get(activeSessionId.value) || null; return sessions.value.get(activeSessionId.value) || null;
@@ -240,6 +259,7 @@ export const useSessionStore = defineStore('session', () => {
activeSessionId, activeSessionId,
// Getters // Getters
sessionTabs, sessionTabs,
sessionTabsWithStatus, // 导出新的 getter
activeSession, activeSession,
// Actions // Actions
openNewSession, openNewSession,
@@ -0,0 +1,68 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
// 定义通知对象的接口
export interface UINotification {
id: number;
type: 'success' | 'error' | 'info' | 'warning';
message: string;
timeout?: number; // 可选的自动关闭超时时间 (毫秒)
}
export const useUiNotificationsStore = defineStore('uiNotifications', () => {
const notifications = ref<UINotification[]>([]);
let nextId = 0;
/**
*
* @param notification - ( type message)
*/
const addNotification = (notification: Omit<UINotification, 'id'>) => {
const id = nextId++;
const newNotification: UINotification = { ...notification, id };
notifications.value.push(newNotification);
// 如果设置了超时,则在超时后自动移除通知
if (notification.timeout) {
setTimeout(() => {
removeNotification(id);
}, notification.timeout);
}
};
/**
*
* @param id - ID
*/
const removeNotification = (id: number) => {
notifications.value = notifications.value.filter(n => n.id !== id);
};
// 便捷方法
const showError = (message: string, options: { timeout?: number } = {}) => {
addNotification({ type: 'error', message, timeout: options.timeout ?? 5000 }); // 默认 5 秒超时
};
const showSuccess = (message: string, options: { timeout?: number } = {}) => {
addNotification({ type: 'success', message, timeout: options.timeout ?? 3000 }); // 默认 3 秒超时
};
const showInfo = (message: string, options: { timeout?: number } = {}) => {
addNotification({ type: 'info', message, timeout: options.timeout ?? 3000 });
};
const showWarning = (message: string, options: { timeout?: number } = {}) => {
addNotification({ type: 'warning', message, timeout: options.timeout ?? 5000 });
};
return {
notifications,
addNotification,
removeNotification,
showError,
showSuccess,
showInfo,
showWarning,
};
});
+7
View File
@@ -0,0 +1,7 @@
// packages/frontend/src/types/splitpanes.d.ts
declare module 'splitpanes' {
import { DefineComponent } from 'vue';
const Splitpanes: DefineComponent<any, any, any>;
const Pane: DefineComponent<any, any, any>;
export { Splitpanes, Pane };
}
+189 -126
View File
@@ -1,15 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onBeforeUnmount, computed, ref } from 'vue'; // import { onMounted, onBeforeUnmount, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia'; // storeToRefs import { storeToRefs } from 'pinia';
import TerminalComponent from '../components/Terminal.vue'; import TerminalComponent from '../components/Terminal.vue';
import FileManagerComponent from '../components/FileManager.vue'; import FileManagerComponent from '../components/FileManager.vue';
import StatusMonitorComponent from '../components/StatusMonitor.vue'; import StatusMonitorComponent from '../components/StatusMonitor.vue';
import WorkspaceConnectionListComponent from '../components/WorkspaceConnectionList.vue'; import WorkspaceConnectionListComponent from '../components/WorkspaceConnectionList.vue';
import AddConnectionFormComponent from '../components/AddConnectionForm.vue'; import AddConnectionFormComponent from '../components/AddConnectionForm.vue';
import TerminalTabBar from '../components/TerminalTabBar.vue'; import TerminalTabBar from '../components/TerminalTabBar.vue';
import { useSessionStore } from '../stores/session.store'; // Session Store import { useSessionStore, type SessionTabInfoWithStatus } from '../stores/session.store';
import type { ConnectionInfo } from '../stores/connections.store'; // ConnectionInfo import type { ConnectionInfo } from '../stores/connections.store';
// splitpanes
import { Splitpanes, Pane } from 'splitpanes';
// FileManagerComponent prop // FileManagerComponent prop
import type { SftpManagerInstance } from '../stores/session.store'; import type { SftpManagerInstance } from '../stores/session.store';
@@ -18,8 +20,7 @@ const { t } = useI18n();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
// --- Store Getters --- // --- Store Getters ---
// 使 storeToRefs 使 sessionStore.xxx const { sessionTabsWithStatus, activeSessionId, activeSession } = storeToRefs(sessionStore);
const { sessionTabs, activeSessionId, activeSession } = storeToRefs(sessionStore);
// --- UI () --- // --- UI () ---
const showAddEditForm = ref(false); const showAddEditForm = ref(false);
@@ -28,25 +29,13 @@ const connectionToEdit = ref<ConnectionInfo | null>(null);
// --- --- // --- ---
onMounted(() => { onMounted(() => {
console.log('[工作区视图] 组件已挂载。'); console.log('[工作区视图] 组件已挂载。');
//
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
console.log('[工作区视图] 组件即将卸载,清理所有会话...'); console.log('[工作区视图] 组件即将卸载,清理所有会话...');
sessionStore.cleanupAllSessions(); // store action sessionStore.cleanupAllSessions();
}); });
// --- ( store ) ---
// watch(activeSessionId, (newSessionId, oldSessionId) => {
// console.log(`[] ID ${oldSessionId} ${newSessionId}`);
// if (newSessionId) {
// nextTick(() => {
// // TODO: ( Store )
// console.log(`[] TODO: ${newSessionId} `);
// });
// }
// });
// --- ( UI ) --- // --- ( UI ) ---
const handleRequestAddConnection = () => { const handleRequestAddConnection = () => {
connectionToEdit.value = null; connectionToEdit.value = null;
@@ -72,73 +61,79 @@ onBeforeUnmount(() => {
console.log('[工作区视图] 连接已更新'); console.log('[工作区视图] 连接已更新');
handleFormClose(); handleFormClose();
}; };
// --- ---
// findConnectionInfo, openNewSession, activateSession, closeSession,
// handleConnectRequest, handleOpenNewSession sessionStore
</script> </script>
<template> <template>
<div class="workspace-view"> <div class="workspace-view">
<!-- 标签栏: 绑定到 store 的状态和 actions -->
<TerminalTabBar <TerminalTabBar
:sessions="sessionTabs" :sessions="sessionTabsWithStatus"
:active-session-id="activeSessionId" :active-session-id="activeSessionId"
@activate-session="sessionStore.activateSession" @activate-session="sessionStore.activateSession"
@close-session="sessionStore.closeSession" @close-session="sessionStore.closeSession"
/> />
<div class="status-bar">
<!-- 状态栏显示活动会话的信息: store getter 获取 -->
{{ t('workspace.statusBar', {
status: activeSession?.wsManager.statusMessage.value ?? t('workspace.status.disconnected'),
id: activeSession?.connectionId ?? t('workspace.noActiveSession')
})
}}
<!-- activeSession getter 获取连接状态 -->
<span :class="`status-${activeSession?.wsManager.connectionStatus.value ?? 'disconnected'}`"></span>
</div>
<div class="main-content-area"> <div class="main-content-area">
<!-- 左侧边栏: 事件绑定到 store actions --> <!-- 最外层左右分割 (连接列表 | 中间区域 + 右侧区域) -->
<div class="left-sidebar"> <splitpanes class="default-theme" :horizontal="false" style="height: 100%">
<!-- 左侧边栏 Pane -->
<pane size="20" min-size="15" class="sidebar-pane">
<WorkspaceConnectionListComponent <WorkspaceConnectionListComponent
@connect-request="sessionStore.handleConnectRequest" @connect-request="sessionStore.handleConnectRequest"
@open-new-session="sessionStore.handleOpenNewSession" @open-new-session="sessionStore.handleOpenNewSession"
@request-add-connection="handleRequestAddConnection" @request-add-connection="handleRequestAddConnection"
@request-edit-connection="handleRequestEditConnection" @request-edit-connection="handleRequestEditConnection"
/> />
</div> </pane>
<!-- 主工作区容器 -->
<div class="main-workspace-container"> <!-- 中间区域 Pane (包含终端和文件管理器) -->
<!-- 会话区域: 循环 store 中的 sessions Map --> <pane size="65" min-size="30">
<!-- 注意: v-for sessions.values() 可能不是响应式的因为 sessions shallowRef --> <!-- 上下分割 (终端 | 文件管理器) -->
<!-- 改为 v-for session in sessionTabs然后通过 session.sessionId 获取完整 session --> <splitpanes :horizontal="true" style="height: 100%">
<!-- 终端 Pane -->
<pane size="65" min-size="20" class="terminal-pane">
<!-- 会话终端区域: 只渲染活动会话的终端 -->
<div <div
v-for="tabInfo in sessionTabs" v-for="tabInfo in sessionTabsWithStatus"
:key="tabInfo.sessionId" :key="tabInfo.sessionId"
v-show="tabInfo.sessionId === activeSessionId" v-show="tabInfo.sessionId === activeSessionId"
class="main-workspace-area-session" class="terminal-session-wrapper"
> >
<!-- 获取当前循环的完整 session 对象 --> <!-- 移除 v-if依赖外层 v-show 控制显隐 -->
<template v-if="sessionStore.sessions.get(tabInfo.sessionId)"> <!-- :key 绑定到 tabInfo.sessionId 保证每个会话对应唯一组件实例 -->
<div class="left-pane"> <!-- :is-active 动态绑定 -->
<div class="terminal-wrapper" :data-session-id="tabInfo.sessionId">
<!-- TerminalComponent: 事件绑定到 activeSession 的管理器方法 -->
<TerminalComponent <TerminalComponent
:key="tabInfo.sessionId" :key="tabInfo.sessionId"
:session-id="tabInfo.sessionId" :session-id="tabInfo.sessionId"
:is-active="tabInfo.sessionId === activeSessionId" :is-active="tabInfo.sessionId === activeSessionId"
@ready="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalReady" @ready="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalReady"
@data="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalData" @data="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalData"
@resize="(dims) => { console.log(`[WorkspaceView ${tabInfo.sessionId}] Received resize event:`, dims); sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalResize(dims); }" @resize="(dims) => { console.log(`[工作区视图 ${tabInfo.sessionId}] 收到 resize 事件:`, dims); sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalResize(dims); }"
/> />
</div> </div>
<div class="file-manager-wrapper"> <!-- 终端占位符 -->
<!-- FileManagerComponent: Props 绑定到 activeSession 的管理器 --> <div v-if="!activeSessionId" class="terminal-placeholder">
<!-- 确保传递正确的 wsDeps --> <h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
<p>{{ t('workspace.selectConnectionHint') }}</p>
</div>
<!-- 终端占位符 -->
<div v-if="!activeSessionId" class="terminal-placeholder">
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
<p>{{ t('workspace.selectConnectionHint') }}</p>
</div>
</pane>
<!-- 文件管理器 Pane -->
<pane size="35" min-size="15" class="file-manager-pane">
<!-- 为每个会话渲染文件管理器实例 v-show 控制 -->
<div
v-for="tabInfo in sessionTabsWithStatus"
:key="tabInfo.sessionId + '-fm-wrapper'"
v-show="tabInfo.sessionId === activeSessionId"
class="file-manager-wrapper"
>
<FileManagerComponent <FileManagerComponent
:key="tabInfo.sessionId" v-if="sessionStore.sessions.get(tabInfo.sessionId)"
:key="tabInfo.sessionId + '-fm'"
:session-id="tabInfo.sessionId" :session-id="tabInfo.sessionId"
:db-connection-id="sessionStore.sessions.get(tabInfo.sessionId)!.connectionId" :db-connection-id="sessionStore.sessions.get(tabInfo.sessionId)!.connectionId"
:sftp-manager="sessionStore.sessions.get(tabInfo.sessionId)!.sftpManager" :sftp-manager="sessionStore.sessions.get(tabInfo.sessionId)!.sftpManager"
@@ -150,24 +145,34 @@ onBeforeUnmount(() => {
}" }"
/> />
</div> </div>
</div> <!-- 文件管理器占位符 -->
<div class="status-monitor-wrapper"> <div v-if="!activeSessionId" class="pane-placeholder">{{ t('fileManager.noActiveSession') }}</div>
<!-- StatusMonitorComponent: Props 绑定到 activeSession 的管理器状态 --> </pane>
</splitpanes>
</pane>
<!-- 右侧边栏 Pane (状态监视器) - 添加 status-monitor-pane -->
<pane size="15" min-size="10" class="sidebar-pane status-monitor-pane">
<!-- 为每个会话渲染状态监视器实例 v-show 控制 -->
<div
v-for="tabInfo in sessionTabsWithStatus"
:key="tabInfo.sessionId + '-sm-wrapper'"
v-show="tabInfo.sessionId === activeSessionId"
class="status-monitor-wrapper"
>
<StatusMonitorComponent <StatusMonitorComponent
:key="tabInfo.sessionId" v-if="sessionStore.sessions.get(tabInfo.sessionId)"
:key="tabInfo.sessionId + '-sm'"
:session-id="tabInfo.sessionId" :session-id="tabInfo.sessionId"
:server-status="(sessionStore.sessions.get(tabInfo.sessionId)?.statusMonitorManager.serverStatus.value) ?? null" :server-status="sessionStore.sessions.get(tabInfo.sessionId)!.statusMonitorManager.serverStatus.value"
:status-error="(sessionStore.sessions.get(tabInfo.sessionId)?.statusMonitorManager.statusError.value) ?? null" :status-error="sessionStore.sessions.get(tabInfo.sessionId)!.statusMonitorManager.statusError.value"
/> />
</div> </div>
</template> <!-- 状态监视器占位符 -->
</div> <div v-if="!activeSessionId" class="pane-placeholder">{{ t('statusMonitor.noActiveSession') }}</div>
<!-- 占位符 --> </pane>
<div v-if="!activeSessionId" class="main-workspace-area placeholder">
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2> </splitpanes>
<p>{{ t('workspace.selectConnectionHint') }}</p>
</div>
</div>
</div> </div>
<!-- 添加/编辑连接表单模态框 (保持不变) --> <!-- 添加/编辑连接表单模态框 (保持不变) -->
@@ -185,23 +190,10 @@ onBeforeUnmount(() => {
.workspace-view { .workspace-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 60px - 30px - 60px - 2rem); height: calc(100vh - 60px - 30px - 2rem); /* 调整以适应您的 header/footer/padding */
overflow: hidden; overflow: hidden;
} }
.status-bar {
padding: 0.5rem 1rem;
background-color: #eee;
border-bottom: 1px solid #ccc;
font-size: 0.9rem;
color: #333;
}
.status-connecting { color: orange; }
.status-connected { color: green; }
.status-disconnected { color: grey; }
.status-error { color: red; }
.main-content-area { .main-content-area {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -209,65 +201,82 @@ onBeforeUnmount(() => {
border-top: 1px solid #ccc; border-top: 1px solid #ccc;
} }
.left-sidebar { /* 为 Pane 添加一些基本样式 */
width: 250px; .sidebar-pane, /* 用于左右侧边栏 */
min-width: 200px; .terminal-pane,
height: 100%; .file-manager-pane {
border-right: 2px solid #ccc;
overflow-y: auto;
}
.main-workspace-container {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
.main-workspace-area-session {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
overflow: hidden;
}
.left-pane {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 300px; overflow: hidden; /* Pane 内部内容溢出时隐藏 */
overflow: hidden; background-color: #f8f9fa; /* 默认背景色 */
}
.terminal-pane {
background-color: #1e1e1e; /* 终端背景 */
position: relative; /* 保持相对定位用于占位符 */
}
.file-manager-pane {
border-top: 1px solid #ccc; /* 终端和文件管理器之间的分隔线 */
} }
.terminal-wrapper { /* 终端会话包装器 */
flex: 1; .terminal-session-wrapper {
flex-grow: 1; /* 填充 terminal-pane */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #1e1e1e;
overflow: hidden; overflow: hidden;
} }
/* 文件管理器包装器 (内部组件应填充) */
.file-manager-wrapper { .file-manager-wrapper {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-top: 2px solid #ccc;
overflow: hidden; overflow: hidden;
} }
.status-monitor-wrapper { /* 文件管理器包装器 (内部组件应填充) */
width: 250px; .file-manager-wrapper {
min-width: 200px; flex: 1;
border-left: 2px solid #ccc;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
.main-workspace-area.placeholder { /* 状态监视器包装器 (内部组件应填充) */
.status-monitor-wrapper {
flex: 1;
display: flex; /* 使内部 StatusMonitorComponent 可以填充 */
flex-direction: column;
overflow: hidden;
}
/* 新增:状态监视器 Pane 样式,使其内容居中 */
.status-monitor-pane {
/* 尝试使用 flex 居中,如果 StatusMonitorComponent 本身是块级元素 */
/* display: flex; */
/* justify-content: center; */
/* align-items: center; */
/* 或者如果内容主要是文本,可以尝试 text-align */
text-align: center; /* 尝试文本居中 */
padding: 1rem; /* 添加一些内边距 */
}
.status-monitor-pane > .status-monitor-wrapper {
/* 如果需要包装器也居中(如果它不是 flex: 1 的话) */
/* margin: auto; */
}
/* 终端占位符 */
.terminal-placeholder {
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 终端占位符 */
.terminal-placeholder {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@@ -280,14 +289,68 @@ onBeforeUnmount(() => {
text-align: center; text-align: center;
color: #6c757d; color: #6c757d;
padding: 2rem; padding: 2rem;
background-color: #f8f9fa; background-color: #f8f9fa; /* 与 pane 背景一致 */
} }
.main-workspace-area.placeholder h2 { .terminal-placeholder h2 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: 300; font-weight: 300;
color: #495057; color: #495057;
} }
.main-workspace-area.placeholder p { .terminal-placeholder p {
font-size: 1em; font-size: 1em;
} }
/* 面板占位符样式 */
.pane-placeholder {
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: #adb5bd;
background-color: #f8f9fa;
font-size: 0.9em;
padding: 1rem;
}
/* Splitpanes 默认主题样式调整 */
.splitpanes.default-theme .splitpanes__splitter {
background-color: #ccc;
box-sizing: border-box;
position: relative;
flex-shrink: 0;
border-left: 1px solid #eee; /* 可选:添加细微边框 */
border-right: 1px solid #eee;
}
.splitpanes--vertical > .splitpanes__splitter {
width: 7px; /* 垂直分割线宽度 */
cursor: col-resize;
}
.splitpanes--horizontal > .splitpanes__splitter {
height: 7px; /* 水平分割线高度 */
cursor: row-resize;
}
.splitpanes.default-theme .splitpanes__splitter:before {
content: '';
position: absolute;
left: 0;
top: 0;
transition: opacity 0.4s;
background-color: rgba(0, 0, 0, 0.15);
opacity: 0;
z-index: 1;
}
.splitpanes.default-theme .splitpanes__splitter:hover:before {
opacity: 1;
}
.splitpanes.default-theme.splitpanes--vertical > .splitpanes__splitter:before {
left: 2px; /* 调整指示器位置 */
right: 2px;
height: 100%;
}
.splitpanes.default-theme.splitpanes--horizontal > .splitpanes__splitter:before {
top: 2px; /* 调整指示器位置 */
bottom: 2px;
width: 100%;
}
</style> </style>