mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
635 lines
21 KiB
TypeScript
635 lines
21 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { authFilesApi } from '@/services/api';
|
|
import { apiClient } from '@/services/api/client';
|
|
import { useNotificationStore } from '@/stores';
|
|
import type { AuthFileItem } from '@/types';
|
|
import { formatFileSize } from '@/utils/format';
|
|
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
|
|
import { downloadBlob } from '@/utils/download';
|
|
import {
|
|
getTypeLabel,
|
|
hasAuthFileStatusMessage,
|
|
isRuntimeOnlyAuthFile,
|
|
} from '@/features/authFiles/constants';
|
|
|
|
type DeleteAllOptions = {
|
|
filter: string;
|
|
problemOnly: boolean;
|
|
onResetFilterToAll: () => void;
|
|
onResetProblemOnly: () => void;
|
|
};
|
|
|
|
export type UseAuthFilesDataResult = {
|
|
files: AuthFileItem[];
|
|
selectedFiles: Set<string>;
|
|
selectionCount: number;
|
|
loading: boolean;
|
|
error: string;
|
|
uploading: boolean;
|
|
deleting: string | null;
|
|
deletingAll: boolean;
|
|
statusUpdating: Record<string, boolean>;
|
|
batchStatusUpdating: boolean;
|
|
fileInputRef: RefObject<HTMLInputElement | null>;
|
|
loadFiles: () => Promise<void>;
|
|
handleUploadClick: () => void;
|
|
handleFileChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
|
|
handleDelete: (name: string) => void;
|
|
handleDeleteAll: (options: DeleteAllOptions) => void;
|
|
handleDownload: (name: string) => Promise<void>;
|
|
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
|
|
toggleSelect: (name: string) => void;
|
|
selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
|
|
invertVisibleSelection: (visibleFiles: AuthFileItem[]) => void;
|
|
deselectAll: () => void;
|
|
batchDownload: (names: string[]) => Promise<void>;
|
|
batchSetStatus: (names: string[], enabled: boolean) => Promise<void>;
|
|
batchDelete: (names: string[]) => void;
|
|
};
|
|
|
|
export type UseAuthFilesDataOptions = {
|
|
refreshKeyStats: () => Promise<void>;
|
|
};
|
|
|
|
export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFilesDataResult {
|
|
const { refreshKeyStats } = options;
|
|
const { t } = useTranslation();
|
|
const { showNotification, showConfirmation } = useNotificationStore();
|
|
|
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [uploading, setUploading] = useState(false);
|
|
const [deleting, setDeleting] = useState<string | null>(null);
|
|
const [deletingAll, setDeletingAll] = useState(false);
|
|
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
|
|
const [batchStatusUpdating, setBatchStatusUpdating] = useState(false);
|
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
|
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const batchStatusPendingRef = useRef(false);
|
|
const selectionCount = selectedFiles.size;
|
|
const toggleSelect = useCallback((name: string) => {
|
|
setSelectedFiles((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(name)) {
|
|
next.delete(name);
|
|
} else {
|
|
next.add(name);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const selectAllVisible = useCallback((visibleFiles: AuthFileItem[]) => {
|
|
const nextSelected = visibleFiles
|
|
.filter((file) => !isRuntimeOnlyAuthFile(file))
|
|
.map((file) => file.name);
|
|
if (nextSelected.length === 0) return;
|
|
setSelectedFiles((prev) => {
|
|
const next = new Set(prev);
|
|
nextSelected.forEach((name) => next.add(name));
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const invertVisibleSelection = useCallback((visibleFiles: AuthFileItem[]) => {
|
|
const visibleNames = visibleFiles
|
|
.filter((file) => !isRuntimeOnlyAuthFile(file))
|
|
.map((file) => file.name);
|
|
if (visibleNames.length === 0) return;
|
|
|
|
setSelectedFiles((prev) => {
|
|
const next = new Set(prev);
|
|
visibleNames.forEach((name) => {
|
|
if (next.has(name)) {
|
|
next.delete(name);
|
|
} else {
|
|
next.add(name);
|
|
}
|
|
});
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const deselectAll = useCallback(() => {
|
|
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));
|
|
setSelectedFiles((prev) => {
|
|
let changed = false;
|
|
const next = new Set<string>();
|
|
prev.forEach((name) => {
|
|
if (existingNames.has(name)) {
|
|
next.add(name);
|
|
} else {
|
|
changed = true;
|
|
}
|
|
});
|
|
return changed ? next : prev;
|
|
});
|
|
}, [files, selectedFiles.size]);
|
|
|
|
const loadFiles = useCallback(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const data = await authFilesApi.list();
|
|
setFiles(data?.files || []);
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
|
|
setError(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [t]);
|
|
|
|
const handleUploadClick = useCallback(() => {
|
|
fileInputRef.current?.click();
|
|
}, []);
|
|
|
|
const handleFileChange = useCallback(
|
|
async (event: ChangeEvent<HTMLInputElement>) => {
|
|
const fileList = event.target.files;
|
|
if (!fileList || fileList.length === 0) return;
|
|
|
|
const filesToUpload = Array.from(fileList);
|
|
const validFiles: File[] = [];
|
|
const invalidFiles: string[] = [];
|
|
const oversizedFiles: string[] = [];
|
|
|
|
filesToUpload.forEach((file) => {
|
|
if (!file.name.endsWith('.json')) {
|
|
invalidFiles.push(file.name);
|
|
return;
|
|
}
|
|
if (file.size > MAX_AUTH_FILE_SIZE) {
|
|
oversizedFiles.push(file.name);
|
|
return;
|
|
}
|
|
validFiles.push(file);
|
|
});
|
|
|
|
if (invalidFiles.length > 0) {
|
|
showNotification(t('auth_files.upload_error_json'), 'error');
|
|
}
|
|
if (oversizedFiles.length > 0) {
|
|
showNotification(
|
|
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
|
'error'
|
|
);
|
|
}
|
|
|
|
if (validFiles.length === 0) {
|
|
event.target.value = '';
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
try {
|
|
const result = await authFilesApi.uploadFiles(validFiles);
|
|
const successCount = result.uploaded;
|
|
|
|
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 (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 = '';
|
|
}
|
|
},
|
|
[loadFiles, refreshKeyStats, showNotification, t]
|
|
);
|
|
|
|
const handleDelete = useCallback(
|
|
(name: string) => {
|
|
showConfirmation({
|
|
title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
|
|
message: `${t('auth_files.delete_confirm')} "${name}" ?`,
|
|
variant: 'danger',
|
|
confirmText: t('common.confirm'),
|
|
onConfirm: async () => {
|
|
setDeleting(name);
|
|
try {
|
|
const result = await authFilesApi.deleteFile(name);
|
|
showNotification(t('auth_files.delete_success'), 'success');
|
|
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');
|
|
} finally {
|
|
setDeleting(null);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
[applyDeletedFiles, showConfirmation, showNotification, t]
|
|
);
|
|
|
|
const handleDeleteAll = useCallback(
|
|
(deleteAllOptions: DeleteAllOptions) => {
|
|
const { filter, problemOnly, onResetFilterToAll, onResetProblemOnly } = deleteAllOptions;
|
|
const isFiltered = filter !== 'all';
|
|
const isProblemOnly = problemOnly === true;
|
|
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
|
|
const confirmMessage = isProblemOnly
|
|
? isFiltered
|
|
? t('auth_files.delete_problem_filtered_confirm', { type: typeLabel })
|
|
: t('auth_files.delete_problem_confirm')
|
|
: isFiltered
|
|
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
|
|
: t('auth_files.delete_all_confirm');
|
|
|
|
showConfirmation({
|
|
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
|
|
message: confirmMessage,
|
|
variant: 'danger',
|
|
confirmText: t('common.confirm'),
|
|
onConfirm: async () => {
|
|
setDeletingAll(true);
|
|
try {
|
|
if (!isFiltered && !isProblemOnly) {
|
|
await authFilesApi.deleteAll();
|
|
showNotification(t('auth_files.delete_all_success'), 'success');
|
|
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
|
deselectAll();
|
|
} else {
|
|
const filesToDelete = files.filter((file) => {
|
|
if (isRuntimeOnlyAuthFile(file)) return false;
|
|
if (isFiltered && file.type !== filter) return false;
|
|
if (isProblemOnly && !hasAuthFileStatusMessage(file)) return false;
|
|
return true;
|
|
});
|
|
|
|
if (filesToDelete.length === 0) {
|
|
const emptyMessage = isProblemOnly
|
|
? isFiltered
|
|
? t('auth_files.delete_problem_filtered_none', { type: typeLabel })
|
|
: t('auth_files.delete_problem_none')
|
|
: t('auth_files.delete_filtered_none', { type: typeLabel });
|
|
showNotification(emptyMessage, 'info');
|
|
setDeletingAll(false);
|
|
return;
|
|
}
|
|
|
|
const result = await authFilesApi.deleteFiles(
|
|
filesToDelete.map((file) => file.name)
|
|
);
|
|
const success = result.deleted;
|
|
const failed = result.failed.length;
|
|
|
|
applyDeletedFiles(result.files);
|
|
|
|
if (failed === 0 && isProblemOnly) {
|
|
showNotification(
|
|
isFiltered
|
|
? t('auth_files.delete_problem_filtered_success', {
|
|
count: success,
|
|
type: typeLabel,
|
|
})
|
|
: t('auth_files.delete_problem_success', { count: success }),
|
|
'success'
|
|
);
|
|
} else if (failed === 0) {
|
|
showNotification(
|
|
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
|
|
'success'
|
|
);
|
|
} else if (isProblemOnly) {
|
|
showNotification(
|
|
isFiltered
|
|
? t('auth_files.delete_problem_filtered_partial', {
|
|
success,
|
|
failed,
|
|
type: typeLabel,
|
|
})
|
|
: t('auth_files.delete_problem_partial', { success, failed }),
|
|
'warning'
|
|
);
|
|
} else {
|
|
showNotification(
|
|
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
|
|
'warning'
|
|
);
|
|
}
|
|
|
|
if (isFiltered) {
|
|
onResetFilterToAll();
|
|
}
|
|
if (isProblemOnly) {
|
|
onResetProblemOnly();
|
|
}
|
|
}
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : '';
|
|
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
|
} finally {
|
|
setDeletingAll(false);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
[applyDeletedFiles, deselectAll, files, showConfirmation, showNotification, t]
|
|
);
|
|
|
|
const handleDownload = useCallback(
|
|
async (name: string) => {
|
|
try {
|
|
const response = await apiClient.getRaw(
|
|
`/auth-files/download?name=${encodeURIComponent(name)}`,
|
|
{ responseType: 'blob' }
|
|
);
|
|
const blob = new Blob([response.data]);
|
|
downloadBlob({ filename: name, blob });
|
|
showNotification(t('auth_files.download_success'), 'success');
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : '';
|
|
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
|
|
}
|
|
},
|
|
[showNotification, t]
|
|
);
|
|
|
|
const handleStatusToggle = useCallback(
|
|
async (item: AuthFileItem, enabled: boolean) => {
|
|
const name = item.name;
|
|
const nextDisabled = !enabled;
|
|
const previousDisabled = item.disabled === true;
|
|
|
|
setStatusUpdating((prev) => ({ ...prev, [name]: true }));
|
|
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
|
|
|
|
try {
|
|
const res = await authFilesApi.setStatus(name, nextDisabled);
|
|
setFiles((prev) =>
|
|
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
|
|
);
|
|
showNotification(
|
|
enabled
|
|
? t('auth_files.status_enabled_success', { name })
|
|
: t('auth_files.status_disabled_success', { name }),
|
|
'success'
|
|
);
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : '';
|
|
setFiles((prev) =>
|
|
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
|
|
);
|
|
showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error');
|
|
} finally {
|
|
setStatusUpdating((prev) => {
|
|
if (!prev[name]) return prev;
|
|
const next = { ...prev };
|
|
delete next[name];
|
|
return next;
|
|
});
|
|
}
|
|
},
|
|
[showNotification, t]
|
|
);
|
|
|
|
const batchSetStatus = useCallback(
|
|
async (names: string[], enabled: boolean) => {
|
|
if (batchStatusPendingRef.current) return;
|
|
|
|
const uniqueNames = Array.from(new Set(names));
|
|
if (uniqueNames.length === 0) return;
|
|
if (uniqueNames.some((name) => statusUpdating[name] === true)) return;
|
|
|
|
const originalDisabled = new Map(
|
|
files
|
|
.filter((file) => uniqueNames.includes(file.name))
|
|
.map((file) => [file.name, file.disabled === true])
|
|
);
|
|
const targetNames = new Set(originalDisabled.keys());
|
|
const targetNameList = Array.from(targetNames);
|
|
if (targetNameList.length === 0) return;
|
|
|
|
const nextDisabled = !enabled;
|
|
|
|
batchStatusPendingRef.current = true;
|
|
setBatchStatusUpdating(true);
|
|
setStatusUpdating((prev) => {
|
|
const next = { ...prev };
|
|
targetNameList.forEach((name) => {
|
|
next[name] = true;
|
|
});
|
|
return next;
|
|
});
|
|
setFiles((prev) =>
|
|
prev.map((file) =>
|
|
targetNames.has(file.name) ? { ...file, disabled: nextDisabled } : file
|
|
)
|
|
);
|
|
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
targetNameList.map((name) => authFilesApi.setStatus(name, nextDisabled))
|
|
);
|
|
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
const failedNames = new Set<string>();
|
|
const confirmedDisabled = new Map<string, boolean>();
|
|
|
|
results.forEach((result, index) => {
|
|
const name = targetNameList[index];
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
confirmedDisabled.set(name, result.value.disabled);
|
|
} else {
|
|
failCount++;
|
|
failedNames.add(name);
|
|
}
|
|
});
|
|
|
|
setFiles((prev) =>
|
|
prev.map((file) => {
|
|
if (failedNames.has(file.name)) {
|
|
return { ...file, disabled: originalDisabled.get(file.name) === true };
|
|
}
|
|
if (confirmedDisabled.has(file.name)) {
|
|
return { ...file, disabled: confirmedDisabled.get(file.name) };
|
|
}
|
|
return file;
|
|
})
|
|
);
|
|
|
|
if (failCount === 0) {
|
|
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
|
|
} else {
|
|
showNotification(
|
|
t('auth_files.batch_status_partial', { success: successCount, failed: failCount }),
|
|
'warning'
|
|
);
|
|
}
|
|
|
|
deselectAll();
|
|
} finally {
|
|
batchStatusPendingRef.current = false;
|
|
setBatchStatusUpdating(false);
|
|
setStatusUpdating((prev) => {
|
|
const next = { ...prev };
|
|
targetNameList.forEach((name) => {
|
|
delete next[name];
|
|
});
|
|
return next;
|
|
});
|
|
}
|
|
},
|
|
[deselectAll, files, showNotification, statusUpdating, t]
|
|
);
|
|
|
|
const batchDownload = useCallback(
|
|
async (names: string[]) => {
|
|
const uniqueNames = Array.from(new Set(names));
|
|
if (uniqueNames.length === 0) return;
|
|
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
|
|
for (const name of uniqueNames) {
|
|
try {
|
|
const response = await apiClient.getRaw(
|
|
`/auth-files/download?name=${encodeURIComponent(name)}`,
|
|
{ responseType: 'blob' }
|
|
);
|
|
const blob = new Blob([response.data]);
|
|
downloadBlob({ filename: name, blob });
|
|
successCount++;
|
|
} catch {
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
if (failCount === 0) {
|
|
showNotification(
|
|
t('auth_files.batch_download_success', { count: successCount }),
|
|
'success'
|
|
);
|
|
} else {
|
|
showNotification(
|
|
t('auth_files.batch_download_partial', { success: successCount, failed: failCount }),
|
|
'warning'
|
|
);
|
|
}
|
|
},
|
|
[showNotification, t]
|
|
);
|
|
|
|
const batchDelete = useCallback(
|
|
(names: string[]) => {
|
|
const uniqueNames = Array.from(new Set(names));
|
|
if (uniqueNames.length === 0) return;
|
|
|
|
showConfirmation({
|
|
title: t('auth_files.batch_delete_title'),
|
|
message: t('auth_files.batch_delete_confirm', { count: uniqueNames.length }),
|
|
variant: 'danger',
|
|
confirmText: t('common.confirm'),
|
|
onConfirm: async () => {
|
|
try {
|
|
const result = await authFilesApi.deleteFiles(uniqueNames);
|
|
applyDeletedFiles(result.files);
|
|
|
|
if (result.failed.length === 0) {
|
|
showNotification(
|
|
`${t('auth_files.delete_all_success')} (${result.deleted})`,
|
|
'success'
|
|
);
|
|
} else {
|
|
showNotification(
|
|
t('auth_files.delete_filtered_partial', {
|
|
success: result.deleted,
|
|
failed: result.failed.length,
|
|
type: t('auth_files.filter_all'),
|
|
}),
|
|
'warning'
|
|
);
|
|
}
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : '';
|
|
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
|
}
|
|
},
|
|
});
|
|
},
|
|
[applyDeletedFiles, showConfirmation, showNotification, t]
|
|
);
|
|
|
|
return {
|
|
files,
|
|
selectedFiles,
|
|
selectionCount,
|
|
loading,
|
|
error,
|
|
uploading,
|
|
deleting,
|
|
deletingAll,
|
|
statusUpdating,
|
|
batchStatusUpdating,
|
|
fileInputRef,
|
|
loadFiles,
|
|
handleUploadClick,
|
|
handleFileChange,
|
|
handleDelete,
|
|
handleDeleteAll,
|
|
handleDownload,
|
|
handleStatusToggle,
|
|
toggleSelect,
|
|
selectAllVisible,
|
|
invertVisibleSelection,
|
|
deselectAll,
|
|
batchDownload,
|
|
batchSetStatus,
|
|
batchDelete,
|
|
};
|
|
}
|