feat(web): add reusable admin form dialog

This commit is contained in:
2026-05-09 20:15:35 +08:00
parent c0335bd5d0
commit a5e66e79cd
49 changed files with 6013 additions and 1108 deletions
@@ -0,0 +1,374 @@
import { useMemo, useState, type FormEvent } from 'react';
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
import type {
BaseModelCatalogItem,
BaseModelUpsertRequest,
CatalogProvider,
} from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type ModelForm = {
providerKey: string;
canonicalModelKey: string;
providerModelName: string;
modelType: string;
displayName: string;
status: string;
pricingVersion: string;
capabilitiesJson: string;
billingJson: string;
rateLimitJson: string;
metadataJson: string;
};
const statuses = ['active', 'deprecated', 'hidden'];
const fallbackTypes = [
'text_generate',
'image_generate',
'image_edit',
'image_analysis',
'video_generate',
'image_to_video',
'omni_video',
'text_embedding',
'text_to_speech',
'audio_generate',
'digital_human_generate',
'text_to_model',
'image_to_model',
'multiview_to_model',
'mesh_edit',
];
const emptyForm: ModelForm = {
providerKey: '',
canonicalModelKey: '',
providerModelName: '',
modelType: 'text_generate',
displayName: '',
status: 'active',
pricingVersion: '1',
capabilitiesJson: '{}',
billingJson: '{}',
rateLimitJson: '{}',
metadataJson: '{}',
};
export function BaseModelCatalogPanel(props: {
baseModels: BaseModelCatalogItem[];
message: string;
providers: CatalogProvider[];
state: LoadState;
onDeleteBaseModel: (baseModelId: string) => Promise<void>;
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<ModelForm>(emptyForm);
const [localError, setLocalError] = useState('');
const [query, setQuery] = useState('');
const [providerFilter, setProviderFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const providerNames = useMemo(
() => new Map(props.providers.map((item) => [item.providerKey, item.displayName])),
[props.providers],
);
const typeOptions = useMemo(
() => Array.from(new Set([...fallbackTypes, ...props.baseModels.map((item) => item.modelType).filter(Boolean)])),
[props.baseModels],
);
const providerOptions = useMemo(
() => Array.from(new Set([...props.providers.map((item) => item.providerKey), ...props.baseModels.map((item) => item.providerKey)])),
[props.baseModels, props.providers],
);
const filteredModels = useMemo(() => {
const keyword = query.trim().toLowerCase();
return props.baseModels.filter((item) => {
const matchesProvider = providerFilter === 'all' || item.providerKey === providerFilter;
const matchesType = typeFilter === 'all' || readModelTypes(item).includes(typeFilter);
const text = `${item.displayName} ${item.providerModelName} ${item.canonicalModelKey}`.toLowerCase();
return matchesProvider && matchesType && (!keyword || text.includes(keyword));
});
}, [props.baseModels, providerFilter, query, typeFilter]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSaveBaseModel(formToPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型保存失败');
}
}
function openCreateDialog() {
const providerKey = providerOptions[0] ?? '';
setEditingId('');
setForm({ ...emptyForm, providerKey, canonicalModelKey: providerKey ? `${providerKey}:` : '' });
setDialogOpen(true);
}
function editModel(model: BaseModelCatalogItem) {
setEditingId(model.id);
setForm(modelToForm(model));
setDialogOpen(true);
}
function closeDialog() {
setEditingId('');
setForm(emptyForm);
setLocalError('');
setDialogOpen(false);
}
async function deleteModel(model: BaseModelCatalogItem) {
const confirmed = window.confirm(`确认删除基准模型 ${model.displayName}`);
if (!confirmed) return;
try {
await props.onDeleteBaseModel(model.id);
if (editingId === model.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型删除失败');
}
}
return (
<div className="pageStack">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<Badge variant="secondary">{props.baseModels.length}</Badge>
</CardHeader>
<CardContent>
<div className="providerToolbar">
<p> server-main integration-platform</p>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</div>
<div className="modelCatalogFilters">
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索模型名称 / canonical key" />
<Select value={providerFilter} onChange={(event) => setProviderFilter(event.target.value)}>
<option value="all"></option>
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
</Select>
<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
<option value="all"></option>
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</div>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
</CardContent>
</Card>
<section className="baseModelGrid">
{filteredModels.map((model) => (
<ModelCard
key={model.id}
model={model}
providerName={providerNames.get(model.providerKey)}
onDelete={() => void deleteModel(model)}
onEdit={() => editModel(model)}
/>
))}
{!filteredModels.length && (
<Card>
<CardContent className="emptyState">
<strong></strong>
</CardContent>
</Card>
)}
</section>
<FormDialog
ariaLabel={editingId ? '编辑基准模型' : '新增基准模型'}
className="baseModelDialog"
eyebrow={editingId ? 'Edit Base Model' : 'New Base Model'}
footer={(
<>
<Button type="submit" disabled={props.state === 'loading'}>
{editingId ? <Pencil size={15} /> : <Plus size={15} />}
{editingId ? '保存修改' : '新增模型'}
</Button>
<Button type="button" variant="outline" onClick={closeDialog}>
<RotateCcw size={15} />
</Button>
</>
)}
formClassName="baseModelForm"
open={dialogOpen}
title={editingId ? '编辑基准模型' : '新增基准模型'}
onClose={closeDialog}
onSubmit={submit}
>
<Label>
Provider
<Select value={form.providerKey} onChange={(event) => setForm({ ...form, providerKey: event.target.value })}>
<option value=""></option>
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
</Select>
</Label>
<Label>
<Select value={form.modelType} onChange={(event) => setForm({ ...form, modelType: event.target.value })}>
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</Label>
<Label>
<Input value={form.providerModelName} onChange={(event) => setForm({ ...form, providerModelName: event.target.value })} />
</Label>
<Label>
<Input value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} />
</Label>
<Label className="spanTwo">
Canonical Key
<Input value={form.canonicalModelKey} onChange={(event) => setForm({ ...form, canonicalModelKey: event.target.value })} />
</Label>
<Label>
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
{statuses.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</Label>
<Label>
<Input value={form.pricingVersion} onChange={(event) => setForm({ ...form, pricingVersion: event.target.value })} />
</Label>
<JsonField label="能力 JSON" value={form.capabilitiesJson} onChange={(value) => setForm({ ...form, capabilitiesJson: value })} />
<JsonField label="基准计费 JSON" value={form.billingJson} onChange={(value) => setForm({ ...form, billingJson: value })} />
<JsonField label="限流 JSON" value={form.rateLimitJson} onChange={(value) => setForm({ ...form, rateLimitJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
</div>
);
}
function ModelCard(props: {
model: BaseModelCatalogItem;
providerName?: string;
onDelete: () => void;
onEdit: () => void;
}) {
const types = readModelTypes(props.model);
return (
<article className="baseModelCard">
<ModelIcon model={props.model} />
<div className="baseModelCardBody">
<div>
<strong>{props.model.displayName}</strong>
<span>{props.model.providerModelName}</span>
</div>
<div className="providerCatalogMeta">
<Badge variant={props.model.status === 'active' ? 'success' : 'secondary'}>{props.model.status}</Badge>
<span>{props.providerName ?? props.model.providerKey}</span>
<span>{props.model.canonicalModelKey}</span>
</div>
<div className="modelAbilityChips">
{types.slice(0, 5).map((item) => <span key={item}>{item}</span>)}
{types.length > 5 && <span>+{types.length - 5}</span>}
</div>
</div>
<div className="providerCatalogActions">
<Button type="button" variant="outline" size="sm" onClick={props.onEdit}>
<Pencil size={14} />
</Button>
<Button type="button" variant="destructive" size="sm" onClick={props.onDelete}>
<Trash2 size={14} />
</Button>
</div>
</article>
);
}
function JsonField(props: { label: string; value: string; onChange: (value: string) => void }) {
return (
<Label className="spanTwo">
{props.label}
<Textarea value={props.value} onChange={(event) => props.onChange(event.target.value)} rows={5} spellCheck={false} />
</Label>
);
}
function ModelIcon(props: { model: BaseModelCatalogItem }) {
const iconPath = readString(props.model.metadata?.iconPath) || readString(readRecord(props.model.metadata?.rawModel)?.icon_path);
if (iconPath) {
return (
<div className="providerCatalogLogo">
<img src={iconPath} alt="" />
</div>
);
}
return <div className="providerCatalogLogo">{props.model.providerKey.slice(0, 2).toUpperCase()}</div>;
}
function modelToForm(model: BaseModelCatalogItem): ModelForm {
return {
providerKey: model.providerKey,
canonicalModelKey: model.canonicalModelKey,
providerModelName: model.providerModelName,
modelType: model.modelType,
displayName: model.displayName,
status: model.status,
pricingVersion: String(model.pricingVersion || 1),
capabilitiesJson: stringifyJson(model.capabilities),
billingJson: stringifyJson(model.baseBillingConfig),
rateLimitJson: stringifyJson(model.defaultRateLimitPolicy),
metadataJson: stringifyJson(model.metadata),
};
}
function formToPayload(form: ModelForm): BaseModelUpsertRequest {
return {
providerKey: form.providerKey.trim(),
canonicalModelKey: form.canonicalModelKey.trim() || undefined,
providerModelName: form.providerModelName.trim(),
modelType: form.modelType.trim(),
displayName: form.displayName.trim() || form.providerModelName.trim(),
capabilities: parseJsonObject(form.capabilitiesJson, '能力 JSON'),
baseBillingConfig: parseJsonObject(form.billingJson, '基准计费 JSON'),
defaultRateLimitPolicy: parseJsonObject(form.rateLimitJson, '限流 JSON'),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
pricingVersion: Number.parseInt(form.pricingVersion, 10) || 1,
status: form.status,
};
}
function readModelTypes(model: BaseModelCatalogItem) {
const values = model.metadata?.originalTypes ?? model.capabilities?.originalTypes;
if (Array.isArray(values)) return values.map(String);
return [model.modelType].filter(Boolean);
}
function parseJsonObject(value: string, label: string) {
try {
const parsed = value.trim() ? JSON.parse(value) : {};
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error(`${label} 必须是 JSON object`);
}
return parsed as Record<string, unknown>;
} catch (err) {
if (err instanceof Error && err.message.includes('必须是')) throw err;
throw new Error(`${label} 格式不正确`);
}
}
function stringifyJson(value?: Record<string, unknown>) {
return JSON.stringify(value ?? {}, null, 2);
}
function readString(value: unknown) {
return typeof value === 'string' ? value : '';
}
function readRecord(value: unknown) {
return value && !Array.isArray(value) && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
}