From a76ab941bc920843a7d53218c0a40bd0f275e46c Mon Sep 17 00:00:00 2001 From: wangbo Date: Tue, 12 May 2026 21:49:08 +0800 Subject: [PATCH] Add platform status switch --- apps/api/internal/httpapi/handlers.go | 10 +++++ apps/api/internal/store/postgres.go | 8 +++- apps/web/src/App.tsx | 38 ++++++++++++++++ apps/web/src/components/ui/index.ts | 1 + apps/web/src/components/ui/switch.tsx | 32 +++++++++++++ apps/web/src/pages/AdminPage.tsx | 3 ++ .../pages/admin/PlatformManagementPanel.tsx | 35 +++++++++++++-- apps/web/src/styles/pages.css | 27 +++++++++++ apps/web/src/styles/ui.css | 45 +++++++++++++++++++ apps/web/src/types.ts | 1 + 10 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/ui/switch.tsx diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 5492f85..16ac3fd 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -182,10 +182,15 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) { input.Provider = strings.TrimSpace(input.Provider) input.Name = strings.TrimSpace(input.Name) input.InternalName = strings.TrimSpace(input.InternalName) + input.Status = strings.TrimSpace(input.Status) if input.Provider == "" || input.Name == "" { writeError(w, http.StatusBadRequest, "provider and name are required") return } + if input.Status != "" && input.Status != "enabled" && input.Status != "disabled" { + writeError(w, http.StatusBadRequest, "status must be enabled or disabled") + return + } if input.AuthType == "" { input.AuthType = "bearer" } @@ -213,10 +218,15 @@ func (s *Server) updatePlatform(w http.ResponseWriter, r *http.Request) { input.Provider = strings.TrimSpace(input.Provider) input.Name = strings.TrimSpace(input.Name) input.InternalName = strings.TrimSpace(input.InternalName) + input.Status = strings.TrimSpace(input.Status) if input.Provider == "" || input.Name == "" { writeError(w, http.StatusBadRequest, "provider and name are required") return } + if input.Status != "" && input.Status != "enabled" && input.Status != "disabled" { + writeError(w, http.StatusBadRequest, "status must be enabled or disabled") + return + } if input.AuthType == "" { input.AuthType = "bearer" } diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index 40a2245..8850040 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -97,6 +97,7 @@ type CreatePlatformInput struct { DefaultDiscountFactor float64 `json:"defaultDiscountFactor"` PricingRuleSetID string `json:"pricingRuleSetId"` Priority int `json:"priority"` + Status string `json:"status"` } type CreateAPIKeyInput struct { @@ -591,11 +592,11 @@ func (s *Store) CreatePlatform(ctx context.Context, input CreatePlatformInput) ( INSERT INTO integration_platforms ( provider, platform_key, name, internal_name, base_url, auth_type, credentials, config, default_pricing_mode, default_discount_factor, pricing_rule_set_id, - priority, retry_policy, rate_limit_policy + priority, retry_policy, rate_limit_policy, status ) VALUES ( $1, COALESCE(NULLIF($2, ''), 'platform_' || replace(gen_random_uuid()::text, '-', '')), $3, NULLIF($4, ''), $5, $6, $7, $8, - $9, $10, NULLIF($11, '')::uuid, $12, $13, $14 + $9, $10, NULLIF($11, '')::uuid, $12, $13, $14, COALESCE(NULLIF($15, ''), 'enabled') ) RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), COALESCE(base_url, ''), auth_type, status, priority, dynamic_priority, COALESCE(dynamic_priority, priority), @@ -606,6 +607,7 @@ RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), C input.Provider, input.PlatformKey, input.Name, strings.TrimSpace(input.InternalName), input.BaseURL, input.AuthType, credentials, config, input.DefaultPricingMode, input.DefaultDiscountFactor, input.PricingRuleSetID, input.Priority, string(retryPolicy), string(rateLimitPolicy), + input.Status, ).Scan( &platform.ID, &platform.Provider, @@ -684,6 +686,7 @@ SET provider = $2, priority = $13, retry_policy = $14, rate_limit_policy = $15, + status = COALESCE(NULLIF($16, ''), status), updated_at = now() WHERE id = $1::uuid RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), COALESCE(base_url, ''), auth_type, status, @@ -707,6 +710,7 @@ RETURNING id::text, provider, platform_key, name, COALESCE(internal_name, ''), C input.Priority, string(retryPolicy), string(rateLimitPolicy), + input.Status, ).Scan( &platform.ID, &platform.Provider, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0b909f7..f353298 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -560,6 +560,24 @@ export function App() { } } + async function savePlatformStatus(platform: IntegrationPlatform, status: 'enabled' | 'disabled') { + setCoreState('loading'); + setCoreMessage(''); + try { + const input = platformStatusUpdatePayload(platform, status); + const updated = await updatePlatform(token, platform.id, input); + const platformForState = withCredentialPreviewFallback(updated, input, platform); + setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item)); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels'); + setCoreState('ready'); + setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '切换平台状态失败'); + throw err; + } + } + async function savePlatformDynamicPriority(platformId: string, input: PlatformDynamicPriorityUpdateRequest) { setCoreState('loading'); setCoreMessage(''); @@ -1029,6 +1047,7 @@ export function App() { onResetBaseModel={resetBaseModelToDefault} onSavePlatform={savePlatformWithModels} onSavePlatformDynamicPriority={savePlatformDynamicPriority} + onTogglePlatformStatus={savePlatformStatus} onSaveProvider={saveProvider} onSavePricingRuleSet={savePricingRuleSet} onSaveRunnerPolicy={saveRunnerPolicy} @@ -1105,6 +1124,25 @@ function mergeExistingPlatformModelInput(input: PlatformModelBindingInput, curre }; } +function platformStatusUpdatePayload(platform: IntegrationPlatform, status: 'enabled' | 'disabled'): PlatformCreateInput { + return { + provider: platform.provider, + platformKey: platform.platformKey, + name: platform.name, + internalName: platform.internalName, + baseUrl: platform.baseUrl, + authType: platform.authType, + config: platform.config ?? {}, + retryPolicy: platform.retryPolicy ?? {}, + rateLimitPolicy: platform.rateLimitPolicy ?? {}, + defaultPricingMode: platform.defaultPricingMode, + defaultDiscountFactor: platform.defaultDiscountFactor, + pricingRuleSetId: platform.pricingRuleSetId, + priority: platform.priority, + status, + }; +} + function withCredentialPreviewFallback( platform: IntegrationPlatform, input: PlatformCreateInput, diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index 2aad302..169f97b 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -13,6 +13,7 @@ export * from './message'; export * from './popover'; export * from './select'; export * from './separator'; +export * from './switch'; export * from './table'; export * from './tabs'; export * from './textarea'; diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx new file mode 100644 index 0000000..a20a19d --- /dev/null +++ b/apps/web/src/components/ui/switch.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { cn } from '../../lib/utils'; + +export interface SwitchProps extends Omit, 'onChange'> { + checked: boolean; + onCheckedChange?: (checked: boolean) => void; +} + +export const Switch = React.forwardRef( + ({ checked, className, disabled, onCheckedChange, ...props }, ref) => ( + + ), +); + +Switch.displayName = 'Switch'; diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index 6f6a18f..b7ccb3c 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -8,6 +8,7 @@ import type { GatewayTenantUpsertRequest, GatewayRunnerPolicyUpsertRequest, GatewayUserUpsertRequest, + IntegrationPlatform, PlatformDynamicPriorityUpdateRequest, PricingRuleSetUpsertRequest, RuntimePolicySetUpsertRequest, @@ -65,6 +66,7 @@ export function AdminPage(props: { onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; onSavePlatform: (input: PlatformWithModelsInput) => Promise; onSavePlatformDynamicPriority: (platformId: string, input: PlatformDynamicPriorityUpdateRequest) => Promise; + onTogglePlatformStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => Promise; onSaveProvider: (input: CatalogProviderUpsertRequest, providerId?: string) => Promise; onSavePricingRuleSet: (input: PricingRuleSetUpsertRequest, ruleSetId?: string) => Promise; onSaveRunnerPolicy: (input: GatewayRunnerPolicyUpsertRequest) => Promise; @@ -149,6 +151,7 @@ export function AdminPage(props: { state={props.state} onDeletePlatform={props.onDeletePlatform} onSavePlatform={props.onSavePlatform} + onTogglePlatformStatus={props.onTogglePlatformStatus} /> )} {props.section === 'realtimeLoad' && ( diff --git a/apps/web/src/pages/admin/PlatformManagementPanel.tsx b/apps/web/src/pages/admin/PlatformManagementPanel.tsx index dd3eee4..900e92c 100644 --- a/apps/web/src/pages/admin/PlatformManagementPanel.tsx +++ b/apps/web/src/pages/admin/PlatformManagementPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'; import { Boxes, CheckCircle2, Globe2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react'; import type { BaseModelCatalogItem, CatalogProvider, IntegrationPlatform, PlatformModel, PricingRuleSet } from '@easyai-ai-gateway/contracts'; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Switch, Table, TableCell, TableHead, TableRow } from '../../components/ui'; import type { LoadState, PlatformWithModelsInput } from '../../types'; import { authTypes, @@ -31,6 +31,7 @@ export function PlatformManagementPanel(props: { state: LoadState; onDeletePlatform: (platformId: string) => Promise; onSavePlatform: (input: PlatformWithModelsInput) => Promise; + onTogglePlatformStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => Promise; }) { const defaultProvider = props.providers[0]?.providerKey ?? props.baseModels[0]?.providerKey ?? ''; const [now, setNow] = useState(() => Date.now()); @@ -42,6 +43,7 @@ export function PlatformManagementPanel(props: { const [globalProxyNoticeOpen, setGlobalProxyNoticeOpen] = useState(false); const [editingPlatform, setEditingPlatform] = useState(null); const [pendingDeletePlatform, setPendingDeletePlatform] = useState(null); + const [togglingPlatformId, setTogglingPlatformId] = useState(''); const providerMap = useMemo(() => new Map(props.providers.map((item) => [item.providerKey, item])), [props.providers]); const platformMap = useMemo(() => new Map(props.platforms.map((item) => [item.id, item])), [props.platforms]); const [form, setForm] = useState(() => createEmptyPlatformForm(defaultProvider, providerDefaults(providerMap.get(defaultProvider)))); @@ -157,6 +159,18 @@ export function PlatformManagementPanel(props: { } } + async function togglePlatformStatus(platform: IntegrationPlatform, status: 'enabled' | 'disabled') { + setTogglingPlatformId(platform.id); + setValidationMessage(''); + try { + await props.onTogglePlatformStatus(platform, status); + } catch (err) { + setValidationMessage(err instanceof Error ? err.message : '切换平台状态失败'); + } finally { + setTogglingPlatformId(''); + } + } + return (
setValidationMessage('')} /> @@ -183,9 +197,11 @@ export function PlatformManagementPanel(props: { platforms={props.platforms} providerMap={providerMap} pricingRuleSets={props.pricingRuleSets} + togglingPlatformId={togglingPlatformId} onDelete={setPendingDeletePlatform} onCreate={openCreateDialog} onEdit={openEditDialog} + onToggleStatus={togglePlatformStatus} /> ) : ( ; pricingRuleSets: PricingRuleSet[]; + togglingPlatformId: string; onCreate: () => void; onDelete: (platform: IntegrationPlatform) => void; onEdit: (platform: IntegrationPlatform) => void; + onToggleStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => void; }) { if (!props.platforms.length) { return ( @@ -438,6 +456,8 @@ function PlatformTable(props: { const rateLimit = platformRateLimitSummary(platform.rateLimitPolicy); const runtime = platformRuntimeSummary(platform); const platformCooldownMs = cooldownRemainingMs(platform.cooldownUntil, props.now); + const isEnabled = platform.status === 'enabled'; + const isToggling = props.togglingPlatformId === platform.id; return ( @@ -467,12 +487,21 @@ function PlatformTable(props: { {props.platformModelCount.get(platform.id) ?? 0} - + + + props.onToggleStatus(platform, checked ? 'enabled' : 'disabled')} + /> + {isEnabled ? '启用' : '禁用'} + {platformCooldownMs > 0 ? ( 冷却中 ) : ( - {platform.status} + {isEnabled ? '已启用' : '已禁用'} )} {platformCooldownMs > 0 ? `剩余 ${formatCooldownRemaining(platformCooldownMs)}` : runtime} diff --git a/apps/web/src/styles/pages.css b/apps/web/src/styles/pages.css index 105bcaf..7695382 100644 --- a/apps/web/src/styles/pages.css +++ b/apps/web/src/styles/pages.css @@ -948,6 +948,33 @@ font-size: var(--font-size-xs); } +.platformStatusCell { + display: grid; + min-width: 0; + gap: 6px; +} + +.platformStatusCell strong, +.platformStatusCell small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.platformStatusCell small { + color: var(--muted-foreground); + font-size: var(--font-size-xs); +} + +.platformStatusToggle { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-strong); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); +} + .tableActions { display: flex; align-items: center; diff --git a/apps/web/src/styles/ui.css b/apps/web/src/styles/ui.css index 3cdc5cc..8862d59 100644 --- a/apps/web/src/styles/ui.css +++ b/apps/web/src/styles/ui.css @@ -152,6 +152,51 @@ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); } +.shSwitch { + position: relative; + display: inline-flex; + width: 36px; + height: 20px; + flex: 0 0 auto; + align-items: center; + border: 1px solid transparent; + border-radius: 999px; + background: #d1d5db; + box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.12); + cursor: pointer; + transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} + +.shSwitch[data-state="checked"] { + background: #059669; + box-shadow: inset 0 1px 2px rgba(6, 95, 70, 0.2), 0 0 0 3px rgba(16, 185, 129, 0.12); +} + +.shSwitch:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.shSwitch:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.shSwitchThumb { + display: block; + width: 16px; + height: 16px; + margin-left: 2px; + border-radius: 999px; + background: #fff; + box-shadow: 0 1px 3px rgba(16, 24, 40, 0.24); + transition: transform 0.16s ease; +} + +.shSwitch[data-state="checked"] .shSwitchThumb { + transform: translateX(16px); +} + .screenMessageViewport { position: fixed; z-index: 100; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 363e5a5..9af7b9b 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -97,6 +97,7 @@ export interface PlatformCreateInput { defaultDiscountFactor?: number; pricingRuleSetId?: string; priority?: number; + status?: string; } export interface PlatformModelBindingInput {