feat: add admin batch policy editor

This commit is contained in:
Lan
2026-06-03 04:54:23 +08:00
parent 16d348a25e
commit ee4607257a
6 changed files with 393 additions and 9 deletions
+163 -3
View File
@@ -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<AdminBatchEditForm>(emptyBatchEditForm())
const showTextPreview = ref(false)
const previewText = ref('')
@@ -89,6 +103,8 @@ export function useAdminFiles() {
const downloadingFileId = ref<number | null>(null)
const selectedFileIds = ref<Set<number>>(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<ApiResponse<AdminBatchUpdateFilesResponse>> => {
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,
+14
View File
@@ -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',
+14
View File
@@ -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: '取件码',
+10 -1
View File
@@ -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<ApiResponse> {
static async updateFile(data: FileEditForm | AdminFilePatchPayload): Promise<ApiResponse> {
return api.patch('/admin/file/update', data)
}
static async updateAdminFiles(
data: AdminBatchUpdateFilesRequest
): Promise<ApiResponse<AdminBatchUpdateFilesResponse>> {
return api.patch('/admin/file/batch-update', data)
}
static async deleteAdminFile(id: number): Promise<ApiResponse> {
return api.delete('/admin/file/delete', {
data: { id }
+46
View File
@@ -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
+146 -5
View File
@@ -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"
>
<template #icon>
@@ -180,10 +180,22 @@
</template>
{{ t('fileManage.clearSelection') }}
</BaseButton>
<BaseButton
variant="secondary"
size="sm"
:disabled="!hasSelectedFiles || isBatchDeleting"
:loading="isBatchUpdating"
@click="openBatchEditModal"
>
<template #icon>
<ClockIcon class="mr-2 h-4 w-4" />
</template>
{{ t('fileManage.batchEdit') }}
</BaseButton>
<BaseButton
variant="danger"
size="sm"
:disabled="!hasSelectedFiles"
:disabled="!hasSelectedFiles || isBatchUpdating"
:loading="isBatchDeleting"
@click="deleteSelectedFiles"
>
@@ -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 @@
</template>
</BaseModal>
<BaseModal :show="showBatchEditModal" size="lg" @close="closeBatchEditModal()">
<template #header>
<div class="flex items-center space-x-3">
<div class="p-2 rounded-lg" :class="[isDarkMode ? 'bg-indigo-500/10' : 'bg-indigo-50']">
<ClockIcon
class="w-5 h-5"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
/>
</div>
<h3
class="text-xl font-semibold leading-6"
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
>
{{ t('fileManage.batchEditTitle') }}
</h3>
</div>
</template>
<div class="space-y-5">
<div
class="rounded-lg border px-4 py-3 text-sm"
:class="[
isDarkMode
? 'border-indigo-500/20 bg-indigo-500/10 text-indigo-200'
: 'border-indigo-100 bg-indigo-50 text-indigo-700'
]"
>
{{ t('fileManage.batchEditSelected', { count: selectedCount }) }}
</div>
<div class="space-y-3">
<p class="text-sm font-medium" :class="[primaryTextClass]">
{{ t('fileManage.batchEditMode') }}
</p>
<div class="grid gap-2 sm:grid-cols-3">
<button
v-for="option in batchEditModeOptions"
:key="option.value"
type="button"
class="flex min-h-12 items-center justify-center rounded-lg border px-3 py-2 text-sm font-medium transition-colors"
:class="getBatchEditModeClass(option.value)"
:disabled="isBatchUpdating"
@click="batchEditForm.mode = option.value"
>
<component :is="option.icon" class="mr-2 h-4 w-4" />
<span>{{ option.label }}</span>
</button>
</div>
</div>
<FileEditField
v-if="batchEditForm.mode === 'expiresAt'"
v-model="batchEditForm.expired_at"
type="datetime-local"
:label="t('fileManage.batchEditExpiresAt')"
/>
<FileEditField
v-else-if="batchEditForm.mode === 'downloadLimit'"
v-model="batchEditForm.expired_count"
type="number"
:label="t('fileManage.batchEditDownloadLimit')"
:placeholder="t('fileManage.form.downloadLimitPlaceholder')"
/>
<div
v-else
class="rounded-lg border px-4 py-3 text-sm"
:class="[
isDarkMode
? 'border-gray-700 bg-gray-700/50 text-gray-300'
: 'border-gray-200 bg-gray-50 text-gray-600'
]"
>
{{ t('fileManage.batchEditForeverHint') }}
</div>
</div>
<template #footer>
<BaseButton variant="secondary" :disabled="isBatchUpdating" @click="closeBatchEditModal()">
{{ t('common.cancel') }}
</BaseButton>
<BaseButton :loading="isBatchUpdating" @click="handleBatchUpdate">
<template #icon>
<CheckIcon class="w-4 h-4 mr-2" />
</template>
{{ t('fileManage.applyBatchEdit') }}
</BaseButton>
</template>
</BaseModal>
<BaseModal :show="showTextPreview" size="lg" @close="closeTextPreview">
<template #header>
<div class="flex items-center space-x-3">
@@ -538,9 +639,10 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, onMounted, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
AdminBatchEditMode,
AdminFileSortBy,
AdminFileSortOrder,
AdminFileStatusFilter,
@@ -593,10 +695,13 @@ const {
hasLoadError,
isLoading,
isAllCurrentPageSelected,
isBatchActionRunning,
isBatchDeleting,
isBatchUpdating,
isCurrentPagePartiallySelected,
isPreviewLoading,
isSaving,
batchEditForm,
downloadingFileId,
hasSelectedFiles,
params,
@@ -606,10 +711,12 @@ const {
selectedFileIds,
storageUsedText,
summary,
showBatchEditModal,
showEditModal,
editForm,
showTextPreview,
previewText,
closeBatchEditModal,
closeEditModal,
closeTextPreview,
copyText,
@@ -620,8 +727,10 @@ const {
exportPreviewText,
handlePageChange,
handleSearch,
handleBatchUpdate,
handleUpdate,
loadFiles,
openBatchEditModal,
openEditModal,
openTextPreview,
refreshFiles,
@@ -701,6 +810,26 @@ const sortOrderOptions = computed<{ value: AdminFileSortOrder; label: string }[]
{ value: 'asc', label: t('fileManage.sort.asc') }
])
const batchEditModeOptions = computed<
{ value: AdminBatchEditMode; label: string; icon: Component }[]
>(() => [
{
value: 'expiresAt',
label: t('fileManage.batchEditExpiresAt'),
icon: ClockIcon
},
{
value: 'downloadLimit',
label: t('fileManage.batchEditDownloadLimit'),
icon: DownloadIcon
},
{
value: 'forever',
label: t('fileManage.batchEditForever'),
icon: CheckIcon
}
])
const getPillClass = (active: boolean) => {
if (active) return 'bg-indigo-600 text-white'
return isDarkMode.value
@@ -713,6 +842,18 @@ const getStatusFilterClass = (value: AdminFileStatusFilter) =>
const getTypeFilterClass = (value: AdminFileTypeFilter) => getPillClass(params.value.type === value)
const getBatchEditModeClass = (value: AdminBatchEditMode) => {
if (batchEditForm.value.mode === value) {
return isDarkMode.value
? 'border-indigo-500 bg-indigo-500/20 text-indigo-200'
: 'border-indigo-500 bg-indigo-50 text-indigo-700'
}
return isDarkMode.value
? '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 getTypeLabel = (file: AdminFileViewItem) => {
if (file.isChunkedFile) return t('fileManage.chunkedType')
return file.isTextFile ? t('fileManage.textType') : t('fileManage.fileType')