feat(auth-files): componentize cards, quota section, sheet editor and batch bar with colocated styles

This commit is contained in:
Supra4E8C
2026-07-30 02:23:25 +08:00
parent 20c0cb865e
commit b9c3fbbb77
19 changed files with 1846 additions and 571 deletions
+10
View File
@@ -168,6 +168,16 @@ export function IconDownload({ size = 20, ...props }: IconProps) {
);
}
export function IconUpload({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M12 3v12" />
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m17 8-5-5-5 5" />
</svg>
);
}
export function IconTrash2({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
@@ -0,0 +1,489 @@
@use '../../../styles/mixins' as *;
/* ============================================================
* 凭证卡片 — statTile 配方(14px 圆角 / 1px 边框 / 82% 透感纸面)
* 遥测内容一律 mono + tabular-nums;品牌色只出现在头像与类型徽章。
* ============================================================ */
.card {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border-radius: 14px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
transition:
transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
box-shadow var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
}
@media (hover: hover) and (pointer: fine) {
.card:hover {
transform: translateY(-1px);
border-color: var(--border-hover);
box-shadow: 0 12px 26px -14px rgb(0 0 0 / 0.16);
}
}
.cardCompact {
gap: 10px;
padding: 13px 14px;
}
.cardSelected {
border-color: color-mix(in srgb, var(--primary-color) 55%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary-color) 40%, transparent);
}
.cardDisabled {
background: color-mix(in srgb, var(--bg-primary) 55%, transparent);
.head,
.note,
.health,
.metaRow {
opacity: 0.55;
}
}
/* 首次数据到达时的一次性级联入场;过滤/翻页/轮询不重播 */
.cardEnter {
animation: card-in 0.45s var(--ease-out-strong, ease-out) both;
animation-delay: var(--card-delay, 0s);
}
@keyframes card-in {
from {
opacity: 0;
transform: translate3d(0, 16px, 0);
}
}
/* ---------- 头部:勾选 + 品牌头像 + 身份 ---------- */
.head {
display: flex;
align-items: flex-start;
gap: 10px;
}
.selection {
margin-top: 8px;
}
.avatar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
}
.avatarImage {
width: 20px;
height: 20px;
object-fit: contain;
}
.avatarFallback {
font-size: 14px;
font-weight: 700;
}
.identity {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.badgeRow {
display: flex;
align-items: center;
gap: 6px;
}
.typeBadge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
line-height: 1.5;
padding: 2px 7px;
border-radius: 6px;
white-space: nowrap;
}
.stateBadge {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 5px;
font-family: $font-mono;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1.6;
padding: 2px 8px;
border-radius: $radius-full;
border: 1px solid transparent;
white-space: nowrap;
}
.stateDot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--text-quaternary);
}
.stateActive {
color: var(--success-badge-text);
border-color: color-mix(in srgb, var(--viz-success) 35%, transparent);
background: color-mix(in srgb, var(--viz-success) 8%, transparent);
.stateDot {
background: var(--viz-success);
}
}
.stateWarning {
color: var(--amber-text);
border-color: var(--amber-30);
background: var(--amber-10);
.stateDot {
background: var(--amber-color);
}
}
.stateDisabled {
color: var(--text-tertiary);
border-color: var(--border-color);
background: var(--bg-tertiary);
}
.stateVirtual {
color: var(--text-secondary);
border-style: dashed;
border-color: var(--border-hover);
}
.fileName {
font-family: $font-mono;
font-size: 13px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-primary);
@include text-ellipsis;
}
.note {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
@include text-ellipsis;
}
/* ---------- 告警行 ---------- */
.warning {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 12px;
line-height: 1.45;
color: var(--warning-text);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: 8px;
padding: 7px 9px;
span {
min-width: 0;
overflow-wrap: anywhere;
@include text-ellipsis-multiline(2);
}
}
.warningIcon {
flex-shrink: 0;
margin-top: 2px;
}
/* ---------- 健康区:eyebrow + 计数 + 状态条 ---------- */
.health {
display: flex;
flex-direction: column;
gap: 7px;
}
.healthHead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.healthLabel {
font-family: $font-mono;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.healthCounts {
display: inline-flex;
align-items: baseline;
gap: 8px;
font-family: $font-mono;
font-size: 11.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.countOk {
color: var(--text-quaternary);
}
.countFail {
color: var(--text-quaternary);
}
/* 绿色只给活的流量:有计数才点亮 */
.countLive.countOk {
color: var(--viz-success);
}
.countLive.countFail {
color: var(--viz-failure);
}
/* ---------- ProviderStatusBar 注入样式(18 个契约类名) ---------- */
.statusBar {
display: flex;
align-items: center;
gap: 8px;
}
.statusBlocks {
display: flex;
gap: 2px;
flex: 1;
min-width: 0;
}
.statusBlockWrapper {
position: relative;
flex: 1 1 0;
min-width: 3px;
}
.statusBlock {
height: 14px;
border-radius: 2px;
}
.statusBlockIdle {
background: color-mix(in srgb, var(--text-quaternary) 26%, transparent);
}
.statusBlockActive .statusBlock {
outline: 1px solid var(--text-secondary);
outline-offset: 1px;
}
.statusRate {
font-family: $font-mono;
font-size: 11.5px;
font-weight: 650;
font-variant-numeric: tabular-nums;
color: var(--text-quaternary);
min-width: 40px;
text-align: right;
}
.statusRateHigh {
color: var(--viz-success);
}
.statusRateMedium {
color: var(--quota-medium-color);
}
.statusRateLow {
color: var(--viz-failure);
}
.statusTooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
z-index: 20;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 150px;
padding: 8px 10px;
border-radius: 10px;
background: var(--floating-surface);
border: 1px solid var(--border-color);
box-shadow: var(--floating-shadow);
pointer-events: none;
font-family: $font-mono;
font-size: 11px;
line-height: 1.5;
}
.statusTooltipLeft {
left: 0;
transform: none;
}
.statusTooltipRight {
left: auto;
right: 0;
transform: none;
}
.tooltipTime {
color: var(--text-tertiary);
letter-spacing: 0.03em;
}
.tooltipStats {
display: flex;
gap: 6px;
flex-wrap: wrap;
color: var(--text-secondary);
}
.tooltipSuccess {
color: var(--viz-success);
}
.tooltipFailure {
color: var(--viz-failure);
}
.tooltipRate {
color: var(--text-secondary);
}
/* ---------- 元数据行:size · modified · P{n} ---------- */
.metaRow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 7px;
font-family: $font-mono;
font-size: 11.5px;
font-variant-numeric: tabular-nums;
color: var(--text-tertiary);
}
.metaDivider {
color: var(--text-quaternary);
user-select: none;
}
.metaPriority {
font-weight: 650;
color: var(--text-secondary);
}
/* ---------- 页脚动作区 ---------- */
.actions {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 12px;
border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
}
.actionsMain {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.utilityActions {
display: flex;
align-items: center;
gap: 4px;
}
/* 图标按钮:方形紧凑,覆盖全局 .btn-sm 的内边距 */
.actions .iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
}
/* 按压反馈:所有可点元素 scale 0.96reduced-motion 下豁免) */
.actions :global(.btn) {
transition:
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
background-color $transition-fast,
border-color $transition-fast,
color $transition-fast;
}
.actions :global(.btn:active:not(:disabled)) {
transform: scale(0.96);
}
.toggleWrap {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.toggleLabel {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
/* ---------- 降级 ---------- */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.cardEnter {
animation: none;
}
.actions :global(.btn) {
transition: none;
}
.actions :global(.btn:active:not(:disabled)) {
transform: none;
}
}
+214 -246
View File
@@ -1,3 +1,4 @@
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
@@ -14,12 +15,7 @@ import {
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
import type { AuthFileItem } from '@/types';
import { resolveAuthProvider } from '@/utils/quota';
import {
normalizeRecentRequestAuthIndex,
normalizeRecentRequestBuckets,
normalizeUsageTotal,
statusBarDataFromRecentRequests,
} from '@/utils/recentRequests';
import { statusBarDataFromRecentRequests } from '@/utils/recentRequests';
import { formatFileSize } from '@/utils/format';
import {
QUOTA_PROVIDER_TYPES,
@@ -32,14 +28,13 @@ import {
isRuntimeOnlyAuthFile,
isThemeSurfaceIconProvider,
normalizeProviderKey,
parsePriorityValue,
supportsAuthFileManualRefresh,
type QuotaProviderType,
type ResolvedTheme,
} from '@/features/authFiles/constants';
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
import styles from '@/pages/AuthFilesPage.module.scss';
import styles from './AuthFileCard.module.scss';
const HEALTHY_STATUS_MESSAGES = new Set(['ok', 'healthy', 'ready', 'success', 'available']);
@@ -54,6 +49,8 @@ export type AuthFileCardProps = {
manualRefreshing: Record<string, boolean>;
quotaFilterType: QuotaProviderType | null;
statusBarCache: Map<string, AuthFileStatusBarData>;
/** 首屏一次性级联入场的延迟;null/undefined 表示不做入场动画。 */
entranceDelayMs?: number | null;
onShowModels: (file: AuthFileItem) => void;
onDownload: (name: string) => void;
onManualRefresh: (file: AuthFileItem) => void;
@@ -82,6 +79,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
manualRefreshing,
quotaFilterType,
statusBarCache,
entranceDelayMs,
onShowModels,
onDownload,
onManualRefresh,
@@ -91,11 +89,6 @@ export function AuthFileCard(props: AuthFileCardProps) {
onToggleSelect,
} = props;
const recentBuckets = normalizeRecentRequestBuckets(file.recent_requests ?? file.recentRequests);
const fileStats = {
success: normalizeUsageTotal(file.success),
failure: normalizeUsageTotal(file.failed),
};
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
const providerKey = normalizeProviderKey(String(file.type ?? file.provider ?? 'unknown'));
const isAistudio = providerKey === 'aistudio';
@@ -110,35 +103,24 @@ export function AuthFileCard(props: AuthFileCardProps) {
const quotaType =
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly && !compact;
const providerCardClass =
quotaType === 'antigravity'
? styles.antigravityCard
: quotaType === 'claude'
? styles.claudeCard
: quotaType === 'codex'
? styles.codexCard
: quotaType === 'kimi'
? styles.kimiCard
: quotaType === 'xai'
? styles.xaiCard
: '';
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeRecentRequestAuthIndex(rawAuthIndex);
const successCount = file.successCount ?? 0;
const failureCount = file.failureCount ?? 0;
const authIndexKey = typeof file.authIndex === 'string' ? file.authIndex : null;
const statusData =
(authIndexKey && statusBarCache.get(authIndexKey)) ||
statusBarDataFromRecentRequests(recentBuckets);
statusBarDataFromRecentRequests(file.recentRequests ?? []);
const rawStatusMessage = getAuthFileStatusMessage(file);
const hasStatusWarning =
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase());
const priorityValue = parsePriorityValue(file.priority ?? file['priority']);
const priorityValue = typeof file.priority === 'number' ? file.priority : undefined;
const noteValue = typeof file.note === 'string' ? file.note.trim() : '';
const stateLabel = isRuntimeOnly
? t('auth_files.type_virtual') || '虚拟认证文件'
? t('auth_files.type_virtual')
: file.disabled
? t('auth_files.health_status_disabled')
: hasStatusWarning
@@ -147,235 +129,221 @@ export function AuthFileCard(props: AuthFileCardProps) {
? t('auth_files.health_status_healthy')
: t('auth_files.status_toggle_label');
const stateBadgeClass = isRuntimeOnly
? styles.stateBadgeVirtual
? styles.stateVirtual
: file.disabled
? styles.stateBadgeDisabled
? styles.stateDisabled
: hasStatusWarning
? styles.stateBadgeWarning
: styles.stateBadgeActive;
? styles.stateWarning
: styles.stateActive;
const cardClasses = [
styles.card,
compact ? styles.cardCompact : '',
selected ? styles.cardSelected : '',
file.disabled ? styles.cardDisabled : '',
entranceDelayMs != null ? styles.cardEnter : '',
]
.filter(Boolean)
.join(' ');
const cardStyle =
entranceDelayMs != null
? ({ '--card-delay': `${entranceDelayMs}ms` } as CSSProperties)
: undefined;
return (
<div
className={`${styles.fileCard} ${compact ? styles.fileCardCompact : ''} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
>
<div className={styles.fileCardLayout}>
<div className={styles.fileCardMain}>
<div className={styles.cardHeader}>
{!isRuntimeOnly && (
<SelectionCheckbox
checked={selected}
onChange={() => onToggleSelect(file.name)}
className={styles.cardSelection}
aria-label={
selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')
<article className={cardClasses} style={cardStyle}>
<header className={styles.head}>
{!isRuntimeOnly && (
<SelectionCheckbox
checked={selected}
onChange={() => onToggleSelect(file.name)}
className={styles.selection}
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')}
/>
)}
<div
className={styles.avatar}
style={
useThemeSurfaceIcon
? {
backgroundColor: getThemeSurfaceIconBackground(resolvedTheme),
color: typeColor.text,
}
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
/>
)}
<div
className={styles.providerAvatar}
style={
useThemeSurfaceIcon
? {
backgroundColor: getThemeSurfaceIconBackground(resolvedTheme),
color: typeColor.text,
}
: {
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {}),
}
}
>
{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} title={file.name}>
{file.name}
</span>
{!compact && 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} ${compact ? styles.cardMetaCompact : ''}`}>
<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>
{rawStatusMessage && hasStatusWarning && (
<div className={styles.healthStatusMessage} title={rawStatusMessage}>
<IconInfo className={styles.messageIcon} size={14} />
<span>{rawStatusMessage}</span>
</div>
: {
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {}),
}
}
>
{providerIcon ? (
<img src={providerIcon} alt="" className={styles.avatarImage} />
) : (
<span className={styles.avatarFallback}>{typeLabel.slice(0, 1).toUpperCase()}</span>
)}
<div className={`${styles.cardInsights} ${compact ? styles.cardInsightsCompact : ''}`}>
<div className={`${styles.cardStats} ${compact ? styles.cardStatsCompact : ''}`}>
<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} ${compact ? styles.statusPanelCompact : ''}`}>
<div className={styles.statusPanelLabel}>
<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>
<div className={styles.identity}>
<div className={styles.badgeRow}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {}),
}}
>
{typeLabel}
</span>
<span className={`${styles.stateBadge} ${stateBadgeClass}`}>
<span className={styles.stateDot} aria-hidden="true" />
{stateLabel}
</span>
</div>
<span className={styles.fileName} title={file.name}>
{file.name}
</span>
</div>
</header>
<div className={styles.cardActions}>
<div className={styles.cardActionsMain}>
{showModelsButton && (
{!compact && noteValue && (
<p className={styles.note} title={noteValue}>
{noteValue}
</p>
)}
{rawStatusMessage && hasStatusWarning && (
<div className={styles.warning} title={rawStatusMessage}>
<IconInfo className={styles.warningIcon} size={14} />
<span>{rawStatusMessage}</span>
</div>
)}
<div className={styles.health}>
<div className={styles.healthHead}>
<span className={styles.healthLabel}>{t('auth_files.health_status_label')}</span>
<span className={styles.healthCounts}>
<span
className={`${styles.countOk} ${successCount > 0 ? styles.countLive : ''}`}
title={t('stats.success')}
>
{t('stats.success')} {successCount}
</span>
<span
className={`${styles.countFail} ${failureCount > 0 ? styles.countLive : ''}`}
title={t('stats.failure')}
>
{t('stats.failure')} {failureCount}
</span>
</span>
</div>
<ProviderStatusBar statusData={statusData} styles={styles} />
</div>
<div className={styles.metaRow}>
<span title={t('auth_files.file_size')}>
{file.size ? formatFileSize(file.size) : '-'}
</span>
<span className={styles.metaDivider} aria-hidden="true">
·
</span>
<span title={t('auth_files.file_modified')}>{formatModified(file)}</span>
{priorityValue !== undefined && (
<>
<span className={styles.metaDivider} aria-hidden="true">
·
</span>
<span className={styles.metaPriority} title={t('auth_files.priority_display')}>
P{priorityValue}
</span>
</>
)}
</div>
{showQuotaLayout && quotaType && (
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
)}
<footer className={styles.actions}>
<div className={styles.actionsMain}>
{showModelsButton && (
<Button
variant="secondary"
size="sm"
onClick={() => onShowModels(file)}
title={t('auth_files.models_button')}
disabled={disableControls}
>
<IconModelCluster size={14} />
{t('auth_files.models_button')}
</Button>
)}
{!isRuntimeOnly && (
<div className={styles.utilityActions}>
{showManualRefreshButton && (
<Button
variant="secondary"
size="sm"
onClick={() => onShowModels(file)}
className={`${styles.primaryActionButton} ${styles.modelsActionButton}`}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
onClick={() => onManualRefresh(file)}
className={styles.iconButton}
title={t('auth_files.manual_refresh_button')}
disabled={
disableControls ||
file.disabled ||
statusUpdating[file.name] === true ||
isManualRefreshing
}
>
<>
<span className={styles.modelsActionIconWrap}>
<IconModelCluster className={styles.actionIcon} size={16} />
</span>
<span className={styles.actionButtonLabel}>
{t('auth_files.models_button', { defaultValue: '模型' })}
</span>
</>
{isManualRefreshing ? <LoadingSpinner size={14} /> : <IconRefreshCw size={15} />}
</Button>
)}
{!isRuntimeOnly && (
<div className={styles.cardUtilityActions}>
{showManualRefreshButton && (
<Button
variant="secondary"
size="sm"
onClick={() => onManualRefresh(file)}
className={styles.iconButton}
title={t('auth_files.manual_refresh_button')}
disabled={
disableControls ||
file.disabled ||
statusUpdating[file.name] === true ||
isManualRefreshing
}
>
{isManualRefreshing ? (
<LoadingSpinner size={14} />
) : (
<IconRefreshCw className={styles.actionIcon} size={16} />
)}
</Button>
)}
<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 || isManualRefreshing}
>
<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 || isManualRefreshing}
>
{deleting === file.name ? (
<LoadingSpinner size={14} />
) : (
<IconTrash2 className={styles.actionIcon} size={16} />
)}
</Button>
</div>
)}
<Button
variant="secondary"
size="sm"
onClick={() => onDownload(file.name)}
className={styles.iconButton}
title={t('auth_files.download_button')}
disabled={disableControls}
>
<IconDownload size={15} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onOpenPrefixProxyEditor(file)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls || isManualRefreshing}
>
<IconSettings size={15} />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(file.name)}
className={styles.iconButton}
title={t('auth_files.delete_button')}
disabled={disableControls || deleting === file.name || isManualRefreshing}
>
{deleting === file.name ? <LoadingSpinner size={14} /> : <IconTrash2 size={15} />}
</Button>
</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}
disabled={
disableControls || statusUpdating[file.name] === true || isManualRefreshing
}
onChange={(value) => onToggleStatus(file, value)}
/>
</div>
)}
</div>
)}
</div>
</div>
</div>
{!isRuntimeOnly && (
<div className={styles.toggleWrap}>
<span className={styles.toggleLabel}>{t('auth_files.status_toggle_label')}</span>
<ToggleSwitch
ariaLabel={t('auth_files.status_toggle_label')}
checked={!file.disabled}
disabled={disableControls || statusUpdating[file.name] === true || isManualRefreshing}
onChange={(value) => onToggleStatus(file, value)}
/>
</div>
)}
</footer>
</article>
);
}
@@ -0,0 +1,89 @@
/* 凭证详情/编辑抽屉内容区 */
.editor {
display: flex;
flex-direction: column;
gap: 16px;
}
.loading {
display: flex;
align-items: center;
gap: 8px;
padding: 24px 0;
justify-content: center;
font-size: 13px;
color: var(--text-secondary);
}
.error {
font-size: 12.5px;
line-height: 1.5;
color: var(--danger-color);
background: var(--bg-error-light);
border: 1px solid var(--warning-border);
border-radius: 8px;
padding: 8px 10px;
overflow-wrap: anywhere;
}
.jsonWrapper {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-family: $font-mono;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
font-family: $font-mono;
font-size: 12px;
line-height: 1.55;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px 12px;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px var(--primary-10);
}
}
.textareaInvalid {
border-color: var(--danger-color) !important;
}
.invalidPreview {
margin: 0;
max-height: 240px;
overflow: auto;
font-family: $font-mono;
font-size: 12px;
line-height: 1.55;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px dashed var(--border-hover);
border-radius: 10px;
padding: 10px 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.fields {
display: flex;
flex-direction: column;
gap: 14px;
}
@@ -1,9 +1,11 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Sheet } from '@/components/ui/Sheet';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useNotificationStore } from '@/stores';
import type {
PrefixProxyEditorField,
PrefixProxyEditorFieldValue,
@@ -13,9 +15,9 @@ import {
supportsAuthFileUsingApi,
supportsAuthFileWebsockets,
} from '@/features/authFiles/constants';
import styles from '@/pages/AuthFilesPage.module.scss';
import styles from './AuthFileDetailsSheet.module.scss';
export type AuthFilesPrefixProxyEditorModalProps = {
export type AuthFileDetailsSheetProps = {
disableControls: boolean;
editor: PrefixProxyEditorState | null;
updatedText: string;
@@ -26,10 +28,37 @@ export type AuthFilesPrefixProxyEditorModalProps = {
onChange: (field: PrefixProxyEditorField, value: PrefixProxyEditorFieldValue) => void;
};
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
/**
* / Modal Sheet
* Escape//×/
*/
export function AuthFileDetailsSheet(props: AuthFileDetailsSheetProps) {
const { t } = useTranslation();
const { disableControls, editor, updatedText, dirty, onClose, onCopyText, onSave, onChange } =
props;
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
const confirmClose = useCallback((): boolean | Promise<boolean> => {
if (!dirty || editor?.saving === true) return true;
return new Promise<boolean>((resolve) => {
showConfirmation({
title: t('providersPage.unsavedChanges.title'),
message: t('providersPage.unsavedChanges.message'),
variant: 'danger',
confirmText: t('providersPage.unsavedChanges.discard'),
cancelText: t('providersPage.unsavedChanges.keepEditing'),
onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
});
}, [dirty, editor?.saving, showConfirmation, t]);
const handleCancelClick = useCallback(() => {
void Promise.resolve(confirmClose()).then((ok) => {
if (ok) onClose();
});
}, [confirmClose, onClose]);
const formatJsonText = (text: string) => {
if (!text) return '';
try {
@@ -42,19 +71,21 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
const invalidContentPreview = editor?.invalidContentPreview ?? '';
return (
<Modal
<Sheet
open={Boolean(editor)}
onClose={onClose}
confirmClose={confirmClose}
size="md"
closeDisabled={editor?.saving === true}
width={720}
title={
editor?.fileName
? t('auth_files.auth_field_editor_title', { name: editor.fileName })
: t('auth_files.prefix_proxy_button')
}
eyebrow={t('auth_files.prefix_proxy_button')}
title={editor?.fileName ?? ''}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={editor?.saving === true}>
<Button
variant="secondary"
onClick={handleCancelClick}
disabled={editor?.saving === true}
>
{dirty ? t('common.cancel') : t('common.close')}
</Button>
<Button
@@ -84,47 +115,43 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
}
>
{editor && (
<div className={styles.prefixProxyEditor}>
<div className={styles.editor}>
{editor.loading ? (
<div className={styles.prefixProxyLoading}>
<div className={styles.loading}>
<LoadingSpinner size={14} />
<span>{t('auth_files.prefix_proxy_loading')}</span>
</div>
) : (
<>
{editor.error && <div className={styles.prefixProxyError}>{editor.error}</div>}
<div className={styles.prefixProxyJsonWrapper}>
<label className={styles.prefixProxyLabel}>
{t('auth_files.prefix_proxy_info_label')}
</label>
{editor.error && <div className={styles.error}>{editor.error}</div>}
<div className={styles.jsonWrapper}>
<label className={styles.label}>{t('auth_files.prefix_proxy_info_label')}</label>
<textarea
className={styles.prefixProxyTextarea}
className={styles.textarea}
rows={8}
readOnly
value={editor.fileInfoText}
/>
</div>
<div className={styles.prefixProxyJsonWrapper}>
<label className={styles.prefixProxyLabel}>
<div className={styles.jsonWrapper}>
<label className={styles.label}>
{editor.json
? t('auth_files.prefix_proxy_source_label')
: t('auth_files.prefix_proxy_invalid_content_label')}
</label>
{editor.json ? (
<textarea
className={styles.prefixProxyTextarea}
className={styles.textarea}
rows={10}
readOnly
value={previewText}
/>
) : (
<pre className={styles.prefixProxyInvalidContentPreview}>
{invalidContentPreview}
</pre>
<pre className={styles.invalidPreview}>{invalidContentPreview}</pre>
)}
</div>
{editor.json && (
<div className={styles.prefixProxyFields}>
<div className={styles.fields}>
<Input
label={t('auth_files.prefix_label')}
value={editor.prefix}
@@ -173,7 +200,7 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
<div className="form-group">
<label>{t('auth_files.headers_label')}</label>
<textarea
className={`input ${editor.headersError ? styles.prefixProxyTextareaInvalid : ''}`}
className={`input ${editor.headersError ? styles.textareaInvalid : ''}`}
value={editor.headersText}
placeholder={t('auth_files.headers_placeholder')}
rows={4}
@@ -198,6 +225,6 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
)}
</div>
)}
</Modal>
</Sheet>
);
}
@@ -0,0 +1,90 @@
@use '../../../styles/mixins' as *;
/* 「支持的模型」弹窗:可点击复制的 mono 模型清单 */
.list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: min(52dvh, 480px);
overflow-y: auto;
padding-right: 2px;
}
.item {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
transition:
background-color $transition-fast,
border-color $transition-fast,
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
&:active {
transform: scale(0.99);
}
}
@media (hover: hover) and (pointer: fine) {
.item:hover {
background: var(--bg-tertiary);
border-color: color-mix(in srgb, var(--border-color) 70%, transparent);
}
}
.itemExcluded {
opacity: 0.55;
}
.modelId {
font-family: $font-mono;
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.modelDisplayName {
font-size: 11.5px;
color: var(--text-tertiary);
@include text-ellipsis;
}
.modelType {
font-family: $font-mono;
font-size: 10px;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-tertiary);
padding: 1px 6px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
}
.excludedBadge {
margin-left: auto;
font-size: 10.5px;
font-weight: 600;
color: var(--failure-badge-text);
background: var(--failure-badge-bg);
border: 1px solid var(--failure-badge-border);
border-radius: $radius-full;
padding: 1px 7px;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.item {
transition: none;
}
.item:active {
transform: none;
}
}
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
import { isModelExcluded } from '@/features/authFiles/constants';
import styles from '@/pages/AuthFilesPage.module.scss';
import styles from './AuthFileModelsModal.module.scss';
export type AuthFileModelsModalProps = {
open: boolean;
@@ -34,7 +34,7 @@ export function AuthFileModelsModal(props: AuthFileModelsModalProps) {
}
>
{loading ? (
<div className={styles.hint}>
<div className="hint">
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
</div>
) : error === 'unsupported' ? (
@@ -52,13 +52,13 @@ export function AuthFileModelsModal(props: AuthFileModelsModalProps) {
})}
/>
) : (
<div className={styles.modelsList}>
<div className={styles.list}>
{models.map((model) => {
const excludedModel = isModelExcluded(model.id, fileType, excluded);
return (
<div
key={model.id}
className={`${styles.modelItem} ${excludedModel ? styles.modelItemExcluded : ''}`}
className={`${styles.item} ${excludedModel ? styles.itemExcluded : ''}`}
onClick={() => {
onCopyText(model.id);
}}
@@ -76,7 +76,7 @@ export function AuthFileModelsModal(props: AuthFileModelsModalProps) {
)}
{model.type && <span className={styles.modelType}>{model.type}</span>}
{excludedModel && (
<span className={styles.modelExcludedBadge}>
<span className={styles.excludedBadge}>
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
</span>
)}
@@ -0,0 +1,268 @@
@use '../../../styles/mixins' as *;
/* ============================================================
* 认证文件卡片的配额区外衣。
*
* 契约:quotaConfigs.renderQuotaItems 通过 helpers.styles 按名查找
* 以下类(antigravity/claude/codex/kimi/xai 五条渲染路径共 23 个),
* 另加本组件宿主类与 QuotaProgressBar 的 5 个注入类。
* 漏名会静默渲染 class="undefined" —— 增删前先对照 quotaConfigs.ts。
* ============================================================ */
/* ---------- 宿主 ---------- */
.quotaSection {
display: flex;
flex-direction: column;
gap: 9px;
padding: 10px 11px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 55%, transparent);
}
.quotaMessage {
font-family: $font-mono;
font-size: 11.5px;
line-height: 1.5;
color: var(--text-tertiary);
text-align: center;
padding: 5px 4px;
}
button.quotaMessageAction {
cursor: pointer;
border: 0;
background: none;
color: var(--text-secondary);
transition: color $transition-fast;
&:hover:not(:disabled) {
color: var(--primary-hover);
}
&:disabled {
cursor: not-allowed;
color: var(--text-quaternary);
}
}
.quotaError {
font-size: 12px;
line-height: 1.5;
color: var(--danger-color);
overflow-wrap: anywhere;
}
.quotaCardActions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.quotaResetCreditButton {
white-space: nowrap;
}
/* ---------- 配额行(全 provider 通用) ---------- */
.quotaRow {
display: flex;
flex-direction: column;
gap: 4px;
}
.quotaRowHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.quotaModel {
min-width: 0;
font-size: 11.5px;
font-weight: 600;
color: var(--text-secondary);
@include text-ellipsis;
}
.quotaMeta {
display: inline-flex;
align-items: baseline;
gap: 6px;
flex-shrink: 0;
font-family: $font-mono;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.quotaPercent {
font-weight: 650;
color: var(--text-primary);
}
.quotaReset {
color: var(--text-quaternary);
}
.quotaAmount {
font-family: $font-mono;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
/* ---------- 进度条(QuotaProgressBar 注入,Meter 风 6px 轨道) ---------- */
.quotaBar {
height: 6px;
border-radius: $radius-full;
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
overflow: hidden;
}
.quotaBarFill {
height: 100%;
border-radius: $radius-full;
transition: width 360ms var(--ease-out-strong, ease);
}
.quotaBarFillHigh {
background: var(--viz-success);
}
.quotaBarFillMedium {
background: var(--quota-medium-color);
}
.quotaBarFillLow {
background: var(--viz-failure);
}
/* ---------- 计划徽章(codexPlan*antigravity/claude/xai 复用) ---------- */
.codexPlan {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.codexPlanItem {
display: inline-flex;
align-items: baseline;
gap: 5px;
padding: 3px 8px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 70%, transparent);
}
.codexPlanLabel {
font-size: 10.5px;
color: var(--text-tertiary);
}
.codexPlanValue {
font-family: $font-mono;
font-size: 11px;
font-weight: 650;
color: var(--text-secondary);
}
.premiumPlanValue {
font-family: $font-mono;
font-size: 11px;
font-weight: 700;
color: var(--amber-text);
}
/* ---------- Antigravity 分组 ---------- */
.antigravityQuotaGroup {
display: flex;
flex-direction: column;
gap: 6px;
& + & {
margin-top: 3px;
padding-top: 8px;
border-top: 1px dashed color-mix(in srgb, var(--border-color) 80%, transparent);
}
}
.antigravityQuotaGroupHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.antigravityQuotaGroupTitle {
font-family: $font-mono;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.antigravityQuotaGroupDescription {
font-size: 10.5px;
color: var(--text-quaternary);
}
/* ---------- Codex 重置积分 ---------- */
.codexResetCredits {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 9px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
}
.codexResetCreditsTitle {
font-family: $font-mono;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.codexResetCreditRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 11.5px;
}
.codexResetCreditLabel {
min-width: 0;
color: var(--text-secondary);
@include text-ellipsis;
}
.codexResetCreditTime {
flex-shrink: 0;
font-family: $font-mono;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.codexResetCreditsError {
font-size: 11px;
color: var(--danger-color);
overflow-wrap: anywhere;
}
/* ---------- 降级 ---------- */
@media (prefers-reduced-motion: reduce) {
.quotaBarFill {
transition: none;
}
}
@@ -23,7 +23,7 @@ import {
QuotaProgressBar,
type QuotaProgressBarProps,
} from '@/components/quota/QuotaProgressBar';
import styles from '@/pages/AuthFilesPage.module.scss';
import styles from './AuthFileQuota.module.scss';
/** 认证文件卡片外衣的进度条:绑定本页样式,满足 quotaConfigs 的 helpers 契约。 */
const BoundQuotaProgressBar = (props: QuotaProgressBarProps) => (
@@ -0,0 +1,85 @@
@use '../../../styles/mixins' as *;
/* 悬浮批量操作条:底部居中的玻璃工具栏(--content-center-x 对齐内容列中心) */
.container {
position: fixed;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
left: var(--content-center-x, 50%);
transform: translateX(-50%);
width: min(960px, calc(100vw - 24px));
z-index: $z-dropdown;
pointer-events: none;
}
.bar {
pointer-events: auto;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px 16px;
padding: 10px 14px;
border-radius: 16px;
--glass-blur: 14px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
backdrop-filter: var(--glass-backdrop-filter);
box-shadow: var(--shadow-lg);
}
.left,
.right {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.count {
font-family: $font-mono;
font-size: 12px;
font-weight: 650;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
padding-right: 4px;
white-space: nowrap;
}
.bar :global(.btn) {
transition:
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
background-color $transition-fast,
border-color $transition-fast,
color $transition-fast;
}
.bar :global(.btn:active:not(:disabled)) {
transform: scale(0.96);
}
@include mobile {
.container {
width: calc(100vw - 16px);
}
.bar {
flex-direction: column;
align-items: stretch;
}
.left,
.right {
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
.bar :global(.btn) {
transition: none;
}
.bar :global(.btn:active:not(:disabled)) {
transform: none;
}
}
@@ -0,0 +1,213 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { animate } from 'motion/mini';
import { Button } from '@/components/ui/Button';
import { prefersReducedMotion } from '@/hooks/motion';
import { useActionBarHeightVar } from '@/hooks/useActionBarHeightVar';
import styles from './BatchActionBar.module.scss';
const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4;
const easePower2In = (progress: number) => progress ** 3;
const BASE_TRANSFORM = 'translateX(-50%)';
const HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
export type BatchActionBarProps = {
selectionCount: number;
selectablePageCount: number;
selectableFilteredCount: number;
disableControls: boolean;
batchStatusDisabled: boolean;
onSelectPage: () => void;
onSelectFiltered: () => void;
onInvertPage: () => void;
onDeselectAll: () => void;
onDownload: () => void;
onEnable: () => void;
onDisable: () => void;
onDelete: () => void;
};
/**
* 悬浮批量操作条:portal 到 body 的玻璃工具栏。
* - 选中数 >0 时上浮入场(0.28s 强减速),清零后加速退场(0.22s)再卸载;
* - reduced-motion 下只做透明度淡入淡出(保留 translateX(-50%) 基础变换,防止错位半宽);
* - 实时高度写入 --auth-files-action-bar-height 供页面底部留白。
*/
export function BatchActionBar(props: BatchActionBarProps) {
const {
selectionCount,
selectablePageCount,
selectableFilteredCount,
disableControls,
batchStatusDisabled,
onSelectPage,
onSelectFiltered,
onInvertPage,
onDeselectAll,
onDownload,
onEnable,
onDisable,
onDelete,
} = props;
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const animationRef = useRef<ReturnType<typeof animate> | null>(null);
const selectionCountRef = useRef(selectionCount);
const previousCountRef = useRef(0);
useActionBarHeightVar(containerRef, '--auth-files-action-bar-height', visible);
useEffect(() => {
selectionCountRef.current = selectionCount;
if (selectionCount > 0) {
setVisible(true);
}
}, [selectionCount]);
useLayoutEffect(() => {
if (!visible) return;
const currentCount = selectionCount;
const previousCount = previousCountRef.current;
const el = containerRef.current;
if (!el) return;
animationRef.current?.stop();
animationRef.current = null;
const reduced = prefersReducedMotion();
if (currentCount > 0 && previousCount === 0) {
if (reduced) {
el.style.transform = BASE_TRANSFORM;
animationRef.current = animate(
el,
{ opacity: [0, 1] },
{
duration: 0.15,
ease: 'linear',
onComplete: () => {
el.style.opacity = '1';
},
}
);
} else {
animationRef.current = animate(
el,
{ transform: [HIDDEN_TRANSFORM, BASE_TRANSFORM], opacity: [0, 1] },
{
duration: 0.28,
ease: easePower3Out,
onComplete: () => {
el.style.transform = BASE_TRANSFORM;
el.style.opacity = '1';
},
}
);
}
} else if (currentCount === 0 && previousCount > 0) {
const finishExit = () => {
if (selectionCountRef.current === 0) {
setVisible(false);
}
};
if (reduced) {
el.style.transform = BASE_TRANSFORM;
animationRef.current = animate(
el,
{ opacity: [1, 0] },
{ duration: 0.12, ease: 'linear', onComplete: finishExit }
);
} else {
animationRef.current = animate(
el,
{ transform: [BASE_TRANSFORM, HIDDEN_TRANSFORM], opacity: [1, 0] },
{ duration: 0.22, ease: easePower2In, onComplete: finishExit }
);
}
}
previousCountRef.current = currentCount;
}, [visible, selectionCount]);
useEffect(
() => () => {
animationRef.current?.stop();
animationRef.current = null;
},
[]
);
if (!visible || typeof document === 'undefined') return null;
return createPortal(
<div className={styles.container} ref={containerRef}>
<div
className={styles.bar}
role="toolbar"
aria-label={t('auth_files.batch_toolbar_label')}
aria-orientation="horizontal"
>
<div className={styles.left}>
<span className={styles.count} aria-live="polite">
{t('auth_files.batch_selected', { count: selectionCount })}
</span>
<Button
variant="secondary"
size="sm"
onClick={onSelectPage}
disabled={selectablePageCount === 0}
>
{t('auth_files.batch_select_page')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={onSelectFiltered}
disabled={selectableFilteredCount === 0}
>
{t('auth_files.batch_select_filtered')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onInvertPage}
disabled={selectablePageCount === 0}
>
{t('auth_files.batch_invert_page')}
</Button>
<Button variant="ghost" size="sm" onClick={onDeselectAll}>
{t('auth_files.batch_deselect')}
</Button>
</div>
<div className={styles.right}>
<Button
variant="secondary"
size="sm"
onClick={onDownload}
disabled={disableControls || selectionCount === 0}
>
{t('auth_files.batch_download')}
</Button>
<Button size="sm" onClick={onEnable} disabled={batchStatusDisabled}>
{t('auth_files.batch_enable')}
</Button>
<Button variant="secondary" size="sm" onClick={onDisable} disabled={batchStatusDisabled}>
{t('auth_files.batch_disable')}
</Button>
<Button
variant="danger"
size="sm"
onClick={onDelete}
disabled={disableControls || selectionCount === 0}
>
{t('common.delete')}
</Button>
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,168 @@
@use '../../../styles/mixins' as *;
/* ============================================================
* OAuth 配置双卡(排除模型 / 模型别名)共用外衣:panel 配方
* 16px 圆角 / 1px 边框 / 82% 透感纸面),列表行走 mono 遥测。
* ============================================================ */
.panel {
display: flex;
flex-direction: column;
gap: 14px;
padding: clamp(16px, 2vw, 20px);
border-radius: 16px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
}
.panelHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.panelTitle {
margin: 0;
font-size: 15px;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.panelExtra {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.panelBody {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
/* ---------- 视图切换(列表/图谱) ---------- */
.viewModeSwitch {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: $radius-md;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
}
/* ---------- provider 行 ---------- */
.list {
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 45%, transparent);
transition:
background-color $transition-fast,
border-color $transition-fast;
}
@media (hover: hover) and (pointer: fine) {
.item:hover {
border-color: var(--border-hover);
background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
}
}
.itemInfo {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.itemProvider {
font-family: $font-mono;
font-size: 12.5px;
font-weight: 650;
color: var(--text-primary);
@include text-ellipsis;
}
.itemCount {
font-size: 11.5px;
color: var(--text-tertiary);
}
.itemActions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ---------- 别名图谱区 ---------- */
.aliasChartSection {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.aliasChartHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.aliasChartTitle {
margin: 0;
font-family: $font-mono;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.aliasChart {
min-width: 0;
}
/* ---------- 按压反馈 ---------- */
.panel :global(.btn) {
transition:
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
background-color $transition-fast,
border-color $transition-fast,
color $transition-fast;
}
.panel :global(.btn:active:not(:disabled)) {
transform: scale(0.96);
}
@media (prefers-reduced-motion: reduce) {
.panel :global(.btn) {
transition: none;
}
.panel :global(.btn:active:not(:disabled)) {
transform: none;
}
}
@@ -1,9 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import type { OAuthConfigLoadError } from '@/features/authFiles/constants';
import styles from '@/pages/AuthFilesPage.module.scss';
import styles from './OAuthConfigPanels.module.scss';
export type OAuthExcludedCardProps = {
disableControls: boolean;
@@ -20,56 +19,59 @@ export function OAuthExcludedCard(props: OAuthExcludedCardProps) {
const { disableControls, excludedError, excluded, onRetry, onAdd, onEdit, onDelete } = props;
return (
<Card
title={t('oauth_excluded.title')}
extra={
<Button size="sm" onClick={onAdd} disabled={disableControls || excludedError !== null}>
{t('oauth_excluded.add')}
</Button>
}
>
{excludedError === 'unsupported' ? (
<EmptyState
title={t('oauth_excluded.upgrade_required_title')}
description={t('oauth_excluded.upgrade_required_desc')}
/>
) : excludedError === 'load' ? (
<EmptyState
title={t('notification.refresh_failed')}
action={
<Button variant="secondary" size="sm" onClick={() => void onRetry()}>
{t('common.refresh')}
</Button>
}
/>
) : excludedError === 'loading' ? (
<EmptyState title={t('common.loading')} />
) : Object.keys(excluded).length === 0 ? (
<EmptyState title={t('oauth_excluded.list_empty_all')} />
) : (
<div className={styles.excludedList}>
{Object.entries(excluded).map(([provider, models]) => (
<div key={provider} className={styles.excludedItem}>
<div className={styles.excludedInfo}>
<div className={styles.excludedProvider}>{provider}</div>
<div className={styles.excludedModels}>
{models?.length
? t('oauth_excluded.model_count', { count: models.length })
: t('oauth_excluded.no_models')}
<section className={styles.panel}>
<header className={styles.panelHead}>
<h3 className={styles.panelTitle}>{t('oauth_excluded.title')}</h3>
<div className={styles.panelExtra}>
<Button size="sm" onClick={onAdd} disabled={disableControls || excludedError !== null}>
{t('oauth_excluded.add')}
</Button>
</div>
</header>
<div className={styles.panelBody}>
{excludedError === 'unsupported' ? (
<EmptyState
title={t('oauth_excluded.upgrade_required_title')}
description={t('oauth_excluded.upgrade_required_desc')}
/>
) : excludedError === 'load' ? (
<EmptyState
title={t('notification.refresh_failed')}
action={
<Button variant="secondary" size="sm" onClick={() => void onRetry()}>
{t('common.refresh')}
</Button>
}
/>
) : excludedError === 'loading' ? (
<EmptyState title={t('common.loading')} />
) : Object.keys(excluded).length === 0 ? (
<EmptyState title={t('oauth_excluded.list_empty_all')} />
) : (
<div className={styles.list}>
{Object.entries(excluded).map(([provider, models]) => (
<div key={provider} className={styles.item}>
<div className={styles.itemInfo}>
<div className={styles.itemProvider}>{provider}</div>
<div className={styles.itemCount}>
{models?.length
? t('oauth_excluded.model_count', { count: models.length })
: t('oauth_excluded.no_models')}
</div>
</div>
<div className={styles.itemActions}>
<Button variant="secondary" size="sm" onClick={() => onEdit(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDelete(provider)}>
{t('oauth_excluded.delete')}
</Button>
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => onEdit(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDelete(provider)}>
{t('oauth_excluded.delete')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
))}
</div>
)}
</div>
</section>
);
}
@@ -1,13 +1,13 @@
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/modelAlias';
import { IconChevronUp } from '@/components/ui/icons';
import type { OAuthModelAliasEntry } from '@/types';
import type { AuthFileModelItem, OAuthConfigLoadError } from '@/features/authFiles/constants';
import styles from '@/pages/AuthFilesPage.module.scss';
import styles from './OAuthConfigPanels.module.scss';
type ViewMode = 'diagram' | 'list';
export type OAuthModelAliasCardProps = {
@@ -55,16 +55,17 @@ export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
} = props;
return (
<Card
title={t('oauth_model_alias.title')}
extra={
<div className={styles.cardExtraButtons}>
<section className={styles.panel}>
<header className={styles.panelHead}>
<h3 className={styles.panelTitle}>{t('oauth_model_alias.title')}</h3>
<div className={styles.panelExtra}>
<div className={styles.viewModeSwitch}>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('list')}
disabled={disableControls || modelAliasError !== null}
aria-pressed={viewMode === 'list'}
>
{t('oauth_model_alias.view_mode_list')}
</Button>
@@ -73,6 +74,7 @@ export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
size="sm"
onClick={() => onViewModeChange('diagram')}
disabled={disableControls || modelAliasError !== null}
aria-pressed={viewMode === 'diagram'}
>
{t('oauth_model_alias.view_mode_diagram')}
</Button>
@@ -81,83 +83,84 @@ export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
{t('oauth_model_alias.add')}
</Button>
</div>
}
>
{modelAliasError === 'unsupported' ? (
<EmptyState
title={t('oauth_model_alias.upgrade_required_title')}
description={t('oauth_model_alias.upgrade_required_desc')}
/>
) : modelAliasError === 'load' ? (
<EmptyState
title={t('notification.refresh_failed')}
action={
<Button variant="secondary" size="sm" onClick={() => void onRetry()}>
{t('common.refresh')}
</Button>
}
/>
) : modelAliasError === 'loading' ? (
<EmptyState title={t('common.loading')} />
) : viewMode === 'diagram' ? (
Object.keys(modelAlias).length === 0 ? (
</header>
<div className={styles.panelBody}>
{modelAliasError === 'unsupported' ? (
<EmptyState
title={t('oauth_model_alias.upgrade_required_title')}
description={t('oauth_model_alias.upgrade_required_desc')}
/>
) : modelAliasError === 'load' ? (
<EmptyState
title={t('notification.refresh_failed')}
action={
<Button variant="secondary" size="sm" onClick={() => void onRetry()}>
{t('common.refresh')}
</Button>
}
/>
) : modelAliasError === 'loading' ? (
<EmptyState title={t('common.loading')} />
) : viewMode === 'diagram' ? (
Object.keys(modelAlias).length === 0 ? (
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
) : (
<div className={styles.aliasChartSection}>
<div className={styles.aliasChartHeader}>
<h4 className={styles.aliasChartTitle}>{t('oauth_model_alias.chart_title')}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => diagramRef.current?.collapseAll()}
disabled={disableControls || modelAliasError !== null}
title={t('oauth_model_alias.diagram_collapse')}
aria-label={t('oauth_model_alias.diagram_collapse')}
>
<IconChevronUp size={16} />
</Button>
</div>
<ModelMappingDiagram
ref={diagramRef}
modelAlias={modelAlias}
allProviderModels={allProviderModels}
onUpdate={onUpdate}
onDeleteLink={onDeleteLink}
onToggleFork={onToggleFork}
onRenameAlias={onRenameAlias}
onDeleteAlias={onDeleteAlias}
onEditProvider={onEditProvider}
onDeleteProvider={onDeleteProvider}
className={styles.aliasChart}
/>
</div>
)
) : Object.keys(modelAlias).length === 0 ? (
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
) : (
<div className={styles.aliasChartSection}>
<div className={styles.aliasChartHeader}>
<h4 className={styles.aliasChartTitle}>{t('oauth_model_alias.chart_title')}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => diagramRef.current?.collapseAll()}
disabled={disableControls || modelAliasError !== null}
title={t('oauth_model_alias.diagram_collapse')}
aria-label={t('oauth_model_alias.diagram_collapse')}
>
<IconChevronUp size={16} />
</Button>
</div>
<ModelMappingDiagram
ref={diagramRef}
modelAlias={modelAlias}
allProviderModels={allProviderModels}
onUpdate={onUpdate}
onDeleteLink={onDeleteLink}
onToggleFork={onToggleFork}
onRenameAlias={onRenameAlias}
onDeleteAlias={onDeleteAlias}
onEditProvider={onEditProvider}
onDeleteProvider={onDeleteProvider}
className={styles.aliasChart}
/>
</div>
)
) : Object.keys(modelAlias).length === 0 ? (
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
) : (
<div className={styles.excludedList}>
{Object.entries(modelAlias).map(([provider, mappings]) => (
<div key={provider} className={styles.excludedItem}>
<div className={styles.excludedInfo}>
<div className={styles.excludedProvider}>{provider}</div>
<div className={styles.excludedModels}>
{mappings?.length
? t('oauth_model_alias.model_count', { count: mappings.length })
: t('oauth_model_alias.no_models')}
<div className={styles.list}>
{Object.entries(modelAlias).map(([provider, mappings]) => (
<div key={provider} className={styles.item}>
<div className={styles.itemInfo}>
<div className={styles.itemProvider}>{provider}</div>
<div className={styles.itemCount}>
{mappings?.length
? t('oauth_model_alias.model_count', { count: mappings.length })
: t('oauth_model_alias.no_models')}
</div>
</div>
<div className={styles.itemActions}>
<Button variant="secondary" size="sm" onClick={() => onEditProvider(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDeleteProvider(provider)}>
{t('oauth_model_alias.delete')}
</Button>
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => onEditProvider(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDeleteProvider(provider)}>
{t('oauth_model_alias.delete')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
))}
</div>
)}
</div>
</section>
);
}
+1
View File
@@ -312,6 +312,7 @@
"batch_delete_title": "Delete Selected Files",
"batch_delete_confirm": "Are you sure you want to delete {{count}} files?",
"batch_selected": "{{count}} selected",
"batch_toolbar_label": "Batch actions",
"batch_select_all": "Select All",
"batch_select_page": "Select page",
"batch_select_filtered": "Select filtered",
+1
View File
@@ -311,6 +311,7 @@
"batch_delete_title": "Удалить выбранные файлы",
"batch_delete_confirm": "Удалить {{count}} файлов?",
"batch_selected": "{{count}} выбрано",
"batch_toolbar_label": "Групповые действия",
"batch_select_all": "Выбрать все",
"batch_select_page": "Выбрать страницу",
"batch_select_filtered": "Выбрать по фильтру",
+1
View File
@@ -312,6 +312,7 @@
"batch_delete_title": "删除选中文件",
"batch_delete_confirm": "确定要删除 {{count}} 个文件吗?",
"batch_selected": "已选 {{count}} 项",
"batch_toolbar_label": "批量操作",
"batch_select_all": "全选",
"batch_select_page": "全选当前页",
"batch_select_filtered": "全选筛选结果",
+1
View File
@@ -312,6 +312,7 @@
"batch_delete_title": "刪除已選檔案",
"batch_delete_confirm": "確定要刪除 {{count}} 個檔案嗎?",
"batch_selected": "已選 {{count}} 項",
"batch_toolbar_label": "批次操作",
"batch_select_all": "全選",
"batch_select_page": "全選目前頁",
"batch_select_filtered": "全選篩選結果",
+18 -159
View File
@@ -2,20 +2,15 @@ import {
useCallback,
type CSSProperties,
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 { animate } from 'motion/mini';
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useActionBarHeightVar } from '@/hooks/useActionBarHeightVar';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -44,7 +39,8 @@ import {
} from '@/features/authFiles/constants';
import { AuthFileCard } from '@/features/authFiles/components/AuthFileCard';
import { AuthFileModelsModal } from '@/features/authFiles/components/AuthFileModelsModal';
import { AuthFilesPrefixProxyEditorModal } from '@/features/authFiles/components/AuthFilesPrefixProxyEditorModal';
import { AuthFileDetailsSheet } from '@/features/authFiles/components/AuthFileDetailsSheet';
import { BatchActionBar } from '@/features/authFiles/components/BatchActionBar';
import { OAuthExcludedCard } from '@/features/authFiles/components/OAuthExcludedCard';
import { OAuthModelAliasCard } from '@/features/authFiles/components/OAuthModelAliasCard';
import { useAuthFilesData } from '@/features/authFiles/hooks/useAuthFilesData';
@@ -65,10 +61,6 @@ import {
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import styles from './AuthFilesPage.module.scss';
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 DEFAULT_REGULAR_PAGE_SIZE = 9;
const DEFAULT_COMPACT_PAGE_SIZE = 12;
@@ -116,12 +108,7 @@ export function AuthFilesPage() {
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
const [displaySettingsOpen, setDisplaySettingsOpen] = useState(false);
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const [uiStateHydrated, setUiStateHydrated] = useState(false);
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
const previousSelectionCountRef = useRef(0);
const selectionCountRef = useRef(0);
const displaySettingsRef = useRef<HTMLDivElement>(null);
const {
@@ -561,75 +548,6 @@ export function AuthFilesPage() {
[filter, navigate]
);
useActionBarHeightVar(
floatingBatchActionsRef,
'--auth-files-action-bar-height',
batchActionBarVisible
);
useEffect(() => {
selectionCountRef.current = selectionCount;
if (selectionCount > 0) {
setBatchActionBarVisible(true);
}
}, [selectionCount]);
useLayoutEffect(() => {
if (!batchActionBarVisible) return;
const currentCount = selectionCount;
const previousCount = previousSelectionCountRef.current;
const actionsEl = floatingBatchActionsRef.current;
if (!actionsEl) return;
batchActionAnimationRef.current?.stop();
batchActionAnimationRef.current = null;
if (currentCount > 0 && previousCount === 0) {
batchActionAnimationRef.current = animate(
actionsEl,
{
transform: [BATCH_BAR_HIDDEN_TRANSFORM, BATCH_BAR_BASE_TRANSFORM],
opacity: [0, 1],
},
{
duration: 0.28,
ease: easePower3Out,
onComplete: () => {
actionsEl.style.transform = BATCH_BAR_BASE_TRANSFORM;
actionsEl.style.opacity = '1';
},
}
);
} else if (currentCount === 0 && previousCount > 0) {
batchActionAnimationRef.current = animate(
actionsEl,
{
transform: [BATCH_BAR_BASE_TRANSFORM, BATCH_BAR_HIDDEN_TRANSFORM],
opacity: [1, 0],
},
{
duration: 0.22,
ease: easePower2In,
onComplete: () => {
if (selectionCountRef.current === 0) {
setBatchActionBarVisible(false);
}
},
}
);
}
previousSelectionCountRef.current = currentCount;
}, [batchActionBarVisible, selectionCount]);
useEffect(
() => () => {
batchActionAnimationRef.current?.stop();
batchActionAnimationRef.current = null;
},
[]
);
const renderFilterTags = () => (
<div className={styles.filterRail}>
<div className={styles.filterTags}>
@@ -975,7 +893,7 @@ export function AuthFilesPage() {
onCopyText={copyTextWithNotification}
/>
<AuthFilesPrefixProxyEditorModal
<AuthFileDetailsSheet
disableControls={disableControls}
editor={prefixProxyEditor}
updatedText={prefixProxyUpdatedText}
@@ -986,80 +904,21 @@ export function AuthFilesPage() {
onChange={handlePrefixProxyChange}
/>
{batchActionBarVisible && 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_page')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => selectAllVisible(sorted)}
disabled={selectableFilteredItems.length === 0}
>
{t('auth_files.batch_select_filtered')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => invertVisibleSelection(pageItems)}
disabled={selectablePageItems.length === 0}
>
{t('auth_files.batch_invert_page')}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll}>
{t('auth_files.batch_deselect')}
</Button>
</div>
<div className={styles.batchActionRight}>
<Button
variant="secondary"
size="sm"
onClick={() => void batchDownload(selectedNames)}
disabled={disableControls || selectedNames.length === 0}
>
{t('auth_files.batch_download')}
</Button>
<Button
size="sm"
onClick={() => batchSetStatus(selectedNames, true)}
disabled={batchStatusButtonsDisabled}
>
{t('auth_files.batch_enable')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => batchSetStatus(selectedNames, false)}
disabled={batchStatusButtonsDisabled}
>
{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}
<BatchActionBar
selectionCount={selectionCount}
selectablePageCount={selectablePageItems.length}
selectableFilteredCount={selectableFilteredItems.length}
disableControls={disableControls}
batchStatusDisabled={batchStatusButtonsDisabled}
onSelectPage={() => selectAllVisible(pageItems)}
onSelectFiltered={() => selectAllVisible(sorted)}
onInvertPage={() => invertVisibleSelection(pageItems)}
onDeselectAll={deselectAll}
onDownload={() => void batchDownload(selectedNames)}
onEnable={() => batchSetStatus(selectedNames, true)}
onDisable={() => batchSetStatus(selectedNames, false)}
onDelete={() => batchDelete(selectedNames)}
/>
</div>
);
}