feat(frontend): 支持快捷指令跨组拖拽并升级网络历史图

新增快捷指令跨标签组拖拽能力,支持将未标记命令直接拖入
目标标签组,并修正 manual/name/last_used 排序按钮状态映射

将状态监控内存与网络卡片的响应式阈值统一收紧到 250px,
同时用可悬浮查看最近采样点的 canvas 历史图替换网络趋势线
This commit is contained in:
yinjianm
2026-04-19 03:40:48 +08:00
parent 8ce007a305
commit 0e01153157
16 changed files with 1001 additions and 136 deletions
@@ -120,43 +120,45 @@
</section>
<section class="monitor-module monitor-module--network">
<div class="network-module__hero">
<div class="monitor-module__heading">
<div>
<span class="monitor-module__eyebrow">{{ t('statusMonitor.networkLabel') }}</span>
<h5 class="monitor-module__title">{{ t('statusMonitor.networkLabel') }}</h5>
</div>
<div class="network-module__sparkline" aria-hidden="true">
<svg viewBox="0 0 160 30" preserveAspectRatio="none">
<path class="network-module__sparkline-path network-module__sparkline-path--up" :d="networkUpSparklinePath"></path>
<path class="network-module__sparkline-path network-module__sparkline-path--down" :d="networkDownSparklinePath"></path>
</svg>
</div>
<span class="monitor-module__pill">{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitLabel }) }}</span>
</div>
<div class="network-table">
<div class="network-table__header">
<span>{{ networkInterfaceDisplay }}</span>
<span>{{ t('statusMonitor.downloadLabel') }} / {{ t('statusMonitor.uploadLabel') }}</span>
</div>
<div class="network-table__columns">
<span></span>
<span>{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitLabel }) }}</span>
<span>{{ t('statusMonitor.totalTrafficLabel') }}</span>
</div>
<div class="module-split module-split--network">
<StatusMonitorNetworkHistoryChart
:download-history="currentNetRxHistory"
:upload-history="currentNetTxHistory"
/>
<div class="network-stat-stack">
<article
v-for="item in networkFlowItems"
:key="item.key"
:class="['network-stat', `network-stat--${item.tone}`]"
>
<span class="network-stat__label">
<i :class="['fas', item.icon]"></i>
<span>{{ item.label }}</span>
</span>
<span class="network-stat__value">{{ item.value }}</span>
<span class="network-stat__total">{{ item.totalValue }}</span>
</article>
<div class="network-table">
<div class="network-table__header">
<span>{{ networkInterfaceDisplay }}</span>
<span>{{ t('statusMonitor.downloadLabel') }} / {{ t('statusMonitor.uploadLabel') }}</span>
</div>
<div class="network-table__columns">
<span></span>
<span>{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitLabel }) }}</span>
<span>{{ t('statusMonitor.totalTrafficLabel') }}</span>
</div>
<div class="network-stat-stack">
<article
v-for="item in networkFlowItems"
:key="item.key"
:class="['network-stat', `network-stat--${item.tone}`]"
>
<span class="network-stat__label">
<i :class="['fas', item.icon]"></i>
<span>{{ item.label }}</span>
</span>
<span class="network-stat__value">{{ item.value }}</span>
<span class="network-stat__total">{{ item.totalValue }}</span>
</article>
</div>
</div>
</div>
</section>
@@ -273,6 +275,7 @@ import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import ProcessManagerModal from './ProcessManagerModal.vue';
import StatusCharts from './StatusCharts.vue';
import StatusMonitorNetworkHistoryChart from './StatusMonitorNetworkHistoryChart.vue';
import { useSessionStore } from '../stores/session.store';
import { useSettingsStore } from '../stores/settings.store';
import { useConnectionsStore } from '../stores/connections.store';
@@ -554,24 +557,6 @@ const networkRateUnitLabel = computed(() => {
return maxRate >= 1024 * 1024 ? 'MB/s' : 'KB/s';
});
const networkHistoryScale = computed(() => {
const values = [
...currentNetRxHistory.value.map(value => value ?? 0),
...currentNetTxHistory.value.map(value => value ?? 0),
];
return Math.max(...values, 1);
});
const networkDownSparklinePath = computed(() => {
const samples = currentNetRxHistory.value.slice(-24).map(value => Math.max(0, ((value ?? 0) / networkHistoryScale.value) * 100));
return buildSparklinePath(samples, 160, 30, 22);
});
const networkUpSparklinePath = computed(() => {
const samples = currentNetTxHistory.value.slice(-24).map(value => Math.max(0, ((value ?? 0) / networkHistoryScale.value) * 100));
return buildSparklinePath(samples, 160, 30, 22);
});
const diskDeviceAccent = computed(() => {
const raw = currentServerStatus.value?.diskDevice;
@@ -1014,6 +999,16 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
gap: 12px;
}
.module-split--memory {
grid-template-columns: minmax(110px, 0.88fr) minmax(0, 1.12fr);
align-items: stretch;
}
.module-split--network {
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
align-items: stretch;
}
.memory-ring-panel,
.disk-device-card,
.disk-io-card,
@@ -1085,6 +1080,12 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
border-radius: 10px;
}
.memory-stat-stack {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-content: center;
gap: 6px;
}
.memory-stat__label {
display: inline-flex;
align-items: center;
@@ -1123,46 +1124,10 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
line-height: 1.15;
}
.network-module__hero {
display: grid;
grid-template-columns: auto minmax(96px, 1fr);
align-items: center;
gap: 12px;
}
.network-module__sparkline {
height: 30px;
min-width: 0;
border-top: 1px solid rgba(148, 163, 184, 0.14);
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.network-module__sparkline svg {
display: block;
width: 100%;
height: 100%;
}
.network-module__sparkline-path {
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.network-module__sparkline-path--up {
stroke: #34d399;
filter: drop-shadow(0 0 6px rgba(52, 211, 153, 0.28));
}
.network-module__sparkline-path--down {
stroke: #60a5fa;
filter: drop-shadow(0 0 6px rgba(96, 165, 250, 0.24));
}
.network-table {
display: grid;
gap: 8px;
height: 100%;
padding: 10px 12px;
}
@@ -1475,12 +1440,6 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
align-items: stretch;
}
.memory-stat-stack {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-content: center;
gap: 6px;
}
.disk-summary-table__head span,
.disk-summary-table__row span {
text-align: left;
@@ -1489,7 +1448,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
@container (max-width: 250px) {
.module-split--memory,
.network-module__hero,
.module-split--network,
.disk-compact-top {
grid-template-columns: 1fr;
}
@@ -1537,10 +1496,6 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
.process-summary-strip {
grid-template-columns: 1fr;
}
.memory-stat-stack {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
@@ -0,0 +1,348 @@
<template>
<div ref="chartHostRef" class="network-history-chart">
<div class="network-history-chart__header">
<div>
<p class="network-history-chart__subtitle">{{ t('statusMonitor.networkHistoryRecentPoints', { count: displayPointCount }) }}</p>
<h6 class="network-history-chart__title">
{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitIsMB ? 'MB/s' : 'KB/s' }) }}
</h6>
</div>
<div class="network-history-chart__legend">
<span class="network-history-chart__legend-item">
<span class="network-history-chart__legend-dot network-history-chart__legend-dot--download"></span>
{{ t('statusMonitor.downloadLabel') }}
</span>
<span class="network-history-chart__legend-item">
<span class="network-history-chart__legend-dot network-history-chart__legend-dot--upload"></span>
{{ t('statusMonitor.uploadLabel') }}
</span>
</div>
</div>
<div class="network-history-chart__canvas">
<Line :data="networkChartData" :options="networkChartOptions" :key="chartKey" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import { Line } from 'vue-chartjs';
import {
CategoryScale,
Chart as ChartJS,
type ChartOptions,
Legend,
LineElement,
LinearScale,
PointElement,
Title,
Tooltip,
type TooltipItem,
} from 'chart.js';
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
PointElement,
CategoryScale,
);
const DISPLAY_POINTS = 24;
const KB_TO_MB_THRESHOLD = 1024;
const props = defineProps({
downloadHistory: {
type: Array as PropType<readonly (number | null)[]>,
required: true,
},
uploadHistory: {
type: Array as PropType<readonly (number | null)[]>,
required: true,
},
});
const { t } = useI18n();
const chartHostRef = ref<HTMLElement | null>(null);
const chartKey = ref(0);
let chartResizeObserver: ResizeObserver | null = null;
let lastChartHostWidth = 0;
const recentDownloadHistory = computed(() => props.downloadHistory.slice(-DISPLAY_POINTS));
const recentUploadHistory = computed(() => props.uploadHistory.slice(-DISPLAY_POINTS));
const displayPointCount = computed(() => Math.max(recentDownloadHistory.value.length, recentUploadHistory.value.length, DISPLAY_POINTS));
const peakHistoryRateKB = computed(() => {
const values = [
...recentDownloadHistory.value.map(value => (value ?? 0) / 1024),
...recentUploadHistory.value.map(value => (value ?? 0) / 1024),
];
return Math.max(...values, 0);
});
const networkRateUnitIsMB = computed(() => peakHistoryRateKB.value >= KB_TO_MB_THRESHOLD);
const chartDivisor = computed(() => (networkRateUnitIsMB.value ? 1024 * 1024 : 1024));
const chartPrecision = computed(() => (networkRateUnitIsMB.value ? 2 : 1));
const chartLabels = computed(() =>
Array.from({ length: displayPointCount.value }, (_, index) => `${index + 1}`),
);
const networkChartData = computed(() => ({
labels: chartLabels.value,
datasets: [
{
label: t('statusMonitor.networkDownloadLabelUnit', { unit: networkRateUnitIsMB.value ? 'MB/s' : 'KB/s' }),
data: recentDownloadHistory.value.map(value =>
value === null || value === undefined ? null : Number((value / chartDivisor.value).toFixed(chartPrecision.value)),
),
borderColor: 'rgba(96, 165, 250, 1)',
backgroundColor: 'rgba(96, 165, 250, 0.18)',
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
tension: 0.24,
spanGaps: true,
},
{
label: t('statusMonitor.networkUploadLabelUnit', { unit: networkRateUnitIsMB.value ? 'MB/s' : 'KB/s' }),
data: recentUploadHistory.value.map(value =>
value === null || value === undefined ? null : Number((value / chartDivisor.value).toFixed(chartPrecision.value)),
),
borderColor: 'rgba(52, 211, 153, 1)',
backgroundColor: 'rgba(52, 211, 153, 0.16)',
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
tension: 0.24,
spanGaps: true,
},
],
}));
const suggestedYAxisMax = computed(() => {
const allValues = [
...recentDownloadHistory.value,
...recentUploadHistory.value,
].filter((value): value is number => value !== null && value !== undefined && Number.isFinite(value))
.map(value => value / chartDivisor.value);
const currentMax = Math.max(...allValues, 0);
if (currentMax === 0) {
return networkRateUnitIsMB.value ? 1 : 100;
}
if (networkRateUnitIsMB.value) {
return Math.max(1, Math.ceil(currentMax * 1.2));
}
if (currentMax <= 100) {
return Math.max(10, Math.ceil((currentMax * 1.2) / 10) * 10);
}
if (currentMax <= 500) {
return Math.ceil((currentMax * 1.2) / 50) * 50;
}
return Math.ceil((currentMax * 1.2) / 100) * 100;
});
const networkChartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
displayColors: true,
callbacks: {
title: () => '',
label: (context: TooltipItem<'line'>) => {
const label = context.dataset.label ?? '';
const value = context.parsed.y;
if (value === null) {
return label;
}
return `${label}: ${Number(value).toFixed(chartPrecision.value)} ${networkRateUnitIsMB.value ? 'MB/s' : 'KB/s'}`;
},
},
},
},
scales: {
x: {
display: false,
grid: {
display: false,
},
},
y: {
beginAtZero: true,
min: 0,
max: suggestedYAxisMax.value,
ticks: {
color: '#8fa0b3',
callback: value => {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue.toFixed(networkRateUnitIsMB.value ? 2 : 0) : '';
},
},
grid: {
color: 'rgba(148, 163, 184, 0.12)',
},
},
},
}));
const rerenderChart = (): void => {
chartKey.value += 1;
};
watch(
() => [recentDownloadHistory.value, recentUploadHistory.value, suggestedYAxisMax.value],
() => {
rerenderChart();
},
{ deep: true },
);
onMounted(() => {
const host = chartHostRef.value;
if (!host || typeof ResizeObserver === 'undefined') {
return;
}
lastChartHostWidth = Math.round(host.getBoundingClientRect().width);
chartResizeObserver = new ResizeObserver(entries => {
const entry = entries[0];
if (!entry) {
return;
}
const nextWidth = Math.round(entry.contentRect.width);
if (!nextWidth || Math.abs(nextWidth - lastChartHostWidth) < 2) {
return;
}
lastChartHostWidth = nextWidth;
nextTick(() => {
rerenderChart();
});
});
chartResizeObserver.observe(host);
});
onBeforeUnmount(() => {
chartResizeObserver?.disconnect();
chartResizeObserver = null;
});
</script>
<style scoped>
.network-history-chart {
display: grid;
gap: 10px;
min-width: 0;
height: 100%;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.02)),
radial-gradient(circle at top left, rgba(59, 130, 246, 0.06), transparent 62%);
padding: 12px;
}
.network-history-chart__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.network-history-chart__subtitle {
margin: 0;
color: #8fa0b3;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.network-history-chart__title {
margin: 6px 0 0;
color: #f8fbff;
font-size: 14px;
font-weight: 800;
line-height: 1.2;
}
.network-history-chart__legend {
display: inline-flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.network-history-chart__legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
color: #d9e5f1;
font-size: 11px;
font-weight: 700;
}
.network-history-chart__legend-dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.network-history-chart__legend-dot--download {
background: rgba(96, 165, 250, 1);
}
.network-history-chart__legend-dot--upload {
background: rgba(52, 211, 153, 1);
}
.network-history-chart__canvas {
min-width: 0;
width: 100%;
height: 164px;
overflow: hidden;
}
.network-history-chart__canvas :deep(canvas) {
width: 100% !important;
max-width: 100% !important;
}
@container (max-width: 300px) {
.network-history-chart__header {
flex-direction: column;
}
.network-history-chart__legend {
justify-content: flex-start;
}
}
</style>