feat: add admin file metadata

This commit is contained in:
Lan
2026-06-03 06:37:56 +08:00
parent 24b92fe1ac
commit d211be3b20
6 changed files with 207 additions and 5 deletions
+78 -5
View File
@@ -12,6 +12,7 @@ import type {
AdminFileDetailResponse, AdminFileDetailResponse,
AdminFileDetailTimelineItem, AdminFileDetailTimelineItem,
AdminFileDetailViewItem, AdminFileDetailViewItem,
AdminFileMetadataRequest,
AdminFileInsightSeverity, AdminFileInsightSeverity,
AdminFilePatchPayload, AdminFilePatchPayload,
AdminFileHealthFilter, AdminFileHealthFilter,
@@ -97,6 +98,13 @@ const emptyBatchEditForm = (): AdminBatchEditForm => ({
expired_count: null expired_count: null
}) })
const metadataTagSeparatorPattern = /[,\n]+/
const emptyDetailMetadataForm = () => ({
note: '',
tagsText: ''
})
const padDatePart = (value: number) => String(value).padStart(2, '0') const padDatePart = (value: number) => String(value).padStart(2, '0')
const formatLocalDateTime = (date: Date) => { const formatLocalDateTime = (date: Date) => {
@@ -151,6 +159,8 @@ export function useAdminFiles() {
const selectedFileDetail = ref<AdminFileDetailViewItem | null>(null) const selectedFileDetail = ref<AdminFileDetailViewItem | null>(null)
const isDetailLoading = ref(false) const isDetailLoading = ref(false)
const isDetailPolicyActionRunning = ref(false) const isDetailPolicyActionRunning = ref(false)
const isDetailMetadataSaving = ref(false)
const detailMetadataForm = ref(emptyDetailMetadataForm())
const downloadingFileId = ref<number | null>(null) const downloadingFileId = ref<number | null>(null)
const selectedFileIds = ref<Set<number>>(new Set()) const selectedFileIds = ref<Set<number>>(new Set())
const isBatchDeleting = ref(false) 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 = ( const createFileDetailViewItem = (
file: FileListItem | AdminFileDetailResponse file: FileListItem | AdminFileDetailResponse
): AdminFileDetailViewItem => { ): AdminFileDetailViewItem => {
@@ -511,6 +529,15 @@ export function useAdminFiles() {
(viewItem.isExpiredFile ? 'expired' : isPermanentFile ? 'permanent' : 'available') (viewItem.isExpiredFile ? 'expired' : isPermanentFile ? 'permanent' : 'available')
const statusInsightNextAction = const statusInsightNextAction =
statusInsights?.nextAction ?? statusInsights?.next_action ?? 'monitor' 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 { return {
...viewItem, ...viewItem,
@@ -535,6 +562,9 @@ export function useAdminFiles() {
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,
metadataNote,
metadataTags,
metadataUpdatedAt,
statusInsightSeverity: normalizeInsightSeverity(statusInsights?.severity), statusInsightSeverity: normalizeInsightSeverity(statusInsights?.severity),
statusInsightState, statusInsightState,
statusInsightNextAction, 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 syncSelectedFilesWithCurrentPage = () => {
const visibleIds = new Set(tableData.value.map((file) => file.id)) const visibleIds = new Set(tableData.value.map((file) => file.id))
selectedFileIds.value = new Set( selectedFileIds.value = new Set(
@@ -725,19 +768,19 @@ export function useAdminFiles() {
const refreshSelectedFileDetail = async (fileId: number, detail?: AdminFileDetailResponse) => { const refreshSelectedFileDetail = async (fileId: number, detail?: AdminFileDetailResponse) => {
if (detail) { if (detail) {
selectedFileDetail.value = createFileDetailViewItem(detail) setSelectedFileDetail(detail)
return return
} }
const fallbackFile = tableData.value.find((file) => file.id === fileId) const fallbackFile = tableData.value.find((file) => file.id === fileId)
if (fallbackFile) { if (fallbackFile) {
selectedFileDetail.value = createFileDetailViewItem(fallbackFile) setSelectedFileDetail(fallbackFile)
} }
try { try {
const response = await FileService.getAdminFileDetail(fileId) const response = await FileService.getAdminFileDetail(fileId)
if (response.detail) { if (response.detail) {
selectedFileDetail.value = createFileDetailViewItem(response.detail) setSelectedFileDetail(response.detail)
} }
} catch (error: unknown) { } catch (error: unknown) {
if (!isLegacyEndpointUnavailable(error)) { if (!isLegacyEndpointUnavailable(error)) {
@@ -1146,14 +1189,14 @@ export function useAdminFiles() {
const openFileDetail = async (file: AdminFileViewItem) => { const openFileDetail = async (file: AdminFileViewItem) => {
const requestSerial = ++detailRequestSerial const requestSerial = ++detailRequestSerial
selectedFileDetail.value = createFileDetailViewItem(file) setSelectedFileDetail(file)
showFileDetailModal.value = true showFileDetailModal.value = true
isDetailLoading.value = true isDetailLoading.value = true
try { try {
const response = await FileService.getAdminFileDetail(file.id) const response = await FileService.getAdminFileDetail(file.id)
if (response.detail && requestSerial === detailRequestSerial) { if (response.detail && requestSerial === detailRequestSerial) {
selectedFileDetail.value = createFileDetailViewItem(response.detail) setSelectedFileDetail(response.detail)
} }
} catch (error: unknown) { } catch (error: unknown) {
if (isLegacyEndpointUnavailable(error)) return 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 = () => { const closeFileDetail = () => {
detailRequestSerial += 1 detailRequestSerial += 1
showFileDetailModal.value = false showFileDetailModal.value = false
selectedFileDetail.value = null selectedFileDetail.value = null
syncDetailMetadataForm(null)
isDetailLoading.value = false isDetailLoading.value = false
} }
@@ -1268,12 +1338,14 @@ export function useAdminFiles() {
isBatchUpdating, isBatchUpdating,
isCurrentPagePartiallySelected, isCurrentPagePartiallySelected,
isDetailLoading, isDetailLoading,
isDetailMetadataSaving,
isDetailPolicyActionRunning, isDetailPolicyActionRunning,
isPreviewLoading, isPreviewLoading,
isSaving, isSaving,
batchEditForm, batchEditForm,
batchPolicyActionOptions, batchPolicyActionOptions,
detailPolicyActionOptions, detailPolicyActionOptions,
detailMetadataForm,
downloadingFileId, downloadingFileId,
healthFilterOptions, healthFilterOptions,
hasSelectedFiles, hasSelectedFiles,
@@ -1320,6 +1392,7 @@ export function useAdminFiles() {
setHealthFilter, setHealthFilter,
setStatusFilter, setStatusFilter,
setTypeFilter, setTypeFilter,
updateDetailMetadata,
toggleCurrentPageSelection, toggleCurrentPageSelection,
toggleFileSelection toggleFileSelection
} }
+12
View File
@@ -488,6 +488,18 @@ export default {
batchPolicyActionFailed: 'Batch policy action failed', batchPolicyActionFailed: 'Batch policy action failed',
policyActionSuccess: 'File policy updated', policyActionSuccess: 'File policy updated',
policyActionFailed: 'Failed to apply policy action', 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: { policyActions: {
extend24h: 'Extend 24h', extend24h: 'Extend 24h',
extend7d: 'Extend 7d', extend7d: 'Extend 7d',
+11
View File
@@ -486,6 +486,17 @@ export default {
batchPolicyActionFailed: '批量策略动作执行失败', batchPolicyActionFailed: '批量策略动作执行失败',
policyActionSuccess: '文件策略已更新', policyActionSuccess: '文件策略已更新',
policyActionFailed: '策略动作执行失败', policyActionFailed: '策略动作执行失败',
metadataInfo: '运营备注',
metadataHint: '仅管理员可见,用于记录处理线索、归档标签和后续动作。',
metadataNote: '备注',
metadataNotePlaceholder: '记录这个分享的背景、处理记录或负责人...',
metadataTags: '标签',
metadataTagsPlaceholder: '逗号分隔,例如:客户A, 待续期, 重要',
metadataUpdatedAt: '最近保存:{time}',
metadataNeverUpdated: '尚未保存备注',
saveMetadata: '保存备注',
metadataSaveSuccess: '备注和标签已保存',
metadataSaveFailed: '备注保存失败',
policyActions: { policyActions: {
extend24h: '延长 24 小时', extend24h: '延长 24 小时',
extend7d: '延长 7 天', extend7d: '延长 7 天',
+15
View File
@@ -9,6 +9,7 @@ import type {
AdminFilePatchPayload, AdminFilePatchPayload,
AdminFileDetailResponse, AdminFileDetailResponse,
AdminFileListParams, AdminFileListParams,
AdminFileMetadataRequest,
AdminFilePolicyActionRequest, AdminFilePolicyActionRequest,
AdminFilePreviewResponse, AdminFilePreviewResponse,
ApiResponse, 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( static async updateAdminFiles(
data: AdminBatchUpdateFilesRequest data: AdminBatchUpdateFilesRequest
): Promise<ApiResponse<AdminBatchUpdateFilesResponse>> { ): Promise<ApiResponse<AdminBatchUpdateFilesResponse>> {
+22
View File
@@ -133,6 +133,19 @@ export interface AdminFilePolicyActionRequest {
download_limit?: number 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 { export interface FileListResponse {
data: FileListItem[] data: FileListItem[]
total: number total: number
@@ -250,6 +263,12 @@ export interface AdminFileDetailResponse extends FileListItem {
upload_id?: string | null upload_id?: string | null
policy?: AdminFileDetailPolicy policy?: AdminFileDetailPolicy
storage?: AdminFileDetailStorage storage?: AdminFileDetailStorage
metadata?: AdminFileMetadata
meta?: AdminFileMetadata
note?: string
tags?: string[]
metadataUpdatedAt?: string | null
metadata_updated_at?: string | null
statusInsights?: AdminFileDetailStatusInsights statusInsights?: AdminFileDetailStatusInsights
status_insights?: AdminFileDetailStatusInsights status_insights?: AdminFileDetailStatusInsights
timeline?: AdminFileDetailTimelineItem[] timeline?: AdminFileDetailTimelineItem[]
@@ -269,6 +288,9 @@ export interface AdminFileDetailViewItem extends AdminFileViewItem {
filePathValue?: string | null filePathValue?: string | null
uuidFileNameValue?: string | null uuidFileNameValue?: string | null
uploadIdValue?: string | null uploadIdValue?: string | null
metadataNote: string
metadataTags: string[]
metadataUpdatedAt: string | null
statusInsightMetrics?: AdminFileDetailInsightMetrics statusInsightMetrics?: AdminFileDetailInsightMetrics
detailTimeline: AdminFileDetailTimelineViewItem[] detailTimeline: AdminFileDetailTimelineViewItem[]
} }
+69
View File
@@ -634,6 +634,67 @@
</button> </button>
</div> </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 <section
class="rounded-lg border px-4 py-4" class="rounded-lg border px-4 py-4"
:class="getInsightPanelClass(selectedFileDetail.statusInsightSeverity)" :class="getInsightPanelClass(selectedFileDetail.statusInsightSeverity)"
@@ -1082,6 +1143,7 @@ import FileEditField from '@/components/common/FileEditField.vue'
import BaseModal from '@/components/common/BaseModal.vue' import BaseModal from '@/components/common/BaseModal.vue'
import BaseButton from '@/components/common/BaseButton.vue' import BaseButton from '@/components/common/BaseButton.vue'
import { useAdminFiles, useInjectedDarkMode } from '@/composables' import { useAdminFiles, useInjectedDarkMode } from '@/composables'
import { formatTimestamp } from '@/utils/common'
const { t } = useI18n() const { t } = useI18n()
const isDarkMode = useInjectedDarkMode() const isDarkMode = useInjectedDarkMode()
@@ -1112,12 +1174,14 @@ const {
isBatchUpdating, isBatchUpdating,
isCurrentPagePartiallySelected, isCurrentPagePartiallySelected,
isDetailLoading, isDetailLoading,
isDetailMetadataSaving,
isDetailPolicyActionRunning, isDetailPolicyActionRunning,
isPreviewLoading, isPreviewLoading,
isSaving, isSaving,
batchEditForm, batchEditForm,
batchPolicyActionOptions, batchPolicyActionOptions,
detailPolicyActionOptions, detailPolicyActionOptions,
detailMetadataForm,
downloadingFileId, downloadingFileId,
healthFilterOptions, healthFilterOptions,
hasSelectedFiles, hasSelectedFiles,
@@ -1163,6 +1227,7 @@ const {
setHealthFilter, setHealthFilter,
setStatusFilter, setStatusFilter,
setTypeFilter, setTypeFilter,
updateDetailMetadata,
toggleCurrentPageSelection, toggleCurrentPageSelection,
toggleFileSelection toggleFileSelection
} = useAdminFiles() } = 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-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' : '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 = { type DetailInfoItem = {
label: string label: string
@@ -1200,6 +1268,7 @@ const formatDetailValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === '') return emptyDetailValue if (value === null || value === undefined || value === '') return emptyDetailValue
return String(value) return String(value)
} }
const formatMetadataUpdatedAt = (value: string) => formatTimestamp(value)
const detailOverviewItems = computed<DetailInfoItem[]>(() => { const detailOverviewItems = computed<DetailInfoItem[]>(() => {
const file = selectedFileDetail.value const file = selectedFileDetail.value