From b9c3fbbb77fc686251290d5d7d5434a038460857 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Thu, 30 Jul 2026 02:23:25 +0800 Subject: [PATCH] feat(auth-files): componentize cards, quota section, sheet editor and batch bar with colocated styles --- src/components/ui/icons.tsx | 10 + .../components/AuthFileCard.module.scss | 489 ++++++++++++++++++ .../authFiles/components/AuthFileCard.tsx | 460 ++++++++-------- .../AuthFileDetailsSheet.module.scss | 89 ++++ ...itorModal.tsx => AuthFileDetailsSheet.tsx} | 85 +-- .../AuthFileModelsModal.module.scss | 90 ++++ .../components/AuthFileModelsModal.tsx | 10 +- .../components/AuthFileQuota.module.scss | 268 ++++++++++ .../components/AuthFileQuotaSection.tsx | 2 +- .../components/BatchActionBar.module.scss | 85 +++ .../authFiles/components/BatchActionBar.tsx | 213 ++++++++ .../components/OAuthConfigPanels.module.scss | 168 ++++++ .../components/OAuthExcludedCard.tsx | 104 ++-- .../components/OAuthModelAliasCard.tsx | 163 +++--- src/i18n/locales/en.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-TW.json | 1 + src/pages/AuthFilesPage.tsx | 177 +------ 19 files changed, 1846 insertions(+), 571 deletions(-) create mode 100644 src/features/authFiles/components/AuthFileCard.module.scss create mode 100644 src/features/authFiles/components/AuthFileDetailsSheet.module.scss rename src/features/authFiles/components/{AuthFilesPrefixProxyEditorModal.tsx => AuthFileDetailsSheet.tsx} (74%) create mode 100644 src/features/authFiles/components/AuthFileModelsModal.module.scss create mode 100644 src/features/authFiles/components/AuthFileQuota.module.scss create mode 100644 src/features/authFiles/components/BatchActionBar.module.scss create mode 100644 src/features/authFiles/components/BatchActionBar.tsx create mode 100644 src/features/authFiles/components/OAuthConfigPanels.module.scss diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx index 6fb09765..ec918338 100644 --- a/src/components/ui/icons.tsx +++ b/src/components/ui/icons.tsx @@ -168,6 +168,16 @@ export function IconDownload({ size = 20, ...props }: IconProps) { ); } +export function IconUpload({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + export function IconTrash2({ size = 20, ...props }: IconProps) { return ( diff --git a/src/features/authFiles/components/AuthFileCard.module.scss b/src/features/authFiles/components/AuthFileCard.module.scss new file mode 100644 index 00000000..671112f0 --- /dev/null +++ b/src/features/authFiles/components/AuthFileCard.module.scss @@ -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; + } +} diff --git a/src/features/authFiles/components/AuthFileCard.tsx b/src/features/authFiles/components/AuthFileCard.tsx index 2a29e6c7..71d4e11b 100644 --- a/src/features/authFiles/components/AuthFileCard.tsx +++ b/src/features/authFiles/components/AuthFileCard.tsx @@ -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; quotaFilterType: QuotaProviderType | null; statusBarCache: Map; + /** 首屏一次性级联入场的延迟;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 ( -
-
-
-
- {!isRuntimeOnly && ( - onToggleSelect(file.name)} - className={styles.cardSelection} - aria-label={ - selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all') +
+
+ {!isRuntimeOnly && ( + 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')} + /> + )} +
- )} -
- {providerIcon ? ( - - ) : ( - - {typeLabel.slice(0, 1).toUpperCase()} - - )} -
-
-
- - {typeLabel} - - {stateLabel} -
- - {file.name} - - {!compact && noteValue && ( -
- {t('auth_files.note_display')} - {noteValue} -
- )} -
-
- -
-
- {t('auth_files.file_size')} - - {file.size ? formatFileSize(file.size) : '-'} - -
-
- {t('auth_files.file_modified')} - {formatModified(file)} -
- {priorityValue !== undefined && ( -
- {t('auth_files.priority_display')} - - {priorityValue} - -
- )} -
- - {rawStatusMessage && hasStatusWarning && ( -
- - {rawStatusMessage} -
+ : { + backgroundColor: typeColor.bg, + color: typeColor.text, + ...(typeColor.border ? { border: typeColor.border } : {}), + } + } + > + {providerIcon ? ( + + ) : ( + {typeLabel.slice(0, 1).toUpperCase()} )} - -
-
-
- {t('stats.success')} - {fileStats.success} -
-
- {t('stats.failure')} - {fileStats.failure} -
-
- -
-
- {t('auth_files.health_status_label')} -
- -
- - {showQuotaLayout && quotaType && ( - - )} +
+
+
+ + {typeLabel} + + +
+ + {file.name} + +
+
-
-
- {showModelsButton && ( + {!compact && noteValue && ( +

+ {noteValue} +

+ )} + + {rawStatusMessage && hasStatusWarning && ( +
+ + {rawStatusMessage} +
+ )} + +
+
+ {t('auth_files.health_status_label')} + + 0 ? styles.countLive : ''}`} + title={t('stats.success')} + > + {t('stats.success')} {successCount} + + 0 ? styles.countLive : ''}`} + title={t('stats.failure')} + > + {t('stats.failure')} {failureCount} + + +
+ +
+ +
+ + {file.size ? formatFileSize(file.size) : '-'} + + + {formatModified(file)} + {priorityValue !== undefined && ( + <> + + + P{priorityValue} + + + )} +
+ + {showQuotaLayout && quotaType && ( + + )} + +
+
+ {showModelsButton && ( + + )} + {!isRuntimeOnly && ( +
+ {showManualRefreshButton && ( )} - {!isRuntimeOnly && ( -
- {showManualRefreshButton && ( - - )} - - - -
- )} + + +
- {!isRuntimeOnly && ( -
- - {t('auth_files.status_toggle_label')} - - onToggleStatus(file, value)} - /> -
- )} -
+ )}
-
-
+ {!isRuntimeOnly && ( +
+ {t('auth_files.status_toggle_label')} + onToggleStatus(file, value)} + /> +
+ )} + + ); } diff --git a/src/features/authFiles/components/AuthFileDetailsSheet.module.scss b/src/features/authFiles/components/AuthFileDetailsSheet.module.scss new file mode 100644 index 00000000..362dc07a --- /dev/null +++ b/src/features/authFiles/components/AuthFileDetailsSheet.module.scss @@ -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; +} diff --git a/src/features/authFiles/components/AuthFilesPrefixProxyEditorModal.tsx b/src/features/authFiles/components/AuthFileDetailsSheet.tsx similarity index 74% rename from src/features/authFiles/components/AuthFilesPrefixProxyEditorModal.tsx rename to src/features/authFiles/components/AuthFileDetailsSheet.tsx index 34295919..0eae7fb1 100644 --- a/src/features/authFiles/components/AuthFilesPrefixProxyEditorModal.tsx +++ b/src/features/authFiles/components/AuthFileDetailsSheet.tsx @@ -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 => { + if (!dirty || editor?.saving === true) return true; + return new Promise((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 ( - -