refactor: modularize frontend flows

This commit is contained in:
Lan
2026-06-03 02:01:57 +08:00
parent 6c29b36279
commit 72249dd2dd
85 changed files with 4654 additions and 4363 deletions
+342 -82
View File
@@ -1,134 +1,394 @@
<template>
<div class="p-6 overflow-y-auto custom-scrollbar">
<h2 class="text-2xl font-bold mb-6" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
{{ t('admin.dashboard.title') }}
</h2>
<!-- 统计卡片区域 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-sm" :class="[mutedTextClass]">FileCodeBox Admin</p>
<h2 class="text-2xl font-bold" :class="[primaryTextClass]">
{{ t('admin.dashboard.title') }}
</h2>
</div>
<button
type="button"
@click="fetchDashboardData"
class="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors"
:class="[
isDarkMode
? 'bg-gray-800 text-gray-200 hover:bg-gray-700'
: 'bg-white text-gray-700 shadow-sm hover:bg-gray-50'
]"
>
<RefreshCwIcon class="mr-2 h-4 w-4" />
{{ t('admin.dashboard.refresh') }}
</button>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard
:title="t('admin.dashboard.totalFiles')"
:value="dashboardData.totalFiles"
:icon="FileIcon"
:icon="FilesIcon"
icon-color="indigo"
description-type="success">
>
<template #description>
<span>{{ t('admin.dashboard.yesterday') }}{{ dashboardData.yesterdayCount }}</span>
<span class="ml-2">{{ t('admin.dashboard.today') }}{{ dashboardData.todayCount }}</span>
{{ t('admin.dashboard.yesterdayShares', { count: dashboardData.yesterdayCount }) }}
</template>
</StatCard>
<StatCard
:title="t('admin.dashboard.storageSpace')"
:value="dashboardData.storageUsed"
:value="dashboardData.storageUsedText"
:icon="HardDriveIcon"
icon-color="purple"
description-type="success">
>
<template #description>
<span>{{ t('admin.dashboard.yesterday') }}{{ dashboardData.yesterdaySize }}</span>
<span class="ml-2">{{ t('admin.dashboard.today') }}{{ dashboardData.todaySize }}</span>
{{ t('admin.dashboard.todayIncrease', { count: dashboardData.todaySizeText }) }}
</template>
</StatCard>
<StatCard
:title="t('admin.dashboard.activeUsers')"
value="25"
:icon="UsersIcon"
:title="t('admin.dashboard.todayShares')"
:value="dashboardData.todayCount"
:icon="UploadCloudIcon"
icon-color="green"
description-type="error">
>
<template #description>
<span>{{ t('admin.dashboard.weeklyChange') }}</span>
{{ t('admin.dashboard.yesterdayShares', { count: dashboardData.yesterdayCount }) }}
</template>
</StatCard>
<StatCard
:title="t('admin.dashboard.systemStatus')"
:value="t('admin.dashboard.normal')"
:icon="ActivityIcon"
:title="t('admin.dashboard.totalRetrievals')"
:value="dashboardData.usedCount"
:icon="DownloadCloudIcon"
icon-color="blue"
description-type="neutral">
>
<template #description>
{{ t('admin.dashboard.serverUptime') }}: {{ dashboardData.sysUptime }}
{{ t('admin.dashboard.serverUptime') }} {{ dashboardData.sysUptimeText }}
</template>
</StatCard>
</div>
<!-- 添加版本和版权信息 -->
<div class="mt-auto text-center py-4" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
<p class="text-sm">
版本 v2.2.1 更新时间2025-09-04
</p>
<p class="text-sm mt-1">
© {{ new Date().getFullYear() }} <a href="https://github.com/vastsa/FileCodeBox">FileCodeBox</a>
</p>
<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]">
<div class="mb-5 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.fileHealth') }}
</h3>
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.fileHealthDesc') }}
</p>
</div>
<ActivityIcon class="h-5 w-5" :class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']" />
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<MetricProgress
:label="t('admin.dashboard.activeFileRatio')"
:value="dashboardData.activeRatio"
:detail="`${dashboardData.activeCount} / ${dashboardData.totalFiles}`"
tone="green"
/>
<MetricProgress
:label="t('admin.dashboard.fileShareRatio')"
:value="dashboardData.fileRatio"
:detail="t('admin.dashboard.binaryFiles', { count: dashboardData.fileCount })"
tone="indigo"
/>
<MetricProgress
:label="t('admin.dashboard.textShareRatio')"
:value="dashboardData.textRatio"
:detail="t('admin.dashboard.textShares', { count: dashboardData.textCount })"
tone="purple"
/>
</div>
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="rounded-lg border p-4" :class="[subtlePanelClass]">
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.expiredFiles') }}
</p>
<div class="mt-2 flex items-end justify-between">
<strong class="text-3xl" :class="[primaryTextClass]">
{{ dashboardData.expiredCount }}
</strong>
<span class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.needCleanup') }}
</span>
</div>
</div>
<div class="rounded-lg border p-4" :class="[subtlePanelClass]">
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.chunkedFiles') }}
</p>
<div class="mt-2 flex items-end justify-between">
<strong class="text-3xl" :class="[primaryTextClass]">
{{ dashboardData.chunkedCount }}
</strong>
<span class="text-sm" :class="[mutedTextClass]">
{{ dashboardData.enableChunk ? t('common.enabled') : t('common.disabled') }}
</span>
</div>
</div>
</div>
</section>
<section class="rounded-lg p-5 shadow-sm" :class="[panelClass]">
<div class="mb-5">
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.storagePolicy') }}
</h3>
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.storagePolicyDesc') }}
</p>
</div>
<div class="space-y-4">
<PolicyRow
:label="t('admin.dashboard.storageBackend')"
:value="dashboardData.storageBackend"
/>
<PolicyRow
:label="t('admin.dashboard.singleFileLimit')"
:value="dashboardData.uploadSizeLimitText"
/>
<PolicyRow
:label="t('admin.dashboard.guestUpload')"
:value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')"
/>
<PolicyRow
:label="t('admin.dashboard.maxSaveTime')"
:value="maxSaveTimeText"
/>
</div>
<div class="mt-5">
<div class="mb-2 flex items-center justify-between text-sm">
<span :class="[mutedTextClass]">{{ t('admin.dashboard.todayCapacityReference') }}</span>
<span :class="[primaryTextClass]">{{ dashboardData.todaySizeRatio }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
<div
class="h-full rounded-full bg-indigo-500"
:style="{ width: `${dashboardData.todaySizeRatio}%` }"
></div>
</div>
</div>
</section>
</div>
<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" :class="[panelClass]">
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.fileTypeDistribution') }}
</h3>
<div class="mt-4 space-y-3">
<div v-if="dashboardData.topSuffixes.length === 0" class="text-sm" :class="[mutedTextClass]">
{{ t('common.noData') }}
</div>
<div v-for="item in dashboardData.topSuffixes" :key="item.suffix" class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span :class="[primaryTextClass]">{{ item.suffix || t('admin.dashboard.textType') }}</span>
<span :class="[mutedTextClass]">{{ item.count }}</span>
</div>
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
<div
class="h-full rounded-full bg-purple-500"
:style="{ width: `${getSuffixRatio(item.count)}%` }"
></div>
</div>
</div>
</div>
</section>
<section class="xl:col-span-2 rounded-lg p-5 shadow-sm" :class="[panelClass]">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.recentFiles') }}
</h3>
<p class="text-sm" :class="[mutedTextClass]">
{{ t('admin.dashboard.recentFilesDesc') }}
</p>
</div>
</div>
<div class="overflow-hidden rounded-lg border" :class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']">
<table class="min-w-full divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']">
<thead :class="[isDarkMode ? 'bg-gray-900/50' : 'bg-gray-50']">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
{{ t('admin.dashboard.table.file') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
{{ t('admin.dashboard.table.size') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
{{ t('admin.dashboard.table.usage') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]">
{{ t('admin.dashboard.table.status') }}
</th>
</tr>
</thead>
<tbody class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-100']">
<tr v-if="dashboardData.recentFiles.length === 0">
<td colspan="4" class="px-4 py-6 text-center text-sm" :class="[mutedTextClass]">
{{ t('common.noData') }}
</td>
</tr>
<tr v-for="file in dashboardData.recentFiles" :key="file.id">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div class="rounded-lg p-2" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']">
<FileTextIcon v-if="file.text" class="h-4 w-4" :class="[mutedTextClass]" />
<FileIcon v-else class="h-4 w-4" :class="[mutedTextClass]" />
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium" :class="[primaryTextClass]">
{{ file.name || file.code }}
</p>
<p class="text-xs" :class="[mutedTextClass]">
{{ file.code }} · {{ formatCreatedAt(file.createdAt) }}
</p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
{{ formatFileSize(file.size) }}
</td>
<td class="px-4 py-3 text-sm" :class="[primaryTextClass]">
{{ file.usedCount }} {{ t('common.times') }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex rounded-full px-2.5 py-1 text-xs font-medium"
:class="[
file.isExpired
? isDarkMode
? 'bg-red-900/40 text-red-300'
: 'bg-red-100 text-red-700'
: isDarkMode
? 'bg-green-900/40 text-green-300'
: 'bg-green-100 text-green-700'
]"
>
{{ file.isExpired ? t('common.expiredFile') : t('admin.dashboard.available') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { inject, onMounted, reactive } from 'vue'
import { computed, defineComponent, h, onMounted } from 'vue'
import type { PropType } from 'vue'
import {
FileIcon,
HardDriveIcon,
UsersIcon,
ActivityIcon,
DownloadCloudIcon,
FileIcon,
FilesIcon,
FileTextIcon,
HardDriveIcon,
RefreshCwIcon,
UploadCloudIcon
} from 'lucide-vue-next'
import { StatsService } from '@/services'
import type { DashboardData } from '@/types'
import StatCard from '@/components/common/StatCard.vue'
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
import { useI18n } from 'vue-i18n'
const isDarkMode = inject('isDarkMode')
const { t } = useI18n()
import { formatFileSize, formatTimestamp } from '@/utils/common'
const dashboardData = reactive<DashboardData>({
totalFiles: 0,
storageUsed: 0,
yesterdayCount: 0,
todayCount: 0,
yesterdaySize: 0,
todaySize: 0,
sysUptime: 0
const isDarkMode = useInjectedDarkMode()
const { t } = useI18n()
const { dashboardData, fetchDashboardData } = useDashboardStats()
const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))
const mutedTextClass = computed(() => (isDarkMode.value ? 'text-gray-400' : 'text-gray-500'))
const panelClass = computed(() =>
isDarkMode.value ? 'bg-gray-800/80 border border-gray-700' : 'bg-white border border-gray-100'
)
const subtlePanelClass = computed(() =>
isDarkMode.value ? 'border-gray-700 bg-gray-900/30' : 'border-gray-100 bg-gray-50'
)
const maxSuffixCount = computed(() =>
Math.max(...dashboardData.topSuffixes.map((item) => item.count), 1)
)
const maxSaveTimeText = computed(() => {
if (!dashboardData.maxSaveSeconds) return t('admin.dashboard.noSaveLimit')
const days = Math.floor(dashboardData.maxSaveSeconds / 86400)
if (days >= 1) return `${days}${t('common.day')}`
const hours = Math.floor(dashboardData.maxSaveSeconds / 3600)
if (hours >= 1) return `${hours}${t('common.hour')}`
return `${Math.floor(dashboardData.maxSaveSeconds / 60)}${t('common.minute')}`
})
const getSysUptime = (startTimestamp: number) => {
const now = new Date().getTime()
const uptime = now - startTimestamp
const days = Math.floor(uptime / (24 * 60 * 60 * 1000))
const hours = Math.floor((uptime % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000))
return `${days}${hours}小时`
const getSuffixRatio = (count: number) => Math.round((count / maxSuffixCount.value) * 100)
const formatCreatedAt = (value: string | null) => {
if (!value) return '-'
return formatTimestamp(value, 'datetime')
}
const getLocalstorageUsed = (nowUsedBit: string) => {
const kb = parseInt(nowUsedBit) / 1024
const mb = kb / 1024
const gb = mb / 1024
const tb = gb / 1024
// 根据大小选择合适的单位
if (tb > 1) {
return `${tb.toFixed(2)}TB`
} else if (gb > 1) {
return `${gb.toFixed(2)}GB`
} else if (mb > 1) {
return `${mb.toFixed(2)}MB`
} else if (kb > 1) {
return `${kb.toFixed(2)}KB`
} else {
return `${nowUsedBit}B`
const MetricProgress = defineComponent({
name: 'MetricProgress',
props: {
label: { type: String, required: true },
value: { type: Number, required: true },
detail: { type: String, required: true },
tone: {
type: String as PropType<'green' | 'indigo' | 'purple'>,
required: true
}
},
setup(props) {
const toneClass = computed(() => {
const classes = {
green: 'bg-green-500',
indigo: 'bg-indigo-500',
purple: 'bg-purple-500'
}
return classes[props.tone]
})
return () =>
h('div', { class: 'rounded-lg border p-4 border-gray-200/60 dark:border-gray-700' }, [
h('div', { class: 'mb-2 flex items-center justify-between text-sm' }, [
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('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)
])
}
}
const getDashboardData = async () => {
const response = await StatsService.getDashboard()
if (response.detail) {
dashboardData.totalFiles = response.detail.totalFiles
dashboardData.storageUsed = getLocalstorageUsed(response.detail.storageUsed.toString())
dashboardData.yesterdaySize = getLocalstorageUsed(response.detail.yesterdaySize.toString())
dashboardData.todaySize = getLocalstorageUsed(response.detail.todaySize.toString())
dashboardData.yesterdayCount = response.detail.yesterdayCount
dashboardData.todayCount = response.detail.todayCount
dashboardData.sysUptime = getSysUptime(Number(response.detail.sysUptime))
})
const PolicyRow = defineComponent({
name: 'PolicyRow',
props: {
label: { type: String, required: true },
value: { type: String, required: true }
},
setup(props) {
return () =>
h('div', { class: 'flex items-center justify-between gap-4 border-b border-gray-200/60 pb-3 last:border-b-0 dark:border-gray-700' }, [
h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label),
h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value)
])
}
}
})
onMounted(() => {
getDashboardData()
void fetchDashboardData()
})
</script>