diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts index 50224eb..b0d46c0 100644 --- a/src/composables/useAdminFiles.ts +++ b/src/composables/useAdminFiles.ts @@ -13,6 +13,8 @@ import type { AdminFileInsightSeverity, AdminFilePatchPayload, AdminFileListParams, + AdminFilePolicyAction, + AdminFilePolicyActionRequest, AdminFileStatusFilter, AdminFileSummary, AdminFileTypeFilter, @@ -61,6 +63,14 @@ const isLegacyEndpointUnavailable = (error: unknown) => { } const legacyForeverExpiresAt = '2099-12-31T23:59' +const detailPolicyDownloadLimit = 5 +const dayInMilliseconds = 24 * 60 * 60 * 1000 + +type DetailPolicyActionOption = { + action: AdminFilePolicyAction + label: string + description: string +} const emptyBatchEditForm = (): AdminBatchEditForm => ({ mode: 'expiresAt', @@ -68,6 +78,18 @@ const emptyBatchEditForm = (): AdminBatchEditForm => ({ expired_count: null }) +const padDatePart = (value: number) => String(value).padStart(2, '0') + +const formatLocalDateTime = (date: Date) => { + const year = date.getFullYear() + const month = padDatePart(date.getMonth() + 1) + const day = padDatePart(date.getDate()) + const hours = padDatePart(date.getHours()) + const minutes = padDatePart(date.getMinutes()) + + return `${year}-${month}-${day}T${hours}:${minutes}` +} + export function useAdminFiles() { const { t } = useI18n() const alertStore = useAlertStore() @@ -108,6 +130,7 @@ export function useAdminFiles() { const showFileDetailModal = ref(false) const selectedFileDetail = ref(null) const isDetailLoading = ref(false) + const isDetailPolicyActionRunning = ref(false) const downloadingFileId = ref(null) const selectedFileIds = ref>(new Set()) const isBatchDeleting = ref(false) @@ -145,6 +168,33 @@ export function useAdminFiles() { params.value.type !== 'all' ) + const detailPolicyActionOptions = computed(() => [ + { + action: 'extend_24h', + label: t('fileManage.policyActions.extend24h'), + description: t('fileManage.policyActionDescriptions.extend24h') + }, + { + action: 'extend_7d', + label: t('fileManage.policyActions.extend7d'), + description: t('fileManage.policyActionDescriptions.extend7d') + }, + { + action: 'make_permanent', + label: t('fileManage.policyActions.makePermanent'), + description: t('fileManage.policyActionDescriptions.makePermanent') + }, + { + action: 'reset_download_limit', + label: t('fileManage.policyActions.resetDownloadLimit', { + count: detailPolicyDownloadLimit + }), + description: t('fileManage.policyActionDescriptions.resetDownloadLimit', { + count: detailPolicyDownloadLimit + }) + } + ]) + const inferIsText = (file: FileListItem) => { if (typeof file.isText === 'boolean') return file.isText if (typeof file.is_text === 'boolean') return file.is_text @@ -158,7 +208,7 @@ export function useAdminFiles() { if ( file.expired_count !== null && file.expired_count !== undefined && - file.expired_count <= 0 + file.expired_count === 0 ) { return true } @@ -463,6 +513,84 @@ export function useAdminFiles() { return patchPayload } + const buildPolicyActionRequest = ( + file: AdminFileDetailViewItem, + action: AdminFilePolicyAction + ): AdminFilePolicyActionRequest => { + const request: AdminFilePolicyActionRequest = { + id: file.id, + action + } + + if (action === 'reset_download_limit') { + request.downloadLimit = detailPolicyDownloadLimit + request.download_limit = detailPolicyDownloadLimit + } + + return request + } + + const getPolicyActionBaseDate = (file: AdminFileDetailViewItem) => { + const now = new Date() + const expiredAt = file.expired_at ? new Date(file.expired_at) : null + if (!expiredAt || Number.isNaN(expiredAt.getTime()) || expiredAt.getTime() < now.getTime()) { + return now + } + return expiredAt + } + + const buildLegacyPolicyActionPayload = ( + file: AdminFileDetailViewItem, + action: AdminFilePolicyAction + ): AdminFilePatchPayload => { + if (action === 'make_permanent') { + return { + id: file.id, + expired_at: legacyForeverExpiresAt, + expired_count: -1 + } + } + + if (action === 'reset_download_limit') { + return { + id: file.id, + expired_count: detailPolicyDownloadLimit + } + } + + const offsetDays = action === 'extend_7d' ? 7 : 1 + const nextExpiredAt = new Date( + getPolicyActionBaseDate(file).getTime() + offsetDays * dayInMilliseconds + ) + return { + id: file.id, + expired_at: formatLocalDateTime(nextExpiredAt) + } + } + + const refreshSelectedFileDetail = async (fileId: number, detail?: AdminFileDetailResponse) => { + if (detail) { + selectedFileDetail.value = createFileDetailViewItem(detail) + return + } + + const fallbackFile = tableData.value.find((file) => file.id === fileId) + if (fallbackFile) { + selectedFileDetail.value = createFileDetailViewItem(fallbackFile) + } + + try { + const response = await FileService.getAdminFileDetail(fileId) + if (response.detail) { + selectedFileDetail.value = createFileDetailViewItem(response.detail) + } + } catch (error: unknown) { + if (!isLegacyEndpointUnavailable(error)) { + throw error + } + } + } + const loadFiles = async () => { isLoading.value = true try { @@ -775,6 +903,36 @@ export function useAdminFiles() { } } + const applyDetailPolicyAction = async (action: AdminFilePolicyAction) => { + const file = selectedFileDetail.value + if (!file || isDetailPolicyActionRunning.value) return + + isDetailPolicyActionRunning.value = true + try { + let nextDetail: AdminFileDetailResponse | undefined + + try { + const response = await FileService.applyAdminFilePolicyAction( + buildPolicyActionRequest(file, action) + ) + nextDetail = response.detail + } catch (error: unknown) { + if (!isLegacyEndpointUnavailable(error)) { + throw error + } + await FileService.updateFile(buildLegacyPolicyActionPayload(file, action)) + } + + await loadFiles() + await refreshSelectedFileDetail(file.id, nextDetail) + alertStore.showAlert(t('fileManage.policyActionSuccess'), 'success') + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, t('fileManage.policyActionFailed')), 'error') + } finally { + isDetailPolicyActionRunning.value = false + } + } + const closeFileDetail = () => { detailRequestSerial += 1 showFileDetailModal.value = false @@ -846,9 +1004,11 @@ export function useAdminFiles() { isBatchUpdating, isCurrentPagePartiallySelected, isDetailLoading, + isDetailPolicyActionRunning, isPreviewLoading, isSaving, batchEditForm, + detailPolicyActionOptions, downloadingFileId, hasSelectedFiles, params, @@ -880,6 +1040,7 @@ export function useAdminFiles() { exportPreviewText, handlePageChange, handleSearch, + applyDetailPolicyAction, handleBatchUpdate, handleUpdate, loadFiles, diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index dba52f4..c737179 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -448,6 +448,20 @@ export default { batchUpdatePartialSuccess: 'Updated {count} files, {failed} failed', batchUpdateFailed: 'Batch update failed', batchUpdateNoFields: 'Choose a policy to update', + policyActionSuccess: 'File policy updated', + policyActionFailed: 'Failed to apply policy action', + policyActions: { + extend24h: 'Extend 24h', + extend7d: 'Extend 7d', + makePermanent: 'Make Permanent', + resetDownloadLimit: 'Reset to {count}' + }, + policyActionDescriptions: { + extend24h: 'Add one day from the active expiration', + extend7d: 'Good for a one-week temporary extension', + makePermanent: 'Clear expiration time and retrieval limits', + resetDownloadLimit: 'Restore retrievals to {count}' + }, applyBatchEdit: 'Apply Changes', overview: 'Overview', policyInfo: 'Policy', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 5a47277..b42cae8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -446,6 +446,20 @@ export default { batchUpdatePartialSuccess: '已更新 {count} 个文件,{failed} 个失败', batchUpdateFailed: '批量更新失败', batchUpdateNoFields: '请选择要更新的策略', + policyActionSuccess: '文件策略已更新', + policyActionFailed: '策略动作执行失败', + policyActions: { + extend24h: '延长 24 小时', + extend7d: '延长 7 天', + makePermanent: '设为永久', + resetDownloadLimit: '重置为 {count} 次' + }, + policyActionDescriptions: { + extend24h: '从当前有效期继续追加一天', + extend7d: '适合临时续期一周', + makePermanent: '清空过期时间和次数限制', + resetDownloadLimit: '恢复可取件次数到 {count} 次' + }, applyBatchEdit: '应用更改', overview: '概览', policyInfo: '策略信息', diff --git a/src/services/file.ts b/src/services/file.ts index 456b6f7..382145d 100644 --- a/src/services/file.ts +++ b/src/services/file.ts @@ -7,6 +7,7 @@ import type { AdminFilePatchPayload, AdminFileDetailResponse, AdminFileListParams, + AdminFilePolicyActionRequest, AdminFilePreviewResponse, ApiResponse, ChunkUploadCompleteRequest, @@ -37,6 +38,11 @@ const toUrlEncodedForm = (data: Record) => { return form } +const isMethodFallbackError = (error: unknown) => { + const status = (error as { response?: { status?: number } })?.response?.status + return status === 404 || status === 405 +} + export class FileService { static async uploadFile( file: File, @@ -139,6 +145,20 @@ export class FileService { return api.patch('/admin/file/update', data) } + static async applyAdminFilePolicyAction( + data: AdminFilePolicyActionRequest + ): Promise> { + try { + return await api.patch('/admin/file/policy-action', data) + } catch (error: unknown) { + if (!isMethodFallbackError(error)) { + throw error + } + + return api.post('/admin/file/policy-action', data) + } + } + static async updateAdminFiles( data: AdminBatchUpdateFilesRequest ): Promise> { diff --git a/src/types/file.ts b/src/types/file.ts index 4636a22..876962c 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -92,6 +92,19 @@ export interface AdminFilePatchPayload { expired_count?: number | null } +export type AdminFilePolicyAction = + | 'extend_24h' + | 'extend_7d' + | 'make_permanent' + | 'reset_download_limit' + +export interface AdminFilePolicyActionRequest { + id: number + action: AdminFilePolicyAction + downloadLimit?: number + download_limit?: number +} + export interface FileListResponse { data: FileListItem[] total: number diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index 5b7adff..669677e 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -620,6 +620,35 @@ {{ reason }} + +
+ +
@@ -969,6 +998,7 @@ import { useI18n } from 'vue-i18n' import type { AdminBatchEditMode, AdminFileInsightSeverity, + AdminFilePolicyAction, AdminFileSortBy, AdminFileSortOrder, AdminFileStatusFilter, @@ -987,8 +1017,10 @@ import { FileTextIcon, FilterIcon, HardDriveIcon, + InfinityIcon, PencilIcon, RefreshCwIcon, + RotateCcwIcon, SearchIcon, TrashIcon, XIcon @@ -1026,9 +1058,11 @@ const { isBatchUpdating, isCurrentPagePartiallySelected, isDetailLoading, + isDetailPolicyActionRunning, isPreviewLoading, isSaving, batchEditForm, + detailPolicyActionOptions, downloadingFileId, hasSelectedFiles, params, @@ -1059,6 +1093,7 @@ const { exportPreviewText, handlePageChange, handleSearch, + applyDetailPolicyAction, handleBatchUpdate, handleUpdate, loadFiles, @@ -1089,6 +1124,11 @@ const detailActionClass = computed(() => ? 'border-gray-700 bg-gray-700/50 text-gray-300 hover:border-gray-600 hover:bg-gray-700' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50' ) +const detailPolicyActionClass = computed(() => + isDarkMode.value + ? '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' +) type DetailInfoItem = { label: string @@ -1291,6 +1331,16 @@ const batchEditModeOptions = computed< } ]) +const detailPolicyActionIconMap: Record = { + extend_24h: ClockIcon, + extend_7d: ClockIcon, + make_permanent: InfinityIcon, + reset_download_limit: RotateCcwIcon +} + +const getDetailPolicyActionIcon = (action: AdminFilePolicyAction) => + detailPolicyActionIconMap[action] + const getPillClass = (active: boolean) => { if (active) return 'bg-indigo-600 text-white' return isDarkMode.value