From fe83731b94c645dccd3c5deb9467c5d38d014d1a Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 20 Sep 2026 01:24:08 +0800 Subject: [PATCH] feat: enhance credential management with cards, settings, and quota controls - Display credentials in responsive cards with quota progress bars - Add request statistics and detailed health and cooldown information - Consolidate nine credential settings into a dialog with partial updates - Reuse Codex quota reset logic and confirmation across pages - Move notes above card actions and hide the healthy status indicator - Add translations and regression coverage --- src/components/AuthFileHealthStatus.css | 91 +++++++++ src/components/AuthFileHealthStatus.tsx | 143 +++++++++++++ src/components/AuthFileQuotaPanel.tsx | 59 ++++++ src/components/AuthFileRequestStatus.css | 108 ++++++++++ src/components/AuthFileRequestStatus.tsx | 85 ++++++++ src/components/AuthFileSettingsDialog.css | 52 +++++ src/components/AuthFileSettingsDialog.tsx | 196 ++++++++++++++++++ src/components/useCodexQuotaReset.ts | 29 +++ src/i18n/ja.ts | 90 +++++++++ src/i18n/locales/en.ts | 90 +++++++++ src/i18n/locales/zh-CN.ts | 90 +++++++++ src/pages/AuthFileManagementPage.css | 57 ++++++ src/pages/AuthFileManagementPage.tsx | 235 +++++----------------- src/pages/QuotaPage.tsx | 23 +-- src/services/authFileHealth.ts | 138 +++++++++++++ src/services/authFileRequests.ts | 70 +++++++ src/services/authFileSettings.ts | 131 ++++++++++++ src/services/authFiles.ts | 1 + tests/authFileHealth.test.ts | 123 +++++++++++ tests/authFileQuotaPanel.test.tsx | 62 ++++++ tests/authFileRequests.test.ts | 76 +++++++ tests/authFileSettings.test.ts | 88 ++++++++ tests/fixtures/auth-file-requests.html | 2 + tests/fixtures/auth-file-requests.tsx | 143 +++++++++++++ 24 files changed, 1973 insertions(+), 209 deletions(-) create mode 100644 src/components/AuthFileHealthStatus.css create mode 100644 src/components/AuthFileHealthStatus.tsx create mode 100644 src/components/AuthFileQuotaPanel.tsx create mode 100644 src/components/AuthFileRequestStatus.css create mode 100644 src/components/AuthFileRequestStatus.tsx create mode 100644 src/components/AuthFileSettingsDialog.css create mode 100644 src/components/AuthFileSettingsDialog.tsx create mode 100644 src/components/useCodexQuotaReset.ts create mode 100644 src/pages/AuthFileManagementPage.css create mode 100644 src/services/authFileHealth.ts create mode 100644 src/services/authFileRequests.ts create mode 100644 src/services/authFileSettings.ts create mode 100644 tests/authFileHealth.test.ts create mode 100644 tests/authFileQuotaPanel.test.tsx create mode 100644 tests/authFileRequests.test.ts create mode 100644 tests/authFileSettings.test.ts create mode 100644 tests/fixtures/auth-file-requests.html create mode 100644 tests/fixtures/auth-file-requests.tsx diff --git a/src/components/AuthFileHealthStatus.css b/src/components/AuthFileHealthStatus.css new file mode 100644 index 0000000..f8144df --- /dev/null +++ b/src/components/AuthFileHealthStatus.css @@ -0,0 +1,91 @@ +.auth-file-health { + --health-accent: var(--text-tertiary); + grid-column: 2 / -1; + min-width: 0; + color: var(--text-secondary); + font-size: var(--font-size-label); + line-height: 1.55; +} + +.auth-file-health.success { --health-accent: var(--success-color); } +.auth-file-health.warning { --health-accent: var(--amber-text); } +.auth-file-health.error { --health-accent: var(--failure-badge-text); } +.auth-file-health.info { --health-accent: var(--primary-color); } + +.auth-file-health.detailed { + border: 1px solid var(--border-color); + border-left: 3px solid var(--health-accent); + border-radius: 6px; + background: color-mix(in srgb, var(--health-accent) 4%, var(--bg-card)); +} + +.auth-health-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 10px; + min-width: 0; + list-style: none; +} + +.auth-file-health.detailed .auth-health-summary { + padding: 9px 12px; + cursor: pointer; +} + +.auth-health-summary::-webkit-details-marker { display: none; } +.auth-health-summary:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; +} + +.auth-health-icon { flex-shrink: 0; color: var(--health-accent); } +.auth-health-summary-content { flex: 1; min-width: 0; } +.auth-health-heading, +.auth-health-record-heading, +.auth-health-record-meta { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 3px 12px; +} +.auth-health-heading strong { color: var(--health-accent); font-weight: 600; } +.auth-health-heading > span { color: var(--text-secondary); } +.auth-health-message-preview { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + overflow-wrap: anywhere; + color: var(--text-secondary); +} +.auth-health-countdown, +.auth-health-record-heading > span { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.auth-health-disclosure { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--text-tertiary); + font-size: var(--font-size-meta); +} +.auth-file-health details[open] .auth-health-disclosure svg { transform: rotate(180deg); } +.auth-health-body { padding: 0 12px 10px; } +.auth-health-core-state { margin: 0 0 8px; } +.auth-health-core-message > span { color: var(--text-tertiary); font-size: var(--font-size-meta); } +.auth-health-core-message p { margin: 4px 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; } +.auth-health-records { margin: 0; padding: 0; list-style: none; } +.auth-health-records li { padding: 9px 0; border-top: 1px solid var(--border-color); } +.auth-health-record-heading { justify-content: space-between; } +.auth-health-record-heading strong { min-width: 0; color: var(--text-primary); overflow-wrap: anywhere; } +.auth-health-record-meta { margin: 3px 0; } +.auth-health-hint { display: block; margin: 3px 0 0; color: var(--text-tertiary); font-size: var(--font-size-meta); overflow-wrap: anywhere; } + +@media (max-width: 760px) { + .auth-health-summary-content { flex-basis: calc(100% - 30px); } + .auth-health-countdown { margin-left: 26px; } + .auth-health-disclosure { margin-left: auto; } +} diff --git a/src/components/AuthFileHealthStatus.tsx b/src/components/AuthFileHealthStatus.tsx new file mode 100644 index 0000000..b6e6d28 --- /dev/null +++ b/src/components/AuthFileHealthStatus.tsx @@ -0,0 +1,143 @@ +import { useMemo, useSyncExternalStore } from 'react'; +import { ChevronDown, CircleHelp, Clock3, Info, PauseCircle, ShieldAlert } from 'lucide-react'; +import { useI18n } from '../i18n'; +import { + authFileHealth, + cooldownReasonKey, + normalizeAuthFileCooldowns, + summarizeAuthFileCooldowns, + type AuthFileCooldownSnapshot, +} from '../services/authFileHealth'; +import './AuthFileHealthStatus.css'; + +let clockNow = Date.now(); +let timer: ReturnType | undefined; +const listeners = new Set<() => void>(); +const getClockSnapshot = () => clockNow; +const subscribeClock = (listener: () => void) => { + listeners.add(listener); + if (!timer) { + clockNow = Date.now(); + timer = setInterval(() => { + clockNow = Date.now(); + listeners.forEach((notify) => notify()); + }, 1000); + } + return () => { + listeners.delete(listener); + if (!listeners.size) { + clearInterval(timer); + timer = undefined; + } + }; +}; + +type HealthProps = { + file: Record; + snapshot?: AuthFileCooldownSnapshot; +}; + +export function AuthFileHealthStatus({ file, receivedAtMs, observedAt }: { + file: Record; + receivedAtMs: number; + observedAt?: string; +}) { + const snapshot = useMemo( + () => normalizeAuthFileCooldowns(file.cooldowns, receivedAtMs, observedAt), + [file, receivedAtMs, observedAt], + ); + return snapshot?.records?.length + ? + : ; +} + +function TimedHealthStatus(props: HealthProps) { + const nowMs = useSyncExternalStore(subscribeClock, getClockSnapshot, getClockSnapshot); + return ; +} + +function HealthStatus({ file, snapshot, nowMs }: HealthProps & { nowMs: number }) { + const { t, formatNumber, formatDate } = useI18n(); + const health = authFileHealth(file); + const cooldown = summarizeAuthFileCooldowns(snapshot, nowMs); + const hasTimers = cooldown.rows.length > 0; + const healthy = health.label === 'authFiles.health.active'; + if (healthy && !hasTimers && snapshot?.records !== null) return null; + const hasDetails = hasTimers || Boolean(health.message); + const reasonSet = new Set(cooldown.active.map(({ record }) => cooldownReasonKey(record.reason))); + if (reasonSet.has('authFiles.health.reason.credentialQuota')) reasonSet.delete('authFiles.health.reason.quota'); + const reasons = Array.from(reasonSet); + const title = health.disabled ? t(health.label) + : cooldown.elapsed ? t('authFiles.health.elapsed') + : cooldown.active.length ? reasons.map((key) => t(key)).join(' · ') + : healthy ? t('authFiles.health.unknownCooldown') : t(health.label); + const tone = health.disabled || (healthy && !hasTimers) ? 'neutral' : hasTimers ? 'warning' : health.tone; + const separateState = hasTimers && !health.disabled && ( + health.status === 'pending' || health.status === 'refreshing' + || health.label === 'authFiles.health.reason.invalidGrant' + || health.label === 'authFiles.health.reason.unauthorized' + || health.label === 'authFiles.health.reason.tokenExpired' + ); + const Icon = health.disabled ? PauseCircle : hasTimers ? Clock3 + : tone === 'error' ? ShieldAlert + : tone === 'warning' || tone === 'info' ? Info : CircleHelp; + const duration = (seconds: number) => seconds < 60 + ? t('authFiles.health.seconds', { count: formatNumber(seconds) }) + : seconds < 3600 ? t('authFiles.health.minutes', { count: formatNumber(Math.ceil(seconds / 60)) }) + : t('authFiles.health.hours', { count: formatNumber(Math.ceil(seconds / 3600)) }); + const scope = cooldown.credentialWide + ? cooldown.modelCount ? t('authFiles.health.credentialModels', { count: cooldown.modelCount }) + : t('authFiles.health.credential') + : cooldown.modelCount ? t('authFiles.health.models', { count: cooldown.modelCount }) : ''; + const formatTimestamp = (value: string) => formatDate(value, { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }); + const summary = <> +