From a5e66e79cd8f9d9e2e53240ee7f674d4e3d07ae6 Mon Sep 17 00:00:00 2001 From: wangbo Date: Sat, 9 May 2026 20:15:35 +0800 Subject: [PATCH] feat(web): add reusable admin form dialog --- apps/web/package.json | 5 + apps/web/src/App.tsx | 943 ++++++------------ apps/web/src/api.ts | 163 +++ apps/web/src/app-state.ts | 35 + apps/web/src/components/AuthPanel.tsx | 134 +++ apps/web/src/components/CoreFlowPanel.tsx | 171 ++++ apps/web/src/components/Dashboard.tsx | 96 ++ apps/web/src/components/DataPanel.tsx | 25 + apps/web/src/components/EntityTable.tsx | 25 + .../web/src/components/LoginRequiredPanel.tsx | 14 + apps/web/src/components/ModuleList.tsx | 19 + apps/web/src/components/PageHeader.tsx | 14 + apps/web/src/components/StatGrid.tsx | 14 + apps/web/src/components/layout/AppShell.tsx | 82 ++ apps/web/src/components/ui/badge.tsx | 24 + apps/web/src/components/ui/button.tsx | 40 + apps/web/src/components/ui/card.tsx | 32 + apps/web/src/components/ui/dialog.tsx | 53 + apps/web/src/components/ui/index.ts | 11 + apps/web/src/components/ui/input.tsx | 8 + apps/web/src/components/ui/label.tsx | 8 + apps/web/src/components/ui/select.tsx | 8 + apps/web/src/components/ui/separator.tsx | 7 + apps/web/src/components/ui/table.tsx | 31 + apps/web/src/components/ui/tabs.tsx | 26 + apps/web/src/components/ui/textarea.tsx | 8 + apps/web/src/hooks/useCatalogOperations.ts | 99 ++ .../src/hooks/usePricingRuleSetOperations.ts | 47 + apps/web/src/lib/auth-storage.ts | 23 + apps/web/src/lib/run-task.ts | 32 + apps/web/src/lib/utils.ts | 6 + apps/web/src/navigation.ts | 56 ++ apps/web/src/pages/AdminPage.tsx | 308 ++++++ apps/web/src/pages/ApiDocsPage.tsx | 223 +++++ apps/web/src/pages/HomePage.tsx | 127 +++ apps/web/src/pages/ModelsPage.tsx | 384 +++++++ apps/web/src/pages/WorkspacePage.tsx | 178 ++++ .../src/pages/admin/BaseModelCatalogPanel.tsx | 374 +++++++ .../pages/admin/PricingRuleVisualEditor.tsx | 348 +++++++ .../web/src/pages/admin/PricingRulesPanel.tsx | 225 +++++ .../pages/admin/ProviderManagementPanel.tsx | 265 +++++ apps/web/src/routing.ts | 110 ++ apps/web/src/styles.css | 766 +++++--------- apps/web/src/styles/landing.css | 230 +++++ apps/web/src/styles/pages.css | 776 ++++++++++++++ apps/web/src/styles/ui.css | 330 ++++++ apps/web/src/types.ts | 60 ++ packages/contracts/src/index.ts | 85 ++ pnpm-lock.yaml | 73 ++ 49 files changed, 6013 insertions(+), 1108 deletions(-) create mode 100644 apps/web/src/app-state.ts create mode 100644 apps/web/src/components/AuthPanel.tsx create mode 100644 apps/web/src/components/CoreFlowPanel.tsx create mode 100644 apps/web/src/components/Dashboard.tsx create mode 100644 apps/web/src/components/DataPanel.tsx create mode 100644 apps/web/src/components/EntityTable.tsx create mode 100644 apps/web/src/components/LoginRequiredPanel.tsx create mode 100644 apps/web/src/components/ModuleList.tsx create mode 100644 apps/web/src/components/PageHeader.tsx create mode 100644 apps/web/src/components/StatGrid.tsx create mode 100644 apps/web/src/components/layout/AppShell.tsx create mode 100644 apps/web/src/components/ui/badge.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/components/ui/card.tsx create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/components/ui/index.ts create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/label.tsx create mode 100644 apps/web/src/components/ui/select.tsx create mode 100644 apps/web/src/components/ui/separator.tsx create mode 100644 apps/web/src/components/ui/table.tsx create mode 100644 apps/web/src/components/ui/tabs.tsx create mode 100644 apps/web/src/components/ui/textarea.tsx create mode 100644 apps/web/src/hooks/useCatalogOperations.ts create mode 100644 apps/web/src/hooks/usePricingRuleSetOperations.ts create mode 100644 apps/web/src/lib/auth-storage.ts create mode 100644 apps/web/src/lib/run-task.ts create mode 100644 apps/web/src/lib/utils.ts create mode 100644 apps/web/src/navigation.ts create mode 100644 apps/web/src/pages/AdminPage.tsx create mode 100644 apps/web/src/pages/ApiDocsPage.tsx create mode 100644 apps/web/src/pages/HomePage.tsx create mode 100644 apps/web/src/pages/ModelsPage.tsx create mode 100644 apps/web/src/pages/WorkspacePage.tsx create mode 100644 apps/web/src/pages/admin/BaseModelCatalogPanel.tsx create mode 100644 apps/web/src/pages/admin/PricingRuleVisualEditor.tsx create mode 100644 apps/web/src/pages/admin/PricingRulesPanel.tsx create mode 100644 apps/web/src/pages/admin/ProviderManagementPanel.tsx create mode 100644 apps/web/src/routing.ts create mode 100644 apps/web/src/styles/landing.css create mode 100644 apps/web/src/styles/pages.css create mode 100644 apps/web/src/styles/ui.css create mode 100644 apps/web/src/types.ts diff --git a/apps/web/package.json b/apps/web/package.json index 33de153..d7f6ebc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,9 +11,14 @@ }, "dependencies": { "@easyai-ai-gateway/contracts": "workspace:*", + "@radix-ui/react-slot": "^1.2.4", "@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-dom": "^19.0.0", + "tailwind-merge": "^3.5.0", "vite": "^7.0.0" }, "devDependencies": { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c89daf7..faa33cc 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,27 +3,31 @@ import type { BaseModelCatalogItem, CatalogProvider, GatewayApiKey, - GatewayTenant, GatewayTask, + GatewayTenant, GatewayUser, IntegrationPlatform, PlatformModel, PricingRule, + PricingRuleSet, RateLimitWindow, UserGroup, } from '@easyai-ai-gateway/contracts'; import { createApiKey, - createChatTask, createPlatform, - getTask, + createPlatformModel, getHealth, + getTask, listApiKeys, listBaseModels, listCatalogProviders, listModels, listPlatforms, listPricingRules, + listPricingRuleSets, + listPublicBaseModels, + listPublicCatalogProviders, listRateLimitWindows, listTenants, listUserGroups, @@ -32,115 +36,104 @@ import { registerLocalAccount, type HealthResponse, } from './api'; - -type LoadState = 'idle' | 'loading' | 'ready' | 'error'; -type AuthMode = 'login' | 'register' | 'external'; - -const primaryModules = [ - { - title: '首页', - path: '/', - description: '服务状态、推荐模型、最近任务、用量摘要和快捷入口。', - items: ['能力概览', '最近任务', '用量摘要'], - }, - { - title: '模型', - path: '/models', - description: '按能力、价格、限流和 provider 浏览模型,并进入在线试用。', - items: ['模型广场', '模型详情', '调用测试'], - }, - { - title: '用户工作台', - path: '/workspace', - description: '个人中心、身份来源、余额充值、API Key 管理和任务记录。', - items: ['个人总览', '身份来源', '余额充值', 'API Key', '任务记录'], - }, - { - title: '管理工作台', - path: '/admin', - description: '租户、用户、用户组、全局模型、平台、限流、重试、队列和回调 outbox。', - items: ['租户管理', '用户管理', '用户组策略', '全局模型', '队列限流'], - }, - { - title: 'API 文档', - path: '/docs', - description: '开放接口、鉴权、错误码、示例代码和在线调用测试。', - items: ['快速开始', '接口文档', '在线调试'], - }, -]; - -const workspacePages = [ - { title: '个人中心总览', path: '/workspace/overview', description: '账号、身份来源、租户、角色、用户组、余额、API Key 数、最近任务和用量摘要。' }, - { title: '余额与充值', path: '/workspace/billing', description: '余额、资源包、充值入口、用户组折扣、消费记录和订单状态。' }, - { title: 'API Key 管理', path: '/workspace/api-keys', description: '创建、禁用、重置、权限范围和最近调用记录。' }, - { title: '任务记录', path: '/workspace/tasks', description: 'Chat、生图、生视频任务列表、进度、结果和计费明细。' }, -]; - -const adminPages = [ - { title: '租户管理', path: '/admin/tenants', description: '本地租户、同步租户、租户策略、状态和用量隔离。' }, - { title: '用户管理', path: '/admin/users', description: '本地用户、同步用户、角色、状态、同步差异和用户组命中。' }, - { title: '用户组策略', path: '/admin/user-groups', description: '用户组成员、充值折扣、调用折扣、TPM/RPM/并发和队列优先级。' }, - { title: '全局模型配置', path: '/admin/models/global', description: '基准模型库、能力 schema、基准定价和默认限流模板。' }, - { title: '平台管理', path: '/admin/platforms', description: '平台 CRUD、凭证、默认折扣、平台模型、限流和重试策略。' }, - { title: '运行与队列', path: '/admin/runtime/queues', description: 'TPM/RPM 窗口、并发 lease、cooldown、任务恢复和队列积压。' }, - { title: '回调与结算', path: '/admin/callbacks', description: '任务进度 callback outbox、结算 outbox、失败重试和手动 replay。' }, -]; - -const apiDocPages = [ - { title: '鉴权与限流', path: '/docs/auth', description: '本地账号、JWT、OpenAPI Key、TPM/RPM/并发限制和错误码。' }, - { title: 'Chat / Responses', path: '/docs/api/chat', description: '对话、stream、结构化输出、取消请求和示例代码。' }, - { title: '图片 / 视频', path: '/docs/api/media', description: '生图、图像编辑、生视频、任务进度和结果取回。' }, - { title: '在线调用测试', path: '/docs/playground', description: '选择模型和 API Key,编辑参数,查看实时响应和 billings。' }, -]; +import type { ConsoleData, StatItem } from './app-state'; +import { AppShell } from './components/layout/AppShell'; +import { LoginRequiredPanel } from './components/LoginRequiredPanel'; +import { useCatalogOperations } from './hooks/useCatalogOperations'; +import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations'; +import { persistAccessToken, readStoredAccessToken } from './lib/auth-storage'; +import { runTask } from './lib/run-task'; +import { AdminPage } from './pages/AdminPage'; +import { ApiDocsPage } from './pages/ApiDocsPage'; +import { HomePage } from './pages/HomePage'; +import { ModelsPage } from './pages/ModelsPage'; +import { WorkspacePage } from './pages/WorkspacePage'; +import { + parseAppRoute, + pathForAdminSection, + pathForApiDocSection, + pathForPage, + pathForWorkspaceSection, + type AppRouteState, +} from './routing'; +import type { + AdminSection, + ApiDocSection, + ApiKeyForm, + AuthMode, + LoadState, + LoginForm, + PageKey, + PlatformForm, + PlatformModelForm, + RegisterForm, + TaskForm, + WorkspaceSection, +} from './types'; export function App() { - const [token, setToken] = useState(''); + const initialRoute = parseAppRoute(); + const [activePage, setActivePage] = useState(initialRoute.activePage); + const [adminSection, setAdminSection] = useState(initialRoute.adminSection); + const [workspaceSection, setWorkspaceSection] = useState(initialRoute.workspaceSection); + const [apiDocSection, setApiDocSection] = useState(initialRoute.apiDocSection); + const [token, setToken] = useState(readStoredAccessToken); const [externalToken, setExternalToken] = useState(''); const [authMode, setAuthMode] = useState('login'); - const [loginForm, setLoginForm] = useState({ account: '', password: '' }); - const [registerForm, setRegisterForm] = useState({ - username: '', - email: '', - password: '', - displayName: '', - invitationCode: '', - }); + const [loginForm, setLoginForm] = useState({ account: '', password: '' }); + const [registerForm, setRegisterForm] = useState({ username: '', email: '', password: '', displayName: '', invitationCode: '' }); const [health, setHealth] = useState(null); const [platforms, setPlatforms] = useState([]); const [models, setModels] = useState([]); const [providers, setProviders] = useState([]); const [baseModels, setBaseModels] = useState([]); const [pricingRules, setPricingRules] = useState([]); + const [pricingRuleSets, setPricingRuleSets] = useState([]); const [rateLimitWindows, setRateLimitWindows] = useState([]); const [tenants, setTenants] = useState([]); const [users, setUsers] = useState([]); const [userGroups, setUserGroups] = useState([]); const [apiKeys, setApiKeys] = useState([]); - const [apiKeyForm, setApiKeyForm] = useState({ name: 'Local smoke key' }); + const [apiKeyForm, setApiKeyForm] = useState({ name: 'Local smoke key' }); const [apiKeySecret, setApiKeySecret] = useState(''); - const [platformForm, setPlatformForm] = useState({ - provider: 'openai', - platformKey: 'openai-simulation', - name: 'OpenAI Simulation', - baseUrl: 'https://api.openai.com/v1', - }); - const [taskForm, setTaskForm] = useState({ - model: 'gpt-4o-mini', - prompt: '用一句话确认 AI Gateway simulation 链路正常。', - }); + const [platformForm, setPlatformForm] = useState({ provider: 'openai', platformKey: 'openai-simulation', name: 'OpenAI Simulation', baseUrl: 'https://api.openai.com/v1', pricingRuleSetId: '', defaultDiscountFactor: '1' }); + const [platformModelForm, setPlatformModelForm] = useState({ platformId: '', canonicalModelKey: '', modelName: 'gpt-4o-mini', modelAlias: 'gpt-4o-mini', modelType: 'chat', pricingRuleSetId: '', discountFactor: '' }); + const [taskForm, setTaskForm] = useState({ kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '用一句话确认 AI Gateway simulation 链路正常。' }); const [taskResult, setTaskResult] = useState(null); const [coreState, setCoreState] = useState('idle'); const [coreMessage, setCoreMessage] = useState(''); const [state, setState] = useState('idle'); const [error, setError] = useState(''); + const { removeBaseModel, removeProvider, saveBaseModel, saveProvider } = useCatalogOperations({ + setBaseModels, + setCoreMessage, + setCoreState, + setProviders, + token, + }); + const { removePricingRuleSet, savePricingRuleSet } = usePricingRuleSetOperations({ + setCoreMessage, + setCoreState, + setPricingRuleSets, + token, + }); useEffect(() => { - getHealth() - .then(setHealth) - .catch((err: Error) => setError(err.message)); + void loadPublicCatalog(); }, []); - - const stats = useMemo(() => { + useEffect(() => { + if (token) { + void refresh(token); + } + }, []); + useEffect(() => { + function handlePopState() { + applyRoute(parseAppRoute()); + } + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + const stats = useMemo(() => { const enabledPlatforms = platforms.filter((item) => item.status === 'enabled').length; const enabledModels = models.filter((item) => item.enabled).length; const activeProviders = providers.filter((item) => item.status === 'active').length; @@ -151,83 +144,85 @@ export function App() { { label: '用户组', value: userGroups.length, tone: 'blue' }, { label: '平台', value: platforms.length, tone: 'blue' }, { label: '启用平台', value: enabledPlatforms, tone: 'green' }, - { label: '基准模型', value: baseModels.length, tone: 'violet' }, - { label: 'Provider', value: activeProviders || providers.length || enabledModels, tone: 'amber' }, + { label: '平台模型', value: enabledModels, tone: 'violet' }, + { label: 'Provider', value: activeProviders || providers.length, tone: 'amber' }, { label: '定价规则', value: pricingRules.length, tone: 'cyan' }, { label: '限流窗口', value: activeRateWindows, tone: 'rose' }, ]; - }, [baseModels.length, models, platforms, pricingRules.length, providers, rateLimitWindows, tenants.length, userGroups.length, users.length]); + }, [models, platforms, pricingRules.length, providers, rateLimitWindows, tenants.length, userGroups.length, users.length]); + + const data = useMemo(() => ({ + apiKeys, + baseModels, + models, + platforms, + pricingRules, + pricingRuleSets, + providers, + rateLimitWindows, + taskResult, + tenants, + userGroups, + users, + }), [apiKeys, baseModels, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, taskResult, tenants, userGroups, users]); async function refresh(nextToken = token) { setState('loading'); setError(''); try { - const [ - platformResponse, - modelResponse, - providerResponse, - baseModelResponse, - pricingRuleResponse, - rateLimitWindowResponse, - tenantResponse, - userResponse, - userGroupResponse, - apiKeyResponse, - ] = await Promise.all([ + const responses = await Promise.all([ listPlatforms(nextToken), listModels(nextToken), listCatalogProviders(nextToken), listBaseModels(nextToken), listPricingRules(nextToken), + listPricingRuleSets(nextToken), listRateLimitWindows(nextToken), listTenants(nextToken), listUsers(nextToken), listUserGroups(nextToken), listApiKeys(nextToken), ]); - setPlatforms(platformResponse.items); - setModels(modelResponse.items); - setProviders(providerResponse.items); - setBaseModels(baseModelResponse.items); - setPricingRules(pricingRuleResponse.items); - setRateLimitWindows(rateLimitWindowResponse.items); - setTenants(tenantResponse.items); - setUsers(userResponse.items); - setUserGroups(userGroupResponse.items); - setApiKeys(apiKeyResponse.items); + setPlatforms(responses[0].items); + setModels(responses[1].items); + setProviders(responses[2].items); + setBaseModels(responses[3].items); + setPricingRules(responses[4].items); + setPricingRuleSets(responses[5].items); + setRateLimitWindows(responses[6].items); + setTenants(responses[7].items); + setUsers(responses[8].items); + setUserGroups(responses[9].items); + setApiKeys(responses[10].items); setState('ready'); } catch (err) { setState('error'); setError(err instanceof Error ? err.message : '加载失败'); } } + async function loadPublicCatalog() { + try { + const [healthResult, providersResult, baseModelsResult] = await Promise.all([ + getHealth(), + listPublicCatalogProviders(), + listPublicBaseModels(), + ]); + setHealth(healthResult); + setProviders(providersResult.items); + setBaseModels(baseModelsResult.items); + } catch (err) { + setError(err instanceof Error ? err.message : '公共模型目录加载失败'); + } + } async function submitLogin(event: FormEvent) { event.preventDefault(); - setState('loading'); - setError(''); - try { - const response = await loginLocalAccount(loginForm); - setToken(response.accessToken); - await refresh(response.accessToken); - } catch (err) { - setState('error'); - setError(err instanceof Error ? err.message : '登录失败'); - } + await authenticate(() => loginLocalAccount(loginForm), '登录失败'); } async function submitRegister(event: FormEvent) { event.preventDefault(); - setState('loading'); - setError(''); - try { - const response = await registerLocalAccount(registerForm); - setToken(response.accessToken); - await refresh(response.accessToken); - } catch (err) { - setState('error'); - setError(err instanceof Error ? err.message : '注册失败'); - } + await authenticate(() => registerLocalAccount(registerForm), '注册失败'); } async function submitExternalToken(event: FormEvent) { @@ -237,26 +232,23 @@ export function App() { setError('请填写 access token'); return; } + persistAccessToken(nextToken); setToken(nextToken); await refresh(nextToken); } - function signOut() { - setToken(''); - setState('idle'); - setPlatforms([]); - setModels([]); - setProviders([]); - setBaseModels([]); - setPricingRules([]); - setRateLimitWindows([]); - setTenants([]); - setUsers([]); - setUserGroups([]); - setApiKeys([]); - setApiKeySecret(''); - setTaskResult(null); - setCoreMessage(''); + async function authenticate(request: () => Promise<{ accessToken: string }>, fallback: string) { + setState('loading'); + setError(''); + try { + const response = await request(); + persistAccessToken(response.accessToken); + setToken(response.accessToken); + await refresh(response.accessToken); + } catch (err) { + setState('error'); + setError(err instanceof Error ? err.message : fallback); + } } async function submitAPIKey(event: FormEvent) { @@ -283,10 +275,13 @@ export function App() { const platform = await createPlatform(token, { ...platformForm, authType: 'bearer', - credentials: { mode: 'simulation' }, config: { testMode: true }, + credentials: { mode: 'simulation' }, + defaultDiscountFactor: Number(platformForm.defaultDiscountFactor) || 1, + pricingRuleSetId: platformForm.pricingRuleSetId || undefined, }); setPlatforms((current) => [platform, ...current.filter((item) => item.id !== platform.id)]); + setPlatformModelForm((current) => ({ ...current, platformId: current.platformId || platform.id })); setCoreState('ready'); setCoreMessage('平台已创建,当前阶段平台凭证仅全局管理员可配置。'); } catch (err) { @@ -295,484 +290,210 @@ export function App() { } } + async function submitPlatformModel(event: FormEvent) { + event.preventDefault(); + if (!platformModelForm.platformId || !platformModelForm.modelName || !platformModelForm.modelType) { + setCoreState('error'); + setCoreMessage('请选择平台并填写模型名。'); + return; + } + setCoreState('loading'); + setCoreMessage(''); + try { + const model = await createPlatformModel(token, platformModelForm.platformId, { + canonicalModelKey: platformModelForm.canonicalModelKey || undefined, + modelAlias: platformModelForm.modelAlias || platformModelForm.modelName, + modelName: platformModelForm.modelName, + modelType: platformModelForm.modelType, + discountFactor: Number(platformModelForm.discountFactor) || undefined, + pricingRuleSetId: platformModelForm.pricingRuleSetId || undefined, + retryPolicy: { enabled: true, maxAttempts: 2 }, + }); + setModels((current) => [model, ...current.filter((item) => item.id !== model.id)]); + setCoreState('ready'); + setCoreMessage('平台模型已绑定,可参与 simulation 任务路由。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '绑定平台模型失败'); + } + } + async function submitTask(event: FormEvent) { event.preventDefault(); const credential = apiKeySecret || token; setCoreState('loading'); setCoreMessage(''); try { - const response = await createChatTask(credential, { - model: taskForm.model, - runMode: 'simulation', - simulation: true, - messages: [{ role: 'user', content: taskForm.prompt }], - }); + const response = await runTask(credential, taskForm); const detail = await getTask(credential, response.task.id); setTaskResult(detail); setCoreState('ready'); - setCoreMessage(apiKeySecret ? '任务已通过本地 API Key 完成 simulation。' : '任务已通过当前 Access Token 完成 simulation。'); + setCoreMessage(`${taskForm.kind} 已通过 ${apiKeySecret ? '本地 API Key' : '当前 Access Token'} 完成 simulation。`); } catch (err) { setCoreState('error'); setCoreMessage(err instanceof Error ? err.message : '测试任务失败'); } } + function signOut() { + persistAccessToken(''); + setToken(''); + setState('idle'); + setPlatforms([]); + setModels([]); + setProviders([]); + setBaseModels([]); + setPricingRules([]); + setPricingRuleSets([]); + setRateLimitWindows([]); + setTenants([]); + setUsers([]); + setUserGroups([]); + setApiKeys([]); + setApiKeySecret(''); + setTaskResult(null); + setCoreMessage(''); + navigatePath('/'); + } + + function showLogin() { + setAuthMode('login'); + navigatePath(pathForWorkspaceSection('overview')); + } + + function currentRouteState(): AppRouteState { + return { activePage, adminSection, apiDocSection, workspaceSection }; + } + + function applyRoute(route: AppRouteState) { + setActivePage(route.activePage); + setAdminSection(route.adminSection); + setApiDocSection(route.apiDocSection); + setWorkspaceSection(route.workspaceSection); + } + + function navigatePath(path: string) { + if (window.location.pathname !== path) { + window.history.pushState(null, '', path); + } + applyRoute(parseAppRoute(path)); + } + + function navigatePage(page: PageKey) { + navigatePath(pathForPage(page, currentRouteState())); + } + + function navigateAdminSection(section: AdminSection) { + navigatePath(pathForAdminSection(section)); + } + + function navigateWorkspaceSection(section: WorkspaceSection) { + navigatePath(pathForWorkspaceSection(section)); + } + + function navigateApiDocSection(section: ApiDocSection) { + navigatePath(pathForApiDocSection(section)); + } + + const isAuthenticated = Boolean(token); + return ( -
-
-
-

EasyAI

-

AI Gateway Console

-
-
-
- - {health?.identityMode ? `${health.service} · ${health.identityMode}` : health?.service ?? 'API 未连接'} -
- {token && ( - - )} -
-
- - {!token ? ( - - ) : ( - <> -
- - -
- - - - - - )} - + void refresh()} + onSignOut={signOut} + > {error &&
{error}
} -
- ); -} - -function AuthPanel(props: { - authMode: AuthMode; - externalToken: string; - loginForm: { account: string; password: string }; - registerForm: { - username: string; - email: string; - password: string; - displayName: string; - invitationCode: string; - }; - state: LoadState; - onAuthModeChange: (value: AuthMode) => void; - onExternalTokenChange: (value: string) => void; - onLoginChange: (value: { account: string; password: string }) => void; - onRegisterChange: (value: { - username: string; - email: string; - password: string; - displayName: string; - invitationCode: string; - }) => void; - onSubmitExternalToken: (event: FormEvent) => void; - onSubmitLogin: (event: FormEvent) => void; - onSubmitRegister: (event: FormEvent) => void; -}) { - return ( -
-
-
-

Gateway Identity

-

登录 AI Gateway

-
-
- {[ - ['login', '账号登录'], - ['register', '注册账号'], - ['external', '外部 Token'], - ].map(([value, label]) => ( - - ))} -
- - {props.authMode === 'login' && ( -
- - - -
- )} - - {props.authMode === 'register' && ( -
- - - - - - -
- )} - - {props.authMode === 'external' && ( -
- - -
- )} -
-
- ); -} - -function CoreFlowPanel(props: { - apiKeyForm: { name: string }; - apiKeys: GatewayApiKey[]; - apiKeySecret: string; - coreMessage: string; - coreState: LoadState; - platformForm: { provider: string; platformKey: string; name: string; baseUrl: string }; - taskForm: { model: string; prompt: string }; - taskResult: GatewayTask | null; - onAPIKeyFormChange: (value: { name: string }) => void; - onPlatformFormChange: (value: { provider: string; platformKey: string; name: string; baseUrl: string }) => void; - onSubmitAPIKey: (event: FormEvent) => void; - onSubmitPlatform: (event: FormEvent) => void; - onSubmitTask: (event: FormEvent) => void; - onTaskFormChange: (value: { model: string; prompt: string }) => void; -}) { - return ( -
-
-
-

Smoke Flow

-

核心链路验证

-
- {props.coreState === 'loading' ? '运行中' : '本地闭环'} -
- -
-
-

1. API Key

- - -

已创建 {props.apiKeys.length} 个 Key

- {props.apiKeySecret && {props.apiKeySecret}} -
- -
-

2. 平台

- - - - - -
- -
-

3. Simulation 任务

- - - - {props.taskResult && ( -
-
- {props.taskResult.status} - {props.taskResult.model} -
-
{JSON.stringify(props.taskResult.result ?? {}, null, 2)}
-
- )} -
-
- {props.coreMessage &&

{props.coreMessage}

} -
- ); -} - -function Dashboard(props: { - baseModels: BaseModelCatalogItem[]; - models: PlatformModel[]; - platforms: IntegrationPlatform[]; - rateLimitWindows: RateLimitWindow[]; - stats: Array<{ label: string; value: number; tone: string }>; -}) { - return ( - <> -
-
-
-

Navigation

-

前端页面结构

-
- 5 个一级模块 -
-
- {primaryModules.map((item) => ( -
-
-

{item.title}

- {item.path} -
-

{item.description}

-
- {item.items.map((tag) => ( - {tag} - ))} -
-
- ))} -
-
- -
-
-
-

Workspace

-

用户、管理与 API 文档

-
- 设计分区 -
-
- - - -
-
- -
- {props.stats.map((item) => ( -
- {item.label} - {item.value} -
- ))} -
- -
- [item.provider, item.name, item.status, String(item.priority)])} - title="平台" + {activePage === 'home' && } + {activePage === 'models' && } + {activePage === 'workspace' && ( + isAuthenticated ? ( + + ) : ( + + ) + )} + {activePage === 'admin' && ( + isAuthenticated ? ( + + ) : ( + + ) + )} + {activePage === 'docs' && ( + - [item.modelName, item.modelType, item.provider ?? item.platformName ?? '-', item.enabled ? '是' : '否'])} - title="模型" - /> -
- -
- [item.providerKey, item.canonicalModelKey, item.modelType, String(item.pricingVersion)])} - title="基准模型库" - /> - [item.scopeKey, item.metric, `${item.usedValue}/${item.limitValue}`, String(item.reservedValue)])} - title="TPM/RPM 窗口" - /> -
- - ); -} - -function DataPanel(props: { columns: string[]; empty: string; rows: string[][]; title: string }) { - return ( -
-
-

{props.title}

- {props.rows.length} -
-
-
- {props.columns.map((column) => ( - {column} - ))} -
- {props.rows.map((row, index) => ( -
- {row.map((cell, cellIndex) => ( - {cell} - ))} -
- ))} - {!props.rows.length &&

{props.empty}

} -
-
- ); -} - -function ModuleList(props: { - title: string; - items: Array<{ title: string; path: string; description: string }>; -}) { - return ( -
-

{props.title}

- {props.items.map((item) => ( -
-
- {item.title} -

{item.description}

-
- {item.path} -
- ))} -
+ )} + ); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 98366cf..39e8bc2 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,7 +1,9 @@ import type { AuthResponse, BaseModelCatalogItem, + BaseModelUpsertRequest, CatalogProvider, + CatalogProviderUpsertRequest, CreatedGatewayApiKey, GatewayApiKey, GatewayTenant, @@ -11,6 +13,8 @@ import type { ListResponse, PlatformModel, PricingRule, + PricingRuleSet, + PricingRuleSetUpsertRequest, RateLimitWindow, UserGroup, } from '@easyai-ai-gateway/contracts'; @@ -58,18 +62,117 @@ export async function listModels(token: string): Promise>('/api/v1/models', { token }); } +export async function listPublicCatalogProviders(): Promise> { + return request>('/api/v1/public/catalog/providers', { auth: false }); +} + export async function listCatalogProviders(token: string): Promise> { return request>('/api/v1/catalog/providers', { token }); } +export async function createCatalogProvider( + token: string, + input: CatalogProviderUpsertRequest, +): Promise { + return request('/api/v1/catalog/providers', { + body: input, + method: 'POST', + token, + }); +} + +export async function updateCatalogProvider( + token: string, + providerId: string, + input: CatalogProviderUpsertRequest, +): Promise { + return request(`/api/v1/catalog/providers/${providerId}`, { + body: input, + method: 'PATCH', + token, + }); +} + +export async function deleteCatalogProvider(token: string, providerId: string): Promise { + await request(`/api/v1/catalog/providers/${providerId}`, { + method: 'DELETE', + token, + }); +} + +export async function listPublicBaseModels(): Promise> { + return request>('/api/v1/public/catalog/base-models', { auth: false }); +} + export async function listBaseModels(token: string): Promise> { return request>('/api/v1/catalog/base-models', { token }); } +export async function createBaseModel(token: string, input: BaseModelUpsertRequest): Promise { + return request('/api/v1/catalog/base-models', { + body: input, + method: 'POST', + token, + }); +} + +export async function updateBaseModel( + token: string, + baseModelId: string, + input: BaseModelUpsertRequest, +): Promise { + return request(`/api/v1/catalog/base-models/${baseModelId}`, { + body: input, + method: 'PATCH', + token, + }); +} + +export async function deleteBaseModel(token: string, baseModelId: string): Promise { + await request(`/api/v1/catalog/base-models/${baseModelId}`, { + method: 'DELETE', + token, + }); +} + export async function listPricingRules(token: string): Promise> { return request>('/api/v1/pricing/rules', { token }); } +export async function listPricingRuleSets(token: string): Promise> { + return request>('/api/v1/pricing/rule-sets', { token }); +} + +export async function createPricingRuleSet( + token: string, + input: PricingRuleSetUpsertRequest, +): Promise { + return request('/api/v1/pricing/rule-sets', { + body: input, + method: 'POST', + token, + }); +} + +export async function updatePricingRuleSet( + token: string, + ruleSetId: string, + input: PricingRuleSetUpsertRequest, +): Promise { + return request(`/api/v1/pricing/rule-sets/${ruleSetId}`, { + body: input, + method: 'PATCH', + token, + }); +} + +export async function deletePricingRuleSet(token: string, ruleSetId: string): Promise { + await request(`/api/v1/pricing/rule-sets/${ruleSetId}`, { + method: 'DELETE', + token, + }); +} + export async function listTenants(token: string): Promise> { return request>('/api/v1/tenants', { token }); } @@ -109,6 +212,7 @@ export async function createPlatform( config?: Record; defaultPricingMode?: string; defaultDiscountFactor?: number; + pricingRuleSetId?: string; priority?: number; }, ): Promise { @@ -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; + rateLimitPolicy?: Record; + pricingRuleSetId?: string; + discountFactor?: number; + }, +): Promise { + return request(`/api/v1/platforms/${platformId}/models`, { + body: input, + method: 'POST', + token, + }); +} + export async function createChatTask( token: string, input: { model: string; messages: Array>; 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 }> { + return request<{ task: GatewayTask; next: Record }>('/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 }> { + return request<{ task: GatewayTask; next: Record }>('/api/v1/images/edits', { + body: input, + method: 'POST', + token, + }); +} + +export async function estimatePricing( + token: string, + input: Record, +): 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 { return request(`/api/v1/tasks/${taskId}`, { token }); } @@ -158,6 +318,9 @@ async function request( const body = await response.text(); throw new Error(parseErrorMessage(body) || `Request failed: ${response.status}`); } + if (response.status === 204) { + return undefined as T; + } return response.json() as Promise; } diff --git a/apps/web/src/app-state.ts b/apps/web/src/app-state.ts new file mode 100644 index 0000000..a8de5bb --- /dev/null +++ b/apps/web/src/app-state.ts @@ -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'; +} diff --git a/apps/web/src/components/AuthPanel.tsx b/apps/web/src/components/AuthPanel.tsx new file mode 100644 index 0000000..4a3b6fd --- /dev/null +++ b/apps/web/src/components/AuthPanel.tsx @@ -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) => void; + onSubmitLogin: (event: FormEvent) => void; + onSubmitRegister: (event: FormEvent) => void; +}) { + return ( +
+ + +
+

Gateway Identity

+ 登录 AI Gateway +
+
+ + + {props.authMode === 'login' && } + {props.authMode === 'register' && } + {props.authMode === 'external' && } + +
+
+ ); +} + +function LoginFormView(props: { + loginForm: LoginForm; + state: LoadState; + onLoginChange: (value: LoginForm) => void; + onSubmitLogin: (event: FormEvent) => void; +}) { + return ( +
+ + + +
+ ); +} + +function RegisterFormView(props: { + registerForm: RegisterForm; + state: LoadState; + onRegisterChange: (value: RegisterForm) => void; + onSubmitRegister: (event: FormEvent) => void; +}) { + return ( +
+ + + + + + +
+ ); +} + +function ExternalTokenForm(props: { + externalToken: string; + state: LoadState; + onExternalTokenChange: (value: string) => void; + onSubmitExternalToken: (event: FormEvent) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/CoreFlowPanel.tsx b/apps/web/src/components/CoreFlowPanel.tsx new file mode 100644 index 0000000..bee1045 --- /dev/null +++ b/apps/web/src/components/CoreFlowPanel.tsx @@ -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) => void; + onSubmitPlatform: (event: FormEvent) => void; + onSubmitTask: (event: FormEvent) => void; + onTaskFormChange: (value: TaskForm) => void; +}) { + return ( +
+
+
+

Smoke Flow

+

核心链路验证

+
+ {props.coreState === 'loading' ? '运行中' : '本地闭环'} +
+ +
+ + + +
+ {props.coreMessage && ( +

+ {props.coreMessage} +

+ )} +
+ ); +} + +function ApiKeyForm(props: { + apiKeyForm: { name: string }; + apiKeys: GatewayApiKey[]; + apiKeySecret: string; + coreState: LoadState; + onAPIKeyFormChange: (value: { name: string }) => void; + onSubmitAPIKey: (event: FormEvent) => void; +}) { + return ( +
+

1. API Key

+ + +

已创建 {props.apiKeys.length} 个 Key

+ {props.apiKeySecret && {props.apiKeySecret}} +
+ ); +} + +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) => void; +}) { + return ( +
+

2. 平台

+ + + + + +
+ ); +} + +function TaskSmokeForm(props: { + coreState: LoadState; + taskForm: TaskForm; + taskResult: GatewayTask | null; + onSubmitTask: (event: FormEvent) => void; + onTaskFormChange: (value: TaskForm) => void; +}) { + return ( +
+

3. Phase 1 测试

+ + + + {props.taskForm.kind === 'images.edits' && ( + <> + + + + )} + + {props.taskResult && ( +
+
+ {props.taskResult.status} + {props.taskResult.model} +
+
{JSON.stringify(props.taskResult.result ?? {}, null, 2)}
+
+ )} +
+ ); +} + +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' }; +} diff --git a/apps/web/src/components/Dashboard.tsx b/apps/web/src/components/Dashboard.tsx new file mode 100644 index 0000000..a3fc411 --- /dev/null +++ b/apps/web/src/components/Dashboard.tsx @@ -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 ( + <> +
+
+
+

