feat(auth-files): revamp Auth Files page filters and card layout

This commit is contained in:
Supra4E8C
2026-03-22 13:30:53 +08:00
parent faf1e3fbab
commit 975d0471f7
4 changed files with 1041 additions and 445 deletions
@@ -3,7 +3,14 @@ import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconBot, IconDownload, IconSettings, IconTrash2 } from '@/components/ui/icons';
import {
IconBot,
IconDownload,
IconInfo,
IconSettings,
IconShield,
IconTrash2,
} from '@/components/ui/icons';
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
import type { AuthFileItem } from '@/types';
import { resolveAuthProvider } from '@/utils/quota';
@@ -12,6 +19,7 @@ import { formatFileSize } from '@/utils/format';
import {
QUOTA_PROVIDER_TYPES,
formatModified,
getAuthFileIcon,
getAuthFileStatusMessage,
getTypeColor,
getTypeLabel,
@@ -76,6 +84,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
const isAistudio = (file.type || '').toLowerCase() === 'aistudio';
const showModelsButton = !isRuntimeOnly || isAistudio;
const typeColor = getTypeColor(file.type || 'unknown', resolvedTheme);
const typeLabel = getTypeLabel(t, file.type || 'unknown');
const providerIcon = getAuthFileIcon(file.type || 'unknown', resolvedTheme);
const quotaType =
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
@@ -105,6 +115,22 @@ export function AuthFileCard(props: AuthFileCardProps) {
const priorityValue = parsePriorityValue(file.priority ?? file['priority']);
const noteValue = typeof file.note === 'string' ? file.note.trim() : '';
const stateLabel = isRuntimeOnly
? t('auth_files.type_virtual') || '虚拟认证文件'
: file.disabled
? t('auth_files.health_status_disabled')
: hasStatusWarning
? t('auth_files.health_status_warning')
: rawStatusMessage
? t('auth_files.health_status_healthy')
: t('auth_files.status_toggle_label');
const stateBadgeClass = isRuntimeOnly
? styles.stateBadgeVirtual
: file.disabled
? styles.stateBadgeDisabled
: hasStatusWarning
? styles.stateBadgeWarning
: styles.stateBadgeActive;
return (
<div
@@ -117,125 +143,173 @@ export function AuthFileCard(props: AuthFileCardProps) {
<SelectionCheckbox
checked={selected}
onChange={() => onToggleSelect(file.name)}
className={styles.cardSelection}
aria-label={
selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')
}
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
/>
)}
<span
className={styles.typeBadge}
<div
className={styles.providerAvatar}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {}),
}}
>
{getTypeLabel(t, file.type || 'unknown')}
</span>
<span className={styles.fileName}>{file.name}</span>
{providerIcon ? (
<img src={providerIcon} alt="" className={styles.providerAvatarImage} />
) : (
<span className={styles.providerAvatarFallback}>
{typeLabel.slice(0, 1).toUpperCase()}
</span>
)}
</div>
<div className={styles.cardHeaderContent}>
<div className={styles.cardBadgeRow}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {}),
}}
>
{typeLabel}
</span>
<span className={`${styles.stateBadge} ${stateBadgeClass}`}>{stateLabel}</span>
</div>
<span className={styles.fileName}>{file.name}</span>
{noteValue && (
<div className={styles.noteText} title={noteValue}>
<span className={styles.noteLabel}>{t('auth_files.note_display')}</span>
<span className={styles.noteValue}>{noteValue}</span>
</div>
)}
</div>
</div>
<div className={styles.cardMeta}>
<span>
{t('auth_files.file_size')}: {file.size ? formatFileSize(file.size) : '-'}
</span>
<span>
{t('auth_files.file_modified')}: {formatModified(file)}
</span>
{priorityValue !== undefined && (
<span className={styles.priorityBadge}>
{t('auth_files.priority_display')}:{' '}
<span className={styles.priorityValue}>{priorityValue}</span>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>{t('auth_files.file_size')}</span>
<span className={styles.metaValue}>
{file.size ? formatFileSize(file.size) : '-'}
</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>{t('auth_files.file_modified')}</span>
<span className={styles.metaValue}>{formatModified(file)}</span>
</div>
{priorityValue !== undefined && (
<div className={`${styles.metaItem} ${styles.priorityBadge}`}>
<span className={styles.metaLabel}>{t('auth_files.priority_display')}</span>
<span className={`${styles.metaValue} ${styles.priorityValue}`}>
{priorityValue}
</span>
</div>
)}
</div>
{noteValue && (
<div className={styles.noteText} title={noteValue}>
<span className={styles.noteLabel}>{t('auth_files.note_display')}: </span>
{noteValue}
</div>
)}
{rawStatusMessage && hasStatusWarning && (
<div className={styles.healthStatusMessage} title={rawStatusMessage}>
{rawStatusMessage}
<IconInfo className={styles.messageIcon} size={14} />
<span>{rawStatusMessage}</span>
</div>
)}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {fileStats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {fileStats.failure}
</span>
<div className={styles.cardInsights}>
<div className={styles.cardStats}>
<div className={`${styles.statPill} ${styles.statSuccess}`}>
<span className={styles.statLabel}>{t('stats.success')}</span>
<span className={styles.statValue}>{fileStats.success}</span>
</div>
<div className={`${styles.statPill} ${styles.statFailure}`}>
<span className={styles.statLabel}>{t('stats.failure')}</span>
<span className={styles.statValue}>{fileStats.failure}</span>
</div>
</div>
<div className={styles.statusPanel}>
<div className={styles.statusPanelLabel}>
<IconShield size={14} />
<span>{t('auth_files.health_status_label')}</span>
</div>
<ProviderStatusBar statusData={statusData} styles={styles} />
</div>
{showQuotaLayout && quotaType && (
<AuthFileQuotaSection
file={file}
quotaType={quotaType}
disableControls={disableControls}
/>
)}
</div>
<ProviderStatusBar statusData={statusData} styles={styles} />
{showQuotaLayout && quotaType && (
<AuthFileQuotaSection
file={file}
quotaType={quotaType}
disableControls={disableControls}
/>
)}
<div className={styles.cardActions}>
{showModelsButton && (
<Button
variant="secondary"
size="sm"
onClick={() => onShowModels(file)}
className={styles.iconButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconBot className={styles.actionIcon} size={16} />
</Button>
)}
{!isRuntimeOnly && (
<>
<div className={styles.cardActionsMain}>
{showModelsButton && (
<Button
variant="secondary"
size="sm"
onClick={() => onDownload(file.name)}
className={styles.iconButton}
title={t('auth_files.download_button')}
onClick={() => onShowModels(file)}
className={styles.primaryActionButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconDownload className={styles.actionIcon} size={16} />
<>
<IconBot className={styles.actionIcon} size={16} />
<span className={styles.actionButtonLabel}>
{t('auth_files.models_button', { defaultValue: '模型' })}
</span>
</>
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onOpenPrefixProxyEditor(file)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls}
>
<IconSettings className={styles.actionIcon} size={16} />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(file.name)}
className={styles.iconButton}
title={t('auth_files.delete_button')}
disabled={disableControls || deleting === file.name}
>
{deleting === file.name ? (
<LoadingSpinner size={14} />
) : (
<IconTrash2 className={styles.actionIcon} size={16} />
)}
</Button>
</>
)}
)}
{!isRuntimeOnly && (
<div className={styles.cardUtilityActions}>
<Button
variant="secondary"
size="sm"
onClick={() => onDownload(file.name)}
className={styles.iconButton}
title={t('auth_files.download_button')}
disabled={disableControls}
>
<IconDownload className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onOpenPrefixProxyEditor(file)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls}
>
<IconSettings className={styles.actionIcon} size={16} />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(file.name)}
className={styles.iconButton}
title={t('auth_files.delete_button')}
disabled={disableControls || deleting === file.name}
>
{deleting === file.name ? (
<LoadingSpinner size={14} />
) : (
<IconTrash2 className={styles.actionIcon} size={16} />
)}
</Button>
</div>
)}
</div>
{!isRuntimeOnly && (
<div className={styles.statusToggle}>
<span className={styles.statusToggleLabel}>
{t('auth_files.status_toggle_label')}
</span>
<ToggleSwitch
ariaLabel={t('auth_files.status_toggle_label')}
checked={!file.disabled}
@@ -244,11 +318,6 @@ export function AuthFileCard(props: AuthFileCardProps) {
/>
</div>
)}
{isRuntimeOnly && (
<div className={styles.virtualBadge}>
{t('auth_files.type_virtual') || '虚拟认证文件'}
</div>
)}
</div>
</div>
</div>
+53 -15
View File
@@ -1,16 +1,31 @@
import type { TFunction } from 'i18next';
import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconCodex from '@/assets/icons/codex.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import type { AuthFileItem } from '@/types';
import {
normalizeAuthIndex,
normalizeUsageSourceId,
type KeyStatBucket,
type KeyStats
type KeyStats,
} from '@/utils/usage';
export type ThemeColors = { bg: string; text: string; border?: string };
export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
export type ResolvedTheme = 'light' | 'dark';
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
export type AuthFileModelItem = {
id: string;
display_name?: string;
type?: string;
owned_by?: string;
};
export type AuthFileIconAsset = string | { light: string; dark: string };
export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
@@ -19,7 +34,7 @@ export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>([
'claude',
'codex',
'gemini-cli',
'kimi'
'kimi',
]);
export const MIN_CARD_PAGE_SIZE = 3;
@@ -34,48 +49,61 @@ export const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
export const TYPE_COLORS: Record<string, TypeColorSet> = {
qwen: {
light: { bg: '#e8f5e9', text: '#2e7d32' },
dark: { bg: '#1b5e20', text: '#81c784' }
dark: { bg: '#1b5e20', text: '#81c784' },
},
kimi: {
light: { bg: '#fff4e5', text: '#ad6800' },
dark: { bg: '#7c4a03', text: '#ffd591' }
dark: { bg: '#7c4a03', text: '#ffd591' },
},
gemini: {
light: { bg: '#e3f2fd', text: '#1565c0' },
dark: { bg: '#0d47a1', text: '#64b5f6' }
dark: { bg: '#0d47a1', text: '#64b5f6' },
},
'gemini-cli': {
light: { bg: '#e7efff', text: '#1e4fa3' },
dark: { bg: '#1c3f73', text: '#a8c7ff' }
dark: { bg: '#1c3f73', text: '#a8c7ff' },
},
aistudio: {
light: { bg: '#f0f2f5', text: '#2f343c' },
dark: { bg: '#373c42', text: '#cfd3db' }
dark: { bg: '#373c42', text: '#cfd3db' },
},
claude: {
light: { bg: '#fce4ec', text: '#c2185b' },
dark: { bg: '#880e4f', text: '#f48fb1' }
dark: { bg: '#880e4f', text: '#f48fb1' },
},
codex: {
light: { bg: '#fff3e0', text: '#ef6c00' },
dark: { bg: '#e65100', text: '#ffb74d' }
dark: { bg: '#e65100', text: '#ffb74d' },
},
antigravity: {
light: { bg: '#e0f7fa', text: '#006064' },
dark: { bg: '#004d40', text: '#80deea' }
dark: { bg: '#004d40', text: '#80deea' },
},
iflow: {
light: { bg: '#f3e5f5', text: '#7b1fa2' },
dark: { bg: '#4a148c', text: '#ce93d8' }
dark: { bg: '#4a148c', text: '#ce93d8' },
},
empty: {
light: { bg: '#f5f5f5', text: '#616161' },
dark: { bg: '#424242', text: '#bdbdbd' }
dark: { bg: '#424242', text: '#bdbdbd' },
},
unknown: {
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
}
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' },
},
};
export const AUTH_FILE_ICONS: Record<string, AuthFileIconAsset> = {
antigravity: iconAntigravity,
aistudio: iconGemini,
claude: iconClaude,
codex: iconCodex,
gemini: iconGemini,
'gemini-cli': iconGemini,
iflow: iconIflow,
kimi: { light: iconKimiLight, dark: iconKimiDark },
qwen: iconQwen,
vertex: iconVertex,
};
export const clampCardPageSize = (value: number) =>
@@ -116,6 +144,16 @@ export const getTypeColor = (type: string, resolvedTheme: ResolvedTheme): ThemeC
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
};
export const getAuthFileIcon = (type: string, resolvedTheme: ResolvedTheme): string | null => {
const iconEntry = AUTH_FILE_ICONS[normalizeProviderKey(type)];
if (!iconEntry) return null;
return typeof iconEntry === 'string'
? iconEntry
: resolvedTheme === 'dark'
? iconEntry.dark
: iconEntry.light;
};
export const parsePriorityValue = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isInteger(value) ? value : undefined;
File diff suppressed because it is too large Load Diff
+202 -182
View File
@@ -1,5 +1,6 @@
import {
useCallback,
type CSSProperties,
useEffect,
useLayoutEffect,
useMemo,
@@ -27,6 +28,7 @@ import {
MIN_CARD_PAGE_SIZE,
QUOTA_PROVIDER_TYPES,
clampCardPageSize,
getAuthFileIcon,
getTypeColor,
getTypeLabel,
hasAuthFileStatusMessage,
@@ -41,15 +43,6 @@ import { AuthFileModelsModal } from '@/features/authFiles/components/AuthFileMod
import { AuthFilesPrefixProxyEditorModal } from '@/features/authFiles/components/AuthFilesPrefixProxyEditorModal';
import { OAuthExcludedCard } from '@/features/authFiles/components/OAuthExcludedCard';
import { OAuthModelAliasCard } from '@/features/authFiles/components/OAuthModelAliasCard';
import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconCodex from '@/assets/icons/codex.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import { useAuthFilesData } from '@/features/authFiles/hooks/useAuthFilesData';
import { useAuthFilesModels } from '@/features/authFiles/hooks/useAuthFilesModels';
import { useAuthFilesOauth } from '@/features/authFiles/hooks/useAuthFilesOauth';
@@ -69,28 +62,6 @@ const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4;
const easePower2In = (progress: number) => progress ** 3;
const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
const AUTH_FILE_FILTER_ICONS: Record<string, string | { light: string; dark: string }> = {
antigravity: iconAntigravity,
aistudio: iconGemini,
claude: iconClaude,
codex: iconCodex,
gemini: iconGemini,
'gemini-cli': iconGemini,
iflow: iconIflow,
kimi: { light: iconKimiLight, dark: iconKimiDark },
qwen: iconQwen,
vertex: iconVertex,
};
const getFilterTagIcon = (type: string, resolvedTheme: ResolvedTheme): string | null => {
const iconEntry = AUTH_FILE_FILTER_ICONS[normalizeProviderKey(type)];
if (!iconEntry) return null;
return typeof iconEntry === 'string'
? iconEntry
: resolvedTheme === 'dark'
? iconEntry.dark
: iconEntry.light;
};
export function AuthFilesPage() {
const { t } = useTranslation();
@@ -325,6 +296,11 @@ export function AuthFilesPage() {
return counts;
}, [filesMatchingProblemFilter]);
const activeFilterLabel =
filter === 'all' ? t('auth_files.filter_all') : getTypeLabel(t, String(filter));
const activeFilterCount = typeCounts[String(filter)] ?? 0;
const activeFilterIcon = getAuthFileIcon(String(filter), resolvedTheme);
const filtered = useMemo(() => {
return filesMatchingProblemFilter.filter((item) => {
const matchType = filter === 'all' || item.type === filter;
@@ -504,38 +480,78 @@ export function AuthFilesPage() {
);
const renderFilterTags = () => (
<div className={styles.filterTags}>
{existingTypes.map((type) => {
const isActive = filter === type;
const iconSrc = getFilterTagIcon(type, resolvedTheme);
const color =
type === 'all'
? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' }
: getTypeColor(type, resolvedTheme);
const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
return (
<button
key={type}
className={`${styles.filterTag} ${isActive ? styles.filterTagActive : ''}`}
style={{
backgroundColor: isActive ? color.text : color.bg,
color: isActive ? activeTextColor : color.text,
borderColor: color.text,
}}
onClick={() => {
setFilter(type);
setPage(1);
}}
>
<span className={styles.filterTagLabel}>
{iconSrc && <img src={iconSrc} alt="" className={styles.filterTagIcon} />}
<span>{getTypeLabel(t, type)}</span>
</span>
<span className={styles.filterTagCount}>{typeCounts[type] ?? 0}</span>
</button>
);
})}
</div>
<aside className={styles.filterRail}>
<div className={styles.filterRailHeader}>
<span className={styles.filterRailEyebrow}>
{t('nav.ai_providers', { defaultValue: 'Providers' })}
</span>
<div className={styles.filterRailHero}>
<div className={styles.filterRailHeroTitle}>
<div className={styles.filterRailHeroIcon}>
{activeFilterIcon ? (
<img src={activeFilterIcon} alt="" className={styles.filterRailHeroIconImage} />
) : (
<span className={styles.filterRailHeroIconFallback}>
{activeFilterLabel.slice(0, 1).toUpperCase()}
</span>
)}
</div>
<div className={styles.filterRailHeroText}>
<span className={styles.filterRailTitle}>{activeFilterLabel}</span>
<span className={styles.filterRailDescription}>{t('auth_files.title_section')}</span>
</div>
</div>
<div className={styles.filterRailMeta}>
<span className={styles.filterRailCount}>{activeFilterCount}</span>
{problemOnly && (
<span className={styles.filterRailMode}>{t('auth_files.problem_filter_only')}</span>
)}
</div>
</div>
</div>
<div className={styles.filterTags}>
{existingTypes.map((type) => {
const isActive = filter === type;
const iconSrc = getAuthFileIcon(type, resolvedTheme);
const color =
type === 'all'
? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' }
: getTypeColor(type, resolvedTheme);
const buttonStyle = {
'--filter-color': color.text,
'--filter-surface': color.bg,
'--filter-active-text': resolvedTheme === 'dark' ? '#111827' : '#ffffff',
} as CSSProperties;
return (
<button
key={type}
className={`${styles.filterTag} ${isActive ? styles.filterTagActive : ''}`}
style={buttonStyle}
onClick={() => {
setFilter(type);
setPage(1);
}}
>
<span className={styles.filterTagLabel}>
<span className={styles.filterTagIconWrap}>
{iconSrc ? (
<img src={iconSrc} alt="" className={styles.filterTagIcon} />
) : (
<span className={styles.filterTagIconFallback}>
{getTypeLabel(t, type).slice(0, 1).toUpperCase()}
</span>
)}
</span>
<span className={styles.filterTagText}>{getTypeLabel(t, type)}</span>
</span>
<span className={styles.filterTagCount}>{typeCounts[type] ?? 0}</span>
</button>
);
})}
</div>
</aside>
);
const titleNode = (
@@ -607,129 +623,133 @@ export function AuthFilesPage() {
<div className={styles.filterSection}>
{renderFilterTags()}
<div className={styles.filterControls}>
<div className={styles.filterItem}>
<label>{t('auth_files.search_label')}</label>
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('auth_files.search_placeholder')}
/>
</div>
<div className={styles.filterItem}>
<label>{t('auth_files.page_size_label')}</label>
<input
className={styles.pageSizeSelect}
type="number"
min={MIN_CARD_PAGE_SIZE}
max={MAX_CARD_PAGE_SIZE}
step={1}
value={pageSizeInput}
onChange={handlePageSizeChange}
onBlur={(e) => commitPageSizeInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
/>
</div>
<div className={styles.filterItem}>
<label>{t('auth_files.sort_label')}</label>
<Select
className={styles.sortSelect}
value={sortMode}
options={sortOptions}
onChange={handleSortModeChange}
ariaLabel={t('auth_files.sort_label')}
fullWidth={false}
/>
</div>
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
<label>{t('auth_files.problem_filter_label')}</label>
<div className={styles.filterToggle}>
<ToggleSwitch
checked={problemOnly}
onChange={(value) => {
setProblemOnly(value);
setPage(1);
}}
ariaLabel={t('auth_files.problem_filter_only')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.problem_filter_only')}
</span>
}
/>
<div className={styles.filterContent}>
<div className={styles.filterControlsPanel}>
<div className={styles.filterControls}>
<div className={styles.filterItem}>
<label>{t('auth_files.search_label')}</label>
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('auth_files.search_placeholder')}
/>
</div>
<div className={styles.filterItem}>
<label>{t('auth_files.page_size_label')}</label>
<input
className={styles.pageSizeSelect}
type="number"
min={MIN_CARD_PAGE_SIZE}
max={MAX_CARD_PAGE_SIZE}
step={1}
value={pageSizeInput}
onChange={handlePageSizeChange}
onBlur={(e) => commitPageSizeInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
/>
</div>
<div className={styles.filterItem}>
<label>{t('auth_files.sort_label')}</label>
<Select
className={styles.sortSelect}
value={sortMode}
options={sortOptions}
onChange={handleSortModeChange}
ariaLabel={t('auth_files.sort_label')}
fullWidth
/>
</div>
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
<label>{t('auth_files.problem_filter_label')}</label>
<div className={styles.filterToggle}>
<ToggleSwitch
checked={problemOnly}
onChange={(value) => {
setProblemOnly(value);
setPage(1);
}}
ariaLabel={t('auth_files.problem_filter_only')}
label={
<span className={styles.filterToggleLabel}>
{t('auth_files.problem_filter_only')}
</span>
}
/>
</div>
</div>
</div>
</div>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : pageItems.length === 0 ? (
<EmptyState
title={t('auth_files.search_empty_title')}
description={t('auth_files.search_empty_desc')}
/>
) : (
<div
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
>
{pageItems.map((file) => (
<AuthFileCard
key={file.name}
file={file}
selected={selectedFiles.has(file.name)}
resolvedTheme={resolvedTheme}
disableControls={disableControls}
deleting={deleting}
statusUpdating={statusUpdating}
quotaFilterType={quotaFilterType}
keyStats={keyStats}
statusBarCache={statusBarCache}
onShowModels={showModels}
onDownload={handleDownload}
onOpenPrefixProxyEditor={openPrefixProxyEditor}
onDelete={handleDelete}
onToggleStatus={handleStatusToggle}
onToggleSelect={toggleSelect}
/>
))}
</div>
)}
{!loading && sorted.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: sorted.length,
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</div>
</div>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : pageItems.length === 0 ? (
<EmptyState
title={t('auth_files.search_empty_title')}
description={t('auth_files.search_empty_desc')}
/>
) : (
<div
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
>
{pageItems.map((file) => (
<AuthFileCard
key={file.name}
file={file}
selected={selectedFiles.has(file.name)}
resolvedTheme={resolvedTheme}
disableControls={disableControls}
deleting={deleting}
statusUpdating={statusUpdating}
quotaFilterType={quotaFilterType}
keyStats={keyStats}
statusBarCache={statusBarCache}
onShowModels={showModels}
onDownload={handleDownload}
onOpenPrefixProxyEditor={openPrefixProxyEditor}
onDelete={handleDelete}
onToggleStatus={handleStatusToggle}
onToggleSelect={toggleSelect}
/>
))}
</div>
)}
{!loading && sorted.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: sorted.length,
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</Card>
<OAuthExcludedCard