Browse Source

fix: improve channel pricing UI and fix ratio naming

- Rename "输入倍率" to "模型倍率" for model_ratio field
- Rename "输出倍率" to "补全倍率" for completion_ratio field
- Update help text to reflect accurate pricing calculation
- Add search functionality for model filtering
- Improve table expansion behavior
- Fix channel data loading in ModelSettingsVisualEditor
- Conditionally show ratio/price fields based on quota type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat/alipay-payment
fengsilin 1 month ago
parent
commit
69713a92bd
2 changed files with 203 additions and 126 deletions
  1. +200
    -125
      web/src/pages/Setting/Ratio/ChannelPricingView.jsx
  2. +3
    -1
      web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx

+ 200
- 125
web/src/pages/Setting/Ratio/ChannelPricingView.jsx View File

@@ -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) => (
<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={filteredModelData}
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('请选择标签')}
>
{tags.map((tag) => (
{safeTags.map((tag) => (
<Select.Option key={tag.id} value={String(tag.id)}>
<Tag color={tag.color}>{tag.name}</Tag>
</Select.Option>


+ 3
- 1
web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx View File

@@ -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);


Loading…
Cancel
Save