From 470ff515796e80be84d8e29ecdd748b342c00884 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Mon, 16 Feb 2026 21:58:22 +0800 Subject: [PATCH] feat(auth-files): add bulk select, status toggle, and delete actions --- .../authFiles/components/AuthFileCard.tsx | 17 +- .../authFiles/hooks/useAuthFilesData.ts | 206 +++++++++++++++++- src/i18n/locales/en.json | 9 + src/i18n/locales/ru.json | 9 + src/i18n/locales/zh-CN.json | 9 + src/pages/AuthFilesPage.module.scss | 78 +++++++ src/pages/AuthFilesPage.tsx | 99 ++++++++- 7 files changed, 420 insertions(+), 7 deletions(-) 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}
); }