Initial project scaffold

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-09 14:36:35 +08:00
co-authored by Cursor
commit 6323e70e49
39 changed files with 8664 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
import { useEffect, useMemo, useState } from 'react';
import type {
BaseModelCatalogItem,
CatalogProvider,
IntegrationPlatform,
PlatformModel,
PricingRule,
RateLimitWindow,
} from '@easyai-ai-gateway/contracts';
import {
getHealth,
listBaseModels,
listCatalogProviders,
listModels,
listPlatforms,
listPricingRules,
listRateLimitWindows,
type HealthResponse,
} from './api';
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
export function App() {
const [token, setToken] = useState('');
const [health, setHealth] = useState<HealthResponse | null>(null);
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
const [models, setModels] = useState<PlatformModel[]>([]);
const [providers, setProviders] = useState<CatalogProvider[]>([]);
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
const [state, setState] = useState<LoadState>('idle');
const [error, setError] = useState('');
useEffect(() => {
getHealth()
.then(setHealth)
.catch((err: Error) => setError(err.message));
}, []);
const stats = useMemo(() => {
const enabledPlatforms = platforms.filter((item) => item.status === 'enabled').length;
const enabledModels = models.filter((item) => item.enabled).length;
const activeProviders = providers.filter((item) => item.status === 'active').length;
const activeRateWindows = rateLimitWindows.filter((item) => item.resetAt >= new Date().toISOString()).length;
return [
{ label: '平台', value: platforms.length, tone: 'blue' },
{ label: '启用平台', value: enabledPlatforms, tone: 'green' },
{ label: '基准模型', value: baseModels.length, tone: 'violet' },
{ label: 'Provider', value: activeProviders || providers.length || enabledModels, tone: 'amber' },
{ label: '定价规则', value: pricingRules.length, tone: 'cyan' },
{ label: '限流窗口', value: activeRateWindows, tone: 'rose' },
];
}, [baseModels.length, models, platforms, pricingRules.length, providers, rateLimitWindows]);
async function refresh() {
setState('loading');
setError('');
try {
const [
platformResponse,
modelResponse,
providerResponse,
baseModelResponse,
pricingRuleResponse,
rateLimitWindowResponse,
] = await Promise.all([
listPlatforms(token),
listModels(token),
listCatalogProviders(token),
listBaseModels(token),
listPricingRules(token),
listRateLimitWindows(token),
]);
setPlatforms(platformResponse.items);
setModels(modelResponse.items);
setProviders(providerResponse.items);
setBaseModels(baseModelResponse.items);
setPricingRules(pricingRuleResponse.items);
setRateLimitWindows(rateLimitWindowResponse.items);
setState('ready');
} catch (err) {
setState('error');
setError(err instanceof Error ? err.message : '加载失败');
}
}
return (
<main className="page">
<header className="topbar">
<div>
<p className="eyebrow">EasyAI</p>
<h1>AI Gateway Console</h1>
</div>
<div className="health" data-ok={health?.ok === true}>
<span />
{health?.service ?? 'API 未连接'}
</div>
</header>
<section className="toolbar" aria-label="授权与刷新">
<label className="tokenField">
<span>Server Main JWT</span>
<input
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="粘贴 server-main access_token"
/>
</label>
<button type="button" onClick={refresh} disabled={!token || state === 'loading'}>
{state === 'loading' ? '加载中' : '刷新'}
</button>
</section>
{error && <div className="notice">{error}</div>}
<section className="metrics" aria-label="概览">
{stats.map((item) => (
<div className="metric" data-tone={item.tone} key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</section>
<section className="split">
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{platforms.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
<span>Provider</span>
<span></span>
<span></span>
<span></span>
</div>
{platforms.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.provider}</span>
<span>{item.name}</span>
<span>{item.status}</span>
<span>{item.priority}</span>
</div>
))}
{!platforms.length && <p className="empty"></p>}
</div>
</div>
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{models.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{models.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.modelName}</span>
<span>{item.modelType}</span>
<span>{item.provider ?? item.platformName}</span>
<span>{item.enabled ? '是' : '否'}</span>
</div>
))}
{!models.length && <p className="empty"></p>}
</div>
</div>
</section>
<section className="split secondary">
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{baseModels.length}</span>
</div>
<div className="table catalogTable" role="table">
<div className="row head" role="row">
<span>Provider</span>
<span></span>
<span></span>
<span></span>
</div>
{baseModels.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.providerKey}</span>
<span>{item.canonicalModelKey}</span>
<span>{item.modelType}</span>
<span>{item.pricingVersion}</span>
</div>
))}
{!baseModels.length && <p className="empty"></p>}
</div>
</div>
<div className="panel">
<div className="panelHeader">
<h2>TPM/RPM </h2>
<span>{rateLimitWindows.length}</span>
</div>
<div className="table rateTable" role="table">
<div className="row head" role="row">
<span>Scope</span>
<span></span>
<span>使</span>
<span></span>
</div>
{rateLimitWindows.map((item) => (
<div className="row" role="row" key={`${item.scopeType}:${item.scopeKey}:${item.metric}:${item.windowStart}`}>
<span>{item.scopeKey}</span>
<span>{item.metric}</span>
<span>{item.usedValue}/{item.limitValue}</span>
<span>{item.reservedValue}</span>
</div>
))}
{!rateLimitWindows.length && <p className="empty"></p>}
</div>
</div>
</section>
</main>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type {
BaseModelCatalogItem,
CatalogProvider,
IntegrationPlatform,
ListResponse,
PlatformModel,
PricingRule,
RateLimitWindow,
} from '@easyai-ai-gateway/contracts';
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
export interface HealthResponse {
ok: boolean;
service: string;
env: string;
}
export async function getHealth(): Promise<HealthResponse> {
return request<HealthResponse>('/healthz', { auth: false });
}
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
return request<ListResponse<IntegrationPlatform>>('/api/v1/platforms', { token });
}
export async function listModels(token: string): Promise<ListResponse<PlatformModel>> {
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
}
export async function listCatalogProviders(token: string): Promise<ListResponse<CatalogProvider>> {
return request<ListResponse<CatalogProvider>>('/api/v1/catalog/providers', { token });
}
export async function listBaseModels(token: string): Promise<ListResponse<BaseModelCatalogItem>> {
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/catalog/base-models', { token });
}
export async function listPricingRules(token: string): Promise<ListResponse<PricingRule>> {
return request<ListResponse<PricingRule>>('/api/v1/pricing/rules', { token });
}
export async function listRateLimitWindows(token: string): Promise<ListResponse<RateLimitWindow>> {
return request<ListResponse<RateLimitWindow>>('/api/v1/runtime/rate-limit-windows', { token });
}
async function request<T>(path: string, options: { token?: string; auth?: boolean } = {}): Promise<T> {
const headers: Record<string, string> = {};
if (options.auth !== false && options.token) {
headers.Authorization = `Bearer ${options.token}`;
}
const response = await fetch(`${API_BASE}${path}`, {
headers,
});
if (!response.ok) {
const body = await response.text();
throw new Error(body || `Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+286
View File
@@ -0,0 +1,286 @@
:root {
color: #172033;
background: #f5f7fb;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input {
font: inherit;
}
.page {
width: min(1180px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 48px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 4px;
color: #667085;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 30px;
line-height: 1.2;
}
h2 {
font-size: 18px;
}
.health {
display: inline-flex;
align-items: center;
gap: 8px;
color: #667085;
font-size: 14px;
}
.health span {
width: 9px;
height: 9px;
border-radius: 50%;
background: #c43f3f;
}
.health[data-ok="true"] span {
background: #1b8a5a;
}
.toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: end;
padding: 16px;
margin-bottom: 18px;
border: 1px solid #dde3ee;
border-radius: 8px;
background: #ffffff;
}
.tokenField {
display: grid;
gap: 8px;
color: #4a5568;
font-size: 13px;
font-weight: 700;
}
.tokenField input {
width: 100%;
min-height: 42px;
padding: 0 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
color: #172033;
outline: none;
}
.tokenField input:focus {
border-color: #2b6cb0;
box-shadow: 0 0 0 3px rgba(43, 108, 176, 0.14);
}
button {
min-height: 42px;
padding: 0 18px;
border: 0;
border-radius: 6px;
background: #214e8a;
color: #ffffff;
font-weight: 700;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.notice {
padding: 12px 14px;
margin-bottom: 18px;
border: 1px solid #f0b8b8;
border-radius: 8px;
background: #fff1f1;
color: #9b2c2c;
font-size: 14px;
}
.metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.metric {
min-height: 96px;
padding: 16px;
border: 1px solid #dde3ee;
border-radius: 8px;
background: #ffffff;
}
.metric span {
color: #667085;
font-size: 13px;
font-weight: 700;
}
.metric strong {
display: block;
margin-top: 10px;
font-size: 30px;
}
.metric[data-tone="blue"] {
border-top: 3px solid #2b6cb0;
}
.metric[data-tone="green"] {
border-top: 3px solid #1b8a5a;
}
.metric[data-tone="violet"] {
border-top: 3px solid #6b46c1;
}
.metric[data-tone="amber"] {
border-top: 3px solid #b7791f;
}
.metric[data-tone="cyan"] {
border-top: 3px solid #087f8c;
}
.metric[data-tone="rose"] {
border-top: 3px solid #b8325f;
}
.split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.split.secondary {
margin-top: 18px;
}
.panel {
overflow: hidden;
border: 1px solid #dde3ee;
border-radius: 8px;
background: #ffffff;
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #e7ecf4;
}
.panelHeader span {
color: #667085;
font-size: 13px;
font-weight: 700;
}
.table {
display: grid;
}
.row {
display: grid;
grid-template-columns: 1.2fr 1.2fr 0.8fr 0.6fr;
gap: 12px;
min-height: 46px;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid #edf1f7;
color: #2d3748;
font-size: 14px;
}
.row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row.head {
min-height: 38px;
background: #f8fafc;
color: #667085;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.empty {
padding: 22px 16px;
color: #667085;
font-size: 14px;
}
@media (max-width: 860px) {
.topbar,
.toolbar {
grid-template-columns: 1fr;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.metrics,
.split {
grid-template-columns: 1fr;
}
.row {
grid-template-columns: 1fr 0.8fr;
padding: 10px 16px;
}
}
@media (min-width: 861px) and (max-width: 1180px) {
.metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />