easyai-ai-gateway/apps/web/src/App.tsx

604 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState, type FormEvent } from 'react';
import type {
BaseModelCatalogItem,
CatalogProvider,
GatewayTenant,
GatewayUser,
IntegrationPlatform,
PlatformModel,
PricingRule,
RateLimitWindow,
UserGroup,
} from '@easyai-ai-gateway/contracts';
import {
getHealth,
listBaseModels,
listCatalogProviders,
listModels,
listPlatforms,
listPricingRules,
listRateLimitWindows,
listTenants,
listUserGroups,
listUsers,
loginLocalAccount,
registerLocalAccount,
type HealthResponse,
} from './api';
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
type AuthMode = 'login' | 'register' | 'external';
const primaryModules = [
{
title: '首页',
path: '/',
description: '服务状态、推荐模型、最近任务、用量摘要和快捷入口。',
items: ['能力概览', '最近任务', '用量摘要'],
},
{
title: '模型',
path: '/models',
description: '按能力、价格、限流和 provider 浏览模型,并进入在线试用。',
items: ['模型广场', '模型详情', '调用测试'],
},
{
title: '用户工作台',
path: '/workspace',
description: '个人中心、身份来源、余额充值、API Key 管理和任务记录。',
items: ['个人总览', '身份来源', '余额充值', 'API Key', '任务记录'],
},
{
title: '管理工作台',
path: '/admin',
description: '租户、用户、用户组、全局模型、平台、限流、重试、队列和回调 outbox。',
items: ['租户管理', '用户管理', '用户组策略', '全局模型', '队列限流'],
},
{
title: 'API 文档',
path: '/docs',
description: '开放接口、鉴权、错误码、示例代码和在线调用测试。',
items: ['快速开始', '接口文档', '在线调试'],
},
];
const workspacePages = [
{ title: '个人中心总览', path: '/workspace/overview', description: '账号、身份来源、租户、角色、用户组、余额、API Key 数、最近任务和用量摘要。' },
{ title: '余额与充值', path: '/workspace/billing', description: '余额、资源包、充值入口、用户组折扣、消费记录和订单状态。' },
{ title: 'API Key 管理', path: '/workspace/api-keys', description: '创建、禁用、重置、权限范围和最近调用记录。' },
{ title: '任务记录', path: '/workspace/tasks', description: 'Chat、生图、生视频任务列表、进度、结果和计费明细。' },
];
const adminPages = [
{ title: '租户管理', path: '/admin/tenants', description: '本地租户、同步租户、租户策略、状态和用量隔离。' },
{ title: '用户管理', path: '/admin/users', description: '本地用户、同步用户、角色、状态、同步差异和用户组命中。' },
{ title: '用户组策略', path: '/admin/user-groups', description: '用户组成员、充值折扣、调用折扣、TPM/RPM/并发和队列优先级。' },
{ title: '全局模型配置', path: '/admin/models/global', description: '基准模型库、能力 schema、基准定价和默认限流模板。' },
{ title: '平台管理', path: '/admin/platforms', description: '平台 CRUD、凭证、默认折扣、平台模型、限流和重试策略。' },
{ title: '运行与队列', path: '/admin/runtime/queues', description: 'TPM/RPM 窗口、并发 lease、cooldown、任务恢复和队列积压。' },
{ title: '回调与结算', path: '/admin/callbacks', description: '任务进度 callback outbox、结算 outbox、失败重试和手动 replay。' },
];
const apiDocPages = [
{ title: '鉴权与限流', path: '/docs/auth', description: '本地账号、JWT、OpenAPI Key、TPM/RPM/并发限制和错误码。' },
{ title: 'Chat / Responses', path: '/docs/api/chat', description: '对话、stream、结构化输出、取消请求和示例代码。' },
{ title: '图片 / 视频', path: '/docs/api/media', description: '生图、图像编辑、生视频、任务进度和结果取回。' },
{ title: '在线调用测试', path: '/docs/playground', description: '选择模型和 API Key编辑参数查看实时响应和 billings。' },
];
export function App() {
const [token, setToken] = useState('');
const [externalToken, setExternalToken] = useState('');
const [authMode, setAuthMode] = useState<AuthMode>('login');
const [loginForm, setLoginForm] = useState({ account: '', password: '' });
const [registerForm, setRegisterForm] = useState({
username: '',
email: '',
password: '',
displayName: '',
tenantKey: '',
tenantName: '',
invitationCode: '',
});
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 [tenants, setTenants] = useState<GatewayTenant[]>([]);
const [users, setUsers] = useState<GatewayUser[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
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: tenants.length, tone: 'cyan' },
{ label: '用户', value: users.length, tone: 'green' },
{ label: '用户组', value: userGroups.length, tone: 'blue' },
{ 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, tenants.length, userGroups.length, users.length]);
async function refresh(nextToken = token) {
setState('loading');
setError('');
try {
const [
platformResponse,
modelResponse,
providerResponse,
baseModelResponse,
pricingRuleResponse,
rateLimitWindowResponse,
tenantResponse,
userResponse,
userGroupResponse,
] = await Promise.all([
listPlatforms(nextToken),
listModels(nextToken),
listCatalogProviders(nextToken),
listBaseModels(nextToken),
listPricingRules(nextToken),
listRateLimitWindows(nextToken),
listTenants(nextToken),
listUsers(nextToken),
listUserGroups(nextToken),
]);
setPlatforms(platformResponse.items);
setModels(modelResponse.items);
setProviders(providerResponse.items);
setBaseModels(baseModelResponse.items);
setPricingRules(pricingRuleResponse.items);
setRateLimitWindows(rateLimitWindowResponse.items);
setTenants(tenantResponse.items);
setUsers(userResponse.items);
setUserGroups(userGroupResponse.items);
setState('ready');
} catch (err) {
setState('error');
setError(err instanceof Error ? err.message : '加载失败');
}
}
async function submitLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setState('loading');
setError('');
try {
const response = await loginLocalAccount(loginForm);
setToken(response.accessToken);
await refresh(response.accessToken);
} catch (err) {
setState('error');
setError(err instanceof Error ? err.message : '登录失败');
}
}
async function submitRegister(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setState('loading');
setError('');
try {
const response = await registerLocalAccount(registerForm);
setToken(response.accessToken);
await refresh(response.accessToken);
} catch (err) {
setState('error');
setError(err instanceof Error ? err.message : '注册失败');
}
}
async function submitExternalToken(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextToken = externalToken.trim();
if (!nextToken) {
setError('请填写 access token');
return;
}
setToken(nextToken);
await refresh(nextToken);
}
function signOut() {
setToken('');
setState('idle');
setPlatforms([]);
setModels([]);
setProviders([]);
setBaseModels([]);
setPricingRules([]);
setRateLimitWindows([]);
setTenants([]);
setUsers([]);
setUserGroups([]);
}
return (
<main className="page">
<header className="topbar">
<div>
<p className="eyebrow">EasyAI</p>
<h1>AI Gateway Console</h1>
</div>
<div className="topbarActions">
<div className="health" data-ok={health?.ok === true}>
<span />
{health?.identityMode ? `${health.service} · ${health.identityMode}` : health?.service ?? 'API 未连接'}
</div>
{token && (
<button type="button" className="ghostButton" onClick={signOut}>
退
</button>
)}
</div>
</header>
{!token ? (
<AuthPanel
authMode={authMode}
externalToken={externalToken}
loginForm={loginForm}
registerForm={registerForm}
state={state}
onAuthModeChange={setAuthMode}
onExternalTokenChange={setExternalToken}
onLoginChange={setLoginForm}
onRegisterChange={setRegisterForm}
onSubmitExternalToken={submitExternalToken}
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
/>
) : (
<>
<section className="toolbar" aria-label="授权与刷新">
<label className="tokenField">
<span>Access Token</span>
<input value={token} onChange={(event) => setToken(event.target.value)} />
</label>
<button type="button" onClick={() => refresh()} disabled={!token || state === 'loading'}>
{state === 'loading' ? '加载中' : '刷新'}
</button>
</section>
<Dashboard
baseModels={baseModels}
models={models}
platforms={platforms}
rateLimitWindows={rateLimitWindows}
stats={stats}
/>
</>
)}
{error && <div className="notice">{error}</div>}
</main>
);
}
function AuthPanel(props: {
authMode: AuthMode;
externalToken: string;
loginForm: { account: string; password: string };
registerForm: {
username: string;
email: string;
password: string;
displayName: string;
tenantKey: string;
tenantName: string;
invitationCode: string;
};
state: LoadState;
onAuthModeChange: (value: AuthMode) => void;
onExternalTokenChange: (value: string) => void;
onLoginChange: (value: { account: string; password: string }) => void;
onRegisterChange: (value: {
username: string;
email: string;
password: string;
displayName: string;
tenantKey: string;
tenantName: string;
invitationCode: string;
}) => void;
onSubmitExternalToken: (event: FormEvent<HTMLFormElement>) => void;
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<section className="authShell" aria-label="登录">
<div className="authPanel">
<div className="authHeader">
<p className="eyebrow">Gateway Identity</p>
<h2> AI Gateway</h2>
</div>
<div className="segmented" role="tablist">
{[
['login', '账号登录'],
['register', '注册账号'],
['external', '外部 Token'],
].map(([value, label]) => (
<button
type="button"
className="segmentButton"
data-active={props.authMode === value}
key={value}
onClick={() => props.onAuthModeChange(value as AuthMode)}
>
{label}
</button>
))}
</div>
{props.authMode === 'login' && (
<form className="authForm" onSubmit={props.onSubmitLogin}>
<label>
<span></span>
<input
autoComplete="username"
value={props.loginForm.account}
onChange={(event) => props.onLoginChange({ ...props.loginForm, account: event.target.value })}
placeholder="用户名或邮箱"
/>
</label>
<label>
<span></span>
<input
autoComplete="current-password"
type="password"
value={props.loginForm.password}
onChange={(event) => props.onLoginChange({ ...props.loginForm, password: event.target.value })}
placeholder="至少 8 位"
/>
</label>
<button type="submit" disabled={props.state === 'loading'}>
{props.state === 'loading' ? '登录中' : '登录'}
</button>
</form>
)}
{props.authMode === 'register' && (
<form className="authForm twoColumn" onSubmit={props.onSubmitRegister}>
<label>
<span></span>
<input
autoComplete="username"
value={props.registerForm.username}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, username: event.target.value })}
placeholder="demo"
/>
</label>
<label>
<span></span>
<input
autoComplete="email"
type="email"
value={props.registerForm.email}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, email: event.target.value })}
placeholder="demo@example.com"
/>
</label>
<label>
<span></span>
<input
value={props.registerForm.displayName}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, displayName: event.target.value })}
placeholder="Demo User"
/>
</label>
<label>
<span></span>
<input
autoComplete="new-password"
type="password"
value={props.registerForm.password}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, password: event.target.value })}
placeholder="至少 8 位"
/>
</label>
<label>
<span> Key</span>
<input
value={props.registerForm.tenantKey}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, tenantKey: event.target.value })}
placeholder="team-a"
/>
</label>
<label>
<span></span>
<input
value={props.registerForm.tenantName}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, tenantName: event.target.value })}
placeholder="Team A"
/>
</label>
<label>
<span></span>
<input
value={props.registerForm.invitationCode}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, invitationCode: event.target.value })}
placeholder="可选"
/>
</label>
<button type="submit" disabled={props.state === 'loading'}>
{props.state === 'loading' ? '注册中' : '注册并登录'}
</button>
</form>
)}
{props.authMode === 'external' && (
<form className="authForm" onSubmit={props.onSubmitExternalToken}>
<label>
<span>Access Token</span>
<input
value={props.externalToken}
onChange={(event) => props.onExternalTokenChange(event.target.value)}
placeholder="粘贴 server-main access_token"
/>
</label>
<button type="submit" disabled={props.state === 'loading'}>
{props.state === 'loading' ? '验证中' : '进入控制台'}
</button>
</form>
)}
</div>
</section>
);
}
function Dashboard(props: {
baseModels: BaseModelCatalogItem[];
models: PlatformModel[];
platforms: IntegrationPlatform[];
rateLimitWindows: RateLimitWindow[];
stats: Array<{ label: string; value: number; tone: string }>;
}) {
return (
<>
<section className="moduleBand" aria-label="一级页面">
<div className="sectionHeader">
<div>
<p className="eyebrow">Navigation</p>
<h2></h2>
</div>
<span>5 </span>
</div>
<div className="moduleGrid">
{primaryModules.map((item) => (
<article className="moduleCard" key={item.path}>
<div className="moduleCardTop">
<h3>{item.title}</h3>
<span>{item.path}</span>
</div>
<p>{item.description}</p>
<div className="moduleTags">
{item.items.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</article>
))}
</div>
</section>
<section className="moduleBand" aria-label="工作台与文档">
<div className="sectionHeader">
<div>
<p className="eyebrow">Workspace</p>
<h2> API </h2>
</div>
<span></span>
</div>
<div className="detailGrid">
<ModuleList title="用户工作台" items={workspacePages} />
<ModuleList title="管理工作台" items={adminPages} />
<ModuleList title="API 文档" items={apiDocPages} />
</div>
</section>
<section className="metrics" aria-label="概览">
{props.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">
<DataPanel
columns={['Provider', '名称', '状态', '优先级']}
empty="暂无平台数据"
rows={props.platforms.map((item) => [item.provider, item.name, item.status, String(item.priority)])}
title="平台"
/>
<DataPanel
columns={['模型', '类型', '平台', '启用']}
empty="暂无模型数据"
rows={props.models.map((item) => [item.modelName, item.modelType, item.provider ?? item.platformName ?? '-', item.enabled ? '是' : '否'])}
title="模型"
/>
</section>
<section className="split secondary">
<DataPanel
columns={['Provider', '模型', '类型', '版本']}
empty="暂无基准模型"
rows={props.baseModels.map((item) => [item.providerKey, item.canonicalModelKey, item.modelType, String(item.pricingVersion)])}
title="基准模型库"
/>
<DataPanel
columns={['Scope', '指标', '使用', '预占']}
empty="暂无限流窗口"
rows={props.rateLimitWindows.map((item) => [item.scopeKey, item.metric, `${item.usedValue}/${item.limitValue}`, String(item.reservedValue)])}
title="TPM/RPM 窗口"
/>
</section>
</>
);
}
function DataPanel(props: { columns: string[]; empty: string; rows: string[][]; title: string }) {
return (
<div className="panel">
<div className="panelHeader">
<h2>{props.title}</h2>
<span>{props.rows.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
{props.columns.map((column) => (
<span key={column}>{column}</span>
))}
</div>
{props.rows.map((row, index) => (
<div className="row" role="row" key={`${props.title}-${index}`}>
{row.map((cell, cellIndex) => (
<span key={`${props.title}-${index}-${cellIndex}`}>{cell}</span>
))}
</div>
))}
{!props.rows.length && <p className="empty">{props.empty}</p>}
</div>
</div>
);
}
function ModuleList(props: {
title: string;
items: Array<{ title: string; path: string; description: string }>;
}) {
return (
<div className="moduleList">
<h3>{props.title}</h3>
{props.items.map((item) => (
<div className="moduleRow" key={item.path}>
<div>
<strong>{item.title}</strong>
<p>{item.description}</p>
</div>
<span>{item.path}</span>
</div>
))}
</div>
);
}