From 16d348a25e5dcc2793517b81377cbef154025516 Mon Sep 17 00:00:00 2001 From: Lan Date: Wed, 3 Jun 2026 04:34:32 +0800 Subject: [PATCH] feat: add admin batch file selection --- src/composables/useAdminFiles.ts | 136 +++++++++++++++++++++++++++- src/i18n/locales/en-US.ts | 10 ++ src/i18n/locales/zh-CN.ts | 10 ++ src/services/file.ts | 9 ++ src/types/file.ts | 21 +++++ src/views/manage/FileManageView.vue | 83 ++++++++++++++++- 6 files changed, 266 insertions(+), 3 deletions(-) diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts index 9f322de..9010adb 100644 --- a/src/composables/useAdminFiles.ts +++ b/src/composables/useAdminFiles.ts @@ -3,11 +3,13 @@ import { useI18n } from 'vue-i18n' import { FileService } from '@/services' import { useAlertStore } from '@/stores/alertStore' import type { + AdminBatchDeleteFilesResponse, AdminFileListParams, AdminFileStatusFilter, AdminFileSummary, AdminFileTypeFilter, AdminFileViewItem, + ApiResponse, FileEditForm, FileListItem } from '@/types' @@ -38,6 +40,17 @@ const isExpiredByDate = (value: string | null | undefined) => { return Number.isFinite(timestamp) && timestamp < Date.now() } +type ErrorWithResponse = { + response?: { + status?: number + } +} + +const isLegacyBatchDeleteUnavailable = (error: unknown) => { + const status = (error as ErrorWithResponse)?.response?.status + return status === 404 || status === 405 +} + export function useAdminFiles() { const { t } = useI18n() const alertStore = useAlertStore() @@ -74,8 +87,21 @@ export function useAdminFiles() { const previewMetaText = ref('') const isPreviewLoading = ref(false) const downloadingFileId = ref(null) + const selectedFileIds = ref>(new Set()) + const isBatchDeleting = ref(false) const totalPages = computed(() => Math.max(Math.ceil(params.value.total / params.value.size), 1)) const storageUsedText = computed(() => formatFileSize(summary.value.storageUsed)) + const currentPageSelectedCount = computed( + () => tableData.value.filter((file) => selectedFileIds.value.has(file.id)).length + ) + const selectedCount = computed(() => selectedFileIds.value.size) + const hasSelectedFiles = computed(() => selectedCount.value > 0) + const isAllCurrentPageSelected = computed( + () => tableData.value.length > 0 && currentPageSelectedCount.value === tableData.value.length + ) + const isCurrentPagePartiallySelected = computed( + () => currentPageSelectedCount.value > 0 && !isAllCurrentPageSelected.value + ) const requestParams = computed(() => ({ page: params.value.page, @@ -166,6 +192,36 @@ export function useAdminFiles() { } } + const syncSelectedFilesWithCurrentPage = () => { + const visibleIds = new Set(tableData.value.map((file) => file.id)) + selectedFileIds.value = new Set( + Array.from(selectedFileIds.value).filter((id) => visibleIds.has(id)) + ) + } + + const clearSelection = () => { + selectedFileIds.value = new Set() + } + + const toggleFileSelection = (id: number) => { + const nextSelectedIds = new Set(selectedFileIds.value) + if (nextSelectedIds.has(id)) { + nextSelectedIds.delete(id) + } else { + nextSelectedIds.add(id) + } + selectedFileIds.value = nextSelectedIds + } + + const toggleCurrentPageSelection = () => { + if (isAllCurrentPageSelected.value) { + clearSelection() + return + } + + selectedFileIds.value = new Set(tableData.value.map((file) => file.id)) + } + const resetEditForm = () => { editForm.value = { id: null, @@ -187,6 +243,7 @@ export function useAdminFiles() { tableData.value = res.detail.data.map(createFileViewItem) params.value.total = res.detail.total summary.value = res.detail.summary || buildFallbackSummary(tableData.value, res.detail.total) + syncSelectedFilesWithCurrentPage() } catch (error) { hasLoadError.value = true alertStore.showAlert( @@ -214,6 +271,7 @@ export function useAdminFiles() { params.value.type = 'all' params.value.sortBy = 'created_at' params.value.sortOrder = 'desc' + clearSelection() await loadFiles() } @@ -279,12 +337,78 @@ export function useAdminFiles() { if (tableData.value.length === 1 && params.value.page > 1) { params.value.page -= 1 } + selectedFileIds.value.delete(id) + selectedFileIds.value = new Set(selectedFileIds.value) await loadFiles() } catch (error: unknown) { alertStore.showAlert(getErrorMessage(error, t('manage.fileManage.deleteFailed')), 'error') } } + const deleteAdminFilesWithFallback = async ( + ids: number[] + ): Promise> => { + try { + return await FileService.deleteAdminFiles(ids) + } catch (error: unknown) { + if (!isLegacyBatchDeleteUnavailable(error)) { + throw error + } + + await Promise.all(ids.map((id) => FileService.deleteAdminFile(id))) + return { + code: 200, + detail: { + deletedCount: ids.length, + deleted_count: ids.length, + deleted: ids, + missing: [], + failed: [] + } + } + } + } + + const deleteSelectedFiles = async () => { + if (!hasSelectedFiles.value || isBatchDeleting.value) return + + const ids = Array.from(selectedFileIds.value) + if (!window.confirm(t('fileManage.batchDeleteConfirm', { count: ids.length }))) { + return + } + + isBatchDeleting.value = true + try { + const response = await deleteAdminFilesWithFallback(ids) + const detail = response.detail + const deletedCount = detail?.deletedCount ?? detail?.deleted_count ?? ids.length + const failedCount = detail?.failedCount ?? detail?.failed_count ?? 0 + + if (tableData.value.length <= deletedCount && params.value.page > 1) { + params.value.page -= 1 + } + + clearSelection() + await loadFiles() + alertStore.showAlert( + t( + failedCount > 0 + ? 'fileManage.batchDeletePartialSuccess' + : 'fileManage.batchDeleteSuccess', + { + count: deletedCount, + failed: failedCount + } + ), + failedCount > 0 ? 'warning' : 'success' + ) + } catch (error: unknown) { + alertStore.showAlert(getErrorMessage(error, t('fileManage.batchDeleteFailed')), 'error') + } finally { + isBatchDeleting.value = false + } + } + const openTextPreview = async (file: AdminFileViewItem) => { if (!file.isTextFile || isPreviewLoading.value) return @@ -366,12 +490,18 @@ export function useAdminFiles() { hasLoadError, hasActiveFilters, isLoading, + isAllCurrentPageSelected, + isBatchDeleting, + isCurrentPagePartiallySelected, isPreviewLoading, isSaving, downloadingFileId, + hasSelectedFiles, params, previewFile, previewMetaText, + selectedCount, + selectedFileIds, storageUsedText, summary, showEditModal, @@ -382,7 +512,9 @@ export function useAdminFiles() { closeEditModal, closeTextPreview, copyText, + clearSelection, deleteFile, + deleteSelectedFiles, downloadFile, exportPreviewText, handlePageChange, @@ -394,6 +526,8 @@ export function useAdminFiles() { refreshFiles, resetFilters, setStatusFilter, - setTypeFilter + setTypeFilter, + toggleCurrentPageSelection, + toggleFileSelection } } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 9bcd7c5..69d3f97 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -418,7 +418,17 @@ export default { previewComplete: 'Loaded full content, {count} characters', previewTruncated: 'Showing {shown} / {total} characters', previewFallback: 'Preview endpoint unavailable; using text from the list', + selectCurrentPage: 'Select current page', + selectedCount: '{count} selected', + selectFile: 'Select {name}', + clearSelection: 'Clear Selection', + batchDelete: 'Batch Delete', + batchDeleteConfirm: 'Delete the selected {count} files? This action cannot be undone.', + batchDeleteSuccess: 'Deleted {count} files', + batchDeletePartialSuccess: 'Deleted {count} files, {failed} failed', + batchDeleteFailed: 'Batch delete failed', headers: { + select: 'Select', code: 'Retrieve Code', name: 'Name', type: 'Type', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 9c7343d..784b932 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -416,7 +416,17 @@ export default { previewComplete: '已加载完整内容,共 {count} 个字符', previewTruncated: '已显示 {shown} / {total} 个字符', previewFallback: '预览接口不可用,已使用列表中的文本内容', + selectCurrentPage: '选择当前页', + selectedCount: '已选择 {count} 项', + selectFile: '选择 {name}', + clearSelection: '清空选择', + batchDelete: '批量删除', + batchDeleteConfirm: '确认删除选中的 {count} 个文件?此操作不可撤销。', + batchDeleteSuccess: '已删除 {count} 个文件', + batchDeletePartialSuccess: '已删除 {count} 个文件,{failed} 个失败', + batchDeleteFailed: '批量删除失败', headers: { + select: '选择', code: '取件码', name: '名称', type: '类型', diff --git a/src/services/file.ts b/src/services/file.ts index 1c68707..e9e5917 100644 --- a/src/services/file.ts +++ b/src/services/file.ts @@ -1,6 +1,7 @@ import api, { rawApiClient } from './client' import { multipartUploadConfig } from './shared' import type { + AdminBatchDeleteFilesResponse, AdminFileListParams, AdminFilePreviewResponse, ApiResponse, @@ -134,6 +135,14 @@ export class FileService { }) } + static async deleteAdminFiles( + ids: number[] + ): Promise> { + return api.delete('/admin/file/batch-delete', { + data: { ids } + }) + } + static async downloadAdminFile( id: number ): Promise<{ data: Blob; headers: Record }> { diff --git a/src/types/file.ts b/src/types/file.ts index e1478d9..14fecfe 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -109,6 +109,27 @@ export interface AdminFilePreviewResponse { expired_at?: string | null } +export interface AdminBatchDeleteFileFailure { + id: number + reason: string +} + +export interface AdminBatchDeleteFilesResponse { + requestedCount?: number + requested_count?: number + uniqueCount?: number + unique_count?: number + deletedCount?: number + deleted_count?: number + missingCount?: number + missing_count?: number + failedCount?: number + failed_count?: number + deleted?: number[] + missing?: number[] + failed?: AdminBatchDeleteFileFailure[] +} + export interface FileUploadResponse { code: string name: string diff --git a/src/views/manage/FileManageView.vue b/src/views/manage/FileManageView.vue index d5a123f..4d46d07 100644 --- a/src/views/manage/FileManageView.vue +++ b/src/views/manage/FileManageView.vue @@ -144,6 +144,57 @@ +
+ + +
+ + + {{ t('fileManage.clearSelection') }} + + + + {{ t('fileManage.batchDelete') }} + +
+
+