Files
easyai-ai-gateway/apps/web/src/pages/admin/PricingRulesPanel.tsx
T

320 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }));
}