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 (
+ : }
+ 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 (
{
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);