feat: improve dashboard refresh state
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
])
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user