feat: add admin batch file selection
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '类型',
|
||||
|
||||
@@ -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> }> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user