Compare commits

..

15 Commits
v1.4.19 ... dev

Author SHA1 Message Date
Supra4E8C
52cf9d86c0 feat(usage): enhance ServiceHealthCard with scrollable health grid 2026-02-14 15:03:02 +08:00
Supra4E8C
a2507b1373 feat(usage): add service health card with 7-day contribution grid 2026-02-14 14:57:06 +08:00
Supra4E8C
1f8c4331c7 feat(status-bar): add gradient colors and tooltip with mobile support 2026-02-14 13:24:53 +08:00
Supra4E8C
faadc3ea3e refactor(select): unify dropdown implementations 2026-02-14 12:50:03 +08:00
Supra4E8C
32b576123c feat(usage): use modal dialog for editing model prices 2026-02-14 12:09:02 +08:00
Supra4E8C
5dce24e3ea feat(select): implement custom Select component with dropdown functionality 2026-02-14 12:01:11 +08:00
Supra4E8C
bf824f8561 fix(clipboard): add shared helper and remove lint warnings 2026-02-14 03:33:09 +08:00
Supra4E8C
3a7ddfdff1 fix(clipboard): add fallback helper and unify copy actions 2026-02-14 03:25:33 +08:00
Supra4E8C
431ec1e0f5 fix(theme): improve dark mode contrast and enforce white button text 2026-02-14 03:06:07 +08:00
Supra4E8C
e2368ddfd7 Refactor color variables and styles across components for a cohesive design update
- Updated active state colors in ToastSelect component for better visibility.
- Adjusted box-shadow and border colors in ModelMappingDiagram styles.
- Changed provider colors in ModelMappingDiagram for improved aesthetics.
- Modified background and border styles in ProviderNav for a more modern look.
- Updated accent colors in StatCards to align with new color scheme.
- Refined token colors in TokenBreakdownChart for consistency.
- Adjusted sparkline colors in useSparklines hook to match new design.
- Changed error icon color in AiProvidersOpenAIEditPage for better contrast.
- Updated failure badge styles in AiProvidersPage for a cleaner appearance.
- Refined various status styles in AuthFilesPage for improved clarity.
- Updated colors in ConfigPage to use new variable definitions.
- Refined error and warning styles in LoginPage for better user feedback.
- Adjusted log status colors in LogsPage for consistency with new theme.
- Updated OAuthPage styles to reflect new color variables.
- Refined quota styles in QuotaPage for better visual hierarchy.
- Updated system page styles for improved user experience.
- Adjusted usage page styles to align with new design language.
- Refactored component styles to use new color variables in components.scss.
- Updated layout styles to reflect new primary color definitions.
- Refined theme colors in themes.scss for a more cohesive look.
- Updated color variables in variables.scss to reflect new design choices.
- Adjusted chart colors in usage.ts for consistency with new color scheme.
2026-02-14 02:25:58 +08:00
Supra4E8C
6f4bc7c3bb fix(format): use page locale by default 2026-02-14 00:26:54 +08:00
Supra4E8C
3937a403b1 fix(i18n): localize splash strings 2026-02-14 00:24:52 +08:00
Supra4E8C
f003a34dc0 fix(auth-files): unify max auth file size 2026-02-14 00:19:04 +08:00
Supra4E8C
dc4ceabc7b refactor(api): centralize url normalization 2026-02-14 00:16:14 +08:00
Supra4E8C
e13d7f5e0f refactor(auth-files): split AuthFilesPage 2026-02-14 00:11:41 +08:00
59 changed files with 4192 additions and 2739 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import './SplashScreen.scss';
@@ -10,6 +11,8 @@ interface SplashScreenProps {
const FADE_OUT_DURATION = 400;
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
const { t } = useTranslation();
useEffect(() => {
if (!fadeOut) return;
const finishTimer = setTimeout(() => {
@@ -25,8 +28,8 @@ export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
<div className="splash-content">
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
<h1 className="splash-title">CLI Proxy API</h1>
<p className="splash-subtitle">Management Center</p>
<h1 className="splash-title">{t('splash.title')}</h1>
<p className="splash-subtitle">{t('splash.subtitle')}</p>
<div className="splash-loader">
<div className="splash-loader-bar" />
</div>

View File

@@ -1,13 +1,14 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconChevronDown } from '@/components/ui/icons';
import { ConfigSection } from '@/components/config/ConfigSection';
import { useNotificationStore } from '@/stores';
import styles from './VisualConfigEditor.module.scss';
import { copyToClipboard } from '@/utils/clipboard';
import type {
PayloadFilterRule,
PayloadModelEntry,
@@ -80,118 +81,6 @@ function Divider() {
return <div style={{ height: 1, background: 'var(--border-color)', margin: '16px 0' }} />;
}
type ToastSelectOption = { value: string; label: string };
function ToastSelect({
value,
options,
disabled,
ariaLabel,
onChange,
}: {
value: string;
options: ReadonlyArray<ToastSelectOption>;
disabled?: boolean;
ariaLabel: string;
onChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const selectedOption = options.find((opt) => opt.value === value) ?? options[0];
useEffect(() => {
if (!open) return;
const handleClickOutside = (event: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<button
type="button"
className="input"
disabled={disabled}
onClick={() => setOpen((prev) => !prev)}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
cursor: disabled ? 'not-allowed' : 'pointer',
textAlign: 'left',
width: '100%',
appearance: 'none',
}}
>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{selectedOption?.label ?? ''}
</span>
<IconChevronDown size={16} style={{ opacity: 0.6, flex: '0 0 auto' }} />
</button>
{open && !disabled && (
<div
role="listbox"
aria-label={ariaLabel}
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
left: 0,
right: 0,
zIndex: 1000,
background: 'var(--bg-primary)',
border: '1px solid var(--border-color)',
borderRadius: 12,
padding: 6,
boxShadow: 'var(--shadow)',
display: 'flex',
flexDirection: 'column',
gap: 6,
maxHeight: 260,
overflowY: 'auto',
}}
>
{options.map((opt) => {
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
style={{
padding: '10px 12px',
borderRadius: 10,
border: active ? '1px solid rgba(59, 130, 246, 0.5)' : '1px solid var(--border-color)',
background: active ? 'rgba(59, 130, 246, 0.10)' : 'var(--bg-primary)',
color: 'var(--text-primary)',
cursor: 'pointer',
textAlign: 'left',
fontWeight: 600,
}}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
);
}
function ApiKeysCardEditor({
value,
disabled,
@@ -266,31 +155,11 @@ function ApiKeysCardEditor({
};
const handleCopy = async (apiKey: string) => {
const copyByExecCommand = () => {
const textarea = document.createElement('textarea');
textarea.value = apiKey;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
if (!copied) throw new Error('copy_failed');
};
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(apiKey);
} else {
copyByExecCommand();
}
showNotification(t('notification.link_copied'), 'success');
} catch {
showNotification(t('notification.copy_failed'), 'error');
}
const copied = await copyToClipboard(apiKey);
showNotification(
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
copied ? 'success' : 'error'
);
};
return (
@@ -426,7 +295,7 @@ function PayloadRulesEditor({
protocolFirst?: boolean;
onChange: (next: PayloadRule[]) => void;
}) {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
@@ -434,7 +303,7 @@ function PayloadRulesEditor({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
[t]
);
const payloadValueTypeOptions = useMemo(
() =>
@@ -442,7 +311,7 @@ function PayloadRulesEditor({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
[t]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
@@ -547,7 +416,7 @@ function PayloadRulesEditor({
>
{protocolFirst ? (
<>
<ToastSelect
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
@@ -575,7 +444,7 @@ function PayloadRulesEditor({
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
disabled={disabled}
/>
<ToastSelect
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
@@ -617,7 +486,7 @@ function PayloadRulesEditor({
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
disabled={disabled}
/>
<ToastSelect
<Select
value={param.valueType}
options={payloadValueTypeOptions}
disabled={disabled}
@@ -685,7 +554,7 @@ function PayloadFilterRulesEditor({
disabled?: boolean;
onChange: (next: PayloadFilterRule[]) => void;
}) {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
@@ -693,7 +562,7 @@ function PayloadFilterRulesEditor({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
[t]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
@@ -760,7 +629,7 @@ function PayloadFilterRulesEditor({
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
disabled={disabled}
/>
<ToastSelect
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
@@ -1013,7 +882,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
/>
<div className="form-group">
<label>{t('config_management.visual.sections.network.routing_strategy')}</label>
<ToastSelect
<Select
value={values.routingStrategy}
options={[
{ value: 'round-robin', label: t('config_management.visual.sections.network.strategy_round_robin') },

View File

@@ -114,7 +114,7 @@
&.selected {
border-color: var(--primary-color);
background-color: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
box-shadow: 0 0 0 2px rgba($primary-color, 0.18);
}
}

View File

@@ -33,7 +33,7 @@ export interface ModelMappingDiagramProps {
}
const PROVIDER_COLORS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#8b8680', '#10b981', '#f59e0b', '#c65746',
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
];

View File

@@ -17,12 +17,12 @@
flex-direction: row;
gap: 6px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.7);
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
overflow-x: auto;
scrollbar-width: none;
max-width: inherit;
@@ -39,7 +39,7 @@
pointer-events: none;
opacity: 0;
border-radius: 999px;
background: rgba(59, 130, 246, 0.15);
background: rgba($primary-color, 0.16);
box-shadow: inset 0 0 0 2px var(--primary-color);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
@@ -73,7 +73,7 @@
flex: 0 0 auto;
&:hover {
background: rgba(0, 0, 0, 0.06);
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
transform: scale(1.08);
}
@@ -104,19 +104,13 @@
// 暗色主题适配
:global([data-theme='dark']) {
.navList {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.navItem {
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
.indicator {
background: rgba(59, 130, 246, 0.25);
background: rgba($primary-color, 0.28);
}
}

View File

@@ -1,36 +1,143 @@
import { calculateStatusBarData } from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { StatusBarData, StatusBlockDetail } from '@/utils/usage';
import defaultStyles from '@/pages/AiProvidersPage.module.scss';
interface ProviderStatusBarProps {
statusData: ReturnType<typeof calculateStatusBarData>;
/**
* 根据成功率 (01) 在三个色标之间做 RGB 线性插值
* 0 → 红 (#ef4444) → 0.5 → 金黄 (#facc15) → 1 → 绿 (#22c55e)
*/
const COLOR_STOPS = [
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
] as const;
function rateToColor(rate: number): string {
const t = Math.max(0, Math.min(1, rate));
const segment = t < 0.5 ? 0 : 1;
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
const from = COLOR_STOPS[segment];
const to = COLOR_STOPS[segment + 1];
const r = Math.round(from.r + (to.r - from.r) * localT);
const g = Math.round(from.g + (to.g - from.g) * localT);
const b = Math.round(from.b + (to.b - from.b) * localT);
return `rgb(${r}, ${g}, ${b})`;
}
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
}
type StylesModule = Record<string, string>;
interface ProviderStatusBarProps {
statusData: StatusBarData;
styles?: StylesModule;
}
export function ProviderStatusBar({ statusData, styles: stylesProp }: ProviderStatusBarProps) {
const { t } = useTranslation();
const s = (stylesProp || defaultStyles) as StylesModule;
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
const blocksRef = useRef<HTMLDivElement>(null);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
? s.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
? s.statusRateMedium
: s.statusRateLow;
// 点击外部关闭 tooltip移动端
useEffect(() => {
if (activeTooltip === null) return;
const handler = (e: PointerEvent) => {
if (blocksRef.current && !blocksRef.current.contains(e.target as Node)) {
setActiveTooltip(null);
}
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [activeTooltip]);
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(idx);
}
}, []);
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(null);
}
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) => (prev === idx ? null : idx));
}
}, []);
const getTooltipPositionClass = (idx: number, total: number): string => {
if (idx <= 2) return s.statusTooltipLeft;
if (idx >= total - 3) return s.statusTooltipRight;
return '';
};
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
const total = detail.success + detail.failure;
const posClass = getTooltipPositionClass(idx, statusData.blockDetails.length);
const timeRange = `${formatTime(detail.startTime)} ${formatTime(detail.endTime)}`;
return (
<div className={`${s.statusTooltip} ${posClass}`}>
<span className={s.tooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={s.tooltipStats}>
<span className={s.tooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
<span className={s.tooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
<span className={s.tooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
<span className={s.tooltipStats}>{t('status_bar.no_requests')}</span>
)}
</div>
);
};
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
<div className={s.statusBar}>
<div className={s.statusBlocks} ref={blocksRef}>
{statusData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip === idx;
return (
<div
key={idx}
className={`${s.statusBlockWrapper} ${isActive ? s.statusBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${s.statusBlock} ${isIdle ? s.statusBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && renderTooltip(detail, idx)}
</div>
);
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
<span className={`${s.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>

View File

@@ -0,0 +1,110 @@
@use '../../styles/mixins' as *;
.wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.wrapFullWidth {
width: 100%;
}
.trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
box-shadow: var(--shadow);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
text-align: left;
box-sizing: border-box;
&:hover {
border-color: var(--border-hover);
}
&:focus {
outline: none;
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
}
&[aria-expanded='true'] {
border-color: var(--primary-color);
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
}
}
.triggerText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
color: var(--text-tertiary);
}
.triggerIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 6px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
}
.option {
padding: 8px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
font-weight: 500;
transition: background-color 0.15s ease, border-color 0.15s ease;
flex-shrink: 0;
&:hover {
background: var(--bg-secondary);
}
}
.optionActive {
border-color: rgba($primary-color, 0.5);
background: rgba($primary-color, 0.1);
font-weight: 600;
}

View File

@@ -0,0 +1,94 @@
import { useState, useEffect, useRef } from 'react';
import { IconChevronDown } from './icons';
import styles from './Select.module.scss';
export interface SelectOption {
value: string;
label: string;
}
interface SelectProps {
value: string;
options: ReadonlyArray<SelectOption>;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
ariaLabel?: string;
fullWidth?: boolean;
}
export function Select({
value,
options,
onChange,
placeholder,
className,
disabled = false,
ariaLabel,
fullWidth = true
}: SelectProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open || disabled) return;
const handleClickOutside = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [disabled, open]);
const isOpen = open && !disabled;
const selected = options.find((o) => o.value === value);
const displayText = selected?.label ?? placeholder ?? '';
const isPlaceholder = !selected && placeholder;
return (
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<button
type="button"
className={styles.trigger}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={ariaLabel}
disabled={disabled}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{isOpen && (
<div className={styles.dropdown} role="listbox" aria-label={ariaLabel}>
{options.map((opt) => {
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''}`}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import styles from '@/pages/UsagePage.module.scss';
export interface ChartLineSelectorProps {
@@ -41,6 +43,14 @@ export function ChartLineSelector({
onChange(newLines);
};
const options = useMemo(
() => [
{ value: 'all', label: t('usage_stats.chart_line_all') },
...modelNames.map((name) => ({ value: name, label: name }))
],
[modelNames, t]
);
return (
<Card
title={t('usage_stats.chart_line_actions_label')}
@@ -66,18 +76,11 @@ export function ChartLineSelector({
<span className={styles.chartLineLabel}>
{t(`usage_stats.chart_line_label_${index + 1}`)}
</span>
<select
<Select
value={line}
onChange={(e) => handleChange(index, e.target.value)}
className={styles.select}
>
<option value="all">{t('usage_stats.chart_line_all')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
options={options}
onChange={(value) => handleChange(index, value)}
/>
{chartLines.length > 1 && (
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
{t('usage_stats.chart_line_delete')}

View File

@@ -277,10 +277,11 @@ export function CredentialStatsCard({
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
return (
<Card title={t('usage_stats.credential_stats')}>
<Card title={t('usage_stats.credential_stats')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length > 0 ? (
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
@@ -326,6 +327,7 @@ export function CredentialStatsCard({
</tbody>
</table>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}

View File

@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import type { ModelPrice } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
@@ -19,11 +21,18 @@ export function PriceSettingsCard({
}: PriceSettingsCardProps) {
const { t } = useTranslation();
// Add form state
const [selectedModel, setSelectedModel] = useState('');
const [promptPrice, setPromptPrice] = useState('');
const [completionPrice, setCompletionPrice] = useState('');
const [cachePrice, setCachePrice] = useState('');
// Edit modal state
const [editModel, setEditModel] = useState<string | null>(null);
const [editPrompt, setEditPrompt] = useState('');
const [editCompletion, setEditCompletion] = useState('');
const [editCache, setEditCache] = useState('');
const handleSavePrice = () => {
if (!selectedModel) return;
const prompt = parseFloat(promptPrice) || 0;
@@ -43,12 +52,22 @@ export function PriceSettingsCard({
onPricesChange(newPrices);
};
const handleEditPrice = (model: string) => {
const handleOpenEdit = (model: string) => {
const price = modelPrices[model];
setSelectedModel(model);
setPromptPrice(price?.prompt?.toString() || '');
setCompletionPrice(price?.completion?.toString() || '');
setCachePrice(price?.cache?.toString() || '');
setEditModel(model);
setEditPrompt(price?.prompt?.toString() || '');
setEditCompletion(price?.completion?.toString() || '');
setEditCache(price?.cache?.toString() || '');
};
const handleSaveEdit = () => {
if (!editModel) return;
const prompt = parseFloat(editPrompt) || 0;
const completion = parseFloat(editCompletion) || 0;
const cache = editCache.trim() === '' ? prompt : parseFloat(editCache) || 0;
const newPrices = { ...modelPrices, [editModel]: { prompt, completion, cache } };
onPricesChange(newPrices);
setEditModel(null);
};
const handleModelSelect = (value: string) => {
@@ -65,6 +84,14 @@ export function PriceSettingsCard({
}
};
const options = useMemo(
() => [
{ value: '', label: t('usage_stats.model_price_select_placeholder') },
...modelNames.map((name) => ({ value: name, label: name }))
],
[modelNames, t]
);
return (
<Card title={t('usage_stats.model_price_settings')}>
<div className={styles.pricingSection}>
@@ -73,18 +100,12 @@ export function PriceSettingsCard({
<div className={styles.formRow}>
<div className={styles.formField}>
<label>{t('usage_stats.model_name')}</label>
<select
<Select
value={selectedModel}
onChange={(e) => handleModelSelect(e.target.value)}
className={styles.select}
>
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
options={options}
onChange={handleModelSelect}
placeholder={t('usage_stats.model_price_select_placeholder')}
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
@@ -144,7 +165,7 @@ export function PriceSettingsCard({
</div>
</div>
<div className={styles.priceActions}>
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
<Button variant="secondary" size="sm" onClick={() => handleOpenEdit(model)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
@@ -159,6 +180,57 @@ export function PriceSettingsCard({
)}
</div>
</div>
{/* Edit Modal */}
<Modal
open={editModel !== null}
title={editModel ?? ''}
onClose={() => setEditModel(null)}
footer={
<div className={styles.priceActions}>
<Button variant="secondary" onClick={() => setEditModel(null)}>
{t('common.cancel')}
</Button>
<Button variant="primary" onClick={handleSaveEdit}>
{t('common.save')}
</Button>
</div>
}
width={420}
>
<div className={styles.editModalBody}>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
<Input
type="number"
value={editPrompt}
onChange={(e) => setEditPrompt(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
<Input
type="number"
value={editCompletion}
onChange={(e) => setEditCompletion(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
<Input
type="number"
value={editCache}
onChange={(e) => setEditCache(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
</div>
</Modal>
</Card>
);
}

View File

@@ -0,0 +1,180 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
collectUsageDetails,
calculateServiceHealthData,
type ServiceHealthData,
type StatusBlockDetail,
} from '@/utils/usage';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const COLOR_STOPS = [
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
] as const;
function rateToColor(rate: number): string {
const t = Math.max(0, Math.min(1, rate));
const segment = t < 0.5 ? 0 : 1;
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
const from = COLOR_STOPS[segment];
const to = COLOR_STOPS[segment + 1];
const r = Math.round(from.r + (to.r - from.r) * localT);
const g = Math.round(from.g + (to.g - from.g) * localT);
const b = Math.round(from.b + (to.b - from.b) * localT);
return `rgb(${r}, ${g}, ${b})`;
}
function formatDateTime(timestamp: number): string {
const date = new Date(timestamp);
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${month}/${day} ${h}:${m}`;
}
export interface ServiceHealthCardProps {
usage: UsagePayload | null;
loading: boolean;
}
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
const { t } = useTranslation();
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const healthData: ServiceHealthData = useMemo(() => {
const details = usage ? collectUsageDetails(usage) : [];
return calculateServiceHealthData(details);
}, [usage]);
const hasData = healthData.totalSuccess + healthData.totalFailure > 0;
useEffect(() => {
if (activeTooltip === null) return;
const handler = (e: PointerEvent) => {
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
setActiveTooltip(null);
}
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [activeTooltip]);
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(idx);
}
}, []);
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(null);
}
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) => (prev === idx ? null : idx));
}
}, []);
const getTooltipPositionClass = (idx: number): string => {
const col = Math.floor(idx / healthData.rows);
if (col <= 2) return styles.healthTooltipLeft;
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
return '';
};
const getTooltipVerticalClass = (idx: number): string => {
const row = idx % healthData.rows;
if (row <= 1) return styles.healthTooltipBelow;
return '';
};
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
const total = detail.success + detail.failure;
const posClass = getTooltipPositionClass(idx);
const vertClass = getTooltipVerticalClass(idx);
const timeRange = `${formatDateTime(detail.startTime)} ${formatDateTime(detail.endTime)}`;
return (
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
<span className={styles.healthTooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={styles.healthTooltipStats}>
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
<span className={styles.healthTooltipStats}>{t('status_bar.no_requests')}</span>
)}
</div>
);
};
const rateClass = !hasData
? ''
: healthData.successRate >= 90
? styles.healthRateHigh
: healthData.successRate >= 50
? styles.healthRateMedium
: styles.healthRateLow;
return (
<div className={styles.healthCard}>
<div className={styles.healthHeader}>
<h3 className={styles.healthTitle}>{t('service_health.title')}</h3>
<div className={styles.healthMeta}>
<span className={styles.healthWindow}>{t('service_health.window')}</span>
<span className={`${styles.healthRate} ${rateClass}`}>
{loading ? '--' : hasData ? `${healthData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
</div>
<div className={styles.healthGridScroller}>
<div
className={styles.healthGrid}
ref={gridRef}
>
{healthData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip === idx;
return (
<div
key={idx}
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && renderTooltip(detail, idx)}
</div>
);
})}
</div>
</div>
<div className={styles.healthLegend}>
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
<div className={styles.healthLegendColors}>
<div className={`${styles.healthLegendBlock} ${styles.healthBlockIdle}`} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#ef4444' }} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#facc15' }} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#22c55e' }} />
</div>
<span className={styles.healthLegendLabel}>{t('service_health.newest')}</span>
</div>
</div>
);
}

View File

@@ -56,9 +56,9 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
key: 'requests',
label: t('usage_stats.total_requests'),
icon: <IconSatellite size={16} />,
accent: '#3b82f6',
accentSoft: 'rgba(59, 130, 246, 0.18)',
accentBorder: 'rgba(59, 130, 246, 0.35)',
accent: '#8b8680',
accentSoft: 'rgba(139, 134, 128, 0.18)',
accentBorder: 'rgba(139, 134, 128, 0.35)',
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
meta: (
<>
@@ -67,7 +67,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
</span>
<span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
<span className={styles.statMetaDot} style={{ backgroundColor: '#c65746' }} />
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
</span>
</>

View File

@@ -13,7 +13,7 @@ import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
input: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.25)' },
input: { border: '#8b8680', bg: 'rgba(139, 134, 128, 0.25)' },
output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' },
cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' },
reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }

View File

@@ -104,7 +104,7 @@ export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSpar
);
const requestsSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
() => buildSparkline(buildLastHourSeries('requests'), '#8b8680', 'rgba(139, 134, 128, 0.18)'),
[buildLastHourSeries, buildSparkline]
);

View File

@@ -35,3 +35,6 @@ export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
export { CostTrendChart } from './CostTrendChart';
export type { CostTrendChartProps } from './CostTrendChart';
export { ServiceHealthCard } from './ServiceHealthCard';
export type { ServiceHealthCardProps } from './ServiceHealthCard';

View File

@@ -0,0 +1,216 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconBot, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
import type { AuthFileItem } from '@/types';
import { resolveAuthProvider } from '@/utils/quota';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import {
QUOTA_PROVIDER_TYPES,
formatModified,
getTypeColor,
getTypeLabel,
isRuntimeOnlyAuthFile,
normalizeAuthIndexValue,
resolveAuthFileStats,
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';
export type AuthFileCardProps = {
file: AuthFileItem;
resolvedTheme: ResolvedTheme;
disableControls: boolean;
deleting: string | null;
statusUpdating: Record<string, boolean>;
quotaFilterType: QuotaProviderType | null;
keyStats: KeyStats;
statusBarCache: Map<string, AuthFileStatusBarData>;
onShowModels: (file: AuthFileItem) => void;
onShowDetails: (file: AuthFileItem) => void;
onDownload: (name: string) => void;
onOpenPrefixProxyEditor: (name: string) => void;
onDelete: (name: string) => void;
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
};
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
const provider = resolveAuthProvider(file);
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
return provider as QuotaProviderType;
};
export function AuthFileCard(props: AuthFileCardProps) {
const { t } = useTranslation();
const {
file,
resolvedTheme,
disableControls,
deleting,
statusUpdating,
quotaFilterType,
keyStats,
statusBarCache,
onShowModels,
onShowDetails,
onDownload,
onOpenPrefixProxyEditor,
onDelete,
onToggleStatus
} = props;
const fileStats = resolveAuthFileStats(file, keyStats);
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
const isAistudio = (file.type || '').toLowerCase() === 'aistudio';
const showModelsButton = !isRuntimeOnly || isAistudio;
const typeColor = getTypeColor(file.type || 'unknown', resolvedTheme);
const quotaType =
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
const providerCardClass =
quotaType === 'antigravity'
? styles.antigravityCard
: quotaType === 'codex'
? styles.codexCard
: quotaType === 'gemini-cli'
? styles.geminiCliCard
: '';
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
const statusData =
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
return (
<div
className={`${styles.fileCard} ${providerCardClass} ${file.disabled ? styles.fileCardDisabled : ''}`}
>
<div className={styles.fileCardLayout}>
<div className={styles.fileCardMain}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(t, file.type || 'unknown')}
</span>
<span className={styles.fileName}>{file.name}</span>
</div>
<div className={styles.cardMeta}>
<span>
{t('auth_files.file_size')}: {file.size ? formatFileSize(file.size) : '-'}
</span>
<span>
{t('auth_files.file_modified')}: {formatModified(file)}
</span>
</div>
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {fileStats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {fileStats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} styles={styles} />
{showQuotaLayout && quotaType && (
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
)}
<div className={styles.cardActions}>
{showModelsButton && (
<Button
variant="secondary"
size="sm"
onClick={() => onShowModels(file)}
className={styles.iconButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconBot className={styles.actionIcon} size={16} />
</Button>
)}
{!isRuntimeOnly && (
<>
<Button
variant="secondary"
size="sm"
onClick={() => onShowDetails(file)}
className={styles.iconButton}
title={t('common.info', { defaultValue: '关于' })}
disabled={disableControls}
>
<IconInfo 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.name)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls}
>
<IconCode className={styles.actionIcon} size={16} />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(file.name)}
className={styles.iconButton}
title={t('auth_files.delete_button')}
disabled={disableControls || deleting === file.name}
>
{deleting === file.name ? (
<LoadingSpinner size={14} />
) : (
<IconTrash2 className={styles.actionIcon} size={16} />
)}
</Button>
</>
)}
{!isRuntimeOnly && (
<div className={styles.statusToggle}>
<ToggleSwitch
ariaLabel={t('auth_files.status_toggle_label')}
checked={!file.disabled}
disabled={disableControls || statusUpdating[file.name] === true}
onChange={(value) => onToggleStatus(file, value)}
/>
</div>
)}
{isRuntimeOnly && (
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import type { AuthFileItem } from '@/types';
import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFileDetailModalProps = {
open: boolean;
file: AuthFileItem | null;
onClose: () => void;
onCopyText: (text: string) => void;
};
export function AuthFileDetailModal({ open, file, onClose, onCopyText }: AuthFileDetailModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
onClose={onClose}
title={file?.name || t('auth_files.title_section')}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
<Button
onClick={() => {
if (!file) return;
const text = JSON.stringify(file, null, 2);
onCopyText(text);
}}
>
{t('common.copy')}
</Button>
</>
}
>
{file && (
<div className={styles.detailContent}>
<pre className={styles.jsonContent}>{JSON.stringify(file, null, 2)}</pre>
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,91 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
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';
export type AuthFileModelsModalProps = {
open: boolean;
fileName: string;
fileType: string;
loading: boolean;
error: 'unsupported' | null;
models: AuthFileModelItem[];
excluded: Record<string, string[]>;
onClose: () => void;
onCopyText: (text: string) => void;
};
export function AuthFileModelsModal(props: AuthFileModelsModalProps) {
const { t } = useTranslation();
const { open, fileName, fileType, loading, error, models, excluded, onClose, onCopyText } = props;
return (
<Modal
open={open}
onClose={onClose}
title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${fileName}`}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{loading ? (
<div className={styles.hint}>
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
</div>
) : error === 'unsupported' ? (
<EmptyState
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
description={t('auth_files.models_unsupported_desc', {
defaultValue: '请更新 CLI Proxy API 到最新版本后重试'
})}
/>
) : models.length === 0 ? (
<EmptyState
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
description={t('auth_files.models_empty_desc', {
defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型'
})}
/>
) : (
<div className={styles.modelsList}>
{models.map((model) => {
const excludedModel = isModelExcluded(model.id, fileType, excluded);
return (
<div
key={model.id}
className={`${styles.modelItem} ${excludedModel ? styles.modelItemExcluded : ''}`}
onClick={() => {
onCopyText(model.id);
}}
title={
excludedModel
? t('auth_files.models_excluded_hint', {
defaultValue: '此 OAuth 模型已被禁用'
})
: t('common.copy', { defaultValue: '点击复制' })
}
>
<span className={styles.modelId}>{model.id}</span>
{model.display_name && model.display_name !== model.id && (
<span className={styles.modelDisplayName}>{model.display_name}</span>
)}
{model.type && <span className={styles.modelType}>{model.type}</span>}
{excludedModel && (
<span className={styles.modelExcludedBadge}>
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
</span>
)}
</div>
);
})}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,124 @@
import { useCallback, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
import { useNotificationStore, useQuotaStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { getStatusFromError } from '@/utils/quota';
import {
isRuntimeOnlyAuthFile,
resolveQuotaErrorMessage,
type QuotaProviderType
} from '@/features/authFiles/constants';
import { QuotaProgressBar } from '@/features/authFiles/components/QuotaProgressBar';
import styles from '@/pages/AuthFilesPage.module.scss';
type QuotaState = { status?: string; error?: string; errorStatus?: number } | undefined;
const getQuotaConfig = (type: QuotaProviderType) => {
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
if (type === 'codex') return CODEX_CONFIG;
return GEMINI_CLI_CONFIG;
};
export type AuthFileQuotaSectionProps = {
file: AuthFileItem;
quotaType: QuotaProviderType;
disableControls: boolean;
};
export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
const { file, quotaType, disableControls } = props;
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const quota = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
return state.geminiCliQuota[file.name] as QuotaState;
});
const updateQuotaState = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
});
const refreshQuotaForFile = useCallback(async () => {
if (disableControls) return;
if (isRuntimeOnlyAuthFile(file)) return;
if (file.disabled) return;
if (quota?.status === 'loading') return;
const config = getQuotaConfig(quotaType) as unknown as {
i18nPrefix: string;
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
buildLoadingState: () => unknown;
buildSuccessState: (data: unknown) => unknown;
buildErrorState: (message: string, status?: number) => unknown;
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
};
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildLoadingState()
}));
try {
const data = await config.fetchQuota(file, t);
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildSuccessState(data)
}));
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildErrorState(message, status)
}));
showNotification(t('auth_files.quota_refresh_failed', { name: file.name, message }), 'error');
}
}, [disableControls, file, quota?.status, quotaType, showNotification, t, updateQuotaState]);
const config = getQuotaConfig(quotaType) as unknown as {
i18nPrefix: string;
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
};
const quotaStatus = quota?.status ?? 'idle';
const canRefreshQuota = !disableControls && !file.disabled;
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
return (
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<button
type="button"
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
onClick={() => void refreshQuotaForFile()}
disabled={!canRefreshQuota}
>
{t(`${config.i18nPrefix}.idle`)}
</button>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${config.i18nPrefix}.load_failed`, {
message: quotaErrorMessage
})}
</div>
) : quota ? (
(config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode)
) : (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import type {
PrefixProxyEditorField,
PrefixProxyEditorState
} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFilesPrefixProxyEditorModalProps = {
disableControls: boolean;
editor: PrefixProxyEditorState | null;
updatedText: string;
dirty: boolean;
onClose: () => void;
onSave: () => void;
onChange: (field: PrefixProxyEditorField, value: string) => void;
};
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
const { t } = useTranslation();
const { disableControls, editor, updatedText, dirty, onClose, onSave, onChange } = props;
return (
<Modal
open={Boolean(editor)}
onClose={onClose}
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')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={editor?.saving === true}>
{t('common.cancel')}
</Button>
<Button
onClick={onSave}
loading={editor?.saving === true}
disabled={
disableControls || editor?.saving === true || !dirty || !editor?.json
}
>
{t('common.save')}
</Button>
</>
}
>
{editor && (
<div className={styles.prefixProxyEditor}>
{editor.loading ? (
<div className={styles.prefixProxyLoading}>
<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_source_label')}
</label>
<textarea
className={styles.prefixProxyTextarea}
rows={10}
readOnly
value={updatedText}
/>
</div>
<div className={styles.prefixProxyFields}>
<Input
label={t('auth_files.prefix_label')}
value={editor.prefix}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('prefix', e.target.value)}
/>
<Input
label={t('auth_files.proxy_url_label')}
value={editor.proxyUrl}
placeholder={t('auth_files.proxy_url_placeholder')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('proxyUrl', e.target.value)}
/>
<Input
label={t('auth_files.priority_label')}
value={editor.priority}
placeholder={t('auth_files.priority_placeholder')}
hint={t('auth_files.priority_hint')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('priority', e.target.value)}
/>
<div className="form-group">
<label>{t('auth_files.excluded_models_label')}</label>
<textarea
className="input"
value={editor.excludedModelsText}
placeholder={t('auth_files.excluded_models_placeholder')}
rows={4}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('excludedModelsText', e.target.value)}
/>
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
</div>
<Input
label={t('auth_files.disable_cooling_label')}
value={editor.disableCooling}
placeholder={t('auth_files.disable_cooling_placeholder')}
hint={t('auth_files.disable_cooling_hint')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('disableCooling', e.target.value)}
/>
</div>
</>
)}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import styles from '@/pages/AuthFilesPage.module.scss';
type UnsupportedError = 'unsupported' | null;
export type OAuthExcludedCardProps = {
disableControls: boolean;
excludedError: UnsupportedError;
excluded: Record<string, string[]>;
onAdd: () => void;
onEdit: (provider: string) => void;
onDelete: (provider: string) => void;
};
export function OAuthExcludedCard(props: OAuthExcludedCardProps) {
const { t } = useTranslation();
const { disableControls, excludedError, excluded, onAdd, onEdit, onDelete } = props;
return (
<Card
title={t('oauth_excluded.title')}
extra={
<Button size="sm" onClick={onAdd} disabled={disableControls || excludedError === 'unsupported'}>
{t('oauth_excluded.add')}
</Button>
}
>
{excludedError === 'unsupported' ? (
<EmptyState
title={t('oauth_excluded.upgrade_required_title')}
description={t('oauth_excluded.upgrade_required_desc')}
/>
) : 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')}
</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>
);
}

View File

@@ -0,0 +1,152 @@
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 } from '@/features/authFiles/constants';
import styles from '@/pages/AuthFilesPage.module.scss';
type UnsupportedError = 'unsupported' | null;
type ViewMode = 'diagram' | 'list';
export type OAuthModelAliasCardProps = {
disableControls: boolean;
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
onAdd: () => void;
onEditProvider: (provider?: string) => void;
onDeleteProvider: (provider: string) => void;
modelAliasError: UnsupportedError;
modelAlias: Record<string, OAuthModelAliasEntry[]>;
allProviderModels: Record<string, AuthFileModelItem[]>;
onUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
onDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => Promise<void>;
onRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
onDeleteAlias: (aliasName: string) => void;
};
export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
const { t } = useTranslation();
const diagramRef = useRef<ModelMappingDiagramRef | null>(null);
const {
disableControls,
viewMode,
onViewModeChange,
onAdd,
onEditProvider,
onDeleteProvider,
modelAliasError,
modelAlias,
allProviderModels,
onUpdate,
onDeleteLink,
onToggleFork,
onRenameAlias,
onDeleteAlias
} = props;
return (
<Card
title={t('oauth_model_alias.title')}
extra={
<div className={styles.cardExtraButtons}>
<div className={styles.viewModeSwitch}>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('list')}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.view_mode_list')}
</Button>
<Button
variant={viewMode === 'diagram' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('diagram')}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.view_mode_diagram')}
</Button>
</div>
<Button
size="sm"
onClick={onAdd}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{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')}
/>
) : 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 === 'unsupported'}
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>
</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>
);
}

View File

@@ -0,0 +1,28 @@
import styles from '@/pages/AuthFilesPage.module.scss';
export type QuotaProgressBarProps = {
percent: number | null;
highThreshold: number;
mediumThreshold: number;
};
export function QuotaProgressBar({ percent, highThreshold, mediumThreshold }: QuotaProgressBarProps) {
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalized = percent === null ? null : clamp(percent, 0, 100);
const fillClass =
normalized === null
? styles.quotaBarFillMedium
: normalized >= highThreshold
? styles.quotaBarFillHigh
: normalized >= mediumThreshold
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
const widthPercent = Math.round(normalized ?? 0);
return (
<div className={styles.quotaBar}>
<div className={`${styles.quotaBarFill} ${fillClass}`} style={{ width: `${widthPercent}%` }} />
</div>
);
}

View File

@@ -0,0 +1,235 @@
import type { TFunction } from 'i18next';
import type { AuthFileItem } from '@/types';
import {
normalizeUsageSourceId,
type KeyStatBucket,
type KeyStats
} from '@/utils/usage';
export type ThemeColors = { bg: string; text: string; border?: string };
export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
export type ResolvedTheme = 'light' | 'dark';
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
export const MIN_CARD_PAGE_SIZE = 3;
export const MAX_CARD_PAGE_SIZE = 30;
export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
export const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
export const TYPE_COLORS: Record<string, TypeColorSet> = {
qwen: {
light: { bg: '#e8f5e9', text: '#2e7d32' },
dark: { bg: '#1b5e20', text: '#81c784' }
},
kimi: {
light: { bg: '#fff4e5', text: '#ad6800' },
dark: { bg: '#7c4a03', text: '#ffd591' }
},
gemini: {
light: { bg: '#e3f2fd', text: '#1565c0' },
dark: { bg: '#0d47a1', text: '#64b5f6' }
},
'gemini-cli': {
light: { bg: '#e7efff', text: '#1e4fa3' },
dark: { bg: '#1c3f73', text: '#a8c7ff' }
},
aistudio: {
light: { bg: '#f0f2f5', text: '#2f343c' },
dark: { bg: '#373c42', text: '#cfd3db' }
},
claude: {
light: { bg: '#fce4ec', text: '#c2185b' },
dark: { bg: '#880e4f', text: '#f48fb1' }
},
codex: {
light: { bg: '#fff3e0', text: '#ef6c00' },
dark: { bg: '#e65100', text: '#ffb74d' }
},
antigravity: {
light: { bg: '#e0f7fa', text: '#006064' },
dark: { bg: '#004d40', text: '#80deea' }
},
iflow: {
light: { bg: '#f3e5f5', text: '#7b1fa2' },
dark: { bg: '#4a148c', text: '#ce93d8' }
},
empty: {
light: { bg: '#f5f5f5', text: '#616161' },
dark: { bg: '#424242', text: '#bdbdbd' }
},
unknown: {
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
}
};
export const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
export const resolveQuotaErrorMessage = (
t: TFunction,
status: number | undefined,
fallback: string
): string => {
if (status === 404) return t('common.quota_update_required');
if (status === 403) return t('common.quota_check_credential');
return fallback;
};
export const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
export const getTypeLabel = (t: TFunction, type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
if (translated !== key) return translated;
if (type.toLowerCase() === 'iflow') return 'iFlow';
return type.charAt(0).toUpperCase() + type.slice(1);
};
export const getTypeColor = (type: string, resolvedTheme: ResolvedTheme): ThemeColors => {
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
};
export const parsePriorityValue = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isInteger(value) ? value : undefined;
}
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed || !INTEGER_STRING_PATTERN.test(trimmed)) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isSafeInteger(parsed) ? parsed : undefined;
};
export const normalizeExcludedModels = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const normalized: string[] = [];
value.forEach((entry) => {
const model = String(entry ?? '')
.trim()
.toLowerCase();
if (!model || seen.has(model)) return;
seen.add(model);
normalized.push(model);
});
return normalized.sort((a, b) => a.localeCompare(b));
};
export const parseExcludedModelsText = (value: string): string[] =>
normalizeExcludedModels(value.split(/[\n,]+/));
export const parseDisableCoolingValue = (value: unknown): boolean | undefined => {
if (typeof value === 'boolean') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value !== 0;
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (TRUTHY_TEXT_VALUES.has(normalized)) return true;
if (FALSY_TEXT_VALUES.has(normalized)) return false;
return undefined;
};
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
export function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
return null;
}
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw;
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
return false;
}
export function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucket {
const defaultStats: KeyStatBucket = { success: 0, failure: 0 };
const rawFileName = file?.name || '';
// 兼容 auth_index 和 authIndex 两种字段名API 返回的是 auth_index
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
// 尝试根据 authIndex 匹配
if (authIndexKey && stats.byAuthIndex?.[authIndexKey]) {
return stats.byAuthIndex[authIndexKey];
}
// 尝试根据 source (文件名) 匹配
const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : '';
if (fileNameId && stats.bySource?.[fileNameId]) {
const fromName = stats.bySource[fileNameId];
if (fromName.success > 0 || fromName.failure > 0) {
return fromName;
}
}
// 尝试去掉扩展名后匹配
if (rawFileName) {
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt);
const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined;
if (
fromNameWithoutExt &&
(fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)
) {
return fromNameWithoutExt;
}
}
}
return defaultStats;
}
export const formatModified = (item: AuthFileItem): string => {
const raw = item['modtime'] ?? item.modified;
if (!raw) return '-';
const asNumber = Number(raw);
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
: new Date(String(raw));
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
// 检查模型是否被 OAuth 排除
export const isModelExcluded = (
modelId: string,
providerType: string,
excluded: Record<string, string[]>
): boolean => {
const providerKey = normalizeProviderKey(providerType);
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
return excludedModels.some((pattern) => {
if (pattern.includes('*')) {
// 支持通配符匹配:先转义正则特殊字符,再将 * 视为通配符
const regexSafePattern = pattern
.split('*')
.map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*');
const regex = new RegExp(`^${regexSafePattern}$`, 'i');
return regex.test(modelId);
}
return pattern.toLowerCase() === modelId.toLowerCase();
});
};

View File

@@ -0,0 +1,319 @@
import { useCallback, useRef, useState, type ChangeEvent, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
type DeleteAllOptions = {
filter: string;
onResetFilterToAll: () => void;
};
export type UseAuthFilesDataResult = {
files: AuthFileItem[];
loading: boolean;
error: string;
uploading: boolean;
deleting: string | null;
deletingAll: boolean;
statusUpdating: Record<string, boolean>;
fileInputRef: RefObject<HTMLInputElement | null>;
loadFiles: () => Promise<void>;
handleUploadClick: () => void;
handleFileChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
handleDelete: (name: string) => void;
handleDeleteAll: (options: DeleteAllOptions) => void;
handleDownload: (name: string) => Promise<void>;
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
};
export type UseAuthFilesDataOptions = {
refreshKeyStats: () => Promise<void>;
};
export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFilesDataResult {
const { refreshKeyStats } = options;
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
const fileInputRef = useRef<HTMLInputElement | null>(null);
const loadFiles = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await authFilesApi.list();
setFiles(data?.files || []);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
setError(errorMessage);
} finally {
setLoading(false);
}
}, [t]);
const handleUploadClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) return;
const filesToUpload = Array.from(fileList);
const validFiles: File[] = [];
const invalidFiles: string[] = [];
const oversizedFiles: string[] = [];
filesToUpload.forEach((file) => {
if (!file.name.endsWith('.json')) {
invalidFiles.push(file.name);
return;
}
if (file.size > MAX_AUTH_FILE_SIZE) {
oversizedFiles.push(file.name);
return;
}
validFiles.push(file);
});
if (invalidFiles.length > 0) {
showNotification(t('auth_files.upload_error_json'), 'error');
}
if (oversizedFiles.length > 0) {
showNotification(
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
'error'
);
}
if (validFiles.length === 0) {
event.target.value = '';
return;
}
setUploading(true);
let successCount = 0;
const failed: { name: string; message: string }[] = [];
for (const file of validFiles) {
try {
await authFilesApi.upload(file);
successCount++;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
failed.push({ name: file.name, message: errorMessage });
}
}
if (successCount > 0) {
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
showNotification(
`${t('auth_files.upload_success')}${suffix}`,
failed.length ? 'warning' : 'success'
);
await loadFiles();
await refreshKeyStats();
}
if (failed.length > 0) {
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
}
setUploading(false);
event.target.value = '';
},
[loadFiles, refreshKeyStats, showNotification, t]
);
const handleDelete = useCallback(
(name: string) => {
showConfirmation({
title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
message: `${t('auth_files.delete_confirm')} "${name}" ?`,
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setDeleting(name);
try {
await authFilesApi.deleteFile(name);
showNotification(t('auth_files.delete_success'), 'success');
setFiles((prev) => prev.filter((item) => item.name !== name));
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeleting(null);
}
}
});
},
[showConfirmation, showNotification, t]
);
const handleDeleteAll = useCallback(
(deleteAllOptions: DeleteAllOptions) => {
const { filter, onResetFilterToAll } = deleteAllOptions;
const isFiltered = filter !== 'all';
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
const confirmMessage = isFiltered
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_all_confirm');
showConfirmation({
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
message: confirmMessage,
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setDeletingAll(true);
try {
if (!isFiltered) {
await authFilesApi.deleteAll();
showNotification(t('auth_files.delete_all_success'), 'success');
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
} else {
const filesToDelete = files.filter(
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
);
if (filesToDelete.length === 0) {
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
setDeletingAll(false);
return;
}
let success = 0;
let failed = 0;
const deletedNames: string[] = [];
for (const file of filesToDelete) {
try {
await authFilesApi.deleteFile(file.name);
success++;
deletedNames.push(file.name);
} catch {
failed++;
}
}
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
if (failed === 0) {
showNotification(
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
'success'
);
} else {
showNotification(
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
'warning'
);
}
onResetFilterToAll();
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeletingAll(false);
}
}
});
},
[files, showConfirmation, showNotification, t]
);
const handleDownload = useCallback(
async (name: string) => {
try {
const response = await apiClient.getRaw(
`/auth-files/download?name=${encodeURIComponent(name)}`,
{ responseType: 'blob' }
);
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('auth_files.download_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
}
},
[showNotification, t]
);
const handleStatusToggle = useCallback(
async (item: AuthFileItem, enabled: boolean) => {
const name = item.name;
const nextDisabled = !enabled;
const previousDisabled = item.disabled === true;
setStatusUpdating((prev) => ({ ...prev, [name]: true }));
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
try {
const res = await authFilesApi.setStatus(name, nextDisabled);
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
);
showNotification(
enabled
? t('auth_files.status_enabled_success', { name })
: t('auth_files.status_disabled_success', { name }),
'success'
);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
);
showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error');
} finally {
setStatusUpdating((prev) => {
if (!prev[name]) return prev;
const next = { ...prev };
delete next[name];
return next;
});
}
},
[showNotification, t]
);
return {
files,
loading,
error,
uploading,
deleting,
deletingAll,
statusUpdating,
fileInputRef,
loadFiles,
handleUploadClick,
handleFileChange,
handleDelete,
handleDeleteAll,
handleDownload,
handleStatusToggle
};
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
type ModelsError = 'unsupported' | null;
export type UseAuthFilesModelsResult = {
modelsModalOpen: boolean;
modelsLoading: boolean;
modelsList: AuthFileModelItem[];
modelsFileName: string;
modelsFileType: string;
modelsError: ModelsError;
showModels: (item: AuthFileItem) => Promise<void>;
closeModelsModal: () => void;
};
export function useAuthFilesModels(): UseAuthFilesModelsResult {
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const [modelsModalOpen, setModelsModalOpen] = useState(false);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
const [modelsFileName, setModelsFileName] = useState('');
const [modelsFileType, setModelsFileType] = useState('');
const [modelsError, setModelsError] = useState<ModelsError>(null);
const modelsCacheRef = useRef<Map<string, AuthFileModelItem[]>>(new Map());
const closeModelsModal = useCallback(() => {
setModelsModalOpen(false);
}, []);
const showModels = useCallback(
async (item: AuthFileItem) => {
setModelsFileName(item.name);
setModelsFileType(item.type || '');
setModelsList([]);
setModelsError(null);
setModelsModalOpen(true);
const cached = modelsCacheRef.current.get(item.name);
if (cached) {
setModelsList(cached);
setModelsLoading(false);
return;
}
setModelsLoading(true);
try {
const models = await authFilesApi.getModelsForAuthFile(item.name);
modelsCacheRef.current.set(item.name, models);
setModelsList(models);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '';
if (
errorMessage.includes('404') ||
errorMessage.includes('not found') ||
errorMessage.includes('Not Found')
) {
setModelsError('unsupported');
} else {
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
}
} finally {
setModelsLoading(false);
}
},
[showNotification, t]
);
return {
modelsModalOpen,
modelsLoading,
modelsList,
modelsFileName,
modelsFileType,
modelsError,
showModels,
closeModelsModal
};
}

View File

@@ -0,0 +1,504 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
import { normalizeProviderKey } from '@/features/authFiles/constants';
type UnsupportedError = 'unsupported' | null;
type ViewMode = 'diagram' | 'list';
export type UseAuthFilesOauthResult = {
excluded: Record<string, string[]>;
excludedError: UnsupportedError;
modelAlias: Record<string, OAuthModelAliasEntry[]>;
modelAliasError: UnsupportedError;
allProviderModels: Record<string, AuthFileModelItem[]>;
providerList: string[];
loadExcluded: () => Promise<void>;
loadModelAlias: () => Promise<void>;
deleteExcluded: (provider: string) => void;
deleteModelAlias: (provider: string) => void;
handleMappingUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
handleDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
handleToggleFork: (
provider: string,
sourceModel: string,
alias: string,
fork: boolean
) => Promise<void>;
handleRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
handleDeleteAlias: (aliasName: string) => void;
};
export type UseAuthFilesOauthOptions = {
viewMode: ViewMode;
files: AuthFileItem[];
};
export function useAuthFilesOauth(options: UseAuthFilesOauthOptions): UseAuthFilesOauthResult {
const { viewMode, files } = options;
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedError, setExcludedError] = useState<UnsupportedError>(null);
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
const [modelAliasError, setModelAliasError] = useState<UnsupportedError>(null);
const [allProviderModels, setAllProviderModels] = useState<Record<string, AuthFileModelItem[]>>(
{}
);
const excludedUnsupportedRef = useRef(false);
const mappingsUnsupportedRef = useRef(false);
const providerList = useMemo(() => {
const providers = new Set<string>();
Object.keys(modelAlias).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key) providers.add(key);
});
files.forEach((file) => {
if (typeof file.type === 'string') {
const key = file.type.trim().toLowerCase();
if (key) providers.add(key);
}
if (typeof file.provider === 'string') {
const key = file.provider.trim().toLowerCase();
if (key) providers.add(key);
}
});
return Array.from(providers);
}, [files, modelAlias]);
useEffect(() => {
if (viewMode !== 'diagram') return;
let cancelled = false;
const loadAllModels = async () => {
if (providerList.length === 0) {
if (!cancelled) setAllProviderModels({});
return;
}
const results = await Promise.all(
providerList.map(async (provider) => {
try {
const models = await authFilesApi.getModelDefinitions(provider);
return { provider, models };
} catch {
return { provider, models: [] as AuthFileModelItem[] };
}
})
);
if (cancelled) return;
const nextModels: Record<string, AuthFileModelItem[]> = {};
results.forEach(({ provider, models }) => {
if (models.length > 0) {
nextModels[provider] = models;
}
});
setAllProviderModels(nextModels);
};
void loadAllModels();
return () => {
cancelled = true;
};
}, [providerList, viewMode]);
const loadExcluded = useCallback(async () => {
try {
const res = await authFilesApi.getOauthExcludedModels();
excludedUnsupportedRef.current = false;
setExcluded(res || {});
setExcludedError(null);
} catch (err: unknown) {
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setExcluded({});
setExcludedError('unsupported');
if (!excludedUnsupportedRef.current) {
excludedUnsupportedRef.current = true;
showNotification(t('oauth_excluded.upgrade_required'), 'warning');
}
return;
}
// 静默失败
}
}, [showNotification, t]);
const loadModelAlias = useCallback(async () => {
try {
const res = await authFilesApi.getOauthModelAlias();
mappingsUnsupportedRef.current = false;
setModelAlias(res || {});
setModelAliasError(null);
} catch (err: unknown) {
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setModelAlias({});
setModelAliasError('unsupported');
if (!mappingsUnsupportedRef.current) {
mappingsUnsupportedRef.current = true;
showNotification(t('oauth_model_alias.upgrade_required'), 'warning');
}
return;
}
// 静默失败
}
}, [showNotification, t]);
const deleteExcluded = useCallback(
(provider: string) => {
const providerLabel = provider.trim() || provider;
showConfirmation({
title: t('oauth_excluded.delete_title', { defaultValue: 'Delete Exclusion' }),
message: t('oauth_excluded.delete_confirm', { provider: providerLabel }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const providerKey = normalizeProviderKey(provider);
if (!providerKey) {
showNotification(t('oauth_excluded.provider_required'), 'error');
return;
}
try {
await authFilesApi.deleteOauthExcludedEntry(providerKey);
await loadExcluded();
showNotification(t('oauth_excluded.delete_success'), 'success');
} catch (err: unknown) {
try {
const current = await authFilesApi.getOauthExcludedModels();
const next: Record<string, string[]> = {};
Object.entries(current).forEach(([key, models]) => {
if (normalizeProviderKey(key) === providerKey) return;
next[key] = models;
});
await authFilesApi.replaceOauthExcludedModels(next);
await loadExcluded();
showNotification(t('oauth_excluded.delete_success'), 'success');
} catch (fallbackErr: unknown) {
const errorMessage =
fallbackErr instanceof Error
? fallbackErr.message
: err instanceof Error
? err.message
: '';
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
}
}
}
});
},
[loadExcluded, showConfirmation, showNotification, t]
);
const deleteModelAlias = useCallback(
(provider: string) => {
showConfirmation({
title: t('oauth_model_alias.delete_title', { defaultValue: 'Delete Mappings' }),
message: t('oauth_model_alias.delete_confirm', { provider }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await authFilesApi.deleteOauthModelAlias(provider);
await loadModelAlias();
showNotification(t('oauth_model_alias.delete_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error');
}
}
});
},
[loadModelAlias, showConfirmation, showNotification, t]
);
const handleMappingUpdate = useCallback(
async (provider: string, sourceModel: string, newAlias: string) => {
if (!provider || !sourceModel || !newAlias) return;
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) return;
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameTrim = sourceModel.trim();
const aliasTrim = newAlias.trim();
const nameKey = nameTrim.toLowerCase();
const aliasKey = aliasTrim.toLowerCase();
if (
currentMappings.some(
(m) =>
(m.name ?? '').trim().toLowerCase() === nameKey &&
(m.alias ?? '').trim().toLowerCase() === aliasKey
)
) {
return;
}
const nextMappings: OAuthModelAliasEntry[] = [
...currentMappings,
{ name: nameTrim, alias: aliasTrim, fork: true }
];
try {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
},
[loadModelAlias, modelAlias, showNotification, t]
);
const handleDeleteLink = useCallback(
(provider: string, sourceModel: string, alias: string) => {
const nameTrim = sourceModel.trim();
const aliasTrim = alias.trim();
if (!provider || !nameTrim || !aliasTrim) return;
showConfirmation({
title: t('oauth_model_alias.delete_link_title', { defaultValue: 'Unlink mapping' }),
message: (
<Trans
i18nKey="oauth_model_alias.delete_link_confirm"
values={{ provider, sourceModel: nameTrim, alias: aliasTrim }}
components={{ code: <code /> }}
/>
),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const normalizedProvider = normalizeProviderKey(provider);
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameKey = nameTrim.toLowerCase();
const aliasKey = aliasTrim.toLowerCase();
const nextMappings = currentMappings.filter(
(m) =>
(m.name ?? '').trim().toLowerCase() !== nameKey ||
(m.alias ?? '').trim().toLowerCase() !== aliasKey
);
if (nextMappings.length === currentMappings.length) return;
try {
if (nextMappings.length === 0) {
await authFilesApi.deleteOauthModelAlias(normalizedProvider);
} else {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
}
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
}
});
},
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
);
const handleToggleFork = useCallback(
async (provider: string, sourceModel: string, alias: string, fork: boolean) => {
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) return;
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameKey = sourceModel.trim().toLowerCase();
const aliasKey = alias.trim().toLowerCase();
let changed = false;
const nextMappings = currentMappings.map((m) => {
const mName = (m.name ?? '').trim().toLowerCase();
const mAlias = (m.alias ?? '').trim().toLowerCase();
if (mName === nameKey && mAlias === aliasKey) {
changed = true;
return fork ? { ...m, fork: true } : { name: m.name, alias: m.alias };
}
return m;
});
if (!changed) return;
try {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
},
[loadModelAlias, modelAlias, showNotification, t]
);
const handleRenameAlias = useCallback(
async (oldAlias: string, newAlias: string) => {
const oldTrim = oldAlias.trim();
const newTrim = newAlias.trim();
if (!oldTrim || !newTrim || oldTrim === newTrim) return;
const oldKey = oldTrim.toLowerCase();
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === oldKey)
);
if (providersToUpdate.length === 0) return;
let hadFailure = false;
let failureMessage = '';
try {
const results = await Promise.allSettled(
providersToUpdate.map(([provider, mappings]) => {
const nextMappings = mappings.map((m) =>
(m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m
);
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
})
);
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failures.length > 0) {
hadFailure = true;
const reason = failures[0].reason;
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
}
} finally {
await loadModelAlias();
}
if (hadFailure) {
showNotification(
failureMessage
? `${t('oauth_model_alias.save_failed')}: ${failureMessage}`
: t('oauth_model_alias.save_failed'),
'error'
);
} else {
showNotification(t('oauth_model_alias.save_success'), 'success');
}
},
[loadModelAlias, modelAlias, showNotification, t]
);
const handleDeleteAlias = useCallback(
(aliasName: string) => {
const aliasTrim = aliasName.trim();
if (!aliasTrim) return;
const aliasKey = aliasTrim.toLowerCase();
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === aliasKey)
);
if (providersToUpdate.length === 0) return;
showConfirmation({
title: t('oauth_model_alias.delete_alias_title', { defaultValue: 'Delete Alias' }),
message: (
<Trans
i18nKey="oauth_model_alias.delete_alias_confirm"
values={{ alias: aliasTrim }}
components={{ code: <code /> }}
/>
),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
let hadFailure = false;
let failureMessage = '';
try {
const results = await Promise.allSettled(
providersToUpdate.map(([provider, mappings]) => {
const nextMappings = mappings.filter(
(m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey
);
if (nextMappings.length === 0) {
return authFilesApi.deleteOauthModelAlias(provider);
}
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
})
);
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failures.length > 0) {
hadFailure = true;
const reason = failures[0].reason;
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
}
} finally {
await loadModelAlias();
}
if (hadFailure) {
showNotification(
failureMessage
? `${t('oauth_model_alias.delete_failed')}: ${failureMessage}`
: t('oauth_model_alias.delete_failed'),
'error'
);
} else {
showNotification(t('oauth_model_alias.delete_success'), 'success');
}
}
});
},
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
);
return {
excluded,
excludedError,
modelAlias,
modelAliasError,
allProviderModels,
providerList,
loadExcluded,
loadModelAlias,
deleteExcluded,
deleteModelAlias,
handleMappingUpdate,
handleDeleteLink,
handleToggleFork,
handleRenameAlias,
handleDeleteAlias
};
}

View File

@@ -0,0 +1,254 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { useNotificationStore } from '@/stores';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
import {
normalizeExcludedModels,
parseDisableCoolingValue,
parseExcludedModelsText,
parsePriorityValue
} from '@/features/authFiles/constants';
export type PrefixProxyEditorField =
| 'prefix'
| 'proxyUrl'
| 'priority'
| 'excludedModelsText'
| 'disableCooling';
export type PrefixProxyEditorState = {
fileName: string;
loading: boolean;
saving: boolean;
error: string | null;
originalText: string;
rawText: string;
json: Record<string, unknown> | null;
prefix: string;
proxyUrl: string;
priority: string;
excludedModelsText: string;
disableCooling: string;
};
export type UseAuthFilesPrefixProxyEditorOptions = {
disableControls: boolean;
loadFiles: () => Promise<void>;
loadKeyStats: () => Promise<void>;
};
export type UseAuthFilesPrefixProxyEditorResult = {
prefixProxyEditor: PrefixProxyEditorState | null;
prefixProxyUpdatedText: string;
prefixProxyDirty: boolean;
openPrefixProxyEditor: (name: string) => Promise<void>;
closePrefixProxyEditor: () => void;
handlePrefixProxyChange: (field: PrefixProxyEditorField, value: string) => void;
handlePrefixProxySave: () => Promise<void>;
};
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
if (!editor?.json) return editor?.rawText ?? '';
const next: Record<string, unknown> = { ...editor.json };
if ('prefix' in next || editor.prefix.trim()) {
next.prefix = editor.prefix;
}
if ('proxy_url' in next || editor.proxyUrl.trim()) {
next.proxy_url = editor.proxyUrl;
}
const parsedPriority = parsePriorityValue(editor.priority);
if (parsedPriority !== undefined) {
next.priority = parsedPriority;
} else if ('priority' in next) {
delete next.priority;
}
const excludedModels = parseExcludedModelsText(editor.excludedModelsText);
if (excludedModels.length > 0) {
next.excluded_models = excludedModels;
} else if ('excluded_models' in next) {
delete next.excluded_models;
}
const parsedDisableCooling = parseDisableCoolingValue(editor.disableCooling);
if (parsedDisableCooling !== undefined) {
next.disable_cooling = parsedDisableCooling;
} else if ('disable_cooling' in next) {
delete next.disable_cooling;
}
return JSON.stringify(next);
};
export function useAuthFilesPrefixProxyEditor(
options: UseAuthFilesPrefixProxyEditorOptions
): UseAuthFilesPrefixProxyEditorResult {
const { disableControls, loadFiles, loadKeyStats } = options;
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
const prefixProxyDirty =
Boolean(prefixProxyEditor?.json) &&
Boolean(prefixProxyEditor?.originalText) &&
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
const closePrefixProxyEditor = () => {
setPrefixProxyEditor(null);
};
const openPrefixProxyEditor = async (name: string) => {
if (disableControls) return;
if (prefixProxyEditor?.fileName === name) {
setPrefixProxyEditor(null);
return;
}
setPrefixProxyEditor({
fileName: name,
loading: true,
saving: false,
error: null,
originalText: '',
rawText: '',
json: null,
prefix: '',
proxyUrl: '',
priority: '',
excludedModelsText: '',
disableCooling: ''
});
try {
const rawText = await authFilesApi.downloadText(name);
const trimmed = rawText.trim();
let parsed: unknown;
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return {
...prev,
loading: false,
error: t('auth_files.prefix_proxy_invalid_json'),
rawText: trimmed,
originalText: trimmed
};
});
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return {
...prev,
loading: false,
error: t('auth_files.prefix_proxy_invalid_json'),
rawText: trimmed,
originalText: trimmed
};
});
return;
}
const json = parsed as Record<string, unknown>;
const originalText = JSON.stringify(json);
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
const priority = parsePriorityValue(json.priority);
const excludedModels = normalizeExcludedModels(json.excluded_models);
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return {
...prev,
loading: false,
originalText,
rawText: originalText,
json,
prefix,
proxyUrl,
priority: priority !== undefined ? String(priority) : '',
excludedModelsText: excludedModels.join('\n'),
disableCooling:
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
error: null
};
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.download_failed');
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return { ...prev, loading: false, error: errorMessage, rawText: '' };
});
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
}
};
const handlePrefixProxyChange = (field: PrefixProxyEditorField, value: string) => {
setPrefixProxyEditor((prev) => {
if (!prev) return prev;
if (field === 'prefix') return { ...prev, prefix: value };
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
if (field === 'priority') return { ...prev, priority: value };
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
return { ...prev, disableCooling: value };
});
};
const handlePrefixProxySave = async () => {
if (!prefixProxyEditor?.json) return;
if (!prefixProxyDirty) return;
const name = prefixProxyEditor.fileName;
const payload = prefixProxyUpdatedText;
const fileSize = new Blob([payload]).size;
if (fileSize > MAX_AUTH_FILE_SIZE) {
showNotification(
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
'error'
);
return;
}
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return { ...prev, saving: true };
});
try {
const file = new File([payload], name, { type: 'application/json' });
await authFilesApi.upload(file);
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
await loadFiles();
await loadKeyStats();
setPrefixProxyEditor(null);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return { ...prev, saving: false };
});
}
};
return {
prefixProxyEditor,
prefixProxyUpdatedText,
prefixProxyDirty,
openPrefixProxyEditor,
closePrefixProxyEditor,
handlePrefixProxyChange,
handlePrefixProxySave
};
}

View File

@@ -0,0 +1,35 @@
import { useCallback, useRef, useState } from 'react';
import { usageApi } from '@/services/api';
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
export type UseAuthFilesStatsResult = {
keyStats: KeyStats;
usageDetails: UsageDetail[];
loadKeyStats: () => Promise<void>;
};
export function useAuthFilesStats(): UseAuthFilesStatsResult {
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const loadingKeyStatsRef = useRef(false);
const loadKeyStats = useCallback(async () => {
if (loadingKeyStatsRef.current) return;
loadingKeyStatsRef.current = true;
try {
const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats);
const details = collectUsageDetails(usageData);
setUsageDetails(details);
} catch {
// 静默失败
} finally {
loadingKeyStatsRef.current = false;
}
}, []);
return { keyStats, usageDetails, loadKeyStats };
}

View File

@@ -0,0 +1,28 @@
import { useMemo } from 'react';
import type { AuthFileItem } from '@/types';
import { calculateStatusBarData, type UsageDetail } from '@/utils/usage';
import { normalizeAuthIndexValue } from '@/features/authFiles/constants';
export type AuthFileStatusBarData = ReturnType<typeof calculateStatusBarData>;
export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails: UsageDetail[]) {
return useMemo(() => {
const cache = new Map<string, AuthFileStatusBarData>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
if (authIndexKey) {
const filteredDetails = usageDetails.filter((detail) => {
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
});
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
}
});
return cache;
}, [files, usageDetails]);
}

View File

@@ -0,0 +1,30 @@
export type AuthFilesUiState = {
filter?: string;
search?: string;
page?: number;
pageSize?: number;
};
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
export const readAuthFilesUiState = (): AuthFilesUiState | null => {
if (typeof window === 'undefined') return null;
try {
const raw = window.sessionStorage.getItem(AUTH_FILES_UI_STATE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthFilesUiState;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
};
export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
} catch {
// ignore
}
};

View File

@@ -54,6 +54,10 @@
"login": "CLI Proxy API Management Center",
"abbr": "CPAMC"
},
"splash": {
"title": "CLI Proxy API",
"subtitle": "Management Center"
},
"auto_login": {
"title": "Auto Login in Progress...",
"message": "Attempting to connect to server using locally saved connection information"
@@ -828,6 +832,17 @@
"success": "Success",
"failure": "Failure"
},
"status_bar": {
"success_short": "✓",
"failure_short": "✗",
"no_requests": "No requests"
},
"service_health": {
"title": "Service Health",
"window": "Last 7 days",
"oldest": "Oldest",
"newest": "Latest"
},
"logs": {
"title": "Logs Viewer",
"refresh_button": "Refresh Logs",

View File

@@ -54,6 +54,10 @@
"login": "Центр управления CLI Proxy API",
"abbr": "CPAMC"
},
"splash": {
"title": "CLI Proxy API",
"subtitle": "Центр управления"
},
"auto_login": {
"title": "Автовход...",
"message": "Пытаемся подключиться к серверу, используя сохранённые данные"
@@ -831,6 +835,17 @@
"success": "Успех",
"failure": "Сбой"
},
"status_bar": {
"success_short": "✓",
"failure_short": "✗",
"no_requests": "Нет запросов"
},
"service_health": {
"title": "Состояние сервиса",
"window": "Последние 7 дней",
"oldest": "Старые",
"newest": "Новые"
},
"logs": {
"title": "Просмотр журналов",
"refresh_button": "Обновить журналы",

View File

@@ -54,6 +54,10 @@
"login": "CLI Proxy API Management Center",
"abbr": "CPAMC"
},
"splash": {
"title": "CLI Proxy API",
"subtitle": "管理中心"
},
"auto_login": {
"title": "正在自动登录...",
"message": "正在使用本地保存的连接信息尝试连接服务器"
@@ -828,6 +832,17 @@
"success": "成功",
"failure": "失败"
},
"status_bar": {
"success_short": "✓",
"failure_short": "✗",
"no_requests": "无请求"
},
"service_health": {
"title": "服务健康监测",
"window": "最近 7 天",
"oldest": "最早",
"newest": "最新"
},
"logs": {
"title": "日志查看",
"refresh_button": "刷新日志",

View File

@@ -59,7 +59,7 @@ function StatusSuccessIcon() {
function StatusErrorIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="8" fill="var(--danger-color, #ef4444)" />
<circle cx="8" cy="8" r="8" fill="var(--danger-color, #c65746)" />
<path
d="M5 5L11 11M11 5L5 11"
stroke="white"

View File

@@ -93,9 +93,9 @@
}
.statFailure {
background-color: var(--failure-badge-bg, #fee2e2);
color: var(--failure-badge-text, #991b1b);
border-color: var(--failure-badge-border, #fca5a5);
background-color: var(--failure-badge-bg);
color: var(--failure-badge-text);
border-color: var(--failure-badge-border);
}
// 字段行样式:标签 + 值
@@ -311,8 +311,8 @@
}
.apiKeyEntryStatFailure {
background: var(--failure-badge-bg, #fee2e2);
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg);
color: var(--failure-badge-text);
}
// OpenAI 模型发现(二级界面)
@@ -402,37 +402,123 @@
gap: 2px;
flex: 1;
min-width: 180px;
position: relative;
}
.statusBlockWrapper {
flex: 1;
min-width: 6px;
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.statusBlock {
flex: 1;
width: 100%;
height: 8px;
border-radius: 2px;
min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
transform: scaleY(1.5);
opacity: 0.85;
.statusBlockWrapper:hover &,
.statusBlockWrapper.statusBlockActive & {
transform: scaleY(1.8);
opacity: 0.9;
}
}
.statusBlockSuccess {
background-color: var(--success-color, #22c55e);
}
.statusBlockFailure {
background-color: var(--danger-color, #ef4444);
}
.statusBlockMixed {
background-color: var(--warning-color, #f59e0b);
}
.statusBlockIdle {
background-color: var(--border-secondary, #e5e7eb);
}
.statusTooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #fff);
border: 1px solid var(--border-secondary, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: $z-dropdown;
pointer-events: none;
color: var(--text-primary);
// 小箭头
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-primary, #fff);
}
&::before {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-secondary, #e5e7eb);
}
}
// 防止左右溢出
.statusTooltipLeft {
left: 0;
transform: translateX(0);
&::after,
&::before {
left: 8px;
transform: none;
}
}
.statusTooltipRight {
left: auto;
right: 0;
transform: translateX(0);
&::after,
&::before {
left: auto;
right: 8px;
transform: none;
}
}
.tooltipTime {
color: var(--text-secondary);
display: block;
margin-bottom: 2px;
}
.tooltipStats {
display: flex;
align-items: center;
gap: 8px;
}
.tooltipSuccess {
color: var(--success-color, #22c55e);
}
.tooltipFailure {
color: var(--danger-color, #ef4444);
}
.tooltipRate {
color: var(--text-secondary);
margin-left: 2px;
}
.statusRate {
display: flex;
align-items: center;
@@ -456,8 +542,19 @@
}
.statusRateLow {
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg, #fee2e2);
color: var(--failure-badge-text);
background: var(--failure-badge-bg);
}
@include mobile {
.statusTooltip {
font-size: 12px;
padding: 8px 12px;
}
.statusBlocks {
min-width: 140px;
}
}
// ============================================
@@ -775,8 +872,8 @@
// 暗色主题适配
:global([data-theme='dark']) {
.headerBadge {
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.3);
background: rgba($primary-color, 0.14);
border-color: rgba($primary-color, 0.35);
color: var(--text-secondary);
strong {
@@ -785,22 +882,22 @@
}
.modelTag {
background: rgba(59, 130, 246, 0.1);
background: rgba($primary-color, 0.1);
border-color: var(--border-secondary);
}
.excludedModelTag {
background: rgba(251, 191, 36, 0.22);
border-color: rgba(251, 191, 36, 0.55);
color: #fde68a;
background: rgba($warning-color, 0.22);
border-color: rgba($warning-color, 0.55);
color: var(--warning-color);
.modelName {
color: #fde68a;
color: var(--warning-color);
}
}
.excludedModelsLabel {
color: #fde68a;
color: var(--warning-color);
}
.apiKeyEntryCard {
@@ -816,6 +913,20 @@
background-color: var(--border-primary, #374151);
}
.statusTooltip {
background: var(--bg-secondary, #1f2937);
border-color: var(--border-primary, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
&::after {
border-top-color: var(--bg-secondary, #1f2937);
}
&::before {
border-top-color: var(--border-primary, #374151);
}
}
.statusRateHigh {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
@@ -827,7 +938,7 @@
}
.statusRateLow {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
background: rgba($error-color, 0.24);
color: #f1b0a6;
}
}

View File

@@ -56,7 +56,7 @@
.errorBox {
padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1);
background-color: rgba($error-color, 0.1);
border: 1px solid var(--danger-color);
border-radius: $radius-md;
color: var(--danger-color);
@@ -382,11 +382,11 @@
}
.quotaBarFillMedium {
background-color: var(--warning-color, #f59e0b);
background-color: var(--warning-color);
}
.quotaBarFillLow {
background-color: var(--danger-color, #ef4444);
background-color: var(--danger-color);
}
.quotaMeta {
@@ -443,7 +443,7 @@
.quotaError {
font-size: 12px;
color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.08);
background-color: rgba($error-color, 0.08);
border: 1px solid var(--danger-color);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
@@ -451,9 +451,9 @@
.quotaWarning {
font-size: 12px;
color: var(--warning-color, #f59e0b);
background-color: rgba(245, 158, 11, 0.12);
border: 1px solid var(--warning-color, #f59e0b);
color: var(--warning-text);
background-color: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}
@@ -586,9 +586,9 @@
}
.statFailure {
background-color: var(--failure-badge-bg, #fee2e2);
color: var(--failure-badge-text, #991b1b);
border-color: var(--failure-badge-border, #fca5a5);
background-color: var(--failure-badge-bg);
color: var(--failure-badge-text);
border-color: var(--failure-badge-border);
}
// 状态监测栏
@@ -605,39 +605,121 @@
gap: 2px;
flex: 1;
min-width: 180px;
position: relative;
}
.statusBlockWrapper {
flex: 1;
min-width: 6px;
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.statusBlock {
flex: 1;
width: 100%;
height: 8px;
border-radius: 2px;
min-width: 6px;
transition:
transform 0.15s ease,
opacity 0.15s ease;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
transform: scaleY(1.5);
opacity: 0.85;
.statusBlockWrapper:hover &,
.statusBlockWrapper.statusBlockActive & {
transform: scaleY(1.8);
opacity: 0.9;
}
}
.statusBlockSuccess {
background-color: var(--success-color, #22c55e);
}
.statusBlockFailure {
background-color: var(--danger-color, #ef4444);
}
.statusBlockMixed {
background-color: var(--warning-color, #f59e0b);
}
.statusBlockIdle {
background-color: var(--border-secondary, #e5e7eb);
}
.statusTooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #fff);
border: 1px solid var(--border-secondary, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: $z-dropdown;
pointer-events: none;
color: var(--text-primary);
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-primary, #fff);
}
&::before {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-secondary, #e5e7eb);
}
}
.statusTooltipLeft {
left: 0;
transform: translateX(0);
&::after,
&::before {
left: 8px;
transform: none;
}
}
.statusTooltipRight {
left: auto;
right: 0;
transform: translateX(0);
&::after,
&::before {
left: auto;
right: 8px;
transform: none;
}
}
.tooltipTime {
color: var(--text-secondary);
display: block;
margin-bottom: 2px;
}
.tooltipStats {
display: flex;
align-items: center;
gap: 8px;
}
.tooltipSuccess {
color: var(--success-color, #22c55e);
}
.tooltipFailure {
color: var(--danger-color, #ef4444);
}
.tooltipRate {
color: var(--text-secondary);
margin-left: 2px;
}
.statusRate {
display: flex;
align-items: center;
@@ -661,8 +743,19 @@
}
.statusRateLow {
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg, #fee2e2);
color: var(--failure-badge-text);
background: var(--failure-badge-bg);
}
@include mobile {
.statusTooltip {
font-size: 12px;
padding: 8px 12px;
}
.statusBlocks {
min-width: 140px;
}
}
.prefixProxyEditor {
@@ -687,7 +780,7 @@
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
border: 1px solid var(--danger-color);
background-color: rgba(239, 68, 68, 0.1);
background-color: rgba($error-color, 0.1);
color: var(--danger-color);
font-size: 12px;
}
@@ -1097,7 +1190,7 @@
.modelExcludedBadge {
font-size: 10px;
color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.1);
background-color: rgba($error-color, 0.1);
padding: 2px 6px;
border-radius: 8px;
border: 1px solid var(--danger-color);

File diff suppressed because it is too large Load Diff

View File

@@ -192,15 +192,15 @@
color: var(--text-secondary);
&.modified {
color: #f59e0b;
color: var(--warning-color);
}
&.saved {
color: #16a34a;
color: var(--success-color);
}
&.error {
color: #dc2626;
color: var(--danger-color);
}
}
@@ -331,12 +331,12 @@
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.7);
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-lg);
max-width: inherit;
overflow-x: auto;
scrollbar-width: none;
@@ -351,7 +351,7 @@
font-weight: 600;
padding: 5px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.06);
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
text-align: center;
max-width: min(280px, 46vw);
white-space: nowrap;
@@ -373,7 +373,7 @@
transition: background-color 0.2s ease, transform 0.15s ease;
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
transform: scale(1.08);
}
@@ -395,26 +395,8 @@
width: 7px;
height: 7px;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25);
}
:global([data-theme='dark']) {
.floatingActionList {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.floatingStatus {
background: rgba(255, 255, 255, 0.08);
}
.floatingActionButton {
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
}
background: var(--warning-color);
box-shadow: 0 0 0 2px rgba($warning-color, 0.25);
}
@media (max-width: 1200px) {

View File

@@ -4,7 +4,7 @@
.container {
min-height: 100vh;
display: flex;
background: var(--bg-primary);
background: var(--bg-secondary);
}
// 左侧品牌展示区
@@ -88,7 +88,7 @@
justify-content: center;
align-items: center;
padding: $spacing-2xl;
background: var(--bg-primary);
background: var(--bg-secondary);
position: relative;
@media (max-width: $breakpoint-mobile) {
@@ -183,7 +183,7 @@
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
}
}
@@ -235,8 +235,8 @@
// 错误提示框
.errorBox {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.4);
background: rgba($error-color, 0.1);
border: 1px solid rgba($error-color, 0.4);
border-radius: $radius-md;
padding: $spacing-sm $spacing-md;
color: $error-color;

View File

@@ -188,8 +188,8 @@ export function LoginPage() {
/* 启动动画 */
<div className={styles.splashContent}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.splashLogo} />
<h1 className={styles.splashTitle}>CLI Proxy API</h1>
<p className={styles.splashSubtitle}>Management Center</p>
<h1 className={styles.splashTitle}>{t('splash.title')}</h1>
<p className={styles.splashSubtitle}>{t('splash.subtitle')}</p>
<div className={styles.splashLoader}>
<div className={styles.splashLoaderBar} />
</div>

View File

@@ -308,7 +308,7 @@
color: var(--text-primary);
&:hover {
background: rgba(59, 130, 246, 0.06);
background: rgba($primary-color, 0.06);
}
@include tablet {
@@ -409,14 +409,14 @@
.statusInfo {
color: var(--info-color);
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
background: rgba($primary-color, 0.12);
border-color: rgba($primary-color, 0.25);
}
.statusWarn {
color: var(--warning-color);
background: rgba(245, 158, 11, 0.14);
border-color: rgba(245, 158, 11, 0.25);
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.statusError {
@@ -427,20 +427,20 @@
.levelInfo {
color: var(--info-color);
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
background: rgba($primary-color, 0.12);
border-color: rgba($primary-color, 0.25);
}
.levelWarn {
color: var(--warning-color);
background: rgba(245, 158, 11, 0.14);
border-color: rgba(245, 158, 11, 0.25);
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.levelError {
color: var(--error-color);
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.25);
background: rgba($error-color, 0.12);
border-color: rgba($error-color, 0.25);
}
.levelDebug,
@@ -452,8 +452,8 @@
.methodBadge {
color: var(--text-primary);
background: rgba(59, 130, 246, 0.08);
border-color: rgba(59, 130, 246, 0.22);
background: rgba($primary-color, 0.08);
border-color: rgba($primary-color, 0.22);
}
.path {

View File

@@ -20,6 +20,7 @@ import {
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { copyToClipboard } from '@/utils/clipboard';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format';
import styles from './LogsPage.module.scss';
@@ -344,30 +345,6 @@ const getErrorMessage = (err: unknown): string => {
return typeof message === 'string' ? message : '';
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
};
type TabType = 'logs' | 'errors';
export function LogsPage() {

View File

@@ -61,13 +61,13 @@
}
&.error {
background-color: rgba(239, 68, 68, 0.1);
color: #dc2626;
background-color: rgba($error-color, 0.12);
color: $error-color;
}
&.waiting {
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
background-color: rgba($primary-color, 0.12);
color: var(--primary-color);
}
}

View File

@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/Input';
import { useNotificationStore, useThemeStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import { copyToClipboard } from '@/utils/clipboard';
import styles from './OAuthPage.module.scss';
import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconCodexDark from '@/assets/icons/codex_drak.svg';
@@ -186,12 +187,11 @@ export function OAuthPage() {
const copyLink = async (url?: string) => {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
showNotification(t('notification.link_copied'), 'success');
} catch {
showNotification('Copy failed', 'error');
}
const copied = await copyToClipboard(url);
showNotification(
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
copied ? 'success' : 'error'
);
};
const submitCallback = async (provider: OAuthProvider) => {

View File

@@ -65,7 +65,7 @@
.errorBox {
padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1);
background-color: rgba($error-color, 0.1);
border: 1px solid var(--danger-color);
border-radius: $radius-md;
color: var(--danger-color);
@@ -233,11 +233,11 @@
}
.quotaBarFillMedium {
background-color: var(--warning-color, #f59e0b);
background-color: var(--warning-color);
}
.quotaBarFillLow {
background-color: var(--danger-color, #ef4444);
background-color: var(--danger-color);
}
.quotaMeta {
@@ -276,7 +276,7 @@
.quotaError {
font-size: 12px;
color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.08);
background-color: rgba($error-color, 0.08);
border: 1px solid var(--danger-color);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
@@ -284,9 +284,9 @@
.quotaWarning {
font-size: 12px;
color: var(--warning-color, #f59e0b);
background-color: rgba(245, 158, 11, 0.12);
border: 1px solid var(--warning-color, #f59e0b);
color: var(--warning-text);
background-color: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}

View File

@@ -83,7 +83,7 @@
&:hover {
transform: translateY(-1px);
border-color: var(--primary-color);
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.15);
box-shadow: 0 8px 18px rgba($primary-color, 0.18);
}
&:active {

View File

@@ -44,100 +44,8 @@
font-weight: 600;
}
.timeRangeSelectWrap {
position: relative;
display: inline-flex;
align-items: center;
}
.timeRangeSelect {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.timeRangeSelectControl {
min-width: 164px;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
box-shadow: var(--shadow);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
text-align: left;
&:hover {
border-color: var(--border-hover);
}
&:focus {
outline: none;
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
}
&[aria-expanded='true'] {
border-color: var(--primary-color);
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
.timeRangeSelectedText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeRangeSelectIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.timeRangeDropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 6px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 4px;
}
.timeRangeOption {
padding: 8px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
font-weight: 500;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
background: var(--bg-secondary);
}
}
.timeRangeOptionActive {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.10);
font-weight: 600;
}
.pageTitle {
@@ -149,7 +57,7 @@
.errorBox {
padding: 10px;
background-color: rgba(239, 68, 68, 0.1);
background-color: rgba($error-color, 0.1);
border: 1px solid var(--error-color);
border-radius: $radius-sm;
color: var(--error-color);
@@ -170,15 +78,11 @@
display: flex;
align-items: center;
justify-content: center;
background: rgba(243, 244, 246, 0.75);
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
:global([data-theme='dark']) .loadingOverlay {
background: rgba(25, 25, 25, 0.72);
}
.loadingOverlayContent {
display: inline-flex;
align-items: center;
@@ -191,9 +95,9 @@
}
.loadingOverlaySpinner {
border-color: rgba(59, 130, 246, 0.25);
border-color: rgba($primary-color, 0.25);
border-top-color: var(--primary-color);
box-shadow: 0 0 10px rgba(59, 130, 246, 0.25);
box-shadow: 0 0 10px rgba($primary-color, 0.25);
}
.loadingOverlayText {
@@ -214,9 +118,9 @@
}
.statCard {
--accent: #3b82f6;
--accent-soft: rgba(59, 130, 246, 0.18);
--accent-border: rgba(59, 130, 246, 0.35);
--accent: var(--primary-color);
--accent-soft: rgba($primary-color, 0.18);
--accent-border: rgba($primary-color, 0.35);
grid-column: span 4;
position: relative;
@@ -366,11 +270,11 @@
}
.statSuccess {
color: var(--success-color, #22c55e);
color: var(--success-color);
}
.statFailure {
color: var(--danger-color, #ef4444);
color: var(--danger-color);
}
.statNeutral {
@@ -459,8 +363,8 @@
}
.apiSortBtnActive {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.10);
border-color: rgba($primary-color, 0.5);
background: rgba($primary-color, 0.1);
color: var(--text-primary);
font-weight: 600;
}
@@ -745,24 +649,6 @@
}
}
.select {
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
height: 40px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
.pricesList {
display: flex;
flex-direction: column;
@@ -831,6 +717,12 @@
flex-shrink: 0;
}
.editModalBody {
display: flex;
flex-direction: column;
gap: 12px;
}
// Chart Section (80%比例)
.chartSection {
display: flex;
@@ -1018,3 +910,266 @@
color: var(--text-tertiary);
margin: 10px 0 0 0;
}
// Service Health Card
.healthCard {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: var(--shadow-lg);
}
.healthHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.healthTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.healthMeta {
display: flex;
align-items: center;
gap: 10px;
}
.healthWindow {
font-size: 11px;
color: var(--text-tertiary);
}
.healthRate {
display: flex;
align-items: center;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-tertiary);
}
.healthRateHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
}
.healthRateMedium {
color: var(--warning-text, #92400e);
background: var(--warning-bg, #fef3c7);
}
.healthRateLow {
color: var(--failure-badge-text);
background: var(--failure-badge-bg);
}
.healthGridScroller {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.healthGrid {
display: grid;
gap: 3px;
grid-auto-flow: column;
grid-template-rows: repeat(7, 10px);
width: fit-content;
margin: 0 auto;
}
.healthBlockWrapper {
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
width: 10px;
height: 10px;
}
.healthBlock {
width: 100%;
height: 100%;
border-radius: 2px;
transition: transform 0.15s ease, opacity 0.15s ease;
.healthBlockWrapper:hover &,
.healthBlockWrapper.healthBlockActive & {
transform: scaleY(1.6);
opacity: 0.85;
}
}
.healthBlockIdle {
background-color: var(--border-secondary, #e5e7eb);
}
.healthTooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #fff);
border: 1px solid var(--border-secondary, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: $z-dropdown;
pointer-events: none;
color: var(--text-primary);
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-primary, #fff);
}
&::before {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-secondary, #e5e7eb);
}
}
// When tooltip should appear below (for top rows)
.healthTooltipBelow {
bottom: auto;
top: calc(100% + 8px);
&::after {
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--bg-primary, #fff);
}
&::before {
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--border-secondary, #e5e7eb);
}
}
.healthTooltipLeft {
left: 0;
transform: translateX(0);
&::after,
&::before {
left: 8px;
transform: none;
}
}
.healthTooltipRight {
left: auto;
right: 0;
transform: translateX(0);
&::after,
&::before {
left: auto;
right: 8px;
transform: none;
}
}
.healthTooltipTime {
color: var(--text-secondary);
display: block;
margin-bottom: 2px;
}
.healthTooltipStats {
display: flex;
align-items: center;
gap: 8px;
}
.healthTooltipSuccess {
color: var(--success-color, #22c55e);
}
.healthTooltipFailure {
color: var(--danger-color, #ef4444);
}
.healthTooltipRate {
color: var(--text-secondary);
margin-left: 2px;
}
.healthLegend {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.healthLegendLabel {
font-size: 10px;
color: var(--text-tertiary);
}
.healthLegendColors {
display: flex;
gap: 3px;
}
.healthLegendBlock {
width: 10px;
height: 10px;
border-radius: 2px;
}
@include mobile {
.healthCard {
padding: 14px;
gap: 10px;
}
.healthGrid {
grid-template-rows: repeat(7, 6px);
gap: 2px;
margin: 0;
}
.healthBlockWrapper {
width: 6px;
height: 6px;
}
.healthTooltip {
font-size: 10px;
padding: 4px 8px;
}
.healthLegendBlock {
width: 8px;
height: 8px;
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
@@ -13,7 +13,7 @@ import {
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconChevronDown } from '@/components/ui/icons';
import { Select } from '@/components/ui/Select';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore, useConfigStore } from '@/stores';
@@ -27,6 +27,7 @@ import {
CredentialStatsCard,
TokenBreakdownChart,
CostTrendChart,
ServiceHealthCard,
useUsageData,
useSparklines,
useChartData
@@ -120,19 +121,6 @@ export function UsagePage() {
const isDark = resolvedTheme === 'dark';
const config = useConfigStore((state) => state.config);
// Time range dropdown
const [timeRangeOpen, setTimeRangeOpen] = useState(false);
const timeRangeRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!timeRangeOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (!timeRangeRef.current?.contains(event.target as Node)) setTimeRangeOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [timeRangeOpen]);
// Data hook
const {
usage,
@@ -156,6 +144,15 @@ export function UsagePage() {
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
const timeRangeOptions = useMemo(
() =>
TIME_RANGE_OPTIONS.map((opt) => ({
value: opt.value,
label: t(opt.labelKey)
})),
[t]
);
const filteredUsage = useMemo(
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
[usage, timeRange]
@@ -238,44 +235,14 @@ export function UsagePage() {
<div className={styles.headerActions}>
<div className={styles.timeRangeGroup}>
<span className={styles.timeRangeLabel}>{t('usage_stats.range_filter')}</span>
<div className={styles.timeRangeSelectWrap} ref={timeRangeRef}>
<button
type="button"
className={styles.timeRangeSelect}
onClick={() => setTimeRangeOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={timeRangeOpen}
>
<span className={styles.timeRangeSelectedText}>
{t(TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.labelKey ?? 'usage_stats.range_24h')}
</span>
<span className={styles.timeRangeSelectIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{timeRangeOpen && (
<div className={styles.timeRangeDropdown} role="listbox" aria-label={t('usage_stats.range_filter')}>
{TIME_RANGE_OPTIONS.map((opt) => {
const active = opt.value === timeRange;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
className={`${styles.timeRangeOption} ${active ? styles.timeRangeOptionActive : ''}`}
onClick={() => {
setTimeRange(opt.value);
setTimeRangeOpen(false);
}}
>
{t(opt.labelKey)}
</button>
);
})}
</div>
)}
</div>
<Select
value={timeRange}
options={timeRangeOptions}
onChange={(value) => setTimeRange(value as UsageTimeRange)}
className={styles.timeRangeSelectControl}
ariaLabel={t('usage_stats.range_filter')}
fullWidth={false}
/>
</div>
<Button
variant="secondary"
@@ -342,6 +309,9 @@ export function UsagePage() {
onChange={handleChartLinesChange}
/>
{/* Service Health */}
<ServiceHealthCard usage={usage} loading={loading} />
{/* Charts Grid */}
<div className={styles.chartsGrid}>
<UsageChart

View File

@@ -7,10 +7,10 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import type { ApiClientConfig, ApiError } from '@/types';
import {
BUILD_DATE_HEADER_KEYS,
MANAGEMENT_API_PREFIX,
REQUEST_TIMEOUT_MS,
VERSION_HEADER_KEYS
} from '@/utils/constants';
import { computeApiUrl } from '@/utils/connection';
class ApiClient {
private instance: AxiosInstance;
@@ -32,7 +32,7 @@ class ApiClient {
* 设置 API 配置
*/
setConfig(config: ApiClientConfig): void {
this.apiBase = this.normalizeApiBase(config.apiBase);
this.apiBase = computeApiUrl(config.apiBase);
this.managementKey = config.managementKey;
if (config.timeout) {
@@ -42,26 +42,6 @@ class ApiClient {
}
}
/**
* 规范化 API Base URL
*/
private normalizeApiBase(base: string): string {
let normalized = base.trim();
// 移除尾部的 /v0/management
normalized = normalized.replace(/\/?v0\/management\/?$/i, '');
// 移除尾部斜杠
normalized = normalized.replace(/\/+$/, '');
// 添加协议
if (!/^https?:\/\//i.test(normalized)) {
normalized = `http://${normalized}`;
}
return `${normalized}${MANAGEMENT_API_PREFIX}`;
}
private readHeader(
headers: Record<string, unknown> | undefined,
keys: string[]

View File

@@ -4,27 +4,17 @@
import axios from 'axios';
import { normalizeModelList } from '@/utils/models';
import { normalizeApiBase } from '@/utils/connection';
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
const normalizeBaseUrl = (baseUrl: string): string => {
let normalized = String(baseUrl || '').trim();
if (!normalized) return '';
normalized = normalized.replace(/\/?v0\/management\/?$/i, '');
normalized = normalized.replace(/\/+$/g, '');
if (!/^https?:\/\//i.test(normalized)) {
normalized = `http://${normalized}`;
}
return normalized;
};
const buildModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeBaseUrl(baseUrl);
const normalized = normalizeApiBase(baseUrl);
if (!normalized) return '';
return `${normalized}/models`;
};
const buildV1ModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeBaseUrl(baseUrl);
const normalized = normalizeApiBase(baseUrl);
if (!normalized) return '';
return `${normalized}/v1/models`;
};

View File

@@ -17,7 +17,7 @@
&.btn-primary {
background-color: var(--primary-color);
color: #fff;
color: var(--primary-contrast, #fff);
border-color: var(--primary-color);
&:hover {
@@ -32,6 +32,7 @@
color: var(--text-primary);
&:hover {
background-color: var(--bg-hover, var(--bg-tertiary));
border-color: var(--border-hover);
}
}
@@ -43,7 +44,7 @@
&:hover {
color: var(--text-primary);
background: var(--bg-secondary);
background: var(--bg-tertiary);
}
}
@@ -72,20 +73,31 @@
}
}
:global([data-theme='dark']) {
.btn {
color: #fff;
}
.btn.btn-secondary,
.btn.btn-ghost {
color: #fff;
}
}
.input,
textarea {
width: 100%;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px 12px;
background-color: var(--bg-primary);
background-color: var(--bg-secondary);
color: var(--text-primary);
transition: border-color $transition-fast, box-shadow $transition-fast;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
}
}
@@ -144,20 +156,20 @@ textarea {
&.success {
color: $success-color;
border-color: rgba(16, 185, 129, 0.35);
background: rgba(16, 185, 129, 0.08);
border-color: rgba($success-color, 0.35);
background: rgba($success-color, 0.08);
}
&.warning {
color: $warning-color;
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.08);
border-color: rgba($warning-color, 0.35);
background: rgba($warning-color, 0.08);
}
&.error {
color: $error-color;
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
border-color: rgba($error-color, 0.35);
background: rgba($error-color, 0.08);
}
&.muted {
@@ -220,13 +232,13 @@ textarea {
}
&.success {
border-color: rgba(16, 185, 129, 0.4);
border-color: rgba($success-color, 0.4);
}
&.warning {
border-color: rgba(245, 158, 11, 0.4);
border-color: rgba($warning-color, 0.4);
}
&.error {
border-color: rgba(239, 68, 68, 0.4);
border-color: rgba($error-color, 0.4);
}
.message {
@@ -324,6 +336,7 @@ textarea {
}
.loading-spinner {
// Fallback: legacy white spinner (in case color-mix is unsupported)
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
@@ -332,6 +345,13 @@ textarea {
animation: spin 0.8s linear infinite;
}
@supports (color: color-mix(in srgb, currentColor 22%, transparent)) {
.loading-spinner {
border-color: color-mix(in srgb, currentColor 22%, transparent);
border-top-color: currentColor;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
@@ -611,8 +631,8 @@ textarea {
}
.error-box {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.4);
background: rgba($error-color, 0.1);
border: 1px solid rgba($error-color, 0.4);
border-radius: $radius-md;
padding: $spacing-sm $spacing-md;
color: $error-color;

View File

@@ -229,7 +229,7 @@
&:focus-visible {
outline: none;
background: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
box-shadow: 0 0 0 2px rgba($primary-color, 0.22);
}
&.active {
@@ -382,9 +382,9 @@
}
&.active {
background: rgba(59, 130, 246, 0.12);
background: rgba($primary-color, 0.14);
color: var(--primary-color);
border: 1px solid rgba(59, 130, 246, 0.3);
border: 1px solid rgba($primary-color, 0.35);
}
}

View File

@@ -4,74 +4,108 @@
// 浅色主题(默认)
:root {
--bg-primary: #ffffff;
--bg-secondary: #f3f4f6;
--bg-tertiary: #e5e7eb;
// 极简暖灰:浅色模式
--bg-secondary: #faf9f5; // 页面背景(纸感)
--bg-primary: #f0eee8; // 容器/卡片背景
--bg-tertiary: #e9e6df; // hover/次级背景
--bg-hover: var(--bg-tertiary);
--bg-quinary: #f6f4ee;
--bg-error-light: rgba(198, 87, 70, 0.1);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--text-primary: #2d2a26;
--text-secondary: #6d6760;
--text-tertiary: #a29c95;
--text-quaternary: #c0bab3;
--text-muted: var(--text-tertiary);
--border-color: #e5e7eb;
--border-hover: #d1d5db;
--border-color: #e3e1db; // 边框/分割线
--border-secondary: var(--border-color);
--border-primary: #d5d2cb;
--border-hover: #cecac4;
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--primary-active: #1d4ed8;
--primary-color: #8b8680; // 行动色(主色)
--primary-hover: #7f7a74;
--primary-active: #726d67;
--primary-contrast: #ffffff;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--info-color: #3b82f6;
--warning-color: #c65746; // 错误/警告色
--error-color: #c65746;
--danger-color: var(--error-color);
--info-color: var(--primary-color);
--warning-bg: rgba(198, 87, 70, 0.12);
--warning-border: rgba(198, 87, 70, 0.35);
--warning-text: var(--warning-color);
--success-badge-bg: #d1fae5;
--success-badge-text: #065f46;
--success-badge-border: #6ee7b7;
--failure-badge-bg: #fee2e2;
--failure-badge-text: #991b1b;
--failure-badge-border: #fca5a5;
--failure-badge-bg: rgba(198, 87, 70, 0.14);
--failure-badge-text: #8a3a30;
--failure-badge-border: rgba(198, 87, 70, 0.35);
--count-badge-bg: rgba(59, 130, 246, 0.14);
--count-badge-bg: rgba(139, 134, 128, 0.18);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--shadow: 0 1px 2px 0 rgb(0 0 0 / 0.08);
--shadow-lg: 0 10px 18px -3px rgb(0 0 0 / 0.1);
--radius-md: 8px;
--accent-tertiary: var(--bg-tertiary);
}
// 深色主题(#191919
[data-theme='dark'] {
--bg-primary: #202020;
--bg-secondary: #191919;
--bg-tertiary: #262626;
// 极简暖灰:深色模式(提升对比度与层级)
--bg-secondary: #151412; // 页面背景
--bg-primary: #1d1b18; // 容器/卡片背景
--bg-tertiary: #262320; // hover/次级背景
--bg-hover: #2e2a26;
--bg-quinary: #191714;
--bg-error-light: rgba(198, 87, 70, 0.18);
--text-primary: #fafafa;
--text-secondary: #a3a3a3;
--text-tertiary: #737373;
--text-primary: #f6f4f1;
--text-secondary: #c9c3bb;
--text-tertiary: #9c958d;
--text-quaternary: #6f6962;
--text-muted: var(--text-tertiary);
--border-color: #262626;
--border-hover: #404040;
--border-color: #3a3530;
--border-secondary: var(--border-color);
--border-primary: #4a453f;
--border-hover: #5a544d;
--primary-color: #3b82f6;
--primary-hover: #60a5fa;
--primary-active: #93c5fd;
--primary-color: #8b8680;
--primary-hover: #9a948e;
--primary-active: #a6a099;
--primary-contrast: #ffffff;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--info-color: #3b82f6;
--warning-color: #c65746;
--error-color: #c65746;
--danger-color: var(--error-color);
--info-color: var(--primary-color);
--warning-bg: rgba(198, 87, 70, 0.22);
--warning-border: rgba(198, 87, 70, 0.45);
--warning-text: #f1b0a6;
--success-badge-bg: rgba(6, 78, 59, 0.3);
--success-badge-text: #6ee7b7;
--success-badge-border: #059669;
--failure-badge-bg: rgba(153, 27, 27, 0.3);
--failure-badge-text: #fca5a5;
--failure-badge-border: #dc2626;
--failure-badge-bg: rgba(198, 87, 70, 0.24);
--failure-badge-text: #f1b0a6;
--failure-badge-border: rgba(198, 87, 70, 0.5);
--count-badge-bg: rgba(59, 130, 246, 0.25);
--count-badge-bg: rgba(139, 134, 128, 0.28);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3);
--radius-md: 8px;
--accent-tertiary: var(--bg-tertiary);
}

View File

@@ -3,11 +3,11 @@
*/
// 颜色
$primary-color: #3b82f6;
$primary-color: #8b8680;
$success-color: #10b981;
$warning-color: #f59e0b;
$error-color: #ef4444;
$info-color: #3b82f6;
$warning-color: #c65746;
$error-color: #c65746;
$info-color: #8b8680;
// 灰阶
$gray-50: #f9fafb;

49
src/utils/clipboard.ts Normal file
View File

@@ -0,0 +1,49 @@
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fallback below
}
try {
if (typeof document === 'undefined') return false;
if (!document.body) return false;
const activeElement = document.activeElement as HTMLElement | null;
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
textarea.style.left = '-9999px';
textarea.style.top = '0';
textarea.style.width = '1px';
textarea.style.height = '1px';
textarea.style.padding = '0';
textarea.style.border = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
if (activeElement?.focus) {
try {
activeElement.focus();
} catch {
// ignore
}
}
return copied;
} catch {
return false;
}
}

View File

@@ -3,6 +3,14 @@
* 从原项目 src/utils/string.js 迁移
*/
const resolveDefaultLocale = (): string | undefined => {
const fromDocument =
typeof document !== 'undefined' ? document.documentElement?.lang?.trim() : '';
if (fromDocument) return fromDocument;
const fromNavigator = typeof navigator !== 'undefined' ? navigator.language?.trim() : '';
return fromNavigator || undefined;
};
/**
* 隐藏 API Key 中间部分,仅保留前后两位
*/
@@ -38,14 +46,15 @@ export function formatFileSize(bytes: number): string {
/**
* 格式化日期时间
*/
export function formatDateTime(date: string | Date): string {
export function formatDateTime(date: string | Date, locale?: string): string {
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) {
return 'Invalid Date';
}
return d.toLocaleString('zh-CN', {
const resolvedLocale = locale?.trim() || resolveDefaultLocale();
return d.toLocaleString(resolvedLocale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -89,8 +98,9 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string {
/**
* 格式化数字(添加千位分隔符)
*/
export function formatNumber(num: number): string {
return num.toLocaleString('zh-CN');
export function formatNumber(num: number, locale?: string): string {
const resolvedLocale = locale?.trim() || resolveDefaultLocale();
return num.toLocaleString(resolvedLocale);
}
/**

View File

@@ -1003,10 +1003,10 @@ export interface ChartData {
}
const CHART_COLORS = [
{ borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' },
{ borderColor: '#8b8680', backgroundColor: 'rgba(139, 134, 128, 0.15)' },
{ borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.15)' },
{ borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.15)' },
{ borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.15)' },
{ borderColor: '#c65746', backgroundColor: 'rgba(198, 87, 70, 0.15)' },
{ borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.15)' },
{ borderColor: '#06b6d4', backgroundColor: 'rgba(6, 182, 212, 0.15)' },
{ borderColor: '#ec4899', backgroundColor: 'rgba(236, 72, 153, 0.15)' },
@@ -1117,11 +1117,26 @@ export function buildChartData(
*/
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
/**
* 状态栏单个格子的详细信息
*/
export interface StatusBlockDetail {
success: number;
failure: number;
/** 该格子的成功率 (01),无请求时为 -1 */
rate: number;
/** 格子起始时间戳 (ms) */
startTime: number;
/** 格子结束时间戳 (ms) */
endTime: number;
}
/**
* 状态栏数据
*/
export interface StatusBarData {
blocks: StatusBlockState[];
blockDetails: StatusBlockDetail[];
successRate: number;
totalSuccess: number;
totalFailure: number;
@@ -1138,7 +1153,7 @@ export function calculateStatusBarData(
): StatusBarData {
const BLOCK_COUNT = 20;
const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
const WINDOW_MS = 200 * 60 * 1000; // 200 minutes
const WINDOW_MS = BLOCK_COUNT * BLOCK_DURATION_MS; // 200 minutes
const now = Date.now();
const windowStart = now - WINDOW_MS;
@@ -1182,18 +1197,30 @@ export function calculateStatusBarData(
}
});
// Convert stats to block states
const blocks: StatusBlockState[] = blockStats.map((stat) => {
if (stat.success === 0 && stat.failure === 0) {
return 'idle';
// Convert stats to block states and build details
const blocks: StatusBlockState[] = [];
const blockDetails: StatusBlockDetail[] = [];
blockStats.forEach((stat, idx) => {
const total = stat.success + stat.failure;
if (total === 0) {
blocks.push('idle');
} else if (stat.failure === 0) {
blocks.push('success');
} else if (stat.success === 0) {
blocks.push('failure');
} else {
blocks.push('mixed');
}
if (stat.failure === 0) {
return 'success';
}
if (stat.success === 0) {
return 'failure';
}
return 'mixed';
const blockStartTime = windowStart + idx * BLOCK_DURATION_MS;
blockDetails.push({
success: stat.success,
failure: stat.failure,
rate: total > 0 ? stat.success / total : -1,
startTime: blockStartTime,
endTime: blockStartTime + BLOCK_DURATION_MS,
});
});
// Calculate success rate
@@ -1202,12 +1229,106 @@ export function calculateStatusBarData(
return {
blocks,
blockDetails,
successRate,
totalSuccess,
totalFailure
};
}
/**
* 服务健康监测数据最近168小时/7天7×96网格
* 每个格子代表15分钟的健康度
*/
export interface ServiceHealthData {
blocks: StatusBlockState[];
blockDetails: StatusBlockDetail[];
successRate: number;
totalSuccess: number;
totalFailure: number;
rows: number;
cols: number;
}
export function calculateServiceHealthData(
usageDetails: UsageDetail[]
): ServiceHealthData {
const ROWS = 7;
const COLS = 96;
const BLOCK_COUNT = ROWS * COLS; // 672
const BLOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
const WINDOW_MS = BLOCK_COUNT * BLOCK_DURATION_MS; // 168 hours (7 days)
const now = Date.now();
const windowStart = now - WINDOW_MS;
const blockStats: Array<{ success: number; failure: number }> = Array.from(
{ length: BLOCK_COUNT },
() => ({ success: 0, failure: 0 })
);
let totalSuccess = 0;
let totalFailure = 0;
usageDetails.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
return;
}
const ageMs = now - timestamp;
const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS);
if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) {
if (detail.failed) {
blockStats[blockIndex].failure += 1;
totalFailure += 1;
} else {
blockStats[blockIndex].success += 1;
totalSuccess += 1;
}
}
});
const blocks: StatusBlockState[] = [];
const blockDetails: StatusBlockDetail[] = [];
blockStats.forEach((stat, idx) => {
const total = stat.success + stat.failure;
if (total === 0) {
blocks.push('idle');
} else if (stat.failure === 0) {
blocks.push('success');
} else if (stat.success === 0) {
blocks.push('failure');
} else {
blocks.push('mixed');
}
const blockStartTime = windowStart + idx * BLOCK_DURATION_MS;
blockDetails.push({
success: stat.success,
failure: stat.failure,
rate: total > 0 ? stat.success / total : -1,
startTime: blockStartTime,
endTime: blockStartTime + BLOCK_DURATION_MS,
});
});
const total = totalSuccess + totalFailure;
const successRate = total > 0 ? (totalSuccess / total) * 100 : 100;
return {
blocks,
blockDetails,
successRate,
totalSuccess,
totalFailure,
rows: ROWS,
cols: COLS,
};
}
export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats {
const apis = getApisRecord(usageData);
if (!apis) {