feat(auth-files): add bulk select, status toggle, and delete actions

This commit is contained in:
Supra4E8C
2026-02-16 21:58:22 +08:00
parent d09ea6aeab
commit 470ff51579
7 changed files with 420 additions and 7 deletions

View File

@@ -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 (
<div
className={`${styles.fileCard} ${providerCardClass} ${file.disabled ? styles.fileCardDisabled : ''}`}
className={`${styles.fileCard} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
>
<div className={styles.fileCardLayout}>
<div className={styles.fileCardMain}>
<div className={styles.cardHeader}>
{!isRuntimeOnly && (
<input
type="checkbox"
className={styles.selectionCheckbox}
checked={selected}
onChange={() => onToggleSelect(file.name)}
aria-label={t('auth_files.batch_select_all')}
/>
)}
<span
className={styles.typeBadge}
style={{

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState, type ChangeEvent, type RefObject } from 'react';
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';
@@ -15,6 +15,8 @@ type DeleteAllOptions = {
export type UseAuthFilesDataResult = {
files: AuthFileItem[];
selectedFiles: Set<string>;
selectionCount: number;
loading: boolean;
error: string;
uploading: boolean;
@@ -29,6 +31,11 @@ export type UseAuthFilesDataResult = {
handleDeleteAll: (options: DeleteAllOptions) => void;
handleDownload: (name: string) => Promise<void>;
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
toggleSelect: (name: string) => void;
selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
deselectAll: () => void;
batchSetStatus: (names: string[], enabled: boolean) => Promise<void>;
batchDelete: (names: string[]) => void;
};
export type UseAuthFilesDataOptions = {
@@ -47,8 +54,50 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
const [deleting, setDeleting] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const fileInputRef = useRef<HTMLInputElement | null>(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<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);
@@ -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<string>();
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<string>();
const confirmedDisabled = new Map<string, boolean>();
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<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'
);
}
}
});
},
[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
};
}

View File

@@ -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...",

View File

@@ -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": "Загрузка файла авторизации...",

View File

@@ -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": "正在加载认证文件...",

View File

@@ -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);

View File

@@ -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<AuthFileItem | null>(null);
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const floatingBatchActionsRef = useRef<HTMLDivElement>(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 = () => (
<div className={styles.filterTags}>
{existingTypes.map((type) => {
@@ -419,6 +461,7 @@ export function AuthFilesPage() {
<AuthFileCard
key={file.name}
file={file}
selected={selectedFiles.has(file.name)}
resolvedTheme={resolvedTheme}
disableControls={disableControls}
deleting={deleting}
@@ -432,6 +475,7 @@ export function AuthFilesPage() {
onOpenPrefixProxyEditor={openPrefixProxyEditor}
onDelete={handleDelete}
onToggleStatus={handleStatusToggle}
onToggleSelect={toggleSelect}
/>
))}
</div>
@@ -520,6 +564,57 @@ export function AuthFilesPage() {
onSave={handlePrefixProxySave}
onChange={handlePrefixProxyChange}
/>
{selectionCount > 0 && typeof document !== 'undefined'
? createPortal(
<div className={styles.batchActionContainer} ref={floatingBatchActionsRef}>
<div className={styles.batchActionBar}>
<div className={styles.batchActionLeft}>
<span className={styles.batchSelectionText}>
{t('auth_files.batch_selected', { count: selectionCount })}
</span>
<Button
variant="secondary"
size="sm"
onClick={() => selectAllVisible(pageItems)}
disabled={selectablePageItems.length === 0}
>
{t('auth_files.batch_select_all')}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll}>
{t('auth_files.batch_deselect')}
</Button>
</div>
<div className={styles.batchActionRight}>
<Button
size="sm"
onClick={() => batchSetStatus(selectedNames, true)}
disabled={disableControls || selectedNames.length === 0}
>
{t('auth_files.batch_enable')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => batchSetStatus(selectedNames, false)}
disabled={disableControls || selectedNames.length === 0}
>
{t('auth_files.batch_disable')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => batchDelete(selectedNames)}
disabled={disableControls || selectedNames.length === 0}
>
{t('common.delete')}
</Button>
</div>
</div>
</div>,
document.body
)
: null}
</div>
);
}