feat(workspace): add workbench layout and traffic totals

Rework the default /workspace layout into a three-column view
with a left-side Workbench, centered terminal, and right-side
status monitor.

Add a new Workbench pane that groups file manager, command
history, and editor into tabs while preserving panel state.
Extend server status data to expose cumulative network upload
and download totals since boot, and show them in the monitor.

Include a lightweight migration for the old default layout and
update related locale strings, pane metadata, and knowledge
base records.
This commit is contained in:
yinjianm
2026-03-25 03:58:45 +08:00
parent 33a027e809
commit f2f9c754f8
19 changed files with 511 additions and 98 deletions
+1
View File
@@ -4,3 +4,4 @@
- 2026-03-25:初始化 `.helloagents/` 知识库骨架与首批模块文档,不代表源码功能变更。
- 2026-03-25:新增 GHCR Docker 发布 workflow,并将 `docker-compose.yml` 的三个业务镜像切换到 `ghcr.io/micah123321/*`
- 2026-03-25`/workspace` 默认布局改为“左侧 Workbench + 中央视终端 + 右侧状态监控”,并在状态监控中新增开机累计上下行流量展示。
@@ -0,0 +1 @@
{"status":"completed","completed":8,"failed":0,"pending":0,"total":8,"done":8,"percent":100,"current":"已完成 workbench 工作台、累计流量监控和构建验证","updated_at":"2026-03-25 12:33:00"}
@@ -0,0 +1,70 @@
# 变更提案: workspace-workbench-monitor
## 元信息
```yaml
类型: 功能增强
方案类型: implementation
优先级: P1
状态: 实施中
状态说明: 已完成实现、构建验证通过,待归档
创建: 2026-03-25
```
---
## 1. 需求
### 背景
当前 `/workspace` 主工作区把 `命令历史``文件管理``编辑器` 拆成多个独立 pane,占用了终端垂直空间;状态监控只展示瞬时上下行速率,没有展示系统自开机以来的累计流量。
### 目标
- 仅改造 `/workspace` 主工作区布局。
- 采用方案 B:左侧 `Workbench` Tab 容器承载 `历史命令 / 文件 / 编辑器`,中央终端保持完整高度,右侧保留状态监控。
- 在右侧状态监控新增“开机累计上/下行流量”展示。
### 约束条件
```yaml
范围约束: 只改 /workspace 主工作区,不调整其它页面和弹层体系
架构约束: 延续现有 Vue 3 + Pinia + splitpanes 布局体系,不引入新布局库
后端约束: 仅基于现有 /proc/net/dev 采集链路扩展累计流量字段
部署约束: 当前只考虑 amd64,不涉及 Docker / GHCR 变更
```
### 验收标准
- [ ] `/workspace` 默认主布局变为“左侧 Workbench + 中央终端 + 右侧状态监控”
- [ ] Workbench 内可切换 `命令历史 / 文件 / 编辑器` 三个标签页
- [ ] 右侧状态监控新增开机累计下载 / 上传流量显示
- [ ] 前后端构建通过,类型链闭合
---
## 2. 方案
### 技术方案
新增前端 `WorkspaceWorkbench.vue` 组件,作为新的布局 pane,在内部用 tab 容器组合 `CommandHistoryView``FileManager``FileEditorContainer`。布局层新增 `workbench` pane 类型,并把默认布局改成“三栏工作台”。为兼容已有保存布局,增加一次仅针对旧默认布局的轻量迁移逻辑。后端则在状态轮询时直接复用 `/proc/net/dev` 已解析的总字节数,新增 `netRxTotalBytes` / `netTxTotalBytes` 字段并透传到前端状态监控。
### 影响范围
```yaml
涉及模块:
- frontend: /workspace 布局、状态监控、i18n、前端类型
- backend: 状态监控服务累计流量字段
预计变更文件: 10+
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 已保存的旧布局不自动切到新工作台 | 中 | 增加仅针对旧默认布局的迁移逻辑,避免误伤自定义布局 |
| 文件管理器在 tab 切换后状态丢失 | 中 | Workbench 内使用 `v-show` 保持三个面板常驻 |
| 累计流量字段前后端类型不一致 | 低 | 同步更新后端接口、本地类型与状态监控展示 |
---
## 3. 技术决策
### workspace-workbench-monitor#D001: 采用方案 B 的三栏工作台
**日期**: 2026-03-25
**状态**: ✅采纳
**决策**: 左侧集中工作台、中央终端、右侧状态监控。
**理由**: 保住终端主区高度,同时让文件/命令/编辑入口集中到同一容器,符合用户确认的 xterminal 参考方向。
**影响**: 影响 `/workspace` 默认布局、布局配置器与状态监控展示逻辑。
@@ -0,0 +1,58 @@
# 任务清单: workspace-workbench-monitor
```yaml
@feature: workspace-workbench-monitor
@created: 2026-03-25
@status: completed
@mode: R3
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 8 | 0 | 0 | 8 |
---
## 任务列表
### 1. 方案包与布局基础
- [√] 1.1 创建 `/workspace` Workbench 改造方案包并记录方案 B | depends_on: []
- [√] 1.2 新增 `workbench` pane 类型与默认布局迁移逻辑 | depends_on: [1.1]
### 2. 前端工作台布局
- [√] 2.1 新增 `WorkspaceWorkbench.vue`,整合命令历史 / 文件 / 编辑器 tab | depends_on: [1.2]
- [√] 2.2 接入 `LayoutRenderer``LayoutConfigurator`、设置侧栏宽度类型链 | depends_on: [2.1]
### 3. 状态监控扩展
- [√] 3.1 后端状态监控新增累计上下行流量字段 | depends_on: [1.1]
- [√] 3.2 前端状态监控展示累计流量并补充 i18n | depends_on: [3.1]
### 4. 验证与知识库同步
- [√] 4.1 运行前后端构建验证并记录结果 | depends_on: [2.2, 3.2]
- [√] 4.2 更新 `.helloagents` 模块文档与变更记录 | depends_on: [4.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-25 12:00 | 1.1 | 完成 | 创建 implementation 方案包,进入开发实施 |
| 2026-03-25 12:18 | 1.2 / 2.1 / 2.2 | 完成 | 新增 `workbench` pane、默认布局切换为三栏工作台,并为旧默认布局做轻量迁移 |
| 2026-03-25 12:20 | 3.1 / 3.2 | 完成 | 后端透出累计上下行字节数,前端状态监控新增“开机累计流量”展示 |
| 2026-03-25 12:31 | 4.1 | 完成 | 执行 `npm install` 后,前后端 build 均通过 |
| 2026-03-25 12:33 | 4.2 | 完成 | 更新模块文档、变更日志并准备归档 |
---
## 执行备注
- 本次只改 `/workspace` 主工作区,不触碰其它页面和全局弹层。
- 默认布局改造需要兼顾已有后端 / localStorage 布局数据,因此会做保守迁移而不是强制清空布局。
- 首次构建前本机缺少依赖,补跑 `npm install` 后,`packages/backend``packages/frontend` 构建均通过。
+2
View File
@@ -8,11 +8,13 @@
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|--------|------|------|---------|------|------|
| 202603250317 | ghcr-docker-publish | implementation | workspace-root | ghcr-docker-publish#D001 | ✅完成 |
| 202603251200 | workspace-workbench-monitor | implementation | frontend, backend | workspace-workbench-monitor#D001 | ✅完成 |
## 按月归档
### 2026-03
- [202603250317_ghcr-docker-publish](./2026-03/202603250317_ghcr-docker-publish/) - 新增 GHCR 镜像发布 workflow 并切换 compose 镜像来源
- [202603251200_workspace-workbench-monitor](./2026-03/202603251200_workspace-workbench-monitor/) - `/workspace` 改为三栏 Workbench 布局,并新增开机累计流量监控
## 结果状态说明
- ✅ 完成
+2 -1
View File
@@ -39,6 +39,7 @@
### 核心功能
- 统一管理 SSH、SFTP、RDP、VNC 远程连接。
- 提供基于 Vue 3 的工作区、布局配置、主题定制和命令输入体验。
- `/workspace` 默认采用“三栏工作台”:左侧 Workbench tab 容器、中央终端、右侧状态监控。
- 提供认证、2FA、Passkey、Captcha、IP 白名单/黑名单、通知和审计能力。
- 支持远程文件管理、在线编辑、快速命令、命令历史和 SSH 挂起会话。
- 使用独立 `remote-gateway` 服务为远程桌面连接生成加密令牌并对接 `guacd`
@@ -89,7 +90,7 @@
| 约束 | 原因 | 决策来源 |
|------|------|---------|
| 暂无已归档方案包约束 | 知识库于 2026-03-25 首次初始化,后续决策应沉淀到 plan/archive | N/A |
| `/workspace` 默认主布局采用“Workbench + 终端 + 状态监控”三栏结构 | 统一终端主区高度,并将命令历史 / 文件 / 编辑器集中到左侧工作台 | workspace-workbench-monitor#D001 |
## 6. 已知技术债务(可选)
+5
View File
@@ -39,6 +39,11 @@
**行为**: 按 `controller/service/repository/routes` 的分层模式组织连接、通知、设置、快速命令、主题等功能。
**结果**: 新增后端能力时应优先延续现有业务域目录结构,而不是在入口文件堆叠逻辑。
### 状态监控
**条件**: 前端工作区通过 WebSocket 订阅服务器状态。
**行为**: `StatusMonitorService` 通过 SSH 读取 `free``df``/proc/stat``/proc/net/dev`,同时计算瞬时网速与默认网卡自开机以来的累计上下行字节数。
**结果**: 前端状态监控既能展示实时速率,也能展示“开机累计流量”,后续扩展监控字段时应优先复用现有 SSH 采集链路。
## 依赖关系
```yaml
+2 -2
View File
@@ -36,8 +36,8 @@
### 工作区交互
**条件**: 用户进入 `/workspace` 或相关管理页面。
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控。
**结果**: 页面逻辑分散在 `views/``components/``stores/``composables/`改动时应优先在对应层级定位
**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合命令历史、文件管理和编辑器
**结果**: 页面逻辑分散在 `views/``components/``stores/``composables/`其中布局调整优先落在 `layout.store.ts``LayoutRenderer.vue``WorkspaceWorkbench.vue`
## 依赖关系
@@ -18,6 +18,8 @@ interface ServerStatus {
cpuModel?: string;
netRxRate?: number; // Bytes per second
netTxRate?: number; // Bytes per second
netRxTotalBytes?: number; // Bytes since boot
netTxTotalBytes?: number; // Bytes since boot
netInterface?: string;
osName?: string;
loadAvg?: number[]; // 系统平均负载 [1min, 5min, 15min]
@@ -318,6 +320,8 @@ export class StatusMonitorService {
status.netInterface = defaultInterface;
const currentRx = currentStats[defaultInterface].rx_bytes;
const currentTx = currentStats[defaultInterface].tx_bytes;
status.netRxTotalBytes = currentRx;
status.netTxTotalBytes = currentTx;
const prevStats = previousNetStats.get(sessionId);
if (prevStats && prevStats.timestamp < timestamp) {
@@ -163,6 +163,7 @@ const paneLabels = computed(() => ({ // Assuming labels might depend on i18n
commandBar: t('layout.pane.commandBar', '命令栏'),
fileManager: t('layout.pane.fileManager', '文件管理器'),
editor: t('layout.pane.editor', '编辑器'),
workbench: t('layout.pane.workbench', '工作台'),
statusMonitor: t('layout.pane.statusMonitor', '状态监视器'),
commandHistory: t('layout.pane.commandHistory', '命令历史'),
quickCommands: t('layout.pane.quickCommands', '快捷指令'),
@@ -88,6 +88,7 @@ const componentMap: Record<PaneName, Component> = {
commandBar: defineAsyncComponent(() => import('./CommandInputBar.vue')),
fileManager: defineAsyncComponent(() => import('./FileManager.vue')),
editor: defineAsyncComponent(() => import('./FileEditorContainer.vue')),
workbench: defineAsyncComponent(() => import('./WorkspaceWorkbench.vue')),
statusMonitor: defineAsyncComponent(() => import('./StatusMonitor.vue')),
commandHistory: defineAsyncComponent(() => import('../views/CommandHistoryView.vue')),
quickCommands: defineAsyncComponent(() => import('../views/QuickCommandsView.vue')),
@@ -131,6 +132,7 @@ const paneLabels = computed(() => ({
commandBar: t('layout.pane.commandBar', '命令栏'),
fileManager: t('layout.pane.fileManager', '文件管理器'),
editor: t('layout.pane.editor', '编辑器'),
workbench: t('layout.pane.workbench', '工作台'),
statusMonitor: t('layout.pane.statusMonitor', '状态监视器'),
commandHistory: t('layout.pane.commandHistory', '命令历史'),
quickCommands: t('layout.pane.quickCommands', '快捷指令'),
@@ -186,6 +188,21 @@ const componentProps = computed(() => {
class: 'pane-content',
// --- 移除事件转发 ---
};
case 'workbench':
return {
tabs: props.editorTabs,
activeTabId: props.activeEditorTabId,
sessionId: currentActiveSession?.sessionId ?? props.activeSessionId,
instanceId: props.layoutNode.id || `workbench-main-${props.activeSessionId ?? 'unknown'}`,
dbConnectionId: currentActiveSession?.connectionId ?? null,
wsDeps: currentActiveSession ? {
sendMessage: currentActiveSession.wsManager.sendMessage,
onMessage: currentActiveSession.wsManager.onMessage,
isConnected: currentActiveSession.wsManager.isConnected,
isSftpReady: currentActiveSession.wsManager.isSftpReady
} : null,
class: 'flex flex-col flex-grow h-full overflow-hidden',
};
case 'commandBar':
return {
class: 'pane-content',
@@ -240,6 +257,21 @@ const sidebarProps = computed(() => (paneName: PaneName | null, side: 'left' | '
...baseProps,
// --- 移除事件转发 ---
};
case 'workbench':
return {
...baseProps,
tabs: editorTabsFromStore.value,
activeTabId: activeEditorTabIdFromStore.value,
sessionId: activeSession.value?.sessionId ?? props.activeSessionId,
instanceId: side === 'left' ? 'workbench-sidebar-left' : 'workbench-sidebar-right',
dbConnectionId: activeSession.value?.connectionId ?? null,
wsDeps: activeSession.value ? {
sendMessage: activeSession.value.wsManager.sendMessage,
onMessage: activeSession.value.wsManager.onMessage,
isConnected: activeSession.value.wsManager.isConnected,
isSftpReady: activeSession.value.wsManager.isSftpReady
} : null,
};
case 'fileManager':
// Only provide props if there's an active session
if (activeSession.value) {
@@ -357,6 +389,7 @@ const getIconClasses = (paneName: PaneName): string[] => {
case 'quickCommands': return ['fas', 'fa-bolt'];
case 'dockerManager': return ['fab', 'fa-docker']; // Use 'fab' for Docker
case 'editor': return ['fas', 'fa-file-alt'];
case 'workbench': return ['fas', 'fa-table-columns'];
case 'statusMonitor': return ['fas', 'fa-tachometer-alt'];
case 'suspendedSshSessions': return ['fas', 'fa-pause-circle']; // 图标:暂停圈
// Add other specific icons here if needed
@@ -143,6 +143,21 @@
</div>
</div>
<div v-if="activeSessionId && currentServerStatus" class="status-item grid grid-cols-[auto_1fr] items-start gap-3 mt-2">
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.totalTrafficLabel') }}:</label>
<div class="flex flex-col gap-1.5 text-xs">
<span class="inline-flex items-center gap-2 whitespace-nowrap text-green-500">
<i class="fas fa-arrow-down w-3 text-center"></i>
<span>{{ t('statusMonitor.downloadLabel') }}</span>
<span class="font-mono text-foreground">{{ formatBytes(currentServerStatus?.netRxTotalBytes) }}</span>
</span>
<span class="inline-flex items-center gap-2 whitespace-nowrap text-orange-500">
<i class="fas fa-arrow-up w-3 text-center"></i>
<span>{{ t('statusMonitor.uploadLabel') }}</span>
<span class="font-mono text-foreground">{{ formatBytes(currentServerStatus?.netTxTotalBytes) }}</span>
</span>
</div>
</div>
<!-- 图表组件 -->
<!-- 仅当有活动会话且有数据时渲染图表 -->
<StatusCharts v-if="activeSessionId && currentServerStatus" :server-status="currentServerStatus" :active-session-id="activeSessionId" />
@@ -161,6 +176,7 @@ import { storeToRefs } from 'pinia'; // 导入 storeToRefs
import { useSettingsStore } from '../stores/settings.store'; // 导入设置 store
import { useConnectionsStore } from '../stores/connections.store'; // 导入连接 store
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // + 导入通知 store
import type { ServerStatus } from '../types/server.types';
const { t } = useI18n();
const sessionStore = useSessionStore();
@@ -173,24 +189,6 @@ const isSwitchingSession = ref(false);
const formatPercentageText = (percentage: number): string => `${Math.round(percentage)}%`;
interface ServerStatus {
cpuPercent?: number;
memPercent?: number;
memUsed?: number; // MB
memTotal?: number; // MB
swapPercent?: number;
swapUsed?: number; // MB
swapTotal?: number; // MB
diskPercent?: number;
diskUsed?: number; // KB
diskTotal?: number; // KB
cpuModel?: string;
netRxRate?: number; // 字节/秒
netTxRate?: number; // 字节/秒
netInterface?: string;
osName?: string;
}
// --- Props ---
const props = defineProps({
activeSessionId: {
@@ -276,6 +274,15 @@ const formatBytesPerSecond = (bytes?: number): string => {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytesPerSecond')}`;
};
const formatBytes = (bytes?: number): string => {
if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytes')}`;
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)} TB`;
};
const formatKbToGb = (kb?: number): string => {
if (kb === undefined || kb === null) return t('statusMonitor.notAvailable');
if (kb === 0) return `0.0 ${t('statusMonitor.gigaBytes')}`;
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, ref, watch, type PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import CommandHistoryView from '../views/CommandHistoryView.vue';
import FileManager from './FileManager.vue';
import FileEditorContainer from './FileEditorContainer.vue';
import { useSessionStore } from '../stores/session.store';
import type { FileTab } from '../stores/fileEditor.store';
import type { WebSocketDependencies } from '../composables/useSftpActions';
type WorkbenchTab = 'files' | 'history' | 'editor';
const props = defineProps({
tabs: {
type: Array as PropType<FileTab[]>,
default: () => [],
},
activeTabId: {
type: String as PropType<string | null>,
default: null,
},
sessionId: {
type: String as PropType<string | null>,
default: null,
},
instanceId: {
type: String as PropType<string | null>,
default: null,
},
dbConnectionId: {
type: String as PropType<string | null>,
default: null,
},
wsDeps: {
type: Object as PropType<WebSocketDependencies | null>,
default: null,
},
});
const { t } = useI18n();
const sessionStore = useSessionStore();
const { sessions } = storeToRefs(sessionStore);
const activeWorkbenchTab = ref<WorkbenchTab>('files');
const workbenchTabs = computed(() => [
{
id: 'files' as const,
label: t('workspace.workbench.tabs.files', '文件'),
icon: 'fas fa-folder-open',
},
{
id: 'history' as const,
label: t('workspace.workbench.tabs.history', '历史命令'),
icon: 'fas fa-history',
},
{
id: 'editor' as const,
label: t('workspace.workbench.tabs.editor', '编辑器'),
icon: 'fas fa-pen-to-square',
},
]);
const activeSessionName = computed(() => {
if (!props.sessionId) {
return null;
}
return sessions.value.get(props.sessionId)?.connectionName ?? props.sessionId;
});
const hasFileManagerContext = computed(() => {
return Boolean(props.sessionId && props.instanceId && props.dbConnectionId && props.wsDeps);
});
const fileManagerSessionId = computed(() => props.sessionId ?? '');
const fileManagerInstanceId = computed(() => props.instanceId ?? '');
const fileManagerConnectionId = computed(() => props.dbConnectionId ?? '');
const fileManagerWsDeps = computed(() => props.wsDeps as WebSocketDependencies);
watch(
() => props.activeTabId,
(newActiveTabId) => {
if (newActiveTabId) {
activeWorkbenchTab.value = 'editor';
}
},
{ immediate: true }
);
</script>
<template>
<div class="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<div class="border-b border-border bg-header px-3 py-3">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-foreground">
{{ t('workspace.workbench.title', 'Workbench') }}
</h3>
<p class="mt-1 text-xs text-text-secondary">
{{ activeSessionName || t('workspace.workbench.noSession', '未激活会话') }}
</p>
</div>
<span class="rounded-full border border-border bg-background px-2 py-1 text-[11px] font-medium text-text-secondary">
{{ t('workspace.workbench.label', '工作台') }}
</span>
</div>
<div class="mt-3 grid grid-cols-3 gap-2">
<button
v-for="tab in workbenchTabs"
:key="tab.id"
type="button"
@click="activeWorkbenchTab = tab.id"
:class="[
'inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-colors',
activeWorkbenchTab === tab.id
? 'border-primary bg-primary text-white shadow-sm'
: 'border-border bg-background text-text-secondary hover:border-primary/40 hover:text-foreground'
]"
>
<i :class="tab.icon"></i>
<span>{{ tab.label }}</span>
</button>
</div>
</div>
<div class="relative flex-1 min-h-0 overflow-hidden bg-background">
<div v-show="activeWorkbenchTab === 'files'" class="absolute inset-0 min-h-0">
<FileManager
v-if="hasFileManagerContext"
:session-id="fileManagerSessionId"
:instance-id="fileManagerInstanceId"
:db-connection-id="fileManagerConnectionId"
:ws-deps="fileManagerWsDeps"
class="h-full"
/>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-text-secondary"
>
<i class="fas fa-plug text-3xl"></i>
<div class="text-sm font-medium">
{{ t('layout.noActiveSession.title', '没有活动的会话') }}
</div>
<div class="text-xs">
{{ t('workspace.workbench.fileManagerHint', '激活一个 SSH 会话后即可浏览远程文件。') }}
</div>
</div>
</div>
<div v-show="activeWorkbenchTab === 'history'" class="absolute inset-0 min-h-0">
<CommandHistoryView />
</div>
<div v-show="activeWorkbenchTab === 'editor'" class="absolute inset-0 min-h-0">
<FileEditorContainer
:tabs="tabs"
:active-tab-id="activeTabId"
:session-id="sessionId"
/>
</div>
</div>
</div>
</template>
+30 -12
View File
@@ -583,6 +583,9 @@
"swapLabel": "Swap:",
"diskLabel": "Disk:",
"networkLabel": "Network",
"totalTrafficLabel": "Traffic Since Boot",
"downloadLabel": "Download",
"uploadLabel": "Upload",
"notAvailable": "N/A",
"bytesPerSecond": "B/s",
"kiloBytesPerSecond": "KB/s",
@@ -1250,10 +1253,11 @@
"pane": {
"connections": "Connections",
"terminal": "Terminal",
"commandBar": "Command Bar",
"fileManager": "File Manager",
"editor": "Editor",
"statusMonitor": "Status Monitor",
"commandBar": "Command Bar",
"fileManager": "File Manager",
"editor": "Editor",
"workbench": "Workbench",
"statusMonitor": "Status Monitor",
"commandHistory": "Command History",
"quickCommands": "Quick Commands",
"dockerManager": "Docker Manager",
@@ -1262,14 +1266,28 @@
"panes": {
"suspendedSshSessions": "Suspended Session Manager"
},
"noActiveSession": {
"title": "No Active Session",
"message": "Please connect to a session first",
"fileManagerSidebar": "File Manager requires an active session",
"statusMonitorSidebar": "Status Monitor requires an active session"
}
},
"header": {
"noActiveSession": {
"title": "No Active Session",
"message": "Please connect to a session first",
"fileManagerSidebar": "File Manager requires an active session",
"statusMonitorSidebar": "Status Monitor requires an active session"
}
},
"workspace": {
"noActiveSession": "No active session",
"workbench": {
"title": "Workbench",
"label": "Workspace",
"noSession": "No active session",
"fileManagerHint": "Activate an SSH session to browse remote files.",
"tabs": {
"files": "Files",
"history": "History",
"editor": "Editor"
}
}
},
"header": {
"hide": "Hide",
"show": "Show Top Navigation"
},
+18
View File
@@ -555,6 +555,7 @@
"dockerManager": "Docker マネージャー",
"editor": "エディター",
"fileManager": "ファイルマネージャー",
"workbench": "ワークベンチ",
"quickCommands": "クイックコマンド",
"statusMonitor": "ステータスモニター",
"terminal": "ターミナル",
@@ -564,6 +565,20 @@
"suspendedSshSessions": "中断されたセッション管理者"
}
},
"workspace": {
"noActiveSession": "アクティブなセッションはありません",
"workbench": {
"title": "Workbench",
"label": "ワークスペース",
"noSession": "アクティブなセッションはありません",
"fileManagerHint": "SSH セッションを有効にするとリモートファイルを参照できます。",
"tabs": {
"files": "ファイル",
"history": "履歴",
"editor": "エディター"
}
}
},
"layoutConfigurator": {
"availablePanes": "利用可能なパネル",
"confirmClearLayout": "レイアウト全体をクリアしますか?すべてのパネルが利用可能なリストに戻ります。",
@@ -1205,6 +1220,9 @@
"megaBytesPerSecond": "MB/秒",
"memoryLabel": "メモリ:",
"networkLabel": "ネットワーク",
"totalTrafficLabel": "起動後の累計トラフィック",
"downloadLabel": "受信",
"uploadLabel": "送信",
"notAvailable": "N/A",
"osLabel": "OS:",
"swapLabel": "スワップ:",
+30 -12
View File
@@ -583,6 +583,9 @@
"swapLabel": "Swap:",
"diskLabel": "磁盘:",
"networkLabel": "网络",
"totalTrafficLabel": "开机累计流量",
"downloadLabel": "下行",
"uploadLabel": "上行",
"notAvailable": "N/A",
"bytesPerSecond": "B/s",
"kiloBytesPerSecond": "KB/s",
@@ -1254,10 +1257,11 @@
"pane": {
"connections": "连接列表",
"terminal": "终端",
"commandBar": "命令栏",
"fileManager": "文件管理器",
"editor": "编辑器",
"statusMonitor": "状态监视器",
"commandBar": "命令栏",
"fileManager": "文件管理器",
"editor": "编辑器",
"workbench": "工作台",
"statusMonitor": "状态监视器",
"commandHistory": "命令历史",
"quickCommands": "快捷指令",
"dockerManager": "Docker 管理器",
@@ -1266,14 +1270,28 @@
"panes": {
"suspendedSshSessions": "挂起会话管理器"
},
"noActiveSession": {
"title": "无活动会话",
"message": "请先连接一个会话",
"fileManagerSidebar": "文件管理器需要活动会话",
"statusMonitorSidebar": "状态监视器需要活动会话"
}
},
"header": {
"noActiveSession": {
"title": "无活动会话",
"message": "请先连接一个会话",
"fileManagerSidebar": "文件管理器需要活动会话",
"statusMonitorSidebar": "状态监视器需要活动会话"
}
},
"workspace": {
"noActiveSession": "没有活动的会话",
"workbench": {
"title": "Workbench",
"label": "工作台",
"noSession": "未激活会话",
"fileManagerHint": "激活一个 SSH 会话后即可浏览远程文件。",
"tabs": {
"files": "文件",
"history": "历史命令",
"editor": "编辑器"
}
}
},
"header": {
"hide": "隐藏",
"show": "显示顶部导航"
},
+60 -51
View File
@@ -3,7 +3,7 @@ import { ref, computed, watch, type Ref, type ComputedRef } from 'vue';
import apiClient from '../utils/apiClient';
// 定义所有可用面板的名称
export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager' | 'suspendedSshSessions';
export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'workbench' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager' | 'suspendedSshSessions';
// 定义布局节点接口
export interface LayoutNode {
@@ -25,7 +25,38 @@ function generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
// 定义默认布局结构 (根据用户提供的配置更新,但使用 generateId)
function isPaneNode(node: LayoutNode | undefined | null, component: PaneName): boolean {
return node?.type === 'pane' && node.component === component;
}
function isLegacyDefaultLayout(node: LayoutNode | null): boolean {
if (!node || node.type !== 'container' || node.direction !== 'horizontal' || !node.children || node.children.length !== 3) {
return false;
}
const [leftColumn, centerColumn, rightColumn] = node.children;
return Boolean(
leftColumn?.type === 'container' &&
leftColumn.direction === 'vertical' &&
leftColumn.children?.length === 3 &&
isPaneNode(leftColumn.children[0], 'statusMonitor') &&
isPaneNode(leftColumn.children[1], 'commandHistory') &&
isPaneNode(leftColumn.children[2], 'quickCommands') &&
centerColumn?.type === 'container' &&
centerColumn.direction === 'vertical' &&
centerColumn.children?.length === 3 &&
isPaneNode(centerColumn.children[0], 'terminal') &&
isPaneNode(centerColumn.children[1], 'commandBar') &&
isPaneNode(centerColumn.children[2], 'fileManager') &&
rightColumn?.type === 'container' &&
rightColumn.direction === 'vertical' &&
rightColumn.children?.length === 1 &&
isPaneNode(rightColumn.children[0], 'editor')
);
}
// 定义默认布局结构
const getDefaultLayout = (): LayoutNode => ({
id: generateId(), // Generate new ID
type: "container",
@@ -33,69 +64,35 @@ const getDefaultLayout = (): LayoutNode => ({
children: [
{
id: generateId(), // Generate new ID
type: "container",
direction: "vertical",
children: [
{
id: generateId(), // Generate new ID
type: "pane",
component: "statusMonitor",
size: 44.56372126372345 // 使用用户提供的 size
},
{
id: generateId(), // Generate new ID
type: "pane",
component: "commandHistory",
size: 26.235651482670775 // 使用用户提供的 size
},
{
id: generateId(), // Generate new ID
type: "pane",
component: "quickCommands",
size: 29.200627253605774 // 使用用户提供的 size
}
],
size: 14.59006012147659 // 使用用户提供的 size
type: "pane",
component: "workbench",
size: 23
},
{
id: generateId(), // Generate new ID
type: "container",
direction: "vertical",
size: 58.02787988626151, // 使用用户提供的 size
size: 57,
children: [
{
id: generateId(), // Generate new ID
type: "pane",
component: "terminal",
size: 59.94833664833884 // 使用用户提供的 size
size: 94
},
{
id: generateId(), // Generate new ID
type: "pane",
component: "commandBar",
size: 5 // 使用用户提供的 size
},
{
id: generateId(), // Generate new ID
type: "pane",
component: "fileManager",
size: 35.05166335166116 // 使用用户提供的 size
size: 6
}
]
},
{
id: generateId(), // Generate new ID
type: "container",
direction: "vertical",
size: 27.3820599922619, // 使用用户提供的 size
children: [
{
id: generateId(), // Generate new ID
type: "pane",
component: "editor",
size: 100 // 使用用户提供的 size
}
]
type: "pane",
component: "statusMonitor",
size: 20
}
]
});
@@ -165,7 +162,7 @@ export const useLayoutStore = defineStore('layout', () => {
// 所有理论上可用的面板名称
const allPossiblePanes: Ref<PaneName[]> = ref([
'connections', 'terminal', 'commandBar', 'fileManager',
'editor', 'statusMonitor', 'commandHistory', 'quickCommands',
'editor', 'workbench', 'statusMonitor', 'commandHistory', 'quickCommands',
'dockerManager', 'suspendedSshSessions' // <-- 添加新的挂起 SSH 会话视图
]);
// 控制布局(Header/Footer)可见性的状态
@@ -201,6 +198,18 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
return node;
}
function normalizeLoadedLayout(node: LayoutNode | null): LayoutNode | null {
const layoutWithIds = ensureNodeIds(node);
if (!layoutWithIds) return null;
if (isLegacyDefaultLayout(layoutWithIds)) {
console.log('[Layout Store] Detected legacy workspace default layout, migrating to workbench layout.');
return ensureNodeIds(getDefaultLayout());
}
return layoutWithIds;
}
// --- Actions ---
// 初始化布局和侧栏配置
async function initializeLayout() {
@@ -220,7 +229,7 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
if (response.data) {
console.log('[Layout Store] Step 1: Backend returned data.');
// +++ 在赋值前确保 ID 存在 +++
loadedLayout = ensureNodeIds(response.data);
loadedLayout = normalizeLoadedLayout(response.data);
layoutLoadedFromBackend = true;
console.log('[Layout Store] Step 1: Layout processed with ensureNodeIds.');
// 更新 localStorage (使用处理过的布局)
@@ -270,13 +279,13 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
const parsedLayout = JSON.parse(savedLayout) as LayoutNode;
console.log('[Layout Store] Step 3: Parsed layout from localStorage.');
// +++ 在赋值前确保 ID 存在 +++
loadedLayout = ensureNodeIds(parsedLayout);
loadedLayout = normalizeLoadedLayout(parsedLayout);
console.log('[Layout Store] Step 3: Layout processed with ensureNodeIds.');
} else {
// 4. 如果 localStorage 也没有,使用默认主布局
console.log('[Layout Store] Step 4: No layout in localStorage. Applying default.');
// +++ 确保默认布局也有 ID (虽然 getDefaultLayout 内部会生成) +++
loadedLayout = ensureNodeIds(getDefaultLayout());
loadedLayout = normalizeLoadedLayout(getDefaultLayout());
console.log('[Layout Store] Step 4: Default layout processed with ensureNodeIds.');
}
} catch (error) {
@@ -284,7 +293,7 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
// Fallback to default if error and loadedLayout is still null
if (!loadedLayout) {
console.log('[Layout Store] Step 3/4: Applying default layout due to error.');
loadedLayout = ensureNodeIds(getDefaultLayout());
loadedLayout = normalizeLoadedLayout(getDefaultLayout());
}
}
}
@@ -326,7 +335,7 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
// Final check (主要是为了调试,可以简化或移除)
if (!layoutTree.value) {
console.error('[Layout Store] FATAL: layoutTree is STILL null after all attempts! Applying default as last resort.');
layoutTree.value = ensureNodeIds(getDefaultLayout());
layoutTree.value = normalizeLoadedLayout(getDefaultLayout());
}
if (!sidebarPanes.value || !Array.isArray(sidebarPanes.value.left) || !Array.isArray(sidebarPanes.value.right)) {
console.warn('[Layout Store] Final Check: Sidebar panes invalid. Applying default.');
@@ -162,7 +162,7 @@ export const useSettingsStore = defineStore('settings', () => {
// Load and parse sidebar pane widths
const defaultPaneWidth = '350px';
// +++ Ensure PaneName type is available or define it here +++
const knownPanes: PaneName[] = ['connections', 'fileManager', 'editor', 'statusMonitor', 'commandHistory', 'quickCommands', 'dockerManager']; // Add all possible sidebar panes
const knownPanes: PaneName[] = ['connections', 'fileManager', 'editor', 'workbench', 'statusMonitor', 'commandHistory', 'quickCommands', 'dockerManager']; // Add all possible sidebar panes
let loadedWidths: Record<string, string> = {};
try {
if (settings.value.sidebarPaneWidths) {
@@ -13,6 +13,8 @@ export interface ServerStatus {
swapTotal?: number; // MB
netRxRate?: number; // Bytes/sec
netTxRate?: number; // Bytes/sec
netRxTotalBytes?: number; // Bytes since boot
netTxTotalBytes?: number; // Bytes since boot
netInterface?: string;
osName?: string;
}