Revert "feat(frontend): unify ui with slate control center"
This reverts commit 91aa6e83ca.
This commit is contained in:
@@ -1,181 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
accentLabel: {
|
||||
type: String,
|
||||
default: 'Slate Control Center',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-layout">
|
||||
<div class="auth-layout__panel auth-layout__panel--brand">
|
||||
<div class="auth-layout__brand-card">
|
||||
<span class="auth-layout__eyebrow">{{ accentLabel }}</span>
|
||||
<img src="../assets/logo.png" alt="Project Logo" class="auth-layout__logo" />
|
||||
<div>
|
||||
<h1 class="auth-layout__brand-title">{{ t('projectName') }}</h1>
|
||||
<p class="auth-layout__brand-copy">{{ t('slogan') }}</p>
|
||||
</div>
|
||||
<div class="auth-layout__brand-meter">
|
||||
<span>{{ subtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-layout__panel auth-layout__panel--content">
|
||||
<div class="auth-layout__content-card">
|
||||
<div class="auth-layout__content-head">
|
||||
<el-tag effect="plain" round size="small">{{ accentLabel }}</el-tag>
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ subtitle }}</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-layout {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 440px) minmax(360px, 560px);
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.75rem;
|
||||
}
|
||||
|
||||
.auth-layout__panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-layout__brand-card,
|
||||
.auth-layout__content-card {
|
||||
height: 100%;
|
||||
border-radius: 32px;
|
||||
border: 1px solid rgba(103, 124, 155, 0.18);
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(22px);
|
||||
}
|
||||
|
||||
.auth-layout__brand-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 2rem;
|
||||
color: #f8fbff;
|
||||
background:
|
||||
linear-gradient(160deg, rgba(17, 31, 53, 0.94), rgba(32, 58, 102, 0.92)),
|
||||
radial-gradient(circle at 20% 20%, rgba(73, 119, 255, 0.34), transparent 35%);
|
||||
}
|
||||
|
||||
.auth-layout__eyebrow {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.auth-layout__logo {
|
||||
width: 84px;
|
||||
height: auto;
|
||||
margin: 2rem 0 1.25rem;
|
||||
}
|
||||
|
||||
.auth-layout__brand-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: clamp(2.4rem, 4vw, 3.6rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.auth-layout__brand-copy {
|
||||
margin: 0.85rem 0 0;
|
||||
color: rgba(240, 245, 255, 0.8);
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.auth-layout__brand-meter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: fit-content;
|
||||
margin-top: 2rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(245, 248, 255, 0.78);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-layout__content-card {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(244, 248, 253, 0.86));
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-layout__content-head {
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
.auth-layout__content-head h2 {
|
||||
margin: 0.95rem 0 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: clamp(1.9rem, 3vw, 2.5rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.auth-layout__content-head p {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.auth-layout {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.auth-layout__brand-card {
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.auth-layout {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.auth-layout__brand-card,
|
||||
.auth-layout__content-card {
|
||||
border-radius: 24px;
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.auth-layout__logo {
|
||||
width: 66px;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
eyebrow: {
|
||||
type: String,
|
||||
default: 'Slate Control Center',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<header class="page-shell__hero">
|
||||
<div class="page-shell__copy">
|
||||
<div class="page-shell__eyebrow">
|
||||
<el-tag effect="plain" round size="small">{{ eyebrow }}</el-tag>
|
||||
<slot name="badge" />
|
||||
</div>
|
||||
<h1 class="page-shell__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="page-shell__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="page-shell__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="$slots.stats" class="page-shell__stats">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
|
||||
<div class="page-shell__body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
width: min(1360px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 1.4rem 0 2rem;
|
||||
}
|
||||
|
||||
.page-shell__hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
align-items: flex-end;
|
||||
padding: 1.5rem 1.6rem;
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(103, 124, 155, 0.16);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(242, 247, 253, 0.78)),
|
||||
linear-gradient(180deg, rgba(60, 105, 231, 0.08), transparent);
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.page-shell__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-shell__eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.page-shell__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: clamp(2rem, 3vw, 2.8rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.page-shell__subtitle {
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 62ch;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.page-shell__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-shell__stats,
|
||||
.page-shell__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.page-shell {
|
||||
width: min(100%, calc(100% - 1.25rem));
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.page-shell__hero {
|
||||
padding: 1.2rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-shell__actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +1,204 @@
|
||||
<template>
|
||||
<!-- 根元素,包含内边距、背景、边框和文本样式 -->
|
||||
<div class="status-monitor p-4 bg-background text-foreground h-full overflow-y-auto text-sm" :class="{ 'bg-header': !activeSessionId }">
|
||||
<h4 v-if="activeSessionId" class="mt-0 mb-4 border-b border-border pb-2 text-base font-medium">
|
||||
{{ t('statusMonitor.title') }}
|
||||
</h4>
|
||||
|
||||
<!-- 无活动会话状态 -->
|
||||
<div v-if="!activeSessionId" class="no-session-status flex flex-col items-center justify-center text-center text-text-secondary mt-4 h-full">
|
||||
<i class="fas fa-plug text-4xl mb-3 text-text-secondary"></i>
|
||||
<span class="text-lg font-medium mb-2">{{ t('layout.noActiveSession.title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="currentStatusError" class="status-error flex flex-col items-center justify-center text-center text-red-500 mt-4 h-full">
|
||||
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
|
||||
<span>{{ t('statusMonitor.errorPrefix') }} {{ currentStatusError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else-if="!currentServerStatus" class="loading-status flex flex-col items-center justify-center text-center text-text-secondary mt-4 h-full">
|
||||
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
|
||||
<span>{{ t('statusMonitor.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 状态网格 -->
|
||||
<div v-else class="status-grid grid gap-3">
|
||||
<!-- IP 地址 (如果启用) -->
|
||||
<div v-if="statusMonitorShowIpBoolean && activeSessionId && sessionIpAddress" class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">IP:</label>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="ip-address-value truncate text-left cursor-pointer hover:text-primary transition-colors"
|
||||
:title="sessionIpAddress"
|
||||
@click="copyIpToClipboard(sessionIpAddress)">
|
||||
{{ sessionIpAddress }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CPU 型号 -->
|
||||
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.cpuModelLabel') }}</label>
|
||||
<span class="cpu-model-value truncate text-left" :title="displayCpuModel">{{ displayCpuModel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作系统名称 -->
|
||||
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.osLabel') }}</label>
|
||||
<span class="os-name-value truncate text-left" :title="displayOsName">{{ displayOsName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 资源使用率分组 -->
|
||||
<div class="resource-monitor-group grid gap-3 mb-3">
|
||||
<!-- CPU 使用率 -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.cpuLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displayCpuPercent"
|
||||
:stroke-width="16"
|
||||
color="#3b82f6"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<!-- 移除 w-12 和 text-right 以实现左对齐 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内存使用率 -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.memoryLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displayMemPercent"
|
||||
:stroke-width="16"
|
||||
color="#22c55e"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<span class="mem-disk-details font-mono text-xs whitespace-nowrap text-left">{{ memDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- swap -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.swapLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displaySwapPercent"
|
||||
:stroke-width="16"
|
||||
:color="(currentServerStatus?.swapPercent ?? 0) > 0 ? '#eab308' : '#6b7280'"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<span class="mem-disk-details font-mono text-xs whitespace-nowrap text-left">{{ swapDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 磁盘使用率 -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.diskLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displayDiskPercent"
|
||||
:stroke-width="16"
|
||||
color="#a855f7"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<span class="mem-disk-details font-mono text-xs whitespace-nowrap text-left">{{ diskDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 网络速率,仅在有活动会话且有数据时显示 -->
|
||||
<div v-if="activeSessionId && currentServerStatus" class="status-item grid grid-cols-[auto_1fr] items-center gap-3 mt-2">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.networkLabel') }} ({{ currentServerStatus?.netInterface || '...' }}):</label>
|
||||
<div class="network-values flex items-center justify-start gap-4"> <!-- 减小间距 -->
|
||||
<span class="rate down inline-flex items-center gap-1 text-green-500 text-xs whitespace-nowrap">
|
||||
<i class="fas fa-arrow-down w-3 text-center"></i> <!-- Font Awesome 图标 -->
|
||||
<span class="font-mono">{{ formatBytesPerSecond(currentServerStatus?.netRxRate) }}</span>
|
||||
</span>
|
||||
<span class="rate up inline-flex items-center gap-1 text-orange-500 text-xs whitespace-nowrap">
|
||||
<i class="fas fa-arrow-up w-3 text-center"></i> <!-- Font Awesome 图标 -->
|
||||
<span class="font-mono">{{ formatBytesPerSecond(currentServerStatus?.netTxRate) }}</span>
|
||||
|
||||
</span>
|
||||
</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" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, type PropType, nextTick } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { ref, computed, watch, type PropType, nextTick } from 'vue';
|
||||
import { ElProgress } from 'element-plus';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import StatusCharts from './StatusCharts.vue';
|
||||
import { useSessionStore } from '../stores/session.store';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||
import { useSessionStore } from '../stores/session.store'; // 注入 sessionStore
|
||||
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();
|
||||
const settingsStore = useSettingsStore();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
|
||||
const { sessions } = storeToRefs(sessionStore);
|
||||
const { statusMonitorShowIpBoolean } = storeToRefs(settingsStore);
|
||||
const settingsStore = useSettingsStore(); // 实例化设置 store
|
||||
const connectionsStore = useConnectionsStore(); // 实例化连接 store
|
||||
const uiNotificationsStore = useUiNotificationsStore(); // + 实例化通知 store
|
||||
const { sessions } = storeToRefs(sessionStore); // 获取响应式的 sessions
|
||||
const { statusMonitorShowIpBoolean } = storeToRefs(settingsStore); // 获取 IP 显示设置
|
||||
const isSwitchingSession = ref(false);
|
||||
|
||||
const formatPercentageText = (percentage: number): string => `${Math.round(percentage)}%`;
|
||||
|
||||
// --- Props ---
|
||||
const props = defineProps({
|
||||
activeSessionId: {
|
||||
type: String as PropType<string | null>,
|
||||
required: false,
|
||||
required: false, // 允许为 null
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const formatPercentageText = (percentage: number): string => `${Math.round(percentage)}%`;
|
||||
|
||||
// --- Computed properties to get current session data ---
|
||||
const currentSessionState = computed(() => {
|
||||
return props.activeSessionId ? sessions.value.get(props.activeSessionId) : null;
|
||||
});
|
||||
@@ -37,546 +207,167 @@ const currentServerStatus = computed<ServerStatus | null>(() => {
|
||||
return currentSessionState.value?.statusMonitorManager?.serverStatus?.value ?? null;
|
||||
});
|
||||
|
||||
const displayCpuPercent = computed(() => currentServerStatus.value?.cpuPercent ?? 0);
|
||||
const displayMemPercent = computed(() => currentServerStatus.value?.memPercent ?? 0);
|
||||
const displaySwapPercent = computed(() => currentServerStatus.value?.swapPercent ?? 0);
|
||||
const displayDiskPercent = computed(() => currentServerStatus.value?.diskPercent ?? 0);
|
||||
// --- 计算属性,用于绑定到进度条宽度 ---
|
||||
// 始终返回当前状态的百分比。动画由 CSS 类控制。
|
||||
const displayCpuPercent = computed(() => {
|
||||
return currentServerStatus.value?.cpuPercent ?? 0;
|
||||
});
|
||||
|
||||
const displayMemPercent = computed(() => {
|
||||
return currentServerStatus.value?.memPercent ?? 0;
|
||||
});
|
||||
|
||||
const displaySwapPercent = computed(() => {
|
||||
return currentServerStatus.value?.swapPercent ?? 0;
|
||||
});
|
||||
|
||||
const displayDiskPercent = computed(() => {
|
||||
return currentServerStatus.value?.diskPercent ?? 0;
|
||||
});
|
||||
|
||||
const currentStatusError = computed<string | null>(() => {
|
||||
return currentSessionState.value?.statusMonitorManager?.statusError?.value ?? null;
|
||||
});
|
||||
|
||||
// --- 缓存逻辑保持不变 ---
|
||||
const cachedCpuModel = ref<string | null>(null);
|
||||
const cachedOsName = ref<string | null>(null);
|
||||
|
||||
watch(
|
||||
currentServerStatus,
|
||||
(newData) => {
|
||||
if (newData?.cpuModel) {
|
||||
// --- Watcher for caching CPU Model and OS Name ---
|
||||
// 现在监听 currentServerStatus
|
||||
watch(currentServerStatus, (newData) => {
|
||||
if (newData) {
|
||||
if (newData.cpuModel !== undefined && newData.cpuModel !== null && newData.cpuModel !== '') {
|
||||
cachedCpuModel.value = newData.cpuModel;
|
||||
}
|
||||
if (newData?.osName) {
|
||||
if (newData.osName !== undefined && newData.osName !== null && newData.osName !== '') {
|
||||
cachedOsName.value = newData.osName;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.activeSessionId,
|
||||
async (newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
isSwitchingSession.value = true;
|
||||
await nextTick();
|
||||
isSwitchingSession.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
}, { immediate: true });
|
||||
|
||||
// --- 监听 activeSessionId 变化以处理会话切换状态 ---
|
||||
watch(() => props.activeSessionId, async (newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
isSwitchingSession.value = true;
|
||||
await nextTick(); // 等待DOM更新(currentServerStatus已改变,displayPercent们会返回0)
|
||||
isSwitchingSession.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Computed properties for display ---
|
||||
const displayCpuModel = computed(() => {
|
||||
// 使用 currentServerStatus
|
||||
return (currentServerStatus.value?.cpuModel ?? cachedCpuModel.value) || t('statusMonitor.notAvailable');
|
||||
});
|
||||
|
||||
const displayOsName = computed(() => {
|
||||
// 使用 currentServerStatus
|
||||
return (currentServerStatus.value?.osName ?? cachedOsName.value) || t('statusMonitor.notAvailable');
|
||||
});
|
||||
|
||||
const formatBytesPerSecond = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || Number.isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes} ${t('statusMonitor.bytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} ${t('statusMonitor.kiloBytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytesPerSecond')}`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytesPerSecond')}`;
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes} ${t('statusMonitor.bytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} ${t('statusMonitor.kiloBytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytesPerSecond')}`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytesPerSecond')}`;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || Number.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`;
|
||||
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')}`;
|
||||
const gb = kb / 1024 / 1024;
|
||||
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
if (kb === undefined || kb === null) return t('statusMonitor.notAvailable');
|
||||
if (kb === 0) return `0.0 ${t('statusMonitor.gigaBytes')}`;
|
||||
const gb = kb / 1024 / 1024;
|
||||
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
};
|
||||
|
||||
// 辅助函数,用于在需要时将 MB 格式化为 GB
|
||||
const formatMemorySize = (mb?: number): string => {
|
||||
if (mb === undefined || mb === null || Number.isNaN(mb)) return t('statusMonitor.notAvailable');
|
||||
if (mb < 1024) {
|
||||
const value = Number.isInteger(mb) ? mb : mb.toFixed(1);
|
||||
return `${value} ${t('statusMonitor.megaBytes')}`;
|
||||
}
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
if (mb === undefined || mb === null || isNaN(mb)) return t('statusMonitor.notAvailable');
|
||||
if (mb < 1024) {
|
||||
const value = Number.isInteger(mb) ? mb : mb.toFixed(1);
|
||||
return `${value} ${t('statusMonitor.megaBytes')}`;
|
||||
} else {
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
}
|
||||
};
|
||||
|
||||
const memDisplay = computed(() => {
|
||||
const data = currentServerStatus.value;
|
||||
if (!data || data.memUsed === undefined || data.memTotal === undefined) return t('statusMonitor.notAvailable');
|
||||
return `${formatMemorySize(data.memUsed)} / ${formatMemorySize(data.memTotal)}`;
|
||||
const data = currentServerStatus.value; // 使用 currentServerStatus
|
||||
if (!data || data.memUsed === undefined || data.memTotal === undefined) return t('statusMonitor.notAvailable');
|
||||
return `${formatMemorySize(data.memUsed)} / ${formatMemorySize(data.memTotal)}`;
|
||||
});
|
||||
|
||||
const diskDisplay = computed(() => {
|
||||
const data = currentServerStatus.value;
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return t('statusMonitor.notAvailable');
|
||||
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)}`;
|
||||
const data = currentServerStatus.value; // 使用 currentServerStatus
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return t('statusMonitor.notAvailable');
|
||||
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)}`;
|
||||
});
|
||||
|
||||
const swapDisplay = computed(() => {
|
||||
const data = currentServerStatus.value;
|
||||
const used = data?.swapUsed ?? 0;
|
||||
const total = data?.swapTotal ?? 0;
|
||||
if (total === 0) return t('statusMonitor.swapNotAvailable');
|
||||
return `${formatMemorySize(used)} / ${formatMemorySize(total)}`;
|
||||
const data = currentServerStatus.value; // 使用 currentServerStatus
|
||||
const used = data?.swapUsed ?? 0;
|
||||
const total = data?.swapTotal ?? 0;
|
||||
const percentVal = data?.swapPercent ?? 0;
|
||||
|
||||
// 仅当交换空间总量 > 0 时显示详细信息
|
||||
if (total === 0) {
|
||||
return t('statusMonitor.swapNotAvailable'); // 或更具体的消息
|
||||
}
|
||||
|
||||
return `${formatMemorySize(used)} / ${formatMemorySize(total)}`;
|
||||
});
|
||||
|
||||
const sessionIpAddress = computed(() => {
|
||||
const sessionState = currentSessionState.value;
|
||||
if (sessionState?.connectionId) {
|
||||
if (sessionState && sessionState.connectionId) {
|
||||
// 直接从 connectionsStore 的 connections 数组中查找
|
||||
const connectionIdAsNumber = parseInt(sessionState.connectionId, 10);
|
||||
if (Number.isNaN(connectionIdAsNumber)) return null;
|
||||
const connectionInfo = connectionsStore.connections.find((conn) => conn.id === connectionIdAsNumber);
|
||||
if (isNaN(connectionIdAsNumber)) {
|
||||
return null; // 如果 connectionId 不是有效的数字,则返回 null
|
||||
}
|
||||
const connectionInfo = connectionsStore.connections.find(conn => conn.id === connectionIdAsNumber);
|
||||
return connectionInfo?.host || null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const overviewStats = computed(() => {
|
||||
if (!currentServerStatus.value) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('statusMonitor.cpuLabel'),
|
||||
value: `${Math.round(displayCpuPercent.value)}%`,
|
||||
meta: displayCpuModel.value,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
label: t('statusMonitor.memoryLabel'),
|
||||
value: memDisplay.value,
|
||||
meta: `${Math.round(displayMemPercent.value)}%`,
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
label: t('statusMonitor.diskLabel'),
|
||||
value: diskDisplay.value,
|
||||
meta: `${Math.round(displayDiskPercent.value)}%`,
|
||||
color: '#a855f7',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
if (!ipAddress) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(ipAddress);
|
||||
uiNotificationsStore.showSuccess(t('common.copied', '已复制'));
|
||||
uiNotificationsStore.showSuccess(t('common.copied', '已复制!'));
|
||||
} catch (err) {
|
||||
console.error('Failed to copy IP address: ', err);
|
||||
uiNotificationsStore.showError(t('statusMonitor.copyIpError', '复制 IP 失败'));
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="status-shell">
|
||||
<header class="status-shell__header">
|
||||
<div>
|
||||
<div class="status-shell__eyebrow">
|
||||
<el-tag round effect="light" type="success">
|
||||
{{ t('statusMonitor.title', '服务器状态') }}
|
||||
</el-tag>
|
||||
<span v-if="activeSessionId" class="status-shell__session">{{ activeSessionId }}</span>
|
||||
</div>
|
||||
<h3>{{ t('statusMonitor.title', '服务器状态') }}</h3>
|
||||
<p>{{ displayOsName }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!activeSessionId" class="status-shell__empty">
|
||||
<el-empty :description="t('layout.noActiveSession.title', '没有活动的会话')">
|
||||
<template #image>
|
||||
<i class="fas fa-plug text-4xl text-text-secondary"></i>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-else-if="currentStatusError"
|
||||
:title="`${t('statusMonitor.errorPrefix')} ${currentStatusError}`"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<div v-else-if="!currentServerStatus" class="status-shell__empty">
|
||||
<el-skeleton :rows="7" animated />
|
||||
</div>
|
||||
|
||||
<div v-else class="status-shell__body">
|
||||
<div class="control-stat-grid">
|
||||
<div v-for="stat in overviewStats" :key="stat.label" class="control-stat-card">
|
||||
<span class="control-stat-card__label">{{ stat.label }}</span>
|
||||
<span class="control-stat-card__value">{{ stat.value }}</span>
|
||||
<span class="control-stat-card__meta">{{ stat.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="status-section">
|
||||
<template #header>
|
||||
<div class="status-section__title">{{ t('statusMonitor.title', '服务器状态') }}</div>
|
||||
</template>
|
||||
|
||||
<div class="status-row" v-if="statusMonitorShowIpBoolean && sessionIpAddress">
|
||||
<span>{{ t('statusMonitor.ipLabel', 'IP 地址') }}</span>
|
||||
<button class="status-link" @click="copyIpToClipboard(sessionIpAddress)">
|
||||
{{ sessionIpAddress }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<span>{{ t('statusMonitor.cpuModelLabel') }}</span>
|
||||
<strong>{{ displayCpuModel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<span>{{ t('statusMonitor.osLabel') }}</span>
|
||||
<strong>{{ displayOsName }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="status-metric">
|
||||
<div class="status-metric__head">
|
||||
<span>{{ t('statusMonitor.cpuLabel') }}</span>
|
||||
<strong>{{ Math.round(displayCpuPercent) }}%</strong>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="displayCpuPercent"
|
||||
:stroke-width="14"
|
||||
color="#3b82f6"
|
||||
:show-text="false"
|
||||
class="themed-progress"
|
||||
:class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="status-metric">
|
||||
<div class="status-metric__head">
|
||||
<span>{{ t('statusMonitor.memoryLabel') }}</span>
|
||||
<strong>{{ memDisplay }}</strong>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="displayMemPercent"
|
||||
:stroke-width="14"
|
||||
color="#22c55e"
|
||||
:show-text="false"
|
||||
class="themed-progress"
|
||||
:class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="status-metric">
|
||||
<div class="status-metric__head">
|
||||
<span>{{ t('statusMonitor.swapLabel') }}</span>
|
||||
<strong>{{ swapDisplay }}</strong>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="displaySwapPercent"
|
||||
:stroke-width="14"
|
||||
:color="(currentServerStatus?.swapPercent ?? 0) > 0 ? '#eab308' : '#94a3b8'"
|
||||
:show-text="false"
|
||||
class="themed-progress"
|
||||
:class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="status-metric">
|
||||
<div class="status-metric__head">
|
||||
<span>{{ t('statusMonitor.diskLabel') }}</span>
|
||||
<strong>{{ diskDisplay }}</strong>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="displayDiskPercent"
|
||||
:stroke-width="14"
|
||||
color="#a855f7"
|
||||
:show-text="false"
|
||||
class="themed-progress"
|
||||
:class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="status-section">
|
||||
<template #header>
|
||||
<div class="status-section__title">{{ t('statusMonitor.networkLabel', '网络') }}</div>
|
||||
</template>
|
||||
|
||||
<div class="network-grid">
|
||||
<div class="network-card">
|
||||
<span class="network-card__label">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
{{ t('statusMonitor.networkLabel') }} / RX
|
||||
</span>
|
||||
<strong>{{ formatBytesPerSecond(currentServerStatus?.netRxRate) }}</strong>
|
||||
<small>{{ currentServerStatus?.netInterface || '--' }}</small>
|
||||
</div>
|
||||
|
||||
<div class="network-card">
|
||||
<span class="network-card__label">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
{{ t('statusMonitor.networkLabel') }} / TX
|
||||
</span>
|
||||
<strong>{{ formatBytesPerSecond(currentServerStatus?.netTxRate) }}</strong>
|
||||
<small>{{ currentServerStatus?.netInterface || '--' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="traffic-summary">
|
||||
<div class="traffic-summary__title">{{ t('statusMonitor.totalTrafficLabel', '开机累计流量') }}</div>
|
||||
<div class="traffic-summary__items">
|
||||
<div class="traffic-chip">
|
||||
<span><i class="fas fa-arrow-down"></i>{{ t('statusMonitor.downloadLabel', '下行') }}</span>
|
||||
<strong>{{ formatBytes(currentServerStatus?.netRxTotalBytes) }}</strong>
|
||||
</div>
|
||||
<div class="traffic-chip traffic-chip--upload">
|
||||
<span><i class="fas fa-arrow-up"></i>{{ t('statusMonitor.uploadLabel', '上行') }}</span>
|
||||
<strong>{{ formatBytes(currentServerStatus?.netTxTotalBytes) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="status-section status-section--chart">
|
||||
<template #header>
|
||||
<div class="status-section__title">{{ t('statusMonitor.cpuUsageTitle', 'CPU 使用率') }}</div>
|
||||
</template>
|
||||
<StatusCharts :server-status="currentServerStatus" :active-session-id="activeSessionId" />
|
||||
</el-card>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(103, 124, 155, 0.18);
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(243, 247, 252, 0.82));
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.status-shell__header h3 {
|
||||
margin: 0.8rem 0 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.status-shell__header p {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.status-shell__eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-shell__session {
|
||||
color: var(--text-color-tertiary);
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-shell__empty {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.status-shell__body {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.status-section__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(103, 124, 155, 0.12);
|
||||
}
|
||||
|
||||
.status-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.status-row span {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.status-row strong {
|
||||
color: var(--text-color);
|
||||
font-size: 0.86rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-link {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-metric {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.status-metric__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.status-metric__head span {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.status-metric__head strong {
|
||||
color: var(--text-color);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.network-grid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.network-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(103, 124, 155, 0.14);
|
||||
border-radius: 18px;
|
||||
background: rgba(247, 250, 253, 0.9);
|
||||
}
|
||||
|
||||
.network-card__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-color-tertiary);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.network-card strong {
|
||||
display: block;
|
||||
margin-top: 0.55rem;
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.network-card small {
|
||||
display: block;
|
||||
margin-top: 0.4rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.traffic-summary {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(103, 124, 155, 0.12);
|
||||
}
|
||||
|
||||
.traffic-summary__title {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.traffic-summary__items {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.traffic-chip {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(24, 190, 120, 0.08);
|
||||
color: #15915e;
|
||||
}
|
||||
|
||||
.traffic-chip--upload {
|
||||
background: rgba(249, 115, 22, 0.08);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.traffic-chip span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.traffic-chip strong {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.status-section--chart :deep(.el-card__body) {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
::v-deep(.el-progress-bar__outer) {
|
||||
background-color: rgba(226, 233, 244, 0.86) !important;
|
||||
background-color: var(--header-bg-color) !important;
|
||||
}
|
||||
|
||||
::v-deep(.themed-progress .el-progress-bar__inner) {
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
::v-deep(.themed-progress.no-transition .el-progress-bar__inner) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.network-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
::v-deep(.el-progress-bar__innerText) {
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
top: -0.5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -743,7 +743,7 @@ watchEffect(() => {
|
||||
.terminal-inner-container :deep(.xterm),
|
||||
.terminal-inner-container :deep(.xterm-screen),
|
||||
.terminal-inner-container :deep(.xterm-viewport) {
|
||||
cursor: text !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.terminal-inner-container :deep(.xterm .xterm-cursor-pointer) {
|
||||
|
||||
@@ -49,40 +49,31 @@ const workbenchTabs = computed(() => [
|
||||
{
|
||||
id: 'quickCommands' as const,
|
||||
label: t('workspace.workbench.tabs.quickCommands', '快捷指令'),
|
||||
shortLabel: t('workspace.workbench.tabs.quickCommands', '快捷指令'),
|
||||
icon: 'fas fa-bolt',
|
||||
hint: t('workspace.workbench.quickCommandsHint', '默认面板,用于常用命令与预置脚本。'),
|
||||
},
|
||||
{
|
||||
id: 'files' as const,
|
||||
label: t('workspace.workbench.tabs.files', '文件'),
|
||||
shortLabel: t('workspace.workbench.tabs.files', '文件'),
|
||||
icon: 'fas fa-folder-tree',
|
||||
hint: t('workspace.workbench.filesHint', '浏览远程目录、拖放文件与操作资源。'),
|
||||
icon: 'fas fa-folder-open',
|
||||
},
|
||||
{
|
||||
id: 'history' as const,
|
||||
label: t('workspace.workbench.tabs.history', '历史命令'),
|
||||
shortLabel: t('workspace.workbench.tabs.history', '历史命令'),
|
||||
icon: 'fas fa-clock-rotate-left',
|
||||
hint: t('workspace.workbench.historyHint', '回放最近命令并快速重发到当前会话。'),
|
||||
icon: 'fas fa-history',
|
||||
},
|
||||
{
|
||||
id: 'editor' as const,
|
||||
label: t('workspace.workbench.tabs.editor', '编辑器'),
|
||||
shortLabel: t('workspace.workbench.tabs.editor', '编辑器'),
|
||||
icon: 'fas fa-pen-ruler',
|
||||
hint: t('workspace.workbench.editorHint', '在工作台里直接查看并编辑当前打开的文件。'),
|
||||
icon: 'fas fa-pen-to-square',
|
||||
},
|
||||
]);
|
||||
|
||||
const activeSessionName = computed(() => {
|
||||
if (!props.sessionId) return null;
|
||||
return sessions.value.get(props.sessionId)?.connectionName ?? props.sessionId;
|
||||
});
|
||||
if (!props.sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeWorkbenchMeta = computed(() => {
|
||||
return workbenchTabs.value.find((tab) => tab.id === activeWorkbenchTab.value) ?? workbenchTabs.value[0];
|
||||
return sessions.value.get(props.sessionId)?.connectionName ?? props.sessionId;
|
||||
});
|
||||
|
||||
const hasFileManagerContext = computed(() => {
|
||||
@@ -106,235 +97,134 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workbench-shell">
|
||||
<header class="workbench-shell__header">
|
||||
<div class="workbench-shell__copy">
|
||||
<div class="workbench-shell__eyebrow">
|
||||
<el-tag round effect="light" type="primary">
|
||||
{{ t('workspace.workbench.label', '工作台') }}
|
||||
</el-tag>
|
||||
<span class="workbench-shell__session">
|
||||
<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', '未激活会话') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<h3>{{ t('workspace.workbench.title', 'Workbench') }}</h3>
|
||||
<p>{{ activeWorkbenchMeta.hint }}</p>
|
||||
<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-2 gap-2 xl:grid-cols-4">
|
||||
<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 === 'quickCommands'" class="absolute inset-0 min-h-0 workbench-quick-commands">
|
||||
<QuickCommandsView />
|
||||
</div>
|
||||
|
||||
<div class="workbench-shell__chips">
|
||||
<div class="workbench-chip">
|
||||
<span>{{ t('workspace.workbench.tabs.quickCommands', '快捷指令') }}</span>
|
||||
<strong>Default</strong>
|
||||
</div>
|
||||
<div class="workbench-chip">
|
||||
<span>{{ t('workspace.workbench.tabs.editor', '编辑器') }}</span>
|
||||
<strong>{{ tabs.length }}</strong>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<el-tabs v-model="activeWorkbenchTab" class="workbench-tabs" stretch>
|
||||
<el-tab-pane v-for="tab in workbenchTabs" :key="tab.id" :name="tab.id">
|
||||
<template #label>
|
||||
<span class="workbench-tab-label">
|
||||
<i :class="tab.icon"></i>
|
||||
<span>{{ tab.shortLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<div v-show="activeWorkbenchTab === 'history'" class="absolute inset-0 min-h-0">
|
||||
<CommandHistoryView />
|
||||
</div>
|
||||
|
||||
<div class="workbench-shell__panel">
|
||||
<div v-show="activeWorkbenchTab === 'quickCommands'" class="workbench-panel workbench-panel--quick">
|
||||
<QuickCommandsView />
|
||||
</div>
|
||||
|
||||
<div v-show="activeWorkbenchTab === 'files'" class="workbench-panel">
|
||||
<FileManager
|
||||
v-if="hasFileManagerContext"
|
||||
:session-id="fileManagerSessionId"
|
||||
:instance-id="fileManagerInstanceId"
|
||||
:db-connection-id="fileManagerConnectionId"
|
||||
:ws-deps="fileManagerWsDeps"
|
||||
class="h-full"
|
||||
/>
|
||||
<div v-else class="workbench-empty">
|
||||
<el-empty :description="t('layout.noActiveSession.title', '没有活动的会话')">
|
||||
<template #image>
|
||||
<i class="fas fa-folder-tree text-4xl text-text-secondary"></i>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ t('workspace.workbench.fileManagerHint', '激活一个 SSH 会话后即可浏览远程文件。') }}
|
||||
</div>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeWorkbenchTab === 'history'" class="workbench-panel">
|
||||
<CommandHistoryView />
|
||||
</div>
|
||||
|
||||
<div v-show="activeWorkbenchTab === 'editor'" class="workbench-panel">
|
||||
<FileEditorContainer :tabs="tabs" :active-tab-id="activeTabId" :session-id="sessionId" />
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
.workbench-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(103, 124, 155, 0.18);
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(243, 247, 252, 0.82));
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.workbench-shell__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem 1.1rem 0.8rem;
|
||||
}
|
||||
|
||||
.workbench-shell__copy h3 {
|
||||
margin: 0.8rem 0 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.workbench-shell__copy p {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workbench-shell__eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workbench-shell__session {
|
||||
color: var(--text-color-tertiary);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workbench-shell__chips {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.workbench-chip {
|
||||
min-width: 92px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid rgba(103, 124, 155, 0.14);
|
||||
border-radius: 18px;
|
||||
background: rgba(247, 250, 253, 0.9);
|
||||
}
|
||||
|
||||
.workbench-chip span {
|
||||
display: block;
|
||||
color: var(--text-color-tertiary);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.workbench-chip strong {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--text-color);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.workbench-tabs {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.85rem 0.85rem;
|
||||
}
|
||||
|
||||
.workbench-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.workbench-tabs :deep(.el-tabs__nav-wrap) {
|
||||
padding: 0.35rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(236, 242, 249, 0.78);
|
||||
}
|
||||
|
||||
.workbench-tabs :deep(.el-tabs__content),
|
||||
.workbench-tabs :deep(.el-tab-pane) {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.workbench-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.workbench-shell__panel {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workbench-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(103, 124, 155, 0.14);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.workbench-panel--quick {
|
||||
.workbench-quick-commands {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(60, 105, 231, 0.14), transparent 24%),
|
||||
linear-gradient(180deg, rgba(248, 250, 255, 0.96), rgba(239, 245, 252, 0.92));
|
||||
linear-gradient(180deg, rgba(15, 17, 22, 0.98) 0%, rgba(12, 14, 18, 1) 100%);
|
||||
}
|
||||
|
||||
.workbench-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.workbench-panel--quick :deep(> div),
|
||||
.workbench-panel--quick :deep(> div > div) {
|
||||
.workbench-quick-commands :deep(> div),
|
||||
.workbench-quick-commands :deep(> div > div) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.workbench-shell__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.workbench-quick-commands :deep(input) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: #f5f7fa;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.workbench-shell__chips {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.workbench-quick-commands :deep(input::placeholder) {
|
||||
color: rgba(226, 232, 240, 0.55);
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep(button) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep([data-command-id]) {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep([data-command-id]::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.2rem;
|
||||
top: 0.2rem;
|
||||
bottom: 0.2rem;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep([data-command-id]:hover) {
|
||||
background: rgba(139, 92, 246, 0.14);
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep([data-command-id].bg-primary\/20) {
|
||||
background: linear-gradient(90deg, rgba(139, 92, 246, 0.3), rgba(139, 92, 246, 0.18));
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep(.font-semibold.flex.items-center) {
|
||||
color: #f8fafc;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user