mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-01 15:34:05 +08:00
feat(auth-files): componentize cards, quota section, sheet editor and batch bar with colocated styles
This commit is contained in:
@@ -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.96(reduced-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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+56
-29
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Выбрать по фильтру",
|
||||
|
||||
@@ -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": "全选筛选结果",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user