forked from boos/BoosAPI
refactor: extract downstream key drawer
This commit is contained in:
@@ -1,74 +1,30 @@
|
|||||||
import React, { Suspense, lazy, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
import React, { useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import CenteredModal from '../components/CenteredModal.js';
|
import CenteredModal from '../components/CenteredModal.js';
|
||||||
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
|
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
|
||||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
|
||||||
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
|
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
|
||||||
import { useIsMobile } from '../components/useIsMobile.js';
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
|
import DownstreamKeyDrawer from './downstream-keys/DownstreamKeyDrawer.js';
|
||||||
|
import {
|
||||||
|
formatCompactTokens,
|
||||||
|
formatIso,
|
||||||
|
formatMoney,
|
||||||
|
RangeToggle,
|
||||||
|
StatusBadge,
|
||||||
|
TagChips,
|
||||||
|
type Range,
|
||||||
|
type SummaryItem,
|
||||||
|
} from './downstream-keys/shared.js';
|
||||||
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||||
|
|
||||||
const DownstreamKeyTrendChart = lazy(() => import('../components/charts/DownstreamKeyTrendChart.js'));
|
|
||||||
type DownstreamKeyTrendBucket = import('../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket;
|
|
||||||
|
|
||||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||||
|
|
||||||
type Range = '24h' | '7d' | 'all';
|
|
||||||
type Status = 'all' | 'enabled' | 'disabled';
|
type Status = 'all' | 'enabled' | 'disabled';
|
||||||
|
|
||||||
type SummaryItem = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
keyMasked: string;
|
|
||||||
enabled: boolean;
|
|
||||||
description: string | null;
|
|
||||||
groupName: string | null;
|
|
||||||
tags: string[];
|
|
||||||
expiresAt: string | null;
|
|
||||||
maxCost: number | null;
|
|
||||||
usedCost: number;
|
|
||||||
maxRequests: number | null;
|
|
||||||
usedRequests: number;
|
|
||||||
supportedModels: string[];
|
|
||||||
allowedRouteIds: number[];
|
|
||||||
siteWeightMultipliers: Record<number, number>;
|
|
||||||
lastUsedAt: string | null;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
rangeUsage: {
|
|
||||||
totalRequests: number;
|
|
||||||
successRequests: number;
|
|
||||||
failedRequests: number;
|
|
||||||
successRate: number | null;
|
|
||||||
totalTokens: number;
|
|
||||||
totalCost: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type AggregateUsage = {
|
|
||||||
totalRequests: number;
|
|
||||||
successRequests: number;
|
|
||||||
failedRequests: number;
|
|
||||||
successRate: number | null;
|
|
||||||
totalTokens: number;
|
|
||||||
totalCost: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OverviewResponse = {
|
|
||||||
success: boolean;
|
|
||||||
item: SummaryItem;
|
|
||||||
usage: null | {
|
|
||||||
last24h: AggregateUsage | null;
|
|
||||||
last7d: AggregateUsage | null;
|
|
||||||
all: AggregateUsage | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type DownstreamApiKeyItem = {
|
type DownstreamApiKeyItem = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -129,29 +85,6 @@ type BatchMetadataForm = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatIso(value: string | null | undefined): string {
|
|
||||||
const text = (value || '').trim();
|
|
||||||
if (!text) return '--';
|
|
||||||
const date = new Date(text);
|
|
||||||
if (Number.isNaN(date.getTime())) return text;
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMoney(value: number): string {
|
|
||||||
if (!Number.isFinite(value)) return '$0';
|
|
||||||
if (value >= 1) return `$${value.toFixed(3)}`;
|
|
||||||
return `$${value.toFixed(6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCompactTokens(value: number): string {
|
|
||||||
if (!Number.isFinite(value)) return '0';
|
|
||||||
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`;
|
|
||||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
|
|
||||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
|
||||||
return String(Math.trunc(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDateTimeLocal(isoString: string | null | undefined): string {
|
function toDateTimeLocal(isoString: string | null | undefined): string {
|
||||||
if (!isoString) return '';
|
if (!isoString) return '';
|
||||||
const ts = Date.parse(isoString);
|
const ts = Date.parse(isoString);
|
||||||
@@ -393,37 +326,6 @@ function summarizeTags(tags: string[]): string {
|
|||||||
return `${tags[0]} +${tags.length - 1}`;
|
return `${tags[0]} +${tags.length - 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TagChips({
|
|
||||||
tags,
|
|
||||||
accent = false,
|
|
||||||
maxVisible = 3,
|
|
||||||
}: {
|
|
||||||
tags: string[];
|
|
||||||
accent?: boolean;
|
|
||||||
maxVisible?: number;
|
|
||||||
}) {
|
|
||||||
if (!Array.isArray(tags) || tags.length === 0) {
|
|
||||||
return <span className="badge badge-muted" style={{ fontSize: 11 }}>无标签</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visible = tags.slice(0, maxVisible);
|
|
||||||
const hidden = tags.length - visible.length;
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
||||||
{visible.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className={`badge ${accent ? 'badge-info' : 'badge-muted'}`}
|
|
||||||
style={{ fontSize: 11 }}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{hidden > 0 ? <span className="badge badge-muted" style={{ fontSize: 11 }}>{`+${hidden}`}</span> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TagInput({
|
function TagInput({
|
||||||
tags,
|
tags,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -523,25 +425,6 @@ function SummaryMetric({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOverviewUsageByRange(
|
|
||||||
overview: OverviewResponse | null,
|
|
||||||
range: Range,
|
|
||||||
): AggregateUsage | null {
|
|
||||||
if (!overview?.usage) return null;
|
|
||||||
if (range === '24h') return overview.usage.last24h;
|
|
||||||
if (range === '7d') return overview.usage.last7d;
|
|
||||||
return overview.usage.all;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrendChartFallback({ height = 260 }: { height?: number }) {
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: 16 }}>
|
|
||||||
<div className="skeleton" style={{ width: 140, height: 28, borderRadius: 'var(--radius-sm)', marginBottom: 10 }} />
|
|
||||||
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InlineToggle({
|
function InlineToggle({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -595,266 +478,6 @@ function InlineToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RangeToggle({ range, onChange }: { range: Range; onChange: (r: Range) => void }) {
|
|
||||||
const base: React.CSSProperties = {
|
|
||||||
padding: '6px 12px',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'var(--color-bg-card)',
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
};
|
|
||||||
|
|
||||||
const active: React.CSSProperties = {
|
|
||||||
background: 'var(--color-primary)',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: 'var(--color-primary)',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'inline-flex', borderRadius: 'var(--radius-sm)', overflow: 'hidden' }}>
|
|
||||||
<button onClick={() => onChange('24h')} style={{ ...base, ...(range === '24h' ? active : {}), borderRight: 'none' }}>
|
|
||||||
24h
|
|
||||||
</button>
|
|
||||||
<button onClick={() => onChange('7d')} style={{ ...base, ...(range === '7d' ? active : {}), borderRight: 'none' }}>
|
|
||||||
7d
|
|
||||||
</button>
|
|
||||||
<button onClick={() => onChange('all')} style={{ ...base, ...(range === 'all' ? active : {}), borderTopRightRadius: 'var(--radius-sm)', borderBottomRightRadius: 'var(--radius-sm)' }}>
|
|
||||||
全部
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ enabled }: { enabled: boolean }) {
|
|
||||||
return (
|
|
||||||
<span className={`badge ${enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
|
||||||
{enabled ? '启用' : '禁用'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Drawer({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
item,
|
|
||||||
initialRange,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
item: SummaryItem | null;
|
|
||||||
initialRange: Range;
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
|
||||||
const presence = useAnimatedVisibility(open, 220);
|
|
||||||
const [overview, setOverview] = useState<OverviewResponse | null>(null);
|
|
||||||
const [overviewLoading, setOverviewLoading] = useState(false);
|
|
||||||
const [trendRange, setTrendRange] = useState<Range>(initialRange);
|
|
||||||
const [trendLoading, setTrendLoading] = useState(false);
|
|
||||||
const [buckets, setBuckets] = useState<DownstreamKeyTrendBucket[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setTrendRange(initialRange);
|
|
||||||
}, [open, initialRange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !item?.id) return;
|
|
||||||
let cancelled = false;
|
|
||||||
setOverviewLoading(true);
|
|
||||||
api.getDownstreamApiKeyOverview(item.id)
|
|
||||||
.then((res: any) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setOverview(res as OverviewResponse);
|
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
toast.error(err?.message || '加载 Key 概览失败');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setOverviewLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [open, item?.id, toast]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !item?.id) return;
|
|
||||||
let cancelled = false;
|
|
||||||
setTrendLoading(true);
|
|
||||||
api.getDownstreamApiKeyTrend(item.id, { range: trendRange })
|
|
||||||
.then((res: any) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setBuckets(Array.isArray(res?.buckets) ? res.buckets : []);
|
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
toast.error(err?.message || '加载趋势失败');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setTrendLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [open, item?.id, trendRange, toast]);
|
|
||||||
|
|
||||||
if (!presence.shouldRender) return null;
|
|
||||||
|
|
||||||
const currentRangeUsage = resolveOverviewUsageByRange(overview, trendRange) || item?.rangeUsage || null;
|
|
||||||
|
|
||||||
const panel = (
|
|
||||||
<div
|
|
||||||
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
|
||||||
onClick={onClose}
|
|
||||||
style={{ justifyContent: 'flex-end', alignItems: 'stretch', padding: 0 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: 'min(92vw, 560px)',
|
|
||||||
maxWidth: 560,
|
|
||||||
height: '100vh',
|
|
||||||
maxHeight: '100vh',
|
|
||||||
borderRadius: 0,
|
|
||||||
animation: presence.isVisible ? 'drawer-slide-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) both' : 'drawer-slide-out 0.22s cubic-bezier(0.4, 0, 1, 1) both',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="modal-header" style={{ paddingTop: 18, paddingBottom: 12, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span>{item?.name || '--'}</span>
|
|
||||||
<StatusBadge enabled={!!item?.enabled} />
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
|
||||||
{item?.keyMasked || '****'}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>
|
|
||||||
<span className={`badge ${item?.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
|
||||||
{item?.groupName ? `主分组 · ${item.groupName}` : '未分组'}
|
|
||||||
</span>
|
|
||||||
<TagChips tags={item?.tags || []} accent maxVisible={4} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-ghost" onClick={onClose} style={{ border: '1px solid var(--color-border)' }}>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-body" style={{ paddingTop: 0 }}>
|
|
||||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>使用趋势</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>按选定时间窗口查看请求、Tokens 与成本变化。</div>
|
|
||||||
</div>
|
|
||||||
<RangeToggle range={trendRange} onChange={setTrendRange} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense fallback={<TrendChartFallback height={260} />}>
|
|
||||||
<DownstreamKeyTrendChart buckets={buckets} loading={trendLoading} height={260} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
|
||||||
基础信息
|
|
||||||
</div>
|
|
||||||
{overviewLoading ? (
|
|
||||||
<div className="skeleton" style={{ width: '100%', height: 72, borderRadius: 'var(--radius-sm)' }} />
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>最近使用</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.lastUsedAt)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计请求</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{(item?.usedRequests || 0).toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计成本</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatMoney(Number(item?.usedCost || 0))}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>到期时间</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.expiresAt)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>主分组</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{item?.groupName || '未分组'}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ gridColumn: '1 / -1' }}>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 6 }}>标签</div>
|
|
||||||
<TagChips tags={item?.tags || []} accent maxVisible={6} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ padding: 16 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
|
||||||
当前范围汇总
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>Tokens</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{formatCompactTokens(currentRangeUsage?.totalTokens || 0)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>请求数</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{(currentRangeUsage?.totalRequests || 0).toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>成功率</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{currentRangeUsage?.successRate == null ? '--' : `${currentRangeUsage.successRate}%`}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>成本</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{formatMoney(Number(currentRangeUsage?.totalCost || 0))}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{overview?.usage ? (
|
|
||||||
<>
|
|
||||||
<div className="card" style={{ padding: 16, marginTop: 16 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
|
||||||
固定窗口对比
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 12 }}>
|
|
||||||
{[
|
|
||||||
{ label: '24h', data: overview.usage.last24h },
|
|
||||||
{ label: '7d', data: overview.usage.last7d },
|
|
||||||
{ label: '全部', data: overview.usage.all },
|
|
||||||
].map((section) => (
|
|
||||||
<div key={section.label} style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 12 }}>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700, marginBottom: 8 }}>{section.label}</div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>Tokens</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700, marginBottom: 8 }}>{formatCompactTokens(section.data?.totalTokens || 0)}</div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>请求数</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700, marginBottom: 8 }}>{(section.data?.totalRequests || 0).toLocaleString()}</div>
|
|
||||||
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>成功率</div>
|
|
||||||
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{section.data?.successRate == null ? '--' : `${section.data.successRate}%`}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return createPortal(panel, document.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditorModal({
|
function EditorModal({
|
||||||
open,
|
open,
|
||||||
editingItem,
|
editingItem,
|
||||||
@@ -1722,13 +1345,14 @@ export default function DownstreamKeys() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
<ResponsiveFilterPanel
|
||||||
<MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title="筛选下游密钥">
|
isMobile={isMobile}
|
||||||
{filterControls}
|
mobileOpen={showFilters}
|
||||||
</MobileFilterSheet>
|
onMobileClose={() => setShowFilters(false)}
|
||||||
) : (
|
mobileTitle="筛选下游密钥"
|
||||||
filterControls
|
mobileContent={filterControls}
|
||||||
)}
|
desktopContent={filterControls}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -1960,7 +1584,7 @@ export default function DownstreamKeys() {
|
|||||||
: <>确定要删除选中的 <strong>{deleteConfirm?.ids.length || 0}</strong> 个密钥吗?</>}
|
: <>确定要删除选中的 <strong>{deleteConfirm?.ids.length || 0}</strong> 个密钥吗?</>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Drawer
|
<DownstreamKeyDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
item={selectedItem}
|
item={selectedItem}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import React, { Suspense, lazy, useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { api } from '../../api.js';
|
||||||
|
import { useToast } from '../../components/Toast.js';
|
||||||
|
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
|
||||||
|
import {
|
||||||
|
formatCompactTokens,
|
||||||
|
formatIso,
|
||||||
|
formatMoney,
|
||||||
|
type OverviewResponse,
|
||||||
|
RangeToggle,
|
||||||
|
resolveOverviewUsageByRange,
|
||||||
|
StatusBadge,
|
||||||
|
TagChips,
|
||||||
|
TrendChartFallback,
|
||||||
|
type Range,
|
||||||
|
type SummaryItem,
|
||||||
|
} from './shared.js';
|
||||||
|
|
||||||
|
const DownstreamKeyTrendChart = lazy(() => import('../../components/charts/DownstreamKeyTrendChart.js'));
|
||||||
|
type DownstreamKeyTrendBucket = import('../../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket;
|
||||||
|
|
||||||
|
type DownstreamKeyDrawerProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
item: SummaryItem | null;
|
||||||
|
initialRange: Range;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DownstreamKeyDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
item,
|
||||||
|
initialRange,
|
||||||
|
}: DownstreamKeyDrawerProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const presence = useAnimatedVisibility(open, 220);
|
||||||
|
const [overview, setOverview] = useState<OverviewResponse | null>(null);
|
||||||
|
const [overviewLoading, setOverviewLoading] = useState(false);
|
||||||
|
const [trendRange, setTrendRange] = useState<Range>(initialRange);
|
||||||
|
const [trendLoading, setTrendLoading] = useState(false);
|
||||||
|
const [buckets, setBuckets] = useState<DownstreamKeyTrendBucket[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setTrendRange(initialRange);
|
||||||
|
}, [open, initialRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !item?.id) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setOverviewLoading(true);
|
||||||
|
api.getDownstreamApiKeyOverview(item.id)
|
||||||
|
.then((res: any) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setOverview(res as OverviewResponse);
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
toast.error(err?.message || '加载 Key 概览失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setOverviewLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, item?.id, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !item?.id) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setTrendLoading(true);
|
||||||
|
api.getDownstreamApiKeyTrend(item.id, { range: trendRange })
|
||||||
|
.then((res: any) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setBuckets(Array.isArray(res?.buckets) ? res.buckets : []);
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
toast.error(err?.message || '加载趋势失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTrendLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, item?.id, trendRange, toast]);
|
||||||
|
|
||||||
|
if (!presence.shouldRender) return null;
|
||||||
|
|
||||||
|
const currentRangeUsage = resolveOverviewUsageByRange(overview, trendRange) || item?.rangeUsage || null;
|
||||||
|
|
||||||
|
const panel = (
|
||||||
|
<div
|
||||||
|
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ justifyContent: 'flex-end', alignItems: 'stretch', padding: 0 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: 'min(92vw, 560px)',
|
||||||
|
maxWidth: 560,
|
||||||
|
height: '100vh',
|
||||||
|
maxHeight: '100vh',
|
||||||
|
borderRadius: 0,
|
||||||
|
animation: presence.isVisible ? 'drawer-slide-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) both' : 'drawer-slide-out 0.22s cubic-bezier(0.4, 0, 1, 1) both',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="modal-header" style={{ paddingTop: 18, paddingBottom: 12, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span>{item?.name || '--'}</span>
|
||||||
|
<StatusBadge enabled={!!item?.enabled} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
{item?.keyMasked || '****'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>
|
||||||
|
<span className={`badge ${item?.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||||
|
{item?.groupName ? `主分组 · ${item.groupName}` : '未分组'}
|
||||||
|
</span>
|
||||||
|
<TagChips tags={item?.tags || []} accent maxVisible={4} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost" onClick={onClose} style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body" style={{ paddingTop: 0 }}>
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>使用趋势</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>按选定时间窗口查看请求、Tokens 与成本变化。</div>
|
||||||
|
</div>
|
||||||
|
<RangeToggle range={trendRange} onChange={setTrendRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback={<TrendChartFallback height={260} />}>
|
||||||
|
<DownstreamKeyTrendChart buckets={buckets} loading={trendLoading} height={260} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
||||||
|
基础信息
|
||||||
|
</div>
|
||||||
|
{overviewLoading ? (
|
||||||
|
<div className="skeleton" style={{ width: '100%', height: 72, borderRadius: 'var(--radius-sm)' }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>最近使用</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.lastUsedAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计请求</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{(item?.usedRequests || 0).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>累计成本</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatMoney(Number(item?.usedCost || 0))}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>到期时间</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{formatIso(item?.expiresAt)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>主分组</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>{item?.groupName || '未分组'}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 6 }}>标签</div>
|
||||||
|
<TagChips tags={item?.tags || []} accent maxVisible={6} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
||||||
|
当前范围汇总
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 12 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>Tokens</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{formatCompactTokens(currentRangeUsage?.totalTokens || 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>请求数</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{(currentRangeUsage?.totalRequests || 0).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>成功率</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{currentRangeUsage?.successRate == null ? '--' : `${currentRangeUsage.successRate}%`}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>成本</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{formatMoney(Number(currentRangeUsage?.totalCost || 0))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overview?.usage ? (
|
||||||
|
<div className="card" style={{ padding: 16, marginTop: 16 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 10 }}>
|
||||||
|
固定窗口对比
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 12 }}>
|
||||||
|
{[
|
||||||
|
{ label: '24h', data: overview.usage.last24h },
|
||||||
|
{ label: '7d', data: overview.usage.last7d },
|
||||||
|
{ label: '全部', data: overview.usage.all },
|
||||||
|
].map((section) => (
|
||||||
|
<div key={section.label} style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 12 }}>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700, marginBottom: 8 }}>{section.label}</div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>Tokens</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700, marginBottom: 8 }}>{formatCompactTokens(section.data?.totalTokens || 0)}</div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>请求数</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700, marginBottom: 8 }}>{(section.data?.totalRequests || 0).toLocaleString()}</div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', marginBottom: 4 }}>成功率</div>
|
||||||
|
<div style={{ color: 'var(--color-text-primary)', fontWeight: 700 }}>{section.data?.successRate == null ? '--' : `${section.data.successRate}%`}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(panel, document.body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export type Range = '24h' | '7d' | 'all';
|
||||||
|
|
||||||
|
export type SummaryItem = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
keyMasked: string;
|
||||||
|
enabled: boolean;
|
||||||
|
description: string | null;
|
||||||
|
groupName: string | null;
|
||||||
|
tags: string[];
|
||||||
|
expiresAt: string | null;
|
||||||
|
maxCost: number | null;
|
||||||
|
usedCost: number;
|
||||||
|
maxRequests: number | null;
|
||||||
|
usedRequests: number;
|
||||||
|
supportedModels: string[];
|
||||||
|
allowedRouteIds: number[];
|
||||||
|
siteWeightMultipliers: Record<number, number>;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
rangeUsage: {
|
||||||
|
totalRequests: number;
|
||||||
|
successRequests: number;
|
||||||
|
failedRequests: number;
|
||||||
|
successRate: number | null;
|
||||||
|
totalTokens: number;
|
||||||
|
totalCost: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AggregateUsage = {
|
||||||
|
totalRequests: number;
|
||||||
|
successRequests: number;
|
||||||
|
failedRequests: number;
|
||||||
|
successRate: number | null;
|
||||||
|
totalTokens: number;
|
||||||
|
totalCost: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OverviewResponse = {
|
||||||
|
success: boolean;
|
||||||
|
item: SummaryItem;
|
||||||
|
usage: null | {
|
||||||
|
last24h: AggregateUsage | null;
|
||||||
|
last7d: AggregateUsage | null;
|
||||||
|
all: AggregateUsage | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatIso(value: string | null | undefined): string {
|
||||||
|
const text = (value || '').trim();
|
||||||
|
if (!text) return '--';
|
||||||
|
const date = new Date(text);
|
||||||
|
if (Number.isNaN(date.getTime())) return text;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(value: number): string {
|
||||||
|
if (!Number.isFinite(value)) return '$0';
|
||||||
|
if (value >= 1) return `$${value.toFixed(3)}`;
|
||||||
|
return `$${value.toFixed(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCompactTokens(value: number): string {
|
||||||
|
if (!Number.isFinite(value)) return '0';
|
||||||
|
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`;
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||||
|
return String(Math.trunc(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagChips({
|
||||||
|
tags,
|
||||||
|
accent = false,
|
||||||
|
maxVisible = 3,
|
||||||
|
}: {
|
||||||
|
tags: string[];
|
||||||
|
accent?: boolean;
|
||||||
|
maxVisible?: number;
|
||||||
|
}) {
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
return <span className="badge badge-muted" style={{ fontSize: 11 }}>无标签</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = tags.slice(0, maxVisible);
|
||||||
|
const hidden = tags.length - visible.length;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{visible.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={`badge ${accent ? 'badge-info' : 'badge-muted'}`}
|
||||||
|
style={{ fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{hidden > 0 ? <span className="badge badge-muted" style={{ fontSize: 11 }}>{`+${hidden}`}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOverviewUsageByRange(
|
||||||
|
overview: OverviewResponse | null,
|
||||||
|
range: Range,
|
||||||
|
): AggregateUsage | null {
|
||||||
|
if (!overview?.usage) return null;
|
||||||
|
if (range === '24h') return overview.usage.last24h;
|
||||||
|
if (range === '7d') return overview.usage.last7d;
|
||||||
|
return overview.usage.all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendChartFallback({ height = 260 }: { height?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16 }}>
|
||||||
|
<div className="skeleton" style={{ width: 140, height: 28, borderRadius: 'var(--radius-sm)', marginBottom: 10 }} />
|
||||||
|
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RangeToggle({ range, onChange }: { range: Range; onChange: (r: Range) => void }) {
|
||||||
|
const base: React.CSSProperties = {
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const active: React.CSSProperties = {
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: '#fff',
|
||||||
|
borderColor: 'var(--color-primary)',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'inline-flex', borderRadius: 'var(--radius-sm)', overflow: 'hidden' }}>
|
||||||
|
<button type="button" onClick={() => onChange('24h')} style={{ ...base, ...(range === '24h' ? active : {}), borderRight: 'none' }}>
|
||||||
|
24h
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onChange('7d')} style={{ ...base, ...(range === '7d' ? active : {}), borderRight: 'none' }}>
|
||||||
|
7d
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onChange('all')} style={{ ...base, ...(range === 'all' ? active : {}), borderTopRightRadius: 'var(--radius-sm)', borderBottomRightRadius: 'var(--radius-sm)' }}>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ enabled }: { enabled: boolean }) {
|
||||||
|
return (
|
||||||
|
<span className={`badge ${enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||||
|
{enabled ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('DownstreamKeys drawer extraction', () => {
|
||||||
|
it('delegates the downstream key detail drawer to a dedicated component', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DownstreamKeys.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
expect(source).toContain("import DownstreamKeyDrawer from './downstream-keys/DownstreamKeyDrawer.js'");
|
||||||
|
expect(source).not.toContain('function Drawer({');
|
||||||
|
expect(source).not.toContain('return createPortal(panel, document.body);');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user