update
This commit is contained in:
Generated
+77
@@ -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",
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ export class StatusMonitorService {
|
|||||||
}
|
}
|
||||||
if (state.statusIntervalId) {
|
if (state.statusIntervalId) {
|
||||||
//console.warn(`[StatusMonitor] 会话 ${sessionId} 的状态轮询已在运行中。`);
|
//console.warn(`[StatusMonitor] 会话 ${sessionId} 的状态轮询已在运行中。`);
|
||||||
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,16 +174,23 @@ 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 /");
|
||||||
if (parts.length >= 5) {
|
const lines = dfOutput.split('\n');
|
||||||
const total = parseInt(parts[1], 10); const used = parseInt(parts[2], 10);
|
if (lines.length >= 2) {
|
||||||
const percentMatch = parts[4].match(/(\d+)%/);
|
const parts = lines[1].split(/\s+/); // 解析第二行 (数据行)
|
||||||
if (!isNaN(total) && !isNaN(used) && percentMatch) {
|
// POSIX 格式: Filesystem 1024-blocks Used Available Capacity Mounted on
|
||||||
status.diskTotal = total; status.diskUsed = used;
|
// parts[1]=Total(KB), parts[2]=Used(KB), parts[4]=Capacity(%)
|
||||||
status.diskPercent = parseFloat(percentMatch[1]);
|
if (parts.length >= 5) {
|
||||||
|
const total = parseInt(parts[1], 10);
|
||||||
|
const used = parseInt(parts[2], 10);
|
||||||
|
const percentMatch = parts[4].match(/(\d+)%/);
|
||||||
|
if (!isNaN(total) && !isNaN(used) && percentMatch) {
|
||||||
|
status.diskTotal = total; status.diskUsed = used;
|
||||||
|
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); }
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>© 2025 {{ t('appName') }}</p>
|
<p>© 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')">×</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>
|
||||||
<div class="progress-bar-container">
|
<!-- Wrap progress bar and percentage in a div -->
|
||||||
<div class="progress-bar" :style="{ width: `${serverStatus.cpuPercent ?? 0}%` }"></div>
|
<div class="value-wrapper">
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar" :style="{ width: `${serverStatus.cpuPercent ?? 0}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<span>{{ serverStatus.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ serverStatus.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item">
|
<div class="status-item">
|
||||||
<label>内存:</label>
|
<label>内存:</label>
|
||||||
<div class="progress-bar-container">
|
<div class="value-wrapper">
|
||||||
<div class="progress-bar" :style="{ width: `${serverStatus.memPercent ?? 0}%` }"></div>
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar" :style="{ width: `${serverStatus.memPercent ?? 0}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="mem-disk-details">{{ memDisplay }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="mem-disk-details">{{ memDisplay }}</span>
|
|
||||||
</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="progress-bar-container">
|
<div class="value-wrapper">
|
||||||
<div class="progress-bar swap-bar" :style="{ width: `${serverStatus.swapPercent ?? 0}%` }"></div>
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar swap-bar" :style="{ width: `${serverStatus.swapPercent ?? 0}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="mem-disk-details">{{ swapDisplay }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="mem-disk-details">{{ swapDisplay }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item">
|
<div class="status-item">
|
||||||
<label>磁盘 (/):</label>
|
<label>磁盘:</label> <!-- 移除 (/) -->
|
||||||
<div class="progress-bar-container">
|
<div class="value-wrapper">
|
||||||
<div class="progress-bar" :style="{ width: `${serverStatus.diskPercent ?? 0}%` }"></div>
|
<div class="progress-bar-container">
|
||||||
|
<div class="progress-bar" :style="{ width: `${serverStatus.diskPercent ?? 0}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="mem-disk-details">{{ diskDisplay }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="mem-disk-details">{{ diskDisplay }}</span>
|
|
||||||
</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>
|
||||||
<div v-if="statusError" class="status-error">
|
<!-- Error display moved up for correct v-if/v-else-if logic -->
|
||||||
错误: {{ statusError }}
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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="关闭标签页">
|
||||||
× <!-- 使用 HTML 实体 '×' -->
|
× <!-- 使用 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)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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 (!groups[groupName]) {
|
if (tag) {
|
||||||
groups[groupName] = [];
|
const groupName = tag.name;
|
||||||
if (expandedGroups.value[groupName] === undefined) {
|
if (!groups[groupName]) {
|
||||||
expandedGroups.value[groupName] = true; // 默认展开
|
groups[groupName] = [];
|
||||||
|
if (expandedGroups.value[groupName] === undefined) {
|
||||||
|
expandedGroups.value[groupName] = true; // 默认展开
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// 避免重复添加(如果一个连接有多个标签)
|
||||||
|
if (!groups[groupName].some(c => c.id === conn.id)) {
|
||||||
|
groups[groupName].push(conn);
|
||||||
|
}
|
||||||
|
tagged = true;
|
||||||
}
|
}
|
||||||
groups[groupName].push(conn);
|
|
||||||
});
|
});
|
||||||
|
// 如果所有标签都无效或未找到,则归入未标记
|
||||||
|
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,34 +174,62 @@ 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>
|
||||||
<div v-else>
|
<!-- 搜索和添加栏 -->
|
||||||
<!-- 添加连接按钮(总是在顶部) -->
|
<div class="search-add-bar">
|
||||||
<button class="add-connection-button" @click="handleMenuAction('add')">
|
<input
|
||||||
<i class="fas fa-plus"></i> {{ t('connections.addConnection') }}
|
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>
|
</button>
|
||||||
<div v-for="group in groupedConnections" :key="group.groupName" class="connection-group">
|
</div>
|
||||||
<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 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 v-else>
|
||||||
|
<!-- 修正: 循环 filteredAndGroupedConnections -->
|
||||||
|
<div v-for="groupData in filteredAndGroupedConnections" :key="groupData.groupName" class="connection-group">
|
||||||
|
<div class="group-header" @click="toggleGroup(groupData.groupName)">
|
||||||
|
<i :class="['fas', expandedGroups[groupData.groupName] ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
|
||||||
|
<span>{{ groupData.groupName }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 修正: 使用 groupData.groupName 和 groupData.connections -->
|
||||||
|
<ul v-show="expandedGroups[groupData.groupName]" class="connection-items">
|
||||||
|
<li
|
||||||
|
v-for="conn in groupData.connections"
|
||||||
|
:key="conn.id"
|
||||||
|
class="connection-item"
|
||||||
|
@click.left="handleConnect(conn.id)"
|
||||||
|
@click.middle.prevent="handleOpenInNewTab(conn.id)"
|
||||||
|
@auxclick.prevent="handleOpenInNewTab(conn.id)"
|
||||||
|
@contextmenu.prevent="showContextMenu($event, conn)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-server connection-icon"></i>
|
||||||
|
<span class="connection-name" :title="conn.name || conn.host">
|
||||||
|
{{ conn.name || conn.host }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<ul v-show="expandedGroups[group.groupName]" class="connection-items">
|
<!-- 移除重复的 ul 块 -->
|
||||||
<li
|
|
||||||
v-for="conn in group.connections"
|
|
||||||
:key="conn.id"
|
|
||||||
class="connection-item"
|
|
||||||
@click.left="handleConnect(conn.id)"
|
|
||||||
@click.middle.prevent="handleOpenInNewTab(conn.id)"
|
|
||||||
@auxclick.prevent="handleOpenInNewTab(conn.id)"
|
|
||||||
@contextmenu.prevent="showContextMenu($event, conn)"
|
|
||||||
>
|
|
||||||
<i class="fas fa-server connection-icon"></i>
|
|
||||||
<span class="connection-name" :title="conn.name || conn.host">
|
|
||||||
{{ conn.name || conn.host }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</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;
|
||||||
statusError.value = '连接已断开'; // 或者使用 i18n
|
// 只有在之前连接成功的情况下才设置断开错误
|
||||||
|
if (oldValue === true) {
|
||||||
|
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>>(); // 此实例的消息处理器注册表
|
||||||
|
|||||||
@@ -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}\"."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}\" 的连接。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); // 使用持久化插件
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,102 +61,118 @@ onBeforeUnmount(() => {
|
|||||||
console.log('[工作区视图] 连接已更新');
|
console.log('[工作区视图] 连接已更新');
|
||||||
handleFormClose();
|
handleFormClose();
|
||||||
};
|
};
|
||||||
|
</script>
|
||||||
// --- 移除本地会话管理函数 ---
|
|
||||||
// findConnectionInfo, openNewSession, activateSession, closeSession,
|
|
||||||
// handleConnectRequest, handleOpenNewSession 已移至 sessionStore
|
|
||||||
|
|
||||||
</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%">
|
||||||
<WorkspaceConnectionListComponent
|
|
||||||
@connect-request="sessionStore.handleConnectRequest"
|
<!-- 左侧边栏 Pane -->
|
||||||
@open-new-session="sessionStore.handleOpenNewSession"
|
<pane size="20" min-size="15" class="sidebar-pane">
|
||||||
@request-add-connection="handleRequestAddConnection"
|
<WorkspaceConnectionListComponent
|
||||||
@request-edit-connection="handleRequestEditConnection"
|
@connect-request="sessionStore.handleConnectRequest"
|
||||||
/>
|
@open-new-session="sessionStore.handleOpenNewSession"
|
||||||
</div>
|
@request-add-connection="handleRequestAddConnection"
|
||||||
<!-- 主工作区容器 -->
|
@request-edit-connection="handleRequestEditConnection"
|
||||||
<div class="main-workspace-container">
|
/>
|
||||||
<!-- 会话区域: 循环 store 中的 sessions Map -->
|
</pane>
|
||||||
<!-- 注意: v-for sessions.values() 可能不是响应式的,因为 sessions 是 shallowRef -->
|
|
||||||
<!-- 改为 v-for session in sessionTabs,然后通过 session.sessionId 获取完整 session -->
|
<!-- 中间区域 Pane (包含终端和文件管理器) -->
|
||||||
<div
|
<pane size="65" min-size="30">
|
||||||
v-for="tabInfo in sessionTabs"
|
<!-- 上下分割 (终端 | 文件管理器) -->
|
||||||
:key="tabInfo.sessionId"
|
<splitpanes :horizontal="true" style="height: 100%">
|
||||||
v-show="tabInfo.sessionId === activeSessionId"
|
<!-- 终端 Pane -->
|
||||||
class="main-workspace-area-session"
|
<pane size="65" min-size="20" class="terminal-pane">
|
||||||
>
|
<!-- 会话终端区域: 只渲染活动会话的终端 -->
|
||||||
<!-- 获取当前循环的完整 session 对象 -->
|
<div
|
||||||
<template v-if="sessionStore.sessions.get(tabInfo.sessionId)">
|
v-for="tabInfo in sessionTabsWithStatus"
|
||||||
<div class="left-pane">
|
:key="tabInfo.sessionId"
|
||||||
<div class="terminal-wrapper" :data-session-id="tabInfo.sessionId">
|
v-show="tabInfo.sessionId === activeSessionId"
|
||||||
<!-- TerminalComponent: 事件绑定到 activeSession 的管理器方法 -->
|
class="terminal-session-wrapper"
|
||||||
<TerminalComponent
|
>
|
||||||
:key="tabInfo.sessionId"
|
<!-- 移除 v-if,依赖外层 v-show 控制显隐 -->
|
||||||
:session-id="tabInfo.sessionId"
|
<!-- :key 绑定到 tabInfo.sessionId 保证每个会话对应唯一组件实例 -->
|
||||||
:is-active="tabInfo.sessionId === activeSessionId"
|
<!-- :is-active 动态绑定 -->
|
||||||
@ready="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalReady"
|
<TerminalComponent
|
||||||
@data="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalData"
|
:key="tabInfo.sessionId"
|
||||||
@resize="(dims) => { console.log(`[WorkspaceView ${tabInfo.sessionId}] Received resize event:`, dims); sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalResize(dims); }"
|
:session-id="tabInfo.sessionId"
|
||||||
/>
|
:is-active="tabInfo.sessionId === activeSessionId"
|
||||||
</div>
|
@ready="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalReady"
|
||||||
<div class="file-manager-wrapper">
|
@data="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalData"
|
||||||
<!-- FileManagerComponent: Props 绑定到 activeSession 的管理器 -->
|
@resize="(dims) => { console.log(`[工作区视图 ${tabInfo.sessionId}] 收到 resize 事件:`, dims); sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalResize(dims); }"
|
||||||
<!-- 确保传递正确的 wsDeps -->
|
/>
|
||||||
<FileManagerComponent
|
</div>
|
||||||
:key="tabInfo.sessionId"
|
<!-- 终端占位符 -->
|
||||||
:session-id="tabInfo.sessionId"
|
<div v-if="!activeSessionId" class="terminal-placeholder">
|
||||||
:db-connection-id="sessionStore.sessions.get(tabInfo.sessionId)!.connectionId"
|
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
|
||||||
:sftp-manager="sessionStore.sessions.get(tabInfo.sessionId)!.sftpManager"
|
<p>{{ t('workspace.selectConnectionHint') }}</p>
|
||||||
:ws-deps="{
|
</div>
|
||||||
sendMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.sendMessage,
|
<!-- 终端占位符 -->
|
||||||
onMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.onMessage,
|
<div v-if="!activeSessionId" class="terminal-placeholder">
|
||||||
isConnected: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isConnected,
|
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
|
||||||
isSftpReady: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isSftpReady
|
<p>{{ t('workspace.selectConnectionHint') }}</p>
|
||||||
}"
|
</div>
|
||||||
/>
|
</pane>
|
||||||
</div>
|
<!-- 文件管理器 Pane -->
|
||||||
</div>
|
<pane size="35" min-size="15" class="file-manager-pane">
|
||||||
<div class="status-monitor-wrapper">
|
<!-- 为每个会话渲染文件管理器实例,用 v-show 控制 -->
|
||||||
<!-- StatusMonitorComponent: Props 绑定到 activeSession 的管理器状态 -->
|
<div
|
||||||
<StatusMonitorComponent
|
v-for="tabInfo in sessionTabsWithStatus"
|
||||||
:key="tabInfo.sessionId"
|
:key="tabInfo.sessionId + '-fm-wrapper'"
|
||||||
:session-id="tabInfo.sessionId"
|
v-show="tabInfo.sessionId === activeSessionId"
|
||||||
:server-status="(sessionStore.sessions.get(tabInfo.sessionId)?.statusMonitorManager.serverStatus.value) ?? null"
|
class="file-manager-wrapper"
|
||||||
:status-error="(sessionStore.sessions.get(tabInfo.sessionId)?.statusMonitorManager.statusError.value) ?? null"
|
>
|
||||||
/>
|
<FileManagerComponent
|
||||||
</div>
|
v-if="sessionStore.sessions.get(tabInfo.sessionId)"
|
||||||
</template>
|
:key="tabInfo.sessionId + '-fm'"
|
||||||
</div>
|
:session-id="tabInfo.sessionId"
|
||||||
<!-- 占位符 -->
|
:db-connection-id="sessionStore.sessions.get(tabInfo.sessionId)!.connectionId"
|
||||||
<div v-if="!activeSessionId" class="main-workspace-area placeholder">
|
:sftp-manager="sessionStore.sessions.get(tabInfo.sessionId)!.sftpManager"
|
||||||
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
|
:ws-deps="{
|
||||||
<p>{{ t('workspace.selectConnectionHint') }}</p>
|
sendMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.sendMessage,
|
||||||
</div>
|
onMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.onMessage,
|
||||||
</div>
|
isConnected: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isConnected,
|
||||||
|
isSftpReady: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isSftpReady
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 文件管理器占位符 -->
|
||||||
|
<div v-if="!activeSessionId" class="pane-placeholder">{{ t('fileManager.noActiveSession') }}</div>
|
||||||
|
</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
|
||||||
|
v-if="sessionStore.sessions.get(tabInfo.sessionId)"
|
||||||
|
:key="tabInfo.sessionId + '-sm'"
|
||||||
|
:session-id="tabInfo.sessionId"
|
||||||
|
:server-status="sessionStore.sessions.get(tabInfo.sessionId)!.statusMonitorManager.serverStatus.value"
|
||||||
|
:status-error="sessionStore.sessions.get(tabInfo.sessionId)!.statusMonitorManager.statusError.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 状态监视器占位符 -->
|
||||||
|
<div v-if="!activeSessionId" class="pane-placeholder">{{ t('statusMonitor.noActiveSession') }}</div>
|
||||||
|
</pane>
|
||||||
|
|
||||||
|
</splitpanes>
|
||||||
</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;
|
display: flex;
|
||||||
overflow-y: auto;
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* Pane 内部内容溢出时隐藏 */
|
||||||
|
background-color: #f8f9fa; /* 默认背景色 */
|
||||||
|
}
|
||||||
|
.terminal-pane {
|
||||||
|
background-color: #1e1e1e; /* 终端背景 */
|
||||||
|
position: relative; /* 保持相对定位用于占位符 */
|
||||||
|
}
|
||||||
|
.file-manager-pane {
|
||||||
|
border-top: 1px solid #ccc; /* 终端和文件管理器之间的分隔线 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-workspace-container {
|
/* 终端会话包装器 */
|
||||||
flex: 1;
|
.terminal-session-wrapper {
|
||||||
display: flex;
|
flex-grow: 1; /* 填充 terminal-pane */
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user