@@ -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 [expandedRow Keys, setExpandedRow Keys] = 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) => {
safeC hannelPricings.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) => (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => toggleExpand(text)}
>
{expandedKeys.includes(text) ? (
<IconChevronDown style={{ marginRight: 8 }} />
) : (
<IconChevronRight style={{ marginRight: 8 }} />
)}
<Typography.Text strong>{text}</Typography.Text>
{record.hasChannelPricing && (
<Tag color="blue" size="small" style={{ marginLeft: 8 }}>
{t('已配置')}
</Tag>
)}
</div>
),
},
{
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) => (
<Button
size="small"
onClick={() => toggleExpand(record.modelName)}
>
{expandedKeys.includes(record.modelName) ? t('收起') : t('展开')}
</Button>
),
},
];
// Channel pricing sub-table columns
const channelColumns = [
{
title: t('渠道'),
dataIndex: 'channelName',
key: 'channelName',
render: (text) => (
<Typography.Text type="tertiary">└ {text}</Typography.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) => (
<div style={{ display: 'flex', alignItems: 'center' }}>
<Typography.Text strong>{text}</Typography.Text>
{record.hasChannelPricing && (
<Tag color="blue" size="small" style={{ marginLeft: 8 }}>
{t('已配置')}
</Tag>
)}
</div>
),
},
{
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 (
<Button
size="small"
icon={isExpanded ? <IconChevronDown /> : <IconChevronRight />}
onClick={(e) => {
e.stopPropagation();
if (isExpanded) {
setExpandedRowKeys(expandedRowKeys.filter(k => k !== record.modelName));
} else {
setExpandedRowKeys([...expandedRowKeys, record.modelName]);
}
}}
>
{isExpanded ? t('收起') : t('展开')}
</Button>
);
},
},
];
// Expanded row render
const expandedRowRender = (record) => {
return (
<Table
columns={channelColumns}
dataSource={record.channelPrices.map((cp) => ({
...cp,
key: `${record.modelName}-${cp.channelId}`,
modelName: record.modelName,
}))}
pagination={false}
size="small"
bordered
/>
);
};
if (modelData.length === 0) {
return (
<Empty
@@ -283,37 +343,37 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
return (
<div>
{/* Search Bar */}
<div style={{ marginBottom: 16 }}>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={(value) => setSearchText(value)}
style={{ width: 250 }}
showClear
/>
{searchText && (
<Typography.Text type="tertiary" style={{ marginLeft: 12 }}>
{t('找到 {{count}} 个模型', { count: filteredModelData.length })}
</Typography.Text>
)}
</div>
<Table
columns={mainColumns}
dataSource={modelData}
dataSource={filteredM odelData}
pagination={false}
rowKey="modelName"
expandedRowKeys={expandedRowKeys}
onExpandedRowsChange={(keys) => {
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 (
<div key={modelName} style={{ marginLeft: 24, marginTop: 8 }}>
<Typography.Title heading={6}>
{t('渠道定价详情')}: {modelName}
</Typography.Title>
<Table
columns={channelColumns}
dataSource={model.channelPrices.map((cp) => ({
...cp,
key: `${modelName}-${cp.channelId}`,
modelName,
}))}
pagination={false}
size="small"
/>
</div>
);
})}
{/* Edit Modal */}
<Modal
title={t('编辑渠道定价')}
@@ -341,28 +401,43 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
field="quota_type"
label={t('计费类型')}
rules={[{ required: true }]}
onChange={(value) => setCurrentQuotaType(value)}
>
<Select.Option value={0}>{t('按量计费')}</Select.Option>
<Select.Option value={1}>{t('按次计费')}</Select.Option>
</Form.Select>
<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.InputNumber
field="model_price"
label={t('固定价格')}
min={0}
step={0.01}
/>
<Typography.Text
size="small"
type="tertiary"
style={{ display: 'block', marginTop: -8, marginBottom: 8 }}
>
{currentQuotaType === 0
? t('按量计费:根据 token 数量计费,使用模型倍率和补全倍率计算')
: t('按次计费:每次请求使用固定价格计费')}
</Typography.Text>
{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.InputNumber
field="model_price"
label={t('固定价格')}
min={0}
step={0.01}
/>
)}
<Form.Select
field="tag_ids"
label={t('标签')}
@@ -371,7 +446,7 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
style={{ width: '100%' }}
placeholder={t('请选择标签')}
>
{t ags.map((tag) => (
{safeT ags.map((tag) => (
<Select.Option key={tag.id} value={String(tag.id)}>
<Tag color={tag.color}>{tag.name}</Tag>
</Select.Option>