This commit is contained in:
Baobhan Sith
2025-04-16 11:04:29 +08:00
parent 269473c526
commit a83b346956
20 changed files with 922 additions and 350 deletions
+77
View File
@@ -466,6 +466,15 @@
"node": ">=12"
}
},
"node_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": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -1014,6 +1023,42 @@
"@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": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.11.tgz",
@@ -4434,6 +4479,23 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -5144,6 +5206,18 @@
"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": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -6274,11 +6348,13 @@
"name": "@nexus-terminal/frontend",
"version": "0.1.0",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@simplewebauthn/browser": "^13.1.0",
"axios": "^1.8.4",
"monaco-editor": "^0.52.2",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
"splitpanes": "^4.0.3",
"vite-plugin-monaco-editor": "^1.1.0",
"vue": "^3.3.0",
"vue-i18n": "^9.14.4",
@@ -6288,6 +6364,7 @@
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@types/splitpanes": "^2.2.6",
"@vitejs/plugin-vue": "^4.2.0",
"typescript": "^5.0.0",
"vite": "^4.4.0",
@@ -55,13 +55,13 @@ export class StatusMonitorService {
}
if (state.statusIntervalId) {
//console.warn(`[StatusMonitor] 会话 ${sessionId} 的状态轮询已在运行中。`);
return;
}
//console.warn(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${interval}ms`);
this.fetchAndSendServerStatus(sessionId); // 立即执行一次
state.statusIntervalId = setInterval(() => {
this.fetchAndSendServerStatus(sessionId);
}, interval);
return;
}
//console.warn(`[StatusMonitor] 为会话 ${sessionId} 启动状态轮询,间隔 ${interval}ms`);
// 移除立即执行,让 setInterval 负责第一次调用,给连接更多准备时间
state.statusIntervalId = setInterval(() => {
this.fetchAndSendServerStatus(sessionId);
}, interval);
}
/**
@@ -118,11 +118,31 @@ export class StatusMonitorService {
status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown');
} catch (err) { console.warn(`[StatusMonitor ${sessionId}] Failed to get OS name:`, err); }
// --- CPU Model ---
// --- CPU Model (Try /proc/cpuinfo first, fallback to lscpu) ---
try {
const lscpuOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'");
status.cpuModel = lscpuOutput.match(/Model name:\s+(.*)/)?.[1].trim() ?? 'Unknown';
} catch (err) { console.warn(`[StatusMonitor ${sessionId}] Failed to get CPU model:`, err); }
let cpuModelOutput = '';
try {
// 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 ---
try {
@@ -154,16 +174,23 @@ export class StatusMonitorService {
} else { status.swapTotal = 0; status.swapUsed = 0; status.swapPercent = 0; }
} 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 {
const dfOutput = await this.executeSshCommand(sshClient, "df -k / | tail -n 1");
const parts = dfOutput.split(/\s+/);
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]);
// 使用 df -kP / 获取 POSIX 标准格式输出,更稳定
const dfOutput = await this.executeSshCommand(sshClient, "df -kP /");
const lines = dfOutput.split('\n');
if (lines.length >= 2) {
const parts = lines[1].split(/\s+/); // 解析第二行 (数据行)
// POSIX 格式: Filesystem 1024-blocks Used Available Capacity Mounted on
// parts[1]=Total(KB), parts[2]=Used(KB), parts[4]=Capacity(%)
if (parts.length >= 5) {
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); }
+3
View File
@@ -8,11 +8,13 @@
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@simplewebauthn/browser": "^13.1.0",
"axios": "^1.8.4",
"monaco-editor": "^0.52.2",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
"splitpanes": "^4.0.3",
"vite-plugin-monaco-editor": "^1.1.0",
"vue": "^3.3.0",
"vue-i18n": "^9.14.4",
@@ -22,6 +24,7 @@
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@types/splitpanes": "^2.2.6",
"@vitejs/plugin-vue": "^4.2.0",
"typescript": "^5.0.0",
"vite": "^4.4.0",
+8 -3
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
import { useI18n } from 'vue-i18n'; // useI18n
import { useAuthStore } from './stores/auth.store'; // Auth Store
import { useI18n } from 'vue-i18n';
import { useAuthStore } from './stores/auth.store';
import { storeToRefs } from 'pinia';
//
import UINotificationDisplay from './components/UINotificationDisplay.vue';
const { t } = useI18n(); // t
const { t } = useI18n();
const authStore = useAuthStore();
const { isAuthenticated } = storeToRefs(authStore); //
@@ -34,6 +36,9 @@ const handleLogout = () => {
<RouterView /> <!-- 路由对应的组件将在这里渲染 -->
</main>
<!-- 添加全局通知显示 -->
<UINotificationDisplay />
<footer>
<!-- 使用 t 函数获取应用名称 -->
<p>&copy; 2025 {{ t('appName') }}</p>
@@ -527,7 +527,7 @@ watchEffect((onCleanup) => {
if (message.requestId === requestId && payload.requestedPath === requestedPath) {
const absolutePath = payload.absolutePath;
console.log(`[FileManager ${props.sessionId}] 收到 '.' 的绝对路径: ${absolutePath}。开始加载目录。`);
currentPath.value = absolutePath;
// currentPath.value loadDirectory
loadDirectory(absolutePath); // 使 props loadDirectory
initialLoadDone.value = true;
cleanupListeners();
@@ -700,11 +700,8 @@ const clearError = () => {
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<!-- Error Alert Box -->
<div v-if="error" class="error-alert">
<span>{{ error }}</span>
<button @click="clearError" class="close-error-btn" :title="t('common.dismiss')">&times;</button> <!-- Use clearSftpError -->
</div>
<!-- 移除内联错误提示框 -->
<!-- <div v-if="error" class="error-alert"> ... </div> -->
<!-- 1. Initial Loading Indicator -->
<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 .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; }
/* Removed .error style for the main container */
.error-alert {
background-color: #f8d7da;
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;
}
/* 移除 .error-alert 和 .close-error-btn 样式 */
/* .error-alert { ... } */
/* .close-error-btn { ... } */
.file-list-container { flex-grow: 1; overflow-y: auto; position: relative; /* Needed for overlay */ }
.file-list-container.drag-over {
outline: 2px dashed #007bff; /* Blue dashed outline */
@@ -1,10 +1,15 @@
<template>
<div class="status-monitor">
<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 v-else class="status-grid">
<!-- Status items remain here -->
<div class="status-item cpu-model">
<label>CPU 型号:</label>
<!-- 使用 displayCpuModel 计算属性 -->
@@ -18,43 +23,54 @@
</div>
<div class="status-item">
<label>CPU:</label>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${serverStatus.cpuPercent ?? 0}%` }"></div>
<!-- Wrap progress bar and percentage in a 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>
<span>{{ serverStatus.cpuPercent?.toFixed(1) ?? 'N/A' }}%</span>
</div>
<div class="status-item">
<label>内存:</label>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${serverStatus.memPercent ?? 0}%` }"></div>
<div class="value-wrapper">
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${serverStatus.memPercent ?? 0}%` }"></div>
</div>
<span class="mem-disk-details">{{ memDisplay }}</span>
</div>
<span class="mem-disk-details">{{ memDisplay }}</span>
</div>
<!-- Removed v-if, Swap will always show -->
<div class="status-item">
<label>Swap:</label>
<div class="progress-bar-container">
<div class="progress-bar swap-bar" :style="{ width: `${serverStatus.swapPercent ?? 0}%` }"></div>
<div class="value-wrapper">
<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>
<span class="mem-disk-details">{{ swapDisplay }}</span>
</div>
<div class="status-item">
<label>磁盘 (/):</label>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${serverStatus.diskPercent ?? 0}%` }"></div>
<label>磁盘:</label> <!-- 移除 (/) -->
<div class="value-wrapper">
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${serverStatus.diskPercent ?? 0}%` }"></div>
</div>
<span class="mem-disk-details">{{ diskDisplay }}</span>
</div>
<span class="mem-disk-details">{{ diskDisplay }}</span>
</div>
<div class="status-item network-rate">
<label>网络 ({{ serverStatus.netInterface || '...' }}):</label>
<span class="rate down"> {{ formatBytesPerSecond(serverStatus.netRxRate) }}</span>
<span class="rate up"> {{ formatBytesPerSecond(serverStatus.netTxRate) }}</span>
<!-- Wrap rates in a div for alignment -->
<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 v-if="statusError" class="status-error">
错误: {{ statusError }}
</div>
<!-- Error display moved up for correct v-if/v-else-if logic -->
</div>
<!-- Removed extra closing div -->
</template>
<script setup lang="ts">
@@ -207,40 +223,48 @@ const swapDisplay = computed(() => {
.status-item {
display: grid;
/* Adjusted grid columns for better alignment */
grid-template-columns: 65px 1fr auto; /* Label slightly wider */
/* Simplified grid columns: Label | Value Area - Further increased label width */
grid-template-columns: 100px 1fr;
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 {
grid-template-columns: 65px 1fr; /* Label, Value */
gap: 0.5rem;
/* grid-template-columns is inherited */
/* gap is inherited */
margin-bottom: 0.5rem; /* Add some space below CPU model */
}
.cpu-model-value {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
grid-column: 2 / 4; /* Span across the value and percentage columns */
/* No longer needs grid-column span */
text-align: left;
color: #333;
}
/* Specific style for OS name row */
/* Specific style for OS name row - Keep consistent with general status-item */
.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 */
text-align: left;
}
/* Increased specificity to override generic span rule */
/* OS name value should just occupy the second column */
.status-item.os-name .os-name-value {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
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;
min-width: auto; /* Override generic min-width */
}
@@ -249,7 +273,7 @@ const swapDisplay = computed(() => {
.status-item label {
font-weight: bold;
color: #555;
text-align: right;
text-align: left; /* 改为左对齐 */
white-space: nowrap;
}
@@ -278,32 +302,59 @@ const swapDisplay = computed(() => {
.status-item span:not(.cpu-model-value) { /* Style for percentage spans */
font-variant-numeric: tabular-nums; /* Keep numbers aligned */
min-width: 45px; /* Ensure space for percentage */
text-align: right;
text-align: left; /* 改为左对齐 */
color: #333;
}
.mem-disk-details {
font-size: 0.9em; /* Slightly smaller font for details */
white-space: nowrap;
text-align: right;
text-align: left; /* 改为左对齐 */
}
/* Network Rate Styles */
/* Network Rate Styles - uses the 2-col grid */
.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 */
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 {
font-size: 0.9em;
white-space: nowrap;
text-align: right;
min-width: 80px; /* Adjust as needed */
text-align: left; /* 改为左对齐 */
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 {
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 {
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>
+13 -4
View File
@@ -118,11 +118,20 @@ onMounted(() => {
// isActive prop fit resize
watch(() => props.isActive, (newValue) => {
if (newValue && terminal && terminalRef.value && terminalRef.value.offsetHeight > 0) {
console.log(`[Terminal ${props.sessionId}] Tab became active, performing immediate fit and resize.`);
// 使 nextTick DOM
if (newValue && terminal && terminalRef.value) {
// DOM fit
console.log(`[Terminal ${props.sessionId}] 标签变为活动状态,准备调整尺寸。`); //
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">
import { PropType } from 'vue';
// ()
interface SessionTabInfo {
sessionId: string;
connectionName: string; //
}
//
import type { SessionTabInfoWithStatus } from '../stores/session.store'; //
// Props
const props = defineProps({
sessions: {
type: Array as PropType<SessionTabInfo[]>,
type: Array as PropType<SessionTabInfoWithStatus[]>, // 使
required: true,
},
activeSessionId: {
@@ -44,6 +40,8 @@ const closeSession = (event: MouseEvent, sessionId: string) => {
@click="activateSession(session.sessionId)"
:title="session.connectionName"
>
<!-- 添加状态点 -->
<span :class="['status-dot', `status-${session.status}`]"></span>
<span class="tab-name">{{ session.connectionName }}</span>
<button class="close-tab-button" @click="closeSession($event, session.sessionId)" title="关闭标签页">
&times; <!-- 使用 HTML 实体 '×' -->
@@ -67,6 +65,22 @@ const closeSession = (event: MouseEvent, sessionId: string) => {
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 {
list-style: none;
padding: 0;
@@ -105,8 +119,10 @@ const closeSession = (event: MouseEvent, sessionId: string) => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 1.5rem; /* 为关闭按钮留出足够空间 */
/* margin-right: 1.5rem; */ /* 调整右边距,因为关闭按钮现在是 flex item */
line-height: normal; /* 默认行高 */
flex-grow: 1; /* 允许名称伸展 */
margin-left: 4px; /* 在状态点和名称之间添加一点间距 */
}
.close-tab-button {
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import { storeToRefs } from 'pinia';
const notificationsStore = useUiNotificationsStore();
const { notifications } = storeToRefs(notificationsStore);
const getIconClass = (type: string) => {
switch (type) {
case 'success': return 'fas fa-check-circle';
case 'error': return 'fas fa-times-circle';
case 'info': return 'fas fa-info-circle';
case 'warning': return 'fas fa-exclamation-triangle';
default: return '';
}
};
const getContainerClass = (type: string) => {
return `notification-item notification-${type}`;
};
</script>
<template>
<div class="notification-container">
<transition-group name="notification-fade" tag="div">
<div
v-for="notification in notifications"
:key="notification.id"
:class="getContainerClass(notification.type)"
>
<i :class="['notification-icon', getIconClass(notification.type)]"></i>
<span class="notification-message">{{ notification.message }}</span>
<button
class="notification-close-btn"
@click="notificationsStore.removeNotification(notification.id)"
>
&times;
</button>
</div>
</transition-group>
</div>
</template>
<style scoped>
.notification-container {
position: fixed;
top: 1rem; /* 距离顶部 */
right: 1rem; /* 距离右侧 */
z-index: 1100; /* 比其他元素层级高 */
display: flex;
flex-direction: column;
align-items: flex-end; /* 从右侧对齐 */
}
.notification-item {
background-color: #fff;
color: #fff;
padding: 0.8rem 1.2rem;
margin-bottom: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
min-width: 250px; /* 最小宽度 */
max-width: 400px; /* 最大宽度 */
opacity: 0.95;
transition: all 0.5s ease; /* 添加过渡效果 */
}
.notification-success { background-color: #28a745; } /* 绿色 */
.notification-error { background-color: #dc3545; } /* 红色 */
.notification-info { background-color: #17a2b8; } /* 蓝色 */
.notification-warning { background-color: #ffc107; color: #333; } /* 黄色,文字用深色 */
.notification-icon {
margin-right: 0.8rem;
font-size: 1.2em;
}
.notification-message {
flex-grow: 1;
word-wrap: break-word; /* 允许长单词换行 */
}
.notification-close-btn {
background: none;
border: none;
color: inherit; /* 继承父元素颜色 */
opacity: 0.7;
cursor: pointer;
font-size: 1.2em;
margin-left: 1rem;
padding: 0 0.3rem;
}
.notification-close-btn:hover {
opacity: 1;
}
/* 过渡动画 */
.notification-fade-enter-active,
.notification-fade-leave-active {
transition: all 0.5s ease;
}
.notification-fade-enter-from,
.notification-fade-leave-to {
opacity: 0;
transform: translateX(30px); /* 从右侧滑入 */
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue'; // ref
import { storeToRefs } from 'pinia';
// import { useRouter } from 'vue-router'; // router
import { useI18n } from 'vue-i18n';
@@ -22,6 +22,9 @@ const tagsStore = useTagsStore();
const { connections, isLoading: connectionsLoading, error: connectionsError } = storeToRefs(connectionsStore);
const { tags, isLoading: tagsLoading, error: tagsError } = storeToRefs(tagsStore);
//
const searchTerm = ref('');
//
const contextMenuVisible = ref(false);
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 groupedConnections = computed(() => {
//
const filteredAndGroupedConnections = computed(() => {
const groups: Record<string, ConnectionInfo[]> = {};
const untagged: ConnectionInfo[] = [];
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) {
let tagged = false; //
conn.tag_ids.forEach(tagId => {
const tag = tagMap.get(tagId);
const groupName = tag ? tag.name : t('workspaceConnectionList.untagged'); // Fallback if tag not found
if (!groups[groupName]) {
groups[groupName] = [];
if (expandedGroups.value[groupName] === undefined) {
expandedGroups.value[groupName] = true; //
//
if (tag) {
const groupName = tag.name;
if (!groups[groupName]) {
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 {
untagged.push(conn);
}
});
//
// 3.
for (const groupName in groups) {
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));
//
const sortedGroupNames = Object.keys(groups).sort();
const result: { groupName: string; connections: ConnectionInfo[] }[] = sortedGroupNames.map(name => ({
groupName: name,
@@ -150,34 +174,62 @@ const handleOpenInNewTab = (connectionId: number) => {
</div>
<div v-else-if="connections.length === 0" class="no-connections">
{{ t('connections.noConnections') }}
<button @click="handleMenuAction('add')">{{ t('connections.addConnection') }}</button>
<!-- 保留添加按钮即使列表为空 -->
<!-- <button @click="handleMenuAction('add')">{{ t('connections.addConnection') }}</button> -->
</div>
<div v-else>
<!-- 添加连接按钮总是在顶部 -->
<button class="add-connection-button" @click="handleMenuAction('add')">
<i class="fas fa-plus"></i> {{ t('connections.addConnection') }}
<!-- 搜索和添加栏 -->
<div class="search-add-bar">
<input
type="text"
v-model="searchTerm"
:placeholder="t('workspaceConnectionList.searchPlaceholder')"
class="search-input"
/>
<button class="add-button" @click="handleMenuAction('add')" :title="t('connections.addConnection')">
<i class="fas fa-plus"></i>
</button>
<div v-for="group in groupedConnections" :key="group.groupName" class="connection-group">
<div class="group-header" @click="toggleGroup(group.groupName)">
<i :class="['fas', expandedGroups[group.groupName] ? 'fa-chevron-down' : 'fa-chevron-right']"></i>
<span>{{ group.groupName }}</span>
</div>
<!-- 连接列表区域 -->
<div 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>
<ul v-show="expandedGroups[group.groupName]" class="connection-items">
<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>
<!-- 移除重复的 ul -->
</div>
</div>
@@ -200,14 +252,58 @@ const handleOpenInNewTab = (connectionId: number) => {
<style scoped>
.workspace-connection-list {
padding: 0.5rem 0;
height: 100%;
overflow-y: auto;
background-color: #f8f9fa; /* Slightly different background */
display: flex;
flex-direction: column;
overflow: hidden; /* 防止内部滚动条影响布局 */
background-color: #f8f9fa;
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;
text-align: center;
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 { 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
@@ -49,8 +51,9 @@ export function createSftpActionsManager(
const fileList = ref<FileListItem[]>([]);
const isLoading = ref<boolean>(false);
const error = ref<string | null>(null);
// const error = ref<string | null>(null); // 不再使用本地 error ref
const instanceSessionId = sessionId; // 保存会话 ID 用于日志
const uiNotificationsStore = useUiNotificationsStore(); // 初始化 UI 通知 store
// 用于存储注销函数的数组
const unregisterCallbacks: (() => void)[] = [];
@@ -62,25 +65,24 @@ export function createSftpActionsManager(
unregisterCallbacks.length = 0; // 清空数组
};
// 清除错误状态的函数
const clearSftpError = () => {
error.value = null;
};
// 不再需要 clearSftpError 函数
// const clearSftpError = () => { ... };
// --- Action Methods ---
const loadDirectory = (path: string) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
// 使用通知 store 显示错误
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
isLoading.value = false;
fileList.value = [];
console.warn(`[SFTP ${instanceSessionId}] Attempted to load directory ${path} but SFTP is not ready.`);
console.warn(`[SFTP ${instanceSessionId}] 尝试加载目录 ${path} SFTP 未就绪。`); // 日志改为中文
return;
}
console.log(`[SFTP ${instanceSessionId}] Loading directory: ${path}`);
console.log(`[SFTP ${instanceSessionId}] 正在加载目录: ${path}`); // 日志改为中文
isLoading.value = true;
error.value = null;
// error.value = null; // 不再需要
currentPathRef.value = path; // 更新外部 ref
const requestId = generateRequestId();
sendMessage({ type: 'sftp:readdir', requestId: requestId, payload: { path } });
@@ -88,8 +90,8 @@ export function createSftpActionsManager(
const createDirectory = (newDirName: string) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[SFTP ${instanceSessionId}] Attempted to create directory ${newDirName} but SFTP is not ready.`);
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
console.warn(`[SFTP ${instanceSessionId}] 尝试创建目录 ${newDirName} SFTP 未就绪。`); // 日志改为中文
return;
}
const newFolderPath = joinPath(currentPathRef.value, newDirName);
@@ -99,8 +101,8 @@ export function createSftpActionsManager(
const createFile = (newFileName: string) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[SFTP ${instanceSessionId}] Attempted to create file ${newFileName} but SFTP is not ready.`);
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
console.warn(`[SFTP ${instanceSessionId}] 尝试创建文件 ${newFileName} SFTP 未就绪。`); // 日志改为中文
return;
}
const newFilePath = joinPath(currentPathRef.value, newFileName);
@@ -114,8 +116,8 @@ export function createSftpActionsManager(
const deleteItems = (items: FileListItem[]) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[SFTP ${instanceSessionId}] Attempted to delete items but SFTP is not ready.`);
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
console.warn(`[SFTP ${instanceSessionId}] 尝试删除项目但 SFTP 未就绪。`); // 日志改为中文
return;
}
if (items.length === 0) return;
@@ -129,8 +131,8 @@ export function createSftpActionsManager(
const renameItem = (item: FileListItem, newName: string) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[SFTP ${instanceSessionId}] Attempted to rename item ${item.filename} but SFTP is not ready.`);
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
console.warn(`[SFTP ${instanceSessionId}] 尝试重命名项目 ${item.filename} SFTP 未就绪。`); // 日志改为中文
return;
}
if (!newName || item.filename === newName) return;
@@ -142,8 +144,8 @@ export function createSftpActionsManager(
const changePermissions = (item: FileListItem, mode: number) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[SFTP ${instanceSessionId}] Attempted to change permissions for ${item.filename} but SFTP is not ready.`);
uiNotificationsStore.showError(t('fileManager.errors.sftpNotReady'), { timeout: 5000 }); // 使用 uiNotificationsStore
console.warn(`[SFTP ${instanceSessionId}] 尝试修改 ${item.filename} 的权限但 SFTP 未就绪。`); // 日志改为中文
return;
}
const targetPath = joinPath(currentPathRef.value, item.filename);
@@ -155,8 +157,10 @@ export function createSftpActionsManager(
const readFile = (path: string): Promise<EditorFileContent> => {
return new Promise((resolve, reject) => {
if (!isSftpReady.value) {
console.warn(`[SFTP ${instanceSessionId}] Attempted to read file ${path} but SFTP is not ready.`);
return reject(new Error(t('fileManager.errors.sftpNotReady')));
const errMsg = t('fileManager.errors.sftpNotReady');
console.warn(`[SFTP ${instanceSessionId}] 尝试读取文件 ${path} 但 SFTP 未就绪。`); // 日志改为中文
uiNotificationsStore.showError(errMsg, { timeout: 5000 }); // 使用 uiNotificationsStore
return reject(new Error(errMsg));
}
const requestId = generateRequestId();
let unregisterSuccess: (() => void) | null = null;
@@ -165,7 +169,9 @@ export function createSftpActionsManager(
const timeoutId = setTimeout(() => {
unregisterSuccess?.();
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 秒超时
unregisterSuccess = onMessage('sftp:readfile:success', (payload: MessagePayload, message: WebSocketMessage) => {
@@ -186,7 +192,9 @@ export function createSftpActionsManager(
clearTimeout(timeoutId);
unregisterSuccess?.();
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> => {
return new Promise((resolve, reject) => {
if (!isSftpReady.value) {
console.warn(`[SFTP ${instanceSessionId}] Attempted to write file ${path} but SFTP is not ready.`);
return reject(new Error(t('fileManager.errors.sftpNotReady')));
const errMsg = 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 encoding: 'utf8' | 'base64' = 'utf8'; // 假设总是 utf8
@@ -208,7 +218,9 @@ export function createSftpActionsManager(
const timeoutId = setTimeout(() => {
unregisterSuccess?.();
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 秒超时
unregisterSuccess = onMessage('sftp:writefile:success', (payload: MessagePayload, message: WebSocketMessage) => {
@@ -227,7 +239,9 @@ export function createSftpActionsManager(
clearTimeout(timeoutId);
unregisterSuccess?.();
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[]
const fileListPayload = payload as FileListItem[];
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);
isLoading.value = false;
error.value = null;
// error.value = null; // 不再需要
} 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
const errorPayload = payload as string;
if (message.path === currentPathRef.value) {
console.error(`[SFTP ${instanceSessionId}] Error loading directory ${message.path}:`, errorPayload);
error.value = errorPayload;
console.error(`[SFTP ${instanceSessionId}] 加载目录 ${message.path} 出错:`, errorPayload); // 日志改为中文
// error.value = errorPayload; // 使用通知
uiNotificationsStore.showError(`${t('fileManager.errors.loadDirectoryFailed')}: ${errorPayload}`, { timeout: 5000 }); // 使用 uiNotificationsStore, 添加 i18n key
isLoading.value = false;
}
};
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);
error.value = null;
// error.value = null; // 不再需要
};
const onActionError = (payload: MessagePayload, message: WebSocketMessage) => {
@@ -284,7 +299,8 @@ export function createSftpActionsManager(
'sftp:writefile:error': t('fileManager.errors.saveFailed'),
};
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 ---
@@ -309,7 +325,7 @@ export function createSftpActionsManager(
// State
fileList: readonly(fileList),
isLoading: readonly(isLoading),
error: readonly(error),
// error: readonly(error), // 移除 error
// Methods
loadDirectory,
@@ -321,7 +337,7 @@ export function createSftpActionsManager(
readFile,
writeFile,
joinPath, // 暴露辅助函数
clearSftpError,
// clearSftpError, // 移除 clearSftpError
// Cleanup function
currentPath: readonly(currentPathRef), // 暴露只读的当前路径 ref
@@ -91,9 +91,12 @@ export function createStatusMonitorManager(sessionId: string, wsDeps: StatusMoni
unregisterAllStatusHandlers();
// 连接断开时清除状态
serverStatus.value = null;
statusError.value = '连接已断开'; // 或者使用 i18n
// 只有在之前连接成功的情况下才设置断开错误
if (oldValue === true) {
statusError.value = '连接已断开'; // 或者使用 i18n
}
}
}, { immediate: true }); // immediate: true 确保初始状态下也会执行一次
}); // 移除 immediate: true,避免初始设置错误状态
// --- 清理函数 ---
const cleanup = () => {
@@ -1,5 +1,9 @@
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
@@ -14,7 +18,7 @@ export function createWebSocketConnectionManager(sessionId: string, dbConnection
// --- Instance State ---
// 每个实例拥有独立的 WebSocket 对象、状态和消息处理器
const ws = shallowRef<WebSocket | null>(null); // WebSocket 实例
const connectionStatus = ref<ConnectionStatus>('disconnected'); // 连接状态
const connectionStatus = ref<WsConnectionStatus>('disconnected'); // 连接状态 (使用导出的类型)
const statusMessage = ref<string>(''); // 状态描述文本
const isSftpReady = ref<boolean>(false); // SFTP 是否就绪
const messageHandlers = new Map<string, Set<MessageHandler>>(); // 此实例的消息处理器注册表
+11 -1
View File
@@ -247,7 +247,12 @@
"saving": "Saving",
"saveSuccess": "Save successful",
"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": {
"title": "Tag Management",
@@ -506,5 +511,10 @@
"SERVER_ERROR": "Server Error",
"DATABASE_MIGRATION": "Database Migration"
}
},
"workspaceConnectionList": {
"untagged": "Untagged",
"searchPlaceholder": "Search name or host...",
"noResults": "No connections found matching \"{searchTerm}\"."
}
}
+8 -2
View File
@@ -251,7 +251,11 @@
"saving": "正在保存",
"saveSuccess": "保存成功",
"saveError": "保存出错",
"editPathTooltip": "点击路径进行编辑"
"editPathTooltip": "点击路径进行编辑",
"noActiveSession": "无活动会话"
},
"statusMonitor": {
"noActiveSession": "无活动会话"
},
"tags": {
"title": "标签管理",
@@ -512,6 +516,8 @@
}
},
"workspaceConnectionList": {
"untagged": "未标记"
"untagged": "未标记",
"searchPlaceholder": "搜索名称或主机...",
"noResults": "未找到匹配 \"{searchTerm}\" 的连接。"
}
}
+4
View File
@@ -6,6 +6,10 @@ import router from './router'; // 引入我们创建的 router
import i18n from './i18n'; // 引入 i18n 实例
import { useSettingsStore } from './stores/settings.store'; // 引入 Settings Store
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 实例
pinia.use(piniaPluginPersistedstate); // 使用持久化插件
+21 -1
View File
@@ -4,7 +4,8 @@ import { useI18n } from 'vue-i18n';
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 { createSshTerminalManager, type SshTerminalDependencies } from '../composables/useSshTerminal';
import { createStatusMonitorManager, type StatusMonitorDependencies } from '../composables/useStatusMonitor';
@@ -31,6 +32,14 @@ export interface SessionState {
currentSftpPath: Ref<string>; // SFTP 当前路径 (可能需要保留在此处或移至 SftpManager 内部)
}
// 为标签栏定义包含状态的类型
export interface SessionTabInfoWithStatus {
sessionId: string;
connectionName: string;
status: WsConnectionStatus; // 添加状态字段
}
export const useSessionStore = defineStore('session', () => {
// --- 依赖 ---
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 => {
if (!activeSessionId.value) return null;
return sessions.value.get(activeSessionId.value) || null;
@@ -240,6 +259,7 @@ export const useSessionStore = defineStore('session', () => {
activeSessionId,
// Getters
sessionTabs,
sessionTabsWithStatus, // 导出新的 getter
activeSession,
// Actions
openNewSession,
@@ -0,0 +1,68 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
// 定义通知对象的接口
export interface UINotification {
id: number;
type: 'success' | 'error' | 'info' | 'warning';
message: string;
timeout?: number; // 可选的自动关闭超时时间 (毫秒)
}
export const useUiNotificationsStore = defineStore('uiNotifications', () => {
const notifications = ref<UINotification[]>([]);
let nextId = 0;
/**
*
* @param notification - ( type message)
*/
const addNotification = (notification: Omit<UINotification, 'id'>) => {
const id = nextId++;
const newNotification: UINotification = { ...notification, id };
notifications.value.push(newNotification);
// 如果设置了超时,则在超时后自动移除通知
if (notification.timeout) {
setTimeout(() => {
removeNotification(id);
}, notification.timeout);
}
};
/**
*
* @param id - ID
*/
const removeNotification = (id: number) => {
notifications.value = notifications.value.filter(n => n.id !== id);
};
// 便捷方法
const showError = (message: string, options: { timeout?: number } = {}) => {
addNotification({ type: 'error', message, timeout: options.timeout ?? 5000 }); // 默认 5 秒超时
};
const showSuccess = (message: string, options: { timeout?: number } = {}) => {
addNotification({ type: 'success', message, timeout: options.timeout ?? 3000 }); // 默认 3 秒超时
};
const showInfo = (message: string, options: { timeout?: number } = {}) => {
addNotification({ type: 'info', message, timeout: options.timeout ?? 3000 });
};
const showWarning = (message: string, options: { timeout?: number } = {}) => {
addNotification({ type: 'warning', message, timeout: options.timeout ?? 5000 });
};
return {
notifications,
addNotification,
removeNotification,
showError,
showSuccess,
showInfo,
showWarning,
};
});
+7
View File
@@ -0,0 +1,7 @@
// packages/frontend/src/types/splitpanes.d.ts
declare module 'splitpanes' {
import { DefineComponent } from 'vue';
const Splitpanes: DefineComponent<any, any, any>;
const Pane: DefineComponent<any, any, any>;
export { Splitpanes, Pane };
}
+226 -163
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, computed, ref } from 'vue'; //
import { onMounted, onBeforeUnmount, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia'; // storeToRefs
import { storeToRefs } from 'pinia';
import TerminalComponent from '../components/Terminal.vue';
import FileManagerComponent from '../components/FileManager.vue';
import StatusMonitorComponent from '../components/StatusMonitor.vue';
import WorkspaceConnectionListComponent from '../components/WorkspaceConnectionList.vue';
import AddConnectionFormComponent from '../components/AddConnectionForm.vue';
import TerminalTabBar from '../components/TerminalTabBar.vue';
import { useSessionStore } from '../stores/session.store'; // Session Store
import type { ConnectionInfo } from '../stores/connections.store'; // ConnectionInfo
import { useSessionStore, type SessionTabInfoWithStatus } from '../stores/session.store';
import type { ConnectionInfo } from '../stores/connections.store';
// splitpanes
import { Splitpanes, Pane } from 'splitpanes';
// FileManagerComponent prop
import type { SftpManagerInstance } from '../stores/session.store';
@@ -18,8 +20,7 @@ const { t } = useI18n();
const sessionStore = useSessionStore();
// --- Store Getters ---
// 使 storeToRefs 使 sessionStore.xxx
const { sessionTabs, activeSessionId, activeSession } = storeToRefs(sessionStore);
const { sessionTabsWithStatus, activeSessionId, activeSession } = storeToRefs(sessionStore);
// --- UI () ---
const showAddEditForm = ref(false);
@@ -28,25 +29,13 @@ const connectionToEdit = ref<ConnectionInfo | null>(null);
// --- ---
onMounted(() => {
console.log('[工作区视图] 组件已挂载。');
//
});
onBeforeUnmount(() => {
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 ) ---
const handleRequestAddConnection = () => {
connectionToEdit.value = null;
@@ -72,102 +61,118 @@ onBeforeUnmount(() => {
console.log('[工作区视图] 连接已更新');
handleFormClose();
};
// --- ---
// findConnectionInfo, openNewSession, activateSession, closeSession,
// handleConnectRequest, handleOpenNewSession sessionStore
</script>
</script>
<template>
<div class="workspace-view">
<!-- 标签栏: 绑定到 store 的状态和 actions -->
<TerminalTabBar
:sessions="sessionTabs"
:sessions="sessionTabsWithStatus"
:active-session-id="activeSessionId"
@activate-session="sessionStore.activateSession"
@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">
<!-- 左侧边栏: 事件绑定到 store actions -->
<div class="left-sidebar">
<WorkspaceConnectionListComponent
@connect-request="sessionStore.handleConnectRequest"
@open-new-session="sessionStore.handleOpenNewSession"
@request-add-connection="handleRequestAddConnection"
@request-edit-connection="handleRequestEditConnection"
/>
</div>
<!-- 主工作区容器 -->
<div class="main-workspace-container">
<!-- 会话区域: 循环 store 中的 sessions Map -->
<!-- 注意: v-for sessions.values() 可能不是响应式的因为 sessions shallowRef -->
<!-- 改为 v-for session in sessionTabs然后通过 session.sessionId 获取完整 session -->
<div
v-for="tabInfo in sessionTabs"
:key="tabInfo.sessionId"
v-show="tabInfo.sessionId === activeSessionId"
class="main-workspace-area-session"
>
<!-- 获取当前循环的完整 session 对象 -->
<template v-if="sessionStore.sessions.get(tabInfo.sessionId)">
<div class="left-pane">
<div class="terminal-wrapper" :data-session-id="tabInfo.sessionId">
<!-- TerminalComponent: 事件绑定到 activeSession 的管理器方法 -->
<TerminalComponent
:key="tabInfo.sessionId"
:session-id="tabInfo.sessionId"
:is-active="tabInfo.sessionId === activeSessionId"
@ready="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalReady"
@data="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalData"
@resize="(dims) => { console.log(`[WorkspaceView ${tabInfo.sessionId}] Received resize event:`, dims); sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalResize(dims); }"
/>
</div>
<div class="file-manager-wrapper">
<!-- FileManagerComponent: Props 绑定到 activeSession 的管理器 -->
<!-- 确保传递正确的 wsDeps -->
<FileManagerComponent
:key="tabInfo.sessionId"
:session-id="tabInfo.sessionId"
:db-connection-id="sessionStore.sessions.get(tabInfo.sessionId)!.connectionId"
:sftp-manager="sessionStore.sessions.get(tabInfo.sessionId)!.sftpManager"
:ws-deps="{
sendMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.sendMessage,
onMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.onMessage,
isConnected: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isConnected,
isSftpReady: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.isSftpReady
}"
/>
</div>
</div>
<div class="status-monitor-wrapper">
<!-- StatusMonitorComponent: Props 绑定到 activeSession 的管理器状态 -->
<StatusMonitorComponent
:key="tabInfo.sessionId"
:session-id="tabInfo.sessionId"
:server-status="(sessionStore.sessions.get(tabInfo.sessionId)?.statusMonitorManager.serverStatus.value) ?? null"
:status-error="(sessionStore.sessions.get(tabInfo.sessionId)?.statusMonitorManager.statusError.value) ?? null"
/>
</div>
</template>
</div>
<!-- 占位符 -->
<div v-if="!activeSessionId" class="main-workspace-area placeholder">
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
<p>{{ t('workspace.selectConnectionHint') }}</p>
</div>
</div>
<!-- 最外层左右分割 (连接列表 | 中间区域 + 右侧区域) -->
<splitpanes class="default-theme" :horizontal="false" style="height: 100%">
<!-- 左侧边栏 Pane -->
<pane size="20" min-size="15" class="sidebar-pane">
<WorkspaceConnectionListComponent
@connect-request="sessionStore.handleConnectRequest"
@open-new-session="sessionStore.handleOpenNewSession"
@request-add-connection="handleRequestAddConnection"
@request-edit-connection="handleRequestEditConnection"
/>
</pane>
<!-- 中间区域 Pane (包含终端和文件管理器) -->
<pane size="65" min-size="30">
<!-- 上下分割 (终端 | 文件管理器) -->
<splitpanes :horizontal="true" style="height: 100%">
<!-- 终端 Pane -->
<pane size="65" min-size="20" class="terminal-pane">
<!-- 会话终端区域: 只渲染活动会话的终端 -->
<div
v-for="tabInfo in sessionTabsWithStatus"
:key="tabInfo.sessionId"
v-show="tabInfo.sessionId === activeSessionId"
class="terminal-session-wrapper"
>
<!-- 移除 v-if依赖外层 v-show 控制显隐 -->
<!-- :key 绑定到 tabInfo.sessionId 保证每个会话对应唯一组件实例 -->
<!-- :is-active 动态绑定 -->
<TerminalComponent
:key="tabInfo.sessionId"
:session-id="tabInfo.sessionId"
:is-active="tabInfo.sessionId === activeSessionId"
@ready="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalReady"
@data="sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalData"
@resize="(dims) => { console.log(`[工作区视图 ${tabInfo.sessionId}] 收到 resize 事件:`, dims); sessionStore.sessions.get(tabInfo.sessionId)?.terminalManager.handleTerminalResize(dims); }"
/>
</div>
<!-- 终端占位符 -->
<div v-if="!activeSessionId" class="terminal-placeholder">
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
<p>{{ t('workspace.selectConnectionHint') }}</p>
</div>
<!-- 终端占位符 -->
<div v-if="!activeSessionId" class="terminal-placeholder">
<h2>{{ t('workspace.selectConnectionPrompt') }}</h2>
<p>{{ t('workspace.selectConnectionHint') }}</p>
</div>
</pane>
<!-- 文件管理器 Pane -->
<pane size="35" min-size="15" class="file-manager-pane">
<!-- 为每个会话渲染文件管理器实例 v-show 控制 -->
<div
v-for="tabInfo in sessionTabsWithStatus"
:key="tabInfo.sessionId + '-fm-wrapper'"
v-show="tabInfo.sessionId === activeSessionId"
class="file-manager-wrapper"
>
<FileManagerComponent
v-if="sessionStore.sessions.get(tabInfo.sessionId)"
:key="tabInfo.sessionId + '-fm'"
:session-id="tabInfo.sessionId"
:db-connection-id="sessionStore.sessions.get(tabInfo.sessionId)!.connectionId"
:sftp-manager="sessionStore.sessions.get(tabInfo.sessionId)!.sftpManager"
:ws-deps="{
sendMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.sendMessage,
onMessage: sessionStore.sessions.get(tabInfo.sessionId)!.wsManager.onMessage,
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>
<!-- 添加/编辑连接表单模态框 (保持不变) -->
@@ -185,23 +190,10 @@ onBeforeUnmount(() => {
.workspace-view {
display: flex;
flex-direction: column;
height: calc(100vh - 60px - 30px - 60px - 2rem);
height: calc(100vh - 60px - 30px - 2rem); /* 调整以适应您的 header/footer/padding */
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 {
display: flex;
flex: 1;
@@ -209,65 +201,82 @@ onBeforeUnmount(() => {
border-top: 1px solid #ccc;
}
.left-sidebar {
width: 250px;
min-width: 200px;
height: 100%;
border-right: 2px solid #ccc;
overflow-y: auto;
/* 为 Pane 添加一些基本样式 */
.sidebar-pane, /* 用于左右侧边栏 */
.terminal-pane,
.file-manager-pane {
display: flex;
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;
display: flex;
overflow: hidden;
position: relative;
}
.main-workspace-area-session {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
overflow: hidden;
}
.left-pane {
flex: 1;
/* 终端会话包装器 */
.terminal-session-wrapper {
flex-grow: 1; /* 填充 terminal-pane */
display: flex;
flex-direction: column;
min-width: 300px;
overflow: hidden;
}
.terminal-wrapper {
flex: 1;
display: flex;
flex-direction: column;
background-color: #1e1e1e;
overflow: hidden;
}
/* 文件管理器包装器 (内部组件应填充) */
.file-manager-wrapper {
flex: 1;
display: flex;
flex-direction: column;
border-top: 2px solid #ccc;
overflow: hidden;
}
.status-monitor-wrapper {
width: 250px;
min-width: 200px;
border-left: 2px solid #ccc;
overflow: hidden;
/* 文件管理器包装器 (内部组件应填充) */
.file-manager-wrapper {
flex: 1;
display: flex;
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;
top: 0;
left: 0;
@@ -280,14 +289,68 @@ onBeforeUnmount(() => {
text-align: center;
color: #6c757d;
padding: 2rem;
background-color: #f8f9fa;
background-color: #f8f9fa; /* 与 pane 背景一致 */
}
.main-workspace-area.placeholder h2 {
.terminal-placeholder h2 {
margin-bottom: 0.5rem;
font-weight: 300;
color: #495057;
}
.main-workspace-area.placeholder p {
.terminal-placeholder p {
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>