feat(authFiles): enhance file upload and deletion handling with batch processing

This commit is contained in:
Supra4E8C
2026-03-27 23:22:38 +08:00
Unverified
parent 75f4c7cd1a
commit a388f72f5f
2 changed files with 254 additions and 114 deletions
+80 -110
View File
@@ -117,6 +117,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
setSelectedFiles(new Set());
}, []);
const applyDeletedFiles = useCallback((names: string[]) => {
const deletedNames = Array.from(
new Set(
names
.map((name) => name.trim())
.filter(Boolean)
)
);
if (deletedNames.length === 0) return;
const deletedSet = new Set(deletedNames);
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
setSelectedFiles((prev) => {
if (prev.size === 0) return prev;
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (deletedSet.has(name)) {
changed = true;
} else {
next.add(name);
}
});
return changed ? next : prev;
});
}, []);
useEffect(() => {
if (selectedFiles.size === 0) return;
const existingNames = new Set(files.map((file) => file.name));
@@ -190,36 +217,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
}
setUploading(true);
let successCount = 0;
const failed: { name: string; message: string }[] = [];
try {
const result = await authFilesApi.uploadFiles(validFiles);
const successCount = result.uploaded;
for (const file of validFiles) {
try {
await authFilesApi.upload(file);
successCount++;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
failed.push({ name: file.name, message: errorMessage });
if (successCount > 0) {
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
showNotification(
`${t('auth_files.upload_success')}${suffix}`,
result.failed.length ? 'warning' : 'success'
);
await loadFiles();
await refreshKeyStats();
}
}
if (successCount > 0) {
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
showNotification(
`${t('auth_files.upload_success')}${suffix}`,
failed.length ? 'warning' : 'success'
);
await loadFiles();
await refreshKeyStats();
if (result.failed.length > 0) {
const details = result.failed
.map((item) => `${item.name}: ${item.error}`)
.join('; ');
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
} finally {
setUploading(false);
event.target.value = '';
}
if (failed.length > 0) {
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
}
setUploading(false);
event.target.value = '';
},
[loadFiles, refreshKeyStats, showNotification, t]
);
@@ -234,15 +258,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
onConfirm: async () => {
setDeleting(name);
try {
await authFilesApi.deleteFile(name);
const result = await authFilesApi.deleteFile(name);
showNotification(t('auth_files.delete_success'), 'success');
setFiles((prev) => prev.filter((item) => item.name !== name));
setSelectedFiles((prev) => {
if (!prev.has(name)) return prev;
const next = new Set(prev);
next.delete(name);
return next;
});
applyDeletedFiles(result.files.length > 0 ? result.files : [name]);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
@@ -252,7 +270,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
},
});
},
[showConfirmation, showNotification, t]
[applyDeletedFiles, showConfirmation, showNotification, t]
);
const handleDeleteAll = useCallback(
@@ -301,35 +319,13 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
return;
}
let success = 0;
let failed = 0;
const deletedNames: string[] = [];
const result = await authFilesApi.deleteFiles(
filesToDelete.map((file) => file.name)
);
const success = result.deleted;
const failed = result.failed.length;
for (const file of filesToDelete) {
try {
await authFilesApi.deleteFile(file.name);
success++;
deletedNames.push(file.name);
} catch {
failed++;
}
}
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
setSelectedFiles((prev) => {
if (prev.size === 0) return prev;
const deletedSet = new Set(deletedNames);
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (deletedSet.has(name)) {
changed = true;
} else {
next.add(name);
}
});
return changed ? next : prev;
});
applyDeletedFiles(result.files);
if (failed === 0 && isProblemOnly) {
showNotification(
@@ -380,7 +376,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
},
});
},
[deselectAll, files, showConfirmation, showNotification, t]
[applyDeletedFiles, deselectAll, files, showConfirmation, showNotification, t]
);
const handleDownload = useCallback(
@@ -579,59 +575,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const results = await Promise.allSettled(
uniqueNames.map((name) => authFilesApi.deleteFile(name))
);
try {
const result = await authFilesApi.deleteFiles(uniqueNames);
applyDeletedFiles(result.files);
const deleted: string[] = [];
let failCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
deleted.push(uniqueNames[index]);
if (result.failed.length === 0) {
showNotification(
`${t('auth_files.delete_all_success')} (${result.deleted})`,
'success'
);
} else {
failCount++;
showNotification(
t('auth_files.delete_filtered_partial', {
success: result.deleted,
failed: result.failed.length,
type: t('auth_files.filter_all'),
}),
'warning'
);
}
});
if (deleted.length > 0) {
const deletedSet = new Set(deleted);
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
}
setSelectedFiles((prev) => {
if (prev.size === 0) return prev;
const deletedSet = new Set(deleted);
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (deletedSet.has(name)) {
changed = true;
} else {
next.add(name);
}
});
return changed ? next : prev;
});
if (failCount === 0) {
showNotification(
`${t('auth_files.delete_all_success')} (${deleted.length})`,
'success'
);
} else {
showNotification(
t('auth_files.delete_filtered_partial', {
success: deleted.length,
failed: failCount,
type: t('auth_files.filter_all'),
}),
'warning'
);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
}
},
});
},
[showConfirmation, showNotification, t]
[applyDeletedFiles, showConfirmation, showNotification, t]
);
return {
+174 -4
View File
@@ -9,6 +9,31 @@ import type { OAuthModelAliasEntry } from '@/types';
type StatusError = { status?: number };
type AuthFileStatusResponse = { status: string; disabled: boolean };
type AuthFileEntry = AuthFilesResponse['files'][number];
type AuthFileBatchFailure = { name: string; error: string };
type AuthFileBatchUploadResponse = {
status?: string;
uploaded?: number;
files?: unknown;
failed?: unknown;
};
type AuthFileBatchDeleteResponse = {
status?: string;
deleted?: number;
files?: unknown;
failed?: unknown;
};
type AuthFileBatchUploadResult = {
status: string;
uploaded: number;
files: string[];
failed: AuthFileBatchFailure[];
};
type AuthFileBatchDeleteResult = {
status: string;
deleted: number;
files: string[];
failed: AuthFileBatchFailure[];
};
export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT';
@@ -18,6 +43,129 @@ const getStatusCode = (err: unknown): number | undefined => {
return undefined;
};
const normalizeRequestedAuthFileNames = (names: string[]): string[] => {
const seen = new Set<string>();
const normalized: string[] = [];
names.forEach((name) => {
const trimmed = String(name ?? '').trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
normalized.push(trimmed);
});
return normalized;
};
const normalizeBatchFileNames = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return normalizeRequestedAuthFileNames(value.map((item) => String(item ?? '')));
};
const normalizeBatchFailures = (value: unknown): AuthFileBatchFailure[] => {
if (!Array.isArray(value)) return [];
return value.reduce<AuthFileBatchFailure[]>((result, item) => {
if (!item || typeof item !== 'object') return result;
const entry = item as Record<string, unknown>;
const name = String(entry.name ?? '').trim();
const error =
typeof entry.error === 'string'
? entry.error.trim()
: typeof entry.message === 'string'
? entry.message.trim()
: '';
if (!name && !error) return result;
result.push({ name, error: error || 'Unknown error' });
return result;
}, []);
};
const deriveSuccessfulFileNames = (requestedNames: string[], failed: AuthFileBatchFailure[]): string[] => {
const failedNames = new Set(
failed
.map((entry) => entry.name.trim())
.filter(Boolean)
);
if (failedNames.size === 0) {
return [...requestedNames];
}
return requestedNames.filter((name) => !failedNames.has(name));
};
const normalizeBatchUploadResponse = (
payload: AuthFileBatchUploadResponse | undefined,
requestedNames: string[]
): AuthFileBatchUploadResult => {
const failed = normalizeBatchFailures(payload?.failed);
const uploadedFilesFromPayload = normalizeBatchFileNames(payload?.files);
const uploaded =
typeof payload?.uploaded === 'number'
? payload.uploaded
: uploadedFilesFromPayload.length > 0
? uploadedFilesFromPayload.length
: requestedNames.length === 1 && failed.length === 0
? 1
: 0;
let uploadedFiles = uploadedFilesFromPayload;
if (uploadedFiles.length === 0 && uploaded > 0) {
if (failed.length === 0 && uploaded === requestedNames.length) {
uploadedFiles = [...requestedNames];
} else {
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
if (derivedNames.length === uploaded) {
uploadedFiles = derivedNames;
}
}
}
return {
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
uploaded,
files: uploadedFiles,
failed,
};
};
const normalizeBatchDeleteResponse = (
payload: AuthFileBatchDeleteResponse | undefined,
requestedNames: string[]
): AuthFileBatchDeleteResult => {
const failed = normalizeBatchFailures(payload?.failed);
const deletedFilesFromPayload = normalizeBatchFileNames(payload?.files);
const deleted =
typeof payload?.deleted === 'number'
? payload.deleted
: deletedFilesFromPayload.length > 0
? deletedFilesFromPayload.length
: requestedNames.length === 1 && failed.length === 0
? 1
: 0;
let deletedFiles = deletedFilesFromPayload;
if (deletedFiles.length === 0 && deleted > 0) {
if (failed.length === 0 && deleted === requestedNames.length) {
deletedFiles = [...requestedNames];
} else {
const derivedNames = deriveSuccessfulFileNames(requestedNames, failed);
if (derivedNames.length === deleted) {
deletedFiles = derivedNames;
}
}
}
return {
status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok',
deleted,
files: deletedFiles,
failed,
};
};
const readTextField = (entry: AuthFileEntry, key: string): string => {
const value = entry[key];
return typeof value === 'string' ? value.trim() : '';
@@ -252,13 +400,35 @@ export const authFilesApi = {
setStatus: (name: string, disabled: boolean) =>
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
upload: (file: File) => {
uploadFiles: async (files: File[]): Promise<AuthFileBatchUploadResult> => {
const requestedNames = files.map((file) => file.name);
if (requestedNames.length === 0) {
return { status: 'ok', uploaded: 0, files: [], failed: [] };
}
const formData = new FormData();
formData.append('file', file, file.name);
return apiClient.postForm('/auth-files', formData);
files.forEach((file) => {
formData.append('file', file, file.name);
});
const payload = await apiClient.postForm<AuthFileBatchUploadResponse>('/auth-files', formData);
return normalizeBatchUploadResponse(payload, requestedNames);
},
deleteFile: (name: string) => apiClient.delete(`/auth-files?name=${encodeURIComponent(name)}`),
upload: (file: File) => authFilesApi.uploadFiles([file]),
deleteFiles: async (names: string[]): Promise<AuthFileBatchDeleteResult> => {
const requestedNames = normalizeRequestedAuthFileNames(names);
if (requestedNames.length === 0) {
return { status: 'ok', deleted: 0, files: [], failed: [] };
}
const payload = await apiClient.delete<AuthFileBatchDeleteResponse>('/auth-files', {
data: { names: requestedNames },
});
return normalizeBatchDeleteResponse(payload, requestedNames);
},
deleteFile: (name: string) => authFilesApi.deleteFiles([name]),
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),