feat(web): add reusable admin form dialog
This commit is contained in:
parent
c0335bd5d0
commit
a5e66e79cd
@ -11,9 +11,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@easyai-ai-gateway/contracts": "workspace:*",
|
"@easyai-ai-gateway/contracts": "workspace:*",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
BaseModelCatalogItem,
|
BaseModelCatalogItem,
|
||||||
|
BaseModelUpsertRequest,
|
||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
|
CatalogProviderUpsertRequest,
|
||||||
CreatedGatewayApiKey,
|
CreatedGatewayApiKey,
|
||||||
GatewayApiKey,
|
GatewayApiKey,
|
||||||
GatewayTenant,
|
GatewayTenant,
|
||||||
@ -11,6 +13,8 @@ import type {
|
|||||||
ListResponse,
|
ListResponse,
|
||||||
PlatformModel,
|
PlatformModel,
|
||||||
PricingRule,
|
PricingRule,
|
||||||
|
PricingRuleSet,
|
||||||
|
PricingRuleSetUpsertRequest,
|
||||||
RateLimitWindow,
|
RateLimitWindow,
|
||||||
UserGroup,
|
UserGroup,
|
||||||
} from '@easyai-ai-gateway/contracts';
|
} from '@easyai-ai-gateway/contracts';
|
||||||
@ -58,18 +62,117 @@ export async function listModels(token: string): Promise<ListResponse<PlatformMo
|
|||||||
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
|
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listPublicCatalogProviders(): Promise<ListResponse<CatalogProvider>> {
|
||||||
|
return request<ListResponse<CatalogProvider>>('/api/v1/public/catalog/providers', { auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
export async function listCatalogProviders(token: string): Promise<ListResponse<CatalogProvider>> {
|
export async function listCatalogProviders(token: string): Promise<ListResponse<CatalogProvider>> {
|
||||||
return request<ListResponse<CatalogProvider>>('/api/v1/catalog/providers', { token });
|
return request<ListResponse<CatalogProvider>>('/api/v1/catalog/providers', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createCatalogProvider(
|
||||||
|
token: string,
|
||||||
|
input: CatalogProviderUpsertRequest,
|
||||||
|
): Promise<CatalogProvider> {
|
||||||
|
return request<CatalogProvider>('/api/v1/catalog/providers', {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCatalogProvider(
|
||||||
|
token: string,
|
||||||
|
providerId: string,
|
||||||
|
input: CatalogProviderUpsertRequest,
|
||||||
|
): Promise<CatalogProvider> {
|
||||||
|
return request<CatalogProvider>(`/api/v1/catalog/providers/${providerId}`, {
|
||||||
|
body: input,
|
||||||
|
method: 'PATCH',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCatalogProvider(token: string, providerId: string): Promise<void> {
|
||||||
|
await request<void>(`/api/v1/catalog/providers/${providerId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPublicBaseModels(): Promise<ListResponse<BaseModelCatalogItem>> {
|
||||||
|
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/public/catalog/base-models', { auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
export async function listBaseModels(token: string): Promise<ListResponse<BaseModelCatalogItem>> {
|
export async function listBaseModels(token: string): Promise<ListResponse<BaseModelCatalogItem>> {
|
||||||
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/catalog/base-models', { token });
|
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/catalog/base-models', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createBaseModel(token: string, input: BaseModelUpsertRequest): Promise<BaseModelCatalogItem> {
|
||||||
|
return request<BaseModelCatalogItem>('/api/v1/catalog/base-models', {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBaseModel(
|
||||||
|
token: string,
|
||||||
|
baseModelId: string,
|
||||||
|
input: BaseModelUpsertRequest,
|
||||||
|
): Promise<BaseModelCatalogItem> {
|
||||||
|
return request<BaseModelCatalogItem>(`/api/v1/catalog/base-models/${baseModelId}`, {
|
||||||
|
body: input,
|
||||||
|
method: 'PATCH',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBaseModel(token: string, baseModelId: string): Promise<void> {
|
||||||
|
await request<void>(`/api/v1/catalog/base-models/${baseModelId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPricingRules(token: string): Promise<ListResponse<PricingRule>> {
|
export async function listPricingRules(token: string): Promise<ListResponse<PricingRule>> {
|
||||||
return request<ListResponse<PricingRule>>('/api/v1/pricing/rules', { token });
|
return request<ListResponse<PricingRule>>('/api/v1/pricing/rules', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listPricingRuleSets(token: string): Promise<ListResponse<PricingRuleSet>> {
|
||||||
|
return request<ListResponse<PricingRuleSet>>('/api/v1/pricing/rule-sets', { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPricingRuleSet(
|
||||||
|
token: string,
|
||||||
|
input: PricingRuleSetUpsertRequest,
|
||||||
|
): Promise<PricingRuleSet> {
|
||||||
|
return request<PricingRuleSet>('/api/v1/pricing/rule-sets', {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePricingRuleSet(
|
||||||
|
token: string,
|
||||||
|
ruleSetId: string,
|
||||||
|
input: PricingRuleSetUpsertRequest,
|
||||||
|
): Promise<PricingRuleSet> {
|
||||||
|
return request<PricingRuleSet>(`/api/v1/pricing/rule-sets/${ruleSetId}`, {
|
||||||
|
body: input,
|
||||||
|
method: 'PATCH',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePricingRuleSet(token: string, ruleSetId: string): Promise<void> {
|
||||||
|
await request<void>(`/api/v1/pricing/rule-sets/${ruleSetId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listTenants(token: string): Promise<ListResponse<GatewayTenant>> {
|
export async function listTenants(token: string): Promise<ListResponse<GatewayTenant>> {
|
||||||
return request<ListResponse<GatewayTenant>>('/api/v1/tenants', { token });
|
return request<ListResponse<GatewayTenant>>('/api/v1/tenants', { token });
|
||||||
}
|
}
|
||||||
@ -109,6 +212,7 @@ export async function createPlatform(
|
|||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
defaultPricingMode?: string;
|
defaultPricingMode?: string;
|
||||||
defaultDiscountFactor?: number;
|
defaultDiscountFactor?: number;
|
||||||
|
pricingRuleSetId?: string;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
},
|
},
|
||||||
): Promise<IntegrationPlatform> {
|
): Promise<IntegrationPlatform> {
|
||||||
@ -119,6 +223,29 @@ export async function createPlatform(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createPlatformModel(
|
||||||
|
token: string,
|
||||||
|
platformId: string,
|
||||||
|
input: {
|
||||||
|
canonicalModelKey?: string;
|
||||||
|
baseModelId?: string;
|
||||||
|
modelName: string;
|
||||||
|
modelAlias?: string;
|
||||||
|
modelType: string;
|
||||||
|
displayName?: string;
|
||||||
|
retryPolicy?: Record<string, unknown>;
|
||||||
|
rateLimitPolicy?: Record<string, unknown>;
|
||||||
|
pricingRuleSetId?: string;
|
||||||
|
discountFactor?: number;
|
||||||
|
},
|
||||||
|
): Promise<PlatformModel> {
|
||||||
|
return request<PlatformModel>(`/api/v1/platforms/${platformId}/models`, {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createChatTask(
|
export async function createChatTask(
|
||||||
token: string,
|
token: string,
|
||||||
input: { model: string; messages: Array<Record<string, unknown>>; runMode?: string; simulation?: boolean },
|
input: { model: string; messages: Array<Record<string, unknown>>; runMode?: string; simulation?: boolean },
|
||||||
@ -130,6 +257,39 @@ export async function createChatTask(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createImageGenerationTask(
|
||||||
|
token: string,
|
||||||
|
input: { model: string; prompt: string; size?: string; quality?: string; runMode?: string; simulation?: boolean },
|
||||||
|
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||||
|
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/images/generations', {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createImageEditTask(
|
||||||
|
token: string,
|
||||||
|
input: { model: string; prompt: string; image?: string; mask?: string; runMode?: string; simulation?: boolean },
|
||||||
|
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||||
|
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/images/edits', {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function estimatePricing(
|
||||||
|
token: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
): Promise<{ items: unknown[]; resolver: string }> {
|
||||||
|
return request<{ items: unknown[]; resolver: string }>('/api/v1/pricing/estimate', {
|
||||||
|
body: input,
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTask(token: string, taskId: string): Promise<GatewayTask> {
|
export async function getTask(token: string, taskId: string): Promise<GatewayTask> {
|
||||||
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
|
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
|
||||||
}
|
}
|
||||||
@ -158,6 +318,9 @@ async function request<T>(
|
|||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
throw new Error(parseErrorMessage(body) || `Request failed: ${response.status}`);
|
throw new Error(parseErrorMessage(body) || `Request failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
apps/web/src/app-state.ts
Normal file
35
apps/web/src/app-state.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type {
|
||||||
|
BaseModelCatalogItem,
|
||||||
|
CatalogProvider,
|
||||||
|
GatewayApiKey,
|
||||||
|
GatewayTask,
|
||||||
|
GatewayTenant,
|
||||||
|
GatewayUser,
|
||||||
|
IntegrationPlatform,
|
||||||
|
PlatformModel,
|
||||||
|
PricingRule,
|
||||||
|
PricingRuleSet,
|
||||||
|
RateLimitWindow,
|
||||||
|
UserGroup,
|
||||||
|
} from '@easyai-ai-gateway/contracts';
|
||||||
|
|
||||||
|
export interface ConsoleData {
|
||||||
|
apiKeys: GatewayApiKey[];
|
||||||
|
baseModels: BaseModelCatalogItem[];
|
||||||
|
models: PlatformModel[];
|
||||||
|
platforms: IntegrationPlatform[];
|
||||||
|
pricingRules: PricingRule[];
|
||||||
|
pricingRuleSets: PricingRuleSet[];
|
||||||
|
providers: CatalogProvider[];
|
||||||
|
rateLimitWindows: RateLimitWindow[];
|
||||||
|
taskResult: GatewayTask | null;
|
||||||
|
tenants: GatewayTenant[];
|
||||||
|
userGroups: UserGroup[];
|
||||||
|
users: GatewayUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatItem {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
tone: 'blue' | 'green' | 'violet' | 'amber' | 'cyan' | 'rose' | 'slate';
|
||||||
|
}
|
||||||
134
apps/web/src/components/AuthPanel.tsx
Normal file
134
apps/web/src/components/AuthPanel.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { LogIn, UserPlus } from 'lucide-react';
|
||||||
|
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Tabs } from './ui';
|
||||||
|
import type { AuthMode, LoadState, LoginForm, RegisterForm } from '../types';
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ value: 'login', label: '账号登录' },
|
||||||
|
{ value: 'register', label: '注册账号' },
|
||||||
|
{ value: 'external', label: '外部 Token' },
|
||||||
|
] satisfies Array<{ value: AuthMode; label: string }>;
|
||||||
|
|
||||||
|
export function AuthPanel(props: {
|
||||||
|
authMode: AuthMode;
|
||||||
|
externalToken: string;
|
||||||
|
loginForm: LoginForm;
|
||||||
|
registerForm: RegisterForm;
|
||||||
|
state: LoadState;
|
||||||
|
onAuthModeChange: (value: AuthMode) => void;
|
||||||
|
onExternalTokenChange: (value: string) => void;
|
||||||
|
onLoginChange: (value: LoginForm) => void;
|
||||||
|
onRegisterChange: (value: RegisterForm) => void;
|
||||||
|
onSubmitExternalToken: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="authShell" aria-label="登录">
|
||||||
|
<Card className="authCard">
|
||||||
|
<CardHeader>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Gateway Identity</p>
|
||||||
|
<CardTitle>登录 AI Gateway</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="authContent">
|
||||||
|
<Tabs value={props.authMode} tabs={tabs} onValueChange={props.onAuthModeChange} />
|
||||||
|
{props.authMode === 'login' && <LoginFormView {...props} />}
|
||||||
|
{props.authMode === 'register' && <RegisterFormView {...props} />}
|
||||||
|
{props.authMode === 'external' && <ExternalTokenForm {...props} />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginFormView(props: {
|
||||||
|
loginForm: LoginForm;
|
||||||
|
state: LoadState;
|
||||||
|
onLoginChange: (value: LoginForm) => void;
|
||||||
|
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form className="formGrid" onSubmit={props.onSubmitLogin}>
|
||||||
|
<Label>
|
||||||
|
账号
|
||||||
|
<Input
|
||||||
|
autoComplete="username"
|
||||||
|
value={props.loginForm.account}
|
||||||
|
onChange={(event) => props.onLoginChange({ ...props.loginForm, account: event.target.value })}
|
||||||
|
placeholder="用户名或邮箱"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
密码
|
||||||
|
<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'}>
|
||||||
|
<LogIn size={15} />
|
||||||
|
{props.state === 'loading' ? '登录中' : '登录'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegisterFormView(props: {
|
||||||
|
registerForm: RegisterForm;
|
||||||
|
state: LoadState;
|
||||||
|
onRegisterChange: (value: RegisterForm) => void;
|
||||||
|
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form className="formGrid two" onSubmit={props.onSubmitRegister}>
|
||||||
|
<Label>
|
||||||
|
用户名
|
||||||
|
<Input autoComplete="username" value={props.registerForm.username} onChange={(event) => props.onRegisterChange({ ...props.registerForm, username: event.target.value })} />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
邮箱
|
||||||
|
<Input autoComplete="email" type="email" value={props.registerForm.email} onChange={(event) => props.onRegisterChange({ ...props.registerForm, email: event.target.value })} />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
显示名
|
||||||
|
<Input value={props.registerForm.displayName} onChange={(event) => props.onRegisterChange({ ...props.registerForm, displayName: event.target.value })} />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
密码
|
||||||
|
<Input autoComplete="new-password" type="password" value={props.registerForm.password} onChange={(event) => props.onRegisterChange({ ...props.registerForm, password: event.target.value })} />
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
邀请码
|
||||||
|
<Input value={props.registerForm.invitationCode} onChange={(event) => props.onRegisterChange({ ...props.registerForm, invitationCode: event.target.value })} placeholder="可选" />
|
||||||
|
</Label>
|
||||||
|
<Button type="submit" disabled={props.state === 'loading'} className="spanTwo">
|
||||||
|
<UserPlus size={15} />
|
||||||
|
{props.state === 'loading' ? '注册中' : '注册并登录'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExternalTokenForm(props: {
|
||||||
|
externalToken: string;
|
||||||
|
state: LoadState;
|
||||||
|
onExternalTokenChange: (value: string) => void;
|
||||||
|
onSubmitExternalToken: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form className="formGrid" onSubmit={props.onSubmitExternalToken}>
|
||||||
|
<Label>
|
||||||
|
Access Token
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
apps/web/src/components/CoreFlowPanel.tsx
Normal file
171
apps/web/src/components/CoreFlowPanel.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import type { GatewayApiKey, GatewayTask } from '@easyai-ai-gateway/contracts';
|
||||||
|
import type { LoadState, TaskForm } from '../types';
|
||||||
|
|
||||||
|
const taskKindOptions = [
|
||||||
|
['chat.completions', 'Chat'],
|
||||||
|
['images.generations', '生图'],
|
||||||
|
['images.edits', '图像编辑'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function CoreFlowPanel(props: {
|
||||||
|
apiKeyForm: { name: string };
|
||||||
|
apiKeys: GatewayApiKey[];
|
||||||
|
apiKeySecret: string;
|
||||||
|
coreMessage: string;
|
||||||
|
coreState: LoadState;
|
||||||
|
platformForm: { provider: string; platformKey: string; name: string; baseUrl: string };
|
||||||
|
taskForm: TaskForm;
|
||||||
|
taskResult: GatewayTask | null;
|
||||||
|
onAPIKeyFormChange: (value: { name: string }) => void;
|
||||||
|
onPlatformFormChange: (value: { provider: string; platformKey: string; name: string; baseUrl: string }) => void;
|
||||||
|
onSubmitAPIKey: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onSubmitPlatform: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onTaskFormChange: (value: TaskForm) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="corePanel" aria-label="核心链路验证">
|
||||||
|
<div className="sectionHeader">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Smoke Flow</p>
|
||||||
|
<h2>核心链路验证</h2>
|
||||||
|
</div>
|
||||||
|
<span>{props.coreState === 'loading' ? '运行中' : '本地闭环'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="coreGrid">
|
||||||
|
<ApiKeyForm {...props} />
|
||||||
|
<PlatformForm {...props} />
|
||||||
|
<TaskSmokeForm {...props} />
|
||||||
|
</div>
|
||||||
|
{props.coreMessage && (
|
||||||
|
<p className="coreMessage" data-error={props.coreState === 'error'}>
|
||||||
|
{props.coreMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApiKeyForm(props: {
|
||||||
|
apiKeyForm: { name: string };
|
||||||
|
apiKeys: GatewayApiKey[];
|
||||||
|
apiKeySecret: string;
|
||||||
|
coreState: LoadState;
|
||||||
|
onAPIKeyFormChange: (value: { name: string }) => void;
|
||||||
|
onSubmitAPIKey: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form className="inlineForm" onSubmit={props.onSubmitAPIKey}>
|
||||||
|
<h3>1. API Key</h3>
|
||||||
|
<label>
|
||||||
|
<span>名称</span>
|
||||||
|
<input value={props.apiKeyForm.name} onChange={(event) => props.onAPIKeyFormChange({ name: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={props.coreState === 'loading'}>
|
||||||
|
创建 API Key
|
||||||
|
</button>
|
||||||
|
<p className="formHint">已创建 {props.apiKeys.length} 个 Key</p>
|
||||||
|
{props.apiKeySecret && <code className="secretBox">{props.apiKeySecret}</code>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformForm(props: {
|
||||||
|
coreState: LoadState;
|
||||||
|
platformForm: { provider: string; platformKey: string; name: string; baseUrl: string };
|
||||||
|
onPlatformFormChange: (value: { provider: string; platformKey: string; name: string; baseUrl: string }) => void;
|
||||||
|
onSubmitPlatform: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form className="inlineForm" onSubmit={props.onSubmitPlatform}>
|
||||||
|
<h3>2. 平台</h3>
|
||||||
|
<label>
|
||||||
|
<span>Provider</span>
|
||||||
|
<input value={props.platformForm.provider} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, provider: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>平台 Key</span>
|
||||||
|
<input value={props.platformForm.platformKey} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, platformKey: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>名称</span>
|
||||||
|
<input value={props.platformForm.name} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, name: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Base URL</span>
|
||||||
|
<input value={props.platformForm.baseUrl} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, baseUrl: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={props.coreState === 'loading'}>
|
||||||
|
创建平台
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskSmokeForm(props: {
|
||||||
|
coreState: LoadState;
|
||||||
|
taskForm: TaskForm;
|
||||||
|
taskResult: GatewayTask | null;
|
||||||
|
onSubmitTask: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onTaskFormChange: (value: TaskForm) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form className="inlineForm" onSubmit={props.onSubmitTask}>
|
||||||
|
<h3>3. Phase 1 测试</h3>
|
||||||
|
<label>
|
||||||
|
<span>能力</span>
|
||||||
|
<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>
|
||||||
|
<span>模型</span>
|
||||||
|
<input value={props.taskForm.model} onChange={(event) => props.onTaskFormChange({ ...props.taskForm, model: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Prompt</span>
|
||||||
|
<input value={props.taskForm.prompt} onChange={(event) => props.onTaskFormChange({ ...props.taskForm, prompt: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
{props.taskForm.kind === 'images.edits' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span>参考图 URL</span>
|
||||||
|
<input value={props.taskForm.image ?? ''} onChange={(event) => props.onTaskFormChange({ ...props.taskForm, image: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Mask URL</span>
|
||||||
|
<input value={props.taskForm.mask ?? ''} onChange={(event) => props.onTaskFormChange({ ...props.taskForm, mask: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={props.coreState === 'loading'}>
|
||||||
|
生成测试任务
|
||||||
|
</button>
|
||||||
|
{props.taskResult && (
|
||||||
|
<div className="resultBox">
|
||||||
|
<div>
|
||||||
|
<span className="statusPill">{props.taskResult.status}</span>
|
||||||
|
<strong>{props.taskResult.model}</strong>
|
||||||
|
</div>
|
||||||
|
<pre>{JSON.stringify(props.taskResult.result ?? {}, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, model: 'gpt-image-1', image: current.image ?? 'https://example.com/source.png', mask: current.mask ?? 'https://example.com/mask.png' };
|
||||||
|
}
|
||||||
|
return { ...current, kind, model: 'gpt-image-1' };
|
||||||
|
}
|
||||||
96
apps/web/src/components/Dashboard.tsx
Normal file
96
apps/web/src/components/Dashboard.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { BaseModelCatalogItem, IntegrationPlatform, PlatformModel, RateLimitWindow } from '@easyai-ai-gateway/contracts';
|
||||||
|
import { adminPages, apiDocPages, primaryModules, workspacePages } from '../navigation';
|
||||||
|
import { DataPanel } from './DataPanel';
|
||||||
|
import { ModuleList } from './ModuleList';
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/src/components/DataPanel.tsx
Normal file
25
apps/web/src/components/DataPanel.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/src/components/EntityTable.tsx
Normal file
25
apps/web/src/components/EntityTable.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { EmptyState, Table, TableCell, TableHead, TableRow } from './ui';
|
||||||
|
|
||||||
|
export function EntityTable(props: {
|
||||||
|
columns: string[];
|
||||||
|
empty: string;
|
||||||
|
rows: Array<Array<string | number>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableRow className="shTableHeader">
|
||||||
|
{props.columns.map((column) => (
|
||||||
|
<TableHead key={column}>{column}</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
{props.rows.map((row, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
{row.map((cell, cellIndex) => (
|
||||||
|
<TableCell key={`${index}-${cellIndex}`}>{cell}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!props.rows.length && <EmptyState title={props.empty} />}
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/web/src/components/LoginRequiredPanel.tsx
Normal file
14
apps/web/src/components/LoginRequiredPanel.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { AuthPanel } from './AuthPanel';
|
||||||
|
|
||||||
|
export function LoginRequiredPanel(props: Parameters<typeof AuthPanel>[0]) {
|
||||||
|
return (
|
||||||
|
<div className="loginRequiredPage">
|
||||||
|
<div className="loginRequiredCopy">
|
||||||
|
<p className="eyebrow">Identity</p>
|
||||||
|
<h1>登录后进入工作台</h1>
|
||||||
|
<p>用户工作台和管理后台需要本地账号、API Key 或 server-main token。</p>
|
||||||
|
</div>
|
||||||
|
<AuthPanel {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/web/src/components/ModuleList.tsx
Normal file
19
apps/web/src/components/ModuleList.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/web/src/components/PageHeader.tsx
Normal file
14
apps/web/src/components/PageHeader.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function PageHeader(props: { eyebrow: string; title: string; description?: string; action?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="pageHeader">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{props.eyebrow}</p>
|
||||||
|
<h1>{props.title}</h1>
|
||||||
|
{props.description && <p>{props.description}</p>}
|
||||||
|
</div>
|
||||||
|
{props.action && <div className="pageHeaderAction">{props.action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/web/src/components/StatGrid.tsx
Normal file
14
apps/web/src/components/StatGrid.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { StatItem } from '../app-state';
|
||||||
|
|
||||||
|
export function StatGrid(props: { items: StatItem[] }) {
|
||||||
|
return (
|
||||||
|
<section className="statGrid" aria-label="统计">
|
||||||
|
{props.items.map((item) => (
|
||||||
|
<div className="statCard" data-tone={item.tone} key={item.label}>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
apps/web/src/components/layout/AppShell.tsx
Normal file
82
apps/web/src/components/layout/AppShell.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { BookOpen, Boxes, Home, RefreshCw, ShieldCheck, UserCircle } from 'lucide-react';
|
||||||
|
import type { HealthResponse } from '../../api';
|
||||||
|
import type { LoadState, PageKey } from '../../types';
|
||||||
|
import { Button, Badge } from '../ui';
|
||||||
|
|
||||||
|
const navItems: Array<{ key: PageKey; label: string; icon: ReactNode }> = [
|
||||||
|
{ key: 'home', label: '首页', icon: <Home size={17} /> },
|
||||||
|
{ key: 'models', label: '模型', icon: <Boxes size={17} /> },
|
||||||
|
{ key: 'workspace', label: '用户工作台', icon: <UserCircle size={17} /> },
|
||||||
|
{ key: 'admin', label: '管理工作台', icon: <ShieldCheck size={17} /> },
|
||||||
|
{ key: 'docs', label: 'API 文档', icon: <BookOpen size={17} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppShell(props: {
|
||||||
|
activePage: PageKey;
|
||||||
|
children: ReactNode;
|
||||||
|
health: HealthResponse | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
state: LoadState;
|
||||||
|
onNavigate: (page: PageKey) => void;
|
||||||
|
onLogin: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onSignOut: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="appShell">
|
||||||
|
<header className="appTopbar">
|
||||||
|
<div className="brandBlock">
|
||||||
|
<div className="brandMark">AI</div>
|
||||||
|
<div>
|
||||||
|
<strong>EasyAI Gateway</strong>
|
||||||
|
<span>Console</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="topNav" aria-label="主导航">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topNavItem"
|
||||||
|
data-active={props.activePage === item.key}
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => props.onNavigate(item.key)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="topbarActions">
|
||||||
|
<div className="health" data-ok={props.health?.ok === true}>
|
||||||
|
<span />
|
||||||
|
{props.health?.identityMode ? `${props.health.service} · ${props.health.identityMode}` : props.health?.service ?? 'API 未连接'}
|
||||||
|
</div>
|
||||||
|
{props.isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={props.onRefresh} disabled={props.state === 'loading'}>
|
||||||
|
<RefreshCw size={15} />
|
||||||
|
{props.state === 'loading' ? '刷新中' : '刷新'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={props.onSignOut}>
|
||||||
|
退出
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button type="button" size="sm" onClick={props.onLogin}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="workspaceShell">
|
||||||
|
<main className="contentShell">
|
||||||
|
{props.state === 'error' && <Badge variant="destructive">数据加载失败</Badge>}
|
||||||
|
{props.children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/src/components/ui/badge.tsx
Normal file
24
apps/web/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva('shBadge', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'shBadgeDefault',
|
||||||
|
secondary: 'shBadgeSecondary',
|
||||||
|
outline: 'shBadgeOutline',
|
||||||
|
success: 'shBadgeSuccess',
|
||||||
|
warning: 'shBadgeWarning',
|
||||||
|
destructive: 'shBadgeDestructive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Badge(props: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
|
||||||
|
const { className, variant, ...rest } = props;
|
||||||
|
return <div className={cn(badgeVariants({ variant, className }))} {...rest} />;
|
||||||
|
}
|
||||||
40
apps/web/src/components/ui/button.tsx
Normal file
40
apps/web/src/components/ui/button.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva('shButton', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'shButtonDefault',
|
||||||
|
secondary: 'shButtonSecondary',
|
||||||
|
outline: 'shButtonOutline',
|
||||||
|
ghost: 'shButtonGhost',
|
||||||
|
destructive: 'shButtonDestructive',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'shButtonDefaultSize',
|
||||||
|
sm: 'shButtonSm',
|
||||||
|
icon: 'shButtonIcon',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
32
apps/web/src/components/ui/card.tsx
Normal file
32
apps/web/src/components/ui/card.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => <div ref={ref} className={cn('shCard', className)} {...props} />,
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => <div ref={ref} className={cn('shCardHeader', className)} {...props} />,
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
export const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => <h3 ref={ref} className={cn('shCardTitle', className)} {...props} />,
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => <p ref={ref} className={cn('shCardDescription', className)} {...props} />,
|
||||||
|
);
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => <div ref={ref} className={cn('shCardContent', className)} {...props} />,
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => <div ref={ref} className={cn('shCardFooter', className)} {...props} />,
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
53
apps/web/src/components/ui/dialog.tsx
Normal file
53
apps/web/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { Button } from './button';
|
||||||
|
|
||||||
|
export interface FormDialogProps {
|
||||||
|
ariaLabel?: string;
|
||||||
|
bodyClassName?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
closeLabel?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
footer: React.ReactNode;
|
||||||
|
formClassName?: string;
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: React.FormEventHandler<HTMLFormElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormDialog(props: FormDialogProps) {
|
||||||
|
const { onClose, open } = props;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [onClose, open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const closeLabel = props.closeLabel ?? '关闭';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="formDialogBackdrop" role="presentation">
|
||||||
|
<div className={cn('formDialog', props.className)} role="dialog" aria-modal="true" aria-label={props.ariaLabel ?? props.title}>
|
||||||
|
<header className="formDialogHeader">
|
||||||
|
<div>
|
||||||
|
{props.eyebrow && <span>{props.eyebrow}</span>}
|
||||||
|
<strong>{props.title}</strong>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={props.onClose}>{closeLabel}</Button>
|
||||||
|
</header>
|
||||||
|
<form className={cn('formDialogForm', props.formClassName)} onSubmit={props.onSubmit}>
|
||||||
|
<div className={cn('formDialogBody', props.bodyClassName)}>{props.children}</div>
|
||||||
|
<div className="formDialogActions">{props.footer}</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/web/src/components/ui/index.ts
Normal file
11
apps/web/src/components/ui/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export * from './badge';
|
||||||
|
export * from './button';
|
||||||
|
export * from './card';
|
||||||
|
export * from './dialog';
|
||||||
|
export * from './input';
|
||||||
|
export * from './label';
|
||||||
|
export * from './select';
|
||||||
|
export * from './separator';
|
||||||
|
export * from './table';
|
||||||
|
export * from './tabs';
|
||||||
|
export * from './textarea';
|
||||||
8
apps/web/src/components/ui/input.tsx
Normal file
8
apps/web/src/components/ui/input.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, ...props }, ref) => <input ref={ref} className={cn('shInput', className)} {...props} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
8
apps/web/src/components/ui/label.tsx
Normal file
8
apps/web/src/components/ui/label.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
||||||
|
({ className, ...props }, ref) => <label ref={ref} className={cn('shLabel', className)} {...props} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
Label.displayName = 'Label';
|
||||||
8
apps/web/src/components/ui/select.tsx
Normal file
8
apps/web/src/components/ui/select.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
|
||||||
|
({ className, ...props }, ref) => <select ref={ref} className={cn('shInput', className)} {...props} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
Select.displayName = 'Select';
|
||||||
7
apps/web/src/components/ui/separator.tsx
Normal file
7
apps/web/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export function Separator(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
return <div className={cn('shSeparator', className)} {...rest} />;
|
||||||
|
}
|
||||||
31
apps/web/src/components/ui/table.tsx
Normal file
31
apps/web/src/components/ui/table.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export function Table(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
return <div className={cn('shTable', className)} role="table" {...rest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableRow(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
return <div className={cn('shTableRow', className)} role="row" {...rest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHead(props: React.HTMLAttributes<HTMLSpanElement>) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
return <span className={cn('shTableHead', className)} role="columnheader" {...rest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableCell(props: React.HTMLAttributes<HTMLSpanElement>) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
return <span className={cn('shTableCell', className)} role="cell" {...rest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState(props: { title: string; description?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="emptyState">
|
||||||
|
<strong>{props.title}</strong>
|
||||||
|
{props.description && <span>{props.description}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/web/src/components/ui/tabs.tsx
Normal file
26
apps/web/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export function Tabs<T extends string>(props: {
|
||||||
|
value: T;
|
||||||
|
tabs: Array<{ value: T; label: string; icon?: React.ReactNode }>;
|
||||||
|
onValueChange: (value: T) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('shTabs', props.className)} role="tablist">
|
||||||
|
{props.tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shTab"
|
||||||
|
data-active={props.value === tab.value}
|
||||||
|
key={tab.value}
|
||||||
|
onClick={() => props.onValueChange(tab.value)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/web/src/components/ui/textarea.tsx
Normal file
8
apps/web/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||||
|
({ className, ...props }, ref) => <textarea ref={ref} className={cn('shTextarea', className)} {...props} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
99
apps/web/src/hooks/useCatalogOperations.ts
Normal file
99
apps/web/src/hooks/useCatalogOperations.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import type {
|
||||||
|
BaseModelCatalogItem,
|
||||||
|
BaseModelUpsertRequest,
|
||||||
|
CatalogProvider,
|
||||||
|
CatalogProviderUpsertRequest,
|
||||||
|
} from '@easyai-ai-gateway/contracts';
|
||||||
|
import {
|
||||||
|
createBaseModel,
|
||||||
|
createCatalogProvider,
|
||||||
|
deleteBaseModel,
|
||||||
|
deleteCatalogProvider,
|
||||||
|
updateBaseModel,
|
||||||
|
updateCatalogProvider,
|
||||||
|
} from '../api';
|
||||||
|
import type { LoadState } from '../types';
|
||||||
|
|
||||||
|
export function useCatalogOperations(input: {
|
||||||
|
setBaseModels: Dispatch<SetStateAction<BaseModelCatalogItem[]>>;
|
||||||
|
setCoreMessage: Dispatch<SetStateAction<string>>;
|
||||||
|
setCoreState: Dispatch<SetStateAction<LoadState>>;
|
||||||
|
setProviders: Dispatch<SetStateAction<CatalogProvider[]>>;
|
||||||
|
token: string;
|
||||||
|
}) {
|
||||||
|
async function saveProvider(payload: CatalogProviderUpsertRequest, providerId?: string) {
|
||||||
|
if (!input.token) throw new Error('请先登录后再维护模型厂商');
|
||||||
|
input.setCoreState('loading');
|
||||||
|
input.setCoreMessage('');
|
||||||
|
try {
|
||||||
|
const provider = providerId
|
||||||
|
? await updateCatalogProvider(input.token, providerId, payload)
|
||||||
|
: await createCatalogProvider(input.token, payload);
|
||||||
|
input.setProviders((current) => [provider, ...current.filter((item) => item.id !== provider.id)]);
|
||||||
|
input.setCoreState('ready');
|
||||||
|
input.setCoreMessage(providerId ? '模型厂商已更新。' : '模型厂商已新增。');
|
||||||
|
} catch (err) {
|
||||||
|
input.setCoreState('error');
|
||||||
|
input.setCoreMessage(err instanceof Error ? err.message : '模型厂商保存失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProvider(providerId: string) {
|
||||||
|
if (!input.token) throw new Error('请先登录后再维护模型厂商');
|
||||||
|
input.setCoreState('loading');
|
||||||
|
input.setCoreMessage('');
|
||||||
|
try {
|
||||||
|
await deleteCatalogProvider(input.token, providerId);
|
||||||
|
input.setProviders((current) => current.filter((item) => item.id !== providerId));
|
||||||
|
input.setCoreState('ready');
|
||||||
|
input.setCoreMessage('模型厂商已删除。');
|
||||||
|
} catch (err) {
|
||||||
|
input.setCoreState('error');
|
||||||
|
input.setCoreMessage(err instanceof Error ? err.message : '模型厂商删除失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBaseModel(payload: BaseModelUpsertRequest, baseModelId?: string) {
|
||||||
|
if (!input.token) throw new Error('请先登录后再维护基准模型');
|
||||||
|
input.setCoreState('loading');
|
||||||
|
input.setCoreMessage('');
|
||||||
|
try {
|
||||||
|
const model = baseModelId
|
||||||
|
? await updateBaseModel(input.token, baseModelId, payload)
|
||||||
|
: await createBaseModel(input.token, payload);
|
||||||
|
input.setBaseModels((current) => [model, ...current.filter((item) => item.id !== model.id)]);
|
||||||
|
input.setCoreState('ready');
|
||||||
|
input.setCoreMessage(baseModelId ? '基准模型已更新。' : '基准模型已新增。');
|
||||||
|
} catch (err) {
|
||||||
|
input.setCoreState('error');
|
||||||
|
input.setCoreMessage(err instanceof Error ? err.message : '基准模型保存失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeBaseModel(baseModelId: string) {
|
||||||
|
if (!input.token) throw new Error('请先登录后再维护基准模型');
|
||||||
|
input.setCoreState('loading');
|
||||||
|
input.setCoreMessage('');
|
||||||
|
try {
|
||||||
|
await deleteBaseModel(input.token, baseModelId);
|
||||||
|
input.setBaseModels((current) => current.filter((item) => item.id !== baseModelId));
|
||||||
|
input.setCoreState('ready');
|
||||||
|
input.setCoreMessage('基准模型已删除。');
|
||||||
|
} catch (err) {
|
||||||
|
input.setCoreState('error');
|
||||||
|
input.setCoreMessage(err instanceof Error ? err.message : '基准模型删除失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
removeBaseModel,
|
||||||
|
removeProvider,
|
||||||
|
saveBaseModel,
|
||||||
|
saveProvider,
|
||||||
|
};
|
||||||
|
}
|
||||||
47
apps/web/src/hooks/usePricingRuleSetOperations.ts
Normal file
47
apps/web/src/hooks/usePricingRuleSetOperations.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import type { PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||||||
|
import { createPricingRuleSet, deletePricingRuleSet, updatePricingRuleSet } from '../api';
|
||||||
|
import type { LoadState } from '../types';
|
||||||
|
|
||||||
|
export function usePricingRuleSetOperations(input: {
|
||||||
|
setCoreMessage: Dispatch<SetStateAction<string>>;
|
||||||
|
setCoreState: Dispatch<SetStateAction<LoadState>>;
|
||||||
|
setPricingRuleSets: Dispatch<SetStateAction<PricingRuleSet[]>>;
|
||||||
|
token: string;
|
||||||
|
}) {
|
||||||
|
async function savePricingRuleSet(payload: PricingRuleSetUpsertRequest, ruleSetId?: string) {
|
||||||
|
if (!input.token) throw new Error('请先登录后再维护定价规则');
|
||||||
|
input.setCoreState('loading');
|
||||||
|
input.setCoreMessage('');
|
||||||
|
try {
|
||||||
|
const item = ruleSetId
|
||||||
|
? await updatePricingRuleSet(input.token, ruleSetId, payload)
|
||||||
|
: await createPricingRuleSet(input.token, payload);
|
||||||
|
input.setPricingRuleSets((current) => [item, ...current.filter((ruleSet) => ruleSet.id !== item.id)]);
|
||||||
|
input.setCoreState('ready');
|
||||||
|
input.setCoreMessage(ruleSetId ? '定价规则已更新。' : '定价规则已新增。');
|
||||||
|
} catch (err) {
|
||||||
|
input.setCoreState('error');
|
||||||
|
input.setCoreMessage(err instanceof Error ? err.message : '定价规则保存失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePricingRuleSet(ruleSetId: string) {
|
||||||
|
if (!input.token) throw new Error('请先登录后再维护定价规则');
|
||||||
|
input.setCoreState('loading');
|
||||||
|
input.setCoreMessage('');
|
||||||
|
try {
|
||||||
|
await deletePricingRuleSet(input.token, ruleSetId);
|
||||||
|
input.setPricingRuleSets((current) => current.filter((item) => item.id !== ruleSetId));
|
||||||
|
input.setCoreState('ready');
|
||||||
|
input.setCoreMessage('定价规则已删除。');
|
||||||
|
} catch (err) {
|
||||||
|
input.setCoreState('error');
|
||||||
|
input.setCoreMessage(err instanceof Error ? err.message : '定价规则删除失败');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { removePricingRuleSet, savePricingRuleSet };
|
||||||
|
}
|
||||||
23
apps/web/src/lib/auth-storage.ts
Normal file
23
apps/web/src/lib/auth-storage.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
const AUTH_TOKEN_STORAGE_KEY = 'easyai_ai_gateway_access_token';
|
||||||
|
|
||||||
|
export function readStoredAccessToken() {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) ?? '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistAccessToken(value: string) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
if (value) {
|
||||||
|
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, value);
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures so private browsing or quota issues do not break login.
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/web/src/lib/run-task.ts
Normal file
32
apps/web/src/lib/run-task.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { GatewayTask } from '@easyai-ai-gateway/contracts';
|
||||||
|
import { createChatTask, createImageEditTask, createImageGenerationTask } from '../api';
|
||||||
|
import type { TaskForm } from '../types';
|
||||||
|
|
||||||
|
export function runTask(token: string, task: TaskForm): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||||
|
if (task.kind === 'images.generations') {
|
||||||
|
return createImageGenerationTask(token, {
|
||||||
|
model: task.model,
|
||||||
|
prompt: task.prompt,
|
||||||
|
quality: 'medium',
|
||||||
|
runMode: 'simulation',
|
||||||
|
simulation: true,
|
||||||
|
size: '1024x1024',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (task.kind === 'images.edits') {
|
||||||
|
return createImageEditTask(token, {
|
||||||
|
model: task.model,
|
||||||
|
prompt: task.prompt,
|
||||||
|
image: task.image,
|
||||||
|
mask: task.mask,
|
||||||
|
runMode: 'simulation',
|
||||||
|
simulation: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createChatTask(token, {
|
||||||
|
model: task.model,
|
||||||
|
runMode: 'simulation',
|
||||||
|
simulation: true,
|
||||||
|
messages: [{ role: 'user', content: task.prompt }],
|
||||||
|
});
|
||||||
|
}
|
||||||
6
apps/web/src/lib/utils.ts
Normal file
6
apps/web/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
56
apps/web/src/navigation.ts
Normal file
56
apps/web/src/navigation.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
export 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: ['快速开始', '接口文档', '在线调试'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export 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、生图、生视频任务列表、进度、结果和计费明细。' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export 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。' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export 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。' },
|
||||||
|
];
|
||||||
308
apps/web/src/pages/AdminPage.tsx
Normal file
308
apps/web/src/pages/AdminPage.tsx
Normal 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
apps/web/src/pages/ApiDocsPage.tsx
Normal file
223
apps/web/src/pages/ApiDocsPage.tsx
Normal 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
apps/web/src/pages/HomePage.tsx
Normal file
127
apps/web/src/pages/HomePage.tsx
Normal 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
apps/web/src/pages/ModelsPage.tsx
Normal file
384
apps/web/src/pages/ModelsPage.tsx
Normal 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
apps/web/src/pages/WorkspacePage.tsx
Normal file
178
apps/web/src/pages/WorkspacePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
374
apps/web/src/pages/admin/BaseModelCatalogPanel.tsx
Normal file
374
apps/web/src/pages/admin/BaseModelCatalogPanel.tsx
Normal file
@ -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;
|
||||||
|
}
|
||||||
348
apps/web/src/pages/admin/PricingRuleVisualEditor.tsx
Normal file
348
apps/web/src/pages/admin/PricingRuleVisualEditor.tsx
Normal file
@ -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}`;
|
||||||
|
}
|
||||||
225
apps/web/src/pages/admin/PricingRulesPanel.tsx
Normal file
225
apps/web/src/pages/admin/PricingRulesPanel.tsx
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
265
apps/web/src/pages/admin/ProviderManagementPanel.tsx
Normal file
265
apps/web/src/pages/admin/ProviderManagementPanel.tsx
Normal file
@ -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-platform,包含厂商名称、code 和图标。</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';
|
||||||
|
}
|
||||||
110
apps/web/src/routing.ts
Normal file
110
apps/web/src/routing.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import type { AdminSection, ApiDocSection, PageKey, WorkspaceSection } from './types';
|
||||||
|
|
||||||
|
export interface AppRouteState {
|
||||||
|
activePage: PageKey;
|
||||||
|
adminSection: AdminSection;
|
||||||
|
apiDocSection: ApiDocSection;
|
||||||
|
workspaceSection: WorkspaceSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultRouteState: AppRouteState = {
|
||||||
|
activePage: 'home',
|
||||||
|
adminSection: 'overview',
|
||||||
|
apiDocSection: 'chat',
|
||||||
|
workspaceSection: 'overview',
|
||||||
|
};
|
||||||
|
|
||||||
|
const workspacePaths: Record<WorkspaceSection, string> = {
|
||||||
|
overview: '/workspace/overview',
|
||||||
|
billing: '/workspace/billing',
|
||||||
|
apiKeys: '/workspace/api-keys',
|
||||||
|
tasks: '/workspace/tasks',
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminPaths: Record<AdminSection, string> = {
|
||||||
|
overview: '/admin/overview',
|
||||||
|
globalModels: '/admin/providers',
|
||||||
|
baseModels: '/admin/base-models',
|
||||||
|
pricing: '/admin/pricing',
|
||||||
|
platforms: '/admin/platforms',
|
||||||
|
tenants: '/admin/tenants',
|
||||||
|
users: '/admin/users',
|
||||||
|
userGroups: '/admin/user-groups',
|
||||||
|
runtime: '/admin/runtime',
|
||||||
|
};
|
||||||
|
|
||||||
|
const docsPaths: Record<ApiDocSection, string> = {
|
||||||
|
chat: '/docs/chat',
|
||||||
|
imageGeneration: '/docs/images/generations',
|
||||||
|
imageEdit: '/docs/images/edits',
|
||||||
|
pricing: '/docs/pricing',
|
||||||
|
files: '/docs/files',
|
||||||
|
};
|
||||||
|
|
||||||
|
const workspaceSections = reverseMap(workspacePaths);
|
||||||
|
const adminSections = reverseMap(adminPaths);
|
||||||
|
const docsSections = reverseMap(docsPaths);
|
||||||
|
|
||||||
|
export function parseAppRoute(pathname = window.location.pathname): AppRouteState {
|
||||||
|
const path = normalizePath(pathname);
|
||||||
|
if (path === '/') return { ...defaultRouteState };
|
||||||
|
if (path === '/models') return { ...defaultRouteState, activePage: 'models' };
|
||||||
|
if (path.startsWith('/workspace')) {
|
||||||
|
return { ...defaultRouteState, activePage: 'workspace', workspaceSection: parseWorkspaceSection(path) };
|
||||||
|
}
|
||||||
|
if (path.startsWith('/admin')) {
|
||||||
|
return { ...defaultRouteState, activePage: 'admin', adminSection: parseAdminSection(path) };
|
||||||
|
}
|
||||||
|
if (path.startsWith('/docs')) {
|
||||||
|
return { ...defaultRouteState, activePage: 'docs', apiDocSection: parseDocSection(path) };
|
||||||
|
}
|
||||||
|
return { ...defaultRouteState };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathForPage(page: PageKey, route: AppRouteState): string {
|
||||||
|
if (page === 'models') return '/models';
|
||||||
|
if (page === 'workspace') return pathForWorkspaceSection(route.workspaceSection);
|
||||||
|
if (page === 'admin') return pathForAdminSection(route.adminSection);
|
||||||
|
if (page === 'docs') return pathForApiDocSection(route.apiDocSection);
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathForWorkspaceSection(section: WorkspaceSection) {
|
||||||
|
return workspacePaths[section] ?? workspacePaths.overview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathForAdminSection(section: AdminSection) {
|
||||||
|
return adminPaths[section] ?? adminPaths.overview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathForApiDocSection(section: ApiDocSection) {
|
||||||
|
return docsPaths[section] ?? docsPaths.chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkspaceSection(path: string): WorkspaceSection {
|
||||||
|
return workspaceSections[path] ?? (path === '/workspace' ? 'overview' : 'overview');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAdminSection(path: string): AdminSection {
|
||||||
|
if (path === '/admin') return 'overview';
|
||||||
|
if (path === '/admin/models/global') return 'baseModels';
|
||||||
|
if (path === '/admin/usergroups') return 'userGroups';
|
||||||
|
return adminSections[path] ?? 'overview';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDocSection(path: string): ApiDocSection {
|
||||||
|
if (path === '/docs') return 'chat';
|
||||||
|
if (path === '/docs/api/chat') return 'chat';
|
||||||
|
if (path === '/docs/api/media') return 'imageGeneration';
|
||||||
|
if (path === '/docs/playground') return 'chat';
|
||||||
|
return docsSections[path] ?? 'chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(pathname: string) {
|
||||||
|
const path = pathname.replace(/\/+$/, '');
|
||||||
|
return path || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseMap<T extends string>(value: Record<T, string>) {
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, path]) => [path, key])) as Record<string, T>;
|
||||||
|
}
|
||||||
@ -1,9 +1,25 @@
|
|||||||
|
@import './styles/ui.css';
|
||||||
|
@import './styles/pages.css';
|
||||||
|
@import './styles/landing.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color: #172033;
|
--background: #f6f7f9;
|
||||||
background: #f5f7fb;
|
--foreground: #111827;
|
||||||
font-family:
|
--card: #ffffff;
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
--card-foreground: #111827;
|
||||||
sans-serif;
|
--muted: #eef2f6;
|
||||||
|
--muted-foreground: #667085;
|
||||||
|
--border: #dde3ea;
|
||||||
|
--input: #cfd8e3;
|
||||||
|
--primary: #145388;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #edf2f7;
|
||||||
|
--secondary-foreground: #1f2937;
|
||||||
|
--destructive: #b42318;
|
||||||
|
--ring: #2f80c1;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--background);
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@ -17,218 +33,235 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
button {
|
||||||
width: min(1180px, calc(100vw - 32px));
|
cursor: pointer;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 28px 0 48px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
button:disabled {
|
||||||
display: flex;
|
cursor: not-allowed;
|
||||||
align-items: center;
|
opacity: 0.52;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbarActions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
margin: 0 0 4px;
|
|
||||||
color: #667085;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
|
h3,
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
line-height: 1.2;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
.eyebrow {
|
||||||
font-size: 18px;
|
margin-bottom: 5px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
.authPage {
|
||||||
margin: 0;
|
width: min(980px, calc(100vw - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 0 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authBrand,
|
||||||
|
.brandBlock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authBrand {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandMark {
|
||||||
|
display: grid;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #145388;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authShell {
|
||||||
|
display: grid;
|
||||||
|
min-height: 560px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authCard {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authContent,
|
||||||
|
.pageStack,
|
||||||
|
.statGrid,
|
||||||
|
.contentGrid,
|
||||||
|
.modelCatalog,
|
||||||
|
.capabilityCard,
|
||||||
|
.endpointCard,
|
||||||
|
.modelMeta,
|
||||||
|
.profileGrid,
|
||||||
|
.balanceCard,
|
||||||
|
.docStack,
|
||||||
|
.taskPreview {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandBlock {
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandBlock strong {
|
||||||
|
display: block;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health {
|
.brandBlock span,
|
||||||
|
.endpointCard span,
|
||||||
|
.modelMeta span,
|
||||||
|
.infoItem span,
|
||||||
|
.balanceCard span,
|
||||||
|
.docNote span,
|
||||||
|
.capabilityCard small {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topNav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topNavItem {
|
||||||
|
display: flex;
|
||||||
|
min-height: 38px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topNavItem:hover,
|
||||||
|
.topNavItem[data-active="true"] {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #145388;
|
||||||
|
box-shadow: 0 1px 4px rgba(16, 24, 40, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaceShell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appTopbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
min-height: 68px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 1px 10px rgba(16, 24, 40, 0.04);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbarActions {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health,
|
||||||
|
.tokenInline {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: #667085;
|
color: var(--muted-foreground);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health span {
|
.health span {
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #c43f3f;
|
background: #d92d20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health[data-ok="true"] span {
|
.health[data-ok="true"] span {
|
||||||
background: #1b8a5a;
|
background: #14805e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.tokenInline input {
|
||||||
display: grid;
|
width: min(320px, 32vw);
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
min-height: 34px;
|
||||||
gap: 12px;
|
|
||||||
align-items: end;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
border: 1px solid #dde3ee;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tokenField {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
color: #4a5568;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tokenField input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 42px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #172033;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tokenField input:focus {
|
|
||||||
border-color: #2b6cb0;
|
|
||||||
box-shadow: 0 0 0 3px rgba(43, 108, 176, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
min-height: 42px;
|
|
||||||
padding: 0 18px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #214e8a;
|
|
||||||
color: #ffffff;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghostButton {
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #2d3748;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.authShell {
|
|
||||||
display: grid;
|
|
||||||
min-height: 620px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.authPanel {
|
|
||||||
width: min(720px, 100%);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid #dde3ee;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.authHeader {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmented {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border: 1px solid #d8e0ec;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segmentButton {
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border-radius: 6px;
|
border: 1px solid var(--input);
|
||||||
background: transparent;
|
border-radius: 8px;
|
||||||
color: #4a5568;
|
background: #fff;
|
||||||
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmentButton[data-active="true"] {
|
.contentShell {
|
||||||
background: #214e8a;
|
width: min(1480px, calc(100vw - 40px));
|
||||||
color: #ffffff;
|
margin: 0 auto;
|
||||||
|
padding: 26px 24px 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authForm {
|
.pageHeader {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 12px;
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authForm.twoColumn {
|
.pageHeader p:not(.eyebrow) {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
margin-top: 8px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authForm label {
|
.notice,
|
||||||
display: grid;
|
.inlineNotice {
|
||||||
gap: 8px;
|
|
||||||
color: #4a5568;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.authForm input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 42px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #172033;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.authForm input:focus {
|
|
||||||
border-color: #2b6cb0;
|
|
||||||
box-shadow: 0 0 0 3px rgba(43, 108, 176, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.authForm button[type="submit"] {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notice {
|
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 18px;
|
|
||||||
border: 1px solid #f0b8b8;
|
border: 1px solid #f0b8b8;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff1f1;
|
background: #fff1f1;
|
||||||
@ -236,377 +269,116 @@ button:disabled {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.moduleBand {
|
.statGrid {
|
||||||
padding: 18px;
|
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||||
margin-bottom: 18px;
|
|
||||||
border: 1px solid #dde3ee;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.corePanel {
|
.contentGrid.two {
|
||||||
padding: 18px;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
margin-bottom: 18px;
|
|
||||||
border: 1px solid #dde3ee;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.coreGrid {
|
.contentGrid.three {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inlineForm {
|
.contentGrid.five {
|
||||||
display: flex;
|
|
||||||
min-height: 260px;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px;
|
|
||||||
border: 1px solid #e4eaf3;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fbfcff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineForm label {
|
|
||||||
display: grid;
|
|
||||||
gap: 7px;
|
|
||||||
color: #4a5568;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineForm input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 40px;
|
|
||||||
padding: 0 11px;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #172033;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inlineForm button {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formHint,
|
|
||||||
.coreMessage {
|
|
||||||
color: #667085;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coreMessage {
|
|
||||||
margin-top: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coreMessage[data-error="true"] {
|
|
||||||
color: #9b2c2c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secretBox {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #d8e0ec;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #214e8a;
|
|
||||||
font-size: 12px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultBox {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #d8e0ec;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultBox > div {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resultBox pre {
|
|
||||||
overflow: auto;
|
|
||||||
max-height: 150px;
|
|
||||||
margin: 0;
|
|
||||||
color: #2d3748;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusPill {
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #e8f5ef;
|
|
||||||
color: #1b8a5a;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionHeader span {
|
|
||||||
color: #667085;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.moduleGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.moduleCard {
|
.contentGrid.wideLeft {
|
||||||
min-height: 174px;
|
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px;
|
|
||||||
border: 1px solid #e4eaf3;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fbfcff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.moduleCardTop {
|
.contentGrid.compact {
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.moduleCardTop span,
|
|
||||||
.moduleRow span {
|
|
||||||
color: #667085;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.moduleCard p,
|
|
||||||
.moduleRow p {
|
|
||||||
color: #4a5568;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.moduleTags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.moduleTags span {
|
|
||||||
padding: 4px 7px;
|
|
||||||
border: 1px solid #d8e0ec;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #ffffff;
|
|
||||||
color: #3f4f67;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.moduleList {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.moduleRow {
|
.spanTwo {
|
||||||
display: grid;
|
grid-column: 1 / -1;
|
||||||
gap: 8px;
|
}
|
||||||
min-height: 112px;
|
|
||||||
padding: 12px;
|
.statCard {
|
||||||
border: 1px solid #e4eaf3;
|
min-height: 92px;
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: 3px solid #64748b;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fbfcff;
|
background: var(--card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.moduleRow strong {
|
.statCard[data-tone="blue"] { border-top-color: #145388; }
|
||||||
display: block;
|
.statCard[data-tone="green"] { border-top-color: #14805e; }
|
||||||
margin-bottom: 4px;
|
.statCard[data-tone="violet"] { border-top-color: #7048b8; }
|
||||||
color: #172033;
|
.statCard[data-tone="amber"] { border-top-color: #b7791f; }
|
||||||
font-size: 14px;
|
.statCard[data-tone="cyan"] { border-top-color: #087f8c; }
|
||||||
}
|
.statCard[data-tone="rose"] { border-top-color: #b8325f; }
|
||||||
|
|
||||||
.metrics {
|
.statCard span {
|
||||||
display: grid;
|
color: var(--muted-foreground);
|
||||||
grid-template-columns: repeat(auto-fit, minmax(126px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric {
|
|
||||||
min-height: 96px;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid #dde3ee;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric span {
|
|
||||||
color: #667085;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric strong {
|
.statCard strong,
|
||||||
|
.capabilityCard strong,
|
||||||
|
.balanceCard strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 30px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric[data-tone="blue"] {
|
.capabilityCard {
|
||||||
border-top: 3px solid #2b6cb0;
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric[data-tone="green"] {
|
.iconBox {
|
||||||
border-top: 3px solid #1b8a5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric[data-tone="violet"] {
|
|
||||||
border-top: 3px solid #6b46c1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric[data-tone="amber"] {
|
|
||||||
border-top: 3px solid #b7791f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric[data-tone="cyan"] {
|
|
||||||
border-top: 3px solid #087f8c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric[data-tone="rose"] {
|
|
||||||
border-top: 3px solid #b8325f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
width: 38px;
|
||||||
gap: 18px;
|
height: 38px;
|
||||||
}
|
place-items: center;
|
||||||
|
|
||||||
.split.secondary {
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #dde3ee;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #ffffff;
|
background: #eaf2f8;
|
||||||
|
color: #145388;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelHeader {
|
.infoItem,
|
||||||
display: flex;
|
.docNote {
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px;
|
|
||||||
border-bottom: 1px solid #e7ecf4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelHeader span {
|
|
||||||
color: #667085;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfcfe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
@media (max-width: 980px) {
|
||||||
display: grid;
|
.contentShell {
|
||||||
grid-template-columns: 1.2fr 1.2fr 0.8fr 0.6fr;
|
width: 100%;
|
||||||
gap: 12px;
|
}
|
||||||
min-height: 46px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 16px;
|
|
||||||
border-bottom: 1px solid #edf1f7;
|
|
||||||
color: #2d3748;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row span {
|
.contentGrid.two,
|
||||||
overflow: hidden;
|
.contentGrid.three,
|
||||||
text-overflow: ellipsis;
|
.contentGrid.five,
|
||||||
white-space: nowrap;
|
.contentGrid.wideLeft,
|
||||||
}
|
.formGrid.two {
|
||||||
|
|
||||||
.row.head {
|
|
||||||
min-height: 38px;
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #667085;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
padding: 22px 16px;
|
|
||||||
color: #667085;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.topbar,
|
|
||||||
.toolbar,
|
|
||||||
.authForm.twoColumn,
|
|
||||||
.segmented {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.appTopbar,
|
||||||
|
.pageHeader {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topNav,
|
||||||
.topbarActions {
|
.topbarActions {
|
||||||
align-items: flex-start;
|
width: 100%;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics,
|
.tokenInline,
|
||||||
.split,
|
.tokenInline input {
|
||||||
.coreGrid,
|
width: 100%;
|
||||||
.moduleGrid,
|
|
||||||
.detailGrid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
grid-template-columns: 1fr 0.8fr;
|
|
||||||
padding: 10px 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 861px) and (max-width: 1180px) {
|
|
||||||
.metrics,
|
|
||||||
.moduleGrid {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailGrid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
230
apps/web/src/styles/landing.css
Normal file
230
apps/web/src/styles/landing.css
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
.landingPage {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.releaseNotice {
|
||||||
|
display: flex;
|
||||||
|
min-height: 48px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid #d7eadf;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f1fbf5;
|
||||||
|
color: #155e3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.releaseNotice strong {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.releaseNotice span {
|
||||||
|
color: #43735b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingHero {
|
||||||
|
display: grid;
|
||||||
|
min-height: 470px;
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(420px, 1.1fr);
|
||||||
|
gap: 32px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 44px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background:
|
||||||
|
linear-gradient(120deg, rgba(245, 250, 255, 0.96), rgba(255, 255, 255, 0.9)),
|
||||||
|
#ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingCopy {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingCopy h1 {
|
||||||
|
max-width: 720px;
|
||||||
|
font-size: 48px;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingCopy p {
|
||||||
|
max-width: 660px;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gatewayPreview {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid #dbe5ef;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
box-shadow: 0 18px 45px rgba(16, 24, 40, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewHeader,
|
||||||
|
.previewFlow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewHeader {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewGrid div {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewGrid span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewGrid strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewFlow {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #102033;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewFlow span {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingSection {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingSectionHeader {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingSectionHeader h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverageGrid,
|
||||||
|
.advantageGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverageGrid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.advantageGrid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingFeatureCard,
|
||||||
|
.advantageCard {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingFeatureCard span,
|
||||||
|
.advantageCard p {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingFeatureCard strong {
|
||||||
|
display: block;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advantageCard strong {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginRequiredPage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.75fr) minmax(420px, 1fr);
|
||||||
|
gap: 28px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 160px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginRequiredCopy {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginRequiredCopy p:not(.eyebrow) {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginRequiredPage .authShell {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.landingHero,
|
||||||
|
.loginRequiredPage {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingHero {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landingCopy h1 {
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverageGrid,
|
||||||
|
.advantageGrid,
|
||||||
|
.previewGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.releaseNotice {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.releaseNotice strong {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
776
apps/web/src/styles/pages.css
Normal file
776
apps/web/src/styles/pages.css
Normal file
@ -0,0 +1,776 @@
|
|||||||
|
.subPageLayout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subPageContent {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideTabs {
|
||||||
|
position: sticky;
|
||||||
|
top: 88px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideTabs .shTab {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelsPage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 246px minmax(0, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelFilters {
|
||||||
|
position: sticky;
|
||||||
|
top: 88px;
|
||||||
|
display: grid;
|
||||||
|
gap: 22px;
|
||||||
|
padding-right: 18px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterGroup {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterGroup h3 {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterChips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterChip {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterChip[data-active="true"] {
|
||||||
|
border-color: #2f6fe4;
|
||||||
|
background: #2f6fe4;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelsContent {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelSearchBar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField {
|
||||||
|
display: grid;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField svg {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField .shInput {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerHero {
|
||||||
|
display: flex;
|
||||||
|
min-height: 86px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid #ebe5fb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f6f1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerHero strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerHero span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerLogo,
|
||||||
|
.modelIcon {
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelIconImage {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelIconImage img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerHero .shBadge {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCard .shCardContent {
|
||||||
|
display: grid;
|
||||||
|
min-height: 150px;
|
||||||
|
gap: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCardTop {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
|
gap: 11px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCardTop strong,
|
||||||
|
.modelCardTop span,
|
||||||
|
.modelCard p {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCardTop span,
|
||||||
|
.modelCard p,
|
||||||
|
.modelCardFooter span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelTags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelTags span {
|
||||||
|
padding: 4px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #fff5e7;
|
||||||
|
color: #9a5b12;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCardFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCardFooter a {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerToolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerToolbar p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formMessage {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogCard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 54px minmax(0, 1fr) auto;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogLogo {
|
||||||
|
display: grid;
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #edf1f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogLogo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogBody {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogBody strong,
|
||||||
|
.providerCatalogBody span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogBody span,
|
||||||
|
.providerCatalogMeta {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogMeta,
|
||||||
|
.providerCatalogActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogActions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCatalogFilters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(160px, 220px) minmax(160px, 220px);
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseModelGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseModelCard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 54px minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 184px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseModelCardBody {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseModelCardBody strong,
|
||||||
|
.baseModelCardBody span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseModelCardBody > div:first-child span {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseModelCard .providerCatalogActions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelAbilityChips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelAbilityChips span {
|
||||||
|
padding: 4px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #eef7ff;
|
||||||
|
color: #0f5d8f;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseModelDialog {
|
||||||
|
width: min(900px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.baseModelForm textarea {
|
||||||
|
min-height: 124px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleCard {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleCard header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleCard strong,
|
||||||
|
.pricingRuleCard span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleCard header span,
|
||||||
|
.pricingRuleCard p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleItems {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleItems span {
|
||||||
|
padding: 5px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleDialog {
|
||||||
|
width: min(1120px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleFormBody {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleEditor {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleEditorHeader,
|
||||||
|
.pricingRuleEditorCard header,
|
||||||
|
.pricingRuleSubHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleEditorHeader strong,
|
||||||
|
.pricingRuleEditorCard strong,
|
||||||
|
.pricingRuleSubHeader strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleEditorHeader span,
|
||||||
|
.pricingRuleEditorCard header span,
|
||||||
|
.pricingRuleSubHeader span,
|
||||||
|
.pricingRuleEmpty span,
|
||||||
|
.mutedLine {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleQuickActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleEditorCard,
|
||||||
|
.pricingRuleSubPanel,
|
||||||
|
.pricingRuleEmpty {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleEditorCard {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleEditorCard header > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleEditorCard header span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricingRuleFields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dimensionChips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dimensionChips button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dimensionChips button.active {
|
||||||
|
border-color: #145388;
|
||||||
|
background: #eaf3fb;
|
||||||
|
color: #145388;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weightGroup {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyValueRows {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyValueRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 0.55fr) minmax(140px, 1fr) 34px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiDocsShell {
|
||||||
|
display: grid;
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
grid-template-columns: 280px minmax(420px, 1fr) 360px;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsSidebar,
|
||||||
|
.docsArticle,
|
||||||
|
.docsRunner {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsSidebar {
|
||||||
|
padding: 18px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsBrand,
|
||||||
|
.docsSearch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsBrand {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsSearch {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsSearch input {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsGroup {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsGroup h3 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsGroup button {
|
||||||
|
display: flex;
|
||||||
|
min-height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: #344054;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsGroup button[data-active="true"] {
|
||||||
|
background: #f0e9ff;
|
||||||
|
color: #7048e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsArticle {
|
||||||
|
padding: 28px 34px 60px;
|
||||||
|
background: linear-gradient(90deg, #fff 0%, #fbf8ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsArticle h1 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpointBar,
|
||||||
|
.runnerEndpoint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsLead {
|
||||||
|
margin: 18px 0 24px;
|
||||||
|
color: #475467;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paramCard {
|
||||||
|
margin-top: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paramCard header,
|
||||||
|
.docsRunner header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paramRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px 110px minmax(0, 1fr) 50px;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-bottom: 1px solid #f0f2f5;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paramRow em {
|
||||||
|
color: #f97316;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsRunner {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsRunner form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerResult {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runnerResult pre {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.modelCards,
|
||||||
|
.providerCatalogGrid,
|
||||||
|
.baseModelGrid,
|
||||||
|
.pricingRuleGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.apiDocsShell {
|
||||||
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docsRunner {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.subPageLayout,
|
||||||
|
.modelsPage,
|
||||||
|
.apiDocsShell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sideTabs,
|
||||||
|
.modelFilters {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCards,
|
||||||
|
.providerCatalogGrid,
|
||||||
|
.baseModelGrid,
|
||||||
|
.pricingRuleGrid,
|
||||||
|
.pricingRuleFields,
|
||||||
|
.pricingRuleFormBody,
|
||||||
|
.modelCatalogFilters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogCard {
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerToolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerCatalogActions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paramRow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
330
apps/web/src/styles/ui.css
Normal file
330
apps/web/src/styles/ui.css
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
.shCard {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--card-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shCardHeader,
|
||||||
|
.shCardContent,
|
||||||
|
.shCardFooter {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shCardHeader {
|
||||||
|
display: flex;
|
||||||
|
min-height: 60px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid #edf1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shCardTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shCardDescription,
|
||||||
|
.mutedText {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shButton {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shButtonDefaultSize {
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shButtonSm {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 11px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shButtonIcon {
|
||||||
|
width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shButtonDefault {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shButtonSecondary {
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--secondary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shButtonOutline {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: #fff;
|
||||||
|
color: #344054;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shButtonGhost {
|
||||||
|
background: transparent;
|
||||||
|
color: #344054;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shButtonDestructive {
|
||||||
|
background: var(--destructive);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formDialogBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 60;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(15, 23, 42, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formDialog {
|
||||||
|
display: grid;
|
||||||
|
width: min(720px, 100%);
|
||||||
|
max-height: min(760px, calc(100vh - 48px));
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formDialogHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formDialogHeader span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formDialogHeader strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formDialogForm {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formDialogBody {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formDialogActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 -10px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shInput,
|
||||||
|
.shTextarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--input);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--foreground);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shInput {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTextarea {
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shInput:focus,
|
||||||
|
.shTextarea:focus,
|
||||||
|
.tokenInline input:focus {
|
||||||
|
border-color: var(--ring);
|
||||||
|
box-shadow: 0 0 0 3px rgba(47, 128, 193, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shLabel,
|
||||||
|
.formGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shLabel {
|
||||||
|
color: #475467;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGrid {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGrid.two {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTab {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: #475467;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTab[data-active="true"] {
|
||||||
|
background: #fff;
|
||||||
|
color: #145388;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shBadgeDefault { background: #eaf2f8; color: #145388; }
|
||||||
|
.shBadgeSecondary { background: #eef2f6; color: #475467; }
|
||||||
|
.shBadgeOutline { border-color: var(--border); background: #fff; color: #344054; }
|
||||||
|
.shBadgeSuccess { background: #e8f5ef; color: #14805e; }
|
||||||
|
.shBadgeWarning { background: #fff6df; color: #98690c; }
|
||||||
|
.shBadgeDestructive { background: #fff1f1; color: #b42318; }
|
||||||
|
|
||||||
|
.shTable {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTableRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
||||||
|
min-width: 520px;
|
||||||
|
border-bottom: 1px solid #edf1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTableHeader {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTableHead,
|
||||||
|
.shTableCell {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTableHead {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTableCell {
|
||||||
|
color: #344054;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: grid;
|
||||||
|
min-height: 104px;
|
||||||
|
place-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelMeta code,
|
||||||
|
.secretBox,
|
||||||
|
.codeBlock,
|
||||||
|
.taskPreview pre {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #1f2937;
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelMeta code,
|
||||||
|
.secretBox {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBlock,
|
||||||
|
.taskPreview pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.formDialogBody {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/web/src/types.ts
Normal file
60
apps/web/src/types.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
export type LoadState = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
|
export type AuthMode = 'login' | 'register' | 'external';
|
||||||
|
export type TaskKind = 'chat.completions' | 'images.generations' | 'images.edits';
|
||||||
|
export type PageKey = 'home' | 'models' | 'workspace' | 'admin' | 'docs';
|
||||||
|
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks';
|
||||||
|
export type ApiDocSection = 'chat' | 'imageGeneration' | 'imageEdit' | 'pricing' | 'files';
|
||||||
|
export type AdminSection =
|
||||||
|
| 'overview'
|
||||||
|
| 'globalModels'
|
||||||
|
| 'baseModels'
|
||||||
|
| 'pricing'
|
||||||
|
| 'platforms'
|
||||||
|
| 'tenants'
|
||||||
|
| 'users'
|
||||||
|
| 'userGroups'
|
||||||
|
| 'runtime';
|
||||||
|
|
||||||
|
export interface LoginForm {
|
||||||
|
account: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterForm {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
displayName: string;
|
||||||
|
invitationCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskForm {
|
||||||
|
kind: TaskKind;
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
image?: string;
|
||||||
|
mask?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyForm {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformForm {
|
||||||
|
provider: string;
|
||||||
|
platformKey: string;
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
pricingRuleSetId: string;
|
||||||
|
defaultDiscountFactor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformModelForm {
|
||||||
|
platformId: string;
|
||||||
|
canonicalModelKey: string;
|
||||||
|
modelName: string;
|
||||||
|
modelAlias: string;
|
||||||
|
modelType: string;
|
||||||
|
pricingRuleSetId: string;
|
||||||
|
discountFactor: string;
|
||||||
|
}
|
||||||
@ -62,6 +62,7 @@ export interface IntegrationPlatform {
|
|||||||
priority: number;
|
priority: number;
|
||||||
defaultPricingMode: PricingMode;
|
defaultPricingMode: PricingMode;
|
||||||
defaultDiscountFactor: number;
|
defaultDiscountFactor: number;
|
||||||
|
pricingRuleSetId?: string;
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@ -80,15 +81,32 @@ export type RateLimitMetric =
|
|||||||
export interface CatalogProvider {
|
export interface CatalogProvider {
|
||||||
id: string;
|
id: string;
|
||||||
providerKey: string;
|
providerKey: string;
|
||||||
|
code: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
providerType: string;
|
providerType: string;
|
||||||
|
iconPath?: string;
|
||||||
|
source?: 'gateway' | 'server-main' | 'sync' | 'server-main.integration-platform' | string;
|
||||||
capabilitySchema?: Record<string, unknown>;
|
capabilitySchema?: Record<string, unknown>;
|
||||||
defaultRateLimitPolicy?: RateLimitPolicy;
|
defaultRateLimitPolicy?: RateLimitPolicy;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
status: 'active' | 'deprecated' | 'hidden' | string;
|
status: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalogProviderUpsertRequest {
|
||||||
|
providerKey: string;
|
||||||
|
code?: string;
|
||||||
|
displayName: string;
|
||||||
|
providerType?: string;
|
||||||
|
iconPath?: string;
|
||||||
|
source?: 'gateway' | 'server-main' | 'sync' | 'server-main.integration-platform' | string;
|
||||||
|
capabilitySchema?: Record<string, unknown>;
|
||||||
|
defaultRateLimitPolicy?: RateLimitPolicy;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
status?: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BaseModelCatalogItem {
|
export interface BaseModelCatalogItem {
|
||||||
id: string;
|
id: string;
|
||||||
providerKey: string;
|
providerKey: string;
|
||||||
@ -99,14 +117,32 @@ export interface BaseModelCatalogItem {
|
|||||||
capabilities?: Record<string, unknown>;
|
capabilities?: Record<string, unknown>;
|
||||||
baseBillingConfig?: BillingConfig;
|
baseBillingConfig?: BillingConfig;
|
||||||
defaultRateLimitPolicy?: RateLimitPolicy;
|
defaultRateLimitPolicy?: RateLimitPolicy;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
pricingVersion: number;
|
pricingVersion: number;
|
||||||
status: 'active' | 'deprecated' | 'hidden' | string;
|
status: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BaseModelUpsertRequest {
|
||||||
|
providerKey: string;
|
||||||
|
canonicalModelKey?: string;
|
||||||
|
providerModelName: string;
|
||||||
|
modelType: string;
|
||||||
|
displayName?: string;
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
|
baseBillingConfig?: BillingConfig;
|
||||||
|
defaultRateLimitPolicy?: RateLimitPolicy;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
pricingVersion?: number;
|
||||||
|
status?: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PricingRule {
|
export interface PricingRule {
|
||||||
id: string;
|
id: string;
|
||||||
|
ruleSetId?: string;
|
||||||
|
ruleKey: string;
|
||||||
|
displayName: string;
|
||||||
scopeType: 'base_model' | 'platform' | 'platform_model' | string;
|
scopeType: 'base_model' | 'platform' | 'platform_model' | string;
|
||||||
scopeId?: string;
|
scopeId?: string;
|
||||||
resourceType:
|
resourceType:
|
||||||
@ -125,10 +161,58 @@ export interface PricingRule {
|
|||||||
currency: 'resource' | 'credit' | 'cny' | 'usd' | string;
|
currency: 'resource' | 'credit' | 'cny' | 'usd' | string;
|
||||||
baseWeight?: Record<string, unknown>;
|
baseWeight?: Record<string, unknown>;
|
||||||
dynamicWeight?: Record<string, unknown>;
|
dynamicWeight?: Record<string, unknown>;
|
||||||
|
calculatorType: 'token_usage' | 'unit_weight' | 'duration_weight' | string;
|
||||||
|
dimensionSchema?: Record<string, unknown>;
|
||||||
|
formulaConfig?: Record<string, unknown>;
|
||||||
|
priority: number;
|
||||||
|
status: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PricingRuleInput {
|
||||||
|
ruleKey?: string;
|
||||||
|
displayName?: string;
|
||||||
|
resourceType: string;
|
||||||
|
unit: string;
|
||||||
|
basePrice: number;
|
||||||
|
currency?: string;
|
||||||
|
baseWeight?: Record<string, unknown>;
|
||||||
|
dynamicWeight?: Record<string, unknown>;
|
||||||
|
calculatorType?: 'token_usage' | 'unit_weight' | 'duration_weight' | string;
|
||||||
|
dimensionSchema?: Record<string, unknown>;
|
||||||
|
formulaConfig?: Record<string, unknown>;
|
||||||
|
priority?: number;
|
||||||
|
status?: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingRuleSet {
|
||||||
|
id: string;
|
||||||
|
ruleSetKey: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category: 'default' | 'custom' | 'provider' | 'model' | string;
|
||||||
|
currency: 'resource' | 'credit' | 'cny' | 'usd' | string;
|
||||||
|
status: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
rules?: PricingRule[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingRuleSetUpsertRequest {
|
||||||
|
ruleSetKey: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
currency?: string;
|
||||||
|
status?: 'active' | 'deprecated' | 'hidden' | string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
rules: PricingRuleInput[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayUser {
|
export interface GatewayUser {
|
||||||
id: string;
|
id: string;
|
||||||
userKey: string;
|
userKey: string;
|
||||||
@ -319,6 +403,7 @@ export interface PlatformModel {
|
|||||||
capabilities?: Record<string, unknown>;
|
capabilities?: Record<string, unknown>;
|
||||||
pricingMode: PricingMode;
|
pricingMode: PricingMode;
|
||||||
discountFactor?: number;
|
discountFactor?: number;
|
||||||
|
pricingRuleSetId?: string;
|
||||||
billingConfigOverride?: BillingConfig;
|
billingConfigOverride?: BillingConfig;
|
||||||
billingConfig?: BillingConfig;
|
billingConfig?: BillingConfig;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@ -29,15 +29,30 @@ importers:
|
|||||||
'@easyai-ai-gateway/contracts':
|
'@easyai-ai-gateway/contracts':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/contracts
|
version: link:../../packages/contracts
|
||||||
|
'@radix-ui/react-slot':
|
||||||
|
specifier: ^1.2.4
|
||||||
|
version: 1.2.4(@types/react@19.2.14)(react@19.2.6)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.2.0(vite@7.3.3(yaml@2.8.4))
|
version: 5.2.0(vite@7.3.3(yaml@2.8.4))
|
||||||
|
class-variance-authority:
|
||||||
|
specifier: ^0.7.1
|
||||||
|
version: 0.7.1
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^1.14.0
|
||||||
|
version: 1.14.0(react@19.2.6)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.6
|
version: 19.2.6
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.6(react@19.2.6)
|
version: 19.2.6(react@19.2.6)
|
||||||
|
tailwind-merge:
|
||||||
|
specifier: ^3.5.0
|
||||||
|
version: 3.5.0
|
||||||
vite:
|
vite:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.3.3(yaml@2.8.4)
|
version: 7.3.3(yaml@2.8.4)
|
||||||
@ -888,6 +903,24 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: ^3 || ^4 || ^5
|
typescript: ^3 || ^4 || ^5
|
||||||
|
|
||||||
|
'@radix-ui/react-compose-refs@1.1.2':
|
||||||
|
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.4':
|
||||||
|
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.3':
|
'@rolldown/pluginutils@1.0.0-rc.3':
|
||||||
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
|
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
|
||||||
|
|
||||||
@ -1238,6 +1271,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
|
class-variance-authority@0.7.1:
|
||||||
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
cli-cursor@3.1.0:
|
cli-cursor@3.1.0:
|
||||||
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
|
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1254,6 +1290,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
clsx@2.1.1:
|
||||||
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@ -1596,6 +1636,11 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
|
lucide-react@1.14.0:
|
||||||
|
resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@ -1866,6 +1911,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
tailwind-merge@3.5.0:
|
||||||
|
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||||
|
|
||||||
tar-stream@2.2.0:
|
tar-stream@2.2.0:
|
||||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -3034,6 +3082,19 @@ snapshots:
|
|||||||
esquery: 1.7.0
|
esquery: 1.7.0
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
|
||||||
|
react: 19.2.6
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.3': {}
|
'@rolldown/pluginutils@1.0.0-rc.3': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.60.3':
|
'@rollup/rollup-android-arm-eabi@4.60.3':
|
||||||
@ -3374,6 +3435,10 @@ snapshots:
|
|||||||
|
|
||||||
check-error@2.1.3: {}
|
check-error@2.1.3: {}
|
||||||
|
|
||||||
|
class-variance-authority@0.7.1:
|
||||||
|
dependencies:
|
||||||
|
clsx: 2.1.1
|
||||||
|
|
||||||
cli-cursor@3.1.0:
|
cli-cursor@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 3.1.0
|
restore-cursor: 3.1.0
|
||||||
@ -3388,6 +3453,8 @@ snapshots:
|
|||||||
|
|
||||||
clone@1.0.4: {}
|
clone@1.0.4: {}
|
||||||
|
|
||||||
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@ -3697,6 +3764,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lucide-react@1.14.0(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@ -4004,6 +4075,8 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
tailwind-merge@3.5.0: {}
|
||||||
|
|
||||||
tar-stream@2.2.0:
|
tar-stream@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
bl: 4.1.0
|
bl: 4.1.0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user