From db3bf25d66b6bbb322811b12b6ffc989f77b1ded Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 06:37:56 +0800 Subject: [PATCH] feat: add admin file metadata --- src/composables/useAdminFiles.ts | 83 +++++++++++++++++++++++++++-- src/i18n/locales/en-US.ts | 12 +++++ src/i18n/locales/zh-CN.ts | 11 ++++ src/services/file.ts | 15 ++++++ src/types/file.ts | 22 ++++++++ src/views/manage/FileManageView.vue | 69 ++++++++++++++++++++++++ 6 files changed, 207 insertions(+), 5 deletions(-) diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts index b71cc44..8b15aca 100644 --- a/src/composables/useAdminFiles.ts +++ b/src/composables/useAdminFiles.ts @@ -12,6 +12,7 @@ import type { AdminFileDetailResponse, AdminFileDetailTimelineItem, AdminFileDetailViewItem, + AdminFileMetadataRequest, AdminFileInsightSeverity, AdminFilePatchPayload, AdminFileHealthFilter, @@ -97,6 +98,13 @@ const emptyBatchEditForm = (): AdminBatchEditForm => ({ expired_count: null }) +const metadataTagSeparatorPattern = /[,,\n]+/ + +const emptyDetailMetadataForm = () => ({ + note: '', + tagsText: '' +}) + const padDatePart = (value: number) => String(value).padStart(2, '0') const formatLocalDateTime = (date: Date) => { @@ -151,6 +159,8 @@ export function useAdminFiles() { const selectedFileDetail = ref(null) const isDetailLoading = ref(false) const isDetailPolicyActionRunning = ref(false) + const isDetailMetadataSaving = ref(false) + const detailMetadataForm = ref(emptyDetailMetadataForm()) const downloadingFileId = ref(null) const selectedFileIds = ref>(new Set()) const isBatchDeleting = ref(false) @@ -460,6 +470,14 @@ export function useAdminFiles() { } }) + const normalizeMetadataTags = (tags: string[] | undefined) => + Array.isArray(tags) ? tags.map((tag) => String(tag).trim()).filter(Boolean) : [] + + const parseMetadataTags = (value: string) => + normalizeMetadataTags(value.split(metadataTagSeparatorPattern)) + + const formatMetadataTags = (tags: string[]) => normalizeMetadataTags(tags).join(', ') + const createFileDetailViewItem = ( file: FileListItem | AdminFileDetailResponse ): AdminFileDetailViewItem => { @@ -511,6 +529,15 @@ export function useAdminFiles() { (viewItem.isExpiredFile ? 'expired' : isPermanentFile ? 'permanent' : 'available') const statusInsightNextAction = statusInsights?.nextAction ?? statusInsights?.next_action ?? 'monitor' + const metadata = detail.metadata ?? detail.meta + const metadataNote = detail.note ?? metadata?.note ?? '' + const metadataTags = normalizeMetadataTags(detail.tags ?? metadata?.tags) + const metadataUpdatedAt = + detail.metadataUpdatedAt ?? + detail.metadata_updated_at ?? + metadata?.updatedAt ?? + metadata?.updated_at ?? + null return { ...viewItem, @@ -535,6 +562,9 @@ export function useAdminFiles() { detail.uuidFileName ?? detail.uuid_file_name, uploadIdValue: storage?.uploadId ?? storage?.upload_id ?? detail.uploadId ?? detail.upload_id, + metadataNote, + metadataTags, + metadataUpdatedAt, statusInsightSeverity: normalizeInsightSeverity(statusInsights?.severity), statusInsightState, statusInsightNextAction, @@ -544,6 +574,19 @@ export function useAdminFiles() { } } + const syncDetailMetadataForm = (file: AdminFileDetailViewItem | null) => { + detailMetadataForm.value = { + note: file?.metadataNote || '', + tagsText: file ? formatMetadataTags(file.metadataTags) : '' + } + } + + const setSelectedFileDetail = (file: FileListItem | AdminFileDetailResponse) => { + const nextFile = createFileDetailViewItem(file) + selectedFileDetail.value = nextFile + syncDetailMetadataForm(nextFile) + } + const syncSelectedFilesWithCurrentPage = () => { const visibleIds = new Set(tableData.value.map((file) => file.id)) selectedFileIds.value = new Set( @@ -725,19 +768,19 @@ export function useAdminFiles() { const refreshSelectedFileDetail = async (fileId: number, detail?: AdminFileDetailResponse) => { if (detail) { - selectedFileDetail.value = createFileDetailViewItem(detail) + setSelectedFileDetail(detail) return } const fallbackFile = tableData.value.find((file) => file.id === fileId) if (fallbackFile) { - selectedFileDetail.value = createFileDetailViewItem(fallbackFile) + setSelectedFileDetail(fallbackFile) } try { const response = await FileService.getAdminFileDetail(fileId) if (response.detail) { - selectedFileDetail.value = createFileDetailViewItem(response.detail) + setSelectedFileDetail(response.detail) } } catch (error: unknown) { if (!isLegacyEndpointUnavailable(error)) { @@ -1146,14 +1189,14 @@ export function useAdminFiles() { const openFileDetail = async (file: AdminFileViewItem) => { const requestSerial = ++detailRequestSerial - selectedFileDetail.value = createFileDetailViewItem(file) + setSelectedFileDetail(file) showFileDetailModal.value = true isDetailLoading.value = true try { const response = await FileService.getAdminFileDetail(file.id) if (response.detail && requestSerial === detailRequestSerial) { - selectedFileDetail.value = createFileDetailViewItem(response.detail) + setSelectedFileDetail(response.detail) } } catch (error: unknown) { if (isLegacyEndpointUnavailable(error)) return @@ -1196,10 +1239,37 @@ export function useAdminFiles() { } } + const updateDetailMetadata = async () => { + const file = selectedFileDetail.value + if (!file || isDetailMetadataSaving.value) return + + const payload: AdminFileMetadataRequest = { + id: file.id, + note: detailMetadataForm.value.note, + tags: parseMetadataTags(detailMetadataForm.value.tagsText) + } + + isDetailMetadataSaving.value = true + try { + const response = await FileService.updateAdminFileMetadata(payload) + if (response.detail) { + setSelectedFileDetail(response.detail) + } else { + await refreshSelectedFileDetail(file.id) + } + alertStore.showAlert(t('fileManage.metadataSaveSuccess'), 'success') + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, t('fileManage.metadataSaveFailed')), 'error') + } finally { + isDetailMetadataSaving.value = false + } + } + const closeFileDetail = () => { detailRequestSerial += 1 showFileDetailModal.value = false selectedFileDetail.value = null + syncDetailMetadataForm(null) isDetailLoading.value = false } @@ -1268,12 +1338,14 @@ export function useAdminFiles() { isBatchUpdating, isCurrentPagePartiallySelected, isDetailLoading, + isDetailMetadataSaving, isDetailPolicyActionRunning, isPreviewLoading, isSaving, batchEditForm, batchPolicyActionOptions, detailPolicyActionOptions, + detailMetadataForm, downloadingFileId, healthFilterOptions, hasSelectedFiles, @@ -1320,6 +1392,7 @@ export function useAdminFiles() { setHealthFilter, setStatusFilter, setTypeFilter, + updateDetailMetadata, toggleCurrentPageSelection, toggleFileSelection } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index e178d84..5e26fd7 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -488,6 +488,18 @@ export default { batchPolicyActionFailed: 'Batch policy action failed', policyActionSuccess: 'File policy updated', policyActionFailed: 'Failed to apply policy action', + metadataInfo: 'Operations Notes', + metadataHint: + 'Visible to admins only. Use it for handling notes, archive tags, and follow-up actions.', + metadataNote: 'Note', + metadataNotePlaceholder: 'Record context, handling history, or ownership...', + metadataTags: 'Tags', + metadataTagsPlaceholder: 'Comma-separated, e.g. Client A, Renew, Important', + metadataUpdatedAt: 'Last saved: {time}', + metadataNeverUpdated: 'No notes saved yet', + saveMetadata: 'Save Notes', + metadataSaveSuccess: 'Notes and tags saved', + metadataSaveFailed: 'Failed to save notes', policyActions: { extend24h: 'Extend 24h', extend7d: 'Extend 7d', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e25b470..8e8d916 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -486,6 +486,17 @@ export default { batchPolicyActionFailed: '批量策略动作执行失败', policyActionSuccess: '文件策略已更新', policyActionFailed: '策略动作执行失败', + metadataInfo: '运营备注', + metadataHint: '仅管理员可见,用于记录处理线索、归档标签和后续动作。', + metadataNote: '备注', + metadataNotePlaceholder: '记录这个分享的背景、处理记录或负责人...', + metadataTags: '标签', + metadataTagsPlaceholder: '逗号分隔,例如:客户A, 待续期, 重要', + metadataUpdatedAt: '最近保存:{time}', + metadataNeverUpdated: '尚未保存备注', + saveMetadata: '保存备注', + metadataSaveSuccess: '备注和标签已保存', + metadataSaveFailed: '备注保存失败', policyActions: { extend24h: '延长 24 小时', extend7d: '延长 7 天', diff --git a/src/services/file.ts b/src/services/file.ts index d194f1e..8b661d7 100644 --- a/src/services/file.ts +++ b/src/services/file.ts @@ -9,6 +9,7 @@ import type { AdminFilePatchPayload, AdminFileDetailResponse, AdminFileListParams, + AdminFileMetadataRequest, AdminFilePolicyActionRequest, AdminFilePreviewResponse, ApiResponse, @@ -175,6 +176,20 @@ export class FileService { } } + static async updateAdminFileMetadata( + data: AdminFileMetadataRequest + ): Promise> { + try { + return await api.patch('/admin/file/metadata', data) + } catch (error: unknown) { + if (!isMethodFallbackError(error)) { + throw error + } + + return api.post('/admin/file/metadata', data) + } + } + static async updateAdminFiles( data: AdminBatchUpdateFilesRequest ): Promise> { diff --git a/src/types/file.ts b/src/types/file.ts index 0685aa1..371d2c1 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -133,6 +133,19 @@ export interface AdminFilePolicyActionRequest { download_limit?: number } +export interface AdminFileMetadata { + note: string + tags: string[] + updatedAt?: string | null + updated_at?: string | null +} + +export interface AdminFileMetadataRequest { + id: number + note?: string + tags?: string[] +} + export interface FileListResponse { data: FileListItem[] total: number @@ -250,6 +263,12 @@ export interface AdminFileDetailResponse extends FileListItem { upload_id?: string | null policy?: AdminFileDetailPolicy storage?: AdminFileDetailStorage + metadata?: AdminFileMetadata + meta?: AdminFileMetadata + note?: string + tags?: string[] + metadataUpdatedAt?: string | null + metadata_updated_at?: string | null statusInsights?: AdminFileDetailStatusInsights status_insights?: AdminFileDetailStatusInsights timeline?: AdminFileDetailTimelineItem[] @@ -269,6 +288,9 @@ export interface AdminFileDetailViewItem extends AdminFileViewItem { filePathValue?: string | null uuidFileNameValue?: string | null uploadIdValue?: string | null + metadataNote: string + metadataTags: string[] + metadataUpdatedAt: string | null statusInsightMetrics?: AdminFileDetailInsightMetrics detailTimeline: AdminFileDetailTimelineViewItem[] } diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index 6bd359d..0b30edd 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -634,6 +634,67 @@ +
+
+
+

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

+

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

+
+ + + {{ t('fileManage.saveMetadata') }} + +
+ +
+ + +
+
+
? 'border-gray-700 bg-gray-800/70 text-gray-200 hover:border-blue-500/40 hover:bg-blue-500/10' : 'border-gray-200 bg-white text-gray-700 hover:border-blue-200 hover:bg-blue-50' ) +const subtleSectionClass = computed(() => + isDarkMode.value ? 'border-gray-700 bg-gray-900/30' : 'border-gray-200 bg-gray-50' +) type DetailInfoItem = { label: string @@ -1200,6 +1268,7 @@ const formatDetailValue = (value: string | number | null | undefined) => { if (value === null || value === undefined || value === '') return emptyDetailValue return String(value) } +const formatMetadataUpdatedAt = (value: string) => formatTimestamp(value) const detailOverviewItems = computed(() => { const file = selectedFileDetail.value