Files
easyai-ai-gateway/apps/web/src/pages/admin/PricingRulesPanel.tsx
T
wangbo de10875439 feat(admin): 优化后台运维与任务管理
完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。

新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。

验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
2026-07-25 01:32:49 +08:00

329 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, Search, Trash2, Video } from 'lucide-react';
import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, 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 className="compactAdminTableCard">
<CardContent className="compactAdminTableContent">
<div className="compactAdminToolbar catalogCompactToolbar">
<span className="platformSearchBox compactAdminSearch">
<Search size={15} />
<Input
aria-label="搜索定价规则"
value={query}
placeholder="规则名称、Key 或说明"
onChange={(event) => setQuery(event.target.value)}
/>
</span>
<div className="compactAdminToolbarActions">
<Button type="button" variant="outline" size="sm" disabled={!query} onClick={() => setQuery('')}>
<RotateCcw size={14} />
重置
</Button>
<Button type="button" size="sm" onClick={openCreateDialog}><Plus size={15} />新增规则</Button>
</div>
</div>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
<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 && (
<div className="emptyState"><strong>暂无匹配的定价规则</strong></div>
)}
</section>
</CardContent>
</Card>
<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,
isFree: rule.isFree,
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 ?? {},
effectiveFrom: rule.effectiveFrom,
effectiveTo: rule.effectiveTo,
};
}
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 }));
}