import React, { useEffect } from 'react'; import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Select, Space } from 'antd'; import dayjs from 'dayjs'; import type { ModelPricingBillingMode, ModelPricingCalculatorVersion, ModelPricingRule, ModelPricingRulePayload, } from '../../types'; interface Props { initial?: ModelPricingRule | null; loading?: boolean; onSubmit: (payload: ModelPricingRulePayload) => Promise | void; onCancel: () => void; } const modeOptions = [ { value: 'text_token_tiered', label: '文本分档 Token 计价' }, { value: 'image_per_output', label: '按输出图片数量计价' }, { value: 'image_input_output_tiered', label: '输入图片 + 输出像素分档计价' }, { value: 'video_token_rate', label: '视频像素 Token 计价' }, ]; const calculatorByMode: Record = { text_token_tiered: 'text_token_tiered_v1', image_per_output: 'image_per_output_v1', image_input_output_tiered: 'image_input_output_tiered_v1', video_token_rate: 'video_pixel_token_v1', }; const billByOptions = [ { value: 'successful_output_count', label: '实际成功输出数' }, { value: 'requested_output_count', label: '请求输出数(估算)' }, { value: 'provider_billed_count', label: '供应商明确计费数' }, ]; function splitList(value?: string): string[] { return String(value || '').split(',').map(v => v.trim()).filter(Boolean); } function readAlias(source: Record, snakeKey: string, camelKey: string): any { if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey]; return source[camelKey]; } function removeKeys(source: Record, keys: string[]): Record { const result = { ...source }; keys.forEach(key => delete result[key]); return result; } function normalizeTextTier(value: Record): Record { const result = removeKeys(value || {}, [ 'max_context_tokens', 'maxContextTokens', 'input_rate', 'inputRate', 'audio_input_rate', 'audioInputRate', 'output_rate', 'outputRate', 'cached_input_rate', 'cachedInputRate', 'cached_audio_input_rate', 'cachedAudioInputRate', ]); const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens'); return { ...result, max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens, input_rate: readAlias(value || {}, 'input_rate', 'inputRate'), audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'), output_rate: readAlias(value || {}, 'output_rate', 'outputRate'), cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'), cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'), }; } function normalizeOutputTier(value: Record): Record { const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']); const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels'); return { ...result, max_pixels: maxPixels === undefined ? null : maxPixels, rate: value?.rate, }; } function normalizeVideoRate(value: Record): Record { const result = removeKeys(value || {}, [ 'has_input_video', 'hasInputVideo', 'generate_audio', 'generateAudio', 'inference_modes', 'inferenceModes', ]); const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo'); const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio'); const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes'); return { ...result, ...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }), ...(generateAudio === undefined ? {} : { generate_audio: generateAudio }), ...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }), }; } /** * 后端规则 JSON 统一使用 snake_case;同时兼容接口层已转换成 camelCase 的历史/当前数据。 * 返回值会移除已知 camelCase 别名,避免保存时同时存在两套字段。 */ function normalizeRuleJson( mode: ModelPricingBillingMode, value: Record, ): Record { const source = value || {}; const base = removeKeys(source, [ 'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour', 'output_rate', 'outputRate', 'bill_by', 'billBy', 'free_input_images', 'freeInputImages', 'input_image_rate', 'inputImageRate', 'output_tiers', 'outputTiers', 'token_formula', 'tokenFormula', 'default_fps', 'defaultFps', 'supported_resolutions', 'supportedResolutions', 'dimension_map', 'dimensionMap', ]); if (mode === 'text_token_tiered') { return { ...base, cache_storage_rate_per_million_token_hour: readAlias( source, 'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour', ), tiers: (Array.isArray(source.tiers) ? source.tiers : []).map(item => normalizeTextTier(item || {})), }; } if (mode === 'image_per_output') { return { ...base, output_rate: readAlias(source, 'output_rate', 'outputRate'), bill_by: readAlias(source, 'bill_by', 'billBy'), }; } if (mode === 'image_input_output_tiered') { const outputTiers = readAlias(source, 'output_tiers', 'outputTiers'); return { ...base, free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'), input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'), output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})), bill_by: readAlias(source, 'bill_by', 'billBy'), }; } return { ...base, token_formula: readAlias(source, 'token_formula', 'tokenFormula'), default_fps: readAlias(source, 'default_fps', 'defaultFps'), supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'), dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {}, rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})), }; } function dimensionRows(rule: Record): Array> { const rows: Array> = []; Object.entries(rule.dimension_map || {}).forEach(([resolution, ratios]) => { Object.entries((ratios || {}) as Record).forEach(([aspectRatio, value]) => { const item = value as Record; rows.push({ resolution, aspectRatio, width: item.width, height: item.height }); }); }); return rows; } function toFormPricing(mode: ModelPricingBillingMode, rule: Record): Record { const normalized = normalizeRuleJson(mode, rule || {}); if (mode === 'text_token_tiered') { return { tiers: normalized.tiers || [], cacheStorageRate: normalized.cache_storage_rate_per_million_token_hour ?? 0, }; } if (mode === 'image_per_output') { return { outputRate: normalized.output_rate ?? 0, billBy: normalized.bill_by || 'successful_output_count', }; } if (mode === 'image_input_output_tiered') { return { freeInputImages: normalized.free_input_images ?? 1, inputImageRate: normalized.input_image_rate ?? 0, outputTiers: normalized.output_tiers || [], billBy: normalized.bill_by || 'successful_output_count', }; } return { tokenFormula: normalized.token_formula || '(input_video_seconds + output_video_seconds) * width * height * fps / 1024', supportedResolutions: (normalized.supported_resolutions || []).join(','), defaultFps: normalized.default_fps ?? 30, dimensionRows: dimensionRows(normalized), rates: (normalized.rates || []).map((r: any) => ({ ...r, resolutions: (r.resolutions || []).join(','), inferenceModes: (r.inference_modes || []).join(','), hasInputVideo: r.has_input_video === undefined ? 'any' : r.has_input_video ? 'true' : 'false', generateAudio: r.generate_audio === undefined ? 'any' : r.generate_audio ? 'true' : 'false', })), }; } function buildDimensionMap(rows: Array>): Record { const result: Record = {}; (rows || []).forEach(row => { const resolution = String(row.resolution || '').trim().toLowerCase(); const aspectRatio = String(row.aspectRatio || '').trim(); const width = Number(row.width || 0); const height = Number(row.height || 0); if (!resolution || !aspectRatio || width <= 0 || height <= 0) return; result[resolution] ||= {}; result[resolution][aspectRatio] = { width, height }; }); return result; } function buildRuleJson( mode: ModelPricingBillingMode, pricing: Record, baseRule: Record, ): Record { const base = normalizeRuleJson(mode, baseRule || {}); if (mode === 'text_token_tiered') { return { ...base, unit: 'CNY_per_million_tokens', cache_storage_rate_per_million_token_hour: String(pricing.cacheStorageRate ?? 0), tiers: (pricing.tiers || []).map((v: any) => ({ max_context_tokens: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === '' || readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === undefined ? null : readAlias(v || {}, 'max_context_tokens', 'maxContextTokens'), input_rate: String(readAlias(v || {}, 'input_rate', 'inputRate') ?? 0), audio_input_rate: String(readAlias(v || {}, 'audio_input_rate', 'audioInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0), output_rate: String(readAlias(v || {}, 'output_rate', 'outputRate') ?? 0), cached_input_rate: String(readAlias(v || {}, 'cached_input_rate', 'cachedInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0), cached_audio_input_rate: String( readAlias(v || {}, 'cached_audio_input_rate', 'cachedAudioInputRate') ?? readAlias(v || {}, 'cached_input_rate', 'cachedInputRate') ?? readAlias(v || {}, 'audio_input_rate', 'audioInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0, ), })), }; } if (mode === 'image_per_output') { return { ...base, unit: 'CNY_per_image', output_rate: String(pricing.outputRate ?? 0), bill_by: pricing.billBy || 'successful_output_count', }; } if (mode === 'image_input_output_tiered') { return { ...base, unit: 'CNY_per_image', free_input_images: Number(pricing.freeInputImages || 0), input_image_rate: String(pricing.inputImageRate ?? 0), output_tiers: (pricing.outputTiers || []).map((v: any) => ({ max_pixels: readAlias(v || {}, 'max_pixels', 'maxPixels') === undefined || readAlias(v || {}, 'max_pixels', 'maxPixels') === null || readAlias(v || {}, 'max_pixels', 'maxPixels') === '' ? null : Number(readAlias(v || {}, 'max_pixels', 'maxPixels')), rate: String(v.rate ?? 0), })), bill_by: pricing.billBy || 'successful_output_count', }; } return { ...base, unit: 'CNY_per_million_tokens', token_formula: '(input_video_seconds + output_video_seconds) * width * height * fps / 1024', default_fps: Number(pricing.defaultFps || 30), supported_resolutions: splitList(pricing.supportedResolutions).map(v => v.toLowerCase()), dimension_map: buildDimensionMap(pricing.dimensionRows || []), rates: (pricing.rates || []).map((v: any) => ({ ...(splitList(v.resolutions).length ? { resolutions: splitList(v.resolutions).map(item => item.toLowerCase()) } : {}), ...(v.hasInputVideo !== 'any' && v.hasInputVideo !== undefined ? { has_input_video: v.hasInputVideo === 'true' } : {}), ...(v.generateAudio !== 'any' && v.generateAudio !== undefined ? { generate_audio: v.generateAudio === 'true' } : {}), ...(splitList(v.inferenceModes).length ? { inference_modes: splitList(v.inferenceModes).map(item => item.toLowerCase()) } : {}), rate: String(v.rate ?? 0), })), }; } const PricingRuleForm: React.FC = ({ initial, loading, onSubmit, onCancel }) => { const [form] = Form.useForm(); const mode = Form.useWatch('billingMode', form) as ModelPricingBillingMode | undefined; useEffect(() => { const billingMode = initial?.billingMode || 'video_token_rate'; form.setFieldsValue({ provider: initial?.provider || 'volcengine', modelName: initial?.modelName || '', modelCategory: initial?.modelCategory || 'video', billingMode, calculatorVersion: initial?.calculatorVersion || calculatorByMode[billingMode], versionCode: initial?.versionCode || `manual_${dayjs().format('YYYYMMDD_HHmmss')}`, effectiveRange: [ dayjs(initial?.effectiveFrom || undefined), initial?.effectiveTo ? dayjs(initial.effectiveTo) : null, ], currency: initial?.currency || 'CNY', ruleSchemaVersion: initial?.ruleSchemaVersion || 1, sourceUrl: initial?.sourceUrl || 'https://www.volcengine.com/docs/82379/1544106', sourceUpdatedAt: initial?.sourceUpdatedAt ? dayjs(initial.sourceUpdatedAt) : null, remark: initial?.remark || '', pricing: toFormPricing(billingMode, initial?.ruleJson || {}), }); }, [initial, form]); useEffect(() => { if (!mode) return; form.setFieldValue('calculatorVersion', calculatorByMode[mode]); }, [mode, form]); const submit = async (values: any) => { const billingMode = values.billingMode as ModelPricingBillingMode; const [from, to] = values.effectiveRange || []; const preserveBase = initial?.billingMode === billingMode ? initial.ruleJson : {}; await onSubmit({ provider: values.provider, model_name: values.modelName, model_category: values.modelCategory, billing_mode: billingMode, calculator_version: calculatorByMode[billingMode], version_code: values.versionCode, effective_from: from.toISOString(), effective_to: to ? to.toISOString() : null, currency: values.currency || 'CNY', rule_schema_version: Number(values.ruleSchemaVersion || initial?.ruleSchemaVersion || 1), rule_json: buildRuleJson(billingMode, values.pricing || {}, preserveBase || {}), source_url: values.sourceUrl || null, source_updated_at: values.sourceUpdatedAt ? values.sourceUpdatedAt.toISOString() : null, remark: values.remark || null, }); }; return (
价格参数 {mode === 'text_token_tiered' && <> {(fields, { add, remove }) => <> {fields.map(field => ( ))} } } {mode === 'image_per_output' && {(fields, { add, remove }) => <> {fields.map(field => )} } } {mode === 'video_token_rate' && <> 分辨率 + 比例对应真实像素 {(fields, { add, remove }) => <> {fields.map(field => )} } 视频价格档位 {(fields, { add, remove }) => <> {fields.map(field =>
)} }
} 来源与备注 ); }; export default PricingRuleForm;