diff --git a/src/components/common/SmoothTrendCanvas.vue b/src/components/common/SmoothTrendCanvas.vue index 96fdbf6..7433ade 100644 --- a/src/components/common/SmoothTrendCanvas.vue +++ b/src/components/common/SmoothTrendCanvas.vue @@ -70,16 +70,20 @@ const offset = ref(0) const windowSize = ref(30) const drag = ref(null) const hoverX = ref(null) +const hoverVisual = ref<{ x: number; downloadY: number; uploadY: number } | null>(null) const velocity = ref(0) const rafId = ref(null) const inertiaRafId = ref(null) const lastInertiaTime = ref(0) +const lastHoverTime = ref(0) const tooltip = ref(null) const INERTIA_REFERENCE_FRAME_MS = 1000 / 120 const INERTIA_MAX_FRAME_MS = 1000 / 30 const INERTIA_DECAY_PER_120HZ_FRAME = 0.96 const INERTIA_STOP_VELOCITY = 0.01 +const HOVER_EASE_PER_120HZ_FRAME = 0.34 +const HOVER_SNAP_DISTANCE = 0.2 const panelClass = computed(() => props.isDarkMode @@ -118,13 +122,13 @@ const clampState = () => { const scheduleDraw = () => { if (rafId.value !== null) return - rafId.value = window.requestAnimationFrame(() => { + rafId.value = window.requestAnimationFrame((timestamp) => { rafId.value = null - draw() + draw(timestamp) }) } -const draw = () => { +const draw = (timestamp = performance.now()) => { const canvas = canvasRef.value if (!canvas) return const ctx = canvas.getContext('2d') @@ -266,19 +270,40 @@ const draw = () => { const uploadTraffic = Number(row.uploadTraffic || 0) const downloadY = padTop + height - (downloads / max) * height const uploadY = padTop + height - (uploads / max) * height - const anchorY = Math.min(downloadY, uploadY) + const elapsed = lastHoverTime.value + ? Math.min(timestamp - lastHoverTime.value, INERTIA_MAX_FRAME_MS) + : INERTIA_REFERENCE_FRAME_MS + lastHoverTime.value = timestamp + + if (!hoverVisual.value) { + hoverVisual.value = { x: snapX, downloadY, uploadY } + } else { + const ease = 1 - Math.pow(1 - HOVER_EASE_PER_120HZ_FRAME, elapsed / INERTIA_REFERENCE_FRAME_MS) + const nextX = hoverVisual.value.x + (snapX - hoverVisual.value.x) * ease + const nextDownloadY = hoverVisual.value.downloadY + (downloadY - hoverVisual.value.downloadY) * ease + const nextUploadY = hoverVisual.value.uploadY + (uploadY - hoverVisual.value.uploadY) * ease + hoverVisual.value = { + x: Math.abs(nextX - snapX) < HOVER_SNAP_DISTANCE ? snapX : nextX, + downloadY: + Math.abs(nextDownloadY - downloadY) < HOVER_SNAP_DISTANCE ? downloadY : nextDownloadY, + uploadY: Math.abs(nextUploadY - uploadY) < HOVER_SNAP_DISTANCE ? uploadY : nextUploadY + } + } + + const visual = hoverVisual.value + const anchorY = Math.min(visual.downloadY, visual.uploadY) const placeBelow = anchorY < 104 const tooltipInset = Math.min(mobile ? 116 : 132, Math.max(48, rect.width / 2 - 8)) ctx.strokeStyle = props.isDarkMode ? 'rgba(148, 163, 184, 0.35)' : 'rgba(91, 121, 132, 0.28)' ctx.lineWidth = 1 ctx.beginPath() - ctx.moveTo(snapX, padTop) - ctx.lineTo(snapX, padTop + height) + ctx.moveTo(visual.x, padTop) + ctx.lineTo(visual.x, padTop + height) ctx.stroke() tooltip.value = { - left: clamp(snapX, tooltipInset, rect.width - tooltipInset), + left: clamp(visual.x, tooltipInset, rect.width - tooltipInset), top: placeBelow ? anchorY + 16 : anchorY - 12, date: row.date, downloads, @@ -287,9 +312,19 @@ const draw = () => { uploadTraffic: formatBytes(uploadTraffic), transform: placeBelow ? 'translate(-50%, 0)' : 'translate(-50%, -100%)' } - drawHoverPoint({ x: snapX, y: downloadY }, downloadColor) - drawHoverPoint({ x: snapX, y: uploadY }, uploadColor) + drawHoverPoint({ x: visual.x, y: visual.downloadY }, downloadColor) + drawHoverPoint({ x: visual.x, y: visual.uploadY }, uploadColor) + + if ( + Math.abs(visual.x - snapX) >= HOVER_SNAP_DISTANCE || + Math.abs(visual.downloadY - downloadY) >= HOVER_SNAP_DISTANCE || + Math.abs(visual.uploadY - uploadY) >= HOVER_SNAP_DISTANCE + ) { + scheduleDraw() + } } else { + hoverVisual.value = null + lastHoverTime.value = 0 tooltip.value = null } } @@ -333,7 +368,7 @@ const applyInertia = (timestamp: number) => { lastInertiaTime.value = timestamp pan(velocity.value * elapsed, false) - draw() + draw(timestamp) velocity.value *= Math.pow( INERTIA_DECAY_PER_120HZ_FRAME, elapsed / INERTIA_REFERENCE_FRAME_MS @@ -385,6 +420,8 @@ const handlePointerCancel = () => { const handlePointerLeave = () => { drag.value = null hoverX.value = null + hoverVisual.value = null + lastHoverTime.value = 0 scheduleDraw() } diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts index 164be95..5552a9a 100644 --- a/src/types/dashboard.ts +++ b/src/types/dashboard.ts @@ -114,6 +114,7 @@ export interface AnalyticsFileRow { upload_count?: number upload_traffic?: number | string current: boolean + expired?: boolean deleted?: boolean created_at?: string uploaded_at?: string diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index 7b5a745..9a09a5b 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -9,19 +9,6 @@ {{ t('fileManage.subtitle', { count: summary.totalFiles }) }}

-
- - - {{ t('fileManage.resetFilters') }} - -
@@ -59,12 +46,25 @@ {{ hasActiveFilters ? '已应用筛选条件' : '默认收起,需要时展开' }}

- - - {{ filterBodyVisible ? '收起筛选' : '展开筛选' }} - +
+ + + {{ t('fileManage.resetFilters') }} + + + + {{ filterBodyVisible ? '收起筛选' : '展开筛选' }} + +
@@ -703,7 +703,7 @@ {{ formatHistoryBytes(file.download_traffic) }} - {{ file.deleted ? '已删除' : file.current ? '现存' : '历史' }} + {{ getHistoryStatusLabel(file) }} @@ -1613,6 +1613,12 @@ const handleHistoryPageSizeChange = (size: number) => { const formatHistoryBytes = (value: number | string | undefined) => formatFileSize(Number(value || 0), 1) const formatHistoryDate = (value?: string) => (value ? formatTimestamp(value) : '-') +const getHistoryStatusLabel = (file: AnalyticsFileRow) => { + if (file.deleted) return '已删除' + if (file.expired) return '已过期' + if (file.current) return '现存' + return '历史' +} const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900')) const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))