@@ -30,6 +30,9 @@ import {
Empty,
Popconfirm,
Input,
RadioGroup,
Radio,
TreeSelect,
} from '@douyinfe/semi-ui';
import {
IconEdit,
@@ -50,12 +53,35 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
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 style={getTagStyle(tag.color)}>{tag.name}</Tag>,
key: String(tag.id),
color: tag.color,
}));
}, [safeTags]);
// Build model -> channel pricing mapping
const modelData = useMemo(() => {
// Build pricing lookup map
@@ -113,9 +139,59 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
);
}, [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,
@@ -147,11 +223,18 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
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: Array.isArray(values.tag_ids) ? values.tag_ids.join(',') : '',
tag_ids: tagIdsStr ,
};
if (editingRecord.pricing?.id) {
@@ -226,7 +309,7 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
render: (_, record) => {
if (!record.pricing?.tags?.length) return '-';
return record.pricing.tags.map((tag) => (
<Tag key={tag.id} color={tag.color} size="small" style={{ marginRight: 4 }}>
<Tag key={tag.id} size="small" style={getTagStyle(tag.color, { marginRight: 4 }) }>
{tag.name}
</Tag>
));
@@ -331,14 +414,27 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
);
}
// Parse tag_ids for editing
// Parse tag_ids for editing (returns flat array for TreeSelect multiple mode)
const getInitialTagIds = (pricing) => {
if (!pricing?.tag_ids) return [];
if (Array.isArray(pricing.tag_ids)) return pricing.tag_ids.map(String);
if (typeof pricing.tag_ids === 'string' && pricing.tag_ids.length > 0) {
return pricing.tag_ids.split(',').map(String);
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);
}
}
return [];
// TreeSelect multiple mode needs flat array: ['1', '2']
return ids;
};
return (
@@ -378,10 +474,15 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
<Modal
title={t('编辑渠道定价')}
visible={editModalVisible}
onCancel={() => setEditModalVisible(false)}
onCancel={() => {
setEditModalVisible(false);
setPricingSubMode('ratio');
setTokenPrices({ inputTokenPrice: '', outputTokenPrice: '' });
}}
onOk={handleSubmit}
>
<Form
key={editingRecord?.pricing?.id || 'new'}
getFormApi={(api) => (formRef.current = api)}
initValues={{
quota_type: editingRecord?.pricing?.quota_type ?? 0,
@@ -415,22 +516,69 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
? t('按量计费:根据 token 数量计费,使用模型倍率和补全倍率计算')
: t('按次计费:每次请求使用固定价格计费')}
</Typography.Text>
{currentQuotaType === 0 ? (
{currentQuotaType === 0 && (
<>
<Form.InputNumber
field="model_ratio"
label={t('模型倍率')}
min={0}
step={0.1}
/>
<Form.InputNumber
field="completion_ratio"
label={t('补全倍率')}
min={0}
step={0.1}
/>
<Form.Section text={t('价格设置方式')}>
<div style={{ marginBottom: '16px' }}>
<RadioGroup
type="button"
value={pricingSubMode}
onChange={(e) => {
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);
}}
>
<Radio value="ratio">{t('按倍率设置')}</Radio>
<Radio value="token-price">{t('按价格设置')}</Radio>
</RadioGroup>
</div>
</Form.Section>
<div style={{ display: pricingSubMode === 'ratio' ? 'block' : 'none' }}>
<Form.InputNumber
field="model_ratio"
label={t('模型倍率')}
min={0}
step={0.1}
/>
<Form.InputNumber
field="completion_ratio"
label={t('补全倍率')}
min={0}
step={0.1}
/>
</div>
<div style={{ display: pricingSubMode === 'token-price' ? 'block' : 'none' }}>
<Form.Input
field="input_token_price"
label={t('输入价格')}
suffix={t('$/1M tokens')}
placeholder="0"
onChange={handleInputTokenPriceChange}
initValue={tokenPrices.inputTokenPrice}
/>
<Form.Input
field="output_token_price"
label={t('输出价格')}
suffix={t('$/1M tokens')}
placeholder="0"
onChange={handleOutputTokenPriceChange}
initValue={tokenPrices.outputTokenPrice}
/>
</div>
</>
) : (
)}
{currentQuotaType === 1 && (
<Form.InputNumber
field="model_price"
label={t('固定价格')}
@@ -438,20 +586,24 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
step={0.01}
/>
)}
<Form.Select
<Form.Tree Select
field="tag_ids"
label={t('标签')}
multiple
filter
filterTreeNode
style={{ width: '100%' }}
placeholder={t('请选择标签')}
>
{safeTags.map((tag) => (
<Select.Option key={tag.id} value={String(tag.id)}>
<Tag color={tag.color}>{tag.name}</Tag>
</Select.Option>
))}
</Form.Select>
treeData={tagTreeData}
leafOnly
renderSelectedItem={(node, { onClose }) => ({
isRenderInTag: false,
content: (
<Tag closable onClose={onClose} style={getTagStyle(node.color, { marginRight: 4 })}>
{node.label}
</Tag>
)
})}
/>
</Form>
</Modal>
</div>