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
@@ -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>