/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useMemo, useRef } from 'react';
import {
Table,
Button,
Modal,
Form,
Select,
Space,
Tag,
Typography,
Empty,
Popconfirm,
Input,
RadioGroup,
Radio,
TreeSelect,
} from '@douyinfe/semi-ui';
import {
IconEdit,
IconDelete,
IconChevronDown,
IconChevronRight,
IconSearch,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { channelPricingApi } from '../../../helpers/api';
import { showError, showSuccess } from '../../../helpers';
const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
const { t } = useTranslation();
const [expandedRowKeys, setExpandedRowKeys] = useState([]);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingRecord, setEditingRecord] = useState(null);
const [currentQuotaType, setCurrentQuotaType] = useState(0); // 0: 按量计费, 1: 按次计费
const [searchText, setSearchText] = useState('');
const formRef = useRef();
const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' | 'token-price'
const [tokenPrices, setTokenPrices] = useState({
inputTokenPrice: '',
outputTokenPrice: ''
});
// Ensure arrays are valid
const safeChannels = Array.isArray(channels) ? channels : [];
const safeChannelPricings = Array.isArray(channelPricings) ? channelPricings : [];
const safeTags = Array.isArray(tags) ? tags : [];
// Helper for colored tag style
const getTagStyle = (color, extra = {}) => ({
backgroundColor: color,
color: '#fff',
borderColor: color,
...extra
});
// Convert tags to TreeSelect tree structure
const tagTreeData = useMemo(() => {
return safeTags.map((tag) => ({
value: String(tag.id),
label: {tag.name},
key: String(tag.id),
color: tag.color,
}));
}, [safeTags]);
// Build model -> channel pricing mapping
const modelData = useMemo(() => {
// Build pricing lookup map
const pricingMap = new Map();
safeChannelPricings.forEach((cp) => {
const key = `${cp.model_name}:${cp.channel_id}`;
pricingMap.set(key, cp);
});
// Get all unique model names from channels' models field
const modelNames = new Set();
const channelModelsMap = new Map(); // channelId -> Set of models
safeChannels.forEach((ch) => {
if (ch.models) {
const models = typeof ch.models === 'string' ? ch.models.split(',') : ch.models;
const modelSet = new Set(models.map((m) => m.trim()).filter(Boolean));
channelModelsMap.set(ch.id, modelSet);
modelSet.forEach((modelName) => modelNames.add(modelName));
}
});
// Build model data with channel pricing info
return Array.from(modelNames).map((modelName) => {
const channelPrices = safeChannels
.filter((ch) => {
const channelModels = channelModelsMap.get(ch.id);
return channelModels && channelModels.has(modelName);
})
.map((ch) => {
const key = `${modelName}:${ch.id}`;
const pricing = pricingMap.get(key);
return {
channelId: ch.id,
channelName: ch.name,
channelType: ch.type,
pricing: pricing || null,
};
});
return {
modelName,
channelPrices,
hasChannelPricing: channelPrices.some((cp) => cp.pricing !== null),
};
});
}, [safeChannels, safeChannelPricings]);
// Filter model data by search text
const filteredModelData = useMemo(() => {
if (!searchText) return modelData;
const lowerSearch = searchText.toLowerCase();
return modelData.filter(item =>
item.modelName.toLowerCase().includes(lowerSearch)
);
}, [modelData, searchText]);
// 价格转倍率计算函数(参考 ModelSettingsVisualEditor.jsx)
const calculateRatioFromTokenPrice = (tokenPrice) => {
return tokenPrice / 2;
};
const calculateCompletionRatioFromPrices = (modelTokenPrice, completionTokenPrice) => {
if (!modelTokenPrice || modelTokenPrice === 0) {
return 0;
}
return completionTokenPrice / modelTokenPrice;
};
// 价格变化处理函数
const handleInputTokenPriceChange = (value) => {
const price = parseFloat(value) || 0;
const ratio = calculateRatioFromTokenPrice(price);
setTokenPrices(prev => ({ ...prev, inputTokenPrice: value }));
if (formRef.current) {
formRef.current.setValue('model_ratio', ratio);
}
};
const handleOutputTokenPriceChange = (value) => {
const outputPrice = parseFloat(value) || 0;
const inputPrice = parseFloat(tokenPrices.inputTokenPrice) || 0;
setTokenPrices(prev => ({ ...prev, outputTokenPrice: value }));
if (inputPrice > 0 && formRef.current) {
const completionRatio = calculateCompletionRatioFromPrices(inputPrice, outputPrice);
formRef.current.setValue('completion_ratio', completionRatio);
}
};
const handleEdit = (modelName, channelId, pricing) => {
const quotaType = pricing?.quota_type ?? 0;
setCurrentQuotaType(quotaType);
// 初始化子模式和价格(根据现有倍率反推)
if (quotaType === 0 && pricing) {
const ratio = pricing.model_ratio || 0;
const completionRatio = pricing.completion_ratio || 0;
const inputTokenPrice = ratio * 2;
const outputTokenPrice = inputTokenPrice * completionRatio;
setTokenPrices({
inputTokenPrice: inputTokenPrice.toString(),
outputTokenPrice: outputTokenPrice.toString()
});
setPricingSubMode('ratio'); // 默认显示倍率模式
} else {
setTokenPrices({ inputTokenPrice: '', outputTokenPrice: '' });
setPricingSubMode('ratio');
}
setEditingRecord({
modelName,
channelId,
pricing: pricing || {
model_name: modelName,
channel_id: channelId,
quota_type: 0,
model_ratio: 0,
completion_ratio: 0,
model_price: 0,
tag_ids: '',
},
});
setEditModalVisible(true);
};
const handleDelete = async (id) => {
try {
const res = await channelPricingApi.delete(id);
if (res.data.success) {
showSuccess(t('删除成功'));
onRefresh();
}
} catch (e) {
showError(e);
}
};
const handleSubmit = async () => {
try {
const values = await formRef.current.validate();
// Convert TreeSelect array ['1', '2'] to comma-separated string '1,2'
let tagIdsStr = '';
if (Array.isArray(values.tag_ids) && values.tag_ids.length > 0) {
tagIdsStr = values.tag_ids.filter(Boolean).join(',');
}
const data = {
model_name: editingRecord.modelName,
channel_id: editingRecord.channelId,
...values,
tag_ids: tagIdsStr,
};
if (editingRecord.pricing?.id) {
data.id = editingRecord.pricing.id;
}
const res = await channelPricingApi.create(data);
if (res.data.success) {
showSuccess(t('保存成功'));
setEditModalVisible(false);
onRefresh();
}
} catch (e) {
showError(e);
}
};
// Channel pricing sub-table columns
const channelColumns = [
{
title: t('渠道'),
dataIndex: 'channelName',
key: 'channelName',
width: 200,
},
{
title: t('计费类型'),
key: 'quotaType',
width: 100,
render: (_, record) => {
if (!record.pricing) return -;
return record.pricing.quota_type === 1 ? t('按次') : t('按量');
},
},
{
title: t('模型倍率'),
key: 'modelRatio',
width: 100,
render: (_, record) => {
if (!record.pricing) return '-';
// 按次计费时不显示倍率
if (record.pricing.quota_type === 1) return '-';
return record.pricing.model_ratio;
},
},
{
title: t('补全倍率'),
key: 'completionRatio',
width: 100,
render: (_, record) => {
if (!record.pricing) return '-';
// 按次计费时不显示倍率
if (record.pricing.quota_type === 1) return '-';
return record.pricing.completion_ratio;
},
},
{
title: t('固定价格'),
key: 'modelPrice',
width: 100,
render: (_, record) => {
if (!record.pricing) return '-';
// 按量计费时不显示固定价格
if (record.pricing.quota_type === 0) return '-';
return record.pricing.model_price;
},
},
{
title: t('标签'),
key: 'tags',
width: 150,
render: (_, record) => {
if (!record.pricing?.tags?.length) return '-';
return record.pricing.tags.map((tag) => (
{tag.name}
));
},
},
{
title: t('操作'),
key: 'action',
width: 120,
render: (_, record) => (
}
onClick={() => handleEdit(record.modelName, record.channelId, record.pricing)}
/>
{record.pricing && (
handleDelete(record.pricing.id)}
>
} type="danger" />
)}
),
},
];
// Main table columns
const mainColumns = [
{
title: t('模型名称'),
dataIndex: 'modelName',
key: 'modelName',
render: (text, record) => (
{text}
{record.hasChannelPricing && (
{t('已配置')}
)}
),
},
{
title: t('渠道数量'),
key: 'channelCount',
width: 120,
render: (_, record) =>
`${record.channelPrices.filter((cp) => cp.pricing).length} / ${record.channelPrices.length}`,
},
{
title: t('操作'),
key: 'action',
width: 80,
render: (_, record) => {
const isExpanded = expandedRowKeys.includes(record.modelName);
return (
: }
onClick={(e) => {
e.stopPropagation();
if (isExpanded) {
setExpandedRowKeys(expandedRowKeys.filter(k => k !== record.modelName));
} else {
setExpandedRowKeys([...expandedRowKeys, record.modelName]);
}
}}
>
{isExpanded ? t('收起') : t('展开')}
);
},
},
];
// Expanded row render
const expandedRowRender = (record) => {
return (
({
...cp,
key: `${record.modelName}-${cp.channelId}`,
modelName: record.modelName,
}))}
pagination={false}
size="small"
bordered
/>
);
};
if (modelData.length === 0) {
return (
);
}
// Parse tag_ids for editing (returns flat array for TreeSelect multiple mode)
const getInitialTagIds = (pricing) => {
if (!pricing) return [];
let ids = [];
// 如果有 tags 数组(包含完整标签对象),从中提取 ID
if (pricing.tags && Array.isArray(pricing.tags) && pricing.tags.length > 0) {
ids = pricing.tags.map(tag => String(tag.id));
}
// 否则从 tag_ids 字段解析
else if (pricing.tag_ids) {
if (Array.isArray(pricing.tag_ids)) {
ids = pricing.tag_ids.map(String);
} else if (typeof pricing.tag_ids === 'string' && pricing.tag_ids.length > 0) {
ids = pricing.tag_ids.split(',').map(String);
}
}
// TreeSelect multiple mode needs flat array: ['1', '2']
return ids;
};
return (
{/* Search Bar */}
}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={(value) => setSearchText(value)}
style={{ width: 250 }}
showClear
/>
{searchText && (
{t('找到 {{count}} 个模型', { count: filteredModelData.length })}
)}
{
console.log('Expanded keys:', keys);
setExpandedRowKeys(keys);
}}
expandedRowRender={expandedRowRender}
expandIcon={false}
/>
{/* Edit Modal */}
{
setEditModalVisible(false);
setPricingSubMode('ratio');
setTokenPrices({ inputTokenPrice: '', outputTokenPrice: '' });
}}
onOk={handleSubmit}
>
setCurrentQuotaType(value)}
>
{t('按量计费')}
{t('按次计费')}
{currentQuotaType === 0
? t('按量计费:根据 token 数量计费,使用模型倍率和补全倍率计算')
: t('按次计费:每次请求使用固定价格计费')}
{currentQuotaType === 0 && (
<>
{
const newMode = e.target.value;
// 切换到倍率模式时,从价格计算倍率
if (newMode === 'ratio' && formRef.current) {
const inputPrice = parseFloat(tokenPrices.inputTokenPrice) || 0;
const outputPrice = parseFloat(tokenPrices.outputTokenPrice) || 0;
if (inputPrice > 0) {
formRef.current.setValue('model_ratio', calculateRatioFromTokenPrice(inputPrice));
formRef.current.setValue('completion_ratio', calculateCompletionRatioFromPrices(inputPrice, outputPrice));
}
}
setPricingSubMode(newMode);
}}
>
{t('按倍率设置')}
{t('按价格设置')}
>
)}
{currentQuotaType === 1 && (
)}
({
isRenderInTag: false,
content: (
{node.label}
)
})}
/>
);
};
export default ChannelPricingView;