feat(web): add reusable admin form dialog
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user