From dd914b327ce28648b0e704eef3b03ff2e10c87ed Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 14:28:37 +0800 Subject: [PATCH] refactor: extract downstream key drawer --- src/web/pages/DownstreamKeys.tsx | 420 +----------------- .../downstream-keys/DownstreamKeyDrawer.tsx | 241 ++++++++++ src/web/pages/downstream-keys/shared.tsx | 164 +++++++ ...downstreamKeys.drawer.architecture.test.ts | 13 + 4 files changed, 440 insertions(+), 398 deletions(-) create mode 100644 src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx create mode 100644 src/web/pages/downstream-keys/shared.tsx create mode 100644 src/web/pages/downstreamKeys.drawer.architecture.test.ts diff --git a/src/web/pages/DownstreamKeys.tsx b/src/web/pages/DownstreamKeys.tsx index c2ec3f5..1dd8ae7 100644 --- a/src/web/pages/DownstreamKeys.tsx +++ b/src/web/pages/DownstreamKeys.tsx @@ -1,74 +1,30 @@ -import React, { Suspense, lazy, useDeferredValue, useEffect, useMemo, useState } from 'react'; -import { createPortal } from 'react-dom'; +import React, { useDeferredValue, useEffect, useMemo, useState } from 'react'; import { api } from '../api.js'; import CenteredModal from '../components/CenteredModal.js'; import DeleteConfirmModal from '../components/DeleteConfirmModal.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 { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; -import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useIsMobile } from '../components/useIsMobile.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'; -const DownstreamKeyTrendChart = lazy(() => import('../components/charts/DownstreamKeyTrendChart.js')); -type DownstreamKeyTrendBucket = import('../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket; - const PROXY_TOKEN_PREFIX = 'sk-'; - -type Range = '24h' | '7d' | 'all'; 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; - 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 = { id: number; name: string; @@ -129,29 +85,6 @@ type BatchMetadataForm = { 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 { if (!isoString) return ''; const ts = Date.parse(isoString); @@ -393,37 +326,6 @@ function summarizeTags(tags: string[]): string { 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 无标签; - } - - const visible = tags.slice(0, maxVisible); - const hidden = tags.length - visible.length; - return ( -
- {visible.map((tag) => ( - - {tag} - - ))} - {hidden > 0 ? {`+${hidden}`} : null} -
- ); -} - function TagInput({ tags, 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 ( -
-
-
-
- ); -} - function InlineToggle({ value, 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 ( -
- - - -
- ); -} - -function StatusBadge({ enabled }: { enabled: boolean }) { - return ( - - {enabled ? '启用' : '禁用'} - - ); -} - -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(null); - const [overviewLoading, setOverviewLoading] = useState(false); - const [trendRange, setTrendRange] = useState(initialRange); - const [trendLoading, setTrendLoading] = useState(false); - const [buckets, setBuckets] = useState([]); - - 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 = ( -
-
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', - }} - > -
-
-
- {item?.name || '--'} - -
-
- {item?.keyMasked || '****'} -
-
- - {item?.groupName ? `主分组 · ${item.groupName}` : '未分组'} - - -
-
- -
- -
-
-
-
-
使用趋势
-
按选定时间窗口查看请求、Tokens 与成本变化。
-
- -
- - }> - - -
- -
-
- 基础信息 -
- {overviewLoading ? ( -
- ) : ( -
-
-
最近使用
-
{formatIso(item?.lastUsedAt)}
-
-
-
累计请求
-
{(item?.usedRequests || 0).toLocaleString()}
-
-
-
累计成本
-
{formatMoney(Number(item?.usedCost || 0))}
-
-
-
到期时间
-
{formatIso(item?.expiresAt)}
-
-
-
主分组
-
{item?.groupName || '未分组'}
-
-
-
标签
- -
-
- )} -
- -
-
- 当前范围汇总 -
-
-
-
Tokens
-
{formatCompactTokens(currentRangeUsage?.totalTokens || 0)}
-
-
-
请求数
-
{(currentRangeUsage?.totalRequests || 0).toLocaleString()}
-
-
-
成功率
-
{currentRangeUsage?.successRate == null ? '--' : `${currentRangeUsage.successRate}%`}
-
-
-
成本
-
{formatMoney(Number(currentRangeUsage?.totalCost || 0))}
-
-
-
- - {overview?.usage ? ( - <> -
-
- 固定窗口对比 -
-
- {[ - { label: '24h', data: overview.usage.last24h }, - { label: '7d', data: overview.usage.last7d }, - { label: '全部', data: overview.usage.all }, - ].map((section) => ( -
-
{section.label}
-
Tokens
-
{formatCompactTokens(section.data?.totalTokens || 0)}
-
请求数
-
{(section.data?.totalRequests || 0).toLocaleString()}
-
成功率
-
{section.data?.successRate == null ? '--' : `${section.data.successRate}%`}
-
- ))} -
-
- - ) : null} -
-
-
- ); - - return createPortal(panel, document.body); -} - function EditorModal({ open, editingItem, @@ -1722,13 +1345,14 @@ export default function DownstreamKeys() {
)}
- {isMobile ? ( - setShowFilters(false)} title="筛选下游密钥"> - {filterControls} - - ) : ( - filterControls - )} + setShowFilters(false)} + mobileTitle="筛选下游密钥" + mobileContent={filterControls} + desktopContent={filterControls} + />
{loading ? ( @@ -1960,7 +1584,7 @@ export default function DownstreamKeys() { : <>确定要删除选中的 {deleteConfirm?.ids.length || 0} 个密钥吗?} /> - setDrawerOpen(false)} item={selectedItem} diff --git a/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx b/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx new file mode 100644 index 0000000..6ef3d3e --- /dev/null +++ b/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx @@ -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(null); + const [overviewLoading, setOverviewLoading] = useState(false); + const [trendRange, setTrendRange] = useState(initialRange); + const [trendLoading, setTrendLoading] = useState(false); + const [buckets, setBuckets] = useState([]); + + 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 = ( +
+
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', + }} + > +
+
+
+ {item?.name || '--'} + +
+
+ {item?.keyMasked || '****'} +
+
+ + {item?.groupName ? `主分组 · ${item.groupName}` : '未分组'} + + +
+
+ +
+ +
+
+
+
+
使用趋势
+
按选定时间窗口查看请求、Tokens 与成本变化。
+
+ +
+ + }> + + +
+ +
+
+ 基础信息 +
+ {overviewLoading ? ( +
+ ) : ( +
+
+
最近使用
+
{formatIso(item?.lastUsedAt)}
+
+
+
累计请求
+
{(item?.usedRequests || 0).toLocaleString()}
+
+
+
累计成本
+
{formatMoney(Number(item?.usedCost || 0))}
+
+
+
到期时间
+
{formatIso(item?.expiresAt)}
+
+
+
主分组
+
{item?.groupName || '未分组'}
+
+
+
标签
+ +
+
+ )} +
+ +
+
+ 当前范围汇总 +
+
+
+
Tokens
+
{formatCompactTokens(currentRangeUsage?.totalTokens || 0)}
+
+
+
请求数
+
{(currentRangeUsage?.totalRequests || 0).toLocaleString()}
+
+
+
成功率
+
{currentRangeUsage?.successRate == null ? '--' : `${currentRangeUsage.successRate}%`}
+
+
+
成本
+
{formatMoney(Number(currentRangeUsage?.totalCost || 0))}
+
+
+
+ + {overview?.usage ? ( +
+
+ 固定窗口对比 +
+
+ {[ + { label: '24h', data: overview.usage.last24h }, + { label: '7d', data: overview.usage.last7d }, + { label: '全部', data: overview.usage.all }, + ].map((section) => ( +
+
{section.label}
+
Tokens
+
{formatCompactTokens(section.data?.totalTokens || 0)}
+
请求数
+
{(section.data?.totalRequests || 0).toLocaleString()}
+
成功率
+
{section.data?.successRate == null ? '--' : `${section.data.successRate}%`}
+
+ ))} +
+
+ ) : null} +
+
+
+ ); + + return createPortal(panel, document.body); +} diff --git a/src/web/pages/downstream-keys/shared.tsx b/src/web/pages/downstream-keys/shared.tsx new file mode 100644 index 0000000..e5cd6da --- /dev/null +++ b/src/web/pages/downstream-keys/shared.tsx @@ -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; + 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 无标签; + } + + const visible = tags.slice(0, maxVisible); + const hidden = tags.length - visible.length; + return ( +
+ {visible.map((tag) => ( + + {tag} + + ))} + {hidden > 0 ? {`+${hidden}`} : null} +
+ ); +} + +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 ( +
+
+
+
+ ); +} + +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 ( +
+ + + +
+ ); +} + +export function StatusBadge({ enabled }: { enabled: boolean }) { + return ( + + {enabled ? '启用' : '禁用'} + + ); +} diff --git a/src/web/pages/downstreamKeys.drawer.architecture.test.ts b/src/web/pages/downstreamKeys.drawer.architecture.test.ts new file mode 100644 index 0000000..73ddeac --- /dev/null +++ b/src/web/pages/downstreamKeys.drawer.architecture.test.ts @@ -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);'); + }); +});