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,225 @@
import { useMemo, useState, type FormEvent } from 'react';
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
import type { LoadState } from '../../types';
import { KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor';
type PricingForm = {
ruleSetKey: string;
name: string;
description: string;
category: string;
currency: string;
status: string;
metadata: Record<string, unknown>;
rules: PricingRuleInput[];
};
const emptyForm: PricingForm = {
ruleSetKey: '',
name: '',
description: '',
category: 'custom',
currency: 'resource',
status: 'active',
metadata: {},
rules: [],
};
const categories = ['default', 'custom', 'provider', 'model'];
const statuses = ['active', 'deprecated', 'hidden'];
const currencies = ['resource', 'credit', 'cny', 'usd'];
export function PricingRulesPanel(props: {
message: string;
pricingRuleSets: PricingRuleSet[];
state: LoadState;
onDeletePricingRuleSet: (ruleSetId: string) => Promise<void>;
onSavePricingRuleSet: (input: PricingRuleSetUpsertRequest, ruleSetId?: string) => Promise<void>;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<PricingForm>(emptyForm);
const [localError, setLocalError] = useState('');
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return props.pricingRuleSets;
return props.pricingRuleSets.filter((item) => `${item.ruleSetKey} ${item.name} ${item.description ?? ''}`.toLowerCase().includes(keyword));
}, [props.pricingRuleSets, query]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSavePricingRuleSet(formToPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '定价规则保存失败');
}
}
function openCreateDialog() {
setEditingId('');
setForm(emptyForm);
setLocalError('');
setDialogOpen(true);
}
function editRuleSet(ruleSet: PricingRuleSet) {
setEditingId(ruleSet.id);
setForm(ruleSetToForm(ruleSet));
setLocalError('');
setDialogOpen(true);
}
function closeDialog() {
setEditingId('');
setForm(emptyForm);
setLocalError('');
setDialogOpen(false);
}
async function deleteRuleSet(ruleSet: PricingRuleSet) {
const confirmed = window.confirm(`确认删除定价规则 ${ruleSet.name}?已绑定的平台或模型会清空规则绑定。`);
if (!confirmed) return;
try {
await props.onDeletePricingRuleSet(ruleSet.id);
if (editingId === ruleSet.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '定价规则删除失败');
}
}
return (
<div className="pageStack">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<Badge variant="secondary">{props.pricingRuleSets.length}</Badge>
</CardHeader>
<CardContent>
<div className="providerToolbar">
<p></p>
<Button type="button" onClick={openCreateDialog}><Plus size={15} /></Button>
</div>
<div className="modelCatalogFilters">
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索规则名称 / key" />
</div>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
</CardContent>
</Card>
<section className="pricingRuleGrid">
{filtered.map((ruleSet) => (
<article className="pricingRuleCard" key={ruleSet.id}>
<header>
<div>
<strong>{ruleSet.name}</strong>
<span>{ruleSet.ruleSetKey}</span>
</div>
<Badge variant={ruleSet.status === 'active' ? 'success' : 'secondary'}>{ruleSet.status}</Badge>
</header>
<p>{ruleSet.description || '未填写说明'}</p>
<div className="providerCatalogMeta">
<span>{ruleSet.category}</span>
<span>{ruleSet.currency}</span>
<span>{ruleSet.rules?.length ?? 0} </span>
</div>
<div className="pricingRuleItems">
{(ruleSet.rules ?? []).slice(0, 5).map((rule) => (
<span key={rule.id || rule.ruleKey}>{rule.displayName || rule.resourceType}: {rule.basePrice}/{rule.unit}</span>
))}
</div>
<div className="providerCatalogActions">
<Button type="button" variant="outline" size="sm" onClick={() => editRuleSet(ruleSet)}><Pencil size={14} /></Button>
<Button type="button" variant="destructive" size="sm" onClick={() => void deleteRuleSet(ruleSet)}><Trash2 size={14} /></Button>
</div>
</article>
))}
{!filtered.length && (
<Card><CardContent className="emptyState"><strong></strong></CardContent></Card>
)}
</section>
<FormDialog
ariaLabel={editingId ? '编辑定价规则' : '新增定价规则'}
bodyClassName="pricingRuleFormBody"
className="pricingRuleDialog"
eyebrow={editingId ? 'Edit Pricing Rule Set' : 'New Pricing Rule Set'}
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>
</>
)}
open={dialogOpen}
title={editingId ? '编辑定价规则' : '新增定价规则'}
onClose={closeDialog}
onSubmit={submit}
>
<Label> Key<Input value={form.ruleSetKey} onChange={(event) => setForm({ ...form, ruleSetKey: event.target.value })} /></Label>
<Label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Label>
<Label className="spanTwo"><Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
<Label><Select value={form.category} onChange={(event) => setForm({ ...form, category: event.target.value })}>{categories.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
<Label><Select value={form.currency} onChange={(event) => setForm({ ...form, currency: event.target.value })}>{currencies.map((item) => <option value={item} key={item}>{item}</option>)}</Select></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>
<PricingRuleVisualEditor currency={form.currency} rules={form.rules} onChange={(rules) => setForm({ ...form, rules })} />
<KeyValueEditor className="spanTwo" title="规则集元数据" value={form.metadata} onChange={(metadata) => setForm({ ...form, metadata })} />
</FormDialog>
</div>
);
}
function ruleSetToForm(ruleSet: PricingRuleSet): PricingForm {
return {
ruleSetKey: ruleSet.ruleSetKey,
name: ruleSet.name,
description: ruleSet.description ?? '',
category: ruleSet.category,
currency: ruleSet.currency,
status: ruleSet.status,
metadata: ruleSet.metadata ?? {},
rules: (ruleSet.rules ?? []).map(ruleToInput),
};
}
function ruleToInput(rule: PricingRule): PricingRuleInput {
return {
ruleKey: rule.ruleKey,
displayName: rule.displayName,
resourceType: rule.resourceType,
unit: rule.unit,
basePrice: rule.basePrice,
currency: rule.currency,
baseWeight: rule.baseWeight ?? {},
dynamicWeight: rule.dynamicWeight ?? {},
calculatorType: rule.calculatorType,
dimensionSchema: rule.dimensionSchema ?? {},
formulaConfig: rule.formulaConfig ?? {},
priority: rule.priority,
status: rule.status,
metadata: rule.metadata ?? {},
};
}
function formToPayload(form: PricingForm): PricingRuleSetUpsertRequest {
const rules = form.rules.map((rule, index) => ({
...rule,
displayName: rule.displayName?.trim() || `${rule.resourceType} 计价`,
priority: rule.priority ?? (index + 1) * 10,
ruleKey: rule.ruleKey?.trim() || `${rule.resourceType}_${index + 1}`,
}));
if (!rules.length) throw new Error('计价规则至少需要一条');
return {
ruleSetKey: form.ruleSetKey.trim(),
name: form.name.trim(),
description: form.description.trim(),
category: form.category,
currency: form.currency,
status: form.status,
metadata: form.metadata,
rules,
};
}