chore: initial commit

This commit is contained in:
2026-10-23 18:35:44 +08:00
commit f6921b7b23
69 changed files with 12148 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
*.tsbuildinfo
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><meta name="theme-color" content="#181a34"/><title>AI Router · 管理台</title><script src="/theme-init.js"></script></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
+1883
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "ai-router-admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.3",
"vite": "^6.1.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
(function () {
try {
var t = localStorage.getItem('theme')
if (t !== 'light' && t !== 'dark') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
document.documentElement.dataset.theme = t
} catch (e) {}
})()
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useState } from 'react'
import { api, APIError, type Channel, type Key, type Log, type Stats } from './api'
import { emptyFilter, type Action, type Filter, type Page } from './common'
import { applyTheme, initialTheme, type Theme } from './theme'
import { Filters } from './components/Filters'
import { Shell } from './components/Shell'
import { Feedback } from './components/ui'
import { ChannelsPage } from './pages/Channels'
import { KeysPage } from './pages/Keys'
import { Login } from './pages/Login'
import { LogsPage } from './pages/Logs'
import { ModelsPage } from './pages/Models'
import { Overview } from './pages/Overview'
export default function App() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const [theme, setTheme] = useState<Theme>(initialTheme)
const [auth, setAuth] = useState<boolean | null>(null)
const [page, setPage] = useState<Page>('概览')
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [loading, setLoading] = useState(false)
const [rev, setRev] = useState(0)
const [channels, setChannels] = useState<Channel[]>([])
const [keys, setKeys] = useState<Key[]>([])
const [stats, setStats] = useState<Stats | null>(null)
const [logs, setLogs] = useState<Log[]>([])
const [filter, setFilter] = useState<Filter>(emptyFilter)
const [query, setQuery] = useState('')
useEffect(() => applyTheme(theme), [theme])
function fail(e: unknown) {
setError(e instanceof Error ? e.message : '操作失败,请重试。')
// 会话失效后回到登录页,各页面的弹窗状态随组件卸载自动清理
if (e instanceof APIError && e.status === 401) setAuth(false)
}
const action: Action = fn => {
setBusy(true)
setError('')
setNotice('')
void fn().catch(fail).finally(() => setBusy(false))
}
async function loadSession() {
const session = await api<{ authenticated: boolean }>('/session')
setAuth(session.authenticated)
}
useEffect(() => {
loadSession().catch(e => {
setAuth(false)
if (!(e instanceof APIError && e.status === 401)) fail(e)
})
}, [])
useEffect(() => {
if (!auth) return
let alive = true
setLoading(true)
setError('')
setStats(null)
setLogs([])
const statsParams = new URLSearchParams(query)
statsParams.set('timezone', timezone || 'UTC')
Promise.all([
api<Channel[]>('/channels'),
api<Key[]>('/keys'),
page === '概览' || page === 'Keys' ? api<Stats>('/stats?' + statsParams) : Promise.resolve(null),
page === '记录' ? api<Log[]>('/logs' + query) : Promise.resolve([] as Log[]),
])
.then(([c, k, s, l]) => { if (alive) { setChannels(c ?? []); setKeys(k ?? []); setStats(s); setLogs(l ?? []) } })
.catch(e => { if (alive) fail(e) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [auth, page, rev, query, timezone])
const refresh = () => setRev(v => v + 1)
/** 从 Keys 页跳转:概览页按该 Key 筛选统计。 */
function viewKeyStats(k: Key) {
setFilter({ ...filter, key_id: k.id })
const p = new URLSearchParams(query)
p.set('key_id', k.id)
setQuery('?' + p)
setPage('概览')
}
if (auth === null) return <div className="login"><p className="muted">正在检查登录状态…</p></div>
if (!auth) {
return <Login busy={busy} error={error} notice={notice} action={action} onSuccess={loadSession} />
}
return (
<Shell
page={page}
theme={theme}
busy={busy}
loading={loading}
onNavigate={p => { setPage(p); setNotice('') }}
onRefresh={refresh}
onThemeChange={setTheme}
onLogout={() => action(async () => { await api('/logout', 'POST'); setAuth(false) })}
>
<Feedback error={error} notice={notice} />
{(page === '概览' || page === '记录') && (
<Filters
keys={keys}
filter={filter}
loading={loading}
timezone={timezone}
onChange={setFilter}
onQuery={q => { setQuery(q); setError('') }}
onRefresh={refresh}
onError={fail}
/>
)}
{loading && <div className="panel empty" role="status">正在加载数据…</div>}
{/* 加载时隐藏而非卸载页面:保留弹窗与表单等本地状态(如创建后展示密钥的弹窗) */}
<div style={loading ? { display: 'none' } : undefined}>
{page === '概览' && <Overview stats={stats} channels={channels} />}
{page === '模型' && <ModelsPage channels={channels} />}
{page === '渠道' && (
<ChannelsPage
channels={channels}
busy={busy}
error={error}
notice={notice}
action={action}
refresh={refresh}
notify={setNotice}
clearError={() => setError('')}
/>
)}
{page === 'Keys' && (
<KeysPage
keys={keys}
stats={stats}
channels={channels}
timezone={timezone}
busy={busy}
error={error}
notice={notice}
action={action}
refresh={refresh}
notify={setNotice}
clearError={() => setError('')}
onViewStats={viewKeyStats}
/>
)}
{page === '记录' && <LogsPage logs={logs} channels={channels} />}
</div>
</Shell>
)
}
+14
View File
@@ -0,0 +1,14 @@
export type Channel = { id: string; name: string; base_url: string; protocol: string; enabled: boolean; models: Record<string, string> }
export type Key = { id: string; name: string; prefix: string; enabled: boolean; revoked?: boolean; all_channels: boolean; channels: string[]; expires_at: string; created_at: string }
export type Usage = { requests: number; success: number; failed: number; input_tokens: number; output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; unknown_usage: number }
export type Stats = { summary: Usage; by_key: (Usage & { key_id: string; name: string })[]; daily: (Usage & { day: string })[]; by_model: (Usage & { channel: string; model: string })[] }
export type Log = { id: string; created_at: string; key_id: string; key_name: string; channel: string; request_model: string; actual_model: string; downstream: string; upstream: string; status: number; duration_ms: number; stream: boolean; input_tokens: number; output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; usage_known: boolean; error: string }
export class APIError extends Error { constructor(message: string, public status: number) { super(message) } }
export async function api<T>(path: string, method = 'GET', body?: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch('/admin/api' + path, { method, signal, credentials: 'same-origin', headers: method === 'GET' ? {} : { 'Content-Type': 'application/json', 'X-Requested-With': 'ai-router' }, body: body === undefined ? undefined : JSON.stringify(body) })
const text = await res.text()
let data
try { data = text ? JSON.parse(text) : undefined } catch { if (res.ok) throw new Error('服务器返回了无法识别的数据,请稍后重试。') }
if (!res.ok) throw new APIError(typeof data?.error === 'string' ? data.error : typeof data?.message === 'string' ? data.message : res.status === 401 ? '登录已失效,请重新登录。' : '请求失败(' + res.status + '),请稍后重试。', res.status)
return data as T
}
+35
View File
@@ -0,0 +1,35 @@
import type { Channel, Usage } from './api'
export type Page = '概览' | '渠道' | '模型' | 'Keys' | '记录'
export const PAGES: Page[] = ['概览', '渠道', '模型', 'Keys', '记录']
export const PAGE_DESC: Record<Page, string> = {
概览: '查看请求趋势与模型用量。',
渠道: '配置上游服务与模型映射。',
模型: '汇总各渠道选定的展示模型;/v1/models 仅返回当前 Key 有权访问的启用渠道模型。',
Keys: '按渠道授权,安全管理每一个访问凭证。',
记录: '查看最近请求明细,快速定位问题。',
}
export interface Filter {
key_id: string
from: string
to: string
}
export const emptyFilter: Filter = { key_id: '', from: '', to: '' }
/** 包装异步操作:统一 busy 状态与错误处理,见 App。 */
export type Action = (fn: () => Promise<void>) => void
export const num = (v: number | null | undefined) => (v == null ? '—' : v.toLocaleString('zh-CN'))
export function channelName(channels: Channel[], id: string) {
return channels.find(c => c.id === id)?.name || id
}
export const usageHeads = ['请求数', '输入 tokens', '输出 tokens', '缓存读取', '缓存写入']
export const usageCells = (u: Usage) =>
[u.requests, u.input_tokens, u.output_tokens, u.cache_read_tokens, u.cache_write_tokens].map(num)
+64
View File
@@ -0,0 +1,64 @@
import type { FormEvent } from 'react'
import type { Key } from '../api'
import { emptyFilter, type Filter } from '../common'
import { filterParams, quickRange } from '../time'
import { IconGlobe } from './icons'
interface FiltersProps {
keys: Key[]
filter: Filter
loading: boolean
timezone: string
onChange: (f: Filter) => void
/** 提交新的查询串(含前导 ?,空串表示无筛选),同时清除已有错误。 */
onQuery: (q: string) => void
onRefresh: () => void
onError: (e: unknown) => void
}
export function Filters({ keys, filter, loading, timezone, onChange, onQuery, onRefresh, onError }: FiltersProps) {
function submit(e: FormEvent) {
e.preventDefault()
try {
onQuery('?' + filterParams(filter))
onRefresh()
} catch (err) {
onError(err)
}
}
function quick(days: number) {
const range = quickRange(days)
onChange({ ...filter, from: range.fromInput, to: range.toInput })
const p = new URLSearchParams({ from: range.from, to: range.to })
if (filter.key_id) p.set('key_id', filter.key_id)
onQuery('?' + p)
}
return (
<form className="filters panel" onSubmit={submit}>
<label>访问密钥
<select value={filter.key_id} onChange={e => onChange({ ...filter, key_id: e.target.value })}>
<option value="">全部 Keys</option>
{keys.map(k => <option key={k.id} value={k.id}>{k.name} · {k.prefix}</option>)}
</select>
</label>
<label>开始时间
<input type="datetime-local" value={filter.from} onChange={e => onChange({ ...filter, from: e.target.value })} />
</label>
<label>结束时间
<input type="datetime-local" value={filter.to} onChange={e => onChange({ ...filter, to: e.target.value })} />
</label>
<div className="filters-actions">
<button className="primary" disabled={loading}>查询</button>
<button type="button" onClick={() => { onChange(emptyFilter); onQuery('') }}>重置</button>
</div>
<div className="quick">
{[1, 7, 30].map(days => (
<button type="button" key={days} onClick={() => quick(days)}>{days === 1 ? '今天' : `近 ${days} 天`}</button>
))}
<small className="tz-hint" title="时间筛选与每日汇总均按浏览器本地时区解释"><IconGlobe />{timezone}</small>
</div>
</form>
)
}
+76
View File
@@ -0,0 +1,76 @@
import type { ReactNode } from 'react'
import { PAGES, PAGE_DESC, type Page } from '../common'
import type { Theme } from '../theme'
import { IconChannel, IconKey, IconLog, IconLogout, IconModel, IconMonitor, IconMoon, IconOverview, IconRefresh, IconRoute, IconSun } from './icons'
const PAGE_ICONS: Record<Page, ReactNode> = {
概览: <IconOverview />,
渠道: <IconChannel />,
模型: <IconModel />,
Keys: <IconKey />,
记录: <IconLog />,
}
const THEMES: { value: Theme; label: string; icon: ReactNode }[] = [
{ value: 'system', label: '跟随系统', icon: <IconMonitor /> },
{ value: 'light', label: '浅色模式', icon: <IconSun /> },
{ value: 'dark', label: '深色模式', icon: <IconMoon /> },
]
interface ShellProps {
page: Page
theme: Theme
busy: boolean
loading: boolean
onNavigate: (p: Page) => void
onRefresh: () => void
onThemeChange: (theme: Theme) => void
onLogout: () => void
children: ReactNode
}
export function Shell({ page, theme, busy, loading, onNavigate, onRefresh, onThemeChange, onLogout, children }: ShellProps) {
return (
<div className="shell">
<aside>
<div className="brand">
<span className="mark"><IconRoute width={22} height={22} strokeWidth={2} /></span>
<div>AI Router<small>模型路由管理台</small></div>
</div>
<nav aria-label="主导航">
{PAGES.map(p => (
<button key={p} aria-current={page === p ? 'page' : undefined} className={page === p ? 'active' : ''} onClick={() => onNavigate(p)}>
{PAGE_ICONS[p]}{p}
</button>
))}
</nav>
<div className="aside-bottom">
<div className="theme-seg" role="group" aria-label="主题模式">
{THEMES.map(t => (
<button
key={t.value}
type="button"
title={t.label}
aria-label={t.label}
aria-pressed={theme === t.value}
className={theme === t.value ? 'active' : ''}
onClick={() => onThemeChange(t.value)}
>{t.icon}</button>
))}
</div>
<button type="button" disabled={busy} onClick={onLogout}><IconLogout />退出登录</button>
</div>
</aside>
<main>
<header>
<div>
<h1>{page === 'Keys' ? '访问密钥' : page}</h1>
<p className="muted">{PAGE_DESC[page]}</p>
</div>
<button type="button" disabled={loading || busy} onClick={onRefresh}><IconRefresh />刷新</button>
</header>
{children}
</main>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useLayoutEffect, useRef, useState } from 'react'
import type { Usage } from '../api'
import { num } from '../common'
/**
* 每日用量堆叠柱状图(纯 CSS 实现,无第三方依赖)。
* 按 README 的口径,缓存读/写是输入 tokens 的细分项,不能再与输入相加,
* 因此图表只堆叠 输入 + 输出 构成真实总量;缓存明细见下方表格。
* X 轴标签数量随容器宽度自适应,窄屏(手机)自动减少,避免重叠。
*/
export function UsageChart({ data }: { data: (Usage & { day: string })[] }) {
const plotRef = useRef<HTMLDivElement>(null)
const [plotWidth, setPlotWidth] = useState(720)
useLayoutEffect(() => {
const el = plotRef.current
if (!el) return
const ro = new ResizeObserver(entries => setPlotWidth(entries[0].contentRect.width))
ro.observe(el)
return () => ro.disconnect()
}, [])
if (!data.length) return <div className="chart-empty">所选时间范围内暂无用量数据。</div>
const totals = data.map(d => d.input_tokens + d.output_tokens)
const max = Math.max(...totals, 1)
// max 较小时四舍五入会产生重复刻度,去重保持纵轴整洁
const ticks = [...new Set([1, 0.75, 0.5, 0.25].map(r => Math.round(max * r)))]
// 天数较多时抽稀 X 轴标签;每个标签约需 52px,按容器实际宽度定上限
const maxLabels = Math.max(3, Math.min(12, Math.floor(plotWidth / 52)))
const labelStep = Math.ceil(data.length / maxLabels)
return (
<div className="chart">
<div className="chart-plot" ref={plotRef} role="img" aria-label="每日 tokens 用量柱状图">
{ticks.map((t, i) => (
<div className="chart-grid" key={i} style={{ bottom: `${(t / max) * 100}%` }}>
<span>{compact(t)}</span>
</div>
))}
<div className="chart-bars">
{data.map((d, i) => {
const total = totals[i]
return (
<div className="chart-col" key={d.day} title={`${d.day}\n输入 ${num(d.input_tokens)} · 输出 ${num(d.output_tokens)}\n请求 ${num(d.requests)} 次`}>
<div className="chart-bar">
<i className="seg-output" style={{ height: `${(d.output_tokens / max) * 100}%` }} />
<i className="seg-input" style={{ height: `${(d.input_tokens / max) * 100}%` }} />
</div>
{(i % labelStep === 0 || i === data.length - 1) && <span className="chart-x">{d.day.slice(5)}</span>}
</div>
)
})}
</div>
</div>
<div className="chart-legend">
<span><i className="seg-input" />输入 tokens</span>
<span><i className="seg-output" />输出 tokens</span>
</div>
</div>
)
}
function compact(v: number): string {
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'k'
return String(v)
}
+209
View File
@@ -0,0 +1,209 @@
import type { SVGProps } from 'react'
type IconProps = SVGProps<SVGSVGElement>
function base(props: IconProps): IconProps {
return {
width: 16,
height: 16,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': true,
...props,
}
}
/** 路由/分流图标,用作品牌标识。 */
export const IconRoute = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 7h10.5l-3-3" />
<path d="M20 17H9.5l3 3" />
<circle cx="4" cy="7" r="2" />
<circle cx="20" cy="17" r="2" />
</svg>
)
export const IconOverview = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3 3v18h18" />
<path d="M8 16v-5" />
<path d="M13 16V7" />
<path d="M18 16v-8" />
</svg>
)
export const IconChannel = (p: IconProps) => (
<svg {...base(p)}>
<rect x="3" y="4" width="18" height="7" rx="2" />
<rect x="3" y="13" width="18" height="7" rx="2" />
<path d="M7 7.5h.01" />
<path d="M7 16.5h.01" />
</svg>
)
export const IconKey = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="8" cy="15" r="4" />
<path d="m10.8 12.2 8.2-8.2" />
<path d="m15 6 3 3" />
</svg>
)
export const IconLog = (p: IconProps) => (
<svg {...base(p)}>
<path d="M8 6h13" />
<path d="M8 12h13" />
<path d="M8 18h13" />
<path d="M3.5 6h.01" />
<path d="M3.5 12h.01" />
<path d="M3.5 18h.01" />
</svg>
)
export const IconRefresh = (p: IconProps) => (
<svg {...base(p)}>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
)
export const IconPlus = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
)
export const IconSun = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m4.93 19.07 1.41-1.41" />
<path d="m17.66 6.34 1.41-1.41" />
</svg>
)
export const IconMoon = (p: IconProps) => (
<svg {...base(p)}>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
</svg>
)
export const IconMonitor = (p: IconProps) => (
<svg {...base(p)}>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M9 20h6" />
<path d="M12 16v4" />
</svg>
)
export const IconLogout = (p: IconProps) => (
<svg {...base(p)}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
)
export const IconClose = (p: IconProps) => (
<svg {...base(p)}>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
)
export const IconGlobe = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3a13.7 13.7 0 0 1 0 18" />
<path d="M12 3a13.7 13.7 0 0 0 0 18" />
</svg>
)
export const IconModel = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 2 3 7v10l9 5 9-5V7z" />
<path d="M12 22V12" />
<path d="m3 7 9 5 9-5" />
</svg>
)
export const IconCopy = (p: IconProps) => (
<svg {...base(p)}>
<rect x="9" y="9" width="12" height="12" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
export const IconCheck = (p: IconProps) => (
<svg {...base(p)}>
<path d="M20 6 9 17l-5-5" />
</svg>
)
export const IconActivity = (p: IconProps) => (
<svg {...base(p)}>
<path d="M3 12h4l3 8 4-16 3 8h4" />
</svg>
)
export const IconCheckCircle = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12.5 2.5 2.5 4.5-5" />
</svg>
)
export const IconXCircle = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />
<path d="m9 9 6 6" />
<path d="m15 9-6 6" />
</svg>
)
export const IconHelpCircle = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="9" />
<path d="M9.2 9a2.9 2.9 0 0 1 5.6 1c0 1.9-2.8 2.3-2.8 4" />
<path d="M12 17.5h.01" />
</svg>
)
export const IconArrowDown = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 4v14" />
<path d="m6 12 6 6 6-6" />
</svg>
)
export const IconArrowUp = (p: IconProps) => (
<svg {...base(p)}>
<path d="M12 20V6" />
<path d="m6 12 6-6 6 6" />
</svg>
)
export const IconZap = (p: IconProps) => (
<svg {...base(p)}>
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
)
export const IconDatabase = (p: IconProps) => (
<svg {...base(p)}>
<ellipse cx="12" cy="5" rx="8" ry="3" />
<path d="M4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5" />
<path d="M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3" />
</svg>
)
+53
View File
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react'
import { IconClose } from './icons'
export function Table({ heads, rows }: { heads: string[]; rows: ReactNode[][] }) {
return (
<div className="table-wrap">
<table>
<thead>
<tr>{heads.map(h => <th key={h}>{h}</th>)}</tr>
</thead>
<tbody>
{rows.length
? rows.map((r, i) => <tr key={i}>{r.map((v, j) => <td key={j}>{v}</td>)}</tr>)
: <tr><td className="empty" colSpan={heads.length}>暂无数据</td></tr>}
</tbody>
</table>
</div>
)
}
export type BadgeVariant = 'good' | 'warn' | 'danger' | 'neutral'
export function Badge({ variant = 'neutral', children }: { variant?: BadgeVariant; children: ReactNode }) {
return <span className={`badge ${variant}`}><i aria-hidden="true" />{children}</span>
}
/** 统一的错误 / 成功反馈条。 */
export function Feedback({ error, notice }: { error: string; notice: string }) {
return (
<>
{error && <div className="alert error" role="alert">{error}</div>}
{notice && <div className="alert success" role="status">{notice}</div>}
</>
)
}
/**
* 模态弹窗。传入 onClose 时标题栏显示关闭按钮;
* 刻意不响应遮罩点击,避免误触丢失表单内容(尤其是只展示一次的密钥)。
*/
export function Dialog({ id, title, onClose, children }: { id: string; title: string; onClose?: () => void; children: ReactNode }) {
return (
<div className="overlay">
<section className="dialog" role="dialog" aria-modal="true" aria-labelledby={id}>
<header className="dialog-head">
<h2 id={id}>{title}</h2>
{onClose && <button type="button" className="icon-btn" aria-label="关闭" onClick={onClose}><IconClose /></button>}
</header>
{children}
</section>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './style.css'
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>)
+218
View File
@@ -0,0 +1,218 @@
import { useState, type FormEvent } from 'react'
import { api, type Channel } from '../api'
import type { Action } from '../common'
import { IconPlus } from '../components/icons'
import { Badge, Dialog, Feedback, Table } from '../components/ui'
const NEW_CHANNEL: Channel = { id: '', name: '', base_url: '', protocol: 'openai-chat', enabled: true, models: {} }
const KNOWN_PROTOCOLS = ['openai-chat', 'openai-responses', 'anthropic']
type ModelDraft = { alias: string; real: string; selected: boolean }
interface ChannelsProps {
channels: Channel[]
busy: boolean
error: string
notice: string
action: Action
refresh: () => void
notify: (msg: string) => void
clearError: () => void
}
export function ChannelsPage({ channels, busy, error, notice, action, refresh, notify, clearError }: ChannelsProps) {
const [draft, setDraft] = useState<Channel | null>(null)
const [secret, setSecret] = useState('')
const [modelRows, setModelRows] = useState<ModelDraft[]>([])
const [fetching, setFetching] = useState(false)
const [modelQuery, setModelQuery] = useState('')
const [candidates, setCandidates] = useState<string[] | null>(null)
const [chosen, setChosen] = useState<Set<string>>(new Set())
const [candidateQuery, setCandidateQuery] = useState('')
const existingModels = new Set(modelRows.map(row => row.real.trim()))
const visibleCandidates = (candidates ?? []).filter(real => real.toLowerCase().includes(candidateQuery.toLowerCase()))
function edit(c: Channel) {
setDraft({ ...c })
setSecret('')
setModelRows(Object.entries(c.models ?? {}).map(([alias, real]) => ({ alias, real, selected: true })))
setModelQuery('')
clearError()
}
function close() {
if (busy) return
setDraft(null)
setSecret('')
clearError()
}
function parseMapping(): Record<string, string> {
const models: Record<string, string> = Object.create(null)
for (const row of modelRows.filter(row => row.selected)) {
const alias = row.alias.trim()
const real = row.real.trim()
if (!alias || !real) throw new Error('已展示模型的展示名称和上游模型不能为空。')
if (Object.hasOwn(models, alias)) throw new Error('展示名称重复:' + alias)
models[alias] = real
}
return models
}
function discover() {
if (!draft) return
action(async () => {
setFetching(true)
try {
const result = await api<{ models: string[] }>('/channels/discover-models', 'POST', { ...draft, api_key: secret })
setCandidates([...new Set(result.models)])
setChosen(new Set())
setCandidateQuery('')
} finally {
setFetching(false)
}
})
}
function updateModel(index: number, patch: Partial<ModelDraft>) {
setModelRows(rows => rows.map((row, i) => i === index ? { ...row, ...patch } : row))
}
function submit(e: FormEvent) {
e.preventDefault()
if (!draft) return
action(async () => {
const models = parseMapping()
await api('/channels', 'POST', { ...draft, models, api_key: secret })
setDraft(null)
setSecret('')
refresh()
notify('渠道已保存。')
})
}
function addChosen() {
const additions = (candidates ?? []).filter(real => chosen.has(real) && !existingModels.has(real))
setModelRows(rows => [...rows, ...additions.map(real => ({ real, alias: real, selected: true }))])
setModelQuery('')
setCandidates(null)
notify('已加入 ' + additions.length + ' 个模型,请保存渠道使配置生效。')
}
function remove(c: Channel) {
if (!confirm(`确定删除渠道“${c.name}”?此操作无法撤销。`)) return
action(async () => {
await api('/channels/' + encodeURIComponent(c.id), 'DELETE')
refresh()
notify('渠道已删除。')
})
}
return (
<section className="panel">
<div className="section-head">
<h2>渠道列表 <small>{channels.length}</small></h2>
<button className="primary" onClick={() => edit(NEW_CHANNEL)}><IconPlus />新建渠道</button>
</div>
<Table
heads={['名称 / 地址', '协议', '路由策略', '状态', '操作']}
rows={channels.map(c => [
<><strong>{c.name}</strong><small className="sub">{c.base_url}</small></>,
c.protocol,
<>严格映射<small className="sub">{Object.keys(c.models ?? {}).length} 个已展示模型</small></>,
<Badge variant={c.enabled ? 'good' : 'neutral'}>{c.enabled ? '启用' : '停用'}</Badge>,
<div className="actions">
<button onClick={() => edit(c)}>编辑</button>
<button className="danger" disabled={busy} onClick={() => remove(c)}>删除</button>
</div>,
])}
/>
{draft && candidates === null && (
<Dialog id="channel-title" title={draft.id ? '编辑渠道' : '新建渠道'} onClose={close}>
<Feedback error={error} notice={notice} />
<form onSubmit={submit}>
<fieldset disabled={busy}>
<label>渠道名称
<input autoFocus required value={draft.name} onChange={e => setDraft({ ...draft, name: e.target.value })} />
</label>
<label>上游 Base URL
<input type="url" required value={draft.base_url} onChange={e => setDraft({ ...draft, base_url: e.target.value })} />
<small>{draft.protocol === 'anthropic' ? '例如 https://api.anthropic.com,不带 /v1;程序追加 /v1/messages。' : '例如 https://api.openai.com/v1;程序追加 /chat/completions 或 /responses。'}</small>
</label>
<label>协议
<select value={draft.protocol} onChange={e => setDraft({ ...draft, protocol: e.target.value })}>
<option value="openai-chat">OpenAI Chat</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic">Anthropic</option>
{!KNOWN_PROTOCOLS.includes(draft.protocol) && <option value={draft.protocol}>{draft.protocol}</option>}
</select>
</label>
<label>上游 API Key
<input type="password" autoComplete="new-password" value={secret} onChange={e => setSecret(e.target.value)} placeholder={draft.id ? '留空保留原密钥' : '输入上游密钥(可选)'} />
</label>
<section className="channel-models" aria-label="渠道模型">
<div className="section-head">
<h3>展示给下游的模型 <small>{modelRows.filter(row => row.selected).length}</small></h3>
<button type="button" onClick={discover} disabled={!draft.base_url.trim()}>{fetching ? '获取中…' : '获取模型列表'}</button>
</div>
<p className="muted">获取后在弹窗中选择要加入的模型,可编辑展示名称,保存渠道后生效;上游不支持获取时可手动添加。</p>
<input aria-label="搜索渠道模型" placeholder="搜索上游模型或展示名称" value={modelQuery} onChange={e => setModelQuery(e.target.value)} />
<div className="channel-model-list">
{modelRows.map((row, index) => ({ row, index })).filter(({ row }) => `${row.real} ${row.alias}`.toLowerCase().includes(modelQuery.toLowerCase())).map(({ row, index }) => (
<div className="channel-model-row" key={index}>
<label className="check"><input type="checkbox" checked={row.selected} onChange={e => updateModel(index, { selected: e.target.checked })} />展示</label>
<label>上游模型<input aria-label={`上游模型 ${index + 1}`} value={row.real} onChange={e => updateModel(index, { real: e.target.value })} spellCheck={false} /></label>
<label>展示名称<input aria-label={`展示名称 ${index + 1}`} value={row.alias} onChange={e => updateModel(index, { alias: e.target.value })} spellCheck={false} /></label>
<button type="button" aria-label={`移除模型 ${index + 1}`} onClick={() => setModelRows(rows => rows.filter((_, i) => i !== index))}>移除</button>
</div>
))}
{modelRows.length === 0 && <p className="muted">尚未选择模型,请获取列表或手动添加。</p>}
</div>
<button type="button" onClick={() => { setModelQuery(''); setModelRows(rows => [...rows, { alias: '', real: '', selected: true }]) }}>手动添加模型</button>
<p className="muted">下游调用格式:{draft.name || '渠道名'}/展示名称。展示名称在同一渠道内不可重复。</p>
</section>
<label className="check">
<input type="checkbox" checked={draft.enabled} onChange={e => setDraft({ ...draft, enabled: e.target.checked })} />启用渠道
</label>
<p className="muted">只允许调用已勾选并保存的模型,未映射模型不可调用。</p>
<div className="dialog-actions">
<button type="button" onClick={close}>取消</button>
<button className="primary">{busy && !fetching ? '保存中…' : '保存渠道'}</button>
</div>
</fieldset>
</form>
</Dialog>
)}
{draft && candidates !== null && (
<Dialog id="model-picker-title" title="选择要加入的模型" onClose={() => setCandidates(null)}>
<p className="muted">获取到 {candidates.length} 个模型,已选择 {chosen.size} 个。已有模型不会重复加入,也不会覆盖展示名称。</p>
<label>搜索上游模型<input autoFocus value={candidateQuery} onChange={e => setCandidateQuery(e.target.value)} placeholder="输入模型名称" /></label>
<div className="actions">
<button type="button" onClick={() => setChosen(current => new Set([...current, ...visibleCandidates.filter(real => !existingModels.has(real))]))}>全选搜索结果</button>
<button type="button" onClick={() => setChosen(new Set())}>清空选择</button>
</div>
<div className="channel-model-list">
{visibleCandidates.map(real => (
<label className="check" key={real} style={{ overflowWrap: 'anywhere' }}>
<input type="checkbox" disabled={existingModels.has(real)} checked={existingModels.has(real) || chosen.has(real)} onChange={e => {
const checked = e.target.checked
setChosen(current => {
const next = new Set(current)
if (checked) next.add(real)
else next.delete(real)
return next
})
}} />
{real}{existingModels.has(real) && <small>已在列表中</small>}
</label>
))}
{visibleCandidates.length === 0 && <p className="muted">{candidates.length === 0 ? '上游未返回可选模型,可返回手动添加。' : '没有匹配的模型。'}</p>}
</div>
<div className="dialog-actions">
<button type="button" onClick={() => setCandidates(null)}>取消</button>
<button type="button" className="primary" disabled={chosen.size === 0} onClick={addChosen}>加入所选模型({chosen.size})</button>
</div>
</Dialog>
)}
</section>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { useState, type FormEvent } from 'react'
import { api, type Channel, type Key, type Stats } from '../api'
import { channelName, num, type Action } from '../common'
import { formatTime, keyExpiry } from '../time'
import { IconPlus } from '../components/icons'
import { Badge, Dialog, Feedback, Table } from '../components/ui'
interface KeyForm {
name: string
all_channels: boolean
channels: string[]
expires_at: string
}
const EMPTY_FORM: KeyForm = { name: '', all_channels: false, channels: [], expires_at: '' }
interface KeysProps {
keys: Key[]
stats: Stats | null
channels: Channel[]
timezone: string
busy: boolean
error: string
notice: string
action: Action
refresh: () => void
notify: (msg: string) => void
clearError: () => void
/** 跳转到概览页并按该 Key 筛选统计。 */
onViewStats: (k: Key) => void
}
export function KeysPage({ keys, stats, channels, timezone, busy, error, notice, action, refresh, notify, clearError, onViewStats }: KeysProps) {
const [creating, setCreating] = useState(false)
const [keyForm, setKeyForm] = useState<KeyForm>(EMPTY_FORM)
const [newKey, setNewKey] = useState('')
const time = formatTime
function openCreate() {
setCreating(true)
setKeyForm(EMPTY_FORM)
}
function closeCreate() {
setCreating(false)
clearError()
}
function submit(e: FormEvent) {
e.preventDefault()
action(async () => {
if (!keyForm.all_channels && !keyForm.channels.length) throw new Error('请选择至少一个渠道,或授权全部渠道。')
const expires_at = keyExpiry(keyForm.expires_at)
const r = await api<{ key: string }>('/keys', 'POST', { ...keyForm, channels: keyForm.all_channels ? [] : keyForm.channels, expires_at })
setNewKey(r.key)
setCreating(false)
refresh()
})
}
function toggle(k: Key) {
action(async () => {
await api('/keys/' + encodeURIComponent(k.id), 'PATCH', { enabled: !k.enabled })
refresh()
})
}
function revoke(k: Key) {
if (!confirm(`确定撤销 Key“${k.name}”?历史记录将保留。`)) return
action(async () => {
await api('/keys/' + encodeURIComponent(k.id), 'DELETE')
refresh()
notify('Key 已撤销,历史记录已保留。')
})
}
function copyKey() {
action(async () => {
if (!navigator.clipboard) throw new Error('无法自动复制,请选中密钥手动复制。')
await navigator.clipboard.writeText(newKey)
notify('密钥已复制。')
})
}
function toggleChannel(id: string, checked: boolean) {
setKeyForm({
...keyForm,
channels: checked ? [...keyForm.channels, id] : keyForm.channels.filter(c => c !== id),
})
}
return (
<section className="panel">
<div className="section-head">
<h2>访问密钥 <small>{keys.length}</small></h2>
<button className="primary" onClick={openCreate}><IconPlus />创建 Key</button>
</div>
<Table
heads={['名称 / 前缀', '渠道权限', '状态', '请求 / tokens', '有效期 / 创建时间', '操作']}
rows={keys.map(k => {
const u = stats?.by_key?.find(u => u.key_id === k.id)
return [
<><button className="link" onClick={() => onViewStats(k)}>{k.name}</button><code className="sub">{k.prefix}…</code></>,
k.all_channels ? '全部渠道' : k.channels?.map(id => channelName(channels, id)).join('、') || '无渠道权限',
<Badge variant={k.revoked ? 'danger' : k.enabled ? 'good' : 'neutral'}>{k.revoked ? '已撤销' : k.enabled ? '启用' : '停用'}</Badge>,
<>{num(u?.requests ?? 0)} 次<small className="sub">输入 {num(u?.input_tokens ?? 0)} / 输出 {num(u?.output_tokens ?? 0)}</small></>,
<>{time(k.expires_at) === '—' ? '永不过期' : time(k.expires_at)}<small className="sub">创建于 {time(k.created_at)}</small></>,
<div className="actions">
<button disabled={busy || k.revoked} onClick={() => toggle(k)}>{k.enabled ? '停用' : '启用'}</button>
<button className="danger" disabled={busy} onClick={() => revoke(k)}>撤销</button>
</div>,
]
})}
/>
{creating && (
<Dialog id="key-title" title="创建访问密钥" onClose={closeCreate}>
<Feedback error={error} notice={notice} />
<form onSubmit={submit}>
<fieldset disabled={busy}>
<label>名称
<input autoFocus required value={keyForm.name} onChange={e => setKeyForm({ ...keyForm, name: e.target.value })} />
</label>
<label>有效期({timezone},可选)
<input type="datetime-local" value={keyForm.expires_at} onChange={e => setKeyForm({ ...keyForm, expires_at: e.target.value })} />
<small>留空表示永不过期。</small>
</label>
<label className="check">
<input type="checkbox" checked={keyForm.all_channels} onChange={e => setKeyForm({ ...keyForm, all_channels: e.target.checked })} />允许全部渠道(包含未来新增渠道)
</label>
{!keyForm.all_channels && (
<div className="channel-options">
<p>选择允许访问的渠道</p>
{!channels.length && <p className="muted">暂无渠道,请先创建渠道。</p>}
{channels.map(c => (
<label className="check" key={c.id}>
<input type="checkbox" checked={keyForm.channels.includes(c.id)} onChange={e => toggleChannel(c.id, e.target.checked)} />
{c.name}{!c.enabled && '(停用)'}
</label>
))}
</div>
)}
<div className="dialog-actions">
<button type="button" onClick={closeCreate}>取消</button>
<button className="primary">{busy ? '创建中…' : '创建 Key'}</button>
</div>
</fieldset>
</form>
</Dialog>
)}
{newKey && (
<Dialog id="secret-title" title="Key 已创建">
<div className="alert warning">完整密钥仅展示这一次。请立即复制并妥善保存,关闭后无法再次查看。</div>
<label>完整密钥
<textarea autoFocus readOnly rows={3} value={newKey} onFocus={e => e.target.select()} />
</label>
<Feedback error={error} notice={notice} />
<div className="dialog-actions">
<button onClick={() => { setNewKey(''); notify('') }}>我已保存,关闭</button>
<button className="primary" onClick={copyKey}>复制密钥</button>
</div>
</Dialog>
)}
</section>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { useState } from 'react'
import { api } from '../api'
import type { Action } from '../common'
import { IconRoute } from '../components/icons'
import { Feedback } from '../components/ui'
interface LoginProps {
busy: boolean
error: string
notice: string
action: Action
/** 登录成功后回调,用于重新加载会话状态。 */
onSuccess: () => Promise<void>
}
export function Login({ busy, error, notice, action, onSuccess }: LoginProps) {
const [password, setPassword] = useState('')
function submit(e: React.FormEvent) {
e.preventDefault()
action(async () => {
await api('/login', 'POST', { password })
setPassword('')
await onSuccess()
})
}
return (
<div className="login">
<form className="login-card" onSubmit={submit}>
<div className="mark"><IconRoute width={22} height={22} strokeWidth={2} /></div>
<h1>AI Router</h1>
<p className="muted">登录管理台,管理模型与访问权限。</p>
<Feedback error={error} notice={notice} />
<label>管理密码
<input type="password" required autoComplete="current-password" autoFocus value={password} onChange={e => setPassword(e.target.value)} />
</label>
<button className="primary" disabled={busy}>{busy ? '登录中…' : '登录管理台'}</button>
</form>
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import type { Channel, Log } from '../api'
import { channelName, num } from '../common'
import { formatTime } from '../time'
import { Badge, Table } from '../components/ui'
export function LogsPage({ logs, channels }: { logs: Log[]; channels: Channel[] }) {
return (
<section className="panel">
<div className="section-head">
<h2>请求记录</h2>
<small>最多 200 条,可通过时间缩小范围</small>
</div>
<Table
heads={['时间 / Key', '渠道 / 模型', '协议 / 模式', '状态 / 耗时', '用量', '详情']}
rows={logs.map(l => [
<><span className="nowrap">{formatTime(l.created_at)}</span><small className="sub">{l.key_name || l.key_id}</small></>,
<>{channelName(channels, l.channel)}<small className="sub">{l.request_model} → {l.actual_model || '—'}</small></>,
<>{l.downstream} → {l.upstream}<small className="sub">{l.stream ? '流式' : '非流式'}</small></>,
<><Badge variant={l.status >= 200 && l.status < 400 ? 'good' : 'danger'}>{l.status || '失败'}</Badge><small className="sub">{num(l.duration_ms)} ms</small></>,
l.usage_known
? <>输入 {num(l.input_tokens)} / 输出 {num(l.output_tokens)}<small className="sub">缓存读 {num(l.cache_read_tokens)} / 写 {num(l.cache_write_tokens)}</small></>
: '用量未知',
<details>
<summary>展开</summary>
<small className="sub">ID: {l.id}</small>
<p>{l.error || '无错误信息'}</p>
</details>,
])}
/>
</section>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from 'react'
import { api, APIError, type Channel } from '../api'
import { IconCheck, IconCopy } from '../components/icons'
import { Badge, Dialog, Table } from '../components/ui'
type TestResult = { ok: boolean; duration_ms: number; status: number; error?: string }
type TestState = { state: 'running' | 'done' | 'cancelled'; result?: TestResult; error?: string }
const DEFAULT_PROMPT = 'Reply with OK.'
const testKey = (m: ModelRow) => JSON.stringify([m.channel.id, m.id, m.real, m.channel.base_url, m.channel.protocol])
interface ModelRow {
id: string
alias: string
real: string
channel: Channel
}
/** 汇总所有渠道的可调用模型:下游调用的模型格式为「渠道名/别名」。 */
export function ModelsPage({ channels }: { channels: Channel[] }) {
const [results, setResults] = useState<Record<string, TestState>>({})
const [manual, setManual] = useState<ModelRow | null>(null)
const [prompt, setPrompt] = useState(DEFAULT_PROMPT)
const [running, setRunning] = useState(false)
const [progress, setProgress] = useState({ done: 0, total: 0 })
const controller = useRef<AbortController | null>(null)
useEffect(() => () => { controller.current?.abort() }, [])
const rows: ModelRow[] = channels
.flatMap(c => Object.entries(c.models ?? {}).map(([alias, real]) => ({ id: `${c.name}/${alias}`, alias, real, channel: c })))
.sort((a, b) => a.id.localeCompare(b.id))
const available = rows.filter(m => m.channel.enabled)
async function runTests(models: ModelRow[], text: string) {
if (controller.current || models.length === 0) return
const ctrl = new AbortController()
controller.current = ctrl
setRunning(true)
setResults(old => {
const next = { ...old }
for (const model of models) delete next[testKey(model)]
return next
})
setProgress({ done: 0, total: models.length })
try {
for (const model of models) {
if (ctrl.signal.aborted) break
const key = testKey(model)
setResults(old => ({ ...old, [key]: { state: 'running' } }))
let stop = false
try {
const result = await api<TestResult>('/models/test', 'POST', { model: model.id, prompt: text }, ctrl.signal)
setResults(old => ({ ...old, [key]: { state: 'done', result } }))
} catch (e) {
setResults(old => ({ ...old, [key]: ctrl.signal.aborted ? { state: 'cancelled' } : { state: 'done', error: e instanceof Error ? e.message : '测试失败' } }))
stop = ctrl.signal.aborted || (e instanceof APIError && (e.status === 401 || e.status === 403))
}
if (!ctrl.signal.aborted) setProgress(p => ({ ...p, done: p.done + 1 }))
if (stop) break
}
} finally {
controller.current = null
setRunning(false)
}
}
function testAll() {
if (!confirm(`将依次测试 ${available.length} 个启用模型,每个模型发送一次简短请求,可能产生 Token 费用。是否继续?`)) return
void runTests(available, DEFAULT_PROMPT)
}
return (
<section className="panel">
<div className="section-head models-head">
<h2>已配置展示模型 <small>{rows.length}</small></h2>
<small>仅汇总渠道中已勾选并保存的模型;下游列表按渠道状态和 Key 权限过滤</small>
<div className="actions">
<button className="primary" disabled={running || available.length === 0} onClick={testAll}>一键测试全部</button>
{running && <button onClick={() => controller.current?.abort()}>停止测试</button>}
</div>
</div>
<p className="muted panel-foot">测试会实际调用上游并可能产生费用,停用渠道不参与测试。结果仅保留在当前页面,不计入正式调用统计,不代表下游 Key 权限验证。{progress.total > 0 && <span role="status"> {running ? '测试中' : '本轮已结束'}:{progress.done}/{progress.total}</span>}</p>
<Table
heads={['下游模型名 / 展示名称', '上游真实模型', '所属渠道', '状态', '测试结果', '操作']}
rows={rows.map(m => [
<span className="model-id"><code>{m.id}</code><CopyButton text={m.id} /></span>,
<code>{m.real}</code>,
<>{m.channel.name}<small className="sub">{m.channel.protocol}</small></>,
<Badge variant={m.channel.enabled ? 'good' : 'neutral'}>{m.channel.enabled ? '可用' : '渠道停用'}</Badge>,
<TestStatus value={results[testKey(m)]} />,
<button disabled={running || !m.channel.enabled} onClick={() => { setManual(m); setPrompt(DEFAULT_PROMPT) }}>手动测试</button>,
])}
/>
{manual && (
<Dialog id="model-test-title" title={`测试 ${manual.id}`} onClose={running ? undefined : () => setManual(null)}>
<form onSubmit={e => { e.preventDefault(); void runTests([manual], prompt.trim()) }}>
<label>测试提示词<textarea required maxLength={2000} rows={3} value={prompt} disabled={running} onChange={e => setPrompt(e.target.value)} /></label>
<p className="muted">发送一次非流式请求,限制输出 Token,最长等待 30 秒。可能产生上游费用;仅检查接口响应,不评价回答质量。</p>
<TestStatus value={results[testKey(manual)]} />
<div className="dialog-actions">
{running ? <button type="button" onClick={() => controller.current?.abort()}>停止测试</button> : <button type="button" onClick={() => setManual(null)}>关闭</button>}
<button className="primary" disabled={running || !prompt.trim()}>{running ? '测试中…' : '开始测试'}</button>
</div>
</form>
</Dialog>
)}
</section>
)
}
function TestStatus({ value }: { value?: TestState }) {
if (!value) return <span className="muted">未测试</span>
if (value.state === 'running') return <span role="status">测试中…</span>
if (value.state === 'cancelled') return <span className="muted">已停止(已发送的请求仍可能计费)</span>
const result = value.result
return <div role="status">
<Badge variant={result?.ok ? 'good' : 'danger'}>{result?.ok ? '成功' : '失败'}</Badge>
{result && <small className="sub">{result.duration_ms} ms{result.status > 0 ? ` · HTTP ${result.status}` : ''}</small>}
{(value.error || result?.error) && <small className="sub">{value.error || result?.error}</small>}
</div>
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
async function copy() {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// 剪贴板不可用(非安全上下文等),静默失败,用户可手动选中复制
}
}
return (
<button type="button" className="icon-btn copy-btn" aria-label={`复制 ${text}`} title={copied ? '已复制' : '复制模型名'} onClick={copy}>
{copied ? <IconCheck /> : <IconCopy />}
</button>
)
}
+63
View File
@@ -0,0 +1,63 @@
import type { ReactNode } from 'react'
import type { Channel, Stats } from '../api'
import { channelName, num, usageCells, usageHeads } from '../common'
import { Table } from '../components/ui'
import { UsageChart } from '../components/UsageChart'
import { IconActivity, IconArrowDown, IconArrowUp, IconCheckCircle, IconDatabase, IconHelpCircle, IconXCircle, IconZap } from '../components/icons'
const METRICS: { key: keyof Stats['summary']; label: string; icon: ReactNode; tone?: 'bad' }[] = [
{ key: 'requests', label: '总请求', icon: <IconActivity /> },
{ key: 'success', label: '成功请求', icon: <IconCheckCircle /> },
{ key: 'failed', label: '失败请求', icon: <IconXCircle />, tone: 'bad' },
{ key: 'unknown_usage', label: '用量未知', icon: <IconHelpCircle /> },
{ key: 'input_tokens', label: '输入 tokens', icon: <IconArrowDown /> },
{ key: 'output_tokens', label: '输出 tokens', icon: <IconArrowUp /> },
{ key: 'cache_read_tokens', label: '缓存读取', icon: <IconZap /> },
{ key: 'cache_write_tokens', label: '缓存写入', icon: <IconDatabase /> },
]
export function Overview({ stats, channels }: { stats: Stats | null; channels: Channel[] }) {
if (!stats) return <div className="panel empty">暂无统计数据,请刷新重试。</div>
return (
<>
<div className="metrics">
{METRICS.map(m => {
const value = stats.summary[m.key]
const alert = m.tone === 'bad' && value > 0
return (
<div className={`metric${alert ? ' alert' : ''}`} key={m.key}>
<span>{m.label}</span>
<span className="metric-icon" aria-hidden="true">{m.icon}</span>
<strong>{num(value)}</strong>
</div>
)
})}
</div>
<p className="muted">用量未知不代表零消耗;tokens 以已返回用量为准。</p>
<section className="panel">
<h2>每日用量</h2>
<UsageChart data={stats.daily ?? []} />
<Table heads={['日期', ...usageHeads]} rows={(stats.daily ?? []).map(u => [<span className="nowrap">{u.day}</span>, ...usageCells(u)])} />
</section>
<section className="panel">
<h2>按 Key 统计</h2>
<Table
heads={['Key', ...usageHeads, '成功 / 失败', '用量未知']}
rows={(stats.by_key ?? []).map(u => [<span className="cell-id" title={u.name || u.key_id}>{u.name || u.key_id}</span>, ...usageCells(u), `${num(u.success)} / ${num(u.failed)}`, num(u.unknown_usage)])}
/>
</section>
<section className="panel">
<h2>按模型统计</h2>
<Table
heads={['渠道', '模型', ...usageHeads]}
rows={(stats.by_model ?? []).map(u => [
<span className="cell-id" title={channelName(channels, u.channel)}>{channelName(channels, u.channel)}</span>,
<span className="cell-id" title={u.model}>{u.model}</span>,
...usageCells(u),
])}
/>
</section>
</>
)
}
+522
View File
@@ -0,0 +1,522 @@
/* ===== 主题令牌 ===== */
:root {
--bg: #f2f3fa;
--surface: #ffffff;
--surface-2: #f6f6fc;
--input-bg: #ffffff;
--border: #e5e6f1;
--border-strong: #d3d5e8;
--text: #21243a;
--text-2: #5b5f7c;
--text-3: #8c8fa9;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-2: #7c3aed;
--accent-soft: rgba(79, 70, 229, .09);
--on-accent: #ffffff;
--danger: #d92d20;
--danger-bg: #fdeceb;
--danger-text: #b42318;
--success-bg: #e4f3e9;
--success-text: #1a7f4b;
--warning-bg: #fbf2d9;
--warning-text: #a15c07;
--sidebar-bg: #181a34;
--sidebar-text: #a4a7cc;
--sidebar-hover: rgba(255, 255, 255, .06);
--sidebar-active: rgba(129, 140, 248, .16);
--sidebar-active-text: #cdd3ff;
--sidebar-border: rgba(255, 255, 255, .08);
--mark-bg: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
--mark-text: #ffffff;
--mark-glow: rgba(99, 102, 241, .38);
--chart-input: #6366f1;
--chart-output: #38bdf8;
--overlay: rgba(24, 26, 52, .45);
--shadow-sm: 0 1px 2px rgba(30, 27, 75, .05);
--shadow-md: 0 1px 2px rgba(30, 27, 75, .06), 0 8px 24px rgba(30, 27, 75, .08);
--shadow-lg: 0 24px 70px rgba(23, 20, 60, .24);
--primary-shadow: 0 1px 2px rgba(30, 27, 75, .16), 0 5px 16px rgba(79, 70, 229, .28);
--primary-shadow-hover: 0 2px 4px rgba(30, 27, 75, .16), 0 8px 22px rgba(79, 70, 229, .36);
--scrollbar-thumb: #c0c3de;
--scrollbar-thumb-hover: #9ea2c8;
--r-sm: 7px;
--r-md: 10px;
--r-lg: 14px;
color-scheme: light;
}
:root[data-theme=dark] {
--bg: #0c0e1b;
--surface: #161932;
--surface-2: #1d2040;
--input-bg: #11132a;
--border: #292d4e;
--border-strong: #3b4068;
--text: #e8eaf8;
--text-2: #a3a7cb;
--text-3: #72769d;
--accent: #818cf8;
--accent-hover: #a5b4fc;
--accent-2: #a78bfa;
--accent-soft: rgba(129, 140, 248, .14);
--on-accent: #1e1b4b;
--danger: #f97066;
--danger-bg: rgba(249, 112, 102, .12);
--danger-text: #fda29b;
--success-bg: rgba(74, 222, 128, .12);
--success-text: #5eeaa6;
--warning-bg: rgba(251, 191, 36, .12);
--warning-text: #fcd34d;
--sidebar-bg: #090a16;
--sidebar-text: #9094bb;
--sidebar-hover: rgba(255, 255, 255, .05);
--sidebar-active: rgba(129, 140, 248, .14);
--sidebar-active-text: #a5b4fc;
--sidebar-border: rgba(255, 255, 255, .07);
--mark-glow: rgba(129, 140, 248, .32);
--chart-input: #818cf8;
--chart-output: #38bdf8;
--overlay: rgba(2, 4, 18, .62);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);
--shadow-md: 0 1px 2px rgba(0, 0, 0, .4), 0 10px 28px rgba(0, 0, 0, .35);
--shadow-lg: 0 24px 70px rgba(0, 0, 0, .6);
--primary-shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 5px 16px rgba(129, 140, 248, .25);
--primary-shadow-hover: 0 2px 4px rgba(0, 0, 0, .3), 0 8px 22px rgba(129, 140, 248, .34);
--scrollbar-thumb: #3b4068;
--scrollbar-thumb-hover: #50558a;
color-scheme: dark;
}
/* ===== 基础 ===== */
:root {
font-family: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif;
font-size: 14px;
font-synthesis: none;
color: var(--text);
background: var(--bg);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
transition: background-color .25s ease, color .25s ease;
}
::selection { background: var(--accent-soft); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 8px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); background-clip: content-box; }
::-webkit-scrollbar-corner { background: transparent; }
/* ===== 按钮 ===== */
button, input, select, textarea { font: inherit; }
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
cursor: pointer;
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
padding: 8px 14px;
background: var(--surface);
color: var(--text);
font-weight: 500;
white-space: nowrap;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease, transform .15s ease, filter .15s ease;
}
button:hover { background: var(--surface-2); }
button:active { transform: translateY(1px); }
button:disabled { opacity: .5; cursor: not-allowed; }
button:disabled:active { transform: none; }
.primary {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
border-color: transparent;
color: var(--on-accent);
font-weight: 600;
box-shadow: var(--primary-shadow);
}
.primary:hover { background: linear-gradient(135deg, var(--accent-hover) 0%, var(--accent-2) 100%); box-shadow: var(--primary-shadow-hover); }
.danger { color: var(--danger); }
.danger:hover { background: var(--danger-bg); border-color: var(--danger); }
.icon-btn { border: 0; background: transparent; color: var(--text-3); padding: 5px; border-radius: var(--r-sm); }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.link { border: 0; padding: 0; background: none; color: var(--accent); font-weight: 600; white-space: normal; text-align: left; display: inline; }
.link:hover { text-decoration: underline; background: none; }
/* ===== 表单 ===== */
input, select, textarea {
border: 1px solid var(--border-strong);
border-radius: var(--r-sm);
padding: 9px 11px;
background: var(--input-bg);
color: var(--text);
min-width: 0;
width: 100%;
transition: border-color .15s ease, box-shadow .15s ease, background-color .25s ease;
}
input::placeholder, textarea::placeholder { color: var(--text-3); }
textarea { resize: vertical; font-family: ui-monospace, SF Mono, Cascadia Mono, Consolas, monospace; font-size: 12.5px; line-height: 1.7; }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
label { display: flex; flex-direction: column; gap: 7px; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 15px; }
label > input, label > select, label > textarea { font-weight: 400; color: var(--text); font-size: 13.5px; }
small, .muted { color: var(--text-3); font-weight: 400; font-size: 12px; line-height: 1.7; }
.sub { display: block; margin-top: 5px; overflow-wrap: anywhere; }
h1 { font-size: 25px; letter-spacing: -.5px; margin: 8px 0; font-weight: 700; }
h2 { font-size: 15.5px; margin: 0; padding: 20px 22px 14px; font-weight: 600; }
h2 small { margin-left: 8px; }
code { font-family: ui-monospace, SF Mono, Cascadia Mono, Consolas, monospace; font-size: 12px; background: var(--surface-2); border: 1px solid var(--border); padding: 1px 6px; border-radius: 5px; }
fieldset { padding: 0; border: 0; margin: 0; min-width: 0; }
.check { flex-direction: row; align-items: center; font-weight: 500; line-height: 1.7; color: var(--text); }
.check input { width: 15px; height: 15px; accent-color: var(--accent); flex-shrink: 0; }
summary { cursor: pointer; color: var(--accent); font-weight: 500; font-size: 12.5px; }
details p { white-space: pre-wrap; min-width: 160px; margin: 8px 0 0; color: var(--text-2); }
/* 行内模型名 + 复制按钮(触屏无 hover,按钮保底可见) */
.model-id { display: inline-flex; align-items: center; gap: 6px; }
.copy-btn { padding: 3px; opacity: .4; transition: opacity .12s ease, background-color .15s ease, color .15s ease; }
tr:hover .copy-btn, .copy-btn:hover, .copy-btn:focus-visible { opacity: 1; }
.copy-btn svg { width: 13px; height: 13px; }
.panel-foot { margin: 0; padding: 12px 22px 16px; border-top: 1px dashed var(--border); }
/* ===== 布局骨架 / 侧栏 ===== */
.shell { display: flex; min-height: 100vh; }
aside {
width: 230px;
flex-shrink: 0;
background: var(--sidebar-bg);
background-image: linear-gradient(180deg, color-mix(in srgb, var(--accent) 16%, var(--sidebar-bg)) 0%, var(--sidebar-bg) 460px);
color: #edf0fb;
display: flex;
flex-direction: column;
padding: 26px 16px 20px;
position: sticky;
top: 0;
height: 100vh;
transition: background-color .25s ease;
}
.brand { display: flex; align-items: center; gap: 12px; font-size: 17.5px; font-weight: 700; padding: 0 6px; margin-bottom: 36px; }
.brand small { display: block; color: var(--sidebar-text); font-size: 11px; font-weight: 400; letter-spacing: .8px; margin-top: 3px; }
.mark {
display: grid;
place-items: center;
background: var(--mark-bg);
color: var(--mark-text);
border-radius: 11px;
width: 40px;
height: 40px;
flex-shrink: 0;
box-shadow: 0 4px 14px var(--mark-glow), inset 0 1px 0 rgba(255, 255, 255, .22);
}
nav { display: grid; gap: 4px; }
nav button {
justify-content: flex-start;
border: 0;
background: transparent;
color: var(--sidebar-text);
padding: 10px 12px 10px 15px;
border-radius: 9px;
font-weight: 500;
position: relative;
}
nav button svg { opacity: .75; }
nav button:hover { background: var(--sidebar-hover); color: #fff; }
nav button.active { background: var(--sidebar-active); color: var(--sidebar-active-text); font-weight: 600; }
nav button.active::before {
content: "";
position: absolute;
left: 4px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 17px;
border-radius: 2px;
background: var(--sidebar-active-text);
}
nav button.active svg { opacity: 1; }
.aside-bottom { margin-top: auto; padding-top: 16px; border-top: 1px solid var(--sidebar-border); display: grid; gap: 8px; }
.aside-bottom button { width: 100%; justify-content: flex-start; background: transparent; color: var(--sidebar-text); border-color: var(--sidebar-border); }
.aside-bottom button:hover { background: var(--sidebar-hover); color: #fff; }
/* 主题分段控件(侧栏深色底上的自定义三态开关) */
.theme-seg { display: flex; gap: 2px; padding: 3px; background: rgba(255, 255, 255, .06); border: 1px solid var(--sidebar-border); border-radius: 11px; }
.aside-bottom .theme-seg button { flex: 1; justify-content: center; border: 0; padding: 6px 0; border-radius: 8px; background: transparent; color: var(--sidebar-text); }
.aside-bottom .theme-seg button:hover { background: var(--sidebar-hover); color: #fff; }
.aside-bottom .theme-seg button.active { background: var(--sidebar-active); color: var(--sidebar-active-text); }
.theme-seg button svg { width: 15px; height: 15px; }
main { padding: 30px 40px 40px; min-width: 0; flex: 1; width: 100%; max-width: 1400px; margin: 0 auto; }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 15px; margin-bottom: 22px; }
header > button { margin-top: 6px; }
header h1 { margin-top: 0; }
header .muted { margin: 0; }
.tz-hint { display: inline-flex; align-items: center; gap: 5px; }
.tz-hint svg { opacity: .7; flex-shrink: 0; }
/* ===== 面板 / 表格 ===== */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
margin-bottom: 22px;
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: background-color .25s ease, border-color .25s ease;
}
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-right: 22px; flex-wrap: wrap; }
.section-head > small { padding-bottom: 6px; }
.empty { text-align: center; padding: 52px 20px; color: var(--text-3); }
/* 指标卡 */
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 14px; }
.metric {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-content: start;
gap: 10px 12px;
padding: 18px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
transition: transform .18s ease, box-shadow .18s ease, background-color .25s ease, border-color .25s ease;
}
.metric:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.metric > span { color: var(--text-2); font-size: 12.5px; }
.metric > .metric-icon {
grid-column: 2;
grid-row: 1 / 3;
align-self: center;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent);
}
.metric > .metric-icon svg { width: 18px; height: 18px; }
.metric strong { grid-column: 1; display: block; font-size: 27px; font-weight: 650; font-variant-numeric: tabular-nums; letter-spacing: -.5px; line-height: 1.15; }
.metric.alert { border-color: var(--danger); }
.metric.alert strong { color: var(--danger); }
.metric.alert > .metric-icon { background: var(--danger-bg); color: var(--danger-text); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; }
th { font-weight: 600; font-size: 12px; letter-spacing: .3px; color: var(--text-3); background: var(--surface-2); white-space: nowrap; transition: background-color .25s ease; }
th, td { padding: 13px 22px; border-top: 1px solid var(--border); vertical-align: top; }
thead th { border-top: 0; }
tbody tr { transition: background-color .12s ease; }
tbody tr:hover { background: var(--surface-2); }
td { max-width: 320px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
td strong { font-weight: 600; }
td.empty:hover { background: transparent; }
.nowrap { white-space: nowrap; }
.actions { display: flex; gap: 7px; }
.actions button { padding: 5px 10px; font-size: 12.5px; font-weight: 500; }
/* ===== 徽章 / 提示条 ===== */
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; white-space: nowrap; font-size: 11.5px; font-weight: 600; background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border); }
.badge i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.badge.good { background: var(--success-bg); color: var(--success-text); border-color: transparent; }
.badge.warn { background: var(--warning-bg); color: var(--warning-text); border-color: transparent; }
.badge.danger { background: var(--danger-bg); color: var(--danger-text); border-color: transparent; }
.alert { padding: 12px 15px; border-radius: var(--r-md); margin-bottom: 16px; line-height: 1.6; overflow-wrap: anywhere; font-size: 13px; border: 1px solid color-mix(in srgb, currentColor 22%, transparent); }
.alert.error { background: var(--danger-bg); color: var(--danger-text); }
.alert.success { background: var(--success-bg); color: var(--success-text); }
.alert.warning { background: var(--warning-bg); color: var(--warning-text); }
/* ===== 弹窗 ===== */
.overlay {
position: fixed;
inset: 0;
background: var(--overlay);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
z-index: 20;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
animation: fade-in .15s ease;
}
.dialog {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
padding: 24px 26px;
width: 560px;
max-width: 100%;
max-height: 90vh;
overflow: auto;
box-shadow: var(--shadow-lg);
animation: slide-up .18s ease;
}
.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.dialog-head h2 { padding: 0; font-size: 18px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
@keyframes fade-in { 0% { opacity: 0; } }
@keyframes slide-up { 0% { opacity: 0; transform: translateY(10px) scale(.985); } }
/* ===== 渠道编辑 ===== */
.channel-models { margin: 20px 0; }
.channel-models .section-head { flex-wrap: wrap; gap: 10px; }
.channel-model-list { max-height: 320px; overflow-y: auto; margin: 12px 0; }
.channel-model-row { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto; align-items: end; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.channel-model-row label { margin: 0; min-width: 0; }
.channel-model-row .check { align-self: center; }
@media (max-width: 600px) {
.channel-model-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.channel-model-row .check { grid-column: 1 / -1; }
}
.channel-options { background: var(--surface-2); border: 1px solid var(--border); padding: 13px 15px; border-radius: var(--r-md); max-height: 220px; overflow: auto; margin-bottom: 15px; transition: background-color .25s ease; }
.channel-options p { margin: 0 0 8px; font-size: 12.5px; color: var(--text-2); }
.channel-options .check { margin-bottom: 8px; }
.channel-options .check:last-child { margin-bottom: 0; }
/* ===== 筛选栏 ===== */
.filters { padding: 18px 20px 14px; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; }
.filters label { margin: 0; flex: 1; min-width: 170px; }
.filters-actions { display: flex; gap: 8px; }
.quick { display: flex; gap: 8px; align-items: center; width: 100%; border-top: 1px dashed var(--border); padding-top: 12px; margin-top: 2px; }
.quick button { padding: 5px 14px; font-size: 12px; border-radius: 999px; }
.quick small { margin-left: auto; }
/* ===== 图表 ===== */
.chart { padding: 0 22px 6px; }
.chart-empty { padding: 36px 0 30px; text-align: center; color: var(--text-3); font-size: 12.5px; }
.chart-plot { position: relative; height: 220px; margin-left: 42px; }
.chart-grid { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--border); }
.chart-grid span { position: absolute; right: 100%; margin-right: 8px; top: -7px; font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; }
.chart-bars { position: absolute; left: 0; right: 0; top: 0; bottom: 26px; display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
.chart-col { flex: 1; position: relative; display: flex; min-width: 0; }
.chart-bar { flex: 1; max-width: 46px; margin: 0 auto; display: flex; flex-direction: column; justify-content: flex-end; }
.chart-bar i { display: block; width: 100%; opacity: .92; transition: opacity .12s ease; }
.chart-col:hover .chart-bar i { opacity: 1; }
/* 柱顶圆角:output 在栈顶自带圆角;input 顶部圆角并通过 -4px 重叠遮住接缝 */
.chart-bar .seg-input { background: linear-gradient(180deg, color-mix(in srgb, var(--chart-input) 76%, #ffffff), var(--chart-input)); border-radius: 4px 4px 0 0; margin-top: -4px; }
.chart-bar .seg-output { background: linear-gradient(180deg, color-mix(in srgb, var(--chart-output) 76%, #ffffff), var(--chart-output)); border-radius: 4px 4px 0 0; }
.chart-x { position: absolute; top: 100%; margin-top: 8px; left: -14px; right: -14px; text-align: center; font-size: 11px; color: var(--text-3); white-space: nowrap; }
.chart-legend { display: flex; gap: 18px; padding: 34px 0 8px; font-size: 12px; color: var(--text-2); }
.chart-legend span { display: inline-flex; align-items: center; gap: 7px; }
.chart-legend i { width: 10px; height: 10px; border-radius: 3px; }
.seg-input { background: var(--chart-input); }
.seg-output { background: var(--chart-output); }
/* ===== 登录页 ===== */
.login {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(1000px 500px at 15% -10%, var(--accent-soft), transparent 60%),
radial-gradient(800px 400px at 100% 110%, var(--accent-soft), transparent 55%),
var(--bg);
transition: background-color .25s ease;
}
.login-card {
background: var(--surface);
width: 400px;
max-width: 100%;
padding: 40px 38px 34px;
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: var(--shadow-md);
transition: background-color .25s ease, border-color .25s ease;
}
.login-card .mark { margin-bottom: 22px; width: 46px; height: 46px; border-radius: 13px; }
.login-card h1 { margin: 0 0 6px; font-size: 23px; }
.login-card > .muted { margin: 0 0 22px; }
.login-card .primary { width: 100%; margin-top: 6px; padding: 10px; }
/* ===== 响应式 ===== */
@media (max-width: 1100px) {
main { padding: 24px 22px 32px; }
aside { width: 198px; padding: 22px 12px 16px; }
.metrics { gap: 10px; }
.metric { padding: 15px 16px; }
.metric strong { font-size: 23px; }
th, td { padding: 11px 14px; }
.filters label { min-width: 150px; }
}
@media (max-width: 760px) {
.shell { display: block; }
aside { position: static; width: 100%; height: auto; padding: 16px; background-image: none; }
.brand { margin-bottom: 14px; font-size: 16px; }
.brand small { display: none; }
.mark { width: 34px; height: 34px; border-radius: 9px; }
nav { display: flex; gap: 6px; }
nav button { flex: 1; justify-content: center; padding: 9px 6px; }
nav button.active::before { display: none; }
.aside-bottom { margin-top: 12px; display: flex; gap: 8px; border-top: 0; padding-top: 0; }
.aside-bottom button { flex: 1; justify-content: center; padding: 7px 8px; font-size: 12.5px; }
.aside-bottom .theme-seg { flex: 1; }
main { padding: 20px 14px 28px; }
h1 { font-size: 22px; }
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filters { padding: 14px; gap: 10px; }
.filters label { min-width: 100%; }
.filters-actions { width: 100%; }
.filters-actions button { flex: 1; }
.section-head { padding-right: 14px; }
/* 模型页头部:标题与按钮同行,说明文字独占一行沉底 */
.models-head > small { order: 3; width: 100%; }
.models-head .actions { margin-left: auto; }
/* 窄屏下保持列宽、横向滚动,避免单元格被挤压断词 */
.table-wrap table { min-width: 620px; }
/* 首列长标识(Key 名/模型名)截断省略,防止被挤成逐字竖排;全名见 title */
.cell-id { display: inline-block; max-width: 148px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
th, td { padding: 11px 16px; }
h2 { padding: 16px 14px 12px; }
.chart { padding: 0 14px 4px; }
.chart-plot { margin-left: 34px; height: 180px; }
.dialog { padding: 20px; }
.overlay { padding: 12px; }
.login-card { padding: 30px 24px 26px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
+34
View File
@@ -0,0 +1,34 @@
export type Theme = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'theme'
/** 读取初始主题:优先用户保存的选择,否则跟随系统偏好。 */
export function initialTheme(): Theme {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'light' || saved === 'dark' || saved === 'system') return saved
} catch {
// localStorage 不可用(隐私模式等),回退到系统偏好
}
return 'system'
}
/** 应用主题到 <html> 并持久化;同步移动端浏览器 chrome 颜色。 */
export function applyTheme(theme: Theme) {
const media = window.matchMedia('(prefers-color-scheme: dark)')
const update = () => {
const resolved = theme === 'system' ? (media.matches ? 'dark' : 'light') : theme
document.documentElement.dataset.theme = resolved
document.documentElement.style.colorScheme = resolved
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', resolved === 'dark' ? '#090a16' : '#181a34')
}
update()
if (theme === 'system') media.addEventListener('change', update)
try {
localStorage.setItem(STORAGE_KEY, theme)
} catch {
// 忽略持久化失败,主题仍在本次会话内生效
}
return () => { if (theme === 'system') media.removeEventListener('change', update) }
}
+38
View File
@@ -0,0 +1,38 @@
export function formatTime(value: string): string {
return !value || value.startsWith('0001-') ? '—' : new Date(value).toLocaleString('zh-CN')
}
function localInput(d: Date): string {
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16)
}
export function localToUTC(value: string): string {
const d = new Date(value)
if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}$/.test(value) || !Number.isFinite(d.getTime()) || localInput(d) !== value) throw new Error('无效的本地时间。')
return d.toISOString()
}
export function quickRange(days: number, now = Date.now()) {
const to = new Date(now), from = new Date(now)
from.setHours(0, 0, 0, 0)
from.setDate(from.getDate() - days + 1)
return { from: from.toISOString(), to: to.toISOString(), fromInput: localInput(from), toInput: localInput(to) }
}
export function filterParams(filter: { key_id: string; from: string; to: string }) {
const from = filter.from ? localToUTC(filter.from) : ''
const to = filter.to ? localToUTC(filter.to) : ''
if (from && to && from > to) throw new Error('开始时间不能晚于结束时间。')
const params = new URLSearchParams()
if (filter.key_id) params.set('key_id', filter.key_id)
if (from) params.set('from', from)
if (to) params.set('to', to)
return params
}
export function keyExpiry(value: string, now = Date.now()) {
if (!value) return ''
const expires = localToUTC(value)
if (new Date(expires).getTime() <= now) throw new Error('有效期必须晚于当前时间。')
return expires
}
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "strict": true, "skipLibCheck": true, "noEmit": true, "allowImportingTsExtensions": true },
"include": ["src", "vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig, type ProxyOptions } from 'vite'
import react from '@vitejs/plugin-react'
const proxy: ProxyOptions = {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
configure(server) {
server.on('proxyReq', request => {
if (request.getHeader('origin')) request.setHeader('origin', 'http://127.0.0.1:8080')
})
},
}
export default defineConfig({ plugins: [react()], server: { proxy: { '/admin': proxy, '/v1': proxy } } })