This commit is contained in:
2026-07-10 17:09:14 +08:00
parent ad2e40bc67
commit d07cd508ee
54 changed files with 7111 additions and 619 deletions
+2
View File
@@ -14,6 +14,7 @@ import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings';
import AdminNotificationManager from './pages/AdminNotificationManager';
import AdminCreditRecords from './pages/AdminCreditRecords';
import AdminModelPricingRules from './pages/AdminModelPricingRules';
import AdminPaymentConfig from './pages/AdminPaymentConfig';
import AdminPaymentStats from './pages/AdminPaymentStats';
import AdminIndustries from './pages/AdminIndustries';
@@ -87,6 +88,7 @@ const App = () => {
<Route path="users" element={<AdminUsers />} />
<Route path="teams" element={<AdminTeams />} />
<Route path="credit-records" element={<AdminCreditRecords />} />
<Route path="model-pricing" element={<AdminModelPricingRules />} />
<Route path="models" element={<AdminModels />} />
<Route path="credit-ratios" element={<AdminCreditRatios />} />
<Route path="video-engines" element={<AdminVideoEngines />} />
+43
View File
@@ -13,6 +13,7 @@ import type {
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
ModelPricingRule, ModelPricingRuleListResponse, ModelPricingRulePayload, ModelPricingPreviewResponse,
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
@@ -296,12 +297,54 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
setMaybe(params, 'source_module', filters?.sourceModule);
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
setMaybe(params, 'billing_scene', filters?.billingScene);
setMaybe(params, 'engine_provider', filters?.engineProvider);
setMaybe(params, 'engine_model_name', filters?.engineModelName);
setMaybe(params, 'pricing_version_code', filters?.pricingVersionCode);
setMaybe(params, 'provider_cost_status', filters?.providerCostStatus);
setMaybe(params, 'provider_cost_is_estimated', filters?.providerCostIsEstimated);
setMaybe(params, 'has_attachment', filters?.hasAttachment);
setMaybe(params, 'start_date', filters?.startDate);
setMaybe(params, 'end_date', filters?.endDate);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/credit-records${q}`);
}
export async function getModelPricingRules(filters?: {
page?: number; pageSize?: number; provider?: string; modelName?: string; modelCategory?: string; publishStatus?: string;
}): Promise<ModelPricingRuleListResponse> {
const params = new URLSearchParams();
setMaybe(params, 'page', filters?.page);
setMaybe(params, 'page_size', filters?.pageSize);
setMaybe(params, 'provider', filters?.provider);
setMaybe(params, 'model_name', filters?.modelName);
setMaybe(params, 'model_category', filters?.modelCategory);
setMaybe(params, 'publish_status', filters?.publishStatus);
return api.get(`/admin/model-pricing/rules${params.toString() ? `?${params}` : ''}`);
}
export async function createModelPricingRule(payload: ModelPricingRulePayload): Promise<ModelPricingRule> {
return api.post('/admin/model-pricing/rules', payload);
}
export async function updateModelPricingRule(id: string, payload: Partial<ModelPricingRulePayload>): Promise<ModelPricingRule> {
return api.put(`/admin/model-pricing/rules/${id}`, payload);
}
export async function publishModelPricingRule(id: string): Promise<ModelPricingRule> {
return api.post(`/admin/model-pricing/rules/${id}/publish`);
}
export async function disableModelPricingRule(id: string): Promise<ModelPricingRule> {
return api.post(`/admin/model-pricing/rules/${id}/disable`);
}
export async function previewModelPricing(payload: {
billing_mode: string; calculator_version: string; rule_json: Record<string, any>; usage: Record<string, any>; currency?: string;
}): Promise<ModelPricingPreviewResponse> {
return api.post('/admin/model-pricing/preview', payload);
}
export async function getIndustryConfigs(): Promise<any[]> {
return api.get('/admin/industry-configs');
}
@@ -0,0 +1,486 @@
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;
@@ -0,0 +1,229 @@
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;
+226 -247
View File
@@ -1,12 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
Button, Card, DatePicker, Descriptions, Drawer, Input, message, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, EyeOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
} from '@ant-design/icons';
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
import dayjs from 'dayjs';
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
import { getCreditRecords, getTeamOptions } from '../api';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
import { formatDate } from '../utils/formatDate';
@@ -14,21 +14,15 @@ import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
totalRecharge: 0,
totalConsume: 0,
totalRefund: 0,
transactionCount: 0,
generationCount: 0,
generationAttemptCount: 0,
imageGenerationCount: 0,
videoGenerationCount: 0,
imageConsume: 0,
videoConsume: 0,
textConsume: 0,
analysisConsume: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
totalRecharge: 0, totalConsume: 0, totalRefund: 0, transactionCount: 0,
generationCount: 0, generationAttemptCount: 0, imageGenerationCount: 0, videoGenerationCount: 0,
imageConsume: 0, videoConsume: 0, textConsume: 0, analysisConsume: 0,
totalTokens: 0, inputTokens: 0, outputTokens: 0,
attachmentImageCount: 0, attachmentVideoCount: 0, attachmentAudioCount: 0, attachmentTotalCount: 0,
generatedImageCount: 0, generatedVideoCount: 0, generatedTotalCount: 0,
providerCostCalculatedTotal: '0.00000000', providerCostEstimatedTotal: '0.00000000',
providerCostCombinedTotal: '0.00000000', providerCostTotal: '0.00000000',
providerCostPendingCount: 0, providerCostEstimatedCount: 0, providerCostAbnormalCount: 0,
};
const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
@@ -39,73 +33,38 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
};
const userScopeOptions = [
{ value: '', label: '全部用户' },
{ value: 'admin', label: '后台用户' },
{ value: 'frontend_internal', label: '前台内部用户' },
{ value: 'frontend_external', label: '前台外部用户' },
{ value: '', label: '全部用户' }, { value: 'admin', label: '后台用户' },
{ value: 'frontend_internal', label: '前台内部用户' }, { value: 'frontend_external', label: '前台外部用户' },
];
const recordTypeOptions = [
{ value: '', label: '全部流水' },
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
{ value: 'team_internal', label: '团队内部' },
{ value: '', label: '全部流水' }, { value: 'recharge', label: '充值' }, { value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' }, { value: 'team_internal', label: '团队内部' },
];
const creditSubjectOptions = [
{ value: '', label: '全部积分类型' },
{ value: 'media', label: '图片/视频生成积分' },
{ value: 'text', label: '提词优化积分' },
{ value: 'module', label: '模块功能积分' },
{ value: 'analysis', label: '分析积分' },
{ value: 'split', label: '切片积分' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
{ value: 'recharge', label: '充值积分' },
{ value: 'unknown', label: '历史未知' },
{ value: '', label: '全部积分类型' }, { value: 'media', label: '图片/视频生成积分' },
{ value: 'text', label: '提词优化积分' }, { value: 'module', label: '模块功能积分' },
{ value: 'analysis', label: '分析积分' }, { value: 'split', label: '切片积分' },
{ value: 'admin_adjust', label: '管理员调整' }, { value: 'team_internal', label: '团队内部转移' },
{ value: 'recharge', label: '充值积分' }, { value: 'unknown', label: '历史未知' },
];
const mediaTypeOptions = [
{ value: '', label: '全部媒体' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
];
const mediaTypeOptions = [{ value: '', label: '全部媒体' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }];
const chargeKindOptions = [
{ value: '', label: '全部扣费子类' },
{ value: 'media', label: '媒体生成' },
{ value: 'text_prompt', label: '提词优化' },
{ value: 'file_parse', label: '文件解析' },
{ value: 'vision_input', label: '图片理解' },
{ value: 'module_create', label: '创建模块项目' },
{ value: 'video_analysis', label: '视频分析' },
{ value: 'video_split', label: '视频切片' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: '', label: '全部扣费子类' }, { value: 'media', label: '媒体生成' }, { value: 'text_prompt', label: '提词优化' },
{ value: 'file_parse', label: '文件解析' }, { value: 'vision_input', label: '图片理解' },
{ value: 'module_create', label: '创建模块项目' }, { value: 'video_analysis', label: '视频分析' },
{ value: 'video_split', label: '视频切片' }, { value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
];
const sourceModuleOptions = [
{ value: '', label: '全部模块' },
{ value: 'ai_creation', label: 'AI创作' },
{ value: 'generation_record', label: '项目记录' },
{ value: 'hot_opening_replicate', label: '爆款开头复刻' },
{ value: 'shot_replicate', label: '拆镜复刻' },
{ value: 'admin', label: '后台管理' },
{ value: 'payment', label: '支付充值' },
{ value: 'team', label: '团队管理' },
{ value: 'unknown', label: '历史未知' },
{ value: '', label: '全部模块' }, { value: 'ai_creation', label: 'AI创作' }, { value: 'generation_record', label: '项目记录' },
{ value: 'hot_opening_replicate', label: '爆款开头复刻' }, { value: 'shot_replicate', label: '拆镜复刻' },
{ value: 'admin', label: '后台管理' }, { value: 'payment', label: '支付充值' }, { value: 'team', label: '团队管理' },
];
const sourceStepOptions = [
{ value: '', label: '全部步骤' },
{ value: 'image_prompt_optimize', label: '图片提词优化' },
{ value: 'image_generate', label: '图片生成' },
{ value: 'video_prompt_optimize', label: '视频提词优化' },
{ value: 'video_generate', label: '视频生成' },
{ value: 'video_analysis', label: '视频分析' },
{ value: '', label: '全部步骤' }, { value: 'image_prompt_optimize', label: '图片提词优化' },
{ value: 'image_generate', label: '图片生成' }, { value: 'video_prompt_optimize', label: '视频提词优化' },
{ value: 'video_generate', label: '视频生成' }, { value: 'video_analysis', label: '视频分析' },
];
const billingSceneOptions = [
{ value: '', label: '全部计费场景' },
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
@@ -135,24 +94,45 @@ const billingSceneOptions = [
{ value: 'team_internal_transfer', label: '团队内部转账' },
{ value: 'unknown', label: '历史未知' },
];
const costStatusOptions = [
{ value: '', label: '全部成本状态' }, { value: 'pending', label: '待回填' }, { value: 'calculated', label: '已核算' },
{ value: 'estimated', label: '估算' }, { value: 'unmatched_rule', label: '未匹配价格' },
{ value: 'usage_missing', label: '用量缺失' }, { value: 'historical_price_unavailable', label: '历史价格缺失' },
{ value: 'historical_engine_unavailable', label: '历史引擎缺失' },
{ value: 'provider_result_uncertain', label: '供应商结果不确定' },
{ value: 'not_incurred', label: '供应商费用未发生' },
{ value: 'error', label: '核算异常' }, { value: 'not_applicable', label: '不涉及成本' },
];
function n(value: number | undefined | null): string {
return Number(value || 0).toLocaleString();
function n(value: number | string | undefined | null, digits = 0): string {
const parsed = Number(value || 0);
return parsed.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function engineTypeLabel(type?: string): string {
if (type === 'model') return '提词/分析模型';
if (type === 'image') return '图片引擎';
if (type === 'video') return '视频引擎';
return '执行配置';
}
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
if (scope === 'admin') return { userType: 'admin' };
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
return {};
}
function costStatusColor(status?: string): string {
if (status === 'calculated') return 'green';
if (status === 'estimated') return 'orange';
if (status === 'pending') return 'blue';
if (status === 'not_applicable') return 'default';
return 'red';
}
const JsonBlock: React.FC<{ value?: Record<string, any> | null }> = ({ value }) => (
<pre style={{ background: '#f7f8fa', borderRadius: 8, padding: 12, overflow: 'auto', whiteSpace: 'pre-wrap' }}>
{value ? JSON.stringify(value, null, 2) : '-'}
</pre>
);
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
@@ -163,6 +143,7 @@ const AdminCreditRecords: React.FC = () => {
const [exportProgress, setExportProgress] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [detail, setDetail] = useState<AdminCreditRecord | null>(null);
const [userScope, setUserScope] = useState('');
const [teamFilter, setTeamFilter] = useState('');
@@ -175,24 +156,24 @@ const AdminCreditRecords: React.FC = () => {
const [sourceStepCode, setSourceStepCode] = useState('');
const [billingScene, setBillingScene] = useState('');
const [userNameFilter, setUserNameFilter] = useState('');
const [engineProvider, setEngineProvider] = useState('');
const [engineModelName, setEngineModelName] = useState('');
const [pricingVersionCode, setPricingVersionCode] = useState('');
const [providerCostStatus, setProviderCostStatus] = useState('');
const [hasAttachment, setHasAttachment] = useState('');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
page,
pageSize,
userName: userNameFilter || undefined,
teamId: teamFilter || undefined,
recordType: recordType || undefined,
creditSubject: creditSubject || undefined,
mediaType: mediaType || undefined,
chargeKind: chargeKind || undefined,
sourceModule: sourceModule || undefined,
sourceStepCode: sourceStepCode || undefined,
page, pageSize, userName: userNameFilter || undefined, teamId: teamFilter || undefined,
recordType: recordType || undefined, creditSubject: creditSubject || undefined, mediaType: mediaType || undefined,
chargeKind: chargeKind || undefined, sourceModule: sourceModule || undefined, sourceStepCode: sourceStepCode || undefined,
billingScene: billingScene || undefined,
startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'),
engineProvider: engineProvider || undefined, engineModelName: engineModelName || undefined,
pricingVersionCode: pricingVersionCode || undefined, providerCostStatus: providerCostStatus || undefined,
hasAttachment: hasAttachment === '' ? undefined : hasAttachment === 'true',
startDate: dateRange[0]?.format('YYYY-MM-DD'), endDate: dateRange[1]?.format('YYYY-MM-DD'),
...buildScope(userScope),
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, engineProvider, engineModelName, pricingVersionCode, providerCostStatus, hasAttachment, dateRange, userScope]);
const load = async () => {
setLoading(true);
@@ -200,33 +181,20 @@ const AdminCreditRecords: React.FC = () => {
const res = await getCreditRecords(query);
setRecords(res.items || []);
setTotal(res.total || 0);
setSummary(res.summary || DEFAULT_SUMMARY);
setSummary({ ...DEFAULT_SUMMARY, ...(res.summary || {}) });
} catch (e: any) {
message.error(e?.message || '加载积分记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [query]);
useEffect(() => {
getTeamOptions(true).then(setTeamOptions).catch(() => {});
}, []);
useEffect(() => { getTeamOptions(true).then(setTeamOptions).catch(() => {}); }, []);
const handleReset = () => {
setUserScope('');
setTeamFilter('');
setRecordType('');
setCreditSubject('');
setMediaType('');
setChargeKind('');
setSourceModule('');
setSourceStepCode('');
setBillingScene('');
setUserNameFilter('');
setDateRange([null, null]);
setPage(1);
setUserScope(''); setTeamFilter(''); setRecordType(''); setCreditSubject(''); setMediaType(''); setChargeKind('');
setSourceModule(''); setSourceStepCode(''); setBillingScene(''); setUserNameFilter(''); setEngineProvider(''); setEngineModelName('');
setPricingVersionCode(''); setProviderCostStatus(''); setHasAttachment(''); setDateRange([null, null]); setPage(1);
};
const exportExcel = async () => {
@@ -237,168 +205,179 @@ const AdminCreditRecords: React.FC = () => {
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
const first = await getCreditRecords(baseQuery);
const all: AdminCreditRecord[] = [...(first.items || [])];
const exportSummary = first.summary || DEFAULT_SUMMARY;
const exportSummary = { ...DEFAULT_SUMMARY, ...(first.summary || {}) };
const totalRows = first.total || 0;
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
setExportProgress(`正在获取 ${all.length} / ${totalRows}`);
for (let p = 2; p <= totalPages; p += 1) {
const res = await getCreditRecords({ ...baseQuery, page: p });
all.push(...(res.items || []));
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
}
const detailColumns: StyledExcelColumn<AdminCreditRecord>[] = [
{ title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') },
{ title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' },
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' },
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
{ title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) },
{ title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' },
{ title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' },
{ title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' },
{ title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' },
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
const columns: StyledExcelColumn<AdminCreditRecord>[] = [
{ title: '时间', maxWidth: 22, render: r => formatDate(r.createdAt || '') },
{ title: '用户', maxWidth: 20, render: r => r.username || '-' }, { title: '手机号', maxWidth: 18, render: r => r.phone || '-' },
{ title: '用户类型', maxWidth: 16, render: r => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: r => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: r => r.teamNameSnapshot || '未分配团队' },
{ title: '流水类型', maxWidth: 14, render: r => r.recordTypeLabel || r.type }, { title: '积分类型', maxWidth: 20, render: r => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 20, render: r => r.chargeKindLabel || '-' }, { title: '模块', maxWidth: 20, render: r => r.sourceModuleLabel || '-' },
{ title: '模块步骤', maxWidth: 22, render: r => r.sourceStepCodeLabel || '-' }, { title: '计费场景', maxWidth: 32, render: r => r.billingSceneLabel || '-' },
{ title: '媒体类型', maxWidth: 12, align: 'center', render: r => r.mediaTypeLabel || '-' },
{ title: '变动积分', numFmt: '#,##0.00', align: 'right', render: r => r.amount }, { title: '变动后余额', numFmt: '#,##0.00', align: 'right', render: r => r.balanceAfter },
{ title: '输入Token', numFmt: '#,##0', align: 'right', render: r => r.inputTokens || 0 }, { title: '输出Token', numFmt: '#,##0', align: 'right', render: r => r.outputTokens || 0 },
{ title: '实际Token', numFmt: '#,##0', align: 'right', render: r => r.totalTokens || 0 },
{ title: '图片附件数', numFmt: '#,##0', render: r => r.attachmentImageCount || 0 }, { title: '视频附件数', numFmt: '#,##0', render: r => r.attachmentVideoCount || 0 },
{ title: '音频附件数', numFmt: '#,##0', render: r => r.attachmentAudioCount || 0 }, { title: '附件总数', numFmt: '#,##0', render: r => r.attachmentTotalCount || 0 },
{ title: '输入视频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentVideoDurationSeconds || 0) },
{ title: '输入音频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentAudioDurationSeconds || 0) },
{ title: '请求生成数', numFmt: '#,##0', render: r => r.requestedOutputCount || 0 }, { title: '实际生成图片数', numFmt: '#,##0', render: r => r.generatedImageCount || 0 },
{ title: '实际生成视频数', numFmt: '#,##0', render: r => r.generatedVideoCount || 0 }, { title: '实际生成总数', numFmt: '#,##0', render: r => r.generatedTotalCount || 0 },
{ title: '供应商', maxWidth: 18, render: r => r.engineProvider || '-' }, { title: '模型', maxWidth: 30, render: r => r.engineModelName || '-' },
{ title: '计价模式', maxWidth: 24, render: r => r.pricingBillingModeLabel || '-' }, { title: '计算器版本', maxWidth: 28, render: r => r.pricingCalculatorVersion || '-' },
{ title: '计价版本', maxWidth: 22, render: r => r.pricingVersionCode || '-' }, { title: '用量来源', maxWidth: 20, render: r => r.pricingUsageSource || '-' },
{ title: '计价时间', maxWidth: 22, render: r => formatDate(r.pricingReferenceAt || '') },
{ title: '供应商成本', numFmt: '#,##0.00000000', align: 'right', render: r => Number(r.providerCostAmount || 0) },
{ title: '成本币种', render: r => r.providerCostCurrency || 'CNY' }, { title: '成本状态', maxWidth: 18, render: r => r.providerCostStatusLabel || '-' },
{ title: '是否估算', render: r => r.providerCostIsEstimated ? '是' : '否' },
{ title: '最终核算时间', maxWidth: 22, render: r => formatDate(r.providerCostFinalizedAt || '') },
{ title: '主供应商用量', render: r => r.providerUsagePrimary ? '是' : '' },
{ title: '执行类型', maxWidth: 18, render: r => engineTypeLabel(r.engineType) },
{ title: '执行配置', maxWidth: 28, render: r => r.engineName || '-' },
{ title: '关联状态', maxWidth: 14, align: 'center', render: r => r.ownerDeleted ? '关联已删除' : '正常' },
{ title: '说明', maxWidth: 42, render: r => r.description || '' }, { title: '业务归属类型', maxWidth: 22, render: r => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 30, render: r => r.ownerId || '' }, { title: 'BizKey', maxWidth: 36, render: r => r.bizKey || '' },
];
const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
exportStyledExcel<AdminCreditRecord>({
filename,
sheetName: '积分流水',
title: '积分流水汇总',
metadataRows: [
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
['导出条数', totalRows],
],
exportStyledExcel({
filename: `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, sheetName: '积分流水', title: '积分流水与供应商成本核查',
metadataRows: [['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`], ['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')], ['导出条数', totalRows]],
summaryRows: [
['总充值', exportSummary.totalRecharge],
['总消费', exportSummary.totalConsume],
['总回退', exportSummary.totalRefund],
['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount],
['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount],
['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分', exportSummary.imageConsume],
['视频消费积分', exportSummary.videoConsume],
['提词消费积分', exportSummary.textConsume],
['视频分析积分', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens],
['输入 Token', exportSummary.inputTokens],
['输出 Token', exportSummary.outputTokens],
],
columns: detailColumns,
rows: all,
['总充值', exportSummary.totalRecharge], ['总消费', exportSummary.totalConsume], ['总回退', exportSummary.totalRefund], ['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount], ['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount], ['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分', exportSummary.imageConsume], ['视频消费积分', exportSummary.videoConsume],
['提词消费积分', exportSummary.textConsume], ['视频分析积分', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens], ['输入 Token', exportSummary.inputTokens], ['输出 Token', exportSummary.outputTokens],
['输入图片附件数', exportSummary.attachmentImageCount], ['输入视频附件数', exportSummary.attachmentVideoCount], ['输入音频附件数', exportSummary.attachmentAudioCount],
['实际生成图片数', exportSummary.generatedImageCount], ['实际生成视频数', exportSummary.generatedVideoCount],
['已核算供应商成本', Number(exportSummary.providerCostCalculatedTotal || 0)],
['估算供应商成本', Number(exportSummary.providerCostEstimatedTotal || 0)],
['成本参考合计', Number(exportSummary.providerCostCombinedTotal || 0)],
['待核算流水数', exportSummary.providerCostPendingCount],
['估算成本流水数', exportSummary.providerCostEstimatedCount], ['异常成本流水数', exportSummary.providerCostAbnormalCount],
], columns, rows: all,
});
message.success('Excel 已导出');
} catch (e: any) {
message.error(e?.message || '导出失败');
} finally {
setExporting(false);
setExportProgress('');
}
} finally { setExporting(false); setExportProgress(''); }
};
const columns = [
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <div><Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.frontendUserKindLabel || '-'}</div></div> },
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text> },
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
{ title: '流水类型', dataIndex: 'recordType', width: 105, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string, r: AdminCreditRecord) => <div><Tag>{v || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.chargeKindLabel || '-'}</div></div> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 135, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 230, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 85, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v, 2)}</Typography.Text> },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v, 2) },
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.inputTokens)} / {n(r.outputTokens)}</div></div> },
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
{ title: '执行配置', key: 'engine', width: 240, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 105, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
{ title: '时间', dataIndex: 'createdAt', width: 165, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
{ title: '附件', key: 'attachments', width: 155, render: (_: any, r: AdminCreditRecord) => <div> {n(r.attachmentImageCount)} / {n(r.attachmentVideoCount)} / {n(r.attachmentAudioCount)}<div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.attachmentTotalCount)}</div></div> },
{ title: '生成产出', key: 'outputs', width: 150, render: (_: any, r: AdminCreditRecord) => <div> {n(r.generatedImageCount)} / {n(r.generatedVideoCount)}<div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.requestedOutputCount)} / {n(r.generatedTotalCount)}</div></div> },
{ title: '供应商实价', key: 'providerCost', width: 175, render: (_: any, r: AdminCreditRecord) => <div><Typography.Text strong>{r.providerCostCurrency || 'CNY'} {n(r.providerCostAmount, 8)}</Typography.Text><div><Tag color={costStatusColor(r.providerCostStatus)}>{r.providerCostStatusLabel || '-'}</Tag>{r.providerCostIsEstimated && <Tag color="orange"></Tag>}</div></div> },
{ title: '计价版本', key: 'pricing', width: 220, render: (_: any, r: AdminCreditRecord) => <div><div>{r.pricingVersionCode || '未锁价'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.pricingBillingModeLabel || '-'} / {r.pricingCalculatorVersion || '-'}</div></div> },
{ title: '操作', key: 'action', width: 90, fixed: 'right' as const, render: (_: any, r: AdminCreditRecord) => <Button size="small" icon={<EyeOutlined />} onClick={() => setDetail(r)}></Button> },
];
return (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}> / </div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small" bordered={false}>{n(summary.imageGenerationCount)} / {n(summary.imageConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.videoGenerationCount)} / {n(summary.videoConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.textConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.analysisConsume)} </Card>
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
<Select
value={teamFilter}
onChange={(v) => { setPage(1); setTeamFilter(v); }}
style={{ width: 170 }}
options={[
{ value: '', label: '全部团队' },
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
<Select value={sourceStepCode} onChange={(v) => { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} />
<Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
</Space>
<Space>
<Button onClick={handleReset}></Button>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}> Excel</Button>
</Space>
</div>
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
<Table
columns={columns}
dataSource={records}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 2050 }}
/>
</Card>
return <div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card bordered={false}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}> / </div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
</div>
);
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small">{n(summary.imageGenerationCount)} / {n(summary.imageConsume, 2)} </Card>
<Card size="small">{n(summary.videoGenerationCount)} / {n(summary.videoConsume, 2)} </Card>
<Card size="small">{n(summary.textConsume, 2)} </Card>
<Card size="small">{n(summary.analysisConsume, 2)} </Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small">//{n(summary.attachmentImageCount)} / {n(summary.attachmentVideoCount)} / {n(summary.attachmentAudioCount)}</Card>
<Card size="small">/{n(summary.generatedImageCount)} / {n(summary.generatedVideoCount)}</Card>
<Card size="small">¥{n(summary.providerCostCalculatedTotal, 8)}</Card>
<Card size="small">{n(summary.providerCostEstimatedCount)} / ¥{n(summary.providerCostEstimatedTotal, 8)}</Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small"> Token{n(summary.totalTokens)}</Card>
<Card size="small">{n(summary.providerCostPendingCount)} </Card>
<Card size="small">{n(summary.providerCostAbnormalCount)} </Card>
<Card size="small">¥{n(summary.providerCostCombinedTotal, 8)}</Card>
</div>
<Card bordered={false} style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select value={userScope} onChange={v => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
<Select value={teamFilter} onChange={v => { setPage(1); setTeamFilter(v); }} style={{ width: 170 }} options={[{ value: '', label: '全部团队' }, { value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' }, ...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name }))]} />
<Select value={recordType} onChange={v => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={v => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={v => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
<Select value={chargeKind} onChange={v => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
<Select value={sourceModule} onChange={v => { setPage(1); setSourceModule(v); }} style={{ width: 160 }} options={sourceModuleOptions} />
<Select value={sourceStepCode} onChange={v => { setPage(1); setSourceStepCode(v); }} style={{ width: 160 }} options={sourceStepOptions} />
<Select value={billingScene} onChange={v => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
<Select value={providerCostStatus} onChange={v => { setPage(1); setProviderCostStatus(v); }} style={{ width: 160 }} options={costStatusOptions} />
<Select value={hasAttachment} onChange={v => { setPage(1); setHasAttachment(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部附件' }, { value: 'true', label: '有附件' }, { value: 'false', label: '无附件' }]} />
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={e => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 190 }} allowClear />
<Input placeholder="供应商" value={engineProvider} onChange={e => { setPage(1); setEngineProvider(e.target.value); }} style={{ width: 130 }} allowClear />
<Input placeholder="模型名称" value={engineModelName} onChange={e => { setPage(1); setEngineModelName(e.target.value); }} style={{ width: 230 }} allowClear />
<Input placeholder="计价版本" value={pricingVersionCode} onChange={e => { setPage(1); setPricingVersionCode(e.target.value); }} style={{ width: 180 }} allowClear />
<DatePicker.RangePicker value={dateRange} onChange={dates => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} />
</Space>
<Space><Button onClick={handleReset}></Button><Button icon={<ReloadOutlined />} onClick={load}></Button><Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}> Excel</Button></Space>
</div>
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
<Table columns={columns} dataSource={records} rowKey="id" loading={loading} scroll={{ x: 3650 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); }, showTotal: t => `${t}` }} />
</Card>
<Drawer open={!!detail} width={860} title="积分流水财务核查" onClose={() => setDetail(null)}>
{detail && <>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="流水ID" span={2}>{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.username || '-'} / {detail.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="发生时间">{formatDate(detail.createdAt || '')}</Descriptions.Item>
<Descriptions.Item label="积分变动">{n(detail.amount, 2)}</Descriptions.Item>
<Descriptions.Item label="余额">{n(detail.balanceAfter, 2)}</Descriptions.Item>
<Descriptions.Item label="模型" span={2}>{detail.engineProvider || '-'} / {detail.engineModelName || detail.engineName || '-'}</Descriptions.Item>
<Descriptions.Item label="计价版本">{detail.pricingVersionCode || '-'}</Descriptions.Item>
<Descriptions.Item label="计价模式">{detail.pricingBillingModeLabel || '-'}</Descriptions.Item>
<Descriptions.Item label="计算器版本">{detail.pricingCalculatorVersion || '-'}</Descriptions.Item>
<Descriptions.Item label="用量来源">{detail.pricingUsageSource || '-'}</Descriptions.Item>
<Descriptions.Item label="计价参考时间">{formatDate(detail.pricingReferenceAt || '')}</Descriptions.Item>
<Descriptions.Item label="价格生效区间">{formatDate(detail.pricingEffectiveFrom || '')} {detail.pricingEffectiveTo ? formatDate(detail.pricingEffectiveTo) : '长期'}</Descriptions.Item>
<Descriptions.Item label="供应商成本"><b>{detail.providerCostCurrency || 'CNY'} {n(detail.providerCostAmount, 8)}</b></Descriptions.Item>
<Descriptions.Item label="核算状态"><Tag color={costStatusColor(detail.providerCostStatus)}>{detail.providerCostStatusLabel || '-'}</Tag></Descriptions.Item>
<Descriptions.Item label="最终核算时间">{formatDate(detail.providerCostFinalizedAt || '')}</Descriptions.Item>
<Descriptions.Item label="主供应商用量">{detail.providerUsagePrimary ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="附件统计"> {detail.attachmentImageCount} / {detail.attachmentVideoCount} / {detail.attachmentAudioCount}</Descriptions.Item>
<Descriptions.Item label="产出统计"> {detail.requestedOutputCount} / {detail.generatedImageCount} / {detail.generatedVideoCount}</Descriptions.Item>
<Descriptions.Item label="Token"> {detail.inputTokens} / {detail.outputTokens} / {detail.totalTokens}</Descriptions.Item>
<Descriptions.Item label="业务归属">{detail.ownerType || '-'} / {detail.ownerId || '-'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.pricingSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.usageSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.attachmentSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.generationSnapshotJson} />
</>}
</Drawer>
</div>;
};
export default AdminCreditRecords;
@@ -0,0 +1,146 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Drawer, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { CopyOutlined, EyeOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
createModelPricingRule,
disableModelPricingRule,
getModelPricingRules,
publishModelPricingRule,
updateModelPricingRule,
} from '../api';
import type { ModelPricingRule, ModelPricingRulePayload } from '../types';
import PricingRuleForm from '../components/modelPricing/PricingRuleForm';
import PricingRulePreview from '../components/modelPricing/PricingRulePreview';
import { formatDate } from '../utils/formatDate';
const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' },
published: { color: 'green', text: '已发布' },
disabled: { color: 'red', text: '已停用' },
};
const modeMap: Record<string, string> = {
text_token_tiered: '文本分档 Token',
image_per_output: '按成功输出图片',
image_input_output_tiered: '输入图 + 输出像素',
video_token_rate: '视频 Token',
};
const AdminModelPricingRules: React.FC = () => {
const [rows, setRows] = useState<ModelPricingRule[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [modelName, setModelName] = useState('');
const [status, setStatus] = useState('');
const [category, setCategory] = useState('');
const [editing, setEditing] = useState<ModelPricingRule | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [detail, setDetail] = useState<ModelPricingRule | null>(null);
const load = async () => {
setLoading(true);
try {
const res = await getModelPricingRules({
page,
pageSize,
modelName: modelName || undefined,
publishStatus: status || undefined,
modelCategory: category || undefined,
});
setRows(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载模型计价规则失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [page, pageSize, modelName, status, category]);
const submit = async (payload: ModelPricingRulePayload) => {
setSaving(true);
try {
if (editing?.id && editing.publishStatus === 'draft') {
await updateModelPricingRule(editing.id, payload);
} else {
await createModelPricingRule(payload);
}
message.success('价格草稿已保存');
setFormOpen(false);
setEditing(null);
await load();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const cloneRule = (rule: ModelPricingRule) => {
setEditing({
...rule,
id: '',
publishStatus: 'draft',
versionCode: `${rule.versionCode}_copy_${dayjs().format('YYYYMMDDHHmm')}`,
effectiveFrom: dayjs().add(1, 'minute').toISOString(),
effectiveTo: null,
referencedCount: 0,
});
setFormOpen(true);
};
const columns = [
{ title: '模型', dataIndex: 'modelName', width: 280, fixed: 'left' as const, render: (v: string, r: ModelPricingRule) => <div><Typography.Text strong>{v}</Typography.Text><div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider} / {r.modelCategory}</div></div> },
{ title: '价格版本', dataIndex: 'versionCode', width: 180 },
{ title: '计价模式/计算器', key: 'calculator', width: 230, render: (_: any, r: ModelPricingRule) => <div>{modeMap[r.billingMode] || r.billingMode}<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.calculatorVersion}</div></div> },
{ title: '生效时间', key: 'effective', width: 290, render: (_: any, r: ModelPricingRule) => <div>{formatDate(r.effectiveFrom)}<div style={{ color: '#94a3b8', fontSize: 12 }}> {r.effectiveTo ? formatDate(r.effectiveTo) : '长期有效'}</div></div> },
{ title: '状态', dataIndex: 'publishStatus', width: 100, render: (v: string) => <Tag color={(statusMap[v] || {}).color}>{(statusMap[v] || {}).text || v}</Tag> },
{ title: '规则Hash/引用', key: 'hash', width: 190, render: (_: any, r: ModelPricingRule) => <div>{r.ruleContentHash ? `${r.ruleContentHash.slice(0, 12)}` : '-'}<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.referencedCount || 0} </div></div> },
{ title: '来源更新时间', dataIndex: 'sourceUpdatedAt', width: 170, render: (v: string) => v ? formatDate(v) : '-' },
{ title: '操作', key: 'action', width: 310, fixed: 'right' as const, render: (_: any, r: ModelPricingRule) => <Space>
<Button size="small" icon={<EyeOutlined />} onClick={() => setDetail(r)}>/</Button>
{r.publishStatus === 'draft' && <Button size="small" onClick={() => { setEditing(r); setFormOpen(true); }}></Button>}
<Button size="small" icon={<CopyOutlined />} onClick={() => cloneRule(r)}></Button>
{r.publishStatus === 'draft' && <Popconfirm title="发布后价格正文不可修改,确认发布?" onConfirm={async () => { await publishModelPricingRule(r.id); message.success('已发布'); load(); }}><Button size="small" type="primary"></Button></Popconfirm>}
{r.publishStatus === 'published' && <Popconfirm title="停用后不再匹配新消费,历史快照不受影响。确认?" onConfirm={async () => { await disableModelPricingRule(r.id); message.success('已停用'); load(); }}><Button size="small" danger></Button></Popconfirm>}
</Space> },
];
return <div>
<Card bordered={false} style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap>
<Input allowClear placeholder="模型名称" value={modelName} onChange={e => { setPage(1); setModelName(e.target.value); }} style={{ width: 260 }} />
<Select value={category} onChange={v => { setPage(1); setCategory(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部类型' }, { value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
<Select value={status} onChange={v => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部状态' }, { value: 'draft', label: '草稿' }, { value: 'published', label: '已发布' }, { value: 'disabled', label: '已停用' }]} />
</Space>
<Space>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); setFormOpen(true); }}></Button>
</Space>
</div>
<Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1500 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
</Card>
<Modal open={formOpen} title={editing?.id ? '编辑价格草稿' : editing ? '克隆价格版本' : '新增价格版本'} width={1100} footer={null} destroyOnClose onCancel={() => { setFormOpen(false); setEditing(null); }}>
<PricingRuleForm initial={editing} loading={saving} onSubmit={submit} onCancel={() => { setFormOpen(false); setEditing(null); }} />
</Modal>
<Drawer open={!!detail} width={760} title={detail ? `${detail.modelName} / ${detail.versionCode}` : '计价详情'} onClose={() => setDetail(null)}>
{detail && <>
<Space wrap style={{ marginBottom: 12 }}><Tag>{detail.provider}</Tag><Tag>{detail.modelCategory}</Tag><Tag color="blue">{modeMap[detail.billingMode] || detail.billingMode}</Tag><Tag color={(statusMap[detail.publishStatus] || {}).color}>{(statusMap[detail.publishStatus] || {}).text}</Tag></Space>
<Typography.Paragraph>{formatDate(detail.effectiveFrom)} {detail.effectiveTo ? formatDate(detail.effectiveTo) : '长期有效'}<br />{detail.calculatorVersion}<br /> Hash{detail.ruleContentHash || '-'}</Typography.Paragraph>
<Typography.Paragraph>{detail.sourceUrl || '-'}<br />{detail.sourceUpdatedAt ? formatDate(detail.sourceUpdatedAt) : '-'}</Typography.Paragraph>
<pre style={{ background: '#f7f8fa', borderRadius: 8, padding: 12, overflow: 'auto' }}>{JSON.stringify(detail.ruleJson, null, 2)}</pre>
<PricingRulePreview billingMode={detail.billingMode} calculatorVersion={detail.calculatorVersion} ruleJson={detail.ruleJson} />
</>}
</Drawer>
</div>;
};
export default AdminModelPricingRules;
+119
View File
@@ -796,6 +796,20 @@ export interface AdminCreditRecordSummary {
totalTokens: number;
inputTokens: number;
outputTokens: number;
attachmentImageCount: number;
attachmentVideoCount: number;
attachmentAudioCount: number;
attachmentTotalCount: number;
generatedImageCount: number;
generatedVideoCount: number;
generatedTotalCount: number;
providerCostCalculatedTotal: string;
providerCostEstimatedTotal: string;
providerCostCombinedTotal: string;
providerCostTotal: string;
providerCostPendingCount: number;
providerCostEstimatedCount: number;
providerCostAbnormalCount: number;
}
export interface AdminCreditRecord {
@@ -848,6 +862,38 @@ export interface AdminCreditRecord {
engineName?: string;
engineProvider?: string;
engineModelName?: string;
pricingRuleId?: string;
pricingVersionCode?: string;
pricingBillingMode?: string;
pricingBillingModeLabel?: string;
pricingCalculatorVersion?: ModelPricingCalculatorVersion;
pricingUsageSource?: string;
pricingReferenceAt?: string;
pricingEffectiveFrom?: string;
pricingEffectiveTo?: string;
pricingSnapshotHash?: string;
providerCostCurrency?: string;
providerCostAmount?: string;
providerCostStatus?: string;
providerCostStatusLabel?: string;
providerCostCalculatedAt?: string;
providerCostFinalizedAt?: string;
providerCostIsEstimated?: boolean;
providerUsagePrimary?: boolean;
attachmentImageCount: number;
attachmentVideoCount: number;
attachmentAudioCount: number;
attachmentTotalCount: number;
attachmentVideoDurationSeconds?: string;
attachmentAudioDurationSeconds?: string;
requestedOutputCount: number;
generatedImageCount: number;
generatedVideoCount: number;
generatedTotalCount: number;
pricingSnapshotJson?: Record<string, any> | null;
usageSnapshotJson?: Record<string, any> | null;
attachmentSnapshotJson?: Record<string, any> | null;
generationSnapshotJson?: Record<string, any> | null;
createdAt?: string;
}
@@ -873,10 +919,83 @@ export interface AdminCreditRecordQueryParams {
sourceModule?: string;
sourceStepCode?: string;
billingScene?: string;
engineProvider?: string;
engineModelName?: string;
pricingVersionCode?: string;
providerCostStatus?: string;
providerCostIsEstimated?: boolean;
hasAttachment?: boolean;
startDate?: string;
endDate?: string;
}
export type ModelPricingCategory = 'text' | 'image' | 'video';
export type ModelPricingBillingMode =
| 'text_token_tiered'
| 'image_per_output'
| 'image_input_output_tiered'
| 'video_token_rate';
export type ModelPricingRuleStatus = 'draft' | 'published' | 'disabled';
export type ModelPricingCalculatorVersion =
| 'text_token_tiered_v1'
| 'image_per_output_v1'
| 'image_input_output_tiered_v1'
| 'video_pixel_token_v1';
export interface ModelPricingRule {
id: string;
provider: string;
modelName: string;
modelCategory: ModelPricingCategory;
billingMode: ModelPricingBillingMode;
calculatorVersion: ModelPricingCalculatorVersion;
versionCode: string;
effectiveFrom: string;
effectiveTo?: string | null;
publishStatus: ModelPricingRuleStatus;
currency: string;
ruleSchemaVersion: number;
ruleContentHash: string;
ruleJson: Record<string, any>;
sourceUrl?: string | null;
sourceUpdatedAt?: string | null;
remark?: string | null;
referencedCount: number;
createdAt?: string;
updatedAt?: string;
}
export interface ModelPricingRulePayload {
provider: string;
model_name: string;
model_category: ModelPricingCategory;
billing_mode: ModelPricingBillingMode;
calculator_version: ModelPricingCalculatorVersion;
version_code: string;
effective_from: string;
effective_to?: string | null;
currency: string;
rule_schema_version: number;
rule_json: Record<string, any>;
source_url?: string | null;
source_updated_at?: string | null;
remark?: string | null;
}
export interface ModelPricingRuleListResponse {
items: ModelPricingRule[];
total: number;
}
export interface ModelPricingPreviewResponse {
amount: string;
currency: string;
isEstimated: boolean;
selectedRate?: string | null;
usageSource: string;
breakdown: Record<string, any>;
}
// ── 首页素材行业装修 ──────────────────────────────────────