/* 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) => ( ); }, }, ]; // 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} >
(formRef.current = api)} initValues={{ quota_type: editingRecord?.pricing?.quota_type ?? 0, model_ratio: editingRecord?.pricing?.model_ratio ?? 0, completion_ratio: editingRecord?.pricing?.completion_ratio ?? 0, model_price: editingRecord?.pricing?.model_price ?? 0, tag_ids: getInitialTagIds(editingRecord?.pricing), cache_ratio: editingRecord?.pricing?.cache_ratio ?? 0, cache_creation_ratio: editingRecord?.pricing?.cache_creation_ratio ?? 0, image_ratio: editingRecord?.pricing?.image_ratio ?? 0, audio_ratio: editingRecord?.pricing?.audio_ratio ?? 0, audio_completion_ratio: editingRecord?.pricing?.audio_completion_ratio ?? 0, }} > 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;