1
This commit is contained in:
@@ -1,486 +0,0 @@
|
||||
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> | 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<ModelPricingBillingMode, ModelPricingCalculatorVersion> = {
|
||||
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<string, any>, snakeKey: string, camelKey: string): any {
|
||||
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
|
||||
return source[camelKey];
|
||||
}
|
||||
|
||||
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
|
||||
const result = { ...source };
|
||||
keys.forEach(key => delete result[key]);
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
|
||||
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<string, any>): Record<string, any> {
|
||||
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<string, any>): Record<string, any> {
|
||||
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<string, any>,
|
||||
): Record<string, any> {
|
||||
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<string, any>): Array<Record<string, any>> {
|
||||
const rows: Array<Record<string, any>> = [];
|
||||
Object.entries(rule.dimension_map || {}).forEach(([resolution, ratios]) => {
|
||||
Object.entries((ratios || {}) as Record<string, any>).forEach(([aspectRatio, value]) => {
|
||||
const item = value as Record<string, any>;
|
||||
rows.push({ resolution, aspectRatio, width: item.width, height: item.height });
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toFormPricing(mode: ModelPricingBillingMode, rule: Record<string, any>): Record<string, any> {
|
||||
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<string, any>>): Record<string, any> {
|
||||
const result: Record<string, any> = {};
|
||||
(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<string, any>,
|
||||
baseRule: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
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<Props> = ({ 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 (
|
||||
<Form form={form} layout="vertical" onFinish={submit} preserve={false}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="种子规则只会创建草稿。请核对真实生效时间、单价和模型输出尺寸后再发布。"
|
||||
/>
|
||||
<Space size={12} align="start" style={{ display: 'flex' }}>
|
||||
<Form.Item name="provider" label="供应商" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
|
||||
<Form.Item name="modelName" label="完整模型名称" rules={[{ required: true }]} style={{ flex: 2 }}><Input /></Form.Item>
|
||||
</Space>
|
||||
<Space size={12} align="start" style={{ display: 'flex' }}>
|
||||
<Form.Item name="modelCategory" label="模型类型" rules={[{ required: true }]} style={{ flex: 1 }}>
|
||||
<Select options={[{ value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="billingMode" label="计价模式" rules={[{ required: true }]} style={{ flex: 2 }}>
|
||||
<Select options={modeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="calculatorVersion" label="计算器版本" style={{ flex: 2 }}><Input disabled /></Form.Item>
|
||||
</Space>
|
||||
<Space size={12} align="start" style={{ display: 'flex' }}>
|
||||
<Form.Item name="versionCode" label="价格版本号" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
|
||||
<Form.Item name="effectiveRange" label="真实生效区间 [开始, 结束)" rules={[{ required: true }]} style={{ flex: 2 }}>
|
||||
<DatePicker.RangePicker showTime allowEmpty={[false, true]} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="currency" label="币种" style={{ width: 100 }}><Input /></Form.Item>
|
||||
<Form.Item name="ruleSchemaVersion" label="结构版本" style={{ width: 100 }}><InputNumber min={1} /></Form.Item>
|
||||
</Space>
|
||||
|
||||
<Divider titlePlacement="start">价格参数</Divider>
|
||||
{mode === 'text_token_tiered' && <>
|
||||
<Form.Item name={['pricing', 'cacheStorageRate']} label="缓存存储单价(元/M Token·小时)">
|
||||
<InputNumber min={0} stringMode style={{ width: 260 }} />
|
||||
</Form.Item>
|
||||
<Form.List name={['pricing', 'tiers']}>
|
||||
{(fields, { add, remove }) => <>
|
||||
{fields.map(field => (
|
||||
<Space key={field.key} align="baseline" wrap>
|
||||
<Form.Item {...field} name={[field.name, 'max_context_tokens']} label="最大上下文 Token"><InputNumber min={1} /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'input_rate']} label="输入单价/M"><InputNumber min={0} stringMode /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'audio_input_rate']} label="音频输入/M"><InputNumber min={0} stringMode /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'cached_input_rate']} label="缓存文本/M"><InputNumber min={0} stringMode /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'cached_audio_input_rate']} label="缓存音频/M"><InputNumber min={0} stringMode /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'output_rate']} label="输出单价/M"><InputNumber min={0} stringMode /></Form.Item>
|
||||
<Button danger onClick={() => remove(field.name)}>删除</Button>
|
||||
</Space>
|
||||
))}
|
||||
<Button onClick={() => add({})}>新增 Token 档位</Button>
|
||||
</>}
|
||||
</Form.List>
|
||||
</>}
|
||||
|
||||
{mode === 'image_per_output' && <Space align="baseline">
|
||||
<Form.Item name={['pricing', 'outputRate']} label="输出图片单价(元/张)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} stringMode style={{ width: 220 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['pricing', 'billBy']} label="计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
|
||||
</Space>}
|
||||
|
||||
{mode === 'image_input_output_tiered' && <>
|
||||
<Space align="baseline">
|
||||
<Form.Item name={['pricing', 'freeInputImages']} label="免费输入图片数"><InputNumber min={0} /></Form.Item>
|
||||
<Form.Item name={['pricing', 'inputImageRate']} label="超出后输入图片单价"><InputNumber min={0} stringMode /></Form.Item>
|
||||
<Form.Item name={['pricing', 'billBy']} label="输出计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
|
||||
</Space>
|
||||
<Form.List name={['pricing', 'outputTiers']}>
|
||||
{(fields, { add, remove }) => <>
|
||||
{fields.map(field => <Space key={field.key} align="baseline">
|
||||
<Form.Item {...field} name={[field.name, 'max_pixels']} label="最大像素(末档留空)"><InputNumber min={1} /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'rate']} label="输出单价(元/张)"><InputNumber min={0} stringMode /></Form.Item>
|
||||
<Button danger onClick={() => remove(field.name)}>删除</Button>
|
||||
</Space>)}
|
||||
<Button onClick={() => add({ max_pixels: null, rate: 0 })}>新增输出像素档位</Button>
|
||||
</>}
|
||||
</Form.List>
|
||||
</>}
|
||||
|
||||
{mode === 'video_token_rate' && <>
|
||||
<Form.Item name={['pricing', 'tokenFormula']} label="Token 公式说明(由计算器版本实现,不执行文本公式)"><Input disabled /></Form.Item>
|
||||
<Space align="baseline" wrap>
|
||||
<Form.Item name={['pricing', 'supportedResolutions']} label="支持分辨率(逗号分隔)"><Input placeholder="480p,720p" /></Form.Item>
|
||||
<Form.Item name={['pricing', 'defaultFps']} label="默认 FPS"><InputNumber min={1} max={120} /></Form.Item>
|
||||
</Space>
|
||||
<Divider titlePlacement="start" plain>分辨率 + 比例对应真实像素</Divider>
|
||||
<Form.List name={['pricing', 'dimensionRows']}>
|
||||
{(fields, { add, remove }) => <>
|
||||
{fields.map(field => <Space key={field.key} align="baseline" wrap>
|
||||
<Form.Item {...field} name={[field.name, 'resolution']} label="分辨率" rules={[{ required: true }]}><Input placeholder="720p" /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'aspectRatio']} label="比例" rules={[{ required: true }]}><Input placeholder="9:16" /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'width']} label="宽" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'height']} label="高" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
|
||||
<Button danger onClick={() => remove(field.name)}>删除</Button>
|
||||
</Space>)}
|
||||
<Button onClick={() => add({})}>新增尺寸映射</Button>
|
||||
</>}
|
||||
</Form.List>
|
||||
<Divider titlePlacement="start" plain>视频价格档位</Divider>
|
||||
<Form.List name={['pricing', 'rates']}>
|
||||
{(fields, { add, remove }) => <>
|
||||
{fields.map(field => <div key={field.key} style={{ border: '1px solid #eee', padding: 12, marginBottom: 12, borderRadius: 8 }}>
|
||||
<Space align="baseline" wrap>
|
||||
<Form.Item {...field} name={[field.name, 'resolutions']} label="分辨率"><Input placeholder="480p,720p;可空" /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'hasInputVideo']} label="含输入视频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'generateAudio']} label="生成音频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'inferenceModes']} label="推理模式"><Input placeholder="online,flex;可空" /></Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'rate']} label="单价/M Token" rules={[{ required: true }]}><InputNumber min={0} stringMode /></Form.Item>
|
||||
<Button danger onClick={() => remove(field.name)}>删除</Button>
|
||||
</Space>
|
||||
</div>)}
|
||||
<Button onClick={() => add({ hasInputVideo: 'any', generateAudio: 'any' })}>新增视频价格档位</Button>
|
||||
</>}
|
||||
</Form.List>
|
||||
</>}
|
||||
|
||||
<Divider titlePlacement="start">来源与备注</Divider>
|
||||
<Form.Item name="sourceUrl" label="官方来源 URL"><Input /></Form.Item>
|
||||
<Form.Item name="sourceUpdatedAt" label="官方文档更新时间(不是价格生效时间)"><DatePicker showTime /></Form.Item>
|
||||
<Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item>
|
||||
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>保存草稿</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingRuleForm;
|
||||
@@ -1,229 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Input, message, Space, Typography } from 'antd';
|
||||
import { previewModelPricing } from '../../api';
|
||||
import type {
|
||||
ModelPricingBillingMode,
|
||||
ModelPricingCalculatorVersion,
|
||||
ModelPricingPreviewResponse,
|
||||
} from '../../types';
|
||||
|
||||
interface Props {
|
||||
billingMode: ModelPricingBillingMode;
|
||||
calculatorVersion: ModelPricingCalculatorVersion;
|
||||
ruleJson: Record<string, any>;
|
||||
}
|
||||
|
||||
const examples: Record<ModelPricingBillingMode, Record<string, any>> = {
|
||||
text_token_tiered: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 300,
|
||||
cached_input_tokens: 0,
|
||||
audio_input_tokens: 0,
|
||||
usage_source: 'provider',
|
||||
},
|
||||
image_per_output: {
|
||||
requested_output_count: 1,
|
||||
successful_output_count: 1,
|
||||
provider_billed_count: 1,
|
||||
usage_source: 'provider_response',
|
||||
},
|
||||
image_input_output_tiered: {
|
||||
provider_input_image_count: 2,
|
||||
requested_output_count: 1,
|
||||
successful_output_count: 1,
|
||||
output_items: [{ width: 2048, height: 2048, pixels: 4194304 }],
|
||||
usage_source: 'provider_response',
|
||||
},
|
||||
video_token_rate: {
|
||||
total_tokens: 1000000,
|
||||
resolution: '720p',
|
||||
aspect_ratio: '9:16',
|
||||
has_input_video: false,
|
||||
generate_audio: false,
|
||||
inference_mode: 'online',
|
||||
usage_source: 'provider',
|
||||
},
|
||||
};
|
||||
|
||||
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
|
||||
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
|
||||
return source[camelKey];
|
||||
}
|
||||
|
||||
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
|
||||
const result = { ...source };
|
||||
keys.forEach(key => delete result[key]);
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
|
||||
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<string, any>): Record<string, any> {
|
||||
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<string, any>): Record<string, any> {
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 返回层可能会把嵌套 rule_json 一并转成 camelCase。
|
||||
* 后端计价器只接受 snake_case,因此试算前必须按计价模式恢复标准结构。
|
||||
*/
|
||||
function normalizeRuleJson(
|
||||
billingMode: ModelPricingBillingMode,
|
||||
value: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
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 (billingMode === 'text_token_tiered') {
|
||||
const tiers = Array.isArray(source.tiers) ? source.tiers : [];
|
||||
return {
|
||||
...base,
|
||||
cache_storage_rate_per_million_token_hour: readAlias(
|
||||
source,
|
||||
'cache_storage_rate_per_million_token_hour',
|
||||
'cacheStorageRatePerMillionTokenHour',
|
||||
),
|
||||
tiers: tiers.map(item => normalizeTextTier(item || {})),
|
||||
};
|
||||
}
|
||||
|
||||
if (billingMode === 'image_per_output') {
|
||||
return {
|
||||
...base,
|
||||
output_rate: readAlias(source, 'output_rate', 'outputRate'),
|
||||
bill_by: readAlias(source, 'bill_by', 'billBy'),
|
||||
};
|
||||
}
|
||||
|
||||
if (billingMode === '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 || {})),
|
||||
};
|
||||
}
|
||||
|
||||
const PricingRulePreview: React.FC<Props> = ({ billingMode, calculatorVersion, ruleJson }) => {
|
||||
const initial = useMemo(() => JSON.stringify(examples[billingMode], null, 2), [billingMode]);
|
||||
const [usageText, setUsageText] = useState(initial);
|
||||
const [result, setResult] = useState<ModelPricingPreviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setUsageText(initial);
|
||||
setResult(null);
|
||||
}, [initial, ruleJson, calculatorVersion]);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const usage = JSON.parse(usageText);
|
||||
setResult(await previewModelPricing({
|
||||
billing_mode: billingMode,
|
||||
calculator_version: calculatorVersion,
|
||||
rule_json: normalizeRuleJson(billingMode, ruleJson),
|
||||
usage,
|
||||
currency: 'CNY',
|
||||
}));
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '计价试算失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <Card size="small" title="规则试算" style={{ marginTop: 16 }}>
|
||||
<Typography.Text type="secondary">
|
||||
计算器:{calculatorVersion}。图片使用同步响应中的实际输出条目;视频优先使用供应商实际 Token。
|
||||
</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={usageText}
|
||||
onChange={e => setUsageText(e.target.value)}
|
||||
rows={10}
|
||||
style={{ marginTop: 10, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Space style={{ marginTop: 10 }}>
|
||||
<Button type="primary" loading={loading} onClick={run}>开始试算</Button>
|
||||
</Space>
|
||||
{result && <Alert
|
||||
style={{ marginTop: 12 }}
|
||||
type={result.isEstimated ? 'warning' : 'success'}
|
||||
showIcon
|
||||
message={`${result.currency} ${result.amount}${result.isEstimated ? '(估算)' : ''}`}
|
||||
description={<>
|
||||
<div style={{ marginBottom: 8 }}>用量来源:{result.usageSource || '-'}</div>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{JSON.stringify(result.breakdown, null, 2)}</pre>
|
||||
</>}
|
||||
/>}
|
||||
</Card>;
|
||||
};
|
||||
|
||||
export default PricingRulePreview;
|
||||
Reference in New Issue
Block a user