From 896395b84a5f0dd01638ed5da61cf48e38de5479 Mon Sep 17 00:00:00 2001 From: Orion Date: Fri, 5 Jun 2026 11:38:48 +0800 Subject: [PATCH] Reorder dashboard cards and expand trend metrics --- src/components/common/SmoothTrendCanvas.vue | 124 +++++--- src/components/common/StatCard.vue | 2 +- src/views/manage/DashboardView.vue | 335 ++++++++++---------- 3 files changed, 262 insertions(+), 199 deletions(-) diff --git a/src/components/common/SmoothTrendCanvas.vue b/src/components/common/SmoothTrendCanvas.vue index e28b0c0..031c900 100644 --- a/src/components/common/SmoothTrendCanvas.vue +++ b/src/components/common/SmoothTrendCanvas.vue @@ -1,5 +1,5 @@ @@ -52,6 +54,14 @@ type TooltipState = { date: string downloads: number uploads: number + downloadTraffic: string + uploadTraffic: string +} + +type ChartPoint = { + x: number + y: number + row: AnalyticsDailyRow } const canvasRef = ref(null) @@ -72,6 +82,14 @@ const tooltipClass = computed(() => : 'border-gray-200 bg-white text-gray-900' ) +const formatBytes = (value: number | string | undefined) => { + const bytes = Number(value || 0) + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}` +} + const getDefaultWindow = () => { const width = canvasRef.value?.getBoundingClientRect().width || window.innerWidth return Math.max(2, Math.min(props.rows.length || 2, width < 640 ? 14 : 60)) @@ -121,9 +139,14 @@ const draw = () => { const padBottom = mobile ? 42 : 36 const width = Math.max(1, rect.width - padLeft - padRight) const height = Math.max(1, rect.height - padTop - padBottom) - const max = Math.max(1, ...visible.map((row) => Number(row.downloads || 0))) + const max = Math.max( + 1, + ...visible.flatMap((row) => [Number(row.downloads || 0), Number(row.uploads || 0)]) + ) const muted = props.isDarkMode ? '#9ca3af' : '#6b7280' const border = props.isDarkMode ? '#374151' : '#e5e7eb' + const downloadColor = '#4f46e5' + const uploadColor = '#10b981' ctx.strokeStyle = border ctx.lineWidth = 1 @@ -139,33 +162,62 @@ const draw = () => { } const denominator = Math.max(1, windowSize.value - 1) - const points = visible - .map((row, index) => { - const sourceIndex = start + index - return { - x: padLeft + ((sourceIndex - offset.value) / denominator) * width, - y: padTop + height - (Number(row.downloads || 0) / max) * height, - row - } - }) - .filter((point) => point.x >= padLeft - 50 && point.x <= padLeft + width + 50) + const buildPoints = (field: 'downloads' | 'uploads') => + visible + .map((row, index) => { + const sourceIndex = start + index + return { + x: padLeft + ((sourceIndex - offset.value) / denominator) * width, + y: padTop + height - (Number(row[field] || 0) / max) * height, + row + } + }) + .filter((point) => point.x >= padLeft - 50 && point.x <= padLeft + width + 50) - ctx.strokeStyle = '#4f46e5' - ctx.lineWidth = 3 - ctx.lineCap = 'round' - ctx.lineJoin = 'round' - ctx.beginPath() - points.forEach((point, index) => { - if (index) ctx.lineTo(point.x, point.y) - else ctx.moveTo(point.x, point.y) - }) - ctx.stroke() - ctx.fillStyle = '#4f46e5' - points.forEach((point) => { + const drawSeries = (points: ChartPoint[], color: string) => { + ctx.strokeStyle = color + ctx.lineWidth = 3 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' ctx.beginPath() - ctx.arc(point.x, point.y, mobile ? 4.5 : 3.5, 0, Math.PI * 2) + points.forEach((point, index) => { + if (index) ctx.lineTo(point.x, point.y) + else ctx.moveTo(point.x, point.y) + }) + ctx.stroke() + ctx.fillStyle = color + points.forEach((point) => { + ctx.beginPath() + ctx.arc(point.x, point.y, mobile ? 4.5 : 3.5, 0, Math.PI * 2) + ctx.fill() + }) + } + + const downloadPoints = buildPoints('downloads') + const uploadPoints = buildPoints('uploads') + drawSeries(downloadPoints, downloadColor) + drawSeries(uploadPoints, uploadColor) + + ctx.font = '12px system-ui' + ctx.fillStyle = muted + ctx.fillText('下载', padLeft, 16) + ctx.fillText('上传', padLeft + 58, 16) + ctx.fillStyle = downloadColor + ctx.fillRect(padLeft - 14, 8, 8, 8) + ctx.fillStyle = uploadColor + ctx.fillRect(padLeft + 44, 8, 8, 8) + + const points = downloadPoints + + const drawHoverPoint = (point: ChartPoint, color: string) => { + ctx.fillStyle = props.isDarkMode ? '#111827' : '#fff' + ctx.strokeStyle = color + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(point.x, point.y, mobile ? 8 : 6, 0, Math.PI * 2) ctx.fill() - }) + ctx.stroke() + } const leftRow = props.rows[Math.max(0, Math.round(offset.value))] const rightRow = props.rows[Math.min(total - 1, Math.round(offset.value + windowSize.value - 1))] @@ -191,20 +243,18 @@ const draw = () => { const nearest = points.reduce((current, point) => Math.abs(point.x - hoverX.value!) < Math.abs(current.x - hoverX.value!) ? point : current ) + const uploadPoint = uploadPoints.find((point) => point.row.date === nearest.row.date) tooltip.value = { left: Math.min(rect.width - 80, Math.max(80, nearest.x)), - top: Math.max(46, nearest.y), + top: Math.max(46, Math.min(nearest.y, uploadPoint?.y ?? nearest.y)), date: nearest.row.date, downloads: Number(nearest.row.downloads || 0), - uploads: Number(nearest.row.uploads || 0) + uploads: Number(nearest.row.uploads || 0), + downloadTraffic: formatBytes(nearest.row.downloadTraffic), + uploadTraffic: formatBytes(nearest.row.uploadTraffic) } - ctx.fillStyle = props.isDarkMode ? '#111827' : '#fff' - ctx.strokeStyle = '#4f46e5' - ctx.lineWidth = 3 - ctx.beginPath() - ctx.arc(nearest.x, nearest.y, mobile ? 8 : 6, 0, Math.PI * 2) - ctx.fill() - ctx.stroke() + drawHoverPoint(nearest, downloadColor) + if (uploadPoint) drawHoverPoint(uploadPoint, uploadColor) } else { tooltip.value = null } diff --git a/src/components/common/StatCard.vue b/src/components/common/StatCard.vue index c381c83..1887284 100644 --- a/src/components/common/StatCard.vue +++ b/src/components/common/StatCard.vue @@ -1,6 +1,6 @@