diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts index 7b5a298..50224eb 100644 --- a/src/composables/useAdminFiles.ts +++ b/src/composables/useAdminFiles.ts @@ -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 || []) } } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index afd7028..dba52f4 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e0d5c1d..5a47277 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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: '取件码', diff --git a/src/types/file.ts b/src/types/file.ts index 38ce03a..4636a22 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -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 { diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index 89b8fc7..5b7adff 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -585,6 +585,43 @@ +
+
+
+

+ {{ t('fileManage.statusInsight') }} +

+

+ {{ getInsightStateLabel(selectedFileDetail.statusInsightState) }} +

+

+ {{ t('fileManage.nextAction') }}: + {{ getInsightActionLabel(selectedFileDetail.statusInsightNextAction) }} +

+
+ + {{ getInsightSeverityLabel(selectedFileDetail.statusInsightSeverity) }} + +
+ +
+ + {{ reason }} + +
+
+

{{ t('fileManage.overview') }} @@ -623,6 +660,52 @@

+
+

+ {{ t('fileManage.lifecycle') }} +

+
+
+
+ + +
+
+
+

+ {{ item.displayTitle }} +

+

+ {{ item.displayMeta }} +

+
+

+ {{ item.displayDescription }} +

+
+
+
+
+

{{ 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(() => { ] }) +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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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() })