mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(auth-files): revamp Auth Files page filters and card layout
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+627
-158
File diff suppressed because it is too large
Load Diff
+202
-182
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user