feat(workspace): add cpu core status display and safer quick command actions

Expose `cpuCores` in backend status collection with multi-command fallback
and surface it in the status panel as a localized CPU core badge under the
CPU model.

Adjust terminal group UX by adding a server-level close-all control in the
SSH tab group header.

Reduce accidental quick command execution by switching list interaction to
single-click select + double-click execute, while preserving keyboard Enter
and context-menu execution paths.
This commit is contained in:
yinjianm
2026-04-12 07:22:09 +08:00
parent 840fe292c3
commit 8c130adcc9
26 changed files with 1000 additions and 55 deletions
@@ -36,7 +36,10 @@
<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 class="cpu-spec-block text-left">
<span class="cpu-model-value truncate" :title="displayCpuModel">{{ displayCpuModel }}</span>
<span class="cpu-core-badge">{{ displayCpuCores }}</span>
</div>
</div>
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
@@ -245,6 +248,7 @@ const displaySwapPercent = computed(() => currentServerStatus.value?.swapPercent
const currentStatusError = computed<string | null>(() => currentSessionState.value?.statusMonitorManager?.statusError?.value ?? null);
const cachedCpuModel = ref<string | null>(null);
const cachedCpuCores = ref<number | null>(null);
const cachedOsName = ref<string | null>(null);
watch(currentServerStatus, newData => {
@@ -252,6 +256,9 @@ watch(currentServerStatus, newData => {
if (newData.cpuModel) {
cachedCpuModel.value = newData.cpuModel;
}
if (typeof newData.cpuCores === 'number' && Number.isFinite(newData.cpuCores)) {
cachedCpuCores.value = newData.cpuCores;
}
if (newData.osName) {
cachedOsName.value = newData.osName;
}
@@ -266,6 +273,14 @@ watch(() => props.activeSessionId, async (newId, oldId) => {
});
const displayCpuModel = computed(() => (currentServerStatus.value?.cpuModel ?? cachedCpuModel.value) || t('statusMonitor.notAvailable'));
const displayCpuCores = computed(() => {
const cpuCores = currentServerStatus.value?.cpuCores ?? cachedCpuCores.value;
if (typeof cpuCores !== 'number' || !Number.isFinite(cpuCores)) {
return t('statusMonitor.notAvailable');
}
return t('statusMonitor.cpuCoresValue', { count: Math.round(cpuCores) });
});
const displayOsName = computed(() => (currentServerStatus.value?.osName ?? cachedOsName.value) || t('statusMonitor.notAvailable'));
const formatBytesPerSecond = (bytes?: number): string => {
@@ -434,6 +449,36 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
border-radius: 14px;
padding: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
container-type: inline-size;
}
.cpu-spec-block {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.cpu-model-value {
display: block;
width: 100%;
}
.cpu-core-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.26);
background: rgba(37, 99, 235, 0.14);
color: #dbeafe;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.status-card__header {
@@ -530,6 +575,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 10px;
padding: 8px 10px;
min-width: 0;
}
.memory-stat__label,
@@ -537,6 +583,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-secondary-color, #9ca3af);
}
@@ -550,6 +597,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
font-weight: 700;
color: #f8fafc;
line-height: 1.15;
overflow-wrap: anywhere;
}
.memory-stat__dot {
@@ -654,6 +702,11 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
align-items: center;
}
.disk-table__header > span,
.disk-table__row > span {
min-width: 0;
}
.disk-table__header {
color: var(--text-secondary-color, #9ca3af);
font-size: 12px;
@@ -688,6 +741,67 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
border: 1px solid rgba(34, 197, 94, 0.18);
}
@container (max-width: 320px) {
.status-card__header {
flex-wrap: wrap;
}
.memory-card__content,
.disk-card__body {
grid-template-columns: 1fr;
}
.memory-ring,
.disk-usage-tube {
justify-self: center;
}
.memory-stats-grid,
.disk-rate-grid {
grid-template-columns: 1fr;
}
.memory-stat__value,
.disk-rate-card__value {
font-size: 18px;
}
.disk-meta-row {
flex-direction: column;
align-items: flex-start;
}
.disk-table__header,
.disk-table__row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container (max-width: 250px) {
.status-card {
padding: 12px;
}
.status-card__badge {
white-space: normal;
}
.memory-stat__value,
.disk-rate-card__value {
font-size: 16px;
}
.disk-table__header,
.disk-table__row {
grid-template-columns: 1fr;
}
.disk-mount-pill,
.disk-percent-pill {
width: 100%;
}
}
@media (max-width: 640px) {
.memory-card__content,
.disk-card__body {
+16 -8
View File
@@ -42,7 +42,7 @@ let lastResizeObserverWidth = 0;
let lastResizeObserverHeight = 0;
const RESIZE_THRESHOLD = 0.5; // px
const BOTTOM_STICK_THRESHOLD = 2;
let lastKnownViewportLine = 0;
let lastKnownDistanceFromBottom = 0;
let lastKnownShouldStickToBottom = true;
@@ -95,7 +95,7 @@ const debounce = (func: Function, delay: number) => {
};
type TerminalViewportSnapshot = {
viewportLine: number;
distanceFromBottom: number;
shouldStickToBottom: boolean;
};
@@ -103,30 +103,38 @@ const getViewportSnapshot = (term: Terminal): TerminalViewportSnapshot => {
const buffer = term.buffer.active;
const maxScrollLine = Math.max(0, buffer.baseY);
const viewportLine = Math.max(0, Math.min(buffer.viewportY, maxScrollLine));
// Keep a relative offset from the live bottom so hidden terminals can recover
// the same reading position even if logs keep appending while inactive.
const distanceFromBottom = Math.max(0, maxScrollLine - viewportLine);
return {
viewportLine,
shouldStickToBottom: maxScrollLine - viewportLine <= BOTTOM_STICK_THRESHOLD,
distanceFromBottom,
shouldStickToBottom: distanceFromBottom <= BOTTOM_STICK_THRESHOLD,
};
};
const syncViewportTracking = (term: Terminal): TerminalViewportSnapshot => {
const snapshot = getViewportSnapshot(term);
lastKnownViewportLine = snapshot.viewportLine;
lastKnownDistanceFromBottom = snapshot.distanceFromBottom;
lastKnownShouldStickToBottom = snapshot.shouldStickToBottom;
return snapshot;
};
const restoreViewportSnapshot = (term: Terminal, snapshot?: TerminalViewportSnapshot) => {
const effectiveSnapshot = snapshot ?? {
viewportLine: lastKnownViewportLine,
distanceFromBottom: lastKnownDistanceFromBottom,
shouldStickToBottom: lastKnownShouldStickToBottom,
};
const maxScrollLine = Math.max(0, term.buffer.active.baseY);
if (effectiveSnapshot.shouldStickToBottom) {
term.scrollToBottom();
} else {
const targetLine = Math.min(effectiveSnapshot.viewportLine, Math.max(0, term.buffer.active.baseY));
const targetDistanceFromBottom = Math.min(
Math.max(0, effectiveSnapshot.distanceFromBottom),
maxScrollLine,
);
const targetLine = Math.max(0, maxScrollLine - targetDistanceFromBottom);
term.scrollToLine(targetLine);
}
@@ -418,7 +426,7 @@ onMounted(() => {
// --- Become Active ---
console.log(`[Terminal ${props.sessionId}] Becoming active. Observing element and fitting.`);
const activationViewportSnapshot = {
viewportLine: lastKnownViewportLine,
distanceFromBottom: lastKnownDistanceFromBottom,
shouldStickToBottom: lastKnownShouldStickToBottom,
};
// Start observing
@@ -58,6 +58,16 @@ const closeSession = (event: MouseEvent, sessionId: string) => {
emitWorkspaceEvent('session:close', { sessionId });
};
const closeConnectionGroup = (event: MouseEvent, connectionId: string) => {
event.preventDefault();
event.stopPropagation();
const sessionIds = getConnectionSessions(connectionId).map((session) => session.sessionId);
sessionIds.forEach((sessionId) => {
emitWorkspaceEvent('session:close', { sessionId });
});
};
// --- 本地状态 ---
const sessionStore = useSessionStore(); // Session store 保持不变
const showConnectionListPopup = ref(false); // 连接列表弹出状态
@@ -534,34 +544,49 @@ onBeforeUnmount(() => {
:class="['flex h-full flex-shrink-0 items-stretch py-1', isGroupStart(index) ? 'pl-1' : 'pl-0']"
@dragstart="handleDragStart"
>
<button
<div
v-if="isSshConnection(session.connectionId)"
type="button"
:class="[
'group flex h-full items-center gap-2 rounded-md border px-3 text-left transition-all duration-150',
'group flex h-full items-center rounded-md border px-3 transition-all duration-150',
session.connectionId === activeConnectionId
? 'border-primary/60 bg-primary/10 text-foreground shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'border-border/70 bg-header/80 text-text-secondary shadow-[0_0_0_1px_rgba(0,0,0,0.08)] hover:bg-border hover:text-foreground',
]"
:title="getTopLevelItemTitle(session)"
@click="activateTopLevelItem(session)"
@contextmenu.prevent="showTopLevelContextMenu($event, session)"
>
<i class="fas fa-server text-[11px] text-primary/80"></i>
<span class="max-w-[180px] truncate text-xs font-semibold tracking-wide">
{{ session.connectionName }}
</span>
<span
:class="[
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
session.connectionId === activeConnectionId
? 'bg-primary/15 text-foreground/90'
: 'bg-black/20 text-text-secondary group-hover:text-foreground',
]"
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-2 text-left"
@click="activateTopLevelItem(session)"
>
{{ getConnectionSessionCount(session.connectionId) }}
</span>
</button>
<i class="fas fa-server text-[11px] text-primary/80"></i>
<span class="max-w-[180px] truncate text-xs font-semibold tracking-wide">
{{ session.connectionName }}
</span>
<span
:class="[
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
session.connectionId === activeConnectionId
? 'bg-primary/15 text-foreground/90'
: 'bg-black/20 text-text-secondary group-hover:text-foreground',
]"
>
{{ getConnectionSessionCount(session.connectionId) }}
</span>
</button>
<button
type="button"
class="ml-2 rounded-full p-0.5 text-text-secondary opacity-0 transition-opacity duration-150 hover:bg-header hover:text-foreground group-hover:opacity-100"
:class="{ 'text-foreground': session.connectionId === activeConnectionId }"
:title="t('terminalTabBar.closeConnectionGroupTooltip', { name: session.connectionName, count: getConnectionSessionCount(session.connectionId) })"
@click="closeConnectionGroup($event, session.connectionId)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div
v-else
:class="[