feat: add admin batch file selection

This commit is contained in:
Lan
2026-06-03 04:34:32 +08:00
parent a4edd1293a
commit d6b6c4d65e
6 changed files with 266 additions and 3 deletions
+135 -1
View File
@@ -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<number | null>(null)
const selectedFileIds = ref<Set<number>>(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<AdminFileListParams>(() => ({
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<ApiResponse<AdminBatchDeleteFilesResponse>> => {
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
}
}
+10
View File
@@ -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',
+10
View File
@@ -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: '类型',
+9
View File
@@ -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<ApiResponse<AdminBatchDeleteFilesResponse>> {
return api.delete('/admin/file/batch-delete', {
data: { ids }
})
}
static async downloadAdminFile(
id: number
): Promise<{ data: Blob; headers: Record<string, string> }> {
+21
View File
@@ -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
+81 -2
View File
@@ -144,6 +144,57 @@
</div>
</section>
<section
v-if="tableData.length > 0"
class="mb-4 flex flex-col gap-3 rounded-lg border px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
:class="[panelClass]"
>
<label class="inline-flex items-center gap-2 text-sm" :class="[primaryTextClass]">
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
:checked="isAllCurrentPageSelected"
:disabled="isBatchDeleting"
:indeterminate="isCurrentPagePartiallySelected"
@change="toggleCurrentPageSelection"
/>
<span>
{{
hasSelectedFiles
? t('fileManage.selectedCount', { count: selectedCount })
: t('fileManage.selectCurrentPage')
}}
</span>
</label>
<div class="flex flex-wrap items-center gap-2">
<BaseButton
v-if="hasSelectedFiles"
variant="outline"
size="sm"
:disabled="isBatchDeleting"
@click="clearSelection"
>
<template #icon>
<XIcon class="mr-2 h-4 w-4" />
</template>
{{ t('fileManage.clearSelection') }}
</BaseButton>
<BaseButton
variant="danger"
size="sm"
:disabled="!hasSelectedFiles"
:loading="isBatchDeleting"
@click="deleteSelectedFiles"
>
<template #icon>
<TrashIcon class="mr-2 h-4 w-4" />
</template>
{{ t('fileManage.batchDelete') }}
</BaseButton>
</div>
</section>
<DataTable :title="t('fileManage.allFiles')" :headers="fileTableHeaders">
<template #body>
<tr v-if="isLoading">
@@ -197,8 +248,25 @@
v-for="file in tableData"
:key="file.id"
class="transition-colors duration-200"
:class="[isDarkMode ? 'hover:bg-gray-700/70' : 'hover:bg-gray-50']"
:class="[
selectedFileIds.has(file.id)
? isDarkMode
? 'bg-indigo-950/30'
: 'bg-indigo-50/70'
: '',
isDarkMode ? 'hover:bg-gray-700/70' : 'hover:bg-gray-50'
]"
>
<td class="px-6 py-4 whitespace-nowrap">
<input
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"
:aria-label="t('fileManage.selectFile', { name: file.displayName })"
@change="toggleFileSelection(file.id)"
/>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="font-medium select-all" :class="[primaryTextClass]">
{{ file.code }}
@@ -508,6 +576,7 @@ const { t } = useI18n()
const isDarkMode = useInjectedDarkMode()
const fileTableHeaders = computed(() => [
t('fileManage.headers.select'),
t('fileManage.headers.code'),
t('fileManage.headers.name'),
t('fileManage.headers.type'),
@@ -523,12 +592,18 @@ const {
hasActiveFilters,
hasLoadError,
isLoading,
isAllCurrentPageSelected,
isBatchDeleting,
isCurrentPagePartiallySelected,
isPreviewLoading,
isSaving,
downloadingFileId,
hasSelectedFiles,
params,
previewFile,
previewMetaText,
selectedCount,
selectedFileIds,
storageUsedText,
summary,
showEditModal,
@@ -538,7 +613,9 @@ const {
closeEditModal,
closeTextPreview,
copyText,
clearSelection,
deleteFile,
deleteSelectedFiles,
downloadFile,
exportPreviewText,
handlePageChange,
@@ -550,7 +627,9 @@ const {
refreshFiles,
resetFilters,
setStatusFilter,
setTypeFilter
setTypeFilter,
toggleCurrentPageSelection,
toggleFileSelection
} = useAdminFiles()
const primaryTextClass = computed(() => (isDarkMode.value ? 'text-white' : 'text-gray-900'))