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; type ModeKey = 'text' | 'image' | 'video'; type ParameterGroup = { key: string; title: string; defaults: RecordValue; labels?: Record; 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('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 (
计费模式 选择能力后维护基础价格、计费公式和计费参数。
{activeRules.length ? ( activeRules.map((rule, index) => ( replaceModeRules(activeRules.map((item, itemIndex) => (itemIndex === index ? nextRule : item)))} onDelete={() => replaceModeRules(activeRules.filter((_, itemIndex) => itemIndex !== index))} /> )) ) : (
{mode.label} 还没有计价配置
)}
); } export function createDefaultPricingRules(currency: string): PricingRuleInput[] { return modeDefinitions.flatMap((mode) => mode.templates(currency).map((rule) => completeRule(rule, mode))); } function modeRuleCounts(rules: PricingRuleInput[]): Record { return Object.fromEntries( modeDefinitions.map((mode) => [mode.key, mode.key === 'text' && rules.some(mode.match) ? 1 : rules.filter(mode.match).length]), ) as Record; } 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) { 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 ; } return (
{rule.resourceType} {rule.displayName || props.mode.label} {rule.ruleKey}
patch({ displayName: event.target.value })} /> patch({ basePrice: Number(event.target.value) })} /> {props.mode.formula}
{props.mode.parameterGroups.map((group) => ( patchDynamicWeight(group.key, value)} /> ))} {Object.entries(rule.dynamicWeight ?? {}).filter(([key]) => !props.mode.parameterGroups.some((group) => group.key === key)).map(([key, value]) => ( { const dynamicWeight = { ...(rule.dynamicWeight ?? {}) }; delete dynamicWeight[key]; dynamicWeight[nextTitle || key] = nextValue; patch({ dynamicWeight }); }} /> ))}
); } 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 (
text_total {props.rule.displayName || '文本'} text
props.onChange({ ...props.rule, displayName: event.target.value, ruleKey: 'text' })} /> updatePrices(Number(event.target.value), prices.outputTokenPrice)} /> updatePrices(prices.inputTokenPrice, Number(event.target.value))} /> {props.formula}
); } function PricingFormRows(props: { children: ReactNode }) { return
{props.children}
; } function PricingFormRow(props: { children: ReactNode; label: string }) { return (
{props.label} {props.children}
); } function PricingReadonlyRow(props: { label: string; value: string }) { return (
{props.label} {props.value}
); } function WeightTable(props: { editableTitle?: boolean; labels?: Record; 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 (
{props.editableTitle ? ( props.onChange(props.value, event.target.value)} /> ) : ( {props.title} )}
{rows.map(([key, value], index) => (
{props.locked ? ( {props.labels?.[key] ?? key} ) : ( updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [event.target.value, value] : row)))} /> )} updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [key, parseScalar(event.target.value)] : row)))} /> {!props.locked && }
))} {!props.locked && }
); } export function KeyValueEditor(props: { className?: string; title: string; value: RecordValue; onChange: (value: RecordValue) => void; }) { return (
{props.title}
); } 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 = { 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}`; }