320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
import { useMemo, useState, type FormEvent } from 'react';
|
||
import { FileText, Image, Pencil, Plus, RotateCcw, Trash2, Video } from 'lucide-react';
|
||
import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select } from '../../components/ui';
|
||
import type { LoadState } from '../../types';
|
||
import { unitLabel } from './PricingEditorControls';
|
||
import { createDefaultPricingRules, 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 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>(() => createEmptyForm());
|
||
const [localError, setLocalError] = useState('');
|
||
const [query, setQuery] = useState('');
|
||
const [pendingDeleteRuleSet, setPendingDeleteRuleSet] = useState<PricingRuleSet | null>(null);
|
||
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(createEmptyForm());
|
||
setLocalError('');
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function editRuleSet(ruleSet: PricingRuleSet) {
|
||
setEditingId(ruleSet.id);
|
||
setForm(ruleSetToForm(ruleSet));
|
||
setLocalError('');
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function closeDialog() {
|
||
setEditingId('');
|
||
setForm(createEmptyForm());
|
||
setLocalError('');
|
||
setDialogOpen(false);
|
||
}
|
||
|
||
function updateCurrency(currency: string) {
|
||
setForm({ ...form, currency, rules: ensureRules(form.rules, currency) });
|
||
}
|
||
|
||
async function deleteRuleSet(ruleSet: PricingRuleSet) {
|
||
if (isDefaultRuleSet(ruleSet)) {
|
||
setLocalError('默认计价规则不能删除。');
|
||
return;
|
||
}
|
||
try {
|
||
await props.onDeletePricingRuleSet(ruleSet.id);
|
||
setPendingDeleteRuleSet(null);
|
||
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>{pricingRuleSummaries(ruleSet).length} 条计价规则</span>
|
||
</div>
|
||
<div className="pricingRuleItems">
|
||
{pricingRuleSummaries(ruleSet).map((item) => (
|
||
<span key={item.label}>
|
||
<strong>{pricingRuleSummaryIcon(item.kind)}{item.label}</strong>
|
||
<em>{item.value}</em>
|
||
</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"
|
||
disabled={isDefaultRuleSet(ruleSet)}
|
||
title={isDefaultRuleSet(ruleSet) ? '默认计价规则不能删除' : undefined}
|
||
onClick={() => setPendingDeleteRuleSet(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) => updateCurrency(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>
|
||
<ConfirmDialog
|
||
confirmLabel="删除规则"
|
||
description="已绑定的平台或模型会清空规则绑定,删除后不可恢复。"
|
||
loading={props.state === 'loading'}
|
||
open={Boolean(pendingDeleteRuleSet)}
|
||
title={`确认删除定价规则 ${pendingDeleteRuleSet?.name ?? ''}?`}
|
||
onCancel={() => setPendingDeleteRuleSet(null)}
|
||
onConfirm={() => pendingDeleteRuleSet ? deleteRuleSet(pendingDeleteRuleSet) : undefined}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function createEmptyForm(currency = 'resource'): PricingForm {
|
||
return {
|
||
ruleSetKey: '',
|
||
name: '',
|
||
description: '',
|
||
category: 'custom',
|
||
currency,
|
||
status: 'active',
|
||
metadata: {},
|
||
rules: createDefaultPricingRules(currency),
|
||
};
|
||
}
|
||
|
||
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 = ensureRules(form.rules, form.currency).map((rule, index) => ({
|
||
...rule,
|
||
displayName: rule.displayName?.trim() || `${rule.resourceType} 计价`,
|
||
priority: rule.priority ?? (index + 1) * 10,
|
||
ruleKey: rule.ruleKey?.trim() || `${rule.resourceType}_${index + 1}`,
|
||
}));
|
||
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,
|
||
};
|
||
}
|
||
|
||
type PricingRuleSummary = {
|
||
kind: 'text' | 'image' | 'video' | 'custom';
|
||
label: string;
|
||
value: string;
|
||
};
|
||
|
||
function pricingRuleSummaries(ruleSet: PricingRuleSet): PricingRuleSummary[] {
|
||
const rules = ruleSet.rules ?? [];
|
||
const textTotal = rules.find((rule) => rule.resourceType === 'text_total' || rule.ruleKey === 'text');
|
||
const textInput = rules.find((rule) => rule.resourceType === 'text_input');
|
||
const textCachedInput = rules.find(
|
||
(rule) => rule.resourceType === 'text_cached_input',
|
||
);
|
||
const textOutput = rules.find((rule) => rule.resourceType === 'text_output');
|
||
const image = rules.find((rule) => rule.resourceType === 'image');
|
||
const video = rules.find((rule) => rule.resourceType === 'video');
|
||
const items: PricingRuleSummary[] = [];
|
||
if (textTotal || textInput || textCachedInput || textOutput) {
|
||
const inputPrice = Number(textTotal?.formulaConfig?.inputTokenPrice ?? textInput?.basePrice ?? textTotal?.basePrice ?? 0);
|
||
const cachedInputPrice = Number(
|
||
textTotal?.formulaConfig?.cachedInputTokenPrice ??
|
||
textTotal?.formulaConfig?.inputCacheHitTokenPrice ??
|
||
textCachedInput?.basePrice ??
|
||
inputPrice,
|
||
);
|
||
const outputPrice = Number(textTotal?.formulaConfig?.outputTokenPrice ?? textOutput?.basePrice ?? 0);
|
||
items.push({
|
||
kind: 'text',
|
||
label: '文本',
|
||
value: `输入 ${formatPrice(inputPrice)} / 缓存输入 ${formatPrice(cachedInputPrice)} / 输出 ${formatPrice(outputPrice)} / 1K Token`,
|
||
});
|
||
}
|
||
if (image) items.push({ kind: 'image', label: '图像', value: `${formatPrice(image.basePrice)} / ${unitLabel(image.unit)}` });
|
||
if (video) items.push({ kind: 'video', label: '视频', value: `${formatPrice(video.basePrice)} / ${unitLabel(video.unit)}` });
|
||
if (items.length) return items;
|
||
return rules.slice(0, 3).map((rule) => ({
|
||
kind: 'custom',
|
||
label: rule.displayName || rule.resourceType,
|
||
value: `${formatPrice(rule.basePrice)} / ${unitLabel(rule.unit)}`,
|
||
}));
|
||
}
|
||
|
||
function pricingRuleSummaryIcon(kind: PricingRuleSummary['kind']) {
|
||
if (kind === 'image') return <Image size={14} />;
|
||
if (kind === 'video') return <Video size={14} />;
|
||
return <FileText size={14} />;
|
||
}
|
||
|
||
function formatPrice(value: number) {
|
||
if (!Number.isFinite(value)) return '0';
|
||
return Number(value.toFixed(4)).toString();
|
||
}
|
||
|
||
function isDefaultRuleSet(ruleSet: PricingRuleSet) {
|
||
return ruleSet.ruleSetKey === 'default-multimodal-v1';
|
||
}
|
||
|
||
function ensureRules(rules: PricingRuleInput[], currency: string): PricingRuleInput[] {
|
||
const sourceRules = rules.length ? rules : createDefaultPricingRules(currency);
|
||
return sourceRules.map((rule) => ({ ...rule, currency }));
|
||
}
|