diff --git a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx index 68778e3..edc7d60 100644 --- a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx +++ b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx @@ -29,13 +29,14 @@ import { Typography, Empty, Popconfirm, - InputNumber, + Input, } from '@douyinfe/semi-ui'; import { IconEdit, IconDelete, IconChevronDown, IconChevronRight, + IconSearch, } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; import { channelPricingApi } from '../../../helpers/api'; @@ -43,33 +44,57 @@ import { showError, showSuccess } from '../../../helpers'; const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { const { t } = useTranslation(); - const [expandedKeys, setExpandedKeys] = useState([]); + 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(); + // Ensure arrays are valid + const safeChannels = Array.isArray(channels) ? channels : []; + const safeChannelPricings = Array.isArray(channelPricings) ? channelPricings : []; + const safeTags = Array.isArray(tags) ? tags : []; + // Build model -> channel pricing mapping const modelData = useMemo(() => { + // Build pricing lookup map const pricingMap = new Map(); - channelPricings.forEach((cp) => { + safeChannelPricings.forEach((cp) => { const key = `${cp.model_name}:${cp.channel_id}`; pricingMap.set(key, cp); }); - // Get all unique model names - const modelNames = new Set(channelPricings.map((cp) => cp.model_name)); + // 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 = channels.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, - }; - }); + 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, @@ -77,9 +102,20 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { hasChannelPricing: channelPrices.some((cp) => cp.pricing !== null), }; }); - }, [channels, channelPricings]); + }, [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]); const handleEdit = (modelName, channelId, pricing) => { + const quotaType = pricing?.quota_type ?? 0; + setCurrentQuotaType(quotaType); setEditingRecord({ modelName, channelId, @@ -133,68 +169,13 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { } }; - const toggleExpand = (modelName) => { - setExpandedKeys((prev) => - prev.includes(modelName) - ? prev.filter((k) => k !== modelName) - : [...prev, modelName] - ); - }; - - const mainColumns = [ - { - title: t('模型名称'), - dataIndex: 'modelName', - key: 'modelName', - render: (text, record) => ( -
toggleExpand(text)} - > - {expandedKeys.includes(text) ? ( - - ) : ( - - )} - {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: 100, - render: (_, record) => ( - - ), - }, - ]; - + // Channel pricing sub-table columns const channelColumns = [ { title: t('渠道'), dataIndex: 'channelName', key: 'channelName', - render: (text) => ( - └ {text} - ), + width: 200, }, { title: t('计费类型'), @@ -206,25 +187,37 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { }, }, { - title: t('输入倍率'), + title: t('模型倍率'), key: 'modelRatio', width: 100, - render: (_, record) => - record.pricing ? record.pricing.model_ratio : '-', + render: (_, record) => { + if (!record.pricing) return '-'; + // 按次计费时不显示倍率 + if (record.pricing.quota_type === 1) return '-'; + return record.pricing.model_ratio; + }, }, { - title: t('输出倍率'), + title: t('补全倍率'), key: 'completionRatio', width: 100, - render: (_, record) => - record.pricing ? record.pricing.completion_ratio : '-', + 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) => - record.pricing?.quota_type === 1 ? record.pricing.model_price : '-', + render: (_, record) => { + if (!record.pricing) return '-'; + // 按量计费时不显示固定价格 + if (record.pricing.quota_type === 0) return '-'; + return record.pricing.model_price; + }, }, { title: t('标签'), @@ -263,6 +256,73 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { }, ]; + // 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 ( + + ); + }, + }, + ]; + + // 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 ( { 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} /> - {/* Expanded channel pricing details */} - {expandedKeys.map((modelName) => { - const model = modelData.find((m) => m.modelName === modelName); - if (!model) return null; - - return ( -
- - {t('渠道定价详情')}: {modelName} - -
({ - ...cp, - key: `${modelName}-${cp.channelId}`, - modelName, - }))} - pagination={false} - size="small" - /> - - ); - })} - {/* Edit Modal */} { field="quota_type" label={t('计费类型')} rules={[{ required: true }]} + onChange={(value) => setCurrentQuotaType(value)} > {t('按量计费')} {t('按次计费')} - - - + + {currentQuotaType === 0 + ? t('按量计费:根据 token 数量计费,使用模型倍率和补全倍率计算') + : t('按次计费:每次请求使用固定价格计费')} + + {currentQuotaType === 0 ? ( + <> + + + + ) : ( + + )} { style={{ width: '100%' }} placeholder={t('请选择标签')} > - {tags.map((tag) => ( + {safeTags.map((tag) => ( {tag.name} diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx index b6dd112..6025192 100644 --- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx +++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx @@ -103,7 +103,9 @@ export default function ModelSettingsVisualEditor(props) { try { const res = await API.get('/api/channel/'); if (res.data.success) { - setChannels(res.data.data || []); + // API returns {items: [...], total: ...}, extract items array + const data = res.data.data; + setChannels(Array.isArray(data) ? data : (data?.items || [])); } } catch (e) { console.error('Failed to load channels', e);