Add admin analytics trend views
This commit is contained in:
@@ -1,14 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mt-4 flex items-center justify-between px-6 py-4 border-t"
|
<div
|
||||||
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']">
|
class="mt-4 flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
<div class="flex items-center text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
: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 }} 到
|
显示第 {{ (currentPage - 1) * pageSize + 1 }} 到
|
||||||
{{ Math.min(currentPage * pageSize, total) }} 条,共 {{ total }} 条
|
{{ Math.min(currentPage * pageSize, total) }} 条,共 {{ total }} 条
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<button @click="$emit('page-change', currentPage - 1)" :disabled="currentPage === 1"
|
<select
|
||||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200" :class="[
|
: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
|
isDarkMode
|
||||||
? currentPage === 1
|
? currentPage === 1
|
||||||
? 'bg-gray-800 text-gray-600 cursor-not-allowed'
|
? 'bg-gray-800 text-gray-600 cursor-not-allowed'
|
||||||
@@ -16,21 +36,26 @@
|
|||||||
: currentPage === 1
|
: currentPage === 1
|
||||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
]">
|
]"
|
||||||
|
>
|
||||||
<ChevronLeftIcon class="w-4 h-4" />
|
<ChevronLeftIcon class="w-4 h-4" />
|
||||||
上一页
|
上一页
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="flex items-center space-x-1">
|
<div class="flex items-center space-x-1">
|
||||||
<template v-for="pageNum in displayedPages" :key="pageNum">
|
<template v-for="pageNum in displayedPages" :key="pageNum">
|
||||||
<button v-if="pageNum !== '...'" @click="$emit('page-change', pageNum as number)"
|
<button
|
||||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200" :class="[
|
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
|
currentPage === pageNum
|
||||||
? 'bg-indigo-600 text-white'
|
? 'bg-indigo-600 text-white'
|
||||||
: isDarkMode
|
: isDarkMode
|
||||||
? 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
? 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
]">
|
]"
|
||||||
|
>
|
||||||
{{ pageNum }}
|
{{ pageNum }}
|
||||||
</button>
|
</button>
|
||||||
<span v-else class="px-2" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
<span v-else class="px-2" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
||||||
@@ -39,8 +64,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="$emit('page-change', currentPage + 1)" :disabled="currentPage >= totalPages"
|
<button
|
||||||
class="inline-flex items-center px-3 py-1.5 rounded-md transition-colors duration-200" :class="[
|
@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
|
isDarkMode
|
||||||
? currentPage >= totalPages
|
? currentPage >= totalPages
|
||||||
? 'bg-gray-800 text-gray-600 cursor-not-allowed'
|
? 'bg-gray-800 text-gray-600 cursor-not-allowed'
|
||||||
@@ -48,7 +76,8 @@
|
|||||||
: currentPage >= totalPages
|
: currentPage >= totalPages
|
||||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
]">
|
]"
|
||||||
|
>
|
||||||
下一页
|
下一页
|
||||||
<ChevronRightIcon class="w-4 h-4" />
|
<ChevronRightIcon class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -64,12 +93,16 @@ interface Props {
|
|||||||
currentPage: number
|
currentPage: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
total: number
|
total: number
|
||||||
|
pageSizeOptions?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
pageSizeOptions: () => [10, 20, 50, 100]
|
||||||
|
})
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'page-change': [page: number]
|
'page-change': [page: number]
|
||||||
|
'page-size-change': [size: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isDarkMode = inject('isDarkMode')
|
const isDarkMode = inject('isDarkMode')
|
||||||
@@ -112,4 +145,4 @@ const displayedPages = computed(() => {
|
|||||||
|
|
||||||
return pages
|
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>
|
<template>
|
||||||
<div class="p-6 rounded-lg shadow-md transition-colors duration-300"
|
<div
|
||||||
:class="[isDarkMode ? 'bg-gray-800 bg-opacity-70' : 'bg-white']">
|
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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
|
<p class="text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
|
||||||
@@ -10,7 +16,10 @@
|
|||||||
{{ value }}
|
{{ value }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</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" />
|
<component :is="icon" class="w-6 h-6" :class="iconClass" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1164,6 +1164,14 @@ export function useAdminFiles() {
|
|||||||
await loadFiles()
|
await loadFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = async (size: number) => {
|
||||||
|
if (!Number.isFinite(size) || size < 1) return
|
||||||
|
params.value.size = Math.min(Math.trunc(size), 100)
|
||||||
|
params.value.page = 1
|
||||||
|
markViewPresetDirty()
|
||||||
|
await loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
const openEditModal = (file: FileListItem) => {
|
const openEditModal = (file: FileListItem) => {
|
||||||
editForm.value = {
|
editForm.value = {
|
||||||
id: file.id,
|
id: file.id,
|
||||||
@@ -1690,6 +1698,7 @@ export function useAdminFiles() {
|
|||||||
downloadFile,
|
downloadFile,
|
||||||
exportPreviewText,
|
exportPreviewText,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
|
handlePageSizeChange,
|
||||||
handleSearch,
|
handleSearch,
|
||||||
applyDetailPolicyAction,
|
applyDetailPolicyAction,
|
||||||
applySelectedPolicyAction,
|
applySelectedPolicyAction,
|
||||||
|
|||||||
+10
-1
@@ -1,8 +1,17 @@
|
|||||||
import api from './client'
|
import api from './client'
|
||||||
import type { ApiResponse, DashboardData } from '@/types'
|
import type { AnalyticsData, ApiResponse, DashboardData } from '@/types'
|
||||||
|
|
||||||
export class StatsService {
|
export class StatsService {
|
||||||
static async getDashboard(): Promise<ApiResponse<DashboardData>> {
|
static async getDashboard(): Promise<ApiResponse<DashboardData>> {
|
||||||
return api.get('/admin/dashboard')
|
return api.get('/admin/dashboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getAnalytics(start?: string, end?: string): Promise<ApiResponse<AnalyticsData>> {
|
||||||
|
return api.get('/admin/analytics', {
|
||||||
|
params: {
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,3 +96,46 @@ export interface DashboardHealthAction {
|
|||||||
health: AdminFileHealthFilter
|
health: AdminFileHealthFilter
|
||||||
tone: 'danger' | 'warning' | 'success' | 'neutral'
|
tone: 'danger' | 'warning' | 'success' | 'neutral'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsDailyRow {
|
||||||
|
date: string
|
||||||
|
downloads: number
|
||||||
|
uploads: number
|
||||||
|
downloadTraffic: number | string
|
||||||
|
uploadTraffic: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFileRow {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
size: number | string
|
||||||
|
download_count: number
|
||||||
|
download_traffic: number | string
|
||||||
|
upload_count?: number
|
||||||
|
upload_traffic?: number | string
|
||||||
|
current: boolean
|
||||||
|
deleted?: boolean
|
||||||
|
created_at?: string
|
||||||
|
uploaded_at?: string
|
||||||
|
expired_at?: string
|
||||||
|
deleted_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsData {
|
||||||
|
range: {
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
}
|
||||||
|
totals: {
|
||||||
|
totalDownloads: number
|
||||||
|
downloadedFiles: number
|
||||||
|
downloadTraffic: number | string
|
||||||
|
historicalFiles: number
|
||||||
|
currentFiles: number
|
||||||
|
totalUploads?: number
|
||||||
|
uploadTraffic?: number | string
|
||||||
|
}
|
||||||
|
daily: AnalyticsDailyRow[]
|
||||||
|
topFiles: AnalyticsFileRow[]
|
||||||
|
historyFiles: AnalyticsFileRow[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,8 +84,166 @@
|
|||||||
</StatCard>
|
</StatCard>
|
||||||
</div>
|
</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">
|
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||||
<section class="xl:col-span-2 rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
<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="[panelClass, cardHoverClass]"
|
||||||
|
>
|
||||||
<div class="mb-5 flex items-center justify-between">
|
<div class="mb-5 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||||
@@ -148,7 +306,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div class="rounded-lg border p-4" :class="[subtlePanelClass]">
|
<div
|
||||||
|
class="rounded-lg border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
:class="[subtlePanelClass, cardHoverClass]"
|
||||||
|
>
|
||||||
<p class="text-sm" :class="[mutedTextClass]">
|
<p class="text-sm" :class="[mutedTextClass]">
|
||||||
{{ t('admin.dashboard.expiredFiles') }}
|
{{ t('admin.dashboard.expiredFiles') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -162,7 +323,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border p-4" :class="[subtlePanelClass]">
|
<div
|
||||||
|
class="rounded-lg border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
:class="[subtlePanelClass, cardHoverClass]"
|
||||||
|
>
|
||||||
<p class="text-sm" :class="[mutedTextClass]">
|
<p class="text-sm" :class="[mutedTextClass]">
|
||||||
{{ t('admin.dashboard.chunkedFiles') }}
|
{{ t('admin.dashboard.chunkedFiles') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -178,7 +342,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
|
<section
|
||||||
|
class="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">
|
<div class="mb-5">
|
||||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||||
{{ t('admin.dashboard.storagePolicy') }}
|
{{ t('admin.dashboard.storagePolicy') }}
|
||||||
@@ -245,7 +412,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent, h, onMounted } from 'vue'
|
import { computed, defineComponent, h, onMounted, ref } from 'vue'
|
||||||
import type { Component, PropType } from 'vue'
|
import type { Component, PropType } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -262,11 +429,14 @@ import {
|
|||||||
UploadCloudIcon
|
UploadCloudIcon
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import StatCard from '@/components/common/StatCard.vue'
|
import StatCard from '@/components/common/StatCard.vue'
|
||||||
|
import SmoothTrendCanvas from '@/components/common/SmoothTrendCanvas.vue'
|
||||||
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
|
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ROUTES } from '@/constants'
|
import { ROUTES } from '@/constants'
|
||||||
import { useConfigStore } from '@/stores/configStore'
|
import { useConfigStore } from '@/stores/configStore'
|
||||||
import type { DashboardHealthAction } from '@/types'
|
import { StatsService } from '@/services'
|
||||||
|
import { formatFileSize } from '@/utils/common'
|
||||||
|
import type { AnalyticsData, AnalyticsFileRow, DashboardHealthAction } from '@/types'
|
||||||
|
|
||||||
const isDarkMode = useInjectedDarkMode()
|
const isDarkMode = useInjectedDarkMode()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -284,9 +454,24 @@ const versionText = computed(() => appVersion.value || t('admin.dashboard.versio
|
|||||||
const panelClass = computed(() =>
|
const panelClass = computed(() =>
|
||||||
isDarkMode.value ? 'bg-gray-800/80 border border-gray-700' : 'bg-white border border-gray-100'
|
isDarkMode.value ? 'bg-gray-800/80 border border-gray-700' : 'bg-white border border-gray-100'
|
||||||
)
|
)
|
||||||
|
const fieldClass = computed(() =>
|
||||||
|
isDarkMode.value
|
||||||
|
? 'border-gray-600 bg-gray-700 text-white'
|
||||||
|
: 'border-gray-300 bg-white text-gray-900'
|
||||||
|
)
|
||||||
|
const detailActionClass = computed(() =>
|
||||||
|
isDarkMode.value
|
||||||
|
? 'border-gray-700 bg-gray-700/50 text-gray-300 hover:border-gray-600 hover:bg-gray-700'
|
||||||
|
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
||||||
|
)
|
||||||
const subtlePanelClass = computed(() =>
|
const subtlePanelClass = computed(() =>
|
||||||
isDarkMode.value ? 'border-gray-700 bg-gray-900/30' : 'border-gray-100 bg-gray-50'
|
isDarkMode.value ? 'border-gray-700 bg-gray-900/30' : 'border-gray-100 bg-gray-50'
|
||||||
)
|
)
|
||||||
|
const cardHoverClass = computed(() =>
|
||||||
|
isDarkMode.value
|
||||||
|
? 'hover:border-indigo-500/40 hover:bg-gray-800 hover:shadow-black/20'
|
||||||
|
: 'hover:border-indigo-200 hover:shadow-gray-200/80'
|
||||||
|
)
|
||||||
const maxSaveTimeText = computed(() => {
|
const maxSaveTimeText = computed(() => {
|
||||||
if (!dashboardData.maxSaveSeconds) return t('admin.dashboard.noSaveLimit')
|
if (!dashboardData.maxSaveSeconds) return t('admin.dashboard.noSaveLimit')
|
||||||
const days = Math.floor(dashboardData.maxSaveSeconds / 86400)
|
const days = Math.floor(dashboardData.maxSaveSeconds / 86400)
|
||||||
@@ -296,6 +481,35 @@ const maxSaveTimeText = computed(() => {
|
|||||||
return `${Math.floor(dashboardData.maxSaveSeconds / 60)}${t('common.minute')}`
|
return `${Math.floor(dashboardData.maxSaveSeconds / 60)}${t('common.minute')}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const today = () => new Date().toISOString().slice(0, 10)
|
||||||
|
const daysAgo = (days: number) => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setDate(date.getDate() - days)
|
||||||
|
return date.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
const trendCanvas = ref<InstanceType<typeof SmoothTrendCanvas> | null>(null)
|
||||||
|
const analyticsStart = ref(daysAgo(120))
|
||||||
|
const analyticsEnd = ref(today())
|
||||||
|
const analyticsData = ref<AnalyticsData | null>(null)
|
||||||
|
const analyticsError = ref('')
|
||||||
|
const analyticsWindowText = ref('无数据')
|
||||||
|
const analyticsDailyRows = computed(() => analyticsData.value?.daily || [])
|
||||||
|
const analyticsTopFiles = computed<AnalyticsFileRow[]>(() => analyticsData.value?.topFiles || [])
|
||||||
|
const analyticsDownloadTrafficText = computed(() =>
|
||||||
|
formatBytes(analyticsData.value?.totals?.downloadTraffic || 0)
|
||||||
|
)
|
||||||
|
const formatBytes = (value: number | string | undefined) => formatFileSize(Number(value || 0), 1)
|
||||||
|
|
||||||
|
const fetchAnalyticsData = async () => {
|
||||||
|
analyticsError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await StatsService.getAnalytics(analyticsStart.value, analyticsEnd.value)
|
||||||
|
analyticsData.value = response.detail || null
|
||||||
|
} catch (error) {
|
||||||
|
analyticsError.value = error instanceof Error ? error.message : '统计加载失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const healthActions = computed<DashboardHealthAction[]>(() => [
|
const healthActions = computed<DashboardHealthAction[]>(() => [
|
||||||
{
|
{
|
||||||
key: 'attention',
|
key: 'attention',
|
||||||
@@ -394,19 +608,26 @@ const MetricProgress = defineComponent({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
h('div', { class: 'rounded-lg border p-4 border-gray-200/60 dark:border-gray-700' }, [
|
h(
|
||||||
h('div', { class: 'mb-2 flex items-center justify-between text-sm' }, [
|
'div',
|
||||||
h('span', { class: 'text-gray-500 dark:text-gray-400' }, props.label),
|
{
|
||||||
h('span', { class: 'font-medium text-gray-900 dark:text-white' }, `${props.value}%`)
|
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'
|
||||||
h('div', { class: 'h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700' }, [
|
},
|
||||||
h('div', {
|
[
|
||||||
class: ['h-full rounded-full', toneClass.value],
|
h('div', { class: 'mb-2 flex items-center justify-between text-sm' }, [
|
||||||
style: { width: `${props.value}%` }
|
h('span', { class: 'text-gray-500 dark:text-gray-400' }, props.label),
|
||||||
})
|
h('span', { class: 'font-medium text-gray-900 dark:text-white' }, `${props.value}%`)
|
||||||
]),
|
]),
|
||||||
h('p', { class: 'mt-2 text-sm text-gray-500 dark:text-gray-400' }, props.detail)
|
h('div', { class: 'h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700' }, [
|
||||||
])
|
h('div', {
|
||||||
|
class: ['h-full rounded-full', toneClass.value],
|
||||||
|
style: { width: `${props.value}%` }
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
h('p', { class: 'mt-2 text-sm text-gray-500 dark:text-gray-400' }, props.detail)
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -433,6 +654,6 @@ const PolicyRow = defineComponent({
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void fetchDashboardData()
|
void Promise.all([fetchDashboardData(), fetchAnalyticsData()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,8 +34,8 @@
|
|||||||
<div
|
<div
|
||||||
v-for="card in summaryCards"
|
v-for="card in summaryCards"
|
||||||
:key="card.label"
|
:key="card.label"
|
||||||
class="rounded-lg border p-4"
|
class="group rounded-lg border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||||
:class="[panelClass]"
|
:class="[panelClass, cardHoverClass]"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -44,7 +44,10 @@
|
|||||||
{{ card.value }}
|
{{ card.value }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg p-2" :class="[card.iconClass]">
|
<div
|
||||||
|
class="rounded-lg p-2 transition-transform duration-200 group-hover:scale-105"
|
||||||
|
:class="[card.iconClass]"
|
||||||
|
>
|
||||||
<component :is="card.icon" class="h-5 w-5" />
|
<component :is="card.icon" class="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +55,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="mb-6 rounded-lg border p-4" :class="[panelClass]">
|
<section class="mb-6 rounded-lg border p-4" :class="[panelClass]">
|
||||||
<div class="grid gap-4">
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold" :class="[primaryTextClass]">条件筛选</h3>
|
||||||
|
<p class="mt-1 text-xs" :class="[mutedTextClass]">
|
||||||
|
{{ hasActiveFilters ? '已应用筛选条件' : '默认收起,需要时展开' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BaseButton variant="secondary" @click="isFilterPanelOpen = !isFilterPanelOpen">
|
||||||
|
<template #icon>
|
||||||
|
<FilterIcon class="mr-2 h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ filterBodyVisible ? '收起筛选' : '展开筛选' }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="filterBodyVisible" class="mt-4 grid gap-4">
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center">
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
<label class="min-w-0 flex-1">
|
<label class="min-w-0 flex-1">
|
||||||
<span class="sr-only">{{ t('fileManage.searchPlaceholder') }}</span>
|
<span class="sr-only">{{ t('fileManage.searchPlaceholder') }}</span>
|
||||||
@@ -556,10 +574,146 @@
|
|||||||
:page-size="params.size"
|
:page-size="params.size"
|
||||||
:total="params.total"
|
:total="params.total"
|
||||||
@page-change="handlePageChange"
|
@page-change="handlePageChange"
|
||||||
|
@page-size-change="handlePageSizeChange"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
|
<section class="mt-6 rounded-lg border p-4" :class="[panelClass]">
|
||||||
|
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">历史文件</h3>
|
||||||
|
<p class="text-sm" :class="[mutedTextClass]">包含已删除、已过期和现存文件的下载统计</p>
|
||||||
|
</div>
|
||||||
|
<BaseButton variant="secondary" :loading="isHistoryLoading" @click="loadHistoryFiles">
|
||||||
|
<template #icon>
|
||||||
|
<RefreshCwIcon class="mr-2 h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
刷新历史
|
||||||
|
</BaseButton>
|
||||||
|
</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>
|
||||||
|
<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="isHistoryLoading">
|
||||||
|
<td colspan="8" class="px-4 py-8 text-center text-sm" :class="[mutedTextClass]">
|
||||||
|
<RefreshCwIcon class="mr-2 inline h-4 w-4 animate-spin" />
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="historyPageRows.length === 0">
|
||||||
|
<td colspan="8" class="px-4 py-8 text-center text-sm" :class="[mutedTextClass]">
|
||||||
|
暂无历史文件
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<template v-else>
|
||||||
|
<tr
|
||||||
|
v-for="file in historyPageRows"
|
||||||
|
:key="file.code"
|
||||||
|
:class="[
|
||||||
|
file.current ? '' : 'opacity-70',
|
||||||
|
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]">
|
||||||
|
{{ formatHistoryBytes(file.size) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm font-mono" :class="[primaryTextClass]">
|
||||||
|
{{ file.code }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm" :class="[mutedTextClass]">
|
||||||
|
{{ formatHistoryDate(file.uploaded_at || file.created_at) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm" :class="[mutedTextClass]">
|
||||||
|
{{ formatHistoryDate(file.expired_at) }}
|
||||||
|
</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]">
|
||||||
|
{{ formatHistoryBytes(file.download_traffic) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm" :class="[mutedTextClass]">
|
||||||
|
{{ file.deleted ? '已删除' : file.current ? '现存' : '历史' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<DataPagination
|
||||||
|
v-if="historyRows.length > 0"
|
||||||
|
:current-page="historyPage"
|
||||||
|
:page-size="historyPageSize"
|
||||||
|
:total="historyRows.length"
|
||||||
|
:page-size-options="[5, 10, 20, 50]"
|
||||||
|
@page-change="historyPage = $event"
|
||||||
|
@page-size-change="handleHistoryPageSizeChange"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<BaseModal :show="showFileDetailModal" size="xl" @close="closeFileDetail">
|
<BaseModal :show="showFileDetailModal" size="xl" @close="closeFileDetail">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex min-w-0 items-center space-x-3">
|
<div class="flex min-w-0 items-center space-x-3">
|
||||||
@@ -1181,10 +1335,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch, type Component } from 'vue'
|
import { computed, onMounted, ref, watch, type Component } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import type {
|
import type {
|
||||||
|
AnalyticsFileRow,
|
||||||
AdminBatchEditMode,
|
AdminBatchEditMode,
|
||||||
AdminFileHealthFilter,
|
AdminFileHealthFilter,
|
||||||
AdminFileInsightSeverity,
|
AdminFileInsightSeverity,
|
||||||
@@ -1222,7 +1377,8 @@ import FileEditField from '@/components/common/FileEditField.vue'
|
|||||||
import BaseModal from '@/components/common/BaseModal.vue'
|
import BaseModal from '@/components/common/BaseModal.vue'
|
||||||
import BaseButton from '@/components/common/BaseButton.vue'
|
import BaseButton from '@/components/common/BaseButton.vue'
|
||||||
import { useAdminFiles, useInjectedDarkMode } from '@/composables'
|
import { useAdminFiles, useInjectedDarkMode } from '@/composables'
|
||||||
import { formatTimestamp } from '@/utils/common'
|
import { StatsService } from '@/services'
|
||||||
|
import { formatFileSize, formatTimestamp } from '@/utils/common'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const isDarkMode = useInjectedDarkMode()
|
const isDarkMode = useInjectedDarkMode()
|
||||||
@@ -1298,6 +1454,7 @@ const {
|
|||||||
downloadFile,
|
downloadFile,
|
||||||
exportPreviewText,
|
exportPreviewText,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
|
handlePageSizeChange,
|
||||||
handleSearch,
|
handleSearch,
|
||||||
applyDetailPolicyAction,
|
applyDetailPolicyAction,
|
||||||
applySelectedPolicyAction,
|
applySelectedPolicyAction,
|
||||||
@@ -1321,6 +1478,34 @@ const {
|
|||||||
toggleFileSelection
|
toggleFileSelection
|
||||||
} = useAdminFiles()
|
} = useAdminFiles()
|
||||||
|
|
||||||
|
const isFilterPanelOpen = ref(false)
|
||||||
|
const filterBodyVisible = computed(() => isFilterPanelOpen.value || hasActiveFilters.value)
|
||||||
|
const isHistoryLoading = ref(false)
|
||||||
|
const historyRows = ref<AnalyticsFileRow[]>([])
|
||||||
|
const historyPage = ref(1)
|
||||||
|
const historyPageSize = ref(10)
|
||||||
|
const historyPageRows = computed(() => {
|
||||||
|
const start = (historyPage.value - 1) * historyPageSize.value
|
||||||
|
return historyRows.value.slice(start, start + historyPageSize.value)
|
||||||
|
})
|
||||||
|
const loadHistoryFiles = async () => {
|
||||||
|
isHistoryLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await StatsService.getAnalytics()
|
||||||
|
historyRows.value = response.detail?.historyFiles || []
|
||||||
|
historyPage.value = 1
|
||||||
|
} finally {
|
||||||
|
isHistoryLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleHistoryPageSizeChange = (size: number) => {
|
||||||
|
historyPageSize.value = size
|
||||||
|
historyPage.value = 1
|
||||||
|
}
|
||||||
|
const formatHistoryBytes = (value: number | string | undefined) =>
|
||||||
|
formatFileSize(Number(value || 0), 1)
|
||||||
|
const formatHistoryDate = (value?: string) => (value ? formatTimestamp(value) : '-')
|
||||||
|
|
||||||
const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
|
const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
|
||||||
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
|
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
|
||||||
const panelClass = computed(() =>
|
const panelClass = computed(() =>
|
||||||
@@ -1336,6 +1521,11 @@ const detailActionClass = computed(() =>
|
|||||||
? 'border-gray-700 bg-gray-700/50 text-gray-300 hover:border-gray-600 hover:bg-gray-700'
|
? 'border-gray-700 bg-gray-700/50 text-gray-300 hover:border-gray-600 hover:bg-gray-700'
|
||||||
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'
|
||||||
)
|
)
|
||||||
|
const cardHoverClass = computed(() =>
|
||||||
|
isDarkMode.value
|
||||||
|
? 'hover:border-indigo-500/40 hover:bg-gray-800 hover:shadow-black/20'
|
||||||
|
: 'hover:border-indigo-200 hover:shadow-gray-200/80'
|
||||||
|
)
|
||||||
const detailPolicyActionClass = computed(() =>
|
const detailPolicyActionClass = computed(() =>
|
||||||
isDarkMode.value
|
isDarkMode.value
|
||||||
? 'border-gray-700 bg-gray-800/70 text-gray-200 hover:border-blue-500/40 hover:bg-blue-500/10'
|
? 'border-gray-700 bg-gray-800/70 text-gray-200 hover:border-blue-500/40 hover:bg-blue-500/10'
|
||||||
@@ -1745,5 +1935,6 @@ onMounted(() => {
|
|||||||
params.value.health = getRouteHealthFilter()
|
params.value.health = getRouteHealthFilter()
|
||||||
void loadViewPresets()
|
void loadViewPresets()
|
||||||
void loadFiles()
|
void loadFiles()
|
||||||
|
void loadHistoryFiles()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user