From 2923699a4104d78fe86ec525c8e477d76c27e63a Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 04:54:23 +0800 Subject: [PATCH] feat: add admin batch policy editor --- src/composables/useAdminFiles.ts | 166 +++++++++++++++++++++++++++- src/i18n/locales/en-US.ts | 14 +++ src/i18n/locales/zh-CN.ts | 14 +++ src/services/file.ts | 11 +- src/types/file.ts | 46 ++++++++ src/views/manage/FileManageView.vue | 151 ++++++++++++++++++++++++- 6 files changed, 393 insertions(+), 9 deletions(-) diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts index 9010adb..0a57cdd 100644 --- a/src/composables/useAdminFiles.ts +++ b/src/composables/useAdminFiles.ts @@ -3,7 +3,11 @@ import { useI18n } from 'vue-i18n' import { FileService } from '@/services' import { useAlertStore } from '@/stores/alertStore' import type { + AdminBatchEditForm, AdminBatchDeleteFilesResponse, + AdminBatchUpdateFilesRequest, + AdminBatchUpdateFilesResponse, + AdminFilePatchPayload, AdminFileListParams, AdminFileStatusFilter, AdminFileSummary, @@ -46,11 +50,19 @@ type ErrorWithResponse = { } } -const isLegacyBatchDeleteUnavailable = (error: unknown) => { +const isLegacyBatchActionUnavailable = (error: unknown) => { const status = (error as ErrorWithResponse)?.response?.status return status === 404 || status === 405 } +const legacyForeverExpiresAt = '2099-12-31T23:59' + +const emptyBatchEditForm = (): AdminBatchEditForm => ({ + mode: 'expiresAt', + expired_at: '', + expired_count: null +}) + export function useAdminFiles() { const { t } = useI18n() const alertStore = useAlertStore() @@ -80,6 +92,8 @@ export function useAdminFiles() { expired_at: '', expired_count: null }) + const showBatchEditModal = ref(false) + const batchEditForm = ref(emptyBatchEditForm()) const showTextPreview = ref(false) const previewText = ref('') @@ -89,6 +103,8 @@ export function useAdminFiles() { const downloadingFileId = ref(null) const selectedFileIds = ref>(new Set()) const isBatchDeleting = ref(false) + const isBatchUpdating = ref(false) + const isBatchActionRunning = computed(() => isBatchDeleting.value || isBatchUpdating.value) const totalPages = computed(() => Math.max(Math.ceil(params.value.total / params.value.size), 1)) const storageUsedText = computed(() => formatFileSize(summary.value.storageUsed)) const currentPageSelectedCount = computed( @@ -233,6 +249,72 @@ export function useAdminFiles() { } } + const resetBatchEditForm = () => { + batchEditForm.value = emptyBatchEditForm() + } + + const openBatchEditModal = () => { + if (!hasSelectedFiles.value || isBatchActionRunning.value) return + + resetBatchEditForm() + showBatchEditModal.value = true + } + + const closeBatchEditModal = (force = false) => { + if (isBatchUpdating.value && !force) return + + showBatchEditModal.value = false + resetBatchEditForm() + } + + const buildBatchUpdatePayload = (ids: number[]): AdminBatchUpdateFilesRequest | null => { + const form = batchEditForm.value + + if (form.mode === 'expiresAt') { + if (!form.expired_at) return null + return { + ids, + expired_at: form.expired_at + } + } + + if (form.mode === 'downloadLimit') { + if (form.expired_count === null || !Number.isFinite(form.expired_count)) return null + return { + ids, + expired_count: form.expired_count + } + } + + return { + ids, + clearExpiredAt: true, + clear_expired_at: true + } + } + + const buildLegacyBatchPatchPayload = ( + id: number, + payload: AdminBatchUpdateFilesRequest + ): AdminFilePatchPayload => { + if (payload.clearExpiredAt || payload.clear_expired_at) { + return { + id, + expired_at: legacyForeverExpiresAt, + expired_count: -1 + } + } + + const patchPayload: AdminFilePatchPayload = { id } + if (payload.expired_at !== undefined) { + patchPayload.expired_at = payload.expired_at + } + if (payload.expired_count !== undefined) { + patchPayload.expired_count = payload.expired_count + } + return patchPayload + } + const loadFiles = async () => { isLoading.value = true try { @@ -351,7 +433,7 @@ export function useAdminFiles() { try { return await FileService.deleteAdminFiles(ids) } catch (error: unknown) { - if (!isLegacyBatchDeleteUnavailable(error)) { + if (!isLegacyBatchActionUnavailable(error)) { throw error } @@ -369,8 +451,36 @@ export function useAdminFiles() { } } + const updateAdminFilesWithFallback = async ( + ids: number[], + payload: AdminBatchUpdateFilesRequest + ): Promise> => { + try { + return await FileService.updateAdminFiles(payload) + } catch (error: unknown) { + if (!isLegacyBatchActionUnavailable(error)) { + throw error + } + + await Promise.all( + ids.map((id) => FileService.updateFile(buildLegacyBatchPatchPayload(id, payload))) + ) + + return { + code: 200, + detail: { + updatedCount: ids.length, + updated_count: ids.length, + updated: ids, + missing: [], + failed: [] + } + } + } + } + const deleteSelectedFiles = async () => { - if (!hasSelectedFiles.value || isBatchDeleting.value) return + if (!hasSelectedFiles.value || isBatchActionRunning.value) return const ids = Array.from(selectedFileIds.value) if (!window.confirm(t('fileManage.batchDeleteConfirm', { count: ids.length }))) { @@ -409,6 +519,49 @@ export function useAdminFiles() { } } + const handleBatchUpdate = async () => { + if (!hasSelectedFiles.value || isBatchActionRunning.value) return + + const ids = Array.from(selectedFileIds.value) + const payload = buildBatchUpdatePayload(ids) + if (!payload) { + alertStore.showAlert(t('fileManage.batchUpdateNoFields'), 'warning') + return + } + + if (!window.confirm(t('fileManage.batchUpdateConfirm', { count: ids.length }))) { + return + } + + isBatchUpdating.value = true + try { + const response = await updateAdminFilesWithFallback(ids, payload) + const detail = response.detail + const updatedCount = detail?.updatedCount ?? detail?.updated_count ?? ids.length + const failedCount = detail?.failedCount ?? detail?.failed_count ?? 0 + + clearSelection() + await loadFiles() + closeBatchEditModal(true) + alertStore.showAlert( + t( + failedCount > 0 + ? 'fileManage.batchUpdatePartialSuccess' + : 'fileManage.batchUpdateSuccess', + { + count: updatedCount, + failed: failedCount + } + ), + failedCount > 0 ? 'warning' : 'success' + ) + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, t('fileManage.batchUpdateFailed')), 'error') + } finally { + isBatchUpdating.value = false + } + } + const openTextPreview = async (file: AdminFileViewItem) => { if (!file.isTextFile || isPreviewLoading.value) return @@ -491,10 +644,13 @@ export function useAdminFiles() { hasActiveFilters, isLoading, isAllCurrentPageSelected, + isBatchActionRunning, isBatchDeleting, + isBatchUpdating, isCurrentPagePartiallySelected, isPreviewLoading, isSaving, + batchEditForm, downloadingFileId, hasSelectedFiles, params, @@ -504,12 +660,14 @@ export function useAdminFiles() { selectedFileIds, storageUsedText, summary, + showBatchEditModal, showEditModal, editForm, showTextPreview, previewText, totalPages, closeEditModal, + closeBatchEditModal, closeTextPreview, copyText, clearSelection, @@ -519,8 +677,10 @@ export function useAdminFiles() { exportPreviewText, handlePageChange, handleSearch, + handleBatchUpdate, handleUpdate, loadFiles, + openBatchEditModal, openEditModal, openTextPreview, refreshFiles, diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 69d3f97..2af1260 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -427,6 +427,20 @@ export default { batchDeleteSuccess: 'Deleted {count} files', batchDeletePartialSuccess: 'Deleted {count} files, {failed} failed', batchDeleteFailed: 'Batch delete failed', + batchEdit: 'Batch Edit', + batchEditTitle: 'Batch Edit File Policy', + batchEditSelected: 'Update the selected {count} files', + batchEditMode: 'Update Mode', + batchEditExpiresAt: 'Set Expiration Time', + batchEditDownloadLimit: 'Set Retrieval Limit', + batchEditForever: 'Make Permanent', + batchEditForeverHint: 'Clear the expiration time and make retrievals unlimited.', + batchUpdateConfirm: 'Update the selected {count} files?', + batchUpdateSuccess: 'Updated {count} files', + batchUpdatePartialSuccess: 'Updated {count} files, {failed} failed', + batchUpdateFailed: 'Batch update failed', + batchUpdateNoFields: 'Choose a policy to update', + applyBatchEdit: 'Apply Changes', headers: { select: 'Select', code: 'Retrieve Code', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 784b932..f17b33a 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -425,6 +425,20 @@ export default { batchDeleteSuccess: '已删除 {count} 个文件', batchDeletePartialSuccess: '已删除 {count} 个文件,{failed} 个失败', batchDeleteFailed: '批量删除失败', + batchEdit: '批量编辑', + batchEditTitle: '批量编辑文件策略', + batchEditSelected: '将更新选中的 {count} 个文件', + batchEditMode: '更新方式', + batchEditExpiresAt: '设置过期时间', + batchEditDownloadLimit: '设置取件次数', + batchEditForever: '设为永久有效', + batchEditForeverHint: '清空过期时间,并将取件次数设为不限。', + batchUpdateConfirm: '确认更新选中的 {count} 个文件?', + batchUpdateSuccess: '已更新 {count} 个文件', + batchUpdatePartialSuccess: '已更新 {count} 个文件,{failed} 个失败', + batchUpdateFailed: '批量更新失败', + batchUpdateNoFields: '请选择要更新的策略', + applyBatchEdit: '应用更改', headers: { select: '选择', code: '取件码', diff --git a/src/services/file.ts b/src/services/file.ts index e9e5917..ab7e236 100644 --- a/src/services/file.ts +++ b/src/services/file.ts @@ -2,6 +2,9 @@ import api, { rawApiClient } from './client' import { multipartUploadConfig } from './shared' import type { AdminBatchDeleteFilesResponse, + AdminBatchUpdateFilesRequest, + AdminBatchUpdateFilesResponse, + AdminFilePatchPayload, AdminFileListParams, AdminFilePreviewResponse, ApiResponse, @@ -125,10 +128,16 @@ export class FileService { return api.get('/admin/file/list', { params }) } - static async updateFile(data: FileEditForm): Promise { + static async updateFile(data: FileEditForm | AdminFilePatchPayload): Promise { return api.patch('/admin/file/update', data) } + static async updateAdminFiles( + data: AdminBatchUpdateFilesRequest + ): Promise> { + return api.patch('/admin/file/batch-update', data) + } + static async deleteAdminFile(id: number): Promise { return api.delete('/admin/file/delete', { data: { id } diff --git a/src/types/file.ts b/src/types/file.ts index 14fecfe..651ac0c 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -83,6 +83,15 @@ export interface FileEditForm { expired_count: number | null } +export interface AdminFilePatchPayload { + id: number + code?: string + prefix?: string + suffix?: string + expired_at?: string | null + expired_count?: number | null +} + export interface FileListResponse { data: FileListItem[] total: number @@ -130,6 +139,43 @@ export interface AdminBatchDeleteFilesResponse { failed?: AdminBatchDeleteFileFailure[] } +export interface AdminBatchUpdateFileFailure { + id: number + reason: string +} + +export interface AdminBatchUpdateFilesRequest { + ids: number[] + expired_at?: string | null + expired_count?: number | null + clearExpiredAt?: boolean + clear_expired_at?: boolean +} + +export interface AdminBatchUpdateFilesResponse { + requestedCount?: number + requested_count?: number + uniqueCount?: number + unique_count?: number + updatedCount?: number + updated_count?: number + missingCount?: number + missing_count?: number + failedCount?: number + failed_count?: number + updated?: number[] + missing?: number[] + failed?: AdminBatchUpdateFileFailure[] +} + +export type AdminBatchEditMode = 'expiresAt' | 'downloadLimit' | 'forever' + +export interface AdminBatchEditForm { + mode: AdminBatchEditMode + expired_at: string + expired_count: number | null +} + export interface FileUploadResponse { code: string name: string diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index 4d46d07..23cee8f 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -154,7 +154,7 @@ type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" :checked="isAllCurrentPageSelected" - :disabled="isBatchDeleting" + :disabled="isBatchActionRunning" :indeterminate="isCurrentPagePartiallySelected" @change="toggleCurrentPageSelection" /> @@ -172,7 +172,7 @@ v-if="hasSelectedFiles" variant="outline" size="sm" - :disabled="isBatchDeleting" + :disabled="isBatchActionRunning" @click="clearSelection" > {{ t('fileManage.clearSelection') }} + + + {{ t('fileManage.batchEdit') }} + @@ -262,7 +274,7 @@ type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" :checked="selectedFileIds.has(file.id)" - :disabled="isBatchDeleting" + :disabled="isBatchActionRunning" :aria-label="t('fileManage.selectFile', { name: file.displayName })" @change="toggleFileSelection(file.id)" /> @@ -467,6 +479,95 @@ + + + +
+
+ {{ t('fileManage.batchEditSelected', { count: selectedCount }) }} +
+ +
+

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

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