mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-16 09:40:51 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dfa71b81e | ||
|
|
d140fe1061 | ||
|
|
b702cd6e4c | ||
|
|
211f9f280c | ||
|
|
6d96c92233 | ||
|
|
52cf9d86c0 | ||
|
|
a2507b1373 | ||
|
|
1f8c4331c7 | ||
|
|
faadc3ea3e | ||
|
|
32b576123c | ||
|
|
5dce24e3ea |
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
@@ -81,120 +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(139, 134, 128, 0.5)'
|
||||
: '1px solid var(--border-color)',
|
||||
background: active ? 'rgba(139, 134, 128, 0.12)' : 'var(--bg-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeysCardEditor({
|
||||
value,
|
||||
disabled,
|
||||
@@ -530,7 +416,7 @@ function PayloadRulesEditor({
|
||||
>
|
||||
{protocolFirst ? (
|
||||
<>
|
||||
<ToastSelect
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
@@ -558,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}
|
||||
@@ -600,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}
|
||||
@@ -743,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}
|
||||
@@ -996,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') },
|
||||
|
||||
@@ -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>;
|
||||
/**
|
||||
* 根据成功率 (0–1) 在三个色标之间做 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>
|
||||
|
||||
110
src/components/ui/Select.module.scss
Normal file
110
src/components/ui/Select.module.scss
Normal 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;
|
||||
}
|
||||
94
src/components/ui/Select.tsx
Normal file
94
src/components/ui/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
180
src/components/usage/ServiceHealthCard.tsx
Normal file
180
src/components/usage/ServiceHealthCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -88,14 +89,6 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||
const statusData =
|
||||
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||
const rateClass = !hasData
|
||||
? ''
|
||||
: statusData.successRate >= 90
|
||||
? styles.statusRateHigh
|
||||
: statusData.successRate >= 50
|
||||
? styles.statusRateMedium
|
||||
: styles.statusRateLow;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -135,24 +128,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} styles={styles} />
|
||||
|
||||
{showQuotaLayout && quotaType && (
|
||||
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
|
||||
|
||||
@@ -832,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",
|
||||
|
||||
@@ -835,6 +835,17 @@
|
||||
"success": "Успех",
|
||||
"failure": "Сбой"
|
||||
},
|
||||
"status_bar": {
|
||||
"success_short": "✓",
|
||||
"failure_short": "✗",
|
||||
"no_requests": "Нет запросов"
|
||||
},
|
||||
"service_health": {
|
||||
"title": "Состояние сервиса",
|
||||
"window": "Последние 7 дней",
|
||||
"oldest": "Старые",
|
||||
"newest": "Новые"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Просмотр журналов",
|
||||
"refresh_button": "Обновить журналы",
|
||||
|
||||
@@ -832,6 +832,17 @@
|
||||
"success": "成功",
|
||||
"failure": "失败"
|
||||
},
|
||||
"status_bar": {
|
||||
"success_short": "✓",
|
||||
"failure_short": "✗",
|
||||
"no_requests": "无请求"
|
||||
},
|
||||
"service_health": {
|
||||
"title": "服务健康监测",
|
||||
"window": "最近 7 天",
|
||||
"oldest": "最早",
|
||||
"newest": "最新"
|
||||
},
|
||||
"logs": {
|
||||
"title": "日志查看",
|
||||
"refresh_button": "刷新日志",
|
||||
|
||||
@@ -254,17 +254,8 @@ export function AiProvidersAmpcodeEditPage() {
|
||||
disabled={loading || saving || disableControls}
|
||||
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
marginTop: -8,
|
||||
marginBottom: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div className="hint" style={{ margin: 0 }}>
|
||||
<div className={layoutStyles.upstreamApiKeyRow}>
|
||||
<div className={layoutStyles.upstreamApiKeyHint}>
|
||||
{t('ai_providers.ampcode_upstream_api_key_current', {
|
||||
key: config?.ampcode?.upstreamApiKey
|
||||
? maskApiKey(config.ampcode.upstreamApiKey)
|
||||
|
||||
@@ -3,3 +3,17 @@
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.upstreamApiKeyRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upstreamApiKeyHint {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card } from '@/components/ui/Card';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
@@ -139,6 +140,20 @@ export function AiProvidersOpenAIEditPage() {
|
||||
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTestingKeys;
|
||||
const hasConfiguredModels = form.modelEntries.some((entry) => entry.name.trim());
|
||||
const hasTestableKeys = form.apiKeyEntries.some((entry) => entry.apiKey?.trim());
|
||||
const modelSelectOptions = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return form.modelEntries.reduce<Array<{ value: string; label: string }>>((acc, entry) => {
|
||||
const name = entry.name.trim();
|
||||
if (!name || seen.has(name)) return acc;
|
||||
seen.add(name);
|
||||
const alias = entry.alias.trim();
|
||||
acc.push({
|
||||
value: name,
|
||||
label: alias && alias !== name ? `${name} (${alias})` : name,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
}, [form.modelEntries]);
|
||||
const connectivityConfigSignature = useMemo(() => {
|
||||
const headersSignature = form.headers
|
||||
.map((entry) => `${entry.key.trim()}:${entry.value.trim()}`)
|
||||
@@ -496,9 +511,9 @@ export function AiProvidersOpenAIEditPage() {
|
||||
>
|
||||
<Card>
|
||||
{invalidIndexParam || invalidIndex ? (
|
||||
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||
<div className={styles.sectionHint}>{t('common.invalid_provider_index')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.openaiEditForm}>
|
||||
<Input
|
||||
label={t('ai_providers.openai_add_modal_name_label')}
|
||||
value={form.name}
|
||||
@@ -564,7 +579,7 @@ export function AiProvidersOpenAIEditPage() {
|
||||
</div>
|
||||
|
||||
{/* 提示文本 */}
|
||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_hint')}</div>
|
||||
|
||||
{/* 模型列表 */}
|
||||
<ModelInputList
|
||||
@@ -589,34 +604,23 @@ export function AiProvidersOpenAIEditPage() {
|
||||
<span className={styles.modelTestHint}>{t('ai_providers.openai_test_hint')}</span>
|
||||
</div>
|
||||
<div className={styles.modelTestControls}>
|
||||
<select
|
||||
className={`input ${styles.openaiTestSelect}`}
|
||||
<Select
|
||||
value={testModel}
|
||||
onChange={(e) => {
|
||||
setTestModel(e.target.value);
|
||||
options={modelSelectOptions}
|
||||
onChange={(value) => {
|
||||
setTestModel(value);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}}
|
||||
disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || availableModels.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{availableModels.length
|
||||
placeholder={
|
||||
availableModels.length
|
||||
? t('ai_providers.openai_test_select_placeholder')
|
||||
: t('ai_providers.openai_test_select_empty')}
|
||||
</option>
|
||||
{form.modelEntries
|
||||
.filter((entry) => entry.name.trim())
|
||||
.map((entry, idx) => {
|
||||
const name = entry.name.trim();
|
||||
const alias = entry.alias.trim();
|
||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
||||
return (
|
||||
<option key={`${name}-${idx}`} value={name}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
: t('ai_providers.openai_test_select_empty')
|
||||
}
|
||||
className={styles.openaiTestSelect}
|
||||
ariaLabel={t('ai_providers.openai_test_title')}
|
||||
disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || availableModels.length === 0}
|
||||
/>
|
||||
<Button
|
||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
||||
size="sm"
|
||||
@@ -645,14 +649,14 @@ export function AiProvidersOpenAIEditPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`form-group ${styles.keyEntriesSection}`}>
|
||||
<div className={styles.keyEntriesSection}>
|
||||
<div className={styles.keyEntriesHeader}>
|
||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||
<label className={styles.keyEntriesTitle}>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||
<span className={styles.keyEntriesHint}>{t('ai_providers.openai_keys_hint')}</span>
|
||||
</div>
|
||||
{renderKeyEntries(form.apiKeyEntries)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</SecondaryScreenShell>
|
||||
|
||||
@@ -153,70 +153,76 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
loadingLabel={t('common.loading')}
|
||||
>
|
||||
<Card>
|
||||
<div className="hint" style={{ marginBottom: 8 }}>
|
||||
{t('ai_providers.openai_models_fetch_hint')}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input className="input" readOnly value={endpoint} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
||||
loading={fetching}
|
||||
disabled={disableControls || saving}
|
||||
>
|
||||
{t('ai_providers.openai_models_fetch_refresh')}
|
||||
</Button>
|
||||
<div className={styles.openaiModelsContent}>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_hint')}</div>
|
||||
<div className={styles.openaiModelsEndpointSection}>
|
||||
<label className={styles.openaiModelsEndpointLabel}>
|
||||
{t('ai_providers.openai_models_fetch_url_label')}
|
||||
</label>
|
||||
<div className={styles.openaiModelsEndpointControls}>
|
||||
<input
|
||||
className={`input ${styles.openaiModelsEndpointInput}`}
|
||||
readOnly
|
||||
value={endpoint}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
||||
loading={fetching}
|
||||
disabled={disableControls || saving}
|
||||
>
|
||||
{t('ai_providers.openai_models_fetch_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('ai_providers.openai_models_search_label')}
|
||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={fetching}
|
||||
/>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{fetching ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
key={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
<Input
|
||||
label={t('ai_providers.openai_models_search_label')}
|
||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={fetching}
|
||||
/>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{fetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_search_empty')}</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
key={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</SecondaryScreenShell>
|
||||
);
|
||||
|
||||
@@ -322,7 +322,7 @@
|
||||
gap: 6px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
margin-top: 8px;
|
||||
margin-top: 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
.statusBlockMixed {
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -460,15 +546,87 @@
|
||||
background: var(--failure-badge-bg);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.statusTooltip {
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.statusBlocks {
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Model Config Section - Unified Layout
|
||||
// ============================================
|
||||
|
||||
.modelConfigSection {
|
||||
margin-bottom: $spacing-md;
|
||||
.openaiEditForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.status-badge) {
|
||||
margin-bottom: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionHint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.openaiModelsContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.openaiModelsEndpointSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.openaiModelsEndpointLabel {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.openaiModelsEndpointControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.openaiModelsEndpointInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelConfigSection {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.modelConfigHeader {
|
||||
@@ -484,10 +642,11 @@
|
||||
}
|
||||
|
||||
.modelConfigTitle {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modelConfigToolbar {
|
||||
@@ -549,7 +708,7 @@
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
margin-top: $spacing-sm;
|
||||
margin-top: 0;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
@@ -564,7 +723,7 @@
|
||||
.modelTestMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -572,13 +731,13 @@
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modelTestHint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modelTestControls {
|
||||
@@ -600,22 +759,29 @@
|
||||
|
||||
.keyEntriesSection {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.keyEntriesHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: $spacing-sm;
|
||||
gap: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
}
|
||||
.keyEntriesTitle {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.keyEntriesHint {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -816,6 +982,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
.statusBlockMixed {
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -665,6 +747,17 @@
|
||||
background: var(--failure-badge-bg);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.statusTooltip {
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.statusBlocks {
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.prefixProxyEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -28,6 +28,35 @@
|
||||
gap: $spacing-xl;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.status-badge) {
|
||||
margin-bottom: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.cardHint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cardHintSecondary {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.oauthSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -72,10 +101,10 @@
|
||||
}
|
||||
|
||||
.callbackSection {
|
||||
margin-top: $spacing-md;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.callbackActions {
|
||||
@@ -117,11 +146,24 @@
|
||||
|
||||
.geminiProjectField {
|
||||
:global(.form-group) {
|
||||
margin-top: $spacing-sm;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.formItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.formItemLabel {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -143,3 +185,49 @@
|
||||
.fileNamePlaceholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.connectionBox {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.connectionLabel {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.keyValueList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.keyValueItem {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.keyValueKey {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.keyValueValue {
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -358,88 +358,90 @@ export function OAuthPage() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="hint">{t(provider.hintKey)}</div>
|
||||
{provider.id === 'gemini-cli' && (
|
||||
<div className={styles.geminiProjectField}>
|
||||
<Input
|
||||
label={t('auth_login.gemini_cli_project_id_label')}
|
||||
hint={t('auth_login.gemini_cli_project_id_hint')}
|
||||
value={state.projectId || ''}
|
||||
error={state.projectIdError}
|
||||
onChange={(e) =>
|
||||
updateProviderState(provider.id, {
|
||||
projectId: e.target.value,
|
||||
projectIdError: undefined
|
||||
})
|
||||
}
|
||||
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{state.url && (
|
||||
<div className={`connection-box ${styles.authUrlBox}`}>
|
||||
<div className={styles.authUrlLabel}>{t(provider.urlLabelKey)}</div>
|
||||
<div className={styles.authUrlValue}>{state.url}</div>
|
||||
<div className={styles.authUrlActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
||||
{t(getAuthKey(provider.id, 'copy_link'))}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{t(getAuthKey(provider.id, 'open_link'))}
|
||||
</Button>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.cardHint}>{t(provider.hintKey)}</div>
|
||||
{provider.id === 'gemini-cli' && (
|
||||
<div className={styles.geminiProjectField}>
|
||||
<Input
|
||||
label={t('auth_login.gemini_cli_project_id_label')}
|
||||
hint={t('auth_login.gemini_cli_project_id_hint')}
|
||||
value={state.projectId || ''}
|
||||
error={state.projectIdError}
|
||||
onChange={(e) =>
|
||||
updateProviderState(provider.id, {
|
||||
projectId: e.target.value,
|
||||
projectIdError: undefined
|
||||
})
|
||||
}
|
||||
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canSubmitCallback && (
|
||||
<div className={styles.callbackSection}>
|
||||
<Input
|
||||
label={t('auth_login.oauth_callback_label')}
|
||||
hint={t('auth_login.oauth_callback_hint')}
|
||||
value={state.callbackUrl || ''}
|
||||
onChange={(e) =>
|
||||
updateProviderState(provider.id, {
|
||||
callbackUrl: e.target.value,
|
||||
callbackStatus: undefined,
|
||||
callbackError: undefined
|
||||
})
|
||||
}
|
||||
placeholder={t('auth_login.oauth_callback_placeholder')}
|
||||
/>
|
||||
<div className={styles.callbackActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => submitCallback(provider.id)}
|
||||
loading={state.callbackSubmitting}
|
||||
>
|
||||
{t('auth_login.oauth_callback_button')}
|
||||
</Button>
|
||||
)}
|
||||
{state.url && (
|
||||
<div className={styles.authUrlBox}>
|
||||
<div className={styles.authUrlLabel}>{t(provider.urlLabelKey)}</div>
|
||||
<div className={styles.authUrlValue}>{state.url}</div>
|
||||
<div className={styles.authUrlActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
||||
{t(getAuthKey(provider.id, 'copy_link'))}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{t(getAuthKey(provider.id, 'open_link'))}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{state.callbackStatus === 'success' && state.status === 'waiting' && (
|
||||
<div className="status-badge success" style={{ marginTop: 8 }}>
|
||||
{t('auth_login.oauth_callback_status_success')}
|
||||
)}
|
||||
{canSubmitCallback && (
|
||||
<div className={styles.callbackSection}>
|
||||
<Input
|
||||
label={t('auth_login.oauth_callback_label')}
|
||||
hint={t('auth_login.oauth_callback_hint')}
|
||||
value={state.callbackUrl || ''}
|
||||
onChange={(e) =>
|
||||
updateProviderState(provider.id, {
|
||||
callbackUrl: e.target.value,
|
||||
callbackStatus: undefined,
|
||||
callbackError: undefined
|
||||
})
|
||||
}
|
||||
placeholder={t('auth_login.oauth_callback_placeholder')}
|
||||
/>
|
||||
<div className={styles.callbackActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => submitCallback(provider.id)}
|
||||
loading={state.callbackSubmitting}
|
||||
>
|
||||
{t('auth_login.oauth_callback_button')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{state.callbackStatus === 'error' && (
|
||||
<div className="status-badge error" style={{ marginTop: 8 }}>
|
||||
{t('auth_login.oauth_callback_status_error')} {state.callbackError || ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.status && state.status !== 'idle' && (
|
||||
<div className="status-badge" style={{ marginTop: 8 }}>
|
||||
{state.status === 'success'
|
||||
? t(getAuthKey(provider.id, 'oauth_status_success'))
|
||||
: state.status === 'error'
|
||||
? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
|
||||
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
|
||||
</div>
|
||||
)}
|
||||
{state.callbackStatus === 'success' && state.status === 'waiting' && (
|
||||
<div className="status-badge success">
|
||||
{t('auth_login.oauth_callback_status_success')}
|
||||
</div>
|
||||
)}
|
||||
{state.callbackStatus === 'error' && (
|
||||
<div className="status-badge error">
|
||||
{t('auth_login.oauth_callback_status_error')} {state.callbackError || ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.status && state.status !== 'idle' && (
|
||||
<div className="status-badge">
|
||||
{state.status === 'success'
|
||||
? t(getAuthKey(provider.id, 'oauth_status_success'))
|
||||
: state.status === 'error'
|
||||
? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
|
||||
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -459,78 +461,80 @@ export function OAuthPage() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="hint">{t('vertex_import.description')}</div>
|
||||
<Input
|
||||
label={t('vertex_import.location_label')}
|
||||
hint={t('vertex_import.location_hint')}
|
||||
value={vertexState.location}
|
||||
onChange={(e) =>
|
||||
setVertexState((prev) => ({
|
||||
...prev,
|
||||
location: e.target.value
|
||||
}))
|
||||
}
|
||||
placeholder={t('vertex_import.location_placeholder')}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('vertex_import.file_label')}</label>
|
||||
<div className={styles.filePicker}>
|
||||
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
|
||||
{t('vertex_import.choose_file')}
|
||||
</Button>
|
||||
<div
|
||||
className={`${styles.fileName} ${
|
||||
vertexState.fileName ? '' : styles.fileNamePlaceholder
|
||||
}`.trim()}
|
||||
>
|
||||
{vertexState.fileName || t('vertex_import.file_placeholder')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hint">{t('vertex_import.file_hint')}</div>
|
||||
<input
|
||||
ref={vertexFileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleVertexFileChange}
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.cardHint}>{t('vertex_import.description')}</div>
|
||||
<Input
|
||||
label={t('vertex_import.location_label')}
|
||||
hint={t('vertex_import.location_hint')}
|
||||
value={vertexState.location}
|
||||
onChange={(e) =>
|
||||
setVertexState((prev) => ({
|
||||
...prev,
|
||||
location: e.target.value
|
||||
}))
|
||||
}
|
||||
placeholder={t('vertex_import.location_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
{vertexState.error && (
|
||||
<div className="status-badge error" style={{ marginTop: 8 }}>
|
||||
{vertexState.error}
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result && (
|
||||
<div className="connection-box" style={{ marginTop: 12 }}>
|
||||
<div className="label">{t('vertex_import.result_title')}</div>
|
||||
<div className="key-value-list">
|
||||
{vertexState.result.projectId && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('vertex_import.result_project')}</span>
|
||||
<span className="value">{vertexState.result.projectId}</span>
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result.email && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('vertex_import.result_email')}</span>
|
||||
<span className="value">{vertexState.result.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result.location && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('vertex_import.result_location')}</span>
|
||||
<span className="value">{vertexState.result.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result.authFile && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('vertex_import.result_file')}</span>
|
||||
<span className="value">{vertexState.result.authFile}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formItemLabel}>{t('vertex_import.file_label')}</label>
|
||||
<div className={styles.filePicker}>
|
||||
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
|
||||
{t('vertex_import.choose_file')}
|
||||
</Button>
|
||||
<div
|
||||
className={`${styles.fileName} ${
|
||||
vertexState.fileName ? '' : styles.fileNamePlaceholder
|
||||
}`.trim()}
|
||||
>
|
||||
{vertexState.fileName || t('vertex_import.file_placeholder')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cardHintSecondary}>{t('vertex_import.file_hint')}</div>
|
||||
<input
|
||||
ref={vertexFileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleVertexFileChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{vertexState.error && (
|
||||
<div className="status-badge error">
|
||||
{vertexState.error}
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result && (
|
||||
<div className={styles.connectionBox}>
|
||||
<div className={styles.connectionLabel}>{t('vertex_import.result_title')}</div>
|
||||
<div className={styles.keyValueList}>
|
||||
{vertexState.result.projectId && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('vertex_import.result_project')}</span>
|
||||
<span className={styles.keyValueValue}>{vertexState.result.projectId}</span>
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result.email && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('vertex_import.result_email')}</span>
|
||||
<span className={styles.keyValueValue}>{vertexState.result.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result.location && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('vertex_import.result_location')}</span>
|
||||
<span className={styles.keyValueValue}>{vertexState.result.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{vertexState.result.authFile && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('vertex_import.result_file')}</span>
|
||||
<span className={styles.keyValueValue}>{vertexState.result.authFile}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* iFlow Cookie 登录 */}
|
||||
@@ -547,60 +551,61 @@ export function OAuthPage() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="hint">{t('auth_login.iflow_cookie_hint')}</div>
|
||||
<div className="hint" style={{ marginTop: 4 }}>
|
||||
{t('auth_login.iflow_cookie_key_hint')}
|
||||
</div>
|
||||
<div className="form-item" style={{ marginTop: 12 }}>
|
||||
<label className="label">{t('auth_login.iflow_cookie_label')}</label>
|
||||
<Input
|
||||
value={iflowCookie.cookie}
|
||||
onChange={(e) => setIflowCookie((prev) => ({ ...prev, cookie: e.target.value }))}
|
||||
placeholder={t('auth_login.iflow_cookie_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
{iflowCookie.error && (
|
||||
<div
|
||||
className={`status-badge ${iflowCookie.errorType === 'warning' ? 'warning' : 'error'}`}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{iflowCookie.errorType === 'warning'
|
||||
? t('auth_login.iflow_cookie_status_duplicate')
|
||||
: t('auth_login.iflow_cookie_status_error')}{' '}
|
||||
{iflowCookie.error}
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.cardHint}>{t('auth_login.iflow_cookie_hint')}</div>
|
||||
<div className={styles.cardHintSecondary}>
|
||||
{t('auth_login.iflow_cookie_key_hint')}
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result && iflowCookie.result.status === 'ok' && (
|
||||
<div className="connection-box" style={{ marginTop: 12 }}>
|
||||
<div className="label">{t('auth_login.iflow_cookie_result_title')}</div>
|
||||
<div className="key-value-list">
|
||||
{iflowCookie.result.email && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('auth_login.iflow_cookie_result_email')}</span>
|
||||
<span className="value">{iflowCookie.result.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.expired && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('auth_login.iflow_cookie_result_expired')}</span>
|
||||
<span className="value">{iflowCookie.result.expired}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.saved_path && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('auth_login.iflow_cookie_result_path')}</span>
|
||||
<span className="value">{iflowCookie.result.saved_path}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.type && (
|
||||
<div className="key-value-item">
|
||||
<span className="key">{t('auth_login.iflow_cookie_result_type')}</span>
|
||||
<span className="value">{iflowCookie.result.type}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.formItem}>
|
||||
<label className={styles.formItemLabel}>{t('auth_login.iflow_cookie_label')}</label>
|
||||
<Input
|
||||
value={iflowCookie.cookie}
|
||||
onChange={(e) => setIflowCookie((prev) => ({ ...prev, cookie: e.target.value }))}
|
||||
placeholder={t('auth_login.iflow_cookie_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
{iflowCookie.error && (
|
||||
<div
|
||||
className={`status-badge ${iflowCookie.errorType === 'warning' ? 'warning' : 'error'}`}
|
||||
>
|
||||
{iflowCookie.errorType === 'warning'
|
||||
? t('auth_login.iflow_cookie_status_duplicate')
|
||||
: t('auth_login.iflow_cookie_status_error')}{' '}
|
||||
{iflowCookie.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{iflowCookie.result && iflowCookie.result.status === 'ok' && (
|
||||
<div className={styles.connectionBox}>
|
||||
<div className={styles.connectionLabel}>{t('auth_login.iflow_cookie_result_title')}</div>
|
||||
<div className={styles.keyValueList}>
|
||||
{iflowCookie.result.email && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_email')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.expired && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_expired')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.expired}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.saved_path && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_path')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.saved_path}</span>
|
||||
</div>
|
||||
)}
|
||||
{iflowCookie.result.type && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_type')}</span>
|
||||
<span className={styles.keyValueValue}>{iflowCookie.result.type}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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($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);
|
||||
}
|
||||
}
|
||||
|
||||
.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($primary-color, 0.5);
|
||||
background: rgba($primary-color, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
@@ -741,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($primary-color, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.pricesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -827,6 +717,12 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editModalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// Chart Section (80%比例)
|
||||
.chartSection {
|
||||
display: flex;
|
||||
@@ -1014,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: 18px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1117,11 +1117,26 @@ export function buildChartData(
|
||||
*/
|
||||
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
|
||||
|
||||
/**
|
||||
* 状态栏单个格子的详细信息
|
||||
*/
|
||||
export interface StatusBlockDetail {
|
||||
success: number;
|
||||
failure: number;
|
||||
/** 该格子的成功率 (0–1),无请求时为 -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) {
|
||||
|
||||
Reference in New Issue
Block a user