feat(front): add AboutBaseInfo and AboutChartView components for enhanced About page functionality and data visualization

This commit is contained in:
keven
2025-10-19 15:05:29 +08:00
parent c931d10485
commit 6238897c18
5 changed files with 257 additions and 193 deletions

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { Skeleton } from '@/components/ui/skeleton'
import getFileSize from '~/lib/getFileSize'
import SparkMD5 from 'spark-md5'
import useMyAppConfig from '@/composables/useMyAppConfig'
import Progress from '~/components/ui/progress/Progress.vue'
import renderI18n from '~/lib/renderI18n'
import { I18nT } from 'vue-i18n'
const appConfig = useMyAppConfig()
const { data, isLoading } = useQuery({
queryKey: ['about'],
queryFn: async () => {
const data = await $fetch<{
data: {
file: {
maximun: number
current: number
}
bg_url?: string
avatar?: string
name?: string
email?: string
url?: string
}
}>('/api/about')
return data?.data
},
})
const { t } = useI18n()
const genUserAvatar = (email: string) => {
return `https://www.gravatar.com/avatar/${SparkMD5.hash(email)}?d=retro`
}
</script>
<template>
<template v-if="isLoading">
<div class="flex flex-col gap-2">
<Skeleton class="aspect-[3/1] w-full rounded-xl" />
<div class="flex flex-col gap-2 items-center">
<Skeleton class="h-6 w-32 rounded" />
<Skeleton class="h-4 w-52 rounded" />
</div>
</div>
</template>
<template v-else>
<NuxtImg v-if="data?.bg_url" :src="data?.bg_url" class="aspect-[3/1] w-full rounded-xl" fit="cover" />
<div class="flex flex-col gap-2 items-center">
<div class="text-xl">{{ renderI18n(appConfig?.site_title ?? {}, 'en') }}</div>
<div class="text-sm opacity-75 text-center px-5">
<I18nT keypath="about.powerBy" tag="span">
<NuxtLink href="https://github.com/keven1024/015" target="_blank" class="text-primary hover:underline">015</NuxtLink>
</I18nT>
</div>
</div>
</template>
<div class="font-semibold">{{ t('about.systemInfo') }}</div>
<template v-if="isLoading">
<div class="flex flex-row gap-2">
<Skeleton class="w-full h-20 rounded-xl" v-for="i in 2" :key="i" />
</div>
</template>
<template v-else>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-xl bg-white/50 flex-1 flex flex-col p-3 gap-2">
<div class="opacity-75 text-xs">{{ t('about.admin') }}</div>
<div
class="flex flex-row gap-2 items-center cursor-pointer"
@click="
() => {
if (data?.url) {
navigateTo(data?.url, { external: true })
return
}
if (data?.email) {
navigateTo(`mailto:${data?.email ?? ''}`, { external: true })
return
}
return
}
"
>
<Avatar class="size-10">
<AvatarImage v-if="!!data?.avatar || !!data?.email" :src="data?.avatar || genUserAvatar(data?.email as string)" />
<AvatarFallback class="bg-black/10 font-bold">
{{ data?.name?.charAt(0)?.toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="flex flex-col">
<div class="text-md font-semibold">{{ data?.name }}</div>
<div class="text-xs opacity-75">{{ data?.email }}</div>
</div>
</div>
</div>
<div class="rounded-xl bg-white/50 flex-1 flex flex-col p-3 gap-2">
<div class="opacity-75 text-xs">{{ t('about.storage') }}</div>
<div class="text-right flex flex-row items-baseline">
<span class="text-lg font-semibold">{{ getFileSize(data?.file?.current ?? 0) }}</span>
<span class="text-md opacity-75">/ {{ getFileSize(data?.file?.maximun ?? 0) }}</span>
</div>
<Progress class="h-1" :model-value="((data?.file?.current ?? 0) / (data?.file?.maximun ?? 0)) * 100" />
</div>
</div>
</template>
</template>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { CurveType } from '@unovis/ts'
import { AreaChart } from '@/components/ui/chart-area'
import { cx } from 'class-variance-authority'
import { useQuery } from '@tanstack/vue-query'
import { Skeleton } from '@/components/ui/skeleton'
import AboutChartTooltip from '@/components/AboutChartTooltip.vue'
import dayjs from 'dayjs'
import { times } from 'lodash-es'
interface FileChartData {
file_size: number
file_num: number
date: string
}
interface QueueChartData {
processed: number
failed: number
date: string
}
type ChartDataItem = FileChartData | QueueChartData
type ChartConfig = {
data: ChartDataItem[]
index: string
categories: string[]
colors: string[]
}
const { data, isLoading } = useQuery({
queryKey: ['stat'],
queryFn: async () => {
const response = await $fetch<{
data: {
chart: {
storage: Record<string, FileChartData>
queue: Record<string, QueueChartData>
}
}
}>('/api/stat')
return response.data
},
})
const { t } = useI18n()
const chartTabs = computed(() => {
return [
{
label: t('about.file'),
value: 'storage',
total: data.value?.chart?.storage
? Object.values(data.value.chart.storage).reduce((acc: number, curr: FileChartData) => acc + curr.file_num, 0)
: 0,
},
{
label: t('about.task'),
value: 'queue',
total: data.value?.chart?.queue
? Object.values(data.value.chart.queue).reduce((acc: number, curr: QueueChartData) => acc + curr.processed + curr.failed, 0)
: 0,
},
]
})
const currentChartTab = ref<'storage' | 'queue'>('storage')
const currentChartData = computed((): ChartConfig => {
const { storage, queue } = data.value?.chart || {}
if (currentChartTab.value === 'storage') {
const storageData = times(30, (i) => {
return {
date: dayjs().subtract(i, 'day').format('YYYY-MM-DD'),
file_size: storage?.[dayjs().subtract(i, 'day').format('YYYY-MM-DD')]?.file_size || 0,
file_num: storage?.[dayjs().subtract(i, 'day').format('YYYY-MM-DD')]?.file_num || 0,
}
})
return {
data: storageData,
index: 'date' as const,
categories: ['file_size', 'file_num'] as const,
colors: ['#22d3ee', '#c084fc'],
}
}
const queueData = times(30, (i) => {
return {
date: dayjs().subtract(i, 'day').format('YYYY-MM-DD'),
processed: queue?.[dayjs().subtract(i, 'day').format('YYYY-MM-DD')]?.processed || 0,
failed: queue?.[dayjs().subtract(i, 'day').format('YYYY-MM-DD')]?.failed || 0,
}
})
return {
data: queueData,
index: 'date' as const,
categories: ['processed', 'failed'] as const,
colors: ['#4ade80', '#f87171'],
}
})
</script>
<template>
<div class="font-semibold">{{ t('about.analysis') }}</div>
<template v-if="isLoading">
<div class="flex flex-row gap-2">
<Skeleton class="w-full h-96 rounded-xl" />
</div>
</template>
<template v-else>
<div class="flex flex-col gap-2 bg-white/50 w-full rounded-xl py-5">
<div class="flex flex-row gap-2 px-5">
<div
:class="cx('rounded-md min-w-30 flex flex-col px-3 py-1.5 cursor-pointer', currentChartTab === tab.value && 'bg-black/10')"
v-for="tab in chartTabs"
:key="tab.value"
@click="
() => {
currentChartTab = tab.value as 'storage' | 'queue'
}
"
>
<div class="opacity-75 text-xs">{{ tab.label }}</div>
<div class="text-lg font-semibold">{{ tab.total }}</div>
</div>
</div>
<AreaChart
v-if="currentChartData"
class="h-64 w-full"
:key="currentChartTab"
:index="currentChartData.index"
:data="currentChartData.data"
:categories="currentChartData.categories"
:show-grid-line="false"
:show-legend="false"
:show-y-axis="true"
:show-x-axis="true"
:colors="currentChartData.colors"
:custom-tooltip="AboutChartTooltip"
:curve-type="CurveType.CatmullRom"
/>
</div>
</template>
</template>

View File

@@ -108,7 +108,7 @@
"powerBy": "Power by {0} as a open source temporary file sharing platform",
"file": "File",
"task": "Task",
"admin": "Admin",
"admin": "Site Admin",
"author": "Author",
"title": "About",
"systemInfo": "System Info",

View File

@@ -108,12 +108,12 @@
"powerBy": "由 {0} 驱动的开源自托管临时文件分享平台",
"file": "文件",
"task": "任务",
"admin": "站长",
"admin": "本站管理员",
"author": "作者",
"title": "关于",
"systemInfo": "系统信息",
"systemVersion": "系统版本",
"storage": "存储空间",
"storage": "已托管的文件",
"analysis": "分析",
"fileSize": "文件大小",
"fileNum": "文件数量",

View File

@@ -11,203 +11,16 @@ import useMyAppConfig from '@/composables/useMyAppConfig'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import Progress from '~/components/ui/progress/Progress.vue'
import AboutBaseInfo from '@/components/About/AboutBaseInfo.vue'
dayjs.extend(relativeTime)
const appConfig = useMyAppConfig()
const { site_title, site_desc } = appConfig.value || {}
const { data, isLoading } = useQuery({
queryKey: ['stat'],
queryFn: async () => {
const data = await $fetch<{ data: any }>('/api/stat')
return data.data
},
})
const { t } = useI18n()
const chartTabs = computed(() => {
return [
{
label: t('about.file'),
value: 'storage',
total: data.value?.chart?.storage?.reduce((acc: number, curr: { file_size: number; file_num: number }) => acc + curr.file_num, 0) ?? 0,
},
{
label: t('about.task'),
value: 'queue',
total:
data.value?.chart?.queue?.reduce(
(acc: number, curr: { processed: number; failed: number }) => acc + curr.processed + curr.failed,
0
) ?? 0,
},
]
})
const currentFileSize = computed(() => {
return data.value?.chart?.storage?.reduce((acc: number, curr: { file_size: number; file_num: number }) => acc + curr.file_size, 0) ?? 0
})
const currentChartTab = ref<'storage' | 'queue'>('storage')
const currentChartData = computed(() => {
const { storage, queue } = data.value?.chart || {}
if (currentChartTab.value === 'storage') {
return {
data: storage,
index: 'date',
categories: ['file_size', 'file_num'],
colors: ['#22d3ee', '#c084fc'],
}
}
return {
data: queue,
index: 'date',
categories: ['processed', 'failed'],
colors: ['#4ade80', '#f87171'],
}
})
const genUserAvatar = ({ email }: { email: string }) => {
if (!email) {
return '/logo.png'
}
return `https://www.gravatar.com/avatar/${SparkMD5.hash(email)}?d=retro`
}
const handleUserClick = ({ url, email }: { url: string; email: string }) => {
if (url) {
return navigateTo(url, { external: true })
}
if (email) {
return navigateTo(`mailto:${email}`, { external: true })
}
return null
}
const users = computed(() => {
const { email, name, url } = data.value?.admin || {}
return [
...(!!name
? [
{
title: t('about.admin'),
email,
name,
url: url ?? (email ? `mailto:${email}` : null),
},
]
: []),
{
title: t('about.author'),
name: 'keven1024',
email: 'keven@fudaoyuan.icu',
url: 'https://github.com/keven1024',
},
]
})
</script>
<template>
<div class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200 my-5 flex flex-col gap-5">
<div class="text-xl font-normal">{{ t('about.title') }}</div>
<div class="flex flex-col gap-2 items-center">
<NuxtImg src="/logo.png" class="size-20 rounded-xl" />
<div class="text-xl">{{ site_title ?? '015' }}</div>
<div class="text-sm opacity-75 text-center px-5">
{{ site_desc ?? t('seo.desc') }}
</div>
</div>
<div class="font-semibold">{{ t('about.systemInfo') }}</div>
<template v-if="isLoading">
<div class="flex flex-row gap-2">
<Skeleton class="w-full h-20 rounded-xl" v-for="i in 2" :key="i" />
</div>
</template>
<template v-else>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-xl bg-white/50 flex-1 flex flex-col p-3">
<div class="opacity-75 text-xs">{{ t('about.systemVersion') }}</div>
<div class="text-xl font-semibold flex items-center gap-1">
{{ data?.version ?? 'dev' }}
<span class="text-xs px-2 py-0.5 rounded bg-primary/20 text-primary">{{ dayjs(data?.build_time * 1000).fromNow() }}</span>
</div>
</div>
<div class="rounded-xl bg-white/50 flex-1 flex flex-col p-3">
<div class="opacity-75 text-xs">{{ t('about.storage') }}</div>
<div class="text-right flex flex-row items-baseline">
<span class="text-lg font-semibold">{{ getFileSize(currentFileSize ?? 0) }}</span>
<span class="text-md opacity-75">/ {{ getFileSize(data?.max_limit?.file_size ?? 0) }}</span>
</div>
<Progress class="h-1" :model-value="(currentFileSize / (data?.max_limit?.file_size ?? 0)) * 100" />
</div>
</div>
</template>
<div class="font-semibold">{{ t('about.analysis') }}</div>
<template v-if="isLoading">
<div class="flex flex-row gap-2">
<Skeleton class="w-full h-96 rounded-xl" />
</div>
</template>
<template v-else>
<div class="flex flex-col gap-2 bg-white/50 w-full rounded-xl py-5">
<div class="flex flex-row gap-2 px-5">
<div
:class="cx('rounded-md min-w-30 flex flex-col px-3 py-1.5 cursor-pointer', currentChartTab === tab.value && 'bg-black/10')"
v-for="tab in chartTabs"
:key="tab.value"
@click="
() => {
currentChartTab = tab.value as 'storage' | 'queue'
}
"
>
<div class="opacity-75 text-xs">{{ tab.label }}</div>
<div class="text-lg font-semibold">{{ tab.total }}</div>
</div>
</div>
<AreaChart
v-if="currentChartData"
class="h-64 w-full"
:key="currentChartTab"
:index="currentChartData.index"
:data="currentChartData.data"
:categories="currentChartData.categories"
:show-grid-line="false"
:show-legend="false"
:show-y-axis="true"
:show-x-axis="true"
:colors="currentChartData.colors"
:custom-tooltip="AboutChartTooltip"
:curve-type="CurveType.CatmullRom"
/>
</div>
</template>
<template v-if="isLoading">
<div class="flex flex-row gap-2">
<Skeleton class="w-full h-20 rounded-xl" v-for="i in 2" :key="i" />
</div>
</template>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="flex flex-col gap-3" v-for="user in users" :key="user.name">
<div class="font-semibold">{{ user.title }}</div>
<div
class="rounded-xl bg-white/50 hover:bg-white/40 flex-1 flex flex-row items-center gap-2 p-3 cursor-pointer"
@click="
() => {
handleUserClick(user)
}
"
>
<div class="size-10 rounded-full bg-white/50">
<NuxtImg :src="genUserAvatar(user)" class="size-full rounded-full" />
</div>
<div class="text-md font-semibold">{{ user.name }}</div>
</div>
</div>
</div>
</template>
<AboutBaseInfo />
<AboutChartView />
</div>
</template>