diff --git a/src/features/authFiles/components/AuthFileCard.tsx b/src/features/authFiles/components/AuthFileCard.tsx index 562c882..9e7b910 100644 --- a/src/features/authFiles/components/AuthFileCard.tsx +++ b/src/features/authFiles/components/AuthFileCard.tsx @@ -18,6 +18,7 @@ import { formatFileSize } from '@/utils/format'; import { QUOTA_PROVIDER_TYPES, formatModified, + getAuthFileStatusMessage, getTypeColor, getTypeLabel, isRuntimeOnlyAuthFile, @@ -105,7 +106,7 @@ export function AuthFileCard(props: AuthFileCardProps) { const authIndexKey = normalizeAuthIndex(rawAuthIndex); const statusData = (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]); - const rawStatusMessage = String(file['status_message'] ?? file.statusMessage ?? '').trim(); + const rawStatusMessage = getAuthFileStatusMessage(file); const hasStatusWarning = Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase()); diff --git a/src/features/authFiles/constants.ts b/src/features/authFiles/constants.ts index 3ae1729..8c02825 100644 --- a/src/features/authFiles/constants.ts +++ b/src/features/authFiles/constants.ts @@ -93,6 +93,16 @@ export const resolveQuotaErrorMessage = ( export const normalizeProviderKey = (value: string) => value.trim().toLowerCase(); +export const getAuthFileStatusMessage = (file: AuthFileItem): string => { + const raw = file['status_message'] ?? file.statusMessage; + if (typeof raw === 'string') return raw.trim(); + if (raw == null) return ''; + return String(raw).trim(); +}; + +export const hasAuthFileStatusMessage = (file: AuthFileItem): boolean => + getAuthFileStatusMessage(file).length > 0; + export const getTypeLabel = (t: TFunction, type: string): string => { const key = `auth_files.filter_${type}`; const translated = t(key); diff --git a/src/features/authFiles/hooks/useAuthFilesData.ts b/src/features/authFiles/hooks/useAuthFilesData.ts index 34d5a9f..a55b5f9 100644 --- a/src/features/authFiles/hooks/useAuthFilesData.ts +++ b/src/features/authFiles/hooks/useAuthFilesData.ts @@ -7,11 +7,17 @@ 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, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants'; +import { + getTypeLabel, + hasAuthFileStatusMessage, + isRuntimeOnlyAuthFile, +} from '@/features/authFiles/constants'; type DeleteAllOptions = { filter: string; + problemOnly: boolean; onResetFilterToAll: () => void; + onResetProblemOnly: () => void; }; export type UseAuthFilesDataResult = { @@ -233,12 +239,17 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles const handleDeleteAll = useCallback( (deleteAllOptions: DeleteAllOptions) => { - const { filter, onResetFilterToAll } = 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 = isFiltered - ? t('auth_files.delete_filtered_confirm', { type: typeLabel }) - : t('auth_files.delete_all_confirm'); + 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' }), @@ -248,18 +259,26 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles onConfirm: async () => { setDeletingAll(true); try { - if (!isFiltered) { + 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( - (f) => f.type === filter && !isRuntimeOnlyAuthFile(f) - ); + 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) { - showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info'); + 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; } @@ -294,18 +313,45 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles return changed ? next : prev; }); - if (failed === 0) { + 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' ); } - onResetFilterToAll(); + + if (isFiltered) { + onResetFilterToAll(); + } + if (isProblemOnly) { + onResetProblemOnly(); + } } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; diff --git a/src/features/authFiles/uiState.ts b/src/features/authFiles/uiState.ts index c23efe2..effa815 100644 --- a/src/features/authFiles/uiState.ts +++ b/src/features/authFiles/uiState.ts @@ -1,5 +1,6 @@ export type AuthFilesUiState = { filter?: string; + problemOnly?: boolean; search?: string; page?: number; pageSize?: number; @@ -27,4 +28,3 @@ export const writeAuthFilesUiState = (state: AuthFilesUiState) => { // ignore } }; - diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 93a26e1..4cbbda5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -469,6 +469,10 @@ "delete_confirm": "Are you sure you want to delete file", "delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!", "delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!", + "delete_problem_button": "Delete Problem Files", + "delete_problem_button_with_type": "Delete Problematic {{type}} Files", + "delete_problem_confirm": "Are you sure you want to delete all problematic auth files? This operation cannot be undone!", + "delete_problem_filtered_confirm": "Are you sure you want to delete all problematic {{type}} auth files? This operation cannot be undone!", "upload_error_json": "Only JSON files are allowed", "upload_error_size": "File size cannot exceed {{maxSize}}", "upload_success": "File uploaded successfully", @@ -478,12 +482,20 @@ "delete_filtered_success": "Deleted {{count}} {{type}} auth files successfully", "delete_filtered_partial": "{{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed", "delete_filtered_none": "No deletable auth files under the current filter ({{type}})", + "delete_problem_success": "Deleted {{count}} problematic auth files successfully", + "delete_problem_filtered_success": "Deleted {{count}} problematic {{type}} auth files successfully", + "delete_problem_partial": "Problematic auth files deletion finished: {{success}} succeeded, {{failed}} failed", + "delete_problem_filtered_partial": "Problematic {{type}} auth files deletion finished: {{success}} succeeded, {{failed}} failed", + "delete_problem_none": "No deletable problematic auth files under the current filter", + "delete_problem_filtered_none": "No deletable problematic {{type}} auth files under the current filter", "files_count": "files", "pagination_prev": "Previous", "pagination_next": "Next", "pagination_info": "Page {{current}} / {{total}} · {{count}} files", "search_label": "Search configs", "search_placeholder": "Filter by name, type, or provider", + "problem_filter_label": "Problem Filter", + "problem_filter_only": "Only show problematic credentials", "page_size_label": "Per page", "page_size_unit": "items", "view_mode_paged": "Paged", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 23c7f44..4861fe6 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -469,6 +469,10 @@ "delete_confirm": "Удалить файл", "delete_all_confirm": "Удалить все файлы авторизации? Это действие нельзя отменить!", "delete_filtered_confirm": "Удалить все файлы авторизации {{type}}? Это действие нельзя отменить!", + "delete_problem_button": "Удалить проблемные", + "delete_problem_button_with_type": "Удалить проблемные файлы {{type}}", + "delete_problem_confirm": "Удалить все проблемные файлы авторизации? Это действие нельзя отменить!", + "delete_problem_filtered_confirm": "Удалить все проблемные файлы авторизации {{type}}? Это действие нельзя отменить!", "upload_error_json": "Допустимы только файлы JSON", "upload_error_size": "Размер файла не может превышать {{maxSize}}", "upload_success": "Файл успешно загружен", @@ -478,12 +482,20 @@ "delete_filtered_success": "Удалено файлов {{type}}: {{count}}", "delete_filtered_partial": "Удаление файлов {{type}} завершено: успешных {{success}}, ошибок {{failed}}", "delete_filtered_none": "Нет файлов {{type}} для удаления при текущем фильтре", + "delete_problem_success": "Удалено проблемных файлов авторизации: {{count}}", + "delete_problem_filtered_success": "Удалено проблемных файлов авторизации {{type}}: {{count}}", + "delete_problem_partial": "Удаление проблемных файлов авторизации завершено: успешных {{success}}, ошибок {{failed}}", + "delete_problem_filtered_partial": "Удаление проблемных файлов авторизации {{type}} завершено: успешных {{success}}, ошибок {{failed}}", + "delete_problem_none": "Нет проблемных файлов авторизации для удаления при текущем фильтре", + "delete_problem_filtered_none": "Нет проблемных файлов авторизации {{type}} для удаления при текущем фильтре", "files_count": "файлов", "pagination_prev": "Предыдущая", "pagination_next": "Следующая", "pagination_info": "Страница {{current}} / {{total}} · {{count}} файлов", "search_label": "Поиск конфигов", "search_placeholder": "Фильтр по имени, типу или провайдеру", + "problem_filter_label": "Фильтр проблем", + "problem_filter_only": "Показывать только проблемные учётные данные", "page_size_label": "На странице", "page_size_unit": "элементов", "view_mode_paged": "Постранично", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5d3b522..bab2763 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -469,6 +469,10 @@ "delete_confirm": "确定要删除文件", "delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!", "delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!", + "delete_problem_button": "删除问题凭证", + "delete_problem_button_with_type": "删除 {{type}} 问题凭证", + "delete_problem_confirm": "确定要删除所有有问题的认证文件吗?此操作不可恢复!", + "delete_problem_filtered_confirm": "确定要删除筛选出的有问题的 {{type}} 认证文件吗?此操作不可恢复!", "upload_error_json": "只能上传JSON文件", "upload_error_size": "文件大小不能超过 {{maxSize}}", "upload_success": "文件上传成功", @@ -478,12 +482,20 @@ "delete_filtered_success": "成功删除 {{count}} 个 {{type}} 认证文件", "delete_filtered_partial": "{{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个", "delete_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的认证文件", + "delete_problem_success": "成功删除 {{count}} 个有问题的认证文件", + "delete_problem_filtered_success": "成功删除 {{count}} 个有问题的 {{type}} 认证文件", + "delete_problem_partial": "有问题认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个", + "delete_problem_filtered_partial": "有问题的 {{type}} 认证文件删除完成,成功 {{success}} 个,失败 {{failed}} 个", + "delete_problem_none": "当前没有可删除的有问题认证文件", + "delete_problem_filtered_none": "当前筛选类型 ({{type}}) 下没有可删除的有问题认证文件", "files_count": "个文件", "pagination_prev": "上一页", "pagination_next": "下一页", "pagination_info": "第 {{current}} / {{total}} 页 · 共 {{count}} 个文件", "search_label": "搜索配置文件", "search_placeholder": "输入名称、类型或提供方关键字", + "problem_filter_label": "问题筛选", + "problem_filter_only": "仅显示有问题凭证", "page_size_label": "单页数量", "page_size_unit": "个/页", "view_mode_paged": "按页显示", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 0eb056f..b284893 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -161,6 +161,25 @@ } } +.filterToggleItem { + min-width: 220px; +} + +.filterToggle { + display: flex; + align-items: center; + min-height: 38px; +} + +.filterToggleLabel { + display: inline-flex; + align-items: center; + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + white-space: nowrap; +} + .pageSizeSelect { padding: 8px 12px; border: 1px solid var(--border-color); diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 47aa141..cda317b 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -19,6 +19,7 @@ import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { EmptyState } from '@/components/ui/EmptyState'; +import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { copyToClipboard } from '@/utils/clipboard'; import { MAX_CARD_PAGE_SIZE, @@ -27,6 +28,7 @@ import { clampCardPageSize, getTypeColor, getTypeLabel, + hasAuthFileStatusMessage, isRuntimeOnlyAuthFile, normalizeProviderKey, type QuotaProviderType, @@ -64,6 +66,7 @@ export function AuthFilesPage() { const navigate = useNavigate(); const [filter, setFilter] = useState<'all' | string>('all'); + const [problemOnly, setProblemOnly] = useState(false); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(9); @@ -162,6 +165,9 @@ export function AuthFilesPage() { if (typeof persisted.filter === 'string' && persisted.filter.trim()) { setFilter(persisted.filter); } + if (typeof persisted.problemOnly === 'boolean') { + setProblemOnly(persisted.problemOnly); + } if (typeof persisted.search === 'string') { setSearch(persisted.search); } @@ -174,8 +180,8 @@ export function AuthFilesPage() { }, []); useEffect(() => { - writeAuthFilesUiState({ filter, search, page, pageSize }); - }, [filter, search, page, pageSize]); + writeAuthFilesUiState({ filter, problemOnly, search, page, pageSize }); + }, [filter, problemOnly, search, page, pageSize]); useEffect(() => { setPageSizeInput(String(pageSize)); @@ -248,17 +254,22 @@ export function AuthFilesPage() { return Array.from(types); }, [files]); + const filesMatchingProblemFilter = useMemo( + () => (problemOnly ? files.filter(hasAuthFileStatusMessage) : files), + [files, problemOnly] + ); + const typeCounts = useMemo(() => { - const counts: Record = { all: files.length }; - files.forEach((file) => { + const counts: Record = { all: filesMatchingProblemFilter.length }; + filesMatchingProblemFilter.forEach((file) => { if (!file.type) return; counts[file.type] = (counts[file.type] || 0) + 1; }); return counts; - }, [files]); + }, [filesMatchingProblemFilter]); const filtered = useMemo(() => { - return files.filter((item) => { + return filesMatchingProblemFilter.filter((item) => { const matchType = filter === 'all' || item.type === filter; const term = search.trim().toLowerCase(); const matchSearch = @@ -268,7 +279,7 @@ export function AuthFilesPage() { (item.provider || '').toString().toLowerCase().includes(term); return matchType && matchSearch; }); - }, [files, filter, search]); + }, [filesMatchingProblemFilter, filter, search]); const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const currentPage = Math.min(page, totalPages); @@ -456,6 +467,14 @@ export function AuthFilesPage() { ); + const deleteAllButtonLabel = problemOnly + ? filter === 'all' + ? t('auth_files.delete_problem_button') + : t('auth_files.delete_problem_button_with_type', { type: getTypeLabel(t, filter) }) + : filter === 'all' + ? t('auth_files.delete_all_button') + : `${t('common.delete')} ${getTypeLabel(t, filter)}`; + return (
@@ -482,14 +501,17 @@ export function AuthFilesPage() { variant="danger" size="sm" onClick={() => - handleDeleteAll({ filter, onResetFilterToAll: () => setFilter('all') }) + handleDeleteAll({ + filter, + problemOnly, + onResetFilterToAll: () => setFilter('all'), + onResetProblemOnly: () => setProblemOnly(false), + }) } disabled={disableControls || loading || deletingAll} loading={deletingAll} > - {filter === 'all' - ? t('auth_files.delete_all_button') - : `${t('common.delete')} ${getTypeLabel(t, filter)}`} + {deleteAllButtonLabel}
+
+ +
+ { + setProblemOnly(value); + setPage(1); + }} + ariaLabel={t('auth_files.problem_filter_only')} + label={ + + {t('auth_files.problem_filter_only')} + + } + /> +
+