feat: improve dashboard refresh state

This commit is contained in:
Lan
2026-06-03 03:39:13 +08:00
parent 96a4e8537a
commit 060e77d343
5 changed files with 204 additions and 87 deletions
+42 -6
View File
@@ -1,7 +1,11 @@
import { reactive } from 'vue' import { computed, ref, reactive } from 'vue'
import { StatsService } from '@/services' import { StatsService } from '@/services'
import type { DashboardViewData } from '@/types' import type { DashboardViewData } from '@/types'
import { formatFileSize } from '@/utils/common' import { formatFileSize, getErrorMessage } from '@/utils/common'
type UseDashboardStatsOptions = {
loadFailedMessage?: string
}
const emptyDashboardData = (): DashboardViewData => ({ const emptyDashboardData = (): DashboardViewData => ({
hasExtendedStats: false, hasExtendedStats: false,
@@ -50,12 +54,32 @@ const formatDuration = (startTimestamp: number | null) => {
return `${days}${hours}小时` return `${days}${hours}小时`
} }
export function useDashboardStats() { const normalizeRecentFiles = (recentFiles: DashboardViewData['recentFiles']) =>
recentFiles.map((file) => ({
...file,
size: toNumber(file.size),
expiredCount: toNumber(file.expiredCount),
usedCount: toNumber(file.usedCount)
}))
export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
const dashboardData = reactive<DashboardViewData>(emptyDashboardData()) const dashboardData = reactive<DashboardViewData>(emptyDashboardData())
const isLoading = ref(false)
const errorMessage = ref('')
const lastUpdatedAt = ref<Date | null>(null)
const lastUpdatedText = computed(() =>
lastUpdatedAt.value ? lastUpdatedAt.value.toLocaleString() : '-'
)
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
isLoading.value = true
errorMessage.value = ''
try {
const response = await StatsService.getDashboard() const response = await StatsService.getDashboard()
if (!response.detail) return if (!response.detail) {
throw new Error('No dashboard data')
}
const detail = response.detail const detail = response.detail
dashboardData.totalFiles = toNumber(detail.totalFiles) dashboardData.totalFiles = toNumber(detail.totalFiles)
@@ -80,7 +104,7 @@ export function useDashboardStats() {
dashboardData.enableChunk = toNumber(detail.enableChunk) dashboardData.enableChunk = toNumber(detail.enableChunk)
dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds) dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds)
dashboardData.topSuffixes = detail.topSuffixes || [] dashboardData.topSuffixes = detail.topSuffixes || []
dashboardData.recentFiles = detail.recentFiles || [] dashboardData.recentFiles = normalizeRecentFiles(detail.recentFiles || [])
dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed) dashboardData.storageUsedText = formatFileSize(dashboardData.storageUsed)
dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize) dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize)
@@ -99,10 +123,22 @@ export function useDashboardStats() {
dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit
? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100) ? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100)
: 0 : 0
lastUpdatedAt.value = new Date()
} catch (error) {
errorMessage.value = getErrorMessage(
error,
options.loadFailedMessage || 'Failed to load dashboard data'
)
} finally {
isLoading.value = false
}
} }
return { return {
dashboardData, dashboardData,
fetchDashboardData errorMessage,
fetchDashboardData,
isLoading,
lastUpdatedText
} }
} }
+3
View File
@@ -65,6 +65,9 @@ export default {
yesterdayShares: 'Yesterday: {count}', yesterdayShares: 'Yesterday: {count}',
serverUptime: 'Uptime', serverUptime: 'Uptime',
refresh: 'Refresh', refresh: 'Refresh',
refreshing: 'Refreshing',
lastUpdated: 'Last updated: {time}',
loadFailed: 'Failed to load dashboard data',
fileHealth: 'File Health', fileHealth: 'File Health',
fileHealthDesc: 'Real file records grouped by availability, expiry, and type.', fileHealthDesc: 'Real file records grouped by availability, expiry, and type.',
activeFileRatio: 'Active Ratio', activeFileRatio: 'Active Ratio',
+3
View File
@@ -65,6 +65,9 @@ export default {
yesterdayShares: '昨日分享:{count}', yesterdayShares: '昨日分享:{count}',
serverUptime: '运行时间', serverUptime: '运行时间',
refresh: '刷新数据', refresh: '刷新数据',
refreshing: '刷新中',
lastUpdated: '最近更新:{time}',
loadFailed: '仪表盘数据加载失败',
fileHealth: '文件健康', fileHealth: '文件健康',
fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。', fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。',
activeFileRatio: '有效占比', activeFileRatio: '有效占比',
+32 -16
View File
@@ -1,24 +1,24 @@
export interface DashboardData { export interface DashboardData {
totalFiles: number totalFiles: number
storageUsed: number storageUsed: number | string
yesterdayCount: number yesterdayCount: number
todayCount: number todayCount: number
yesterdaySize: number yesterdaySize: number | string
todaySize: number todaySize: number | string
sysUptime: number | null sysUptime: number | null
activeCount: number activeCount?: number
expiredCount: number expiredCount?: number
textCount: number textCount?: number
fileCount: number fileCount?: number
chunkedCount: number chunkedCount?: number
usedCount: number usedCount?: number
storageBackend: string storageBackend?: string
uploadSizeLimit: number uploadSizeLimit?: number
openUpload: number openUpload?: number
enableChunk: number enableChunk?: number
maxSaveSeconds: number maxSaveSeconds?: number
topSuffixes: DashboardSuffixStat[] topSuffixes?: DashboardSuffixStat[]
recentFiles: DashboardRecentFile[] recentFiles?: DashboardRecentFile[]
} }
export interface DashboardSuffixStat { export interface DashboardSuffixStat {
@@ -42,6 +42,22 @@ export interface DashboardRecentFile {
export interface DashboardViewData extends DashboardData { export interface DashboardViewData extends DashboardData {
hasExtendedStats: boolean hasExtendedStats: boolean
activeCount: number
expiredCount: number
textCount: number
fileCount: number
chunkedCount: number
usedCount: number
storageBackend: string
uploadSizeLimit: number
openUpload: number
enableChunk: number
maxSaveSeconds: number
topSuffixes: DashboardSuffixStat[]
recentFiles: DashboardRecentFile[]
storageUsed: number
yesterdaySize: number
todaySize: number
storageUsedText: string storageUsedText: string
yesterdaySizeText: string yesterdaySizeText: string
todaySizeText: string todaySizeText: string
+81 -22
View File
@@ -6,22 +6,38 @@
<h2 class="text-2xl font-bold" :class="[primaryTextClass]"> <h2 class="text-2xl font-bold" :class="[primaryTextClass]">
{{ t('admin.dashboard.title') }} {{ t('admin.dashboard.title') }}
</h2> </h2>
<p class="mt-1 text-xs" :class="[mutedTextClass]">
{{ t('admin.dashboard.lastUpdated', { time: lastUpdatedText }) }}
</p>
</div> </div>
<button <button
type="button" type="button"
:disabled="isLoading"
@click="fetchDashboardData" @click="fetchDashboardData"
class="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors" class="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-70"
:class="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-800 text-gray-200 hover:bg-gray-700' ? 'bg-gray-800 text-gray-200 hover:bg-gray-700'
: 'bg-white text-gray-700 shadow-sm hover:bg-gray-50' : 'bg-white text-gray-700 shadow-sm hover:bg-gray-50'
]" ]"
> >
<RefreshCwIcon class="mr-2 h-4 w-4" /> <RefreshCwIcon class="mr-2 h-4 w-4" :class="{ 'animate-spin': isLoading }" />
{{ t('admin.dashboard.refresh') }} {{ isLoading ? t('admin.dashboard.refreshing') : t('admin.dashboard.refresh') }}
</button> </button>
</div> </div>
<div
v-if="errorMessage"
class="mb-6 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'
]"
>
{{ errorMessage }}
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard <StatCard
:title="t('admin.dashboard.totalFiles')" :title="t('admin.dashboard.totalFiles')"
@@ -79,7 +95,10 @@
{{ t('admin.dashboard.fileHealthDesc') }} {{ t('admin.dashboard.fileHealthDesc') }}
</p> </p>
</div> </div>
<ActivityIcon class="h-5 w-5" :class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']" /> <ActivityIcon
class="h-5 w-5"
:class="[isDarkMode ? 'text-indigo-300' : 'text-indigo-500']"
/>
</div> </div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
@@ -157,10 +176,7 @@
:label="t('admin.dashboard.guestUpload')" :label="t('admin.dashboard.guestUpload')"
:value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')" :value="dashboardData.openUpload ? t('common.enabled') : t('common.disabled')"
/> />
<PolicyRow <PolicyRow :label="t('admin.dashboard.maxSaveTime')" :value="maxSaveTimeText" />
:label="t('admin.dashboard.maxSaveTime')"
:value="maxSaveTimeText"
/>
</div> </div>
<div class="mt-5"> <div class="mt-5">
@@ -168,7 +184,10 @@
<span :class="[mutedTextClass]">{{ t('admin.dashboard.todayCapacityReference') }}</span> <span :class="[mutedTextClass]">{{ t('admin.dashboard.todayCapacityReference') }}</span>
<span :class="[primaryTextClass]">{{ dashboardData.todaySizeRatio }}%</span> <span :class="[primaryTextClass]">{{ dashboardData.todaySizeRatio }}%</span>
</div> </div>
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"> <div
class="h-2 overflow-hidden rounded-full"
:class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"
>
<div <div
class="h-full rounded-full bg-indigo-500" class="h-full rounded-full bg-indigo-500"
:style="{ width: `${dashboardData.todaySizeRatio}%` }" :style="{ width: `${dashboardData.todaySizeRatio}%` }"
@@ -184,15 +203,24 @@
{{ t('admin.dashboard.fileTypeDistribution') }} {{ t('admin.dashboard.fileTypeDistribution') }}
</h3> </h3>
<div class="mt-4 space-y-3"> <div class="mt-4 space-y-3">
<div v-if="dashboardData.topSuffixes.length === 0" class="text-sm" :class="[mutedTextClass]"> <div
v-if="dashboardData.topSuffixes.length === 0"
class="text-sm"
:class="[mutedTextClass]"
>
{{ t('common.noData') }} {{ t('common.noData') }}
</div> </div>
<div v-for="item in dashboardData.topSuffixes" :key="item.suffix" class="space-y-1"> <div v-for="item in dashboardData.topSuffixes" :key="item.suffix" class="space-y-1">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span :class="[primaryTextClass]">{{ item.suffix || t('admin.dashboard.textType') }}</span> <span :class="[primaryTextClass]">{{
item.suffix || t('admin.dashboard.textType')
}}</span>
<span :class="[mutedTextClass]">{{ item.count }}</span> <span :class="[mutedTextClass]">{{ item.count }}</span>
</div> </div>
<div class="h-2 overflow-hidden rounded-full" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"> <div
class="h-2 overflow-hidden rounded-full"
:class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"
>
<div <div
class="h-full rounded-full bg-purple-500" class="h-full rounded-full bg-purple-500"
:style="{ width: `${getSuffixRatio(item.count)}%` }" :style="{ width: `${getSuffixRatio(item.count)}%` }"
@@ -214,20 +242,38 @@
</div> </div>
</div> </div>
<div class="overflow-hidden rounded-lg border" :class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"> <div
<table class="min-w-full divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']"> 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']"> <thead :class="[isDarkMode ? 'bg-gray-900/50' : 'bg-gray-50']">
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]"> <th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
:class="[mutedTextClass]"
>
{{ t('admin.dashboard.table.file') }} {{ t('admin.dashboard.table.file') }}
</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]"> <th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
:class="[mutedTextClass]"
>
{{ t('admin.dashboard.table.size') }} {{ t('admin.dashboard.table.size') }}
</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]"> <th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
:class="[mutedTextClass]"
>
{{ t('admin.dashboard.table.usage') }} {{ t('admin.dashboard.table.usage') }}
</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider" :class="[mutedTextClass]"> <th
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
:class="[mutedTextClass]"
>
{{ t('admin.dashboard.table.status') }} {{ t('admin.dashboard.table.status') }}
</th> </th>
</tr> </tr>
@@ -241,7 +287,10 @@
<tr v-for="file in dashboardData.recentFiles" :key="file.id"> <tr v-for="file in dashboardData.recentFiles" :key="file.id">
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="rounded-lg p-2" :class="[isDarkMode ? 'bg-gray-700' : 'bg-gray-100']"> <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]" /> <FileTextIcon v-if="file.text" class="h-4 w-4" :class="[mutedTextClass]" />
<FileIcon v-else class="h-4 w-4" :class="[mutedTextClass]" /> <FileIcon v-else class="h-4 w-4" :class="[mutedTextClass]" />
</div> </div>
@@ -306,7 +355,10 @@ import { formatFileSize, formatTimestamp } from '@/utils/common'
const isDarkMode = useInjectedDarkMode() const isDarkMode = useInjectedDarkMode()
const { t } = useI18n() const { t } = useI18n()
const { dashboardData, fetchDashboardData } = useDashboardStats() const { dashboardData, errorMessage, fetchDashboardData, isLoading, lastUpdatedText } =
useDashboardStats({
loadFailedMessage: t('admin.dashboard.loadFailed')
})
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'))
@@ -381,10 +433,17 @@ const PolicyRow = defineComponent({
}, },
setup(props) { setup(props) {
return () => 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(
'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 text-gray-500 dark:text-gray-400' }, props.label),
h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value) h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value)
]) ]
)
} }
}) })