feat: show admin file detail lifecycle
This commit is contained in:
@@ -8,7 +8,9 @@ import type {
|
||||
AdminBatchUpdateFilesRequest,
|
||||
AdminBatchUpdateFilesResponse,
|
||||
AdminFileDetailResponse,
|
||||
AdminFileDetailTimelineItem,
|
||||
AdminFileDetailViewItem,
|
||||
AdminFileInsightSeverity,
|
||||
AdminFilePatchPayload,
|
||||
AdminFileListParams,
|
||||
AdminFileStatusFilter,
|
||||
@@ -20,7 +22,7 @@ import type {
|
||||
FileListItem
|
||||
} from '@/types'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import { formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common'
|
||||
import { formatDuration, formatFileSize, formatTimestamp, getErrorMessage } from '@/utils/common'
|
||||
import {
|
||||
downloadAdminManagedFile,
|
||||
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 = (
|
||||
file: FileListItem | AdminFileDetailResponse
|
||||
): AdminFileDetailViewItem => {
|
||||
@@ -260,6 +317,12 @@ export function useAdminFiles() {
|
||||
const storageBackendValue =
|
||||
storage?.backend ?? detail.storageBackend ?? detail.storage_backend ?? '-'
|
||||
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 {
|
||||
...viewItem,
|
||||
@@ -283,7 +346,13 @@ export function useAdminFiles() {
|
||||
storage?.uuid_file_name ??
|
||||
detail.uuidFileName ??
|
||||
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 || [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -452,6 +452,9 @@ export default {
|
||||
overview: 'Overview',
|
||||
policyInfo: 'Policy',
|
||||
storageInfo: 'Storage',
|
||||
statusInsight: 'Status Insight',
|
||||
nextAction: 'Next Action',
|
||||
lifecycle: 'Lifecycle',
|
||||
permanent: 'Permanent',
|
||||
createdAt: 'Created Time',
|
||||
remainingDownloads: 'Remaining Downloads',
|
||||
@@ -468,6 +471,70 @@ export default {
|
||||
uploadId: 'Upload Session',
|
||||
yes: 'Yes',
|
||||
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: {
|
||||
select: 'Select',
|
||||
code: 'Retrieve Code',
|
||||
|
||||
@@ -450,6 +450,9 @@ export default {
|
||||
overview: '概览',
|
||||
policyInfo: '策略信息',
|
||||
storageInfo: '存储信息',
|
||||
statusInsight: '状态洞察',
|
||||
nextAction: '建议动作',
|
||||
lifecycle: '生命周期',
|
||||
permanent: '永久有效',
|
||||
createdAt: '创建时间',
|
||||
remainingDownloads: '剩余次数',
|
||||
@@ -466,6 +469,70 @@ export default {
|
||||
uploadId: '上传会话',
|
||||
yes: '是',
|
||||
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: {
|
||||
select: '选择',
|
||||
code: '取件码',
|
||||
|
||||
@@ -145,6 +145,44 @@ export interface AdminFileDetailStorage {
|
||||
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 {
|
||||
filename?: string
|
||||
displayName?: string
|
||||
@@ -171,6 +209,9 @@ export interface AdminFileDetailResponse extends FileListItem {
|
||||
upload_id?: string | null
|
||||
policy?: AdminFileDetailPolicy
|
||||
storage?: AdminFileDetailStorage
|
||||
statusInsights?: AdminFileDetailStatusInsights
|
||||
status_insights?: AdminFileDetailStatusInsights
|
||||
timeline?: AdminFileDetailTimelineItem[]
|
||||
}
|
||||
|
||||
export interface AdminFileDetailViewItem extends AdminFileViewItem {
|
||||
@@ -187,6 +228,12 @@ export interface AdminFileDetailViewItem extends AdminFileViewItem {
|
||||
filePathValue?: string | null
|
||||
uuidFileNameValue?: string | null
|
||||
uploadIdValue?: string | null
|
||||
statusInsightSeverity: AdminFileInsightSeverity
|
||||
statusInsightState: string
|
||||
statusInsightNextAction: string
|
||||
statusInsightReasons: string[]
|
||||
statusInsightMetrics?: AdminFileDetailInsightMetrics
|
||||
detailTimeline: AdminFileDetailTimelineViewItem[]
|
||||
}
|
||||
|
||||
export interface AdminBatchDeleteFileFailure {
|
||||
|
||||
@@ -585,6 +585,43 @@
|
||||
</button>
|
||||
</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">
|
||||
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('fileManage.overview') }}
|
||||
@@ -623,6 +660,52 @@
|
||||
</div>
|
||||
</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">
|
||||
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('fileManage.storageInfo') }}
|
||||
@@ -885,6 +968,7 @@ import { computed, onMounted, type Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type {
|
||||
AdminBatchEditMode,
|
||||
AdminFileInsightSeverity,
|
||||
AdminFileSortBy,
|
||||
AdminFileSortOrder,
|
||||
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 = () => {
|
||||
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'
|
||||
}
|
||||
|
||||
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(() => {
|
||||
void loadFiles()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user