feat: add admin file metadata

This commit is contained in:
Lan
2026-06-03 06:37:56 +08:00
parent e2318f007d
commit db3bf25d66
6 changed files with 207 additions and 5 deletions
+78 -5
View File
@@ -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<AdminFileDetailViewItem | null>(null)
const isDetailLoading = ref(false)
const isDetailPolicyActionRunning = ref(false)
const isDetailMetadataSaving = ref(false)
const detailMetadataForm = ref(emptyDetailMetadataForm())
const downloadingFileId = ref<number | null>(null)
const selectedFileIds = ref<Set<number>>(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
}
+12
View File
@@ -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',
+11
View File
@@ -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 天',
+15
View File
@@ -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<ApiResponse<AdminFileDetailResponse>> {
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<ApiResponse<AdminBatchUpdateFilesResponse>> {
+22
View File
@@ -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[]
}
+69
View File
@@ -634,6 +634,67 @@
</button>
</div>
<section class="space-y-3 rounded-lg border px-4 py-4" :class="[subtleSectionClass]">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
{{ t('fileManage.metadataInfo') }}
</h4>
<p class="mt-1 text-xs" :class="[mutedTextClass]">
{{ t('fileManage.metadataHint') }}
</p>
</div>
<BaseButton
size="sm"
:loading="isDetailMetadataSaving"
:disabled="isDetailLoading"
@click="updateDetailMetadata"
>
<template #icon>
<CheckIcon class="mr-2 h-4 w-4" />
</template>
{{ t('fileManage.saveMetadata') }}
</BaseButton>
</div>
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,0.55fr)]">
<label class="space-y-1">
<span class="text-xs font-medium" :class="[mutedTextClass]">
{{ t('fileManage.metadataNote') }}
</span>
<textarea
v-model="detailMetadataForm.note"
rows="4"
maxlength="2000"
class="w-full resize-y rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
:placeholder="t('fileManage.metadataNotePlaceholder')"
></textarea>
</label>
<label class="space-y-1">
<span class="text-xs font-medium" :class="[mutedTextClass]">
{{ t('fileManage.metadataTags') }}
</span>
<input
v-model="detailMetadataForm.tagsText"
type="text"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
:placeholder="t('fileManage.metadataTagsPlaceholder')"
/>
<span class="block text-xs" :class="[mutedTextClass]">
{{
selectedFileDetail.metadataUpdatedAt
? t('fileManage.metadataUpdatedAt', {
time: formatMetadataUpdatedAt(selectedFileDetail.metadataUpdatedAt)
})
: t('fileManage.metadataNeverUpdated')
}}
</span>
</label>
</div>
</section>
<section
class="rounded-lg border px-4 py-4"
:class="getInsightPanelClass(selectedFileDetail.statusInsightSeverity)"
@@ -1082,6 +1143,7 @@ import FileEditField from '@/components/common/FileEditField.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import BaseButton from '@/components/common/BaseButton.vue'
import { useAdminFiles, useInjectedDarkMode } from '@/composables'
import { formatTimestamp } from '@/utils/common'
const { t } = useI18n()
const isDarkMode = useInjectedDarkMode()
@@ -1112,12 +1174,14 @@ const {
isBatchUpdating,
isCurrentPagePartiallySelected,
isDetailLoading,
isDetailMetadataSaving,
isDetailPolicyActionRunning,
isPreviewLoading,
isSaving,
batchEditForm,
batchPolicyActionOptions,
detailPolicyActionOptions,
detailMetadataForm,
downloadingFileId,
healthFilterOptions,
hasSelectedFiles,
@@ -1163,6 +1227,7 @@ const {
setHealthFilter,
setStatusFilter,
setTypeFilter,
updateDetailMetadata,
toggleCurrentPageSelection,
toggleFileSelection
} = useAdminFiles()
@@ -1187,6 +1252,9 @@ const detailPolicyActionClass = computed(() =>
? '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<DetailInfoItem[]>(() => {
const file = selectedFileDetail.value