Add admin analytics trend views
This commit is contained in:
@@ -1,14 +1,34 @@
|
||||
<template>
|
||||
<div class="mt-4 flex items-center justify-between px-6 py-4 border-t"
|
||||
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']">
|
||||
<div class="flex items-center text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
||||
<div
|
||||
class="mt-4 flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
|
||||
>
|
||||
<div
|
||||
class="flex items-center text-sm"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
>
|
||||
显示第 {{ (currentPage - 1) * pageSize + 1 }} 到
|
||||
{{ Math.min(currentPage * pageSize, total) }} 条,共 {{ total }} 条
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="$emit('page-change', currentPage - 1)" :disabled="currentPage === 1"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200" :class="[
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
:value="pageSize"
|
||||
class="h-9 rounded-md border px-2 text-sm"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'border-gray-700 bg-gray-800 text-gray-200'
|
||||
: 'border-gray-200 bg-white text-gray-700'
|
||||
]"
|
||||
@change="$emit('page-size-change', Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}/页</option>
|
||||
</select>
|
||||
<button
|
||||
@click="$emit('page-change', currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? currentPage === 1
|
||||
? 'bg-gray-800 text-gray-600 cursor-not-allowed'
|
||||
@@ -16,21 +36,26 @@
|
||||
: currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
]">
|
||||
]"
|
||||
>
|
||||
<ChevronLeftIcon class="w-4 h-4" />
|
||||
上一页
|
||||
</button>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<template v-for="pageNum in displayedPages" :key="pageNum">
|
||||
<button v-if="pageNum !== '...'" @click="$emit('page-change', pageNum as number)"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200" :class="[
|
||||
<button
|
||||
v-if="pageNum !== '...'"
|
||||
@click="$emit('page-change', pageNum as number)"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200"
|
||||
:class="[
|
||||
currentPage === pageNum
|
||||
? 'bg-indigo-600 text-white'
|
||||
: isDarkMode
|
||||
? 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
]">
|
||||
]"
|
||||
>
|
||||
{{ pageNum }}
|
||||
</button>
|
||||
<span v-else class="px-2" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
||||
@@ -39,8 +64,11 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button @click="$emit('page-change', currentPage + 1)" :disabled="currentPage >= totalPages"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200" :class="[
|
||||
<button
|
||||
@click="$emit('page-change', currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? currentPage >= totalPages
|
||||
? 'bg-gray-800 text-gray-600 cursor-not-allowed'
|
||||
@@ -48,7 +76,8 @@
|
||||
: currentPage >= totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
]">
|
||||
]"
|
||||
>
|
||||
下一页
|
||||
<ChevronRightIcon class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -64,12 +93,16 @@ interface Props {
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
total: number
|
||||
pageSizeOptions?: number[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pageSizeOptions: () => [10, 20, 50, 100]
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'page-change': [page: number]
|
||||
'page-size-change': [size: number]
|
||||
}>()
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
@@ -112,4 +145,4 @@ const displayedPages = computed(() => {
|
||||
|
||||
return pages
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden rounded-lg 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"
|
||||
@wheel.prevent="handleWheel"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="handlePointerCancel"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@dblclick.prevent="resetWindow"
|
||||
/>
|
||||
<div
|
||||
v-if="tooltip"
|
||||
class="pointer-events-none absolute z-10 min-w-32 rounded-lg border px-3 py-2 text-xs shadow-lg"
|
||||
:class="tooltipClass"
|
||||
:style="{
|
||||
left: `${tooltip.left}px`,
|
||||
top: `${tooltip.top}px`,
|
||||
transform: 'translate(-50%, -112%)'
|
||||
}"
|
||||
>
|
||||
<p class="font-semibold">{{ tooltip.date }}</p>
|
||||
<p>下载:{{ tooltip.downloads }}</p>
|
||||
<p>上传:{{ tooltip.uploads }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type { AnalyticsDailyRow } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: AnalyticsDailyRow[]
|
||||
isDarkMode: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
windowChange: [text: string]
|
||||
}>()
|
||||
|
||||
type DragState = {
|
||||
lastX: number
|
||||
time: number
|
||||
}
|
||||
|
||||
type TooltipState = {
|
||||
left: number
|
||||
top: number
|
||||
date: string
|
||||
downloads: number
|
||||
uploads: number
|
||||
}
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const offset = ref(0)
|
||||
const windowSize = ref(30)
|
||||
const drag = ref<DragState | null>(null)
|
||||
const hoverX = ref<number | null>(null)
|
||||
const velocity = ref(0)
|
||||
const rafId = ref<number | null>(null)
|
||||
const tooltip = ref<TooltipState | null>(null)
|
||||
|
||||
const panelClass = computed(() =>
|
||||
props.isDarkMode ? 'border-gray-700 bg-gray-900/30' : 'border-gray-100 bg-gray-50'
|
||||
)
|
||||
const tooltipClass = computed(() =>
|
||||
props.isDarkMode
|
||||
? 'border-gray-700 bg-gray-800 text-gray-100'
|
||||
: 'border-gray-200 bg-white text-gray-900'
|
||||
)
|
||||
|
||||
const getDefaultWindow = () => {
|
||||
const width = canvasRef.value?.getBoundingClientRect().width || window.innerWidth
|
||||
return Math.max(2, Math.min(props.rows.length || 2, width < 640 ? 14 : 60))
|
||||
}
|
||||
|
||||
const clampState = () => {
|
||||
const total = props.rows.length
|
||||
windowSize.value = Math.max(
|
||||
2,
|
||||
Math.min(windowSize.value || getDefaultWindow(), Math.max(2, total || 2))
|
||||
)
|
||||
offset.value = Math.max(0, Math.min(offset.value, Math.max(0, total - windowSize.value)))
|
||||
}
|
||||
|
||||
const scheduleDraw = () => {
|
||||
if (rafId.value !== null) return
|
||||
rafId.value = window.requestAnimationFrame(() => {
|
||||
rafId.value = null
|
||||
draw()
|
||||
})
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
|
||||
clampState()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
|
||||
const total = props.rows.length
|
||||
const start = Math.max(0, Math.floor(offset.value) - 1)
|
||||
const end = Math.min(total, Math.ceil(offset.value + windowSize.value) + 2)
|
||||
const visible = props.rows.slice(start, end)
|
||||
const mobile = rect.width < 640
|
||||
const padLeft = mobile ? 34 : 42
|
||||
const padRight = mobile ? 16 : 20
|
||||
const padTop = 26
|
||||
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 muted = props.isDarkMode ? '#9ca3af' : '#6b7280'
|
||||
const border = props.isDarkMode ? '#374151' : '#e5e7eb'
|
||||
|
||||
ctx.strokeStyle = border
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = muted
|
||||
ctx.font = '12px system-ui'
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const y = padTop + (height * i) / 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padLeft, y)
|
||||
ctx.lineTo(padLeft + width, y)
|
||||
ctx.stroke()
|
||||
ctx.fillText(String(Math.round(max * (1 - i / 3))), 4, y + 4)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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) => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(point.x, point.y, mobile ? 4.5 : 3.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
|
||||
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))]
|
||||
if (total) {
|
||||
ctx.fillStyle = muted
|
||||
ctx.fillText(leftRow?.date || '', padLeft, rect.height - 10)
|
||||
const last = rightRow?.date || ''
|
||||
ctx.fillText(
|
||||
last,
|
||||
Math.max(padLeft, rect.width - padRight - ctx.measureText(last).width),
|
||||
rect.height - 10
|
||||
)
|
||||
}
|
||||
|
||||
emit(
|
||||
'windowChange',
|
||||
total
|
||||
? `${Math.floor(offset.value) + 1}-${Math.min(total, Math.ceil(offset.value + windowSize.value))} / ${total}`
|
||||
: '无数据'
|
||||
)
|
||||
|
||||
if (hoverX.value !== null && points.length) {
|
||||
const nearest = points.reduce((current, point) =>
|
||||
Math.abs(point.x - hoverX.value!) < Math.abs(current.x - hoverX.value!) ? point : current
|
||||
)
|
||||
tooltip.value = {
|
||||
left: Math.min(rect.width - 80, Math.max(80, nearest.x)),
|
||||
top: Math.max(46, nearest.y),
|
||||
date: nearest.row.date,
|
||||
downloads: Number(nearest.row.downloads || 0),
|
||||
uploads: Number(nearest.row.uploads || 0)
|
||||
}
|
||||
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()
|
||||
} else {
|
||||
tooltip.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const pan = (deltaX: number) => {
|
||||
const rect = canvasRef.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
offset.value += (-deltaX / Math.max(1, rect.width)) * Math.max(1, windowSize.value - 1) * 1.15
|
||||
clampState()
|
||||
scheduleDraw()
|
||||
}
|
||||
|
||||
const zoom = (factor: number, center = 0.5) => {
|
||||
clampState()
|
||||
const minWindow = Math.min(props.rows.length || 2, window.innerWidth < 640 ? 5 : 7)
|
||||
const next = Math.max(minWindow, Math.min(props.rows.length || 2, windowSize.value * factor))
|
||||
const anchor = offset.value + windowSize.value * center
|
||||
windowSize.value = next
|
||||
offset.value = anchor - next * center
|
||||
clampState()
|
||||
scheduleDraw()
|
||||
}
|
||||
|
||||
const applyInertia = () => {
|
||||
if (Math.abs(velocity.value) < 0.02) return
|
||||
pan(velocity.value)
|
||||
velocity.value *= 0.92
|
||||
window.requestAnimationFrame(applyInertia)
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
const rect = canvasRef.value?.getBoundingClientRect()
|
||||
zoom(event.deltaY > 0 ? 1.16 : 0.86, rect ? (event.clientX - rect.left) / rect.width : 0.5)
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
canvasRef.value?.setPointerCapture(event.pointerId)
|
||||
velocity.value = 0
|
||||
drag.value = { lastX: event.clientX, time: performance.now() }
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const rect = canvasRef.value?.getBoundingClientRect()
|
||||
if (rect) hoverX.value = event.clientX - rect.left
|
||||
if (!drag.value) {
|
||||
scheduleDraw()
|
||||
return
|
||||
}
|
||||
const now = performance.now()
|
||||
const deltaX = event.clientX - drag.value.lastX
|
||||
const deltaTime = Math.max(16, now - drag.value.time)
|
||||
velocity.value = deltaX * (16 / deltaTime)
|
||||
pan(deltaX)
|
||||
drag.value = { lastX: event.clientX, time: now }
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
drag.value = null
|
||||
window.requestAnimationFrame(applyInertia)
|
||||
}
|
||||
|
||||
const handlePointerCancel = () => {
|
||||
drag.value = null
|
||||
}
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
drag.value = null
|
||||
hoverX.value = null
|
||||
scheduleDraw()
|
||||
}
|
||||
|
||||
const resetWindow = () => {
|
||||
windowSize.value = getDefaultWindow()
|
||||
offset.value = Math.max(0, props.rows.length - windowSize.value)
|
||||
scheduleDraw()
|
||||
}
|
||||
|
||||
const handleResize = () => scheduleDraw()
|
||||
|
||||
watch(
|
||||
() => props.rows,
|
||||
async () => {
|
||||
await nextTick()
|
||||
resetWindow()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
resetWindow()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (rafId.value !== null) window.cancelAnimationFrame(rafId.value)
|
||||
})
|
||||
|
||||
defineExpose({ zoom, resetWindow })
|
||||
</script>
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<div class="p-6 rounded-lg shadow-md transition-colors duration-300"
|
||||
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-70' : 'bg-white']">
|
||||
<div
|
||||
class="group rounded-lg 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'
|
||||
: 'bg-white hover:shadow-gray-200/80'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
|
||||
@@ -10,7 +16,10 @@
|
||||
{{ value }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-3 rounded-full" :class="iconBgClass">
|
||||
<div
|
||||
class="rounded-full p-3 transition-transform duration-300 group-hover:scale-105"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<component :is="icon" class="w-6 h-6" :class="iconClass" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user