Add platform status switch

This commit is contained in:
wangbo 2026-05-12 21:49:08 +08:00
parent 682a491d27
commit a76ab941bc
10 changed files with 195 additions and 5 deletions

View File

@ -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"
}

View File

@ -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,

View File

@ -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,

View File

@ -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';

View File

@ -0,0 +1,32 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
checked: boolean;
onCheckedChange?: (checked: boolean) => void;
}
export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ checked, className, disabled, onCheckedChange, ...props }, ref) => (
<button
aria-checked={checked}
className={cn('shSwitch', className)}
data-state={checked ? 'checked' : 'unchecked'}
disabled={disabled}
ref={ref}
role="switch"
{...props}
type="button"
onClick={(event) => {
props.onClick?.(event);
if (!event.defaultPrevented && !disabled) {
onCheckedChange?.(!checked);
}
}}
>
<span className="shSwitchThumb" />
</button>
),
);
Switch.displayName = 'Switch';

View File

@ -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<void>;
onSavePlatform: (input: PlatformWithModelsInput) => Promise<void>;
onSavePlatformDynamicPriority: (platformId: string, input: PlatformDynamicPriorityUpdateRequest) => Promise<void>;
onTogglePlatformStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => Promise<void>;
onSaveProvider: (input: CatalogProviderUpsertRequest, providerId?: string) => Promise<void>;
onSavePricingRuleSet: (input: PricingRuleSetUpsertRequest, ruleSetId?: string) => Promise<void>;
onSaveRunnerPolicy: (input: GatewayRunnerPolicyUpsertRequest) => Promise<void>;
@ -149,6 +151,7 @@ export function AdminPage(props: {
state={props.state}
onDeletePlatform={props.onDeletePlatform}
onSavePlatform={props.onSavePlatform}
onTogglePlatformStatus={props.onTogglePlatformStatus}
/>
)}
{props.section === 'realtimeLoad' && (

View File

@ -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<void>;
onSavePlatform: (input: PlatformWithModelsInput) => Promise<void>;
onTogglePlatformStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => Promise<void>;
}) {
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<IntegrationPlatform | null>(null);
const [pendingDeletePlatform, setPendingDeletePlatform] = useState<IntegrationPlatform | null>(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<PlatformWizardForm>(() => 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 (
<section className="pageStack">
<ScreenMessage message={validationMessage} variant="error" onClose={() => 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}
/>
) : (
<PlatformModelTable
@ -404,9 +420,11 @@ function PlatformTable(props: {
platforms: IntegrationPlatform[];
providerMap: Map<string, CatalogProvider>;
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 (
<TableRow key={platform.id}>
<TableCell>
@ -467,12 +487,21 @@ function PlatformTable(props: {
</TableCell>
<TableCell>{props.platformModelCount.get(platform.id) ?? 0}</TableCell>
<TableCell>
<span className="platformTableName">
<span className="platformStatusCell">
<span className="platformStatusToggle">
<Switch
aria-label={`${platformDisplayName(platform)} ${isEnabled ? '禁用' : '启用'}平台`}
checked={isEnabled}
disabled={isToggling}
onCheckedChange={(checked) => props.onToggleStatus(platform, checked ? 'enabled' : 'disabled')}
/>
<span>{isEnabled ? '启用' : '禁用'}</span>
</span>
<strong>
{platformCooldownMs > 0 ? (
<Badge variant="warning"></Badge>
) : (
<Badge variant={platform.status === 'enabled' ? 'success' : 'secondary'}>{platform.status}</Badge>
<Badge variant={isEnabled ? 'success' : 'destructive'}>{isEnabled ? '已启用' : '已禁用'}</Badge>
)}
</strong>
<small>{platformCooldownMs > 0 ? `剩余 ${formatCooldownRemaining(platformCooldownMs)}` : runtime}</small>

View File

@ -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;

View File

@ -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;

View File

@ -97,6 +97,7 @@ export interface PlatformCreateInput {
defaultDiscountFactor?: number;
pricingRuleSetId?: string;
priority?: number;
status?: string;
}
export interface PlatformModelBindingInput {