feat: add dashboard health actions
This commit is contained in:
@@ -46,6 +46,8 @@ const emptySummary = (): AdminFileSummary => ({
|
|||||||
expiringSoonCount: 0,
|
expiringSoonCount: 0,
|
||||||
storageIssueCount: 0,
|
storageIssueCount: 0,
|
||||||
neverRetrievedCount: 0,
|
neverRetrievedCount: 0,
|
||||||
|
healthyCount: 0,
|
||||||
|
permanentCount: 0,
|
||||||
storageUsed: 0,
|
storageUsed: 0,
|
||||||
usedCount: 0
|
usedCount: 0
|
||||||
})
|
})
|
||||||
@@ -219,7 +221,12 @@ export function useAdminFiles() {
|
|||||||
{
|
{
|
||||||
value: 'healthy',
|
value: 'healthy',
|
||||||
label: t('fileManage.healthFilters.healthy'),
|
label: t('fileManage.healthFilters.healthy'),
|
||||||
count: Math.max(summary.value.totalFiles - summary.value.healthAttentionCount, 0)
|
count: summary.value.healthyCount
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'permanent',
|
||||||
|
label: t('fileManage.healthFilters.permanent'),
|
||||||
|
count: summary.value.permanentCount
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -305,6 +312,8 @@ export function useAdminFiles() {
|
|||||||
neverRetrievedCount: files.filter((file) =>
|
neverRetrievedCount: files.filter((file) =>
|
||||||
file.statusInsightReasons.includes('never_retrieved')
|
file.statusInsightReasons.includes('never_retrieved')
|
||||||
).length,
|
).length,
|
||||||
|
healthyCount: files.filter((file) => file.statusInsightSeverity === 'success').length,
|
||||||
|
permanentCount: files.filter((file) => file.statusInsightState === 'permanent').length,
|
||||||
storageUsed: files.reduce((totalSize, file) => totalSize + normalizeCount(file.size), 0),
|
storageUsed: files.reduce((totalSize, file) => totalSize + normalizeCount(file.size), 0),
|
||||||
usedCount: files.reduce(
|
usedCount: files.reduce(
|
||||||
(totalUsed, file) => totalUsed + normalizeCount(file.usedCount ?? file.used_count),
|
(totalUsed, file) => totalUsed + normalizeCount(file.usedCount ?? file.used_count),
|
||||||
@@ -312,6 +321,40 @@ export function useAdminFiles() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const normalizeSummary = (
|
||||||
|
rawSummary: Partial<AdminFileSummary> | undefined,
|
||||||
|
files: AdminFileViewItem[],
|
||||||
|
total: number
|
||||||
|
): AdminFileSummary => {
|
||||||
|
const fallback = buildFallbackSummary(files, total)
|
||||||
|
if (!rawSummary) return fallback
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalFiles: normalizeCount(rawSummary.totalFiles ?? fallback.totalFiles),
|
||||||
|
activeCount: normalizeCount(rawSummary.activeCount ?? fallback.activeCount),
|
||||||
|
expiredCount: normalizeCount(rawSummary.expiredCount ?? fallback.expiredCount),
|
||||||
|
textCount: normalizeCount(rawSummary.textCount ?? fallback.textCount),
|
||||||
|
fileCount: normalizeCount(rawSummary.fileCount ?? fallback.fileCount),
|
||||||
|
chunkedCount: normalizeCount(rawSummary.chunkedCount ?? fallback.chunkedCount),
|
||||||
|
healthAttentionCount: normalizeCount(
|
||||||
|
rawSummary.healthAttentionCount ?? fallback.healthAttentionCount
|
||||||
|
),
|
||||||
|
healthDangerCount: normalizeCount(rawSummary.healthDangerCount ?? fallback.healthDangerCount),
|
||||||
|
healthWarningCount: normalizeCount(
|
||||||
|
rawSummary.healthWarningCount ?? fallback.healthWarningCount
|
||||||
|
),
|
||||||
|
expiringSoonCount: normalizeCount(rawSummary.expiringSoonCount ?? fallback.expiringSoonCount),
|
||||||
|
storageIssueCount: normalizeCount(rawSummary.storageIssueCount ?? fallback.storageIssueCount),
|
||||||
|
neverRetrievedCount: normalizeCount(
|
||||||
|
rawSummary.neverRetrievedCount ?? fallback.neverRetrievedCount
|
||||||
|
),
|
||||||
|
healthyCount: normalizeCount(rawSummary.healthyCount ?? fallback.healthyCount),
|
||||||
|
permanentCount: normalizeCount(rawSummary.permanentCount ?? fallback.permanentCount),
|
||||||
|
storageUsed: normalizeCount(rawSummary.storageUsed ?? fallback.storageUsed),
|
||||||
|
usedCount: normalizeCount(rawSummary.usedCount ?? fallback.usedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeInsightSeverity = (
|
const normalizeInsightSeverity = (
|
||||||
severity: AdminFileInsightSeverity | undefined
|
severity: AdminFileInsightSeverity | undefined
|
||||||
): AdminFileInsightSeverity => {
|
): AdminFileInsightSeverity => {
|
||||||
@@ -684,7 +727,7 @@ export function useAdminFiles() {
|
|||||||
|
|
||||||
tableData.value = res.detail.data.map(createFileViewItem)
|
tableData.value = res.detail.data.map(createFileViewItem)
|
||||||
params.value.total = res.detail.total
|
params.value.total = res.detail.total
|
||||||
summary.value = res.detail.summary || buildFallbackSummary(tableData.value, res.detail.total)
|
summary.value = normalizeSummary(res.detail.summary, tableData.value, res.detail.total)
|
||||||
syncSelectedFilesWithCurrentPage()
|
syncSelectedFilesWithCurrentPage()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasLoadError.value = true
|
hasLoadError.value = true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, ref, reactive } from 'vue'
|
import { computed, ref, reactive } from 'vue'
|
||||||
import { StatsService } from '@/services'
|
import { StatsService } from '@/services'
|
||||||
import type { DashboardViewData } from '@/types'
|
import type { DashboardData, DashboardHealthSummary, DashboardViewData } from '@/types'
|
||||||
import { formatFileSize, getErrorMessage } from '@/utils/common'
|
import { formatFileSize, getErrorMessage } from '@/utils/common'
|
||||||
|
|
||||||
type UseDashboardStatsOptions = {
|
type UseDashboardStatsOptions = {
|
||||||
@@ -27,6 +27,14 @@ const emptyDashboardData = (): DashboardViewData => ({
|
|||||||
openUpload: 0,
|
openUpload: 0,
|
||||||
enableChunk: 0,
|
enableChunk: 0,
|
||||||
maxSaveSeconds: 0,
|
maxSaveSeconds: 0,
|
||||||
|
healthAttentionCount: 0,
|
||||||
|
healthDangerCount: 0,
|
||||||
|
healthWarningCount: 0,
|
||||||
|
expiringSoonCount: 0,
|
||||||
|
storageIssueCount: 0,
|
||||||
|
neverRetrievedCount: 0,
|
||||||
|
healthyCount: 0,
|
||||||
|
permanentCount: 0,
|
||||||
topSuffixes: [],
|
topSuffixes: [],
|
||||||
recentFiles: [],
|
recentFiles: [],
|
||||||
storageUsedText: '0 Bytes',
|
storageUsedText: '0 Bytes',
|
||||||
@@ -37,6 +45,8 @@ const emptyDashboardData = (): DashboardViewData => ({
|
|||||||
activeRatio: 0,
|
activeRatio: 0,
|
||||||
textRatio: 0,
|
textRatio: 0,
|
||||||
fileRatio: 0,
|
fileRatio: 0,
|
||||||
|
healthyRatio: 0,
|
||||||
|
healthAttentionRatio: 0,
|
||||||
todaySizeRatio: 0
|
todaySizeRatio: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -62,6 +72,34 @@ const normalizeRecentFiles = (recentFiles: DashboardViewData['recentFiles']) =>
|
|||||||
usedCount: toNumber(file.usedCount)
|
usedCount: toNumber(file.usedCount)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const healthSummaryKeys: (keyof DashboardHealthSummary)[] = [
|
||||||
|
'healthAttentionCount',
|
||||||
|
'healthDangerCount',
|
||||||
|
'healthWarningCount',
|
||||||
|
'expiringSoonCount',
|
||||||
|
'storageIssueCount',
|
||||||
|
'neverRetrievedCount',
|
||||||
|
'healthyCount',
|
||||||
|
'permanentCount'
|
||||||
|
]
|
||||||
|
|
||||||
|
const normalizeHealthSummary = (detail: DashboardData): DashboardHealthSummary => ({
|
||||||
|
healthAttentionCount: toNumber(
|
||||||
|
detail.healthSummary?.healthAttentionCount ?? detail.healthAttentionCount
|
||||||
|
),
|
||||||
|
healthDangerCount: toNumber(detail.healthSummary?.healthDangerCount ?? detail.healthDangerCount),
|
||||||
|
healthWarningCount: toNumber(
|
||||||
|
detail.healthSummary?.healthWarningCount ?? detail.healthWarningCount
|
||||||
|
),
|
||||||
|
expiringSoonCount: toNumber(detail.healthSummary?.expiringSoonCount ?? detail.expiringSoonCount),
|
||||||
|
storageIssueCount: toNumber(detail.healthSummary?.storageIssueCount ?? detail.storageIssueCount),
|
||||||
|
neverRetrievedCount: toNumber(
|
||||||
|
detail.healthSummary?.neverRetrievedCount ?? detail.neverRetrievedCount
|
||||||
|
),
|
||||||
|
healthyCount: toNumber(detail.healthSummary?.healthyCount ?? detail.healthyCount),
|
||||||
|
permanentCount: toNumber(detail.healthSummary?.permanentCount ?? detail.permanentCount)
|
||||||
|
})
|
||||||
|
|
||||||
export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
|
export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
|
||||||
const dashboardData = reactive<DashboardViewData>(emptyDashboardData())
|
const dashboardData = reactive<DashboardViewData>(emptyDashboardData())
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
@@ -103,6 +141,10 @@ export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
|
|||||||
dashboardData.openUpload = toNumber(detail.openUpload)
|
dashboardData.openUpload = toNumber(detail.openUpload)
|
||||||
dashboardData.enableChunk = toNumber(detail.enableChunk)
|
dashboardData.enableChunk = toNumber(detail.enableChunk)
|
||||||
dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds)
|
dashboardData.maxSaveSeconds = toNumber(detail.maxSaveSeconds)
|
||||||
|
const healthSummary = normalizeHealthSummary(detail)
|
||||||
|
healthSummaryKeys.forEach((key) => {
|
||||||
|
dashboardData[key] = healthSummary[key]
|
||||||
|
})
|
||||||
dashboardData.topSuffixes = detail.topSuffixes || []
|
dashboardData.topSuffixes = detail.topSuffixes || []
|
||||||
dashboardData.recentFiles = normalizeRecentFiles(detail.recentFiles || [])
|
dashboardData.recentFiles = normalizeRecentFiles(detail.recentFiles || [])
|
||||||
|
|
||||||
@@ -120,6 +162,12 @@ export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
|
|||||||
dashboardData.fileRatio = dashboardData.totalFiles
|
dashboardData.fileRatio = dashboardData.totalFiles
|
||||||
? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100)
|
? clampRatio((dashboardData.fileCount / dashboardData.totalFiles) * 100)
|
||||||
: 0
|
: 0
|
||||||
|
dashboardData.healthyRatio = dashboardData.totalFiles
|
||||||
|
? clampRatio((dashboardData.healthyCount / dashboardData.totalFiles) * 100)
|
||||||
|
: 0
|
||||||
|
dashboardData.healthAttentionRatio = dashboardData.totalFiles
|
||||||
|
? clampRatio((dashboardData.healthAttentionCount / dashboardData.totalFiles) * 100)
|
||||||
|
: 0
|
||||||
dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit
|
dashboardData.todaySizeRatio = dashboardData.uploadSizeLimit
|
||||||
? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100)
|
? clampRatio((dashboardData.todaySize / dashboardData.uploadSizeLimit) * 100)
|
||||||
: 0
|
: 0
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default {
|
|||||||
lastUpdated: 'Last updated: {time}',
|
lastUpdated: 'Last updated: {time}',
|
||||||
loadFailed: 'Failed to load dashboard data',
|
loadFailed: 'Failed to load dashboard data',
|
||||||
fileHealth: 'File Health',
|
fileHealth: 'File Health',
|
||||||
fileHealthDesc: 'Real file records grouped by availability, expiry, and type.',
|
fileHealthDesc: 'Status insights for available, risky, and pending file queues.',
|
||||||
activeFileRatio: 'Active Ratio',
|
activeFileRatio: 'Active Ratio',
|
||||||
fileShareRatio: 'File Ratio',
|
fileShareRatio: 'File Ratio',
|
||||||
textShareRatio: 'Text Ratio',
|
textShareRatio: 'Text Ratio',
|
||||||
@@ -91,6 +91,28 @@ export default {
|
|||||||
recentFiles: 'Recent Shares',
|
recentFiles: 'Recent Shares',
|
||||||
recentFilesDesc: 'Recently created share records for quick status checks.',
|
recentFilesDesc: 'Recently created share records for quick status checks.',
|
||||||
available: 'Available',
|
available: 'Available',
|
||||||
|
healthActions: {
|
||||||
|
attention: {
|
||||||
|
title: 'Needs Attention',
|
||||||
|
description: 'Handle issue and warning files together'
|
||||||
|
},
|
||||||
|
storageIssue: {
|
||||||
|
title: 'Storage Issues',
|
||||||
|
description: 'Review files missing download metadata'
|
||||||
|
},
|
||||||
|
expiringSoon: {
|
||||||
|
title: 'Expiring Soon',
|
||||||
|
description: 'Extend shares that should stay available'
|
||||||
|
},
|
||||||
|
neverRetrieved: {
|
||||||
|
title: 'Never Retrieved',
|
||||||
|
description: 'Find shares that have not been retrieved yet'
|
||||||
|
},
|
||||||
|
permanent: {
|
||||||
|
title: 'Permanent',
|
||||||
|
description: 'View long-retention share records'
|
||||||
|
}
|
||||||
|
},
|
||||||
table: {
|
table: {
|
||||||
file: 'File',
|
file: 'File',
|
||||||
size: 'Size',
|
size: 'Size',
|
||||||
@@ -410,7 +432,8 @@ export default {
|
|||||||
expiringSoon: 'Expiring Soon',
|
expiringSoon: 'Expiring Soon',
|
||||||
storageIssue: 'Storage Issues',
|
storageIssue: 'Storage Issues',
|
||||||
neverRetrieved: 'Never Retrieved',
|
neverRetrieved: 'Never Retrieved',
|
||||||
healthy: 'Healthy'
|
healthy: 'Healthy',
|
||||||
|
permanent: 'Permanent'
|
||||||
},
|
},
|
||||||
sortBy: 'Sort',
|
sortBy: 'Sort',
|
||||||
unlimited: 'Unlimited',
|
unlimited: 'Unlimited',
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default {
|
|||||||
lastUpdated: '最近更新:{time}',
|
lastUpdated: '最近更新:{time}',
|
||||||
loadFailed: '仪表盘数据加载失败',
|
loadFailed: '仪表盘数据加载失败',
|
||||||
fileHealth: '文件健康',
|
fileHealth: '文件健康',
|
||||||
fileHealthDesc: '基于真实文件记录统计有效、过期和类型分布。',
|
fileHealthDesc: '基于状态洞察统计可取件、风险与待处理队列。',
|
||||||
activeFileRatio: '有效占比',
|
activeFileRatio: '有效占比',
|
||||||
fileShareRatio: '文件占比',
|
fileShareRatio: '文件占比',
|
||||||
textShareRatio: '文本占比',
|
textShareRatio: '文本占比',
|
||||||
@@ -91,6 +91,28 @@ export default {
|
|||||||
recentFiles: '最近分享',
|
recentFiles: '最近分享',
|
||||||
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
recentFilesDesc: '最近创建的分享记录,便于快速核对状态。',
|
||||||
available: '可取件',
|
available: '可取件',
|
||||||
|
healthActions: {
|
||||||
|
attention: {
|
||||||
|
title: '需关注',
|
||||||
|
description: '集中处理异常和预警文件'
|
||||||
|
},
|
||||||
|
storageIssue: {
|
||||||
|
title: '存储异常',
|
||||||
|
description: '检查缺少下载信息的文件'
|
||||||
|
},
|
||||||
|
expiringSoon: {
|
||||||
|
title: '即将过期',
|
||||||
|
description: '优先续期仍需保留的分享'
|
||||||
|
},
|
||||||
|
neverRetrieved: {
|
||||||
|
title: '未取件',
|
||||||
|
description: '识别创建后尚未被取件的记录'
|
||||||
|
},
|
||||||
|
permanent: {
|
||||||
|
title: '永久有效',
|
||||||
|
description: '查看长期保留的分享记录'
|
||||||
|
}
|
||||||
|
},
|
||||||
table: {
|
table: {
|
||||||
file: '文件',
|
file: '文件',
|
||||||
size: '大小',
|
size: '大小',
|
||||||
@@ -408,7 +430,8 @@ export default {
|
|||||||
expiringSoon: '即将过期',
|
expiringSoon: '即将过期',
|
||||||
storageIssue: '存储异常',
|
storageIssue: '存储异常',
|
||||||
neverRetrieved: '未取件',
|
neverRetrieved: '未取件',
|
||||||
healthy: '健康'
|
healthy: '健康',
|
||||||
|
permanent: '永久有效'
|
||||||
},
|
},
|
||||||
sortBy: '排序',
|
sortBy: '排序',
|
||||||
unlimited: '不限次数',
|
unlimited: '不限次数',
|
||||||
|
|||||||
+80
-27
@@ -1,3 +1,16 @@
|
|||||||
|
import type { AdminFileHealthFilter } from './file'
|
||||||
|
|
||||||
|
export interface DashboardHealthSummary {
|
||||||
|
healthAttentionCount: number
|
||||||
|
healthDangerCount: number
|
||||||
|
healthWarningCount: number
|
||||||
|
expiringSoonCount: number
|
||||||
|
storageIssueCount: number
|
||||||
|
neverRetrievedCount: number
|
||||||
|
healthyCount: number
|
||||||
|
permanentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
totalFiles: number
|
totalFiles: number
|
||||||
storageUsed: number | string
|
storageUsed: number | string
|
||||||
@@ -17,6 +30,15 @@ export interface DashboardData {
|
|||||||
openUpload?: number
|
openUpload?: number
|
||||||
enableChunk?: number
|
enableChunk?: number
|
||||||
maxSaveSeconds?: number
|
maxSaveSeconds?: number
|
||||||
|
healthAttentionCount?: number
|
||||||
|
healthDangerCount?: number
|
||||||
|
healthWarningCount?: number
|
||||||
|
expiringSoonCount?: number
|
||||||
|
storageIssueCount?: number
|
||||||
|
neverRetrievedCount?: number
|
||||||
|
healthyCount?: number
|
||||||
|
permanentCount?: number
|
||||||
|
healthSummary?: Partial<DashboardHealthSummary>
|
||||||
topSuffixes?: DashboardSuffixStat[]
|
topSuffixes?: DashboardSuffixStat[]
|
||||||
recentFiles?: DashboardRecentFile[]
|
recentFiles?: DashboardRecentFile[]
|
||||||
}
|
}
|
||||||
@@ -40,31 +62,62 @@ export interface DashboardRecentFile {
|
|||||||
isExpired: boolean
|
isExpired: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardViewData extends DashboardData {
|
export type DashboardViewData = Omit<
|
||||||
hasExtendedStats: boolean
|
DashboardData,
|
||||||
activeCount: number
|
| keyof DashboardHealthSummary
|
||||||
expiredCount: number
|
| 'activeCount'
|
||||||
textCount: number
|
| 'expiredCount'
|
||||||
fileCount: number
|
| 'textCount'
|
||||||
chunkedCount: number
|
| 'fileCount'
|
||||||
usedCount: number
|
| 'chunkedCount'
|
||||||
storageBackend: string
|
| 'usedCount'
|
||||||
uploadSizeLimit: number
|
| 'storageBackend'
|
||||||
openUpload: number
|
| 'uploadSizeLimit'
|
||||||
enableChunk: number
|
| 'openUpload'
|
||||||
maxSaveSeconds: number
|
| 'enableChunk'
|
||||||
topSuffixes: DashboardSuffixStat[]
|
| 'maxSaveSeconds'
|
||||||
recentFiles: DashboardRecentFile[]
|
| 'topSuffixes'
|
||||||
storageUsed: number
|
| 'recentFiles'
|
||||||
yesterdaySize: number
|
| 'storageUsed'
|
||||||
todaySize: number
|
| 'yesterdaySize'
|
||||||
storageUsedText: string
|
| 'todaySize'
|
||||||
yesterdaySizeText: string
|
> &
|
||||||
todaySizeText: string
|
DashboardHealthSummary & {
|
||||||
uploadSizeLimitText: string
|
hasExtendedStats: boolean
|
||||||
sysUptimeText: string
|
activeCount: number
|
||||||
activeRatio: number
|
expiredCount: number
|
||||||
textRatio: number
|
textCount: number
|
||||||
fileRatio: number
|
fileCount: number
|
||||||
todaySizeRatio: 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
|
||||||
|
yesterdaySizeText: string
|
||||||
|
todaySizeText: string
|
||||||
|
uploadSizeLimitText: string
|
||||||
|
sysUptimeText: string
|
||||||
|
activeRatio: number
|
||||||
|
textRatio: number
|
||||||
|
fileRatio: number
|
||||||
|
healthyRatio: number
|
||||||
|
healthAttentionRatio: number
|
||||||
|
todaySizeRatio: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardHealthAction {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
count: number
|
||||||
|
health: AdminFileHealthFilter
|
||||||
|
tone: 'danger' | 'warning' | 'success' | 'neutral'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export interface AdminFileSummary {
|
|||||||
expiringSoonCount: number
|
expiringSoonCount: number
|
||||||
storageIssueCount: number
|
storageIssueCount: number
|
||||||
neverRetrievedCount: number
|
neverRetrievedCount: number
|
||||||
|
healthyCount: number
|
||||||
|
permanentCount: number
|
||||||
storageUsed: number
|
storageUsed: number
|
||||||
usedCount: number
|
usedCount: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,31 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<button
|
||||||
|
v-for="action in healthActions"
|
||||||
|
:key="action.key"
|
||||||
|
type="button"
|
||||||
|
class="group flex min-h-28 flex-col justify-between rounded-lg border p-4 text-left transition-colors"
|
||||||
|
:class="getHealthActionClass(action.tone)"
|
||||||
|
@click="openHealthQueue(action.health)"
|
||||||
|
>
|
||||||
|
<span class="flex items-start justify-between gap-3">
|
||||||
|
<span>
|
||||||
|
<span class="block text-2xl font-semibold">{{ action.count }}</span>
|
||||||
|
<span class="mt-1 block text-sm font-medium">{{ action.label }}</span>
|
||||||
|
</span>
|
||||||
|
<component :is="getHealthActionIcon(action.tone)" class="h-5 w-5 shrink-0" />
|
||||||
|
</span>
|
||||||
|
<span class="mt-3 flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span class="line-clamp-2">{{ action.description }}</span>
|
||||||
|
<ArrowRightIcon
|
||||||
|
class="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</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" :class="[subtlePanelClass]">
|
||||||
<p class="text-sm" :class="[mutedTextClass]">
|
<p class="text-sm" :class="[mutedTextClass]">
|
||||||
@@ -337,24 +362,32 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent, h, onMounted } from 'vue'
|
import { computed, defineComponent, h, onMounted } from 'vue'
|
||||||
import type { PropType } from 'vue'
|
import type { Component, PropType } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
|
AlertTriangleIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
DownloadCloudIcon,
|
DownloadCloudIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
FilesIcon,
|
FilesIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
HardDriveIcon,
|
HardDriveIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
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 { useDashboardStats, useInjectedDarkMode } from '@/composables'
|
import { useDashboardStats, useInjectedDarkMode } from '@/composables'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { formatFileSize, formatTimestamp } from '@/utils/common'
|
import { formatFileSize, formatTimestamp } from '@/utils/common'
|
||||||
|
import { ROUTES } from '@/constants'
|
||||||
|
import type { DashboardHealthAction } from '@/types'
|
||||||
|
|
||||||
const isDarkMode = useInjectedDarkMode()
|
const isDarkMode = useInjectedDarkMode()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
const { dashboardData, errorMessage, fetchDashboardData, isLoading, lastUpdatedText } =
|
const { dashboardData, errorMessage, fetchDashboardData, isLoading, lastUpdatedText } =
|
||||||
useDashboardStats({
|
useDashboardStats({
|
||||||
loadFailedMessage: t('admin.dashboard.loadFailed')
|
loadFailedMessage: t('admin.dashboard.loadFailed')
|
||||||
@@ -380,6 +413,49 @@ const maxSaveTimeText = computed(() => {
|
|||||||
return `${Math.floor(dashboardData.maxSaveSeconds / 60)}${t('common.minute')}`
|
return `${Math.floor(dashboardData.maxSaveSeconds / 60)}${t('common.minute')}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const healthActions = computed<DashboardHealthAction[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'attention',
|
||||||
|
label: t('admin.dashboard.healthActions.attention.title'),
|
||||||
|
description: t('admin.dashboard.healthActions.attention.description'),
|
||||||
|
count: dashboardData.healthAttentionCount,
|
||||||
|
health: 'attention',
|
||||||
|
tone: dashboardData.healthAttentionCount > 0 ? 'danger' : 'success'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'storageIssue',
|
||||||
|
label: t('admin.dashboard.healthActions.storageIssue.title'),
|
||||||
|
description: t('admin.dashboard.healthActions.storageIssue.description'),
|
||||||
|
count: dashboardData.storageIssueCount,
|
||||||
|
health: 'storage_issue',
|
||||||
|
tone: dashboardData.storageIssueCount > 0 ? 'danger' : 'success'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'expiringSoon',
|
||||||
|
label: t('admin.dashboard.healthActions.expiringSoon.title'),
|
||||||
|
description: t('admin.dashboard.healthActions.expiringSoon.description'),
|
||||||
|
count: dashboardData.expiringSoonCount,
|
||||||
|
health: 'expiring_soon',
|
||||||
|
tone: dashboardData.expiringSoonCount > 0 ? 'warning' : 'success'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'neverRetrieved',
|
||||||
|
label: t('admin.dashboard.healthActions.neverRetrieved.title'),
|
||||||
|
description: t('admin.dashboard.healthActions.neverRetrieved.description'),
|
||||||
|
count: dashboardData.neverRetrievedCount,
|
||||||
|
health: 'never_retrieved',
|
||||||
|
tone: dashboardData.neverRetrievedCount > 0 ? 'neutral' : 'success'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'permanent',
|
||||||
|
label: t('admin.dashboard.healthActions.permanent.title'),
|
||||||
|
description: t('admin.dashboard.healthActions.permanent.description'),
|
||||||
|
count: dashboardData.permanentCount,
|
||||||
|
health: 'permanent',
|
||||||
|
tone: 'success'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
const getSuffixRatio = (count: number) => Math.round((count / maxSuffixCount.value) * 100)
|
const getSuffixRatio = (count: number) => Math.round((count / maxSuffixCount.value) * 100)
|
||||||
|
|
||||||
const formatCreatedAt = (value: string | null) => {
|
const formatCreatedAt = (value: string | null) => {
|
||||||
@@ -387,6 +463,39 @@ const formatCreatedAt = (value: string | null) => {
|
|||||||
return formatTimestamp(value, 'datetime')
|
return formatTimestamp(value, 'datetime')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const healthActionIconMap: Record<DashboardHealthAction['tone'], Component> = {
|
||||||
|
danger: AlertTriangleIcon,
|
||||||
|
warning: AlertTriangleIcon,
|
||||||
|
success: CheckCircleIcon,
|
||||||
|
neutral: ShieldCheckIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
const getHealthActionIcon = (tone: DashboardHealthAction['tone']) => healthActionIconMap[tone]
|
||||||
|
|
||||||
|
const getHealthActionClass = (tone: DashboardHealthAction['tone']) => {
|
||||||
|
const darkClasses: Record<DashboardHealthAction['tone'], string> = {
|
||||||
|
danger: 'border-red-500/20 bg-red-500/10 text-red-200 hover:border-red-400/40',
|
||||||
|
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-200 hover:border-amber-400/40',
|
||||||
|
success: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200 hover:border-emerald-400/40',
|
||||||
|
neutral: 'border-gray-700 bg-gray-900/30 text-gray-300 hover:border-gray-600'
|
||||||
|
}
|
||||||
|
const lightClasses: Record<DashboardHealthAction['tone'], string> = {
|
||||||
|
danger: 'border-red-100 bg-red-50 text-red-700 hover:border-red-200',
|
||||||
|
warning: 'border-amber-100 bg-amber-50 text-amber-700 hover:border-amber-200',
|
||||||
|
success: 'border-emerald-100 bg-emerald-50 text-emerald-700 hover:border-emerald-200',
|
||||||
|
neutral: 'border-gray-100 bg-gray-50 text-gray-700 hover:border-gray-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDarkMode.value ? darkClasses[tone] : lightClasses[tone]
|
||||||
|
}
|
||||||
|
|
||||||
|
const openHealthQueue = (health: DashboardHealthAction['health']) => {
|
||||||
|
void router.push({
|
||||||
|
path: ROUTES.FILE_MANAGE,
|
||||||
|
query: { health }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const MetricProgress = defineComponent({
|
const MetricProgress = defineComponent({
|
||||||
name: 'MetricProgress',
|
name: 'MetricProgress',
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
v-if="hasActiveFilters"
|
v-if="hasActiveFilters"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
@click="resetFilters"
|
@click="handleResetFilters"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<XIcon class="mr-2 h-4 w-4" />
|
<XIcon class="mr-2 h-4 w-4" />
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
|
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
:class="getHealthFilterClass(option.value)"
|
:class="getHealthFilterClass(option.value)"
|
||||||
@click="setHealthFilter(option.value)"
|
@click="handleHealthFilterChange(option.value)"
|
||||||
>
|
>
|
||||||
<span>{{ option.label }}</span>
|
<span>{{ option.label }}</span>
|
||||||
<span v-if="typeof option.count === 'number'">({{ option.count }})</span>
|
<span v-if="typeof option.count === 'number'">({{ option.count }})</span>
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
v-if="hasActiveFilters"
|
v-if="hasActiveFilters"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@click="resetFilters"
|
@click="handleResetFilters"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<XIcon class="mr-2 h-4 w-4" />
|
<XIcon class="mr-2 h-4 w-4" />
|
||||||
@@ -1027,8 +1027,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, type Component } from 'vue'
|
import { computed, onMounted, watch, type Component } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import type {
|
import type {
|
||||||
AdminBatchEditMode,
|
AdminBatchEditMode,
|
||||||
AdminFileHealthFilter,
|
AdminFileHealthFilter,
|
||||||
@@ -1069,6 +1070,8 @@ import { useAdminFiles, useInjectedDarkMode } from '@/composables'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const isDarkMode = useInjectedDarkMode()
|
const isDarkMode = useInjectedDarkMode()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const fileTableHeaders = computed(() => [
|
const fileTableHeaders = computed(() => [
|
||||||
t('fileManage.headers.select'),
|
t('fileManage.headers.select'),
|
||||||
@@ -1348,6 +1351,47 @@ const sortOrderOptions = computed<{ value: AdminFileSortOrder; label: string }[]
|
|||||||
{ value: 'asc', label: t('fileManage.sort.asc') }
|
{ value: 'asc', label: t('fileManage.sort.asc') }
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const healthFilterValues: AdminFileHealthFilter[] = [
|
||||||
|
'all',
|
||||||
|
'attention',
|
||||||
|
'danger',
|
||||||
|
'warning',
|
||||||
|
'healthy',
|
||||||
|
'expired',
|
||||||
|
'expiring_soon',
|
||||||
|
'storage_issue',
|
||||||
|
'never_retrieved',
|
||||||
|
'permanent'
|
||||||
|
]
|
||||||
|
|
||||||
|
const getRouteHealthFilter = (): AdminFileHealthFilter => {
|
||||||
|
const health = route.query.health
|
||||||
|
if (typeof health === 'string' && healthFilterValues.includes(health as AdminFileHealthFilter)) {
|
||||||
|
return health as AdminFileHealthFilter
|
||||||
|
}
|
||||||
|
return 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncHealthFilterQuery = async (health: AdminFileHealthFilter) => {
|
||||||
|
const query = { ...route.query }
|
||||||
|
if (health === 'all') {
|
||||||
|
delete query.health
|
||||||
|
} else {
|
||||||
|
query.health = health
|
||||||
|
}
|
||||||
|
await router.replace({ query })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHealthFilterChange = async (health: AdminFileHealthFilter) => {
|
||||||
|
await setHealthFilter(health)
|
||||||
|
await syncHealthFilterQuery(health)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetFilters = async () => {
|
||||||
|
await resetFilters()
|
||||||
|
await syncHealthFilterQuery('all')
|
||||||
|
}
|
||||||
|
|
||||||
const batchEditModeOptions = computed<
|
const batchEditModeOptions = computed<
|
||||||
{ value: AdminBatchEditMode; label: string; icon: Component }[]
|
{ value: AdminBatchEditMode; label: string; icon: Component }[]
|
||||||
>(() => [
|
>(() => [
|
||||||
@@ -1484,7 +1528,17 @@ const getTimelineDotClass = (severity: AdminFileInsightSeverity) => {
|
|||||||
return classes[severity]
|
return classes[severity]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.health,
|
||||||
|
async () => {
|
||||||
|
const health = getRouteHealthFilter()
|
||||||
|
if (params.value.health === health) return
|
||||||
|
await setHealthFilter(health)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
params.value.health = getRouteHealthFilter()
|
||||||
void loadFiles()
|
void loadFiles()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user