Reorder dashboard cards and expand trend metrics

This commit is contained in:
2026-06-05 11:38:48 +08:00
parent b6f8d6df18
commit 896395b84a
3 changed files with 262 additions and 199 deletions
+87 -37
View File
@@ -1,5 +1,5 @@
<template>
<div class="relative overflow-hidden rounded-lg border" :class="panelClass">
<div class="relative overflow-hidden rounded-[30px] border" :class="panelClass">
<canvas
ref="canvasRef"
class="block h-80 min-h-80 w-full touch-none select-none md:h-72 md:min-h-72"
@@ -22,8 +22,10 @@
}"
>
<p class="font-semibold">{{ tooltip.date }}</p>
<p>下载{{ tooltip.downloads }}</p>
<p>上传{{ tooltip.uploads }}</p>
<p>下载次数{{ tooltip.downloads }}</p>
<p>上传次数{{ tooltip.uploads }}</p>
<p>下载流量{{ tooltip.downloadTraffic }}</p>
<p>上传流量{{ tooltip.uploadTraffic }}</p>
</div>
</div>
</template>
@@ -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<HTMLCanvasElement | null>(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
}
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div
class="group rounded-lg p-6 shadow-md transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
class="group rounded-[30px] p-6 shadow-md transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
:class="[
isDarkMode
? 'bg-gray-800/70 hover:bg-gray-800 hover:shadow-black/20'
+174 -161
View File
@@ -84,164 +84,9 @@
</StatCard>
</div>
<section
class="mt-6 rounded-lg p-5 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
:class="[panelClass, cardHoverClass]"
>
<div class="mb-5 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">下载趋势</h3>
<p class="text-sm" :class="[mutedTextClass]">
{{ analyticsWindowText }}下载流量 {{ analyticsDownloadTrafficText }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="analyticsStart"
type="date"
min="1900-01-01"
max="2100-12-31"
class="h-11 rounded-lg border px-3 text-sm"
:class="[fieldClass]"
@change="fetchAnalyticsData"
/>
<input
v-model="analyticsEnd"
type="date"
min="1900-01-01"
max="2100-12-31"
class="h-11 rounded-lg border px-3 text-sm"
:class="[fieldClass]"
@change="fetchAnalyticsData"
/>
<button
type="button"
class="h-11 rounded-lg border px-3 text-sm font-medium transition-colors"
:class="detailActionClass"
@click="trendCanvas?.zoom(0.75)"
>
放大
</button>
<button
type="button"
class="h-11 rounded-lg border px-3 text-sm font-medium transition-colors"
:class="detailActionClass"
@click="trendCanvas?.zoom(1.33)"
>
缩小
</button>
<button
type="button"
class="h-11 rounded-lg border px-3 text-sm font-medium transition-colors"
:class="detailActionClass"
@click="trendCanvas?.resetWindow()"
>
重置
</button>
</div>
</div>
<div
v-if="analyticsError"
class="mb-4 rounded-lg border px-4 py-3 text-sm"
:class="[
isDarkMode
? 'border-red-900/50 bg-red-950/30 text-red-200'
: 'border-red-200 bg-red-50 text-red-700'
]"
>
{{ analyticsError }}
</div>
<SmoothTrendCanvas
ref="trendCanvas"
:rows="analyticsDailyRows"
:is-dark-mode="isDarkMode"
@window-change="analyticsWindowText = $event"
/>
</section>
<section
class="mt-6 rounded-lg p-5 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
:class="[panelClass, cardHoverClass]"
>
<div class="mb-5 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">热门下载 Top 5</h3>
<p class="text-sm" :class="[mutedTextClass]">统计当前日期范围内的下载次数和流量</p>
</div>
<DownloadCloudIcon
class="h-5 w-5"
:class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']"
/>
</div>
<div class="overflow-x-auto">
<table
class="min-w-full divide-y"
:class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']"
>
<thead>
<tr>
<th
class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]"
>
文件
</th>
<th
class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]"
>
下载
</th>
<th
class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]"
>
流量
</th>
<th
class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]"
>
状态
</th>
</tr>
</thead>
<tbody class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-100']">
<tr v-if="analyticsTopFiles.length === 0">
<td colspan="4" class="px-4 py-8 text-center text-sm" :class="[mutedTextClass]">
暂无下载记录
</td>
</tr>
<tr
v-for="file in analyticsTopFiles"
:key="file.code"
:class="[isDarkMode ? 'hover:bg-gray-700/60' : 'hover:bg-gray-50']"
>
<td
class="max-w-xs truncate px-4 py-3 text-sm font-medium"
:class="[primaryTextClass]"
:title="file.name"
>
{{ file.name }}
</td>
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
{{ file.download_count || 0 }}
</td>
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
{{ formatBytes(file.download_traffic) }}
</td>
<td class="px-4 py-3 text-sm" :class="[mutedTextClass]">
{{ file.current ? '现存' : '历史' }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3">
<section
class="rounded-lg p-5 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg xl:col-span-2"
class="rounded-[30px] p-5 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg xl:col-span-2"
:class="[panelClass, cardHoverClass]"
>
<div class="mb-5 flex items-center justify-between">
@@ -285,7 +130,7 @@
v-for="action in healthActions"
:key="action.key"
type="button"
class="group flex min-h-28 flex-col justify-between rounded-lg border p-4 text-left transition-colors"
class="group flex min-h-28 flex-col justify-between rounded-[30px] border p-4 text-left transition-colors"
:class="getHealthActionClass(action.tone)"
@click="openHealthQueue(action.health)"
>
@@ -307,7 +152,7 @@
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<div
class="rounded-lg border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
class="rounded-[30px] border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
:class="[subtlePanelClass, cardHoverClass]"
>
<p class="text-sm" :class="[mutedTextClass]">
@@ -324,7 +169,7 @@
</div>
<div
class="rounded-lg border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
class="rounded-[30px] border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
:class="[subtlePanelClass, cardHoverClass]"
>
<p class="text-sm" :class="[mutedTextClass]">
@@ -343,7 +188,7 @@
</section>
<section
class="rounded-lg p-5 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
class="rounded-[30px] p-5 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
:class="[panelClass, cardHoverClass]"
>
<div class="mb-5">
@@ -389,6 +234,163 @@
</section>
</div>
<div class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-2">
<section
class="rounded-[30px] p-5 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
:class="[panelClass, cardHoverClass]"
>
<div class="mb-5 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">下载/上传趋势</h3>
<p class="text-sm" :class="[mutedTextClass]">
{{ analyticsSummaryText }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="analyticsStart"
type="date"
min="1900-01-01"
max="2100-12-31"
class="h-11 rounded-lg border px-3 text-sm"
:class="[fieldClass]"
@change="fetchAnalyticsData"
/>
<input
v-model="analyticsEnd"
type="date"
min="1900-01-01"
max="2100-12-31"
class="h-11 rounded-lg border px-3 text-sm"
:class="[fieldClass]"
@change="fetchAnalyticsData"
/>
<button
type="button"
class="h-11 rounded-lg border px-3 text-sm font-medium transition-colors"
:class="detailActionClass"
@click="trendCanvas?.zoom(0.75)"
>
放大
</button>
<button
type="button"
class="h-11 rounded-lg border px-3 text-sm font-medium transition-colors"
:class="detailActionClass"
@click="trendCanvas?.zoom(1.33)"
>
缩小
</button>
<button
type="button"
class="h-11 rounded-lg border px-3 text-sm font-medium transition-colors"
:class="detailActionClass"
@click="trendCanvas?.resetWindow()"
>
重置
</button>
</div>
</div>
<div
v-if="analyticsError"
class="mb-4 rounded-lg border px-4 py-3 text-sm"
:class="[
isDarkMode
? 'border-red-900/50 bg-red-950/30 text-red-200'
: 'border-red-200 bg-red-50 text-red-700'
]"
>
{{ analyticsError }}
</div>
<SmoothTrendCanvas
ref="trendCanvas"
:rows="analyticsDailyRows"
:is-dark-mode="isDarkMode"
@window-change="analyticsWindowText = $event"
/>
</section>
<section
class="rounded-[30px] p-5 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
:class="[panelClass, cardHoverClass]"
>
<div class="mb-5 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">热门下载 Top 5</h3>
<p class="text-sm" :class="[mutedTextClass]">统计当前日期范围内的下载次数和流量</p>
</div>
<DownloadCloudIcon
class="h-5 w-5"
:class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']"
/>
</div>
<div class="overflow-x-auto">
<table
class="min-w-full divide-y"
:class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']"
>
<thead>
<tr>
<th
class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]"
>
文件
</th>
<th
class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]"
>
下载
</th>
<th
class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]"
>
流量
</th>
<th
class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]"
>
状态
</th>
</tr>
</thead>
<tbody class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-100']">
<tr v-if="analyticsTopFiles.length === 0">
<td colspan="4" class="px-4 py-8 text-center text-sm" :class="[mutedTextClass]">
暂无下载记录
</td>
</tr>
<tr
v-for="file in analyticsTopFiles"
:key="file.code"
:class="[isDarkMode ? 'hover:bg-gray-700/60' : 'hover:bg-gray-50']"
>
<td
class="max-w-xs truncate px-4 py-3 text-sm font-medium"
:class="[primaryTextClass]"
:title="file.name"
>
{{ file.name }}
</td>
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
{{ file.download_count || 0 }}
</td>
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
{{ formatBytes(file.download_traffic) }}
</td>
<td class="px-4 py-3 text-sm" :class="[mutedTextClass]">
{{ file.current ? '现存' : '历史' }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<footer
class="mt-6 flex flex-col gap-2 border-t pt-4 text-xs sm:flex-row sm:items-center sm:justify-between"
:class="[isDarkMode ? 'border-gray-800 text-gray-500' : 'border-gray-200 text-gray-500']"
@@ -495,9 +497,20 @@ const analyticsError = ref('')
const analyticsWindowText = ref('无数据')
const analyticsDailyRows = computed(() => analyticsData.value?.daily || [])
const analyticsTopFiles = computed<AnalyticsFileRow[]>(() => analyticsData.value?.topFiles || [])
const analyticsUploadTrafficText = computed(() =>
formatBytes(analyticsData.value?.totals?.uploadTraffic || 0)
)
const analyticsDownloadTrafficText = computed(() =>
formatBytes(analyticsData.value?.totals?.downloadTraffic || 0)
)
const analyticsSummaryText = computed(
() =>
`${analyticsWindowText.value},下载 ${analyticsData.value?.totals?.totalDownloads || 0} 次 / ${
analyticsDownloadTrafficText.value
},上传 ${analyticsData.value?.totals?.totalUploads || 0} 次 / ${
analyticsUploadTrafficText.value
}`
)
const formatBytes = (value: number | string | undefined) => formatFileSize(Number(value || 0), 1)
const fetchAnalyticsData = async () => {
@@ -611,7 +624,7 @@ const MetricProgress = defineComponent({
'div',
{
class:
'rounded-lg border border-gray-200/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:border-indigo-200 hover:shadow-md hover:shadow-gray-200/80 dark:border-gray-700 dark:hover:border-indigo-500/40 dark:hover:bg-gray-800 dark:hover:shadow-black/20'
'rounded-[30px] border border-gray-200/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:border-indigo-200 hover:shadow-md hover:shadow-gray-200/80 dark:border-gray-700 dark:hover:border-indigo-500/40 dark:hover:bg-gray-800 dark:hover:shadow-black/20'
},
[
h('div', { class: 'mb-2 flex items-center justify-between text-sm' }, [