feat: show admin file detail lifecycle

This commit is contained in:
Lan
2026-06-03 05:28:11 +08:00
parent 5efeb49262
commit 4ab0c0e7dc
5 changed files with 400 additions and 2 deletions
+71 -2
View File
@@ -8,7 +8,9 @@ import type {
AdminBatchUpdateFilesRequest, AdminBatchUpdateFilesRequest,
AdminBatchUpdateFilesResponse, AdminBatchUpdateFilesResponse,
AdminFileDetailResponse, AdminFileDetailResponse,
AdminFileDetailTimelineItem,
AdminFileDetailViewItem, AdminFileDetailViewItem,
AdminFileInsightSeverity,
AdminFilePatchPayload, AdminFilePatchPayload,
AdminFileListParams, AdminFileListParams,
AdminFileStatusFilter, AdminFileStatusFilter,
@@ -20,7 +22,7 @@ import type {
FileListItem FileListItem
} from '@/types' } from '@/types'
import { copyToClipboard } from '@/utils/clipboard' import { copyToClipboard } from '@/utils/clipboard'
import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common' import { formatDuration, formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common'
import { import {
downloadAdminManagedFile, downloadAdminManagedFile,
exportAdminTextFile, exportAdminTextFile,
@@ -215,6 +217,61 @@ export function useAdminFiles() {
} }
} }
const normalizeInsightSeverity = (
severity: AdminFileInsightSeverity | undefined
): AdminFileInsightSeverity => {
if (severity === 'success' || severity === 'warning' || severity === 'danger') {
return severity
}
if (severity === 'info' || severity === 'neutral') {
return severity
}
return 'neutral'
}
const formatTimelineValue = (item: AdminFileDetailTimelineItem) => {
if (item.key === 'download_limit') {
if (item.status === 'unlimited') return t('fileManage.unlimited')
if (typeof item.value === 'number') return t('fileManage.remaining', { count: item.value })
}
if (item.key === 'retrieved' && typeof item.value === 'number') {
return `${item.value} ${t('common.times')}`
}
if (item.key === 'expiration_policy') {
if (item.status === 'unlimited') return t('send.expiration.units.forever')
if (typeof item.value === 'number') {
const duration = formatDuration(Math.abs(item.value), (key) => t(key))
return item.value <= 0
? t('fileManage.timeline.expiration_policy.overdue', { time: duration })
: t('fileManage.timeline.expiration_policy.remaining', { time: duration })
}
}
if (item.detail) return item.detail
if (item.value !== null && item.value !== undefined) return String(item.value)
return ''
}
const createTimelineViewItems = (items: AdminFileDetailTimelineItem[] = []) =>
items.map((item) => {
const timestampText = item.timestamp ? formatTimestamp(item.timestamp) : ''
const valueText = formatTimelineValue(item)
const metaParts = [timestampText, valueText].filter(Boolean)
return {
...item,
severity: normalizeInsightSeverity(item.severity),
displayTitle: t(`fileManage.timeline.${item.key}.title`),
displayDescription: t(`fileManage.timeline.${item.key}.description`, {
status: t(`fileManage.timeline.status.${item.status || 'pending'}`),
detail: item.detail || '',
value: valueText || ''
}),
displayMeta: metaParts.length > 0 ? metaParts.join(' · ') : '-'
}
})
const createFileDetailViewItem = ( const createFileDetailViewItem = (
file: FileListItem | AdminFileDetailResponse file: FileListItem | AdminFileDetailResponse
): AdminFileDetailViewItem => { ): AdminFileDetailViewItem => {
@@ -260,6 +317,12 @@ export function useAdminFiles() {
const storageBackendValue = const storageBackendValue =
storage?.backend ?? detail.storageBackend ?? detail.storage_backend ?? '-' storage?.backend ?? detail.storageBackend ?? detail.storage_backend ?? '-'
const isChunkedStorage = storage?.isChunked ?? storage?.is_chunked ?? viewItem.isChunkedFile const isChunkedStorage = storage?.isChunked ?? storage?.is_chunked ?? viewItem.isChunkedFile
const statusInsights = detail.statusInsights ?? detail.status_insights
const statusInsightState =
statusInsights?.state ??
(viewItem.isExpiredFile ? 'expired' : isPermanentFile ? 'permanent' : 'available')
const statusInsightNextAction =
statusInsights?.nextAction ?? statusInsights?.next_action ?? 'monitor'
return { return {
...viewItem, ...viewItem,
@@ -283,7 +346,13 @@ export function useAdminFiles() {
storage?.uuid_file_name ?? storage?.uuid_file_name ??
detail.uuidFileName ?? detail.uuidFileName ??
detail.uuid_file_name, detail.uuid_file_name,
uploadIdValue: storage?.uploadId ?? storage?.upload_id ?? detail.uploadId ?? detail.upload_id uploadIdValue: storage?.uploadId ?? storage?.upload_id ?? detail.uploadId ?? detail.upload_id,
statusInsightSeverity: normalizeInsightSeverity(statusInsights?.severity),
statusInsightState,
statusInsightNextAction,
statusInsightReasons: statusInsights?.reasons || [],
statusInsightMetrics: statusInsights?.metrics,
detailTimeline: createTimelineViewItems(detail.timeline || [])
} }
} }
+67
View File
@@ -452,6 +452,9 @@ export default {
overview: 'Overview', overview: 'Overview',
policyInfo: 'Policy', policyInfo: 'Policy',
storageInfo: 'Storage', storageInfo: 'Storage',
statusInsight: 'Status Insight',
nextAction: 'Next Action',
lifecycle: 'Lifecycle',
permanent: 'Permanent', permanent: 'Permanent',
createdAt: 'Created Time', createdAt: 'Created Time',
remainingDownloads: 'Remaining Downloads', remainingDownloads: 'Remaining Downloads',
@@ -468,6 +471,70 @@ export default {
uploadId: 'Upload Session', uploadId: 'Upload Session',
yes: 'Yes', yes: 'Yes',
no: 'No', no: 'No',
insightSeverity: {
success: 'Healthy',
warning: 'Needs Attention',
danger: 'Issue',
info: 'Info',
neutral: 'Watch'
},
insightStates: {
available: 'Available for retrieval',
expired: 'Unavailable for retrieval',
storage_incomplete: 'Storage metadata incomplete',
expiring_soon: 'Expiring soon',
permanent: 'Permanent'
},
insightActions: {
monitor: 'Keep monitoring',
extend_or_delete: 'Extend retention or clean up',
inspect_storage: 'Inspect storage path and file name',
extend_expiration: 'Extend expiration if needed'
},
insightReasons: {
expired: 'Past expiration time',
download_limit_exhausted: 'Retrieval limit exhausted',
expires_soon: 'Expires within 24 hours',
never_retrieved: 'Never retrieved',
storage_metadata_incomplete: 'Missing downloadable storage metadata',
chunked_upload: 'Created by chunked upload'
},
timeline: {
status: {
done: 'Done',
pending: 'Pending',
active: 'Active',
expired: 'Expired',
exhausted: 'Exhausted',
unlimited: 'Unlimited'
},
created: {
title: 'Record Created',
description: 'The share record has been created and is currently {status}.'
},
content_ready: {
title: 'Content Ready',
description: 'Content type is {detail}; current status is {status}.'
},
upload_session: {
title: 'Upload Session',
description: 'Upload session recorded: {detail}.'
},
expiration_policy: {
title: 'Expiration Policy',
description: 'Expiration policy is currently {status}.',
remaining: '{time} left',
overdue: '{time} overdue'
},
download_limit: {
title: 'Retrieval Limit',
description: 'Retrieval limit is currently {status}; {value}.'
},
retrieved: {
title: 'Retrieval History',
description: 'Retrieved {value}; current status is {status}.'
}
},
headers: { headers: {
select: 'Select', select: 'Select',
code: 'Retrieve Code', code: 'Retrieve Code',
+67
View File
@@ -450,6 +450,9 @@ export default {
overview: '概览', overview: '概览',
policyInfo: '策略信息', policyInfo: '策略信息',
storageInfo: '存储信息', storageInfo: '存储信息',
statusInsight: '状态洞察',
nextAction: '建议动作',
lifecycle: '生命周期',
permanent: '永久有效', permanent: '永久有效',
createdAt: '创建时间', createdAt: '创建时间',
remainingDownloads: '剩余次数', remainingDownloads: '剩余次数',
@@ -466,6 +469,70 @@ export default {
uploadId: '上传会话', uploadId: '上传会话',
yes: '是', yes: '是',
no: '否', no: '否',
insightSeverity: {
success: '正常',
warning: '需关注',
danger: '异常',
info: '提示',
neutral: '待观察'
},
insightStates: {
available: '当前可正常取件',
expired: '当前已不可取件',
storage_incomplete: '存储信息不完整',
expiring_soon: '即将过期',
permanent: '永久有效'
},
insightActions: {
monitor: '保持观察',
extend_or_delete: '延长有效期或清理记录',
inspect_storage: '检查存储路径与文件名',
extend_expiration: '按需延长过期时间'
},
insightReasons: {
expired: '已超过过期时间',
download_limit_exhausted: '取件次数已用尽',
expires_soon: '24 小时内过期',
never_retrieved: '尚未被取件',
storage_metadata_incomplete: '缺少可下载存储信息',
chunked_upload: '由分片上传生成'
},
timeline: {
status: {
done: '已完成',
pending: '等待中',
active: '生效中',
expired: '已过期',
exhausted: '已用尽',
unlimited: '不限'
},
created: {
title: '记录创建',
description: '分享记录已创建,当前状态为 {status}。'
},
content_ready: {
title: '内容就绪',
description: '内容类型为 {detail},当前状态为 {status}。'
},
upload_session: {
title: '上传会话',
description: '上传会话已记录:{detail}。'
},
expiration_policy: {
title: '过期策略',
description: '过期策略当前为 {status}。',
remaining: '还剩 {time}',
overdue: '已超时 {time}'
},
download_limit: {
title: '取件次数',
description: '取件次数策略当前为 {status}{value}。'
},
retrieved: {
title: '取件记录',
description: '累计取件 {value},当前状态为 {status}。'
}
},
headers: { headers: {
select: '选择', select: '选择',
code: '取件码', code: '取件码',
+47
View File
@@ -145,6 +145,44 @@ export interface AdminFileDetailStorage {
upload_id?: string | null upload_id?: string | null
} }
export type AdminFileInsightSeverity = 'success' | 'warning' | 'danger' | 'info' | 'neutral'
export interface AdminFileDetailInsightMetrics {
ageSeconds?: number
age_seconds?: number
secondsUntilExpiration?: number | null
seconds_until_expiration?: number | null
remainingDownloads?: number | null
remaining_downloads?: number | null
usedCount?: number
used_count?: number
}
export interface AdminFileDetailStatusInsights {
severity?: AdminFileInsightSeverity
state?: string
nextAction?: string
next_action?: string
reasons?: string[]
metrics?: AdminFileDetailInsightMetrics
}
export interface AdminFileDetailTimelineItem {
key: string
status?: string
severity?: AdminFileInsightSeverity
timestamp?: string | null
value?: number | string | null
detail?: string | null
}
export interface AdminFileDetailTimelineViewItem extends AdminFileDetailTimelineItem {
severity: AdminFileInsightSeverity
displayTitle: string
displayDescription: string
displayMeta: string
}
export interface AdminFileDetailResponse extends FileListItem { export interface AdminFileDetailResponse extends FileListItem {
filename?: string filename?: string
displayName?: string displayName?: string
@@ -171,6 +209,9 @@ export interface AdminFileDetailResponse extends FileListItem {
upload_id?: string | null upload_id?: string | null
policy?: AdminFileDetailPolicy policy?: AdminFileDetailPolicy
storage?: AdminFileDetailStorage storage?: AdminFileDetailStorage
statusInsights?: AdminFileDetailStatusInsights
status_insights?: AdminFileDetailStatusInsights
timeline?: AdminFileDetailTimelineItem[]
} }
export interface AdminFileDetailViewItem extends AdminFileViewItem { export interface AdminFileDetailViewItem extends AdminFileViewItem {
@@ -187,6 +228,12 @@ export interface AdminFileDetailViewItem extends AdminFileViewItem {
filePathValue?: string | null filePathValue?: string | null
uuidFileNameValue?: string | null uuidFileNameValue?: string | null
uploadIdValue?: string | null uploadIdValue?: string | null
statusInsightSeverity: AdminFileInsightSeverity
statusInsightState: string
statusInsightNextAction: string
statusInsightReasons: string[]
statusInsightMetrics?: AdminFileDetailInsightMetrics
detailTimeline: AdminFileDetailTimelineViewItem[]
} }
export interface AdminBatchDeleteFileFailure { export interface AdminBatchDeleteFileFailure {
+148
View File
@@ -585,6 +585,43 @@
</button> </button>
</div> </div>
<section
class="rounded-lg border px-4 py-4"
:class="getInsightPanelClass(selectedFileDetail.statusInsightSeverity)"
>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<p class="text-xs font-medium uppercase" :class="[mutedTextClass]">
{{ t('fileManage.statusInsight') }}
</p>
<h4 class="mt-1 text-base font-semibold" :class="[primaryTextClass]">
{{ getInsightStateLabel(selectedFileDetail.statusInsightState) }}
</h4>
<p class="mt-1 text-sm" :class="[mutedTextClass]">
{{ t('fileManage.nextAction') }}:
{{ getInsightActionLabel(selectedFileDetail.statusInsightNextAction) }}
</p>
</div>
<span
class="inline-flex w-fit items-center rounded-full px-2.5 py-1 text-xs font-medium"
:class="getInsightBadgeClass(selectedFileDetail.statusInsightSeverity)"
>
{{ getInsightSeverityLabel(selectedFileDetail.statusInsightSeverity) }}
</span>
</div>
<div v-if="detailInsightReasonLabels.length > 0" class="mt-3 flex flex-wrap gap-2">
<span
v-for="reason in detailInsightReasonLabels"
:key="reason"
class="rounded-full px-2.5 py-1 text-xs font-medium"
:class="isDarkMode ? 'bg-gray-700 text-gray-300' : 'bg-white text-gray-600 shadow-sm'"
>
{{ reason }}
</span>
</div>
</section>
<section class="space-y-3"> <section class="space-y-3">
<h4 class="text-sm font-semibold" :class="[primaryTextClass]"> <h4 class="text-sm font-semibold" :class="[primaryTextClass]">
{{ t('fileManage.overview') }} {{ t('fileManage.overview') }}
@@ -623,6 +660,52 @@
</div> </div>
</section> </section>
<section v-if="selectedFileDetail.detailTimeline.length > 0" class="space-y-3">
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
{{ t('fileManage.lifecycle') }}
</h4>
<div
class="rounded-lg border px-4 py-4"
:class="[isDarkMode ? 'border-gray-700 bg-gray-700/30' : 'border-gray-200 bg-white']"
>
<div
v-for="(item, index) in selectedFileDetail.detailTimeline"
:key="`${item.key}-${index}`"
class="grid grid-cols-[auto_minmax(0,1fr)] gap-3"
>
<div class="flex flex-col items-center">
<span
class="mt-1 h-3 w-3 rounded-full"
:class="getTimelineDotClass(item.severity)"
/>
<span
v-if="index < selectedFileDetail.detailTimeline.length - 1"
class="my-1 w-px flex-1"
:class="[isDarkMode ? 'bg-gray-600' : 'bg-gray-200']"
/>
</div>
<div
class="mb-3 rounded-lg border px-3 py-2 last:mb-0"
:class="[
isDarkMode ? 'border-gray-700 bg-gray-800/60' : 'border-gray-100 bg-gray-50'
]"
>
<div class="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<p class="text-sm font-medium" :class="[primaryTextClass]">
{{ item.displayTitle }}
</p>
<p class="text-xs" :class="[mutedTextClass]">
{{ item.displayMeta }}
</p>
</div>
<p class="mt-1 text-sm" :class="[mutedTextClass]">
{{ item.displayDescription }}
</p>
</div>
</div>
</div>
</section>
<section class="space-y-3"> <section class="space-y-3">
<h4 class="text-sm font-semibold" :class="[primaryTextClass]"> <h4 class="text-sm font-semibold" :class="[primaryTextClass]">
{{ t('fileManage.storageInfo') }} {{ t('fileManage.storageInfo') }}
@@ -885,6 +968,7 @@ import { computed, onMounted, type Component } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { import type {
AdminBatchEditMode, AdminBatchEditMode,
AdminFileInsightSeverity,
AdminFileSortBy, AdminFileSortBy,
AdminFileSortOrder, AdminFileSortOrder,
AdminFileStatusFilter, AdminFileStatusFilter,
@@ -1106,6 +1190,13 @@ const detailStorageItems = computed<DetailInfoItem[]>(() => {
] ]
}) })
const detailInsightReasonLabels = computed(() => {
const file = selectedFileDetail.value
if (!file) return []
return file.statusInsightReasons.map((reason) => t(`fileManage.insightReasons.${reason}`))
})
const openDetailEditModal = () => { const openDetailEditModal = () => {
if (!selectedFileDetail.value) return if (!selectedFileDetail.value) return
@@ -1246,6 +1337,63 @@ const getStatusBadgeClass = (file: AdminFileViewItem) => {
return isDarkMode.value ? 'bg-green-900/30 text-green-300' : 'bg-green-100 text-green-700' return isDarkMode.value ? 'bg-green-900/30 text-green-300' : 'bg-green-100 text-green-700'
} }
const getInsightSeverityLabel = (severity: AdminFileInsightSeverity) =>
t(`fileManage.insightSeverity.${severity}`)
const getInsightStateLabel = (state: string) => t(`fileManage.insightStates.${state}`)
const getInsightActionLabel = (action: string) => t(`fileManage.insightActions.${action}`)
const getInsightPanelClass = (severity: AdminFileInsightSeverity) => {
const darkClasses: Record<AdminFileInsightSeverity, string> = {
success: 'border-emerald-500/20 bg-emerald-500/10',
warning: 'border-amber-500/20 bg-amber-500/10',
danger: 'border-red-500/20 bg-red-500/10',
info: 'border-blue-500/20 bg-blue-500/10',
neutral: 'border-gray-700 bg-gray-700/30'
}
const lightClasses: Record<AdminFileInsightSeverity, string> = {
success: 'border-emerald-100 bg-emerald-50',
warning: 'border-amber-100 bg-amber-50',
danger: 'border-red-100 bg-red-50',
info: 'border-blue-100 bg-blue-50',
neutral: 'border-gray-200 bg-gray-50'
}
return isDarkMode.value ? darkClasses[severity] : lightClasses[severity]
}
const getInsightBadgeClass = (severity: AdminFileInsightSeverity) => {
const darkClasses: Record<AdminFileInsightSeverity, string> = {
success: 'bg-emerald-900/40 text-emerald-200',
warning: 'bg-amber-900/40 text-amber-200',
danger: 'bg-red-900/40 text-red-200',
info: 'bg-blue-900/40 text-blue-200',
neutral: 'bg-gray-700 text-gray-300'
}
const lightClasses: Record<AdminFileInsightSeverity, string> = {
success: 'bg-emerald-100 text-emerald-700',
warning: 'bg-amber-100 text-amber-700',
danger: 'bg-red-100 text-red-700',
info: 'bg-blue-100 text-blue-700',
neutral: 'bg-gray-100 text-gray-700'
}
return isDarkMode.value ? darkClasses[severity] : lightClasses[severity]
}
const getTimelineDotClass = (severity: AdminFileInsightSeverity) => {
const classes: Record<AdminFileInsightSeverity, string> = {
success: 'bg-emerald-500',
warning: 'bg-amber-500',
danger: 'bg-red-500',
info: 'bg-blue-500',
neutral: isDarkMode.value ? 'bg-gray-500' : 'bg-gray-300'
}
return classes[severity]
}
onMounted(() => { onMounted(() => {
void loadFiles() void loadFiles()
}) })