chore: initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
@@ -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>
|
||||
Generated
+1883
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 } } })
|
||||
Reference in New Issue
Block a user