easyai-ai-gateway/apps/web/src/pages/admin/PricingRuleVisualEditor.tsx

569 lines
24 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 ReactNode } from 'react';
import { ListChecks, Plus, Trash2 } from 'lucide-react';
import type { PricingRuleInput } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Input, Label, Select } from '../../components/ui';
import { cn } from '../../lib/utils';
import { calculatorLabel, PricingFormulaNote, unitLabel } from './PricingEditorControls';
type RecordValue = Record<string, unknown>;
type ModeKey = 'text' | 'image' | 'video';
type ParameterGroup = {
key: string;
title: string;
defaults: RecordValue;
labels?: Record<string, string>;
locked?: boolean;
};
type ModeDefinition = {
key: ModeKey;
label: string;
formula: string;
match: (rule: PricingRuleInput) => boolean;
templates: (currency: string) => PricingRuleInput[];
parameterGroups: ParameterGroup[];
};
const statuses = ['active', 'deprecated', 'hidden'];
const modeDefinitions: ModeDefinition[] = [
{
key: 'text',
label: '文本',
formula: '一次对话扣费 = 输入 Token / 1000 × 输入扣费单价 + 输出 Token / 1000 × 输出扣费单价。',
match: (rule) => rule.resourceType.startsWith('text_'),
templates: (currency) => [createTextRule(currency, 0.01, 0.03)],
parameterGroups: [],
},
{
key: 'image',
label: '图像',
formula: '扣费 = 基础单价 × 数量 × 分辨率参数 × 图像质量参数。图像质量按 OpenAI GPT Image 的 low / medium / high 三档配置。',
match: (rule) => rule.resourceType === 'image' || rule.resourceType === 'image_edit',
templates: (currency) => [createRule('image', '图像', 'image', 'image', 10, currency, 'unit_weight', 'count * base_price * resolution_factor * quality_factor', {
resolutionFactors: { '1K': 1, '2K': 1.5, '3K': 1.75, '4K': 2, '8K': 4 },
qualityFactors: { low: 0.5, medium: 1, high: 1.5 },
}, { dimensions: ['count', 'resolution', 'quality'], defaults: { count: 1, resolution: '1K', quality: 'medium' } })],
parameterGroups: [
{ key: 'resolutionFactors', title: '分辨率', defaults: { '1K': 1, '2K': 1.5, '3K': 1.75, '4K': 2, '8K': 4 }, locked: true },
{ key: 'qualityFactors', title: '图像质量', defaults: { low: 0.5, medium: 1, high: 1.5 }, labels: { low: '低质量 (low)', medium: '标准质量 (medium)', high: '高质量 (high)' }, locked: true },
],
},
{
key: 'video',
label: '视频',
formula: '扣费 = 基础单价 × 生成时长单位 × 数量 × 分辨率、音频、参考视频、音色等计费参数。',
match: (rule) => rule.resourceType === 'video',
templates: (currency) => [
createRule('video', '视频', 'video', '5s', 100, currency, 'duration_weight', 'count * ceil(duration_seconds / 5) * base_price * resolution_factor * audio_factor * reference_video_factor * voice_specified_factor', {
resolutionWeights: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 },
audioWeights: { true: 2, false: 1 },
referenceVideoWeights: { true: 1.5, false: 1 },
voiceSpecifiedWeights: { true: 1.2, false: 1 },
}, { dimensions: ['count', 'duration_seconds', 'resolution', 'audio', 'reference_video', 'voice_specified'], defaults: { count: 1, duration_seconds: 5, resolution: '720p', audio: false, reference_video: false, voice_specified: false } }),
],
parameterGroups: [
{ key: 'resolutionWeights', title: '分辨率', defaults: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 }, locked: true },
{ key: 'audioWeights', title: '生成声音', defaults: { true: 2, false: 1 }, labels: { true: '生成声音', false: '不生成声音' }, locked: true },
{ key: 'referenceVideoWeights', title: '参考视频', defaults: { true: 1.5, false: 1 }, labels: { true: '有参考视频', false: '无参考视频' }, locked: true },
{ key: 'voiceSpecifiedWeights', title: '指定音色', defaults: { true: 1.2, false: 1 }, labels: { true: '指定音色', false: '不指定音色' }, locked: true },
],
},
];
export function PricingRuleVisualEditor(props: {
currency: string;
rules: PricingRuleInput[];
onChange: (rules: PricingRuleInput[]) => void;
}) {
const [activeMode, setActiveMode] = useState<ModeKey>('text');
const mode = modeDefinitions.find((item) => item.key === activeMode) ?? modeDefinitions[0];
const rawActiveRules = useMemo(() => props.rules.filter(mode.match), [mode, props.rules]);
const activeRules = useMemo(() => {
if (mode.key === 'text' && rawActiveRules.length) return [completeRule(mergeTextRules(rawActiveRules, props.currency), mode)];
if (mode.key === 'image' && rawActiveRules.length) return [completeRule(mergeTemplateRules(mode.templates(props.currency), rawActiveRules)[0], mode)];
return rawActiveRules.map((rule) => completeRule(rule, mode));
}, [mode.key, props.currency, rawActiveRules]);
const modeCounts = useMemo(() => modeRuleCounts(props.rules), [props.rules]);
const modeSummary = useMemo(() => summarizePricingMode(mode, activeRules), [activeRules, mode]);
function replaceModeRules(nextRules: PricingRuleInput[]) {
props.onChange([...props.rules.filter((rule) => !mode.match(rule)), ...nextRules]);
}
return (
<section className="pricingModeEditor spanTwo">
<div className="pricingModeHeader">
<div>
<strong></strong>
<span></span>
</div>
</div>
<div className="pricingModeWorkbench">
<aside className="pricingModeSidebar">
<div className="pricingModeSidebarTitle">
<ListChecks size={15} />
<strong></strong>
</div>
<div className="pricingModeList">
{modeDefinitions.map((item) => (
<button data-active={item.key === activeMode} key={item.key} type="button" onClick={() => setActiveMode(item.key)}>
<span>
<strong>{item.label}</strong>
<small>{modeCounts[item.key] ?? 0} </small>
</span>
</button>
))}
</div>
<div className="pricingModeSummary">
<strong></strong>
<p>{modeSummary}</p>
</div>
</aside>
<div className="pricingModePanel">
{activeRules.length ? (
activeRules.map((rule, index) => (
<ModeRuleEditor
key={`${rule.ruleKey ?? rule.resourceType}-${index}`}
mode={mode}
rule={rule}
onChange={(nextRule) => replaceModeRules(activeRules.map((item, itemIndex) => (itemIndex === index ? nextRule : item)))}
onDelete={() => replaceModeRules(activeRules.filter((_, itemIndex) => itemIndex !== index))}
/>
))
) : (
<div className="pricingModeEmpty">
<strong>{mode.label} </strong>
<Button type="button" onClick={() => replaceModeRules(mode.templates(props.currency))}><Plus size={15} /></Button>
</div>
)}
</div>
</div>
</section>
);
}
export function createDefaultPricingRules(currency: string): PricingRuleInput[] {
return modeDefinitions.flatMap((mode) => mode.templates(currency).map((rule) => completeRule(rule, mode)));
}
function modeRuleCounts(rules: PricingRuleInput[]): Record<ModeKey, number> {
return Object.fromEntries(
modeDefinitions.map((mode) => [mode.key, mode.key === 'text' && rules.some(mode.match) ? 1 : rules.filter(mode.match).length]),
) as Record<ModeKey, number>;
}
function summarizePricingMode(mode: ModeDefinition, rules: PricingRuleInput[]) {
if (!rules.length) return `${mode.label}暂无计价配置,可一键添加默认规则。`;
const rule = rules[0];
if (mode.key === 'text') {
const prices = textPrices(rule);
return `文本按输入/输出 Token 分别计费,输入 ${prices.inputTokenPrice}/${unitLabel(rule.unit)},输出 ${prices.outputTokenPrice}/${unitLabel(rule.unit)}`;
}
const groupSummaries = mode.parameterGroups
.map((group) => summarizeWeightGroup(group, readGroup(rule.dynamicWeight, group)))
.filter(Boolean);
const details = groupSummaries.length ? `\n${groupSummaries.join('\n')}` : '';
return `${mode.label}${unitLabel(rule.unit)} 计费,基础单价 ${rule.basePrice},计算方式为${calculatorLabel(rule.calculatorType)}${details}`;
}
function summarizeWeightGroup(group: ParameterGroup, value: RecordValue) {
const items = Object.entries(value)
.map(([key, weight]) => `${summaryWeightLabel(group, key)} ×${scalarToString(weight)}`)
.join('、');
return items ? `${group.title}${items}` : '';
}
function summaryWeightLabel(group: ParameterGroup, key: string) {
const label = group.labels?.[key] ?? key;
return label.replace(/\s*\([^)]*\)\s*$/, '');
}
function ModeRuleEditor(props: {
mode: ModeDefinition;
rule: PricingRuleInput;
onChange: (rule: PricingRuleInput) => void;
onDelete: () => void;
}) {
const rule = props.rule;
function patch(patchValue: Partial<PricingRuleInput>) {
props.onChange(completeRule({ ...rule, ...patchValue }, props.mode));
}
function patchDynamicWeight(key: string, value: RecordValue) {
patch({ dynamicWeight: { ...(rule.dynamicWeight ?? {}), [key]: value } });
}
if (props.mode.key === 'text') {
return <TextRuleEditor formula={props.mode.formula} rule={rule} onChange={props.onChange} onDelete={props.onDelete} />;
}
return (
<article className="pricingModeRule">
<header>
<div>
<Badge variant="secondary">{rule.resourceType}</Badge>
<strong>{rule.displayName || props.mode.label}</strong>
<span>{rule.ruleKey}</span>
</div>
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} /></Button>
</header>
<PricingFormRows>
<PricingFormRow label="展示名称">
<Input size="sm" value={rule.displayName ?? ''} onChange={(event) => patch({ displayName: event.target.value })} />
</PricingFormRow>
<PricingFormRow label="状态">
<Select size="sm" value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
</PricingFormRow>
<PricingFormRow label={`基础单价/${unitLabel(rule.unit)}`}>
<Input size="sm" min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
</PricingFormRow>
<PricingReadonlyRow label="计价单位" value={unitLabel(rule.unit)} />
<PricingReadonlyRow label="计算方式" value={calculatorLabel(rule.calculatorType)} />
</PricingFormRows>
<PricingFormulaNote>{props.mode.formula}</PricingFormulaNote>
<div className="pricingWeightStack">
{props.mode.parameterGroups.map((group) => (
<WeightTable
key={group.key}
title={group.title}
value={readGroup(rule.dynamicWeight, group)}
labels={group.labels}
locked={group.locked}
onChange={(value) => patchDynamicWeight(group.key, value)}
/>
))}
{Object.entries(rule.dynamicWeight ?? {}).filter(([key]) => !props.mode.parameterGroups.some((group) => group.key === key)).map(([key, value]) => (
<WeightTable key={key} editableTitle title={key} value={isPlainObject(value) ? value as RecordValue : { value }} onChange={(nextValue, nextTitle) => {
const dynamicWeight = { ...(rule.dynamicWeight ?? {}) };
delete dynamicWeight[key];
dynamicWeight[nextTitle || key] = nextValue;
patch({ dynamicWeight });
}} />
))}
</div>
</article>
);
}
function TextRuleEditor(props: {
formula: string;
rule: PricingRuleInput;
onChange: (rule: PricingRuleInput) => void;
onDelete: () => void;
}) {
const prices = textPrices(props.rule);
function updatePrices(inputTokenPrice: number, outputTokenPrice: number) {
props.onChange({
...props.rule,
basePrice: inputTokenPrice,
displayName: props.rule.displayName || '文本',
resourceType: 'text_total',
ruleKey: 'text',
unit: '1k_tokens',
formulaConfig: {
...(props.rule.formulaConfig ?? {}),
formula: textFormula,
inputTokenPrice,
outputTokenPrice,
},
dimensionSchema: {
...(props.rule.dimensionSchema ?? {}),
metrics: ['input_tokens', 'output_tokens'],
unitScale: 1000,
},
});
}
return (
<article className="pricingModeRule pricingTextRule">
<header>
<div>
<Badge variant="secondary">text_total</Badge>
<strong>{props.rule.displayName || '文本'}</strong>
<span>text</span>
</div>
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} /></Button>
</header>
<PricingFormRows>
<PricingFormRow label="展示名称">
<Input size="sm" value={props.rule.displayName ?? '文本'} onChange={(event) => props.onChange({ ...props.rule, displayName: event.target.value, ruleKey: 'text' })} />
</PricingFormRow>
<PricingFormRow label="状态">
<Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
</PricingFormRow>
<PricingFormRow label="输入单价/1K tokens">
<Input size="sm" min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} />
</PricingFormRow>
<PricingFormRow label="输出单价/1K tokens">
<Input size="sm" min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} />
</PricingFormRow>
<PricingReadonlyRow label="计价单位" value={unitLabel(props.rule.unit)} />
<PricingReadonlyRow label="计算方式" value={calculatorLabel(props.rule.calculatorType)} />
</PricingFormRows>
<PricingFormulaNote>{props.formula}</PricingFormulaNote>
</article>
);
}
function PricingFormRows(props: { children: ReactNode }) {
return <div className="pricingFormRows">{props.children}</div>;
}
function PricingFormRow(props: { children: ReactNode; label: string }) {
return (
<div className="pricingFormRow">
<strong>{props.label}</strong>
{props.children}
</div>
);
}
function PricingReadonlyRow(props: { label: string; value: string }) {
return (
<div className="pricingFormRow">
<strong>{props.label}</strong>
<span className="pricingFormReadonly">{props.value}</span>
</div>
);
}
function WeightTable(props: {
editableTitle?: boolean;
labels?: Record<string, string>;
locked?: boolean;
title: string;
value: RecordValue;
onChange: (value: RecordValue, title?: string) => void;
}) {
const rows: Array<[string, unknown]> = Object.entries(props.value ?? {});
function updateRows(nextRows: Array<[string, unknown]>) {
props.onChange(rowsToRecord(nextRows), props.title);
}
return (
<section className="pricingWeightTable">
<header>
{props.editableTitle ? (
<Input value={props.title} onChange={(event) => props.onChange(props.value, event.target.value)} />
) : (
<strong>{props.title}</strong>
)}
</header>
<div className="pricingWeightRows">
{rows.map(([key, value], index) => (
<div className={cn('pricingWeightRow', props.locked && 'locked')} key={`${key}-${index}`}>
{props.locked ? (
<span className="pricingFactorLabel">{props.labels?.[key] ?? key}</span>
) : (
<Input value={key} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [event.target.value, value] : row)))} />
)}
<Input value={scalarToString(value)} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [key, parseScalar(event.target.value)] : row)))} />
{!props.locked && <Button type="button" variant="ghost" size="icon" onClick={() => updateRows(rows.filter((_, rowIndex) => rowIndex !== index))}><Trash2 size={14} /></Button>}
</div>
))}
{!props.locked && <Button className="pricingInlineAdd" type="button" variant="outline" size="sm" onClick={() => updateRows([...rows, [nextKey(props.value, 'option'), 1]])}><Plus size={14} /></Button>}
</div>
</section>
);
}
export function KeyValueEditor(props: {
className?: string;
title: string;
value: RecordValue;
onChange: (value: RecordValue) => void;
}) {
return (
<div className={cn('pricingWeightTable', props.className)}>
<header><strong>{props.title}</strong></header>
<WeightTable title={props.title} value={props.value} onChange={props.onChange} />
</div>
);
}
function completeRule(rule: PricingRuleInput, mode: ModeDefinition): PricingRuleInput {
const template = mode.templates(rule.currency ?? 'resource')[0] ?? rule;
return {
...template,
...rule,
ruleKey: mode.key,
displayName: normalizeDisplayName(rule.displayName || template.displayName, mode),
resourceType: template.resourceType,
dimensionSchema: template.dimensionSchema,
formulaConfig: {
...(rule.formulaConfig ?? {}),
formula: template.formulaConfig?.formula,
},
dynamicWeight: normalizeDynamicWeight(rule.dynamicWeight, mode),
};
}
function normalizeDisplayName(value: string | undefined, mode: ModeDefinition) {
const legacyNames: Record<ModeKey, string[]> = {
text: ['文本输入 Token', '文本输出 Token', '文本生成 Token', '文本生成'],
image: ['图像生成', '图像编辑'],
video: ['视频生成'],
};
if (!value || legacyNames[mode.key].includes(value)) return mode.label;
return value;
}
function normalizeDynamicWeight(value: PricingRuleInput['dynamicWeight'], mode: ModeDefinition): RecordValue {
const source = isPlainObject(value) ? { ...(value as RecordValue) } : {};
if (mode.key === 'image') {
if (!isPlainObject(source.resolutionFactors) && isPlainObject(source.resolutionWeights)) {
source.resolutionFactors = source.resolutionWeights;
}
if (!isPlainObject(source.qualityFactors) && isPlainObject(source.qualityWeights)) {
source.qualityFactors = source.qualityWeights;
}
delete source.modeFactors;
delete source.modeWeights;
delete source.referenceImageWeights;
delete source.resolutionWeights;
delete source.qualityWeights;
}
const groupKeys = new Set(mode.parameterGroups.map((group) => group.key));
const defaults = Object.fromEntries(mode.parameterGroups.map((group) => [
group.key,
mergeGroupDefaults(group, isPlainObject(source[group.key]) ? source[group.key] as RecordValue : {}),
]));
const custom = Object.fromEntries(Object.entries(source).filter(([key]) => !groupKeys.has(key)));
return { ...defaults, ...custom };
}
function mergeGroupDefaults(group: ParameterGroup, source: RecordValue): RecordValue {
if (!group.locked) return { ...group.defaults, ...source };
return Object.fromEntries(Object.entries(group.defaults).map(([key, defaultValue]) => [
key,
key in source ? source[key] : defaultValue,
]));
}
function mergeTemplateRules(templates: PricingRuleInput[], rules: PricingRuleInput[]) {
if (templates.length === 1) return rules.length ? [{ ...templates[0], ...rules[0] }] : templates;
const byKey = new Map(templates.map((rule) => [rule.ruleKey ?? rule.resourceType, rule]));
for (const rule of rules) {
byKey.set(rule.ruleKey ?? rule.resourceType, rule);
}
return Array.from(byKey.values());
}
function mergeTextRules(rules: PricingRuleInput[], currency: string): PricingRuleInput {
const totalRule = rules.find((rule) => rule.resourceType === 'text_total');
const inputRule = rules.find((rule) => rule.resourceType === 'text_input');
const outputRule = rules.find((rule) => rule.resourceType === 'text_output');
const source = totalRule ?? inputRule ?? outputRule ?? createTextRule(currency, 0.01, 0.03);
return createTextRule(
source.currency ?? currency,
Number(source.formulaConfig?.inputTokenPrice ?? inputRule?.basePrice ?? source.basePrice ?? 0.01),
Number(source.formulaConfig?.outputTokenPrice ?? outputRule?.basePrice ?? 0.03),
source,
);
}
function readGroup(value: RecordValue | undefined, group: ModeDefinition['parameterGroups'][number]) {
return isPlainObject(value?.[group.key]) ? value?.[group.key] as RecordValue : group.defaults;
}
const textFormula = 'input_tokens / 1000 * input_token_price + output_tokens / 1000 * output_token_price';
function createTextRule(currency: string, inputTokenPrice: number, outputTokenPrice: number, source?: PricingRuleInput): PricingRuleInput {
return {
...(source ?? {}),
ruleKey: 'text',
displayName: normalizeDisplayName(source?.displayName, modeDefinitions[0]),
resourceType: 'text_total',
unit: '1k_tokens',
basePrice: inputTokenPrice,
currency,
calculatorType: 'token_usage',
baseWeight: {},
dynamicWeight: {},
dimensionSchema: {
metrics: ['input_tokens', 'output_tokens'],
unitScale: 1000,
...(source?.dimensionSchema ?? {}),
},
formulaConfig: {
...(source?.formulaConfig ?? {}),
formula: textFormula,
inputTokenPrice,
outputTokenPrice,
},
priority: source?.priority ?? 100,
status: source?.status ?? 'active',
metadata: source?.metadata ?? {},
};
}
function textPrices(rule: PricingRuleInput) {
return {
inputTokenPrice: Number(rule.formulaConfig?.inputTokenPrice ?? rule.basePrice ?? 0.01),
outputTokenPrice: Number(rule.formulaConfig?.outputTokenPrice ?? 0.03),
};
}
function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput {
return {
ruleKey,
displayName,
resourceType,
unit,
basePrice,
currency,
calculatorType,
baseWeight: {},
dynamicWeight,
dimensionSchema,
formulaConfig: { formula },
priority: 100,
status: 'active',
metadata: {},
};
}
function isPlainObject(value: unknown) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function scalarToString(value: unknown): string {
if (value === undefined || value === null) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function parseScalar(value: string): unknown {
const trimmed = value.trim();
if (trimmed === '') return '';
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try { return JSON.parse(trimmed) as unknown; } catch { return value; }
}
return value;
}
function rowsToRecord(rows: Array<[string, unknown]>): RecordValue {
return Object.fromEntries(rows.filter(([key]) => key.trim()).map(([key, value]) => [key.trim(), value]));
}
function nextKey(value: RecordValue, prefix: string) {
let index = 1;
while (`${prefix}${index}` in value) index += 1;
return `${prefix}${index}`;
}