feat(web): add reusable admin form dialog

This commit is contained in:
2026-05-09 20:15:35 +08:00
parent c0335bd5d0
commit a5e66e79cd
49 changed files with 6013 additions and 1108 deletions
+308
View File
@@ -0,0 +1,308 @@
import type { FormEvent, ReactNode } from 'react';
import { Boxes, Building2, Gauge, KeyRound, Route, ServerCog, ShieldCheck, UsersRound, Workflow } from 'lucide-react';
import type { BaseModelUpsertRequest, CatalogProviderUpsertRequest, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
import type { ConsoleData, StatItem } from '../app-state';
import { EntityTable } from '../components/EntityTable';
import { PageHeader } from '../components/PageHeader';
import { StatGrid } from '../components/StatGrid';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Select, Tabs } from '../components/ui';
import type { AdminSection, LoadState, PlatformForm, PlatformModelForm } from '../types';
import { BaseModelCatalogPanel } from './admin/BaseModelCatalogPanel';
import { PricingRulesPanel } from './admin/PricingRulesPanel';
import { ProviderManagementPanel } from './admin/ProviderManagementPanel';
const tabs = [
{ value: 'overview', label: '总览', icon: <Workflow size={15} /> },
{ value: 'globalModels', label: 'Provider', icon: <Boxes size={15} /> },
{ value: 'baseModels', label: '基准模型库', icon: <Boxes size={15} /> },
{ value: 'pricing', label: '定价规则', icon: <Gauge size={15} /> },
{ value: 'platforms', label: '平台管理', icon: <ServerCog size={15} /> },
{ value: 'tenants', label: '租户', icon: <Building2 size={15} /> },
{ value: 'users', label: '用户', icon: <UsersRound size={15} /> },
{ value: 'userGroups', label: '用户组', icon: <UsersRound size={15} /> },
{ value: 'runtime', label: '运行限流', icon: <Gauge size={15} /> },
] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>;
export function AdminPage(props: {
data: ConsoleData;
platformForm: PlatformForm;
platformModelForm: PlatformModelForm;
operationMessage: string;
section: AdminSection;
stats: StatItem[];
state: LoadState;
onDeleteBaseModel: (baseModelId: string) => Promise<void>;
onDeleteProvider: (providerId: string) => Promise<void>;
onDeletePricingRuleSet: (ruleSetId: string) => Promise<void>;
onPlatformFormChange: (value: PlatformForm) => void;
onPlatformModelFormChange: (value: PlatformModelForm) => void;
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
onSaveProvider: (input: CatalogProviderUpsertRequest, providerId?: string) => Promise<void>;
onSavePricingRuleSet: (input: PricingRuleSetUpsertRequest, ruleSetId?: string) => Promise<void>;
onSectionChange: (value: AdminSection) => void;
onSubmitPlatform: (event: FormEvent<HTMLFormElement>) => void;
onSubmitPlatformModel: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<div className="pageStack">
<PageHeader eyebrow="Admin" title="管理工作台" description="全局模型、平台、租户用户和运行策略。" />
<div className="subPageLayout">
<Tabs className="sideTabs" value={props.section} tabs={tabs} onValueChange={props.onSectionChange} />
<div className="subPageContent">
{props.section === 'overview' && <OverviewPanel data={props.data} stats={props.stats} />}
{props.section === 'globalModels' && (
<ProviderManagementPanel
message={props.operationMessage}
providers={props.data.providers}
state={props.state}
onDeleteProvider={props.onDeleteProvider}
onSaveProvider={props.onSaveProvider}
/>
)}
{props.section === 'baseModels' && (
<BaseModelCatalogPanel
baseModels={props.data.baseModels}
message={props.operationMessage}
providers={props.data.providers}
state={props.state}
onDeleteBaseModel={props.onDeleteBaseModel}
onSaveBaseModel={props.onSaveBaseModel}
/>
)}
{props.section === 'pricing' && (
<PricingRulesPanel
message={props.operationMessage}
pricingRuleSets={props.data.pricingRuleSets}
state={props.state}
onDeletePricingRuleSet={props.onDeletePricingRuleSet}
onSavePricingRuleSet={props.onSavePricingRuleSet}
/>
)}
{props.section === 'platforms' && <PlatformsPanel {...props} />}
{props.section === 'tenants' && <TenantsPanel data={props.data} />}
{props.section === 'users' && <UsersPanel data={props.data} />}
{props.section === 'userGroups' && <UserGroupsPanel data={props.data} />}
{props.section === 'runtime' && <RuntimePanel data={props.data} />}
</div>
</div>
</div>
);
}
function OverviewPanel(props: { data: ConsoleData; stats: StatItem[] }) {
const enabledPlatforms = props.data.platforms.filter((item) => item.status === 'enabled');
const chatModels = props.data.models.filter((item) => item.modelType === 'chat' && item.enabled);
const imageModels = props.data.models.filter((item) => item.modelType === 'image' && item.enabled);
return (
<div className="pageStack">
<StatGrid items={props.stats} />
<section className="contentGrid three">
<ManagementCard icon={<Route size={18} />} label="路由候选" value={enabledPlatforms.length} detail="平台优先级 / 失败切换" />
<ManagementCard icon={<ShieldCheck size={18} />} label="策略对象" value={props.data.userGroups.length} detail="用户组 / 折扣 / 并发" />
<ManagementCard icon={<Workflow size={18} />} label="运行窗口" value={props.data.rateLimitWindows.length} detail="TPM / RPM / 并发" />
</section>
<section className="contentGrid two">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<EntityTable
columns={['Provider', '平台', '状态', '优先级']}
empty="暂无平台"
rows={props.data.platforms.slice(0, 6).map((item) => [item.provider, item.name, item.status, item.priority])}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{props.data.taskResult ? (
<div className="taskPreview">
<Badge variant={props.data.taskResult.status === 'succeeded' ? 'success' : 'secondary'}>{props.data.taskResult.status}</Badge>
<strong>{props.data.taskResult.model}</strong>
<pre>{JSON.stringify(props.data.taskResult.result ?? {}, null, 2)}</pre>
</div>
) : (
<div className="emptyState">
<KeyRound size={18} />
<strong></strong>
</div>
)}
</CardContent>
</Card>
</section>
<section className="contentGrid two">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<EntityTable
columns={['能力', '数量', '阶段', '入口']}
empty="暂无模型"
rows={[
['对话', chatModels.length, 'Phase 1', '/v1/chat/completions'],
['图像', imageModels.length, 'Phase 1', '/v1/images/*'],
['视频', props.data.models.filter((item) => item.modelType === 'video').length, 'Next', '/v1/videos/*'],
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<EntityTable
columns={['Scope', '指标', '使用', '限制']}
empty="暂无限流窗口"
rows={props.data.rateLimitWindows.slice(0, 6).map((item) => [item.scopeKey, item.metric, item.usedValue, item.limitValue])}
/>
</CardContent>
</Card>
</section>
</div>
);
}
function ManagementCard(props: { detail: string; icon: ReactNode; label: string; value: number }) {
return (
<Card>
<CardContent className="capabilityCard">
<div className="iconBox">{props.icon}</div>
<div>
<span>{props.label}</span>
<strong>{props.value}</strong>
<small>{props.detail}</small>
</div>
</CardContent>
</Card>
);
}
function PlatformsPanel(props: {
data: ConsoleData;
platformForm: PlatformForm;
platformModelForm: PlatformModelForm;
state: LoadState;
onPlatformFormChange: (value: PlatformForm) => void;
onPlatformModelFormChange: (value: PlatformModelForm) => void;
onSubmitPlatform: (event: FormEvent<HTMLFormElement>) => void;
onSubmitPlatformModel: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<section className="contentGrid two">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form className="formGrid" onSubmit={props.onSubmitPlatform}>
<Label>Provider<Input value={props.platformForm.provider} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, provider: event.target.value })} /></Label>
<Label> Key<Input value={props.platformForm.platformKey} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, platformKey: event.target.value })} /></Label>
<Label><Input value={props.platformForm.name} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, name: event.target.value })} /></Label>
<Label>Base URL<Input value={props.platformForm.baseUrl} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, baseUrl: event.target.value })} /></Label>
<Label>
<Select value={props.platformForm.pricingRuleSetId} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, pricingRuleSetId: event.target.value })}>
<option value=""></option>
{props.data.pricingRuleSets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</Select>
</Label>
<Label><Input value={props.platformForm.defaultDiscountFactor} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, defaultDiscountFactor: event.target.value })} /></Label>
<Button type="submit" disabled={props.state === 'loading'}></Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form className="formGrid" onSubmit={props.onSubmitPlatformModel}>
<Label>
<Select value={props.platformModelForm.platformId} onChange={(event) => props.onPlatformModelFormChange({ ...props.platformModelForm, platformId: event.target.value })}>
<option value=""></option>
{props.data.platforms.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</Select>
</Label>
<Label>
<Select value={props.platformModelForm.canonicalModelKey} onChange={(event) => updateBaseModel(event.target.value, props)}>
<option value=""></option>
{props.data.baseModels.map((item) => <option value={item.canonicalModelKey} key={item.id}>{item.canonicalModelKey}</option>)}
</Select>
</Label>
<Label><Input value={props.platformModelForm.modelName} onChange={(event) => props.onPlatformModelFormChange({ ...props.platformModelForm, modelName: event.target.value })} /></Label>
<Label><Input value={props.platformModelForm.modelAlias} onChange={(event) => props.onPlatformModelFormChange({ ...props.platformModelForm, modelAlias: event.target.value })} /></Label>
<Label>
<Select value={props.platformModelForm.pricingRuleSetId} onChange={(event) => props.onPlatformModelFormChange({ ...props.platformModelForm, pricingRuleSetId: event.target.value })}>
<option value=""></option>
{props.data.pricingRuleSets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</Select>
</Label>
<Label><Input value={props.platformModelForm.discountFactor} onChange={(event) => props.onPlatformModelFormChange({ ...props.platformModelForm, discountFactor: event.target.value })} /></Label>
<Button type="submit" disabled={props.state === 'loading'}></Button>
</form>
</CardContent>
</Card>
<Card className="spanTwo">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<EntityTable
columns={['模型', '类型', '平台', '状态']}
empty="暂无平台模型"
rows={props.data.models.map((item) => [item.modelName, item.modelType, item.platformName ?? item.provider ?? '-', item.enabled ? 'enabled' : 'disabled'])}
/>
</CardContent>
</Card>
</section>
);
}
function TenantsPanel(props: { data: ConsoleData }) {
return <Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent><EntityTable columns={['Key', '名称', '来源', '状态']} empty="暂无租户" rows={props.data.tenants.map((item) => [item.tenantKey, item.name, item.source, item.status])} /></CardContent></Card>;
}
function UsersPanel(props: { data: ConsoleData }) {
return <Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent><EntityTable columns={['用户名', '租户', '来源', '状态']} empty="暂无用户" rows={props.data.users.map((item) => [item.username, item.tenantKey ?? '-', item.source, item.status])} /></CardContent></Card>;
}
function UserGroupsPanel(props: { data: ConsoleData }) {
return <Card><CardHeader><CardTitle></CardTitle></CardHeader><CardContent><EntityTable columns={['Key', '名称', '优先级', '状态']} empty="暂无用户组" rows={props.data.userGroups.map((item) => [item.groupKey, item.name, item.priority, item.status])} /></CardContent></Card>;
}
function RuntimePanel(props: { data: ConsoleData }) {
return (
<Card>
<CardHeader>
<CardTitle>TPM / RPM / </CardTitle>
<Badge variant="secondary">{props.data.rateLimitWindows.length}</Badge>
</CardHeader>
<CardContent>
<EntityTable columns={['Scope', '指标', '使用', '限制']} empty="暂无限流窗口" rows={props.data.rateLimitWindows.map((item) => [item.scopeKey, item.metric, item.usedValue, item.limitValue])} />
</CardContent>
</Card>
);
}
function updateBaseModel(value: string, props: Parameters<typeof PlatformsPanel>[0]) {
const base = props.data.baseModels.find((item) => item.canonicalModelKey === value);
props.onPlatformModelFormChange({
...props.platformModelForm,
canonicalModelKey: value,
modelAlias: value,
modelName: base?.providerModelName ?? '',
modelType: base?.modelType ?? props.platformModelForm.modelType,
});
}
+223
View File
@@ -0,0 +1,223 @@
import { useMemo, type FormEvent } from 'react';
import type { GatewayTask } from '@easyai-ai-gateway/contracts';
import { BookOpen, Play, Search, Send } from 'lucide-react';
import { Badge, Button, Select, Textarea } from '../components/ui';
import type { ApiDocSection, LoadState, TaskForm } from '../types';
const docs: Array<{ key: ApiDocSection; group: string; method: string; path: string; title: string }> = [
{ key: 'chat', group: '聊天(Chat)', method: 'POST', path: '/v1/chat/completions', title: 'Chat(聊天)' },
{ key: 'imageGeneration', group: '图片', method: 'POST', path: '/v1/images/generations', title: '创建图片' },
{ key: 'imageEdit', group: '图片', method: 'POST', path: '/v1/images/edits', title: '编辑图片' },
{ key: 'pricing', group: '计费', method: 'POST', path: '/api/v1/pricing/estimate', title: '价格预估' },
{ key: 'files', group: '文件', method: 'POST', path: '/v1/files/upload', title: '上传文件' },
];
const guideItems = ['获取 Base URL 和 API Key', '通知设置-WebHook 参数介绍', '错误码', '测试模式'];
const taskKindOptions = [
['chat.completions', 'Chat'],
['images.generations', '生图'],
['images.edits', '图像编辑'],
] as const;
export function ApiDocsPage(props: {
activeDocSection: ApiDocSection;
apiKeySecret: string;
canRun: boolean;
coreMessage: string;
coreState: LoadState;
taskForm: TaskForm;
taskResult: GatewayTask | null;
onLogin: () => void;
onDocSectionChange: (value: ApiDocSection) => void;
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
onTaskFormChange: (value: TaskForm) => void;
}) {
const current = docs.find((item) => item.key === props.activeDocSection) ?? docs[0];
const bodyExample = useMemo(() => requestBodyExample(props.taskForm), [props.taskForm]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
if (!props.canRun) {
event.preventDefault();
props.onLogin();
return;
}
props.onSubmitTask(event);
}
return (
<div className="apiDocsShell">
<aside className="docsSidebar">
<div className="docsBrand">
<BookOpen size={19} />
<strong>API Docs</strong>
</div>
<label className="docsSearch">
<Search size={15} />
<input placeholder="搜索" />
</label>
<DocsGroup title="指南" items={guideItems.map((title) => ({ title }))} />
{groupDocs(docs).map((group) => (
<DocsGroup
key={group.title}
title={group.title}
items={group.items.map((item) => ({
active: item.key === props.activeDocSection,
method: item.method,
title: item.title,
onClick: () => props.onDocSectionChange(item.key),
}))}
/>
))}
</aside>
<main className="docsArticle">
<p className="eyebrow">{current.group}</p>
<h1>{current.title}</h1>
<div className="endpointBar">
<Badge variant="warning">{current.method}</Badge>
<code>{current.path}</code>
</div>
<p className="docsLead">
integration-platform / OpenAI API Key server-main token
</p>
<section className="paramCard">
<header>
<h2>Header </h2>
<Button type="button" variant="secondary" size="sm"></Button>
</header>
<ParamRow name="Content-Type" type="string" required value="application/json" />
<ParamRow name="Accept" type="string" required value="application/json" />
<ParamRow name="Authorization" type="string" value="Bearer {{YOUR_API_KEY}}" />
</section>
<section className="paramCard">
<header>
<h2>Body </h2>
<Badge variant="outline">application/json</Badge>
</header>
<ParamRow name="model" type="string" required value="模型 ID 或别名" />
<ParamRow name="messages / prompt" type="array|string" required value="对话消息或图片提示词" />
<ParamRow name="simulation" type="boolean" value="测试模式开关" />
<ParamRow name="stream" type="boolean" value="对话进度流式返回" />
</section>
</main>
<aside className="docsRunner">
<form onSubmit={handleSubmit}>
<header>
<strong>线</strong>
<Button type="submit" size="sm" disabled={props.canRun && props.coreState === 'loading'}>
<Send size={14} />
{props.canRun ? '发送' : '登录'}
</Button>
</header>
<div className="runnerEndpoint">
<Badge variant="warning">POST</Badge>
<span>{current.path}</span>
</div>
<label className="shLabel">
<Select value={props.taskForm.kind} onChange={(event) => props.onTaskFormChange(defaultTaskForKind(event.target.value as TaskForm['kind'], props.taskForm))}>
{taskKindOptions.map(([value, label]) => (
<option value={value} key={value}>{label}</option>
))}
</Select>
</label>
<label className="shLabel">
Body
<Textarea value={bodyExample} onChange={(event) => props.onTaskFormChange(parseBody(event.target.value, props.taskForm))} />
</label>
<Button type="submit" disabled={props.canRun && props.coreState === 'loading'}>
<Play size={15} />
{!props.canRun ? '登录后运行' : props.coreState === 'loading' ? '运行中' : '运行测试'}
</Button>
</form>
<section className="runnerResult">
<strong></strong>
{props.taskResult ? (
<pre>{JSON.stringify(props.taskResult.result ?? props.taskResult, null, 2)}</pre>
) : (
<div className="emptyState">
<span></span>
</div>
)}
{props.coreMessage && <p>{props.coreMessage}</p>}
</section>
</aside>
</div>
);
}
function DocsGroup(props: {
items: Array<{ active?: boolean; method?: string; onClick?: () => void; title: string }>;
title: string;
}) {
return (
<section className="docsGroup">
<h3>{props.title}</h3>
{props.items.map((item) => (
<button type="button" data-active={item.active} key={item.title} onClick={item.onClick}>
<span>{item.title}</span>
{item.method && <Badge variant="warning">{item.method}</Badge>}
</button>
))}
</section>
);
}
function ParamRow(props: { name: string; required?: boolean; type: string; value: string }) {
return (
<div className="paramRow">
<code>{props.name}</code>
<span>{props.type}</span>
<p>{props.value}</p>
<em>{props.required ? '必需' : '可选'}</em>
</div>
);
}
function groupDocs(items: typeof docs) {
const groups = new Map<string, typeof docs>();
for (const item of items) {
groups.set(item.group, [...(groups.get(item.group) ?? []), item]);
}
return Array.from(groups, ([title, groupItems]) => ({ title, items: groupItems }));
}
function defaultTaskForKind(kind: TaskForm['kind'], current: TaskForm): TaskForm {
if (kind === 'chat.completions') return { ...current, kind, model: 'gpt-4o-mini' };
if (kind === 'images.edits') return { ...current, kind, image: current.image ?? 'https://example.com/source.png', mask: current.mask ?? 'https://example.com/mask.png', model: 'gpt-image-1' };
return { ...current, kind, model: 'gpt-image-1' };
}
function requestBodyExample(task: TaskForm) {
const body = task.kind === 'chat.completions'
? { model: task.model, messages: [{ role: 'user', content: task.prompt }], simulation: true, stream: true }
: task.kind === 'images.edits'
? { model: task.model, prompt: task.prompt, image: task.image, mask: task.mask, simulation: true }
: { model: task.model, prompt: task.prompt, quality: 'medium', simulation: true, size: '1024x1024' };
return JSON.stringify(body, null, 2);
}
function parseBody(value: string, current: TaskForm): TaskForm {
try {
const body = JSON.parse(value) as {
image?: string;
mask?: string;
messages?: Array<{ content?: string }>;
model?: string;
prompt?: string;
};
return {
...current,
image: body.image ?? current.image,
mask: body.mask ?? current.mask,
model: body.model ?? current.model,
prompt: body.prompt ?? body.messages?.[0]?.content ?? current.prompt,
};
} catch {
return current;
}
}
+127
View File
@@ -0,0 +1,127 @@
import type { ReactNode } from 'react';
import { Activity, Boxes, Image, Layers, Route, ServerCog, ShieldCheck, Video, Workflow } from 'lucide-react';
import { Badge, Button, Card, CardContent } from '../components/ui';
import type { PageKey } from '../types';
const coverage = [
{ icon: <Activity size={18} />, label: '大模型对话', value: 'Chat / Responses', detail: '兼容 OpenAI 对话接口与 EasyAI 站内调用' },
{ icon: <Image size={18} />, label: '图像生成', value: 'Image Generation', detail: '支持尺寸、质量、计费权重和结果转存' },
{ icon: <Layers size={18} />, label: '图像编辑', value: 'Image Edit', detail: '参考图、mask 和主服务文件上传链路' },
{ icon: <Video size={18} />, label: '视频能力', value: 'Video Ready', detail: '保留任务队列、进度事件和后续 Client 扩展位' },
];
const advantages = [
{ icon: <Route size={18} />, title: '多客户端路由', body: '同一模型可配置多个平台客户端,前一个失败后按策略切换下一个。' },
{ icon: <Workflow size={18} />, title: '持久化任务', body: '任务、事件、队列和回调 outbox 入库,服务重启后可以恢复运行状态。' },
{ icon: <ShieldCheck size={18} />, title: '策略化限流', body: 'TPM、RPM、并发、用户组折扣和平台优先级统一纳入调度。' },
{ icon: <ServerCog size={18} />, title: 'Hybrid 接入', body: '既可独立闭环运行,也可以对接 server-main 的认证、上传和业务前端。' },
];
export function HomePage(props: { onNavigate: (page: PageKey) => void }) {
return (
<div className="landingPage">
<section className="releaseNotice">
<Badge variant="success">线</Badge>
<strong>OpenAI Gemini Phase 1 Client </strong>
<span></span>
</section>
<section className="landingHero">
<div className="landingCopy">
<Badge variant="secondary">EasyAI AI Gateway</Badge>
<h1> AI </h1>
<p> server-main </p>
<div className="landingActions">
<Button type="button" onClick={() => props.onNavigate('models')}></Button>
<Button type="button" variant="outline" onClick={() => props.onNavigate('docs')}> API </Button>
</div>
</div>
<GatewayPreview />
</section>
<section className="landingSection">
<div className="landingSectionHeader">
<p className="eyebrow">Model Coverage</p>
<h2></h2>
</div>
<div className="coverageGrid">
{coverage.map((item) => (
<FeatureCard {...item} key={item.label} />
))}
</div>
</section>
<section className="landingSection">
<div className="landingSectionHeader">
<p className="eyebrow">Gateway Advantage</p>
<h2></h2>
</div>
<div className="advantageGrid">
{advantages.map((item) => (
<AdvantageCard {...item} key={item.title} />
))}
</div>
</section>
</div>
);
}
function GatewayPreview() {
return (
<div className="gatewayPreview" aria-label="网关能力预览">
<div className="previewHeader">
<span>Gateway Runtime</span>
<Badge variant="success">hybrid</Badge>
</div>
<div className="previewGrid">
<PreviewItem label="Provider" value="OpenAI / Gemini" />
<PreviewItem label="Routes" value="Chat · Image · Edit · Video" />
<PreviewItem label="Limits" value="TPM · RPM · Concurrent" />
<PreviewItem label="Queue" value="Persistent Recovery" />
</div>
<div className="previewFlow">
<span>Request</span>
<span>Policy</span>
<span>Client Retry</span>
<span>Callback</span>
<span>Result</span>
</div>
</div>
);
}
function PreviewItem(props: { label: string; value: string }) {
return (
<div>
<span>{props.label}</span>
<strong>{props.value}</strong>
</div>
);
}
function FeatureCard(props: { detail: string; icon: ReactNode; label: string; value: string }) {
return (
<Card>
<CardContent className="landingFeatureCard">
<div className="iconBox">{props.icon}</div>
<div>
<span>{props.label}</span>
<strong>{props.value}</strong>
<p>{props.detail}</p>
</div>
</CardContent>
</Card>
);
}
function AdvantageCard(props: { body: string; icon: ReactNode; title: string }) {
return (
<Card>
<CardContent className="advantageCard">
<div className="iconBox">{props.icon}</div>
<strong>{props.title}</strong>
<p>{props.body}</p>
</CardContent>
</Card>
);
}
+384
View File
@@ -0,0 +1,384 @@
import { useMemo, useState } from 'react';
import { Boxes, Search, Sparkles } from 'lucide-react';
import type {
BaseModelCatalogItem,
BillingConfig,
CatalogProvider,
PlatformModel,
} from '@easyai-ai-gateway/contracts';
import type { ConsoleData } from '../app-state';
import { PageHeader } from '../components/PageHeader';
import { Badge, Card, CardContent, Input } from '../components/ui';
type ModelListItem = {
id: string;
providerKey: string;
platformName?: string;
modelName: string;
modelAlias?: string;
modelType: string;
displayName: string;
capabilities?: Record<string, unknown>;
pricingMode: string;
billingConfig?: BillingConfig;
billingConfigOverride?: BillingConfig;
enabled: boolean;
};
const capabilityFilters = [
{ value: 'all', label: '全部' },
{ value: 'chat', label: '对话' },
{ value: 'image', label: '绘图' },
{ value: 'video', label: '视频' },
{ value: 'audio', label: '音频' },
{ value: 'embedding', label: 'Embedding' },
];
const publicProviders: CatalogProvider[] = [
{
id: 'public-openai',
providerKey: 'openai',
code: 'openai',
displayName: 'OpenAI',
providerType: 'openai',
source: 'server-main.integration-platform',
status: 'active',
createdAt: '',
updatedAt: '',
},
{
id: 'public-gemini',
providerKey: 'gemini',
code: 'google-gemini',
displayName: 'Google Gemini',
providerType: 'gemini',
iconPath: 'https://static.51easyai.com/gemini-color.png',
source: 'server-main.integration-platform',
status: 'active',
createdAt: '',
updatedAt: '',
},
];
const publicModels: PlatformModel[] = [
{
id: 'public-openai-gpt-4o-mini',
platformId: 'public-openai',
provider: 'OpenAI',
platformName: 'OpenAI Simulation',
modelName: 'gpt-4o-mini',
modelAlias: 'gpt-4o-mini',
modelType: 'chat',
displayName: 'gpt-4o-mini',
capabilities: { multimodal: true },
pricingMode: 'inherit',
enabled: true,
createdAt: '',
updatedAt: '',
},
{
id: 'public-openai-gpt-image-1',
platformId: 'public-openai',
provider: 'OpenAI',
platformName: 'OpenAI Simulation',
modelName: 'gpt-image-1',
modelAlias: 'gpt-image-1',
modelType: 'image',
displayName: 'gpt-image-1',
capabilities: { imageEdit: true },
pricingMode: 'inherit',
enabled: true,
createdAt: '',
updatedAt: '',
},
{
id: 'public-gemini-flash',
platformId: 'public-gemini',
provider: 'Gemini',
platformName: 'Gemini Simulation',
modelName: 'gemini-2.0-flash',
modelAlias: 'gemini-2.0-flash',
modelType: 'chat',
displayName: 'gemini-2.0-flash',
capabilities: { multimodal: true, vision: true },
pricingMode: 'inherit_discount',
enabled: true,
createdAt: '',
updatedAt: '',
},
];
export function ModelsPage(props: { data: ConsoleData }) {
const [query, setQuery] = useState('');
const [provider, setProvider] = useState('all');
const [capability, setCapability] = useState('all');
const sourceProviders = props.data.providers.length ? props.data.providers : publicProviders;
const providerMap = useMemo(() => buildProviderMap(sourceProviders), [sourceProviders]);
const sourceModels = useMemo(() => {
if (props.data.models.length) {
return props.data.models.map(modelFromPlatform);
}
if (props.data.baseModels.length) {
return props.data.baseModels.map(modelFromBaseModel);
}
return publicModels.map(modelFromPlatform);
}, [props.data.baseModels, props.data.models]);
const providerOptions = useMemo(() => {
const options = new Map<string, string>();
sourceProviders
.filter((item) => item.status !== 'hidden')
.forEach((item) => options.set(item.providerKey, item.displayName));
sourceModels.forEach((model) => {
if (!options.has(model.providerKey)) {
options.set(model.providerKey, providerMap.get(model.providerKey)?.displayName ?? model.providerKey);
}
});
return [
{ value: 'all', label: '全部' },
...Array.from(options.entries())
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([value, label]) => ({ value, label })),
];
}, [providerMap, sourceModels, sourceProviders]);
const filteredModels = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return sourceModels.filter((model) => {
const providerInfo = providerMap.get(model.providerKey);
const matchedProvider = provider === 'all' || model.providerKey === provider;
const matchedCapability = capability === 'all' || model.modelType === capability;
const matchedQuery = [
model.modelName,
model.modelAlias,
model.displayName,
providerInfo?.displayName,
providerInfo?.code,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(normalizedQuery);
return matchedProvider && matchedCapability && matchedQuery;
});
}, [capability, provider, providerMap, query, sourceModels]);
const selectedProvider = provider === 'all' ? undefined : providerMap.get(provider);
return (
<div className="modelsPage">
<aside className="modelFilters">
<FilterGroup
title="模型能力"
items={capabilityFilters}
value={capability}
onChange={setCapability}
/>
<FilterGroup
title="模型厂商"
items={providerOptions}
value={provider}
onChange={setProvider}
/>
</aside>
<main className="modelsContent">
<PageHeader
eyebrow="Models"
title="模型"
description={`${sourceModels.length} 个模型,当前显示 ${filteredModels.length}`}
/>
<div className="modelSearchBar">
<div className="searchField">
<Search size={16} />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="模型名称模糊搜索" />
</div>
</div>
<section className="providerHero">
{selectedProvider ? <ProviderIcon provider={selectedProvider} /> : (
<div className="providerLogo">
<Sparkles size={22} />
</div>
)}
<div>
<strong>{selectedProvider?.displayName ?? '全部模型厂商'}</strong>
<span>{selectedProvider ? `${selectedProvider.code} · ${selectedProvider.providerType}` : `${sourceProviders.length} 个厂商来自 integration-platform 目录`}</span>
</div>
<Badge variant="success">{filteredModels.length} </Badge>
</section>
<section className="modelCards">
{filteredModels.map((model) => (
<ModelCard model={model} provider={providerMap.get(model.providerKey)} key={model.id} />
))}
{!filteredModels.length && (
<Card>
<CardContent className="emptyState">
<Boxes size={18} />
<strong></strong>
</CardContent>
</Card>
)}
</section>
</main>
</div>
);
}
function FilterGroup(props: {
items: Array<{ value: string; label: string }>;
title: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<section className="filterGroup">
<h3>{props.title}</h3>
<div className="filterChips">
{props.items.map((item) => (
<button
type="button"
className="filterChip"
data-active={props.value === item.value}
key={item.value}
onClick={() => props.onChange(item.value)}
>
{item.label}
</button>
))}
</div>
</section>
);
}
function ModelCard(props: { model: ModelListItem; provider?: CatalogProvider }) {
const tags = tagsForModel(props.model);
const providerName = props.provider?.displayName ?? props.model.providerKey;
return (
<Card className="modelCard">
<CardContent>
<div className="modelCardTop">
<ProviderIcon provider={props.provider} label={providerName} />
<div>
<strong>{props.model.displayName || props.model.modelName}</strong>
<span>{providerName} · {props.model.platformName ?? props.provider?.code ?? 'catalog'}</span>
</div>
<Badge variant={props.model.enabled ? 'success' : 'secondary'}>
{props.model.enabled ? '启用' : '停用'}
</Badge>
</div>
<p>{props.model.modelAlias || props.model.modelName}</p>
<div className="modelTags">
{tags.map((tag) => <span key={tag}>{tag}</span>)}
</div>
<div className="modelCardFooter">
<span>{priceLabel(props.model)}</span>
<a href="#docs"></a>
</div>
</CardContent>
</Card>
);
}
function ProviderIcon(props: { provider?: CatalogProvider; label?: string }) {
const label = props.label ?? props.provider?.displayName ?? props.provider?.providerKey ?? 'AI';
if (props.provider?.iconPath) {
return (
<div className="modelIcon modelIconImage">
<img src={props.provider.iconPath} alt="" />
</div>
);
}
return <div className="modelIcon">{providerInitials(label)}</div>;
}
function buildProviderMap(providers: CatalogProvider[]) {
const map = new Map<string, CatalogProvider>();
providers.forEach((provider) => {
[
provider.providerKey,
provider.code,
provider.displayName,
stringMetadata(provider.metadata, 'sourceCode'),
].filter(Boolean).forEach((key) => map.set(normalizeProviderKey(key), provider));
map.set(provider.providerKey, provider);
});
return map;
}
function modelFromPlatform(model: PlatformModel): ModelListItem {
return {
id: model.id,
providerKey: normalizeProviderKey(model.provider ?? model.platformName ?? ''),
platformName: model.platformName,
modelName: model.modelName,
modelAlias: model.modelAlias,
modelType: model.modelType,
displayName: model.displayName,
capabilities: model.capabilities ?? model.capabilityOverride,
pricingMode: model.pricingMode,
billingConfig: model.billingConfig,
billingConfigOverride: model.billingConfigOverride,
enabled: model.enabled,
};
}
function modelFromBaseModel(model: BaseModelCatalogItem): ModelListItem {
return {
id: model.id,
providerKey: model.providerKey,
modelName: model.providerModelName,
modelAlias: model.canonicalModelKey,
modelType: model.modelType,
displayName: model.displayName,
capabilities: model.capabilities,
pricingMode: 'inherit',
billingConfig: model.baseBillingConfig,
enabled: model.status === 'active',
};
}
function normalizeProviderKey(value: string) {
const normalized = value.trim().toLowerCase();
if (!normalized) return 'unknown';
if (normalized.includes('gemini')) return 'gemini';
if (normalized.includes('openai')) return 'openai';
return normalized.replace(/\s+/g, '-');
}
function stringMetadata(metadata: Record<string, unknown> | undefined, key: string) {
const value = metadata?.[key];
return typeof value === 'string' ? value : '';
}
function providerInitials(label: string) {
return label
.split(/\s+/)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase() || 'AI';
}
function tagsForModel(model: ModelListItem) {
const tags = [capabilityName(model.modelType)];
const capabilities = model.capabilities ?? {};
if (capabilities.multimodal || capabilities.vision) tags.push('多模态');
if (capabilities.reasoning) tags.push('推理');
if (model.pricingMode === 'inherit_discount') tags.push('折扣');
if (model.pricingMode === 'custom') tags.push('自定义价');
return tags;
}
function capabilityName(type: string) {
return capabilityFilters.find((item) => item.value === type)?.label ?? type;
}
function priceLabel(model: ModelListItem) {
const config = model.billingConfig ?? model.billingConfigOverride;
if (typeof config?.basePrice === 'number') {
return `${config.basePrice}/${config.unit ?? config.resourceType ?? 'unit'}`;
}
return model.pricingMode === 'inherit' ? '跟随基准定价' : model.pricingMode;
}
+178
View File
@@ -0,0 +1,178 @@
import type { FormEvent, ReactNode } from 'react';
import { CreditCard, KeyRound, ListChecks, UserRound } from 'lucide-react';
import type { ConsoleData } from '../app-state';
import { EntityTable } from '../components/EntityTable';
import { PageHeader } from '../components/PageHeader';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Tabs } from '../components/ui';
import type { ApiKeyForm, LoadState, WorkspaceSection } from '../types';
const tabs = [
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
{ value: 'billing', label: '余额充值', icon: <CreditCard size={15} /> },
{ value: 'apiKeys', label: 'API Key', icon: <KeyRound size={15} /> },
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>;
export function WorkspacePage(props: {
apiKeyForm: ApiKeyForm;
apiKeySecret: string;
data: ConsoleData;
section: WorkspaceSection;
state: LoadState;
onApiKeyFormChange: (value: ApiKeyForm) => void;
onSectionChange: (value: WorkspaceSection) => void;
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<div className="pageStack">
<PageHeader eyebrow="Workspace" title="用户工作台" description="个人资产、API Key 和任务记录。" />
<div className="subPageLayout">
<Tabs className="sideTabs" value={props.section} tabs={tabs} onValueChange={props.onSectionChange} />
<div className="subPageContent">
{props.section === 'overview' && <WorkspaceOverview data={props.data} />}
{props.section === 'billing' && <BillingPanel />}
{props.section === 'apiKeys' && <ApiKeyPanel {...props} />}
{props.section === 'tasks' && <TaskPanel data={props.data} />}
</div>
</div>
</div>
);
}
function WorkspaceOverview(props: { data: ConsoleData }) {
const owner = props.data.users[0];
return (
<section className="contentGrid two">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="profileGrid">
<InfoItem label="账号" value={owner?.username ?? '-'} />
<InfoItem label="租户" value={owner?.tenantKey ?? 'default'} />
<InfoItem label="身份源" value={owner?.source ?? 'gateway'} />
<InfoItem label="API Key" value={String(props.data.apiKeys.length)} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<EntityTable
columns={['用户组', '优先级', '状态', '来源']}
empty="暂无用户组"
rows={props.data.userGroups.map((item) => [item.groupKey, item.priority, item.status, item.source])}
/>
</CardContent>
</Card>
</section>
);
}
function BillingPanel() {
return (
<section className="contentGrid two">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="balanceCard">
<span>resource</span>
<strong>0.00</strong>
<Badge variant="secondary">local</Badge>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="formGrid">
<Label>
<Input value="100" readOnly />
</Label>
<Button type="button" variant="secondary"></Button>
</CardContent>
</Card>
</section>
);
}
function ApiKeyPanel(props: {
apiKeyForm: ApiKeyForm;
apiKeySecret: string;
data: ConsoleData;
state: LoadState;
onApiKeyFormChange: (value: ApiKeyForm) => void;
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<section className="contentGrid two">
<Card>
<CardHeader>
<CardTitle> API Key</CardTitle>
</CardHeader>
<CardContent>
<form className="formGrid" onSubmit={props.onSubmitApiKey}>
<Label>
<Input value={props.apiKeyForm.name} onChange={(event) => props.onApiKeyFormChange({ name: event.target.value })} />
</Label>
<Button type="submit" disabled={props.state === 'loading'}>
<KeyRound size={15} />
</Button>
{props.apiKeySecret && <code className="secretBox">{props.apiKeySecret}</code>}
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Key </CardTitle>
</CardHeader>
<CardContent>
<EntityTable
columns={['名称', '前缀', '状态', '创建时间']}
empty="暂无 API Key"
rows={props.data.apiKeys.map((item) => [item.name, item.keyPrefix, item.status, new Date(item.createdAt).toLocaleString()])}
/>
</CardContent>
</Card>
</section>
);
}
function TaskPanel(props: { data: ConsoleData }) {
const task = props.data.taskResult;
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{task ? (
<div className="taskPreview">
<Badge variant={task.status === 'succeeded' ? 'success' : 'secondary'}>{task.status}</Badge>
<strong>{task.kind}</strong>
<span>{task.model}</span>
<pre>{JSON.stringify(task.result ?? {}, null, 2)}</pre>
</div>
) : (
<div className="emptyState">
<strong></strong>
</div>
)}
</CardContent>
</Card>
);
}
function InfoItem(props: { label: string; value: string }) {
return (
<div className="infoItem">
<span>{props.label}</span>
<strong>{props.value}</strong>
</div>
);
}
@@ -0,0 +1,374 @@
import { useMemo, useState, type FormEvent } from 'react';
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
import type {
BaseModelCatalogItem,
BaseModelUpsertRequest,
CatalogProvider,
} from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type ModelForm = {
providerKey: string;
canonicalModelKey: string;
providerModelName: string;
modelType: string;
displayName: string;
status: string;
pricingVersion: string;
capabilitiesJson: string;
billingJson: string;
rateLimitJson: string;
metadataJson: string;
};
const statuses = ['active', 'deprecated', 'hidden'];
const fallbackTypes = [
'text_generate',
'image_generate',
'image_edit',
'image_analysis',
'video_generate',
'image_to_video',
'omni_video',
'text_embedding',
'text_to_speech',
'audio_generate',
'digital_human_generate',
'text_to_model',
'image_to_model',
'multiview_to_model',
'mesh_edit',
];
const emptyForm: ModelForm = {
providerKey: '',
canonicalModelKey: '',
providerModelName: '',
modelType: 'text_generate',
displayName: '',
status: 'active',
pricingVersion: '1',
capabilitiesJson: '{}',
billingJson: '{}',
rateLimitJson: '{}',
metadataJson: '{}',
};
export function BaseModelCatalogPanel(props: {
baseModels: BaseModelCatalogItem[];
message: string;
providers: CatalogProvider[];
state: LoadState;
onDeleteBaseModel: (baseModelId: string) => Promise<void>;
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<ModelForm>(emptyForm);
const [localError, setLocalError] = useState('');
const [query, setQuery] = useState('');
const [providerFilter, setProviderFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const providerNames = useMemo(
() => new Map(props.providers.map((item) => [item.providerKey, item.displayName])),
[props.providers],
);
const typeOptions = useMemo(
() => Array.from(new Set([...fallbackTypes, ...props.baseModels.map((item) => item.modelType).filter(Boolean)])),
[props.baseModels],
);
const providerOptions = useMemo(
() => Array.from(new Set([...props.providers.map((item) => item.providerKey), ...props.baseModels.map((item) => item.providerKey)])),
[props.baseModels, props.providers],
);
const filteredModels = useMemo(() => {
const keyword = query.trim().toLowerCase();
return props.baseModels.filter((item) => {
const matchesProvider = providerFilter === 'all' || item.providerKey === providerFilter;
const matchesType = typeFilter === 'all' || readModelTypes(item).includes(typeFilter);
const text = `${item.displayName} ${item.providerModelName} ${item.canonicalModelKey}`.toLowerCase();
return matchesProvider && matchesType && (!keyword || text.includes(keyword));
});
}, [props.baseModels, providerFilter, query, typeFilter]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSaveBaseModel(formToPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型保存失败');
}
}
function openCreateDialog() {
const providerKey = providerOptions[0] ?? '';
setEditingId('');
setForm({ ...emptyForm, providerKey, canonicalModelKey: providerKey ? `${providerKey}:` : '' });
setDialogOpen(true);
}
function editModel(model: BaseModelCatalogItem) {
setEditingId(model.id);
setForm(modelToForm(model));
setDialogOpen(true);
}
function closeDialog() {
setEditingId('');
setForm(emptyForm);
setLocalError('');
setDialogOpen(false);
}
async function deleteModel(model: BaseModelCatalogItem) {
const confirmed = window.confirm(`确认删除基准模型 ${model.displayName}`);
if (!confirmed) return;
try {
await props.onDeleteBaseModel(model.id);
if (editingId === model.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型删除失败');
}
}
return (
<div className="pageStack">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<Badge variant="secondary">{props.baseModels.length}</Badge>
</CardHeader>
<CardContent>
<div className="providerToolbar">
<p> server-main integration-platform</p>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</div>
<div className="modelCatalogFilters">
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索模型名称 / canonical key" />
<Select value={providerFilter} onChange={(event) => setProviderFilter(event.target.value)}>
<option value="all"></option>
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
</Select>
<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
<option value="all"></option>
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</div>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
</CardContent>
</Card>
<section className="baseModelGrid">
{filteredModels.map((model) => (
<ModelCard
key={model.id}
model={model}
providerName={providerNames.get(model.providerKey)}
onDelete={() => void deleteModel(model)}
onEdit={() => editModel(model)}
/>
))}
{!filteredModels.length && (
<Card>
<CardContent className="emptyState">
<strong></strong>
</CardContent>
</Card>
)}
</section>
<FormDialog
ariaLabel={editingId ? '编辑基准模型' : '新增基准模型'}
className="baseModelDialog"
eyebrow={editingId ? 'Edit Base Model' : 'New Base Model'}
footer={(
<>
<Button type="submit" disabled={props.state === 'loading'}>
{editingId ? <Pencil size={15} /> : <Plus size={15} />}
{editingId ? '保存修改' : '新增模型'}
</Button>
<Button type="button" variant="outline" onClick={closeDialog}>
<RotateCcw size={15} />
</Button>
</>
)}
formClassName="baseModelForm"
open={dialogOpen}
title={editingId ? '编辑基准模型' : '新增基准模型'}
onClose={closeDialog}
onSubmit={submit}
>
<Label>
Provider
<Select value={form.providerKey} onChange={(event) => setForm({ ...form, providerKey: event.target.value })}>
<option value=""></option>
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
</Select>
</Label>
<Label>
<Select value={form.modelType} onChange={(event) => setForm({ ...form, modelType: event.target.value })}>
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</Label>
<Label>
<Input value={form.providerModelName} onChange={(event) => setForm({ ...form, providerModelName: event.target.value })} />
</Label>
<Label>
<Input value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} />
</Label>
<Label className="spanTwo">
Canonical Key
<Input value={form.canonicalModelKey} onChange={(event) => setForm({ ...form, canonicalModelKey: event.target.value })} />
</Label>
<Label>
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
{statuses.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</Label>
<Label>
<Input value={form.pricingVersion} onChange={(event) => setForm({ ...form, pricingVersion: event.target.value })} />
</Label>
<JsonField label="能力 JSON" value={form.capabilitiesJson} onChange={(value) => setForm({ ...form, capabilitiesJson: value })} />
<JsonField label="基准计费 JSON" value={form.billingJson} onChange={(value) => setForm({ ...form, billingJson: value })} />
<JsonField label="限流 JSON" value={form.rateLimitJson} onChange={(value) => setForm({ ...form, rateLimitJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
</div>
);
}
function ModelCard(props: {
model: BaseModelCatalogItem;
providerName?: string;
onDelete: () => void;
onEdit: () => void;
}) {
const types = readModelTypes(props.model);
return (
<article className="baseModelCard">
<ModelIcon model={props.model} />
<div className="baseModelCardBody">
<div>
<strong>{props.model.displayName}</strong>
<span>{props.model.providerModelName}</span>
</div>
<div className="providerCatalogMeta">
<Badge variant={props.model.status === 'active' ? 'success' : 'secondary'}>{props.model.status}</Badge>
<span>{props.providerName ?? props.model.providerKey}</span>
<span>{props.model.canonicalModelKey}</span>
</div>
<div className="modelAbilityChips">
{types.slice(0, 5).map((item) => <span key={item}>{item}</span>)}
{types.length > 5 && <span>+{types.length - 5}</span>}
</div>
</div>
<div className="providerCatalogActions">
<Button type="button" variant="outline" size="sm" onClick={props.onEdit}>
<Pencil size={14} />
</Button>
<Button type="button" variant="destructive" size="sm" onClick={props.onDelete}>
<Trash2 size={14} />
</Button>
</div>
</article>
);
}
function JsonField(props: { label: string; value: string; onChange: (value: string) => void }) {
return (
<Label className="spanTwo">
{props.label}
<Textarea value={props.value} onChange={(event) => props.onChange(event.target.value)} rows={5} spellCheck={false} />
</Label>
);
}
function ModelIcon(props: { model: BaseModelCatalogItem }) {
const iconPath = readString(props.model.metadata?.iconPath) || readString(readRecord(props.model.metadata?.rawModel)?.icon_path);
if (iconPath) {
return (
<div className="providerCatalogLogo">
<img src={iconPath} alt="" />
</div>
);
}
return <div className="providerCatalogLogo">{props.model.providerKey.slice(0, 2).toUpperCase()}</div>;
}
function modelToForm(model: BaseModelCatalogItem): ModelForm {
return {
providerKey: model.providerKey,
canonicalModelKey: model.canonicalModelKey,
providerModelName: model.providerModelName,
modelType: model.modelType,
displayName: model.displayName,
status: model.status,
pricingVersion: String(model.pricingVersion || 1),
capabilitiesJson: stringifyJson(model.capabilities),
billingJson: stringifyJson(model.baseBillingConfig),
rateLimitJson: stringifyJson(model.defaultRateLimitPolicy),
metadataJson: stringifyJson(model.metadata),
};
}
function formToPayload(form: ModelForm): BaseModelUpsertRequest {
return {
providerKey: form.providerKey.trim(),
canonicalModelKey: form.canonicalModelKey.trim() || undefined,
providerModelName: form.providerModelName.trim(),
modelType: form.modelType.trim(),
displayName: form.displayName.trim() || form.providerModelName.trim(),
capabilities: parseJsonObject(form.capabilitiesJson, '能力 JSON'),
baseBillingConfig: parseJsonObject(form.billingJson, '基准计费 JSON'),
defaultRateLimitPolicy: parseJsonObject(form.rateLimitJson, '限流 JSON'),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
pricingVersion: Number.parseInt(form.pricingVersion, 10) || 1,
status: form.status,
};
}
function readModelTypes(model: BaseModelCatalogItem) {
const values = model.metadata?.originalTypes ?? model.capabilities?.originalTypes;
if (Array.isArray(values)) return values.map(String);
return [model.modelType].filter(Boolean);
}
function parseJsonObject(value: string, label: string) {
try {
const parsed = value.trim() ? JSON.parse(value) : {};
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error(`${label} 必须是 JSON object`);
}
return parsed as Record<string, unknown>;
} catch (err) {
if (err instanceof Error && err.message.includes('必须是')) throw err;
throw new Error(`${label} 格式不正确`);
}
}
function stringifyJson(value?: Record<string, unknown>) {
return JSON.stringify(value ?? {}, null, 2);
}
function readString(value: unknown) {
return typeof value === 'string' ? value : '';
}
function readRecord(value: unknown) {
return value && !Array.isArray(value) && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
}
@@ -0,0 +1,348 @@
import { useMemo, useState } from 'react';
import { Calculator, Plus, Trash2 } from 'lucide-react';
import type { PricingRuleInput } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Input, Label, Select } from '../../components/ui';
import { cn } from '../../lib/utils';
type RecordValue = Record<string, unknown>;
type ModeKey = 'text' | 'image' | 'video' | 'digitalHuman' | 'speech' | 'music';
type ModeDefinition = {
key: ModeKey;
label: string;
formula: string;
match: (rule: PricingRuleInput) => boolean;
templates: (currency: string) => PricingRuleInput[];
weightGroups: Array<{ key: string; title: string; defaults: RecordValue }>;
};
const currencies = ['resource', 'credit', 'cny', 'usd'];
const calculators = ['token_usage', 'unit_weight', 'duration_weight', 'formula'];
const statuses = ['active', 'deprecated', 'hidden'];
const modeDefinitions: ModeDefinition[] = [
{
key: 'text',
label: '大语言模型',
formula: '扣费 = 输入 Token 单价 × 输入 Token 数 / 1000 + 输出 Token 单价 × 输出 Token 数 / 1000。',
match: (rule) => rule.resourceType.startsWith('text_'),
templates: (currency) => [
createRule('text_input_tokens', '文本输入 Token', 'text_input', '1k_tokens', 0.01, currency, 'token_usage', 'ceil(input_tokens / 1000) * base_price', { meter: 'input_tokens' }, {}, { metrics: ['input_tokens'], unitScale: 1000 }),
createRule('text_output_tokens', '文本输出 Token', 'text_output', '1k_tokens', 0.03, currency, 'token_usage', 'ceil(output_tokens / 1000) * base_price', { meter: 'output_tokens' }, {}, { metrics: ['output_tokens'], unitScale: 1000 }),
],
weightGroups: [],
},
{
key: 'image',
label: '图像生成',
formula: '扣费 = 基础单价 × 基础权重 × 动态参数权重,例如 2K 或高清质量会按对应倍数计费。',
match: (rule) => rule.resourceType === 'image' || rule.resourceType === 'image_edit',
templates: (currency) => [
createRule('image_generation', '图像生成', 'image', 'image', 10, currency, 'unit_weight', 'count * base_price * resolution_weight * quality_weight * mode_weight', { meter: 'count', base: 1 }, {
resolutionWeights: { '512x512': 0.5, '1024x1024': 1, '2K': 1.5, '4K': 2 },
qualityWeights: { standard: 1, hd: 1.5 },
modeWeights: { generation: 1 },
}, { dimensions: ['count', 'resolution', 'quality', 'mode'], defaults: { count: 1, resolution: '1024x1024', quality: 'standard', mode: 'generation' } }),
],
weightGroups: [
{ key: 'resolutionWeights', title: '分辨率', defaults: { '512x512': 0.5, '1024x1024': 1, '2K': 1.5, '4K': 2 } },
{ key: 'qualityWeights', title: '图像质量', defaults: { standard: 1, hd: 1.5 } },
{ key: 'referenceImageWeights', title: '参考图数量', defaults: { single: 1, multiple: 1.3 } },
],
},
{
key: 'video',
label: '视频生成',
formula: '扣费 = 基础单价 × 基础权重 × 动态参数权重。例如生成 1080p 且带音频时,会叠加分辨率和音频权重。',
match: (rule) => rule.resourceType === 'video',
templates: (currency) => [
createRule('video_generation', '视频生成', 'video', '5s', 100, currency, 'duration_weight', 'count * ceil(duration_seconds / 5) * base_price * resolution_weight * audio_weight * reference_video_weight * voice_specified_weight', { meter: 'duration', unitSeconds: 5, base: 1 }, {
resolutionWeights: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 },
audioWeights: { true: 2, false: 1 },
referenceVideoWeights: { true: 1.5, false: 1 },
voiceSpecifiedWeights: { true: 1.2, false: 1 },
}, { dimensions: ['count', 'duration_seconds', 'resolution', 'audio', 'reference_video', 'voice_specified'], defaults: { count: 1, duration_seconds: 5, resolution: '720p', audio: false, reference_video: false, voice_specified: false } }),
],
weightGroups: [
{ key: 'resolutionWeights', title: '分辨率', defaults: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 } },
{ key: 'audioWeights', title: '是否生成音频', defaults: { true: 2, false: 1 } },
{ key: 'referenceVideoWeights', title: '是否含有参考视频', defaults: { true: 1.5, false: 1 } },
{ key: 'voiceSpecifiedWeights', title: '是否指定音色', defaults: { true: 1.2, false: 1 } },
],
},
{
key: 'digitalHuman',
label: '数字人生成',
formula: '扣费 = 基础单价 × 生成时长 × 分辨率、音频驱动等动态参数权重。',
match: (rule) => rule.resourceType === 'digital_human',
templates: (currency) => [createRule('digital_human_generation', '数字人生成', 'digital_human', 'second', 2, currency, 'duration_weight', 'duration_seconds * base_price * resolution_weight', { meter: 'duration_seconds', base: 1 }, { resolutionWeights: { '720p': 1, '1080p': 1.5 } }, { dimensions: ['duration_seconds', 'resolution'], defaults: { duration_seconds: 10, resolution: '720p' } })],
weightGroups: [{ key: 'resolutionWeights', title: '分辨率', defaults: { '720p': 1, '1080p': 1.5 } }],
},
{
key: 'speech',
label: '语音合成',
formula: '扣费 = 基础单价 × 字符数 / 1000 × 声音或质量权重。',
match: (rule) => rule.resourceType === 'audio',
templates: (currency) => [createRule('speech_generation', '语音合成', 'audio', 'character_1k', 1, currency, 'unit_weight', 'ceil(characters / 1000) * base_price * voice_weight', { meter: 'characters', base: 1 }, { voiceWeights: { standard: 1, premium: 1.5 } }, { dimensions: ['characters', 'voice'], defaults: { voice: 'standard' } })],
weightGroups: [{ key: 'voiceWeights', title: '音色', defaults: { standard: 1, premium: 1.5 } }],
},
{
key: 'music',
label: '音乐生成',
formula: '扣费 = 基础单价 × 生成数量 × 时长或质量权重。',
match: (rule) => rule.resourceType === 'music',
templates: (currency) => [createRule('music_generation', '音乐生成', 'music', 'item', 20, currency, 'unit_weight', 'count * base_price * duration_weight * quality_weight', { meter: 'count', base: 1 }, { durationWeights: { short: 1, long: 1.8 }, qualityWeights: { standard: 1, high: 1.5 } }, { dimensions: ['count', 'duration', 'quality'], defaults: { count: 1, duration: 'short', quality: 'standard' } })],
weightGroups: [
{ key: 'durationWeights', title: '时长', defaults: { short: 1, long: 1.8 } },
{ key: 'qualityWeights', title: '质量', defaults: { standard: 1, high: 1.5 } },
],
},
];
export function PricingRuleVisualEditor(props: {
currency: string;
rules: PricingRuleInput[];
onChange: (rules: PricingRuleInput[]) => void;
}) {
const [activeMode, setActiveMode] = useState<ModeKey>('video');
const mode = modeDefinitions.find((item) => item.key === activeMode) ?? modeDefinitions[0];
const activeRules = useMemo(() => props.rules.filter(mode.match), [mode, props.rules]);
function replaceModeRules(nextRules: PricingRuleInput[]) {
props.onChange([...props.rules.filter((rule) => !mode.match(rule)), ...nextRules]);
}
function completeMissing() {
const existing = activeRules.length ? activeRules : mode.templates(props.currency);
replaceModeRules(existing.map((rule) => completeRule(rule, mode)));
}
return (
<section className="pricingModeEditor spanTwo">
<div className="pricingModeHeader">
<div>
<strong></strong>
<span></span>
</div>
<Button type="button" variant="outline" size="sm" onClick={completeMissing}></Button>
</div>
<div className="pricingModeTabs">
{modeDefinitions.map((item) => (
<button className={item.key === activeMode ? 'active' : ''} key={item.key} type="button" onClick={() => setActiveMode(item.key)}>{item.label}</button>
))}
</div>
{activeRules.length ? (
activeRules.map((rule, index) => (
<ModeRuleEditor
key={`${rule.ruleKey ?? rule.resourceType}-${index}`}
mode={mode}
rule={rule}
onChange={(nextRule) => replaceModeRules(activeRules.map((item, itemIndex) => (itemIndex === index ? nextRule : item)))}
onDelete={() => replaceModeRules(activeRules.filter((_, itemIndex) => itemIndex !== index))}
/>
))
) : (
<div className="pricingModeEmpty">
<strong>{mode.label} </strong>
<Button type="button" onClick={() => replaceModeRules(mode.templates(props.currency))}><Plus size={15} /></Button>
</div>
)}
</section>
);
}
function ModeRuleEditor(props: {
mode: ModeDefinition;
rule: PricingRuleInput;
onChange: (rule: PricingRuleInput) => void;
onDelete: () => void;
}) {
const rule = props.rule;
const baseWeight = Number((rule.baseWeight ?? {}).base ?? 1);
const formula = String((rule.formulaConfig ?? {}).formula ?? props.mode.formula);
function patch(patchValue: Partial<PricingRuleInput>) {
props.onChange({ ...rule, ...patchValue });
}
function patchDynamicWeight(key: string, value: RecordValue) {
patch({ dynamicWeight: { ...(rule.dynamicWeight ?? {}), [key]: value } });
}
function addCustomGroup() {
patchDynamicWeight(nextKey(rule.dynamicWeight ?? {}, 'customWeights'), {});
}
return (
<article className="pricingModeRule">
<header>
<div>
<Badge variant="secondary">{rule.resourceType}</Badge>
<strong>{rule.displayName || props.mode.label}</strong>
<span>{rule.ruleKey}</span>
</div>
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} /></Button>
</header>
<div className="pricingModeBaseRows">
<Label> Key<Input value={rule.ruleKey ?? ''} onChange={(event) => patch({ ruleKey: event.target.value })} /></Label>
<Label><Input value={rule.displayName ?? ''} onChange={(event) => patch({ displayName: event.target.value })} /></Label>
<Label><Input value={rule.resourceType} onChange={(event) => patch({ resourceType: event.target.value })} /></Label>
<Label><Select value={rule.calculatorType ?? 'unit_weight'} onChange={(event) => patch({ calculatorType: event.target.value })}>{calculators.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
<Label><Select value={rule.currency ?? 'resource'} onChange={(event) => patch({ currency: event.target.value })}>{currencies.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
<Label><Select value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
</div>
<div className="pricingModeInlineRows">
<div className="pricingModeInlineInput"><span>/{rule.unit}</span><Input min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} /></div>
<div className="pricingModeInlineInput"><span></span><Input min="0" step="0.01" type="number" value={baseWeight} onChange={(event) => patch({ baseWeight: { ...(rule.baseWeight ?? {}), base: Number(event.target.value) } })} /></div>
<div className="pricingModeInlineInput"><span></span><Input value={rule.unit} onChange={(event) => patch({ unit: event.target.value })} /></div>
</div>
<div className="pricingFormulaBox">
<Calculator size={16} />
<div>
<strong></strong>
<Input value={formula} onChange={(event) => patch({ formulaConfig: { ...(rule.formulaConfig ?? {}), formula: event.target.value } })} />
<p>{props.mode.formula}</p>
</div>
</div>
<div className="pricingWeightStack">
{props.mode.weightGroups.map((group) => (
<WeightTable
key={group.key}
title={group.title}
value={readGroup(rule.dynamicWeight, group)}
onChange={(value) => patchDynamicWeight(group.key, value)}
/>
))}
{Object.entries(rule.dynamicWeight ?? {}).filter(([key]) => !props.mode.weightGroups.some((group) => group.key === key)).map(([key, value]) => (
<WeightTable key={key} editableTitle title={key} value={isPlainObject(value) ? value as RecordValue : { value }} onChange={(nextValue, nextTitle) => {
const dynamicWeight = { ...(rule.dynamicWeight ?? {}) };
delete dynamicWeight[key];
dynamicWeight[nextTitle || key] = nextValue;
patch({ dynamicWeight });
}} />
))}
</div>
<Button className="pricingAddWeightButton" type="button" variant="outline" onClick={addCustomGroup}><Plus size={15} /></Button>
</article>
);
}
function WeightTable(props: {
editableTitle?: boolean;
title: string;
value: RecordValue;
onChange: (value: RecordValue, title?: string) => void;
}) {
const rows: Array<[string, unknown]> = Object.entries(props.value ?? {});
function updateRows(nextRows: Array<[string, unknown]>) {
props.onChange(rowsToRecord(nextRows), props.title);
}
return (
<section className="pricingWeightTable">
<header>
{props.editableTitle ? (
<Input value={props.title} onChange={(event) => props.onChange(props.value, event.target.value)} />
) : (
<strong>{props.title}</strong>
)}
</header>
<div className="pricingWeightRows">
{rows.map(([key, value], index) => (
<div className="pricingWeightRow" key={`${key}-${index}`}>
<Input value={key} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [event.target.value, value] : row)))} />
<Input value={scalarToString(value)} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [key, parseScalar(event.target.value)] : row)))} />
<Button type="button" variant="ghost" size="icon" onClick={() => updateRows(rows.filter((_, rowIndex) => rowIndex !== index))}><Trash2 size={14} /></Button>
</div>
))}
<Button className="pricingInlineAdd" type="button" variant="outline" size="sm" onClick={() => updateRows([...rows, [nextKey(props.value, 'option'), 1]])}><Plus size={14} /></Button>
</div>
</section>
);
}
export function KeyValueEditor(props: {
className?: string;
title: string;
value: RecordValue;
onChange: (value: RecordValue) => void;
}) {
return (
<div className={cn('pricingWeightTable', props.className)}>
<header><strong>{props.title}</strong></header>
<WeightTable title={props.title} value={props.value} onChange={props.onChange} />
</div>
);
}
function completeRule(rule: PricingRuleInput, mode: ModeDefinition): PricingRuleInput {
return {
...rule,
dynamicWeight: {
...Object.fromEntries(mode.weightGroups.map((group) => [group.key, { ...group.defaults, ...(isPlainObject(rule.dynamicWeight?.[group.key]) ? rule.dynamicWeight?.[group.key] as RecordValue : {}) }])),
...(rule.dynamicWeight ?? {}),
},
};
}
function readGroup(value: RecordValue | undefined, group: ModeDefinition['weightGroups'][number]) {
return isPlainObject(value?.[group.key]) ? value?.[group.key] as RecordValue : group.defaults;
}
function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, baseWeight: RecordValue, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput {
return {
ruleKey,
displayName,
resourceType,
unit,
basePrice,
currency,
calculatorType,
baseWeight,
dynamicWeight,
dimensionSchema,
formulaConfig: { formula },
priority: 100,
status: 'active',
metadata: {},
};
}
function isPlainObject(value: unknown) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function scalarToString(value: unknown): string {
if (value === undefined || value === null) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function parseScalar(value: string): unknown {
const trimmed = value.trim();
if (trimmed === '') return '';
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try { return JSON.parse(trimmed) as unknown; } catch { return value; }
}
return value;
}
function rowsToRecord(rows: Array<[string, unknown]>): RecordValue {
return Object.fromEntries(rows.filter(([key]) => key.trim()).map(([key, value]) => [key.trim(), value]));
}
function nextKey(value: RecordValue, prefix: string) {
let index = 1;
while (`${prefix}${index}` in value) index += 1;
return `${prefix}${index}`;
}
@@ -0,0 +1,225 @@
import { useMemo, useState, type FormEvent } from 'react';
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
import type { LoadState } from '../../types';
import { KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor';
type PricingForm = {
ruleSetKey: string;
name: string;
description: string;
category: string;
currency: string;
status: string;
metadata: Record<string, unknown>;
rules: PricingRuleInput[];
};
const emptyForm: PricingForm = {
ruleSetKey: '',
name: '',
description: '',
category: 'custom',
currency: 'resource',
status: 'active',
metadata: {},
rules: [],
};
const categories = ['default', 'custom', 'provider', 'model'];
const statuses = ['active', 'deprecated', 'hidden'];
const currencies = ['resource', 'credit', 'cny', 'usd'];
export function PricingRulesPanel(props: {
message: string;
pricingRuleSets: PricingRuleSet[];
state: LoadState;
onDeletePricingRuleSet: (ruleSetId: string) => Promise<void>;
onSavePricingRuleSet: (input: PricingRuleSetUpsertRequest, ruleSetId?: string) => Promise<void>;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<PricingForm>(emptyForm);
const [localError, setLocalError] = useState('');
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return props.pricingRuleSets;
return props.pricingRuleSets.filter((item) => `${item.ruleSetKey} ${item.name} ${item.description ?? ''}`.toLowerCase().includes(keyword));
}, [props.pricingRuleSets, query]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSavePricingRuleSet(formToPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '定价规则保存失败');
}
}
function openCreateDialog() {
setEditingId('');
setForm(emptyForm);
setLocalError('');
setDialogOpen(true);
}
function editRuleSet(ruleSet: PricingRuleSet) {
setEditingId(ruleSet.id);
setForm(ruleSetToForm(ruleSet));
setLocalError('');
setDialogOpen(true);
}
function closeDialog() {
setEditingId('');
setForm(emptyForm);
setLocalError('');
setDialogOpen(false);
}
async function deleteRuleSet(ruleSet: PricingRuleSet) {
const confirmed = window.confirm(`确认删除定价规则 ${ruleSet.name}?已绑定的平台或模型会清空规则绑定。`);
if (!confirmed) return;
try {
await props.onDeletePricingRuleSet(ruleSet.id);
if (editingId === ruleSet.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '定价规则删除失败');
}
}
return (
<div className="pageStack">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<Badge variant="secondary">{props.pricingRuleSets.length}</Badge>
</CardHeader>
<CardContent>
<div className="providerToolbar">
<p></p>
<Button type="button" onClick={openCreateDialog}><Plus size={15} /></Button>
</div>
<div className="modelCatalogFilters">
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索规则名称 / key" />
</div>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
</CardContent>
</Card>
<section className="pricingRuleGrid">
{filtered.map((ruleSet) => (
<article className="pricingRuleCard" key={ruleSet.id}>
<header>
<div>
<strong>{ruleSet.name}</strong>
<span>{ruleSet.ruleSetKey}</span>
</div>
<Badge variant={ruleSet.status === 'active' ? 'success' : 'secondary'}>{ruleSet.status}</Badge>
</header>
<p>{ruleSet.description || '未填写说明'}</p>
<div className="providerCatalogMeta">
<span>{ruleSet.category}</span>
<span>{ruleSet.currency}</span>
<span>{ruleSet.rules?.length ?? 0} </span>
</div>
<div className="pricingRuleItems">
{(ruleSet.rules ?? []).slice(0, 5).map((rule) => (
<span key={rule.id || rule.ruleKey}>{rule.displayName || rule.resourceType}: {rule.basePrice}/{rule.unit}</span>
))}
</div>
<div className="providerCatalogActions">
<Button type="button" variant="outline" size="sm" onClick={() => editRuleSet(ruleSet)}><Pencil size={14} /></Button>
<Button type="button" variant="destructive" size="sm" onClick={() => void deleteRuleSet(ruleSet)}><Trash2 size={14} /></Button>
</div>
</article>
))}
{!filtered.length && (
<Card><CardContent className="emptyState"><strong></strong></CardContent></Card>
)}
</section>
<FormDialog
ariaLabel={editingId ? '编辑定价规则' : '新增定价规则'}
bodyClassName="pricingRuleFormBody"
className="pricingRuleDialog"
eyebrow={editingId ? 'Edit Pricing Rule Set' : 'New Pricing Rule Set'}
footer={(
<>
<Button type="submit" disabled={props.state === 'loading'}>{editingId ? <Pencil size={15} /> : <Plus size={15} />}{editingId ? '保存修改' : '新增规则'}</Button>
<Button type="button" variant="outline" onClick={closeDialog}><RotateCcw size={15} /></Button>
</>
)}
open={dialogOpen}
title={editingId ? '编辑定价规则' : '新增定价规则'}
onClose={closeDialog}
onSubmit={submit}
>
<Label> Key<Input value={form.ruleSetKey} onChange={(event) => setForm({ ...form, ruleSetKey: event.target.value })} /></Label>
<Label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Label>
<Label className="spanTwo"><Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
<Label><Select value={form.category} onChange={(event) => setForm({ ...form, category: event.target.value })}>{categories.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<Label><Select value={form.currency} onChange={(event) => setForm({ ...form, currency: event.target.value })}>{currencies.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<Label><Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{statuses.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<PricingRuleVisualEditor currency={form.currency} rules={form.rules} onChange={(rules) => setForm({ ...form, rules })} />
<KeyValueEditor className="spanTwo" title="规则集元数据" value={form.metadata} onChange={(metadata) => setForm({ ...form, metadata })} />
</FormDialog>
</div>
);
}
function ruleSetToForm(ruleSet: PricingRuleSet): PricingForm {
return {
ruleSetKey: ruleSet.ruleSetKey,
name: ruleSet.name,
description: ruleSet.description ?? '',
category: ruleSet.category,
currency: ruleSet.currency,
status: ruleSet.status,
metadata: ruleSet.metadata ?? {},
rules: (ruleSet.rules ?? []).map(ruleToInput),
};
}
function ruleToInput(rule: PricingRule): PricingRuleInput {
return {
ruleKey: rule.ruleKey,
displayName: rule.displayName,
resourceType: rule.resourceType,
unit: rule.unit,
basePrice: rule.basePrice,
currency: rule.currency,
baseWeight: rule.baseWeight ?? {},
dynamicWeight: rule.dynamicWeight ?? {},
calculatorType: rule.calculatorType,
dimensionSchema: rule.dimensionSchema ?? {},
formulaConfig: rule.formulaConfig ?? {},
priority: rule.priority,
status: rule.status,
metadata: rule.metadata ?? {},
};
}
function formToPayload(form: PricingForm): PricingRuleSetUpsertRequest {
const rules = form.rules.map((rule, index) => ({
...rule,
displayName: rule.displayName?.trim() || `${rule.resourceType} 计价`,
priority: rule.priority ?? (index + 1) * 10,
ruleKey: rule.ruleKey?.trim() || `${rule.resourceType}_${index + 1}`,
}));
if (!rules.length) throw new Error('计价规则至少需要一条');
return {
ruleSetKey: form.ruleSetKey.trim(),
name: form.name.trim(),
description: form.description.trim(),
category: form.category,
currency: form.currency,
status: form.status,
metadata: form.metadata,
rules,
};
}
@@ -0,0 +1,265 @@
import { useMemo, useState, type FormEvent } from 'react';
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
import type { CatalogProvider, CatalogProviderUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
import type { LoadState } from '../../types';
type ProviderForm = {
code: string;
displayName: string;
providerType: string;
iconPath: string;
source: string;
status: string;
};
const emptyForm: ProviderForm = {
code: '',
displayName: '',
providerType: 'openai',
iconPath: '',
source: 'gateway',
status: 'active',
};
const defaultSpecTypes = [
'openai',
'azure',
'google-gemini',
'LiblibAI',
'keling',
'runninghub',
'blackforest',
'dify',
'ollama',
'jimeng',
'volces',
'tripo3d',
'tencent-hunyuan',
'tencent-hunyuan-image',
'tencent-hunyuan-video',
'suno',
'minimax',
'midjourney',
'tencent-lke',
'easyai',
'aliyun-bailian',
'universal',
'newapi',
'vidu',
'mock-test',
'n8n',
];
const providerStatuses = ['active', 'deprecated', 'hidden'];
export function ProviderManagementPanel(props: {
message: string;
providers: CatalogProvider[];
state: LoadState;
onDeleteProvider: (providerId: string) => Promise<void>;
onSaveProvider: (input: CatalogProviderUpsertRequest, providerId?: string) => Promise<void>;
}) {
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<ProviderForm>(emptyForm);
const [dialogOpen, setDialogOpen] = useState(false);
const editingProvider = useMemo(
() => props.providers.find((item) => item.id === editingId),
[editingId, props.providers],
);
const specTypes = useMemo(
() => Array.from(new Set([...defaultSpecTypes, ...props.providers.map((item) => item.providerType).filter(Boolean)])),
[props.providers],
);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const payload = providerPayload(form, editingProvider);
try {
await props.onSaveProvider(payload, editingId || undefined);
closeDialog();
} catch {
// The parent surfaces the operation message; keep the current form for correction.
}
}
function openCreateDialog() {
setEditingId('');
setForm(emptyForm);
setDialogOpen(true);
}
function editProvider(provider: CatalogProvider) {
setEditingId(provider.id);
setForm({
code: provider.code,
displayName: provider.displayName,
providerType: provider.providerType,
iconPath: provider.iconPath ?? '',
source: provider.source ?? 'gateway',
status: provider.status,
});
setDialogOpen(true);
}
function closeDialog() {
setEditingId('');
setForm(emptyForm);
setDialogOpen(false);
}
async function deleteProvider(provider: CatalogProvider) {
const confirmed = window.confirm(`确认删除模型厂商 ${provider.displayName}`);
if (!confirmed) return;
try {
await props.onDeleteProvider(provider.id);
if (editingId === provider.id) {
closeDialog();
}
} catch {
// The parent surfaces the operation message.
}
}
return (
<div className="pageStack">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<Badge variant="secondary">{props.providers.length}</Badge>
</CardHeader>
<CardContent>
<div className="providerToolbar">
<p> server-main integration-platformcode </p>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</div>
{props.message && <p className="formMessage">{props.message}</p>}
</CardContent>
</Card>
<section className="providerCatalogGrid">
{props.providers.map((provider) => (
<article className="providerCatalogCard" key={provider.id}>
<ProviderLogo provider={provider} />
<div className="providerCatalogBody">
<div>
<strong>{provider.displayName}</strong>
<span>Code: {provider.code}</span>
</div>
<div className="providerCatalogMeta">
<Badge variant={provider.status === 'active' ? 'success' : 'secondary'}>{provider.status}</Badge>
<span>spec_type: {provider.providerType}</span>
<span>{provider.source ?? 'gateway'}</span>
</div>
</div>
<div className="providerCatalogActions">
<Button type="button" variant="outline" size="sm" onClick={() => editProvider(provider)}>
<Pencil size={14} />
</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => void deleteProvider(provider)}>
<Trash2 size={14} />
</Button>
</div>
</article>
))}
{!props.providers.length && (
<Card>
<CardContent className="emptyState">
<strong></strong>
</CardContent>
</Card>
)}
</section>
<FormDialog
ariaLabel={editingId ? '编辑模型厂商' : '新增模型厂商'}
eyebrow={editingId ? 'Edit Provider' : 'New Provider'}
footer={(
<>
<Button type="submit" disabled={props.state === 'loading'}>
{editingId ? <Pencil size={15} /> : <Plus size={15} />}
{editingId ? '保存修改' : '新增厂商'}
</Button>
<Button type="button" variant="outline" onClick={closeDialog}>
<RotateCcw size={15} />
</Button>
</>
)}
open={dialogOpen}
title={editingId ? '编辑模型厂商' : '新增模型厂商'}
onClose={closeDialog}
onSubmit={submit}
>
<Label>
Code
<Input value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} placeholder="integration-platform code" />
</Label>
<Label>
<Input value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} placeholder="OpenAI" />
</Label>
<Label>
spec_type
<Select value={form.providerType} onChange={(event) => setForm({ ...form, providerType: event.target.value })}>
{specTypes.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</Label>
<Label className="spanTwo">
URL
<Input value={form.iconPath} onChange={(event) => setForm({ ...form, iconPath: event.target.value })} placeholder="https://static.51easyai.com/xxx.png" />
</Label>
<Label>
<Input value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} />
</Label>
<Label>
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
{providerStatuses.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</Label>
</FormDialog>
</div>
);
}
function ProviderLogo(props: { provider: CatalogProvider }) {
if (props.provider.iconPath) {
return (
<div className="providerCatalogLogo">
<img src={props.provider.iconPath} alt="" />
</div>
);
}
return <div className="providerCatalogLogo">{providerInitials(props.provider.displayName)}</div>;
}
function providerPayload(form: ProviderForm, current?: CatalogProvider): CatalogProviderUpsertRequest {
const code = form.code.trim();
return {
providerKey: current?.providerKey ?? code,
code,
displayName: form.displayName.trim(),
providerType: form.providerType.trim() || 'openai',
iconPath: form.iconPath.trim(),
source: form.source.trim() || 'gateway',
status: form.status,
capabilitySchema: current?.capabilitySchema ?? {},
defaultRateLimitPolicy: current?.defaultRateLimitPolicy ?? {},
metadata: current?.metadata ?? {},
};
}
function providerInitials(label: string) {
return label
.split(/\s+/)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase() || 'AI';
}