Navigation

+

前端页面结构

+
+ 5 个一级模块 +
+
+ {primaryModules.map((item) => ( +
+
+

{item.title}

+ {item.path} +
+

{item.description}

+
+ {item.items.map((tag) => ( + {tag} + ))} +
+
+ ))} +
+
+ +
+
+
+

Workspace

+

用户、管理与 API 文档

+
+ 设计分区 +
+
+ + + +
+
+ +
+ {props.stats.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ +
+ [item.provider, item.name, item.status, String(item.priority)])} + title="平台" + /> + [item.modelName, item.modelType, item.provider ?? item.platformName ?? '-', item.enabled ? '是' : '否'])} + title="模型" + /> +
+ +
+ [item.providerKey, item.canonicalModelKey, item.modelType, String(item.pricingVersion)])} + title="基准模型库" + /> + [item.scopeKey, item.metric, `${item.usedValue}/${item.limitValue}`, String(item.reservedValue)])} + title="TPM/RPM 窗口" + /> +
+ + ); +} diff --git a/apps/web/src/components/DataPanel.tsx b/apps/web/src/components/DataPanel.tsx new file mode 100644 index 0000000..88fc49f --- /dev/null +++ b/apps/web/src/components/DataPanel.tsx @@ -0,0 +1,25 @@ +export function DataPanel(props: { columns: string[]; empty: string; rows: string[][]; title: string }) { + return ( +
+
+

{props.title}

+ {props.rows.length} +
+
+
+ {props.columns.map((column) => ( + {column} + ))} +
+ {props.rows.map((row, index) => ( +
+ {row.map((cell, cellIndex) => ( + {cell} + ))} +
+ ))} + {!props.rows.length &&

{props.empty}

} +
+
+ ); +} diff --git a/apps/web/src/components/EntityTable.tsx b/apps/web/src/components/EntityTable.tsx new file mode 100644 index 0000000..bb534c0 --- /dev/null +++ b/apps/web/src/components/EntityTable.tsx @@ -0,0 +1,25 @@ +import { EmptyState, Table, TableCell, TableHead, TableRow } from './ui'; + +export function EntityTable(props: { + columns: string[]; + empty: string; + rows: Array>; +}) { + return ( + + + {props.columns.map((column) => ( + {column} + ))} + + {props.rows.map((row, index) => ( + + {row.map((cell, cellIndex) => ( + {cell} + ))} + + ))} + {!props.rows.length && } +
+ ); +} diff --git a/apps/web/src/components/LoginRequiredPanel.tsx b/apps/web/src/components/LoginRequiredPanel.tsx new file mode 100644 index 0000000..66365a9 --- /dev/null +++ b/apps/web/src/components/LoginRequiredPanel.tsx @@ -0,0 +1,14 @@ +import { AuthPanel } from './AuthPanel'; + +export function LoginRequiredPanel(props: Parameters[0]) { + return ( +
+
+

Identity

+

登录后进入工作台

+

用户工作台和管理后台需要本地账号、API Key 或 server-main token。

+
+ +
+ ); +} diff --git a/apps/web/src/components/ModuleList.tsx b/apps/web/src/components/ModuleList.tsx new file mode 100644 index 0000000..8f93dce --- /dev/null +++ b/apps/web/src/components/ModuleList.tsx @@ -0,0 +1,19 @@ +export function ModuleList(props: { + title: string; + items: Array<{ title: string; path: string; description: string }>; +}) { + return ( +
+

{props.title}

+ {props.items.map((item) => ( +
+
+ {item.title} +

{item.description}

+
+ {item.path} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/PageHeader.tsx b/apps/web/src/components/PageHeader.tsx new file mode 100644 index 0000000..a9f46df --- /dev/null +++ b/apps/web/src/components/PageHeader.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +export function PageHeader(props: { eyebrow: string; title: string; description?: string; action?: ReactNode }) { + return ( +
+
+

{props.eyebrow}

+

{props.title}

+ {props.description &&

{props.description}

} +
+ {props.action &&
{props.action}
} +
+ ); +} diff --git a/apps/web/src/components/StatGrid.tsx b/apps/web/src/components/StatGrid.tsx new file mode 100644 index 0000000..f8d7123 --- /dev/null +++ b/apps/web/src/components/StatGrid.tsx @@ -0,0 +1,14 @@ +import type { StatItem } from '../app-state'; + +export function StatGrid(props: { items: StatItem[] }) { + return ( +
+ {props.items.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..6a5585d --- /dev/null +++ b/apps/web/src/components/layout/AppShell.tsx @@ -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: }, + { key: 'models', label: '模型', icon: }, + { key: 'workspace', label: '用户工作台', icon: }, + { key: 'admin', label: '管理工作台', icon: }, + { key: 'docs', label: 'API 文档', icon: }, +]; + +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 ( +
+
+
+
AI
+
+ EasyAI Gateway + Console +
+
+ + +
+
+ + {props.health?.identityMode ? `${props.health.service} · ${props.health.identityMode}` : props.health?.service ?? 'API 未连接'} +
+ {props.isAuthenticated ? ( + <> + + + + ) : ( + + )} +
+
+ +
+
+ {props.state === 'error' && 数据加载失败} + {props.children} +
+
+
+ ); +} diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..b8b916a --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -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 & VariantProps) { + const { className, variant, ...rest } = props; + return
; +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 0000000..a980747 --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean; +} + +export const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ; + }, +); + +Button.displayName = 'Button'; diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 0000000..8561fb9 --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { cn } from '../../lib/utils'; + +export const Card = React.forwardRef>( + ({ className, ...props }, ref) =>
, +); +Card.displayName = 'Card'; + +export const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) =>
, +); +CardHeader.displayName = 'CardHeader'; + +export const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) =>

, +); +CardTitle.displayName = 'CardTitle'; + +export const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) =>

, +); +CardDescription.displayName = 'CardDescription'; + +export const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>

, +); +CardContent.displayName = 'CardContent'; + +export const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) =>
, +); +CardFooter.displayName = 'CardFooter'; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..590ee88 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -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; +} + +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 ( +
+
+
+
+ {props.eyebrow && {props.eyebrow}} + {props.title} +
+ +
+
+
{props.children}
+
{props.footer}
+
+
+
+ ); +} diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts new file mode 100644 index 0000000..641d422 --- /dev/null +++ b/apps/web/src/components/ui/index.ts @@ -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'; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..899059d --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; +import { cn } from '../../lib/utils'; + +export const Input = React.forwardRef>( + ({ className, ...props }, ref) => , +); + +Input.displayName = 'Input'; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 0000000..856aa38 --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; +import { cn } from '../../lib/utils'; + +export const Label = React.forwardRef>( + ({ className, ...props }, ref) =>