diff --git a/src/features/authFiles/components/AuthFileCard.tsx b/src/features/authFiles/components/AuthFileCard.tsx
index f92acc7..5bec54e 100644
--- a/src/features/authFiles/components/AuthFileCard.tsx
+++ b/src/features/authFiles/components/AuthFileCard.tsx
@@ -25,6 +25,7 @@ import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFileCardProps = {
file: AuthFileItem;
+ selected: boolean;
resolvedTheme: ResolvedTheme;
disableControls: boolean;
deleting: string | null;
@@ -38,6 +39,7 @@ export type AuthFileCardProps = {
onOpenPrefixProxyEditor: (name: string) => void;
onDelete: (name: string) => void;
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
+ onToggleSelect: (name: string) => void;
};
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
@@ -50,6 +52,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
const { t } = useTranslation();
const {
file,
+ selected,
resolvedTheme,
disableControls,
deleting,
@@ -62,7 +65,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
onDownload,
onOpenPrefixProxyEditor,
onDelete,
- onToggleStatus
+ onToggleStatus,
+ onToggleSelect
} = props;
const fileStats = resolveAuthFileStats(file, keyStats);
@@ -92,11 +96,20 @@ export function AuthFileCard(props: AuthFileCardProps) {
return (
+ {!isRuntimeOnly && (
+
onToggleSelect(file.name)}
+ aria-label={t('auth_files.batch_select_all')}
+ />
+ )}
;
+ selectionCount: number;
loading: boolean;
error: string;
uploading: boolean;
@@ -29,6 +31,11 @@ export type UseAuthFilesDataResult = {
handleDeleteAll: (options: DeleteAllOptions) => void;
handleDownload: (name: string) => Promise;
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise;
+ toggleSelect: (name: string) => void;
+ selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
+ deselectAll: () => void;
+ batchSetStatus: (names: string[], enabled: boolean) => Promise;
+ batchDelete: (names: string[]) => void;
};
export type UseAuthFilesDataOptions = {
@@ -47,8 +54,50 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
const [deleting, setDeleting] = useState(null);
const [deletingAll, setDeletingAll] = useState(false);
const [statusUpdating, setStatusUpdating] = useState>({});
+ const [selectedFiles, setSelectedFiles] = useState>(new Set());
const fileInputRef = useRef(null);
+ 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);
+ setSelectedFiles(new Set(nextSelected));
+ }, []);
+
+ const deselectAll = useCallback(() => {
+ setSelectedFiles(new Set());
+ }, []);
+
+ useEffect(() => {
+ if (selectedFiles.size === 0) return;
+ const existingNames = new Set(files.map((file) => file.name));
+ setSelectedFiles((prev) => {
+ let changed = false;
+ const next = new Set();
+ 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);
@@ -153,6 +202,12 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
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;
+ });
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
@@ -186,6 +241,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
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)
@@ -212,6 +268,20 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
}
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();
+ prev.forEach((name) => {
+ if (deletedSet.has(name)) {
+ changed = true;
+ } else {
+ next.add(name);
+ }
+ });
+ return changed ? next : prev;
+ });
if (failed === 0) {
showNotification(
@@ -235,7 +305,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
}
});
},
- [files, showConfirmation, showNotification, t]
+ [deselectAll, files, showConfirmation, showNotification, t]
);
const handleDownload = useCallback(
@@ -299,8 +369,133 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
[showNotification, t]
);
+ const batchSetStatus = useCallback(
+ async (names: string[], enabled: boolean) => {
+ const uniqueNames = Array.from(new Set(names));
+ if (uniqueNames.length === 0) return;
+
+ const targetNames = new Set(uniqueNames);
+ const nextDisabled = !enabled;
+
+ setFiles((prev) =>
+ prev.map((file) =>
+ targetNames.has(file.name) ? { ...file, disabled: nextDisabled } : file
+ )
+ );
+
+ const results = await Promise.allSettled(
+ uniqueNames.map((name) => authFilesApi.setStatus(name, nextDisabled))
+ );
+
+ let successCount = 0;
+ let failCount = 0;
+ const failedNames = new Set();
+ const confirmedDisabled = new Map();
+
+ results.forEach((result, index) => {
+ const name = uniqueNames[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: !nextDisabled };
+ }
+ 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();
+ },
+ [deselectAll, 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 () => {
+ const results = await Promise.allSettled(
+ uniqueNames.map((name) => authFilesApi.deleteFile(name))
+ );
+
+ const deleted: string[] = [];
+ let failCount = 0;
+ results.forEach((result, index) => {
+ if (result.status === 'fulfilled') {
+ deleted.push(uniqueNames[index]);
+ } else {
+ failCount++;
+ }
+ });
+
+ 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();
+ 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'
+ );
+ }
+ }
+ });
+ },
+ [showConfirmation, showNotification, t]
+ );
+
return {
files,
+ selectedFiles,
+ selectionCount,
loading,
error,
uploading,
@@ -314,6 +509,11 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
handleDelete,
handleDeleteAll,
handleDownload,
- handleStatusToggle
+ handleStatusToggle,
+ toggleSelect,
+ selectAllVisible,
+ deselectAll,
+ batchSetStatus,
+ batchDelete
};
}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index dfa9ea4..de56b53 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -425,6 +425,15 @@
"status_toggle_label": "Enabled",
"status_enabled_success": "\"{{name}}\" enabled",
"status_disabled_success": "\"{{name}}\" disabled",
+ "batch_status_success": "{{count}} files updated successfully",
+ "batch_status_partial": "{{success}} updated, {{failed}} failed",
+ "batch_delete_title": "Delete Selected Files",
+ "batch_delete_confirm": "Are you sure you want to delete {{count}} files?",
+ "batch_selected": "{{count}} selected",
+ "batch_select_all": "Select All",
+ "batch_deselect": "Deselect",
+ "batch_enable": "Enable",
+ "batch_disable": "Disable",
"prefix_proxy_button": "Edit Auth Fields",
"auth_field_editor_title": "Edit Auth Fields - {{name}}",
"prefix_proxy_loading": "Loading auth file...",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index 87aff1e..dc20afe 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -425,6 +425,15 @@
"status_toggle_label": "Включено",
"status_enabled_success": "\"{{name}}\" включён",
"status_disabled_success": "\"{{name}}\" отключён",
+ "batch_status_success": "{{count}} файлов обновлено",
+ "batch_status_partial": "{{success}} обновлено, {{failed}} не удалось",
+ "batch_delete_title": "Удалить выбранные файлы",
+ "batch_delete_confirm": "Удалить {{count}} файлов?",
+ "batch_selected": "{{count}} выбрано",
+ "batch_select_all": "Выбрать все",
+ "batch_deselect": "Отменить",
+ "batch_enable": "Включить",
+ "batch_disable": "Отключить",
"prefix_proxy_button": "Редактировать поля файла авторизации",
"auth_field_editor_title": "Редактировать поля файла авторизации - {{name}}",
"prefix_proxy_loading": "Загрузка файла авторизации...",
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 043316d..29b2f32 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -425,6 +425,15 @@
"status_toggle_label": "启用",
"status_enabled_success": "已启用 \"{{name}}\"",
"status_disabled_success": "已停用 \"{{name}}\"",
+ "batch_status_success": "已成功更新 {{count}} 个文件",
+ "batch_status_partial": "成功 {{success}} 个,失败 {{failed}} 个",
+ "batch_delete_title": "删除选中文件",
+ "batch_delete_confirm": "确定要删除 {{count}} 个文件吗?",
+ "batch_selected": "已选 {{count}} 项",
+ "batch_select_all": "全选",
+ "batch_deselect": "取消选择",
+ "batch_enable": "启用",
+ "batch_disable": "禁用",
"prefix_proxy_button": "编辑认证文件字段",
"auth_field_editor_title": "编辑认证文件字段 - {{name}}",
"prefix_proxy_loading": "正在加载认证文件...",
diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss
index 626ffb8..357a564 100644
--- a/src/pages/AuthFilesPage.module.scss
+++ b/src/pages/AuthFilesPage.module.scss
@@ -5,6 +5,7 @@
display: flex;
flex-direction: column;
gap: $spacing-lg;
+ padding-bottom: calc(var(--auth-files-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom));
}
.pageHeader {
@@ -497,6 +498,15 @@
}
}
+.fileCardSelected {
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 1px var(--primary-color);
+
+ &:hover {
+ border-color: var(--primary-color);
+ }
+}
+
.fileCardDisabled {
opacity: 0.6;
@@ -528,6 +538,15 @@
min-height: 28px;
}
+.selectionCheckbox {
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ flex-shrink: 0;
+ accent-color: var(--primary-color);
+ cursor: pointer;
+}
+
.typeBadge {
padding: 4px 10px;
border-radius: 12px;
@@ -878,6 +897,65 @@
border-top: 1px solid var(--border-color);
}
+.batchActionContainer {
+ position: fixed;
+ left: var(--content-center-x, 50%);
+ bottom: calc(16px + env(safe-area-inset-bottom));
+ transform: translateX(-50%);
+ z-index: 50;
+ width: min(960px, calc(100vw - 24px));
+}
+
+.batchActionBar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: $spacing-sm;
+ padding: 10px 12px;
+ border-radius: $radius-lg;
+ border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
+ background: color-mix(in srgb, var(--bg-primary) 84%, transparent);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ box-shadow: var(--shadow-lg);
+}
+
+.batchActionLeft,
+.batchActionRight {
+ display: flex;
+ align-items: center;
+ gap: $spacing-xs;
+ flex-wrap: wrap;
+}
+
+.batchActionRight {
+ justify-content: flex-end;
+}
+
+.batchSelectionText {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-right: 2px;
+}
+
+@include mobile {
+ .batchActionContainer {
+ width: calc(100vw - 16px);
+ bottom: calc(12px + env(safe-area-inset-bottom));
+ }
+
+ .batchActionBar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .batchActionLeft,
+ .batchActionRight {
+ justify-content: center;
+ }
+}
+
.pageInfo {
font-size: 13px;
color: var(--text-secondary);
diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx
index 3cd39e2..a9a2079 100644
--- a/src/pages/AuthFilesPage.tsx
+++ b/src/pages/AuthFilesPage.tsx
@@ -1,4 +1,5 @@
-import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
+import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useInterval } from '@/hooks/useInterval';
@@ -16,6 +17,7 @@ import {
clampCardPageSize,
getTypeColor,
getTypeLabel,
+ isRuntimeOnlyAuthFile,
normalizeProviderKey,
type QuotaProviderType,
type ResolvedTheme
@@ -54,10 +56,13 @@ export function AuthFilesPage() {
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
+ const floatingBatchActionsRef = useRef(null);
const { keyStats, usageDetails, loadKeyStats } = useAuthFilesStats();
const {
files,
+ selectedFiles,
+ selectionCount,
loading,
error,
uploading,
@@ -71,7 +76,12 @@ export function AuthFilesPage() {
handleDelete,
handleDeleteAll,
handleDownload,
- handleStatusToggle
+ handleStatusToggle,
+ toggleSelect,
+ selectAllVisible,
+ deselectAll,
+ batchSetStatus,
+ batchDelete
} = useAuthFilesData({ refreshKeyStats: loadKeyStats });
const statusBarCache = useAuthFilesStatusBarCache(files, usageDetails);
@@ -240,6 +250,11 @@ export function AuthFilesPage() {
const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * pageSize;
const pageItems = filtered.slice(start, start + pageSize);
+ const selectablePageItems = useMemo(
+ () => pageItems.filter((file) => !isRuntimeOnlyAuthFile(file)),
+ [pageItems]
+ );
+ const selectedNames = useMemo(() => Array.from(selectedFiles), [selectedFiles]);
const showDetails = (file: AuthFileItem) => {
setSelectedFile(file);
@@ -289,6 +304,33 @@ export function AuthFilesPage() {
[filter, navigate]
);
+ useLayoutEffect(() => {
+ if (typeof window === 'undefined') return;
+
+ const actionsEl = floatingBatchActionsRef.current;
+ if (!actionsEl) {
+ document.documentElement.style.removeProperty('--auth-files-action-bar-height');
+ return;
+ }
+
+ const updatePadding = () => {
+ const height = actionsEl.getBoundingClientRect().height;
+ document.documentElement.style.setProperty('--auth-files-action-bar-height', `${height}px`);
+ };
+
+ updatePadding();
+ window.addEventListener('resize', updatePadding);
+
+ const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePadding);
+ ro?.observe(actionsEl);
+
+ return () => {
+ ro?.disconnect();
+ window.removeEventListener('resize', updatePadding);
+ document.documentElement.style.removeProperty('--auth-files-action-bar-height');
+ };
+ }, [selectionCount]);
+
const renderFilterTags = () => (
{existingTypes.map((type) => {
@@ -419,6 +461,7 @@ export function AuthFilesPage() {
))}
@@ -520,6 +564,57 @@ export function AuthFilesPage() {
onSave={handlePrefixProxySave}
onChange={handlePrefixProxyChange}
/>
+
+ {selectionCount > 0 && typeof document !== 'undefined'
+ ? createPortal(
+
+
+
+
+ {t('auth_files.batch_selected', { count: selectionCount })}
+
+
+
+
+
+
+
+
+
+
+
,
+ document.body
+ )
+ : null}
);
}