fix: update language settings and improve model pricing editor for better clarity and functionality

This commit is contained in:
CaIon
2026-03-06 21:36:51 +08:00
parent 782124510a
commit 8186ed0ea5
23 changed files with 2217 additions and 4422 deletions

View File

@@ -265,7 +265,7 @@ export default function GroupRatioSettings(props) {
</Col>
</Row>
</Form>
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
<Button onClick={onSubmit}>{t('保存分组相关设置')}</Button>
</Spin>
);
}

View File

@@ -18,47 +18,13 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Input,
Modal,
Form,
Space,
Typography,
Radio,
Notification,
} from '@douyinfe/semi-ui';
import {
IconDelete,
IconPlus,
IconSearch,
IconSave,
IconBolt,
} from '@douyinfe/semi-icons';
import { API, showError, showSuccess } from '../../../helpers';
import { API, showError } from '../../../helpers';
import { useTranslation } from 'react-i18next';
import ModelPricingEditor from './components/ModelPricingEditor';
export default function ModelRatioNotSetEditor(props) {
const { t } = useTranslation();
const [models, setModels] = useState([]);
const [visible, setVisible] = useState(false);
const [batchVisible, setBatchVisible] = useState(false);
const [currentModel, setCurrentModel] = useState(null);
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [loading, setLoading] = useState(false);
const [enabledModels, setEnabledModels] = useState([]);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [batchFillType, setBatchFillType] = useState('ratio');
const [batchFillValue, setBatchFillValue] = useState('');
const [batchRatioValue, setBatchRatioValue] = useState('');
const [batchCompletionRatioValue, setBatchCompletionRatioValue] =
useState('');
const { Text } = Typography;
// 定义可选的每页显示条数
const pageSizeOptions = [10, 20, 50, 100];
const getAllEnabledModels = async () => {
try {
@@ -79,540 +45,20 @@ export default function ModelRatioNotSetEditor(props) {
// 获取所有启用的模型
getAllEnabledModels();
}, []);
useEffect(() => {
try {
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
const modelRatio = JSON.parse(props.options.ModelRatio || '{}');
const completionRatio = JSON.parse(props.options.CompletionRatio || '{}');
// 找出所有未设置价格和倍率的模型
const unsetModels = enabledModels.filter((modelName) => {
const hasPrice = modelPrice[modelName] !== undefined;
const hasRatio = modelRatio[modelName] !== undefined;
// 如果模型没有价格或者没有倍率设置,则显示
return !hasPrice && !hasRatio;
});
// 创建模型数据
const modelData = unsetModels.map((name) => ({
name,
price: modelPrice[name] || '',
ratio: modelRatio[name] || '',
completionRatio: completionRatio[name] || '',
}));
setModels(modelData);
// 清空选择
setSelectedRowKeys([]);
} catch (error) {
console.error(t('JSON解析错误:'), error);
}
}, [props.options, enabledModels]);
// 首先声明分页相关的工具函数
const getPagedData = (data, currentPage, pageSize) => {
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
return data.slice(start, end);
};
// 处理页面大小变化
const handlePageSizeChange = (size) => {
setPageSize(size);
// 重新计算当前页,避免数据丢失
const totalPages = Math.ceil(filteredModels.length / size);
if (currentPage > totalPages) {
setCurrentPage(totalPages || 1);
}
};
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) =>
searchText ? model.name.includes(searchText) : true,
);
// 然后基于过滤后的数据计算分页数据
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
const SubmitData = async () => {
setLoading(true);
const output = {
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
};
try {
// 数据转换 - 只处理已修改的模型
models.forEach((model) => {
// 只有当用户设置了值时才更新
if (model.price !== '') {
// 如果价格不为空,则转换为浮点数,忽略倍率参数
output.ModelPrice[model.name] = parseFloat(model.price);
} else {
if (model.ratio !== '')
output.ModelRatio[model.name] = parseFloat(model.ratio);
if (model.completionRatio !== '')
output.CompletionRatio[model.name] = parseFloat(
model.completionRatio,
);
}
});
// 准备API请求数组
const finalOutput = {
ModelPrice: JSON.stringify(output.ModelPrice, null, 2),
ModelRatio: JSON.stringify(output.ModelRatio, null, 2),
CompletionRatio: JSON.stringify(output.CompletionRatio, null, 2),
};
const requestQueue = Object.entries(finalOutput).map(([key, value]) => {
return API.put('/api/option/', {
key,
value,
});
});
// 批量处理请求
const results = await Promise.all(requestQueue);
// 验证结果
if (requestQueue.length === 1) {
if (results.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (results.includes(undefined)) {
return showError(t('部分保存失败,请重试'));
}
}
// 检查每个请求的结果
for (const res of results) {
if (!res.data.success) {
return showError(res.data.message);
}
}
showSuccess(t('保存成功'));
props.refresh();
// 重新获取未设置的模型
getAllEnabledModels();
} catch (error) {
console.error(t('保存失败:'), error);
showError(t('保存失败,请重试'));
} finally {
setLoading(false);
}
};
const columns = [
{
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
},
{
title: t('模型固定价格'),
dataIndex: 'price',
key: 'price',
render: (text, record) => (
<Input
value={text}
placeholder={t('按量计费')}
onChange={(value) => updateModel(record.name, 'price', value)}
/>
),
},
{
title: t('模型倍率'),
dataIndex: 'ratio',
key: 'ratio',
render: (text, record) => (
<Input
value={text}
placeholder={record.price !== '' ? t('模型倍率') : t('输入模型倍率')}
disabled={record.price !== ''}
onChange={(value) => updateModel(record.name, 'ratio', value)}
/>
),
},
{
title: t('补全倍率'),
dataIndex: 'completionRatio',
key: 'completionRatio',
render: (text, record) => (
<Input
value={text}
placeholder={record.price !== '' ? t('补全倍率') : t('输入补全倍率')}
disabled={record.price !== ''}
onChange={(value) =>
updateModel(record.name, 'completionRatio', value)
}
/>
),
},
];
const updateModel = (name, field, value) => {
if (value !== '' && isNaN(value)) {
showError(t('请输入数字'));
return;
}
setModels((prev) =>
prev.map((model) =>
model.name === name ? { ...model, [field]: value } : model,
),
);
};
const addModel = (values) => {
// 检查模型名称是否存在, 如果存在则拒绝添加
if (models.some((model) => model.name === values.name)) {
showError(t('模型名称已存在'));
return;
}
setModels((prev) => [
{
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
},
...prev,
]);
setVisible(false);
showSuccess(t('添加成功'));
};
// 批量填充功能
const handleBatchFill = () => {
if (selectedRowKeys.length === 0) {
showError(t('请先选择需要批量设置的模型'));
return;
}
if (batchFillType === 'bothRatio') {
if (batchRatioValue === '' || batchCompletionRatioValue === '') {
showError(t('请输入模型倍率和补全倍率'));
return;
}
if (isNaN(batchRatioValue) || isNaN(batchCompletionRatioValue)) {
showError(t('请输入有效的数字'));
return;
}
} else {
if (batchFillValue === '') {
showError(t('请输入填充值'));
return;
}
if (isNaN(batchFillValue)) {
showError(t('请输入有效的数字'));
return;
}
}
// 根据选择的类型批量更新模型
setModels((prev) =>
prev.map((model) => {
if (selectedRowKeys.includes(model.name)) {
if (batchFillType === 'price') {
return {
...model,
price: batchFillValue,
ratio: '',
completionRatio: '',
};
} else if (batchFillType === 'ratio') {
return {
...model,
price: '',
ratio: batchFillValue,
};
} else if (batchFillType === 'completionRatio') {
return {
...model,
price: '',
completionRatio: batchFillValue,
};
} else if (batchFillType === 'bothRatio') {
return {
...model,
price: '',
ratio: batchRatioValue,
completionRatio: batchCompletionRatioValue,
};
}
}
return model;
}),
);
setBatchVisible(false);
Notification.success({
title: t('批量设置成功'),
content: t('已为 {{count}} 个模型设置{{type}}', {
count: selectedRowKeys.length,
type:
batchFillType === 'price'
? t('固定价格')
: batchFillType === 'ratio'
? t('模型倍率')
: batchFillType === 'completionRatio'
? t('补全倍率')
: t('模型倍率和补全倍率'),
}),
duration: 3,
});
};
const handleBatchTypeChange = (value) => {
console.log(t('Changing batch type to:'), value);
setBatchFillType(value);
// 切换类型时清空对应的值
if (value !== 'bothRatio') {
setBatchFillValue('');
} else {
setBatchRatioValue('');
setBatchCompletionRatioValue('');
}
};
const rowSelection = {
selectedRowKeys,
onChange: (selectedKeys) => {
setSelectedRowKeys(selectedKeys);
},
};
return (
<>
<Space vertical align='start' style={{ width: '100%' }}>
<Space className='mt-2'>
<Button icon={<IconPlus />} onClick={() => setVisible(true)}>
{t('添加模型')}
</Button>
<Button
icon={<IconBolt />}
type='secondary'
onClick={() => setBatchVisible(true)}
disabled={selectedRowKeys.length === 0}
>
{t('批量设置')} ({selectedRowKeys.length})
</Button>
<Button
type='primary'
icon={<IconSave />}
onClick={SubmitData}
loading={loading}
>
{t('应用更改')}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={(value) => {
setSearchText(value);
setCurrentPage(1);
}}
style={{ width: 200 }}
/>
</Space>
<Text>
{t('此页面仅显示未设置价格或倍率的模型,设置后将自动从列表中移除')}
</Text>
<Table
columns={columns}
dataSource={pagedData}
rowSelection={rowSelection}
rowKey='name'
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: filteredModels.length,
onPageChange: (page) => setCurrentPage(page),
onPageSizeChange: handlePageSizeChange,
pageSizeOptions: pageSizeOptions,
showTotal: true,
showSizeChanger: true,
}}
empty={
<div style={{ textAlign: 'center', padding: '20px' }}>
{t('没有未设置的模型')}
</div>
}
/>
</Space>
{/* 添加模型弹窗 */}
<Modal
title={t('添加模型')}
visible={visible}
onCancel={() => setVisible(false)}
onOk={() => {
currentModel && addModel(currentModel);
}}
>
<Form>
<Form.Input
field='name'
label={t('模型名称')}
placeholder='strawberry'
required
onChange={(value) =>
setCurrentModel((prev) => ({ ...prev, name: value }))
}
/>
<Form.Switch
field='priceMode'
label={
<>
{t('定价模式')}
{currentModel?.priceMode ? t('固定价格') : t('倍率模式')}
</>
}
onChange={(checked) => {
setCurrentModel((prev) => ({
...prev,
price: '',
ratio: '',
completionRatio: '',
priceMode: checked,
}));
}}
/>
{currentModel?.priceMode ? (
<Form.Input
field='price'
label={t('固定价格(每次)')}
placeholder={t('输入每次价格')}
onChange={(value) =>
setCurrentModel((prev) => ({ ...prev, price: value }))
}
/>
) : (
<>
<Form.Input
field='ratio'
label={t('模型倍率')}
placeholder={t('输入模型倍率')}
onChange={(value) =>
setCurrentModel((prev) => ({ ...prev, ratio: value }))
}
/>
<Form.Input
field='completionRatio'
label={t('补全倍率')}
placeholder={t('输入补全价格')}
onChange={(value) =>
setCurrentModel((prev) => ({
...prev,
completionRatio: value,
}))
}
/>
</>
)}
</Form>
</Modal>
{/* 批量设置弹窗 */}
<Modal
title={t('批量设置模型参数')}
visible={batchVisible}
onCancel={() => setBatchVisible(false)}
onOk={handleBatchFill}
width={500}
>
<Form>
<Form.Section text={t('设置类型')}>
<div style={{ marginBottom: '16px' }}>
<Space>
<Radio
checked={batchFillType === 'price'}
onChange={() => handleBatchTypeChange('price')}
>
{t('固定价格')}
</Radio>
<Radio
checked={batchFillType === 'ratio'}
onChange={() => handleBatchTypeChange('ratio')}
>
{t('模型倍率')}
</Radio>
<Radio
checked={batchFillType === 'completionRatio'}
onChange={() => handleBatchTypeChange('completionRatio')}
>
{t('补全倍率')}
</Radio>
<Radio
checked={batchFillType === 'bothRatio'}
onChange={() => handleBatchTypeChange('bothRatio')}
>
{t('模型倍率和补全倍率同时设置')}
</Radio>
</Space>
</div>
</Form.Section>
{batchFillType === 'bothRatio' ? (
<>
<Form.Input
field='batchRatioValue'
label={t('模型倍率值')}
placeholder={t('请输入模型倍率')}
value={batchRatioValue}
onChange={(value) => setBatchRatioValue(value)}
/>
<Form.Input
field='batchCompletionRatioValue'
label={t('补全倍率值')}
placeholder={t('请输入补全倍率')}
value={batchCompletionRatioValue}
onChange={(value) => setBatchCompletionRatioValue(value)}
/>
</>
) : (
<Form.Input
field='batchFillValue'
label={
batchFillType === 'price'
? t('固定价格值')
: batchFillType === 'ratio'
? t('模型倍率值')
: t('补全倍率值')
}
placeholder={t('请输入数值')}
value={batchFillValue}
onChange={(value) => setBatchFillValue(value)}
/>
)}
<Text type='tertiary'>
{t('将为选中的 ')} <Text strong>{selectedRowKeys.length}</Text>{' '}
{t(' 个模型设置相同的值')}
</Text>
<div style={{ marginTop: '8px' }}>
<Text type='tertiary'>
{t('当前设置类型: ')}{' '}
<Text strong>
{batchFillType === 'price'
? t('固定价格')
: batchFillType === 'ratio'
? t('模型倍率')
: batchFillType === 'completionRatio'
? t('补全倍率')
: t('模型倍率和补全倍率')}
</Text>
</Text>
</div>
</Form>
</Modal>
</>
<ModelPricingEditor
options={props.options}
refresh={props.refresh}
candidateModelNames={enabledModels}
filterMode='unset'
allowAddModel={false}
allowDeleteModel={false}
showConflictFilter={false}
listDescription={t(
'此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出',
)}
emptyTitle={t('没有未设置定价的模型')}
emptyDescription={t('当前没有未设置定价的模型')}
/>
);
}

View File

@@ -17,741 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import {
Table,
Button,
Input,
Modal,
Form,
Space,
RadioGroup,
Radio,
Checkbox,
Tag,
} from '@douyinfe/semi-ui';
import {
IconDelete,
IconPlus,
IconSearch,
IconSave,
IconEdit,
} from '@douyinfe/semi-icons';
import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers';
import { useTranslation } from 'react-i18next';
import React from 'react';
import ModelPricingEditor from './components/ModelPricingEditor';
export default function ModelSettingsVisualEditor(props) {
const { t } = useTranslation();
const [models, setModels] = useState([]);
const [visible, setVisible] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [currentModel, setCurrentModel] = useState(null);
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const [pricingMode, setPricingMode] = useState('per-token'); // 'per-token' or 'per-request'
const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' or 'token-price'
const [conflictOnly, setConflictOnly] = useState(false);
const formRef = useRef(null);
const pageSize = 10;
const quotaPerUnit = getQuotaPerUnit();
useEffect(() => {
try {
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
const modelRatio = JSON.parse(props.options.ModelRatio || '{}');
const completionRatio = JSON.parse(props.options.CompletionRatio || '{}');
// 合并所有模型名称
const modelNames = new Set([
...Object.keys(modelPrice),
...Object.keys(modelRatio),
...Object.keys(completionRatio),
]);
const modelData = Array.from(modelNames).map((name) => {
const price = modelPrice[name] === undefined ? '' : modelPrice[name];
const ratio = modelRatio[name] === undefined ? '' : modelRatio[name];
const comp =
completionRatio[name] === undefined ? '' : completionRatio[name];
return {
name,
price,
ratio,
completionRatio: comp,
hasConflict: price !== '' && (ratio !== '' || comp !== ''),
};
});
setModels(modelData);
} catch (error) {
console.error('JSON解析错误:', error);
}
}, [props.options]);
// 首先声明分页相关的工具函数
const getPagedData = (data, currentPage, pageSize) => {
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
return data.slice(start, end);
};
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) => {
const keywordMatch = searchText ? model.name.includes(searchText) : true;
const conflictMatch = conflictOnly ? model.hasConflict : true;
return keywordMatch && conflictMatch;
});
// 然后基于过滤后的数据计算分页数据
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
const SubmitData = async () => {
setLoading(true);
const output = {
ModelPrice: {},
ModelRatio: {},
CompletionRatio: {},
};
let currentConvertModelName = '';
try {
// 数据转换
models.forEach((model) => {
currentConvertModelName = model.name;
if (model.price !== '') {
// 如果价格不为空,则转换为浮点数,忽略倍率参数
output.ModelPrice[model.name] = parseFloat(model.price);
} else {
if (model.ratio !== '')
output.ModelRatio[model.name] = parseFloat(model.ratio);
if (model.completionRatio !== '')
output.CompletionRatio[model.name] = parseFloat(
model.completionRatio,
);
}
});
// 准备API请求数组
const finalOutput = {
ModelPrice: JSON.stringify(output.ModelPrice, null, 2),
ModelRatio: JSON.stringify(output.ModelRatio, null, 2),
CompletionRatio: JSON.stringify(output.CompletionRatio, null, 2),
};
const requestQueue = Object.entries(finalOutput).map(([key, value]) => {
return API.put('/api/option/', {
key,
value,
});
});
// 批量处理请求
const results = await Promise.all(requestQueue);
// 验证结果
if (requestQueue.length === 1) {
if (results.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (results.includes(undefined)) {
return showError('部分保存失败,请重试');
}
}
// 检查每个请求的结果
for (const res of results) {
if (!res.data.success) {
return showError(res.data.message);
}
}
showSuccess('保存成功');
props.refresh();
} catch (error) {
console.error('保存失败:', error);
showError('保存失败,请重试');
} finally {
setLoading(false);
}
};
const columns = [
{
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<span>
{text}
{record.hasConflict && (
<Tag color='red' shape='circle' className='ml-2'>
{t('矛盾')}
</Tag>
)}
</span>
),
},
{
title: t('模型固定价格'),
dataIndex: 'price',
key: 'price',
render: (text, record) => (
<Input
value={text}
placeholder={t('按量计费')}
onChange={(value) => updateModel(record.name, 'price', value)}
/>
),
},
{
title: t('模型倍率'),
dataIndex: 'ratio',
key: 'ratio',
render: (text, record) => (
<Input
value={text}
placeholder={record.price !== '' ? t('模型倍率') : t('默认补全倍率')}
disabled={record.price !== ''}
onChange={(value) => updateModel(record.name, 'ratio', value)}
/>
),
},
{
title: t('补全倍率'),
dataIndex: 'completionRatio',
key: 'completionRatio',
render: (text, record) => (
<Input
value={text}
placeholder={record.price !== '' ? t('补全倍率') : t('默认补全倍率')}
disabled={record.price !== ''}
onChange={(value) =>
updateModel(record.name, 'completionRatio', value)
}
/>
),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Space>
<Button
type='primary'
icon={<IconEdit />}
onClick={() => editModel(record)}
></Button>
<Button
icon={<IconDelete />}
type='danger'
onClick={() => deleteModel(record.name)}
/>
</Space>
),
},
];
const updateModel = (name, field, value) => {
if (isNaN(value)) {
showError('请输入数字');
return;
}
setModels((prev) =>
prev.map((model) => {
if (model.name !== name) return model;
const updated = { ...model, [field]: value };
updated.hasConflict =
updated.price !== '' &&
(updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
};
const deleteModel = (name) => {
setModels((prev) => prev.filter((model) => model.name !== name));
};
const calculateRatioFromTokenPrice = (tokenPrice) => {
return tokenPrice / 2;
};
const calculateCompletionRatioFromPrices = (
modelTokenPrice,
completionTokenPrice,
) => {
if (!modelTokenPrice || modelTokenPrice === '0') {
showError('模型价格不能为0');
return '';
}
return completionTokenPrice / modelTokenPrice;
};
const handleTokenPriceChange = (value) => {
// Use a temporary variable to hold the new state
let newState = {
...(currentModel || {}),
tokenPrice: value,
ratio: 0,
};
if (!isNaN(value) && value !== '') {
const tokenPrice = parseFloat(value);
const ratio = calculateRatioFromTokenPrice(tokenPrice);
newState.ratio = ratio;
}
// Set the state with the complete updated object
setCurrentModel(newState);
};
const handleCompletionTokenPriceChange = (value) => {
// Use a temporary variable to hold the new state
let newState = {
...(currentModel || {}),
completionTokenPrice: value,
completionRatio: 0,
};
if (!isNaN(value) && value !== '' && currentModel?.tokenPrice) {
const completionTokenPrice = parseFloat(value);
const modelTokenPrice = parseFloat(currentModel.tokenPrice);
if (modelTokenPrice > 0) {
const completionRatio = calculateCompletionRatioFromPrices(
modelTokenPrice,
completionTokenPrice,
);
newState.completionRatio = completionRatio;
}
}
// Set the state with the complete updated object
setCurrentModel(newState);
};
const addOrUpdateModel = (values) => {
// Check if we're editing an existing model or adding a new one
const existingModelIndex = models.findIndex(
(model) => model.name === values.name,
);
if (existingModelIndex >= 0) {
// Update existing model
setModels((prev) =>
prev.map((model, index) => {
if (index !== existingModelIndex) return model;
const updated = {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
};
updated.hasConflict =
updated.price !== '' &&
(updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
setVisible(false);
showSuccess(t('更新成功'));
} else {
// Add new model
// Check if model name already exists
if (models.some((model) => model.name === values.name)) {
showError(t('模型名称已存在'));
return;
}
setModels((prev) => {
const newModel = {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
};
newModel.hasConflict =
newModel.price !== '' &&
(newModel.ratio !== '' || newModel.completionRatio !== '');
return [newModel, ...prev];
});
setVisible(false);
showSuccess(t('添加成功'));
}
};
const calculateTokenPriceFromRatio = (ratio) => {
return ratio * 2;
};
const resetModalState = () => {
setCurrentModel(null);
setPricingMode('per-token');
setPricingSubMode('ratio');
setIsEditMode(false);
};
const editModel = (record) => {
setIsEditMode(true);
// Determine which pricing mode to use based on the model's current configuration
let initialPricingMode = 'per-token';
let initialPricingSubMode = 'ratio';
if (record.price !== '') {
initialPricingMode = 'per-request';
} else {
initialPricingMode = 'per-token';
// We default to ratio mode, but could set to token-price if needed
}
// Set the pricing modes for the form
setPricingMode(initialPricingMode);
setPricingSubMode(initialPricingSubMode);
// Create a copy of the model data to avoid modifying the original
const modelCopy = { ...record };
// If the model has ratio data and we want to populate token price fields
if (record.ratio) {
modelCopy.tokenPrice = calculateTokenPriceFromRatio(
parseFloat(record.ratio),
).toString();
if (record.completionRatio) {
modelCopy.completionTokenPrice = (
parseFloat(modelCopy.tokenPrice) * parseFloat(record.completionRatio)
).toString();
}
}
// Set the current model
setCurrentModel(modelCopy);
// Open the modal
setVisible(true);
// Use setTimeout to ensure the form is rendered before setting values
setTimeout(() => {
if (formRef.current) {
// Update the form fields based on pricing mode
const formValues = {
name: modelCopy.name,
};
if (initialPricingMode === 'per-request') {
formValues.priceInput = modelCopy.price;
} else if (initialPricingMode === 'per-token') {
formValues.ratioInput = modelCopy.ratio;
formValues.completionRatioInput = modelCopy.completionRatio;
formValues.modelTokenPrice = modelCopy.tokenPrice;
formValues.completionTokenPrice = modelCopy.completionTokenPrice;
}
formRef.current.setValues(formValues);
}
}, 0);
};
return (
<>
<Space vertical align='start' style={{ width: '100%' }}>
<Space className='mt-2'>
<Button
icon={<IconPlus />}
onClick={() => {
resetModalState();
setVisible(true);
}}
>
{t('添加模型')}
</Button>
<Button type='primary' icon={<IconSave />} onClick={SubmitData}>
{t('应用更改')}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={(value) => {
setSearchText(value);
setCurrentPage(1);
}}
style={{ width: 200 }}
showClear
/>
<Checkbox
checked={conflictOnly}
onChange={(e) => {
setConflictOnly(e.target.checked);
setCurrentPage(1);
}}
>
{t('仅显示矛盾倍率')}
</Checkbox>
</Space>
<Table
columns={columns}
dataSource={pagedData}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: filteredModels.length,
onPageChange: (page) => setCurrentPage(page),
showTotal: true,
showSizeChanger: false,
}}
/>
</Space>
<Modal
title={isEditMode ? t('编辑模型') : t('添加模型')}
visible={visible}
onCancel={() => {
resetModalState();
setVisible(false);
}}
onOk={() => {
if (currentModel) {
// If we're in token price mode, make sure ratio values are properly set
const valuesToSave = { ...currentModel };
if (
pricingMode === 'per-token' &&
pricingSubMode === 'token-price' &&
currentModel.tokenPrice
) {
// Calculate and set ratio from token price
const tokenPrice = parseFloat(currentModel.tokenPrice);
valuesToSave.ratio = (tokenPrice / 2).toString();
// Calculate and set completion ratio if both token prices are available
if (
currentModel.completionTokenPrice &&
currentModel.tokenPrice
) {
const completionPrice = parseFloat(
currentModel.completionTokenPrice,
);
const modelPrice = parseFloat(currentModel.tokenPrice);
if (modelPrice > 0) {
valuesToSave.completionRatio = (
completionPrice / modelPrice
).toString();
}
}
}
// Clear price if we're in per-token mode
if (pricingMode === 'per-token') {
valuesToSave.price = '';
} else {
// Clear ratios if we're in per-request mode
valuesToSave.ratio = '';
valuesToSave.completionRatio = '';
}
addOrUpdateModel(valuesToSave);
}
}}
>
<Form getFormApi={(api) => (formRef.current = api)}>
<Form.Input
field='name'
label={t('模型名称')}
placeholder='strawberry'
required
disabled={isEditMode}
onChange={(value) =>
setCurrentModel((prev) => ({ ...prev, name: value }))
}
/>
<Form.Section text={t('定价模式')}>
<div style={{ marginBottom: '16px' }}>
<RadioGroup
type='button'
value={pricingMode}
onChange={(e) => {
const newMode = e.target.value;
const oldMode = pricingMode;
setPricingMode(newMode);
// Instead of resetting all values, convert between modes
if (currentModel) {
const updatedModel = { ...currentModel };
// Update formRef with converted values
if (formRef.current) {
const formValues = {
name: updatedModel.name,
};
if (newMode === 'per-request') {
formValues.priceInput = updatedModel.price || '';
} else if (newMode === 'per-token') {
formValues.ratioInput = updatedModel.ratio || '';
formValues.completionRatioInput =
updatedModel.completionRatio || '';
formValues.modelTokenPrice =
updatedModel.tokenPrice || '';
formValues.completionTokenPrice =
updatedModel.completionTokenPrice || '';
}
formRef.current.setValues(formValues);
}
// Update the model state
setCurrentModel(updatedModel);
}
}}
>
<Radio value='per-token'>{t('按量计费')}</Radio>
<Radio value='per-request'>{t('按次计费')}</Radio>
</RadioGroup>
</div>
</Form.Section>
{pricingMode === 'per-token' && (
<>
<Form.Section text={t('价格设置方式')}>
<div style={{ marginBottom: '16px' }}>
<RadioGroup
type='button'
value={pricingSubMode}
onChange={(e) => {
const newSubMode = e.target.value;
const oldSubMode = pricingSubMode;
setPricingSubMode(newSubMode);
// Handle conversion between submodes
if (currentModel) {
const updatedModel = { ...currentModel };
// Convert between ratio and token price
if (
oldSubMode === 'ratio' &&
newSubMode === 'token-price'
) {
if (updatedModel.ratio) {
updatedModel.tokenPrice =
calculateTokenPriceFromRatio(
parseFloat(updatedModel.ratio),
).toString();
if (updatedModel.completionRatio) {
updatedModel.completionTokenPrice = (
parseFloat(updatedModel.tokenPrice) *
parseFloat(updatedModel.completionRatio)
).toString();
}
}
} else if (
oldSubMode === 'token-price' &&
newSubMode === 'ratio'
) {
// Ratio values should already be calculated by the handlers
}
// Update the form values
if (formRef.current) {
const formValues = {};
if (newSubMode === 'ratio') {
formValues.ratioInput = updatedModel.ratio || '';
formValues.completionRatioInput =
updatedModel.completionRatio || '';
} else if (newSubMode === 'token-price') {
formValues.modelTokenPrice =
updatedModel.tokenPrice || '';
formValues.completionTokenPrice =
updatedModel.completionTokenPrice || '';
}
formRef.current.setValues(formValues);
}
setCurrentModel(updatedModel);
}
}}
>
<Radio value='ratio'>{t('按倍率设置')}</Radio>
<Radio value='token-price'>{t('按价格设置')}</Radio>
</RadioGroup>
</div>
</Form.Section>
{pricingSubMode === 'ratio' && (
<>
<Form.Input
field='ratioInput'
label={t('模型倍率')}
placeholder={t('输入模型倍率')}
onChange={(value) =>
setCurrentModel((prev) => ({
...(prev || {}),
ratio: value,
}))
}
initValue={currentModel?.ratio || ''}
/>
<Form.Input
field='completionRatioInput'
label={t('补全倍率')}
placeholder={t('输入补全倍率')}
onChange={(value) =>
setCurrentModel((prev) => ({
...(prev || {}),
completionRatio: value,
}))
}
initValue={currentModel?.completionRatio || ''}
/>
</>
)}
{pricingSubMode === 'token-price' && (
<>
<Form.Input
field='modelTokenPrice'
label={t('输入价格')}
onChange={(value) => {
handleTokenPriceChange(value);
}}
initValue={currentModel?.tokenPrice || ''}
suffix={t('$/1M tokens')}
/>
<Form.Input
field='completionTokenPrice'
label={t('输出价格')}
onChange={(value) => {
handleCompletionTokenPriceChange(value);
}}
initValue={currentModel?.completionTokenPrice || ''}
suffix={t('$/1M tokens')}
/>
</>
)}
</>
)}
{pricingMode === 'per-request' && (
<Form.Input
field='priceInput'
label={t('固定价格(每次)')}
placeholder={t('输入每次价格')}
onChange={(value) =>
setCurrentModel((prev) => ({
...(prev || {}),
price: value,
}))
}
initValue={currentModel?.price || ''}
/>
)}
</Form>
</Modal>
</>
);
return <ModelPricingEditor options={props.options} refresh={props.refresh} />;
}

View File

@@ -0,0 +1,739 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo, useState } from 'react';
import {
Banner,
Button,
Card,
Checkbox,
Empty,
Input,
Modal,
Radio,
RadioGroup,
Space,
Switch,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
IconDelete,
IconPlus,
IconSave,
IconSearch,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import {
PAGE_SIZE,
PRICE_SUFFIX,
buildSummaryText,
hasValue,
useModelPricingEditorState,
} from '../hooks/useModelPricingEditorState';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const { Text } = Typography;
const EMPTY_CANDIDATE_MODEL_NAMES = [];
const PriceInput = ({
label,
value,
placeholder,
onChange,
suffix = PRICE_SUFFIX,
disabled = false,
extraText = '',
headerAction = null,
hidden = false,
}) => (
<div style={{ marginBottom: 16 }}>
<div className='mb-1 font-medium text-gray-700 flex items-center justify-between gap-3'>
<span>{label}</span>
{headerAction}
</div>
{!hidden ? (
<Input
value={value}
placeholder={placeholder}
onChange={onChange}
suffix={suffix}
disabled={disabled}
/>
) : null}
{extraText ? (
<div className='mt-1 text-xs text-gray-500'>{extraText}</div>
) : null}
</div>
);
export default function ModelPricingEditor({
options,
refresh,
candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES,
filterMode = 'all',
allowAddModel = true,
allowDeleteModel = true,
showConflictFilter = true,
listDescription = '',
emptyTitle = '',
emptyDescription = '',
}) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [addVisible, setAddVisible] = useState(false);
const [batchVisible, setBatchVisible] = useState(false);
const [newModelName, setNewModelName] = useState('');
const {
selectedModel,
selectedModelName,
selectedModelNames,
setSelectedModelName,
setSelectedModelNames,
searchText,
setSearchText,
currentPage,
setCurrentPage,
loading,
conflictOnly,
setConflictOnly,
filteredModels,
pagedData,
selectedWarnings,
previewRows,
isOptionalFieldEnabled,
handleOptionalFieldToggle,
handleNumericFieldChange,
handleBillingModeChange,
handleSubmit,
addModel,
deleteModel,
applySelectedModelPricing,
} = useModelPricingEditorState({
options,
refresh,
t,
candidateModelNames,
filterMode,
});
const columns = useMemo(
() => [
{
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Space>
<Button
theme='borderless'
type='tertiary'
onClick={() => setSelectedModelName(record.name)}
style={{
padding: 0,
color:
record.name === selectedModelName
? 'var(--semi-color-primary)'
: undefined,
}}
>
{text}
</Button>
{selectedModelNames.includes(record.name) ? (
<Tag color='green' shape='circle'>
{t('已勾选')}
</Tag>
) : null}
{record.hasConflict ? (
<Tag color='red' shape='circle'>
{t('矛盾')}
</Tag>
) : null}
</Space>
),
},
{
title: t('计费方式'),
dataIndex: 'billingMode',
key: 'billingMode',
render: (_, record) => (
<Tag color={record.billingMode === 'per-request' ? 'teal' : 'violet'}>
{record.billingMode === 'per-request'
? t('按次计费')
: t('按量计费')}
</Tag>
),
},
{
title: t('价格摘要'),
dataIndex: 'summary',
key: 'summary',
render: (_, record) => buildSummaryText(record, t),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Space>
{allowDeleteModel ? (
<Button
size='small'
type='danger'
icon={<IconDelete />}
onClick={() => deleteModel(record.name)}
/>
) : null}
</Space>
),
},
],
[
allowDeleteModel,
deleteModel,
selectedModelName,
selectedModelNames,
setSelectedModelName,
t,
],
);
const handleAddModel = () => {
if (addModel(newModelName)) {
setNewModelName('');
setAddVisible(false);
}
};
const rowSelection = {
selectedRowKeys: selectedModelNames,
onChange: (selectedRowKeys) => setSelectedModelNames(selectedRowKeys),
};
return (
<>
<Space vertical align='start' style={{ width: '100%' }}>
<Space wrap className='mt-2'>
{allowAddModel ? (
<Button
icon={<IconPlus />}
onClick={() => setAddVisible(true)}
style={isMobile ? { width: '100%' } : undefined}
>
{t('添加模型')}
</Button>
) : null}
<Button
type='primary'
icon={<IconSave />}
loading={loading}
onClick={handleSubmit}
style={isMobile ? { width: '100%' } : undefined}
>
{t('应用更改')}
</Button>
<Button
disabled={!selectedModel || selectedModelNames.length === 0}
onClick={() => setBatchVisible(true)}
style={isMobile ? { width: '100%' } : undefined}
>
{t('批量应用当前模型价格')}
{selectedModelNames.length > 0 ? ` (${selectedModelNames.length})` : ''}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={(value) => setSearchText(value)}
style={{ width: isMobile ? '100%' : 220 }}
showClear
/>
{showConflictFilter ? (
<Checkbox
checked={conflictOnly}
onChange={(event) => setConflictOnly(event.target.checked)}
>
{t('仅显示矛盾倍率')}
</Checkbox>
) : null}
</Space>
{listDescription ? (
<div className='text-sm text-gray-500'>{listDescription}</div>
) : null}
{selectedModelNames.length > 0 ? (
<div
style={{
width: '100%',
padding: '10px 12px',
borderRadius: 8,
background: 'var(--semi-color-primary-light-default)',
border: '1px solid var(--semi-color-primary)',
color: 'var(--semi-color-primary)',
fontWeight: 600,
}}
>
{t('已勾选 {{count}} 个模型', { count: selectedModelNames.length })}
</div>
) : null}
<div
style={{
width: '100%',
display: 'grid',
gap: 16,
gridTemplateColumns: isMobile
? 'minmax(0, 1fr)'
: 'minmax(360px, 1.1fr) minmax(420px, 1fr)',
}}
>
<Card
bodyStyle={{ padding: 0 }}
style={isMobile ? { order: 2 } : undefined}
>
<div style={{ overflowX: 'auto' }}>
<Table
columns={columns}
dataSource={pagedData}
rowKey='name'
rowSelection={rowSelection}
pagination={{
currentPage,
pageSize: PAGE_SIZE,
total: filteredModels.length,
onPageChange: (page) => setCurrentPage(page),
showTotal: true,
showSizeChanger: false,
}}
empty={
<div style={{ textAlign: 'center', padding: '20px' }}>
{emptyTitle || t('暂无模型')}
</div>
}
onRow={(record) => ({
style: {
background: selectedModelNames.includes(record.name)
? 'var(--semi-color-success-light-default)'
: record.name === selectedModelName
? 'var(--semi-color-primary-light-default)'
: undefined,
boxShadow: selectedModelNames.includes(record.name)
? 'inset 4px 0 0 var(--semi-color-success)'
: record.name === selectedModelName
? 'inset 4px 0 0 var(--semi-color-primary)'
: undefined,
transition: 'background 0.2s ease, box-shadow 0.2s ease',
},
onClick: () => setSelectedModelName(record.name),
})}
scroll={isMobile ? { x: 720 } : undefined}
/>
</div>
</Card>
<Card
style={isMobile ? { order: 1 } : undefined}
title={selectedModel ? selectedModel.name : t('模型计费编辑器')}
headerExtraContent={
selectedModel ? (
<Tag color='blue'>
{selectedModel.billingMode === 'per-request'
? t('按次计费')
: t('按量计费')}
</Tag>
) : null
}
>
{!selectedModel ? (
<Empty
title={emptyTitle || t('暂无模型')}
description={
emptyDescription || t('请先新增模型或从左侧列表选择一个模型')
}
/>
) : (
<div>
<div className='mb-4'>
<div className='mb-2 font-medium text-gray-700'>
{t('计费方式')}
</div>
<RadioGroup
type='button'
value={selectedModel.billingMode}
onChange={(event) => handleBillingModeChange(event.target.value)}
>
<Radio value='per-token'>{t('按量计费')}</Radio>
<Radio value='per-request'>{t('按次计费')}</Radio>
</RadioGroup>
<div className='mt-2 text-xs text-gray-500'>
{t(
'这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。',
)}
</div>
</div>
{selectedWarnings.length > 0 ? (
<Card
bodyStyle={{ padding: 12 }}
style={{
marginBottom: 16,
background: 'var(--semi-color-warning-light-default)',
}}
>
<div className='font-medium mb-2'>{t('当前提示')}</div>
{selectedWarnings.map((warning) => (
<div key={warning} className='text-sm text-gray-700 mb-1'>
{warning}
</div>
))}
</Card>
) : null}
{selectedModel.billingMode === 'per-request' ? (
<PriceInput
label={t('固定价格')}
value={selectedModel.fixedPrice}
placeholder={t('输入每次调用价格')}
suffix={t('$/次')}
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
extraText={t('适合 MJ / 任务类等按次收费模型。')}
/>
) : (
<>
<Card
bodyStyle={{ padding: 16 }}
style={{
marginBottom: 16,
background: 'var(--semi-color-fill-0)',
}}
>
<div className='font-medium mb-3'>{t('基础价格')}</div>
<PriceInput
label={t('输入价格')}
value={selectedModel.inputPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) => handleNumericFieldChange('inputPrice', value)}
/>
{selectedModel.completionRatioLocked ? (
<Banner
type='warning'
bordered
fullMode={false}
closeIcon={null}
style={{ marginBottom: 12 }}
title={t('补全价格已锁定')}
description={t(
'该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。',
{
ratio: selectedModel.lockedCompletionRatio || '-',
},
)}
/>
) : null}
<PriceInput
label={t('补全价格')}
value={selectedModel.completionPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('completionPrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'completionPrice',
)}
disabled={selectedModel.completionRatioLocked}
onChange={(checked) =>
handleOptionalFieldToggle('completionPrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'completionPrice')
}
disabled={
!hasValue(selectedModel.inputPrice) ||
selectedModel.completionRatioLocked
}
extraText={
selectedModel.completionRatioLocked
? t(
'后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。',
{
ratio: selectedModel.lockedCompletionRatio || '-',
},
)
: !isOptionalFieldEnabled(
selectedModel,
'completionPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('提示缓存价格')}
value={selectedModel.cachePrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) => handleNumericFieldChange('cachePrice', value)}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(selectedModel, 'cachePrice')}
onChange={(checked) =>
handleOptionalFieldToggle('cachePrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'cachePrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(selectedModel, 'cachePrice')
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('缓存创建价格')}
value={selectedModel.createCachePrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('createCachePrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'createCachePrice',
)}
onChange={(checked) =>
handleOptionalFieldToggle('createCachePrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'createCachePrice')
}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'createCachePrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
</Card>
<Card
bodyStyle={{ padding: 16 }}
style={{
marginBottom: 16,
background: 'var(--semi-color-fill-0)',
}}
>
<div className='mb-3'>
<div className='font-medium'>{t('扩展价格')}</div>
<div className='text-xs text-gray-500 mt-1'>
{t('这些价格都是可选项,不填也可以。')}
</div>
</div>
<PriceInput
label={t('图片输入价格')}
value={selectedModel.imagePrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) => handleNumericFieldChange('imagePrice', value)}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(selectedModel, 'imagePrice')}
onChange={(checked) =>
handleOptionalFieldToggle('imagePrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'imagePrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(selectedModel, 'imagePrice')
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('音频输入价格')}
value={selectedModel.audioInputPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('audioInputPrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)}
onChange={(checked) =>
handleOptionalFieldToggle('audioInputPrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'audioInputPrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('音频补全价格')}
value={selectedModel.audioOutputPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('audioOutputPrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'audioOutputPrice',
)}
disabled={!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)}
onChange={(checked) =>
handleOptionalFieldToggle('audioOutputPrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'audioOutputPrice')
}
disabled={!hasValue(selectedModel.audioInputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)
? t('请先开启并填写音频输入价格。')
: !isOptionalFieldEnabled(
selectedModel,
'audioOutputPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
</Card>
</>
)}
<Card
bodyStyle={{ padding: 16 }}
style={{ background: 'var(--semi-color-fill-0)' }}
>
<div className='font-medium mb-3'>{t('保存预览')}</div>
<div className='text-xs text-gray-500 mb-3'>
{t(
'下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。',
)}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(140px, 180px) 1fr',
gap: 8,
}}
>
{previewRows.map((row) => (
<React.Fragment key={row.key}>
<Text strong>{row.label}</Text>
<Text>{row.value}</Text>
</React.Fragment>
))}
</div>
</Card>
</div>
)}
</Card>
</div>
</Space>
{allowAddModel ? (
<Modal
title={t('添加模型')}
visible={addVisible}
onCancel={() => {
setAddVisible(false);
setNewModelName('');
}}
onOk={handleAddModel}
>
<Input
value={newModelName}
placeholder={t('输入模型名称,例如 gpt-4.1')}
onChange={(value) => setNewModelName(value)}
/>
</Modal>
) : null}
<Modal
title={t('批量应用当前模型价格')}
visible={batchVisible}
onCancel={() => setBatchVisible(false)}
onOk={() => {
if (applySelectedModelPricing()) {
setBatchVisible(false);
}
}}
>
<div className='text-sm text-gray-600'>
{selectedModel
? t(
'将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。',
{
name: selectedModel.name,
count: selectedModelNames.length,
},
)
: t('请先选择一个作为模板的模型')}
</div>
{selectedModel ? (
<div className='text-xs text-gray-500 mt-3'>
{t(
'适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。',
)}
</div>
) : null}
</Modal>
</>
);
}

View File

@@ -0,0 +1,937 @@
import { useEffect, useMemo, useState } from 'react';
import { API, showError, showSuccess } from '../../../../helpers';
export const PAGE_SIZE = 10;
export const PRICE_SUFFIX = '$/1M tokens';
const EMPTY_CANDIDATE_MODEL_NAMES = [];
const EMPTY_MODEL = {
name: '',
billingMode: 'per-token',
fixedPrice: '',
inputPrice: '',
completionPrice: '',
lockedCompletionRatio: '',
completionRatioLocked: false,
cachePrice: '',
createCachePrice: '',
imagePrice: '',
audioInputPrice: '',
audioOutputPrice: '',
rawRatios: {
modelRatio: '',
completionRatio: '',
cacheRatio: '',
createCacheRatio: '',
imageRatio: '',
audioRatio: '',
audioCompletionRatio: '',
},
hasConflict: false,
};
const NUMERIC_INPUT_REGEX = /^(\d+(\.\d*)?|\.\d*)?$/;
export const hasValue = (value) =>
value !== '' && value !== null && value !== undefined && value !== false;
const toNumericString = (value) => {
if (!hasValue(value) && value !== 0) {
return '';
}
const num = Number(value);
return Number.isFinite(num) ? String(num) : '';
};
const toNumberOrNull = (value) => {
if (!hasValue(value) && value !== 0) {
return null;
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
};
const formatNumber = (value) => {
const num = toNumberOrNull(value);
if (num === null) {
return '';
}
return parseFloat(num.toFixed(12)).toString();
};
const parseOptionJSON = (rawValue) => {
if (!rawValue || rawValue.trim() === '') {
return {};
}
try {
const parsed = JSON.parse(rawValue);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (error) {
console.error('JSON解析错误:', error);
return {};
}
};
const ratioToBasePrice = (ratio) => {
const num = toNumberOrNull(ratio);
if (num === null) return '';
return formatNumber(num * 2);
};
const normalizeCompletionRatioMeta = (rawMeta) => {
if (!rawMeta || typeof rawMeta !== 'object' || Array.isArray(rawMeta)) {
return {
locked: false,
ratio: '',
};
}
return {
locked: Boolean(rawMeta.locked),
ratio: toNumericString(rawMeta.ratio),
};
};
const buildModelState = (name, sourceMaps) => {
const modelRatio = toNumericString(sourceMaps.ModelRatio[name]);
const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]);
const completionRatioMeta = normalizeCompletionRatioMeta(
sourceMaps.CompletionRatioMeta?.[name],
);
const cacheRatio = toNumericString(sourceMaps.CacheRatio[name]);
const createCacheRatio = toNumericString(sourceMaps.CreateCacheRatio[name]);
const imageRatio = toNumericString(sourceMaps.ImageRatio[name]);
const audioRatio = toNumericString(sourceMaps.AudioRatio[name]);
const audioCompletionRatio = toNumericString(
sourceMaps.AudioCompletionRatio[name],
);
const fixedPrice = toNumericString(sourceMaps.ModelPrice[name]);
const inputPrice = ratioToBasePrice(modelRatio);
const inputPriceNumber = toNumberOrNull(inputPrice);
const audioInputPrice =
inputPriceNumber !== null && hasValue(audioRatio)
? formatNumber(inputPriceNumber * Number(audioRatio))
: '';
return {
...EMPTY_MODEL,
name,
billingMode: hasValue(fixedPrice) ? 'per-request' : 'per-token',
fixedPrice,
inputPrice,
completionRatioLocked: completionRatioMeta.locked,
lockedCompletionRatio: completionRatioMeta.ratio,
completionPrice:
inputPriceNumber !== null &&
hasValue(completionRatioMeta.locked ? completionRatioMeta.ratio : completionRatio)
? formatNumber(
inputPriceNumber *
Number(
completionRatioMeta.locked
? completionRatioMeta.ratio
: completionRatio,
),
)
: '',
cachePrice:
inputPriceNumber !== null && hasValue(cacheRatio)
? formatNumber(inputPriceNumber * Number(cacheRatio))
: '',
createCachePrice:
inputPriceNumber !== null && hasValue(createCacheRatio)
? formatNumber(inputPriceNumber * Number(createCacheRatio))
: '',
imagePrice:
inputPriceNumber !== null && hasValue(imageRatio)
? formatNumber(inputPriceNumber * Number(imageRatio))
: '',
audioInputPrice,
audioOutputPrice:
toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio)
? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio))
: '',
rawRatios: {
modelRatio,
completionRatio,
cacheRatio,
createCacheRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
},
hasConflict:
hasValue(fixedPrice) &&
[
modelRatio,
completionRatio,
cacheRatio,
createCacheRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
].some(hasValue),
};
};
export const isBasePricingUnset = (model) =>
!hasValue(model.fixedPrice) && !hasValue(model.inputPrice);
export const getModelWarnings = (model, t) => {
if (!model) {
return [];
}
const warnings = [];
const hasDerivedPricing = [
model.inputPrice,
model.completionPrice,
model.cachePrice,
model.createCachePrice,
model.imagePrice,
model.audioInputPrice,
model.audioOutputPrice,
].some(hasValue);
if (model.hasConflict) {
warnings.push(t('当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。'));
}
if (
!hasValue(model.inputPrice) &&
[
model.rawRatios.completionRatio,
model.rawRatios.cacheRatio,
model.rawRatios.createCacheRatio,
model.rawRatios.imageRatio,
model.rawRatios.audioRatio,
model.rawRatios.audioCompletionRatio,
].some(hasValue)
) {
warnings.push(
t('当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。'),
);
}
if (model.billingMode === 'per-token' && hasDerivedPricing && !hasValue(model.inputPrice)) {
warnings.push(t('按量计费下需要先填写输入价格,才能保存其它价格项。'));
}
if (
model.billingMode === 'per-token' &&
hasValue(model.audioOutputPrice) &&
!hasValue(model.audioInputPrice)
) {
warnings.push(t('填写音频补全价格前,需要先填写音频输入价格。'));
}
return warnings;
};
export const buildSummaryText = (model, t) => {
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
return `${t('按次')} $${model.fixedPrice} / ${t('次')}`;
}
if (hasValue(model.inputPrice)) {
const extraCount = [
model.completionPrice,
model.cachePrice,
model.createCachePrice,
model.imagePrice,
model.audioInputPrice,
model.audioOutputPrice,
].filter(hasValue).length;
const extraLabel =
extraCount > 0 ? `${t('额外价格项')} ${extraCount}` : '';
return `${t('输入')} $${model.inputPrice}${extraLabel}`;
}
return t('未设置价格');
};
export const buildOptionalFieldToggles = (model) => ({
completionPrice: model.completionRatioLocked || hasValue(model.completionPrice),
cachePrice: hasValue(model.cachePrice),
createCachePrice: hasValue(model.createCachePrice),
imagePrice: hasValue(model.imagePrice),
audioInputPrice: hasValue(model.audioInputPrice),
audioOutputPrice: hasValue(model.audioOutputPrice),
});
const serializeModel = (model, t) => {
const result = {
ModelPrice: null,
ModelRatio: null,
CompletionRatio: null,
CacheRatio: null,
CreateCacheRatio: null,
ImageRatio: null,
AudioRatio: null,
AudioCompletionRatio: null,
};
if (model.billingMode === 'per-request') {
if (hasValue(model.fixedPrice)) {
result.ModelPrice = Number(model.fixedPrice);
}
return result;
}
const inputPrice = toNumberOrNull(model.inputPrice);
const completionPrice = toNumberOrNull(model.completionPrice);
const cachePrice = toNumberOrNull(model.cachePrice);
const createCachePrice = toNumberOrNull(model.createCachePrice);
const imagePrice = toNumberOrNull(model.imagePrice);
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
const hasDependentPrice = [
completionPrice,
cachePrice,
createCachePrice,
imagePrice,
audioInputPrice,
audioOutputPrice,
].some((value) => value !== null);
if (inputPrice === null) {
if (hasDependentPrice) {
throw new Error(
t('模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率', {
name: model.name,
}),
);
}
if (hasValue(model.rawRatios.modelRatio)) {
result.ModelRatio = Number(model.rawRatios.modelRatio);
}
if (hasValue(model.rawRatios.completionRatio)) {
result.CompletionRatio = Number(model.rawRatios.completionRatio);
}
if (hasValue(model.rawRatios.cacheRatio)) {
result.CacheRatio = Number(model.rawRatios.cacheRatio);
}
if (hasValue(model.rawRatios.createCacheRatio)) {
result.CreateCacheRatio = Number(model.rawRatios.createCacheRatio);
}
if (hasValue(model.rawRatios.imageRatio)) {
result.ImageRatio = Number(model.rawRatios.imageRatio);
}
if (hasValue(model.rawRatios.audioRatio)) {
result.AudioRatio = Number(model.rawRatios.audioRatio);
}
if (hasValue(model.rawRatios.audioCompletionRatio)) {
result.AudioCompletionRatio = Number(model.rawRatios.audioCompletionRatio);
}
return result;
}
result.ModelRatio = inputPrice / 2;
if (!model.completionRatioLocked && completionPrice !== null) {
result.CompletionRatio = completionPrice / inputPrice;
} else if (
model.completionRatioLocked &&
hasValue(model.rawRatios.completionRatio)
) {
result.CompletionRatio = Number(model.rawRatios.completionRatio);
}
if (cachePrice !== null) {
result.CacheRatio = cachePrice / inputPrice;
}
if (createCachePrice !== null) {
result.CreateCacheRatio = createCachePrice / inputPrice;
}
if (imagePrice !== null) {
result.ImageRatio = imagePrice / inputPrice;
}
if (audioInputPrice !== null) {
result.AudioRatio = audioInputPrice / inputPrice;
}
if (audioOutputPrice !== null) {
if (audioInputPrice === null || audioInputPrice === 0) {
throw new Error(
t('模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率', {
name: model.name,
}),
);
}
result.AudioCompletionRatio = audioOutputPrice / audioInputPrice;
}
return result;
};
export const buildPreviewRows = (model, t) => {
if (!model) return [];
if (model.billingMode === 'per-request') {
return [
{
key: 'ModelPrice',
label: 'ModelPrice',
value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'),
},
];
}
const inputPrice = toNumberOrNull(model.inputPrice);
if (inputPrice === null) {
return [
{
key: 'ModelRatio',
label: 'ModelRatio',
value: hasValue(model.rawRatios.modelRatio)
? model.rawRatios.modelRatio
: t('空'),
},
{
key: 'CompletionRatio',
label: 'CompletionRatio',
value: hasValue(model.rawRatios.completionRatio)
? model.rawRatios.completionRatio
: t('空'),
},
{
key: 'CacheRatio',
label: 'CacheRatio',
value: hasValue(model.rawRatios.cacheRatio)
? model.rawRatios.cacheRatio
: t('空'),
},
{
key: 'CreateCacheRatio',
label: 'CreateCacheRatio',
value: hasValue(model.rawRatios.createCacheRatio)
? model.rawRatios.createCacheRatio
: t('空'),
},
{
key: 'ImageRatio',
label: 'ImageRatio',
value: hasValue(model.rawRatios.imageRatio)
? model.rawRatios.imageRatio
: t('空'),
},
{
key: 'AudioRatio',
label: 'AudioRatio',
value: hasValue(model.rawRatios.audioRatio)
? model.rawRatios.audioRatio
: t('空'),
},
{
key: 'AudioCompletionRatio',
label: 'AudioCompletionRatio',
value: hasValue(model.rawRatios.audioCompletionRatio)
? model.rawRatios.audioCompletionRatio
: t('空'),
},
];
}
const completionPrice = toNumberOrNull(model.completionPrice);
const cachePrice = toNumberOrNull(model.cachePrice);
const createCachePrice = toNumberOrNull(model.createCachePrice);
const imagePrice = toNumberOrNull(model.imagePrice);
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
return [
{
key: 'ModelRatio',
label: 'ModelRatio',
value: formatNumber(inputPrice / 2),
},
{
key: 'CompletionRatio',
label: 'CompletionRatio',
value: model.completionRatioLocked
? `${model.lockedCompletionRatio || t('空')} (${t('后端固定')})`
: completionPrice !== null
? formatNumber(completionPrice / inputPrice)
: t('空'),
},
{
key: 'CacheRatio',
label: 'CacheRatio',
value: cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'),
},
{
key: 'CreateCacheRatio',
label: 'CreateCacheRatio',
value:
createCachePrice !== null
? formatNumber(createCachePrice / inputPrice)
: t('空'),
},
{
key: 'ImageRatio',
label: 'ImageRatio',
value: imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'),
},
{
key: 'AudioRatio',
label: 'AudioRatio',
value:
audioInputPrice !== null
? formatNumber(audioInputPrice / inputPrice)
: t('空'),
},
{
key: 'AudioCompletionRatio',
label: 'AudioCompletionRatio',
value:
audioOutputPrice !== null && audioInputPrice !== null && audioInputPrice !== 0
? formatNumber(audioOutputPrice / audioInputPrice)
: t('空'),
},
];
};
export function useModelPricingEditorState({
options,
refresh,
t,
candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES,
filterMode = 'all',
}) {
const [models, setModels] = useState([]);
const [initialVisibleModelNames, setInitialVisibleModelNames] = useState([]);
const [selectedModelName, setSelectedModelName] = useState('');
const [selectedModelNames, setSelectedModelNames] = useState([]);
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const [conflictOnly, setConflictOnly] = useState(false);
const [optionalFieldToggles, setOptionalFieldToggles] = useState({});
useEffect(() => {
const sourceMaps = {
ModelPrice: parseOptionJSON(options.ModelPrice),
ModelRatio: parseOptionJSON(options.ModelRatio),
CompletionRatio: parseOptionJSON(options.CompletionRatio),
CompletionRatioMeta: parseOptionJSON(options.CompletionRatioMeta),
CacheRatio: parseOptionJSON(options.CacheRatio),
CreateCacheRatio: parseOptionJSON(options.CreateCacheRatio),
ImageRatio: parseOptionJSON(options.ImageRatio),
AudioRatio: parseOptionJSON(options.AudioRatio),
AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio),
};
const names = new Set([
...candidateModelNames,
...Object.keys(sourceMaps.ModelPrice),
...Object.keys(sourceMaps.ModelRatio),
...Object.keys(sourceMaps.CompletionRatio),
...Object.keys(sourceMaps.CompletionRatioMeta),
...Object.keys(sourceMaps.CacheRatio),
...Object.keys(sourceMaps.CreateCacheRatio),
...Object.keys(sourceMaps.ImageRatio),
...Object.keys(sourceMaps.AudioRatio),
...Object.keys(sourceMaps.AudioCompletionRatio),
]);
const nextModels = Array.from(names)
.map((name) => buildModelState(name, sourceMaps))
.sort((a, b) => a.name.localeCompare(b.name));
setModels(nextModels);
setInitialVisibleModelNames(
filterMode === 'unset'
? nextModels
.filter((model) => isBasePricingUnset(model))
.map((model) => model.name)
: nextModels.map((model) => model.name),
);
setOptionalFieldToggles(
nextModels.reduce((acc, model) => {
acc[model.name] = buildOptionalFieldToggles(model);
return acc;
}, {}),
);
setSelectedModelName((previous) => {
if (previous && nextModels.some((model) => model.name === previous)) {
return previous;
}
const nextVisibleModels =
filterMode === 'unset'
? nextModels.filter((model) => isBasePricingUnset(model))
: nextModels;
return nextVisibleModels[0]?.name || '';
});
}, [candidateModelNames, filterMode, options]);
const visibleModels = useMemo(() => {
return filterMode === 'unset'
? models.filter((model) => initialVisibleModelNames.includes(model.name))
: models;
}, [filterMode, initialVisibleModelNames, models]);
const filteredModels = useMemo(() => {
return visibleModels.filter((model) => {
const keyword = searchText.trim().toLowerCase();
const keywordMatch = keyword
? model.name.toLowerCase().includes(keyword)
: true;
const conflictMatch = conflictOnly ? model.hasConflict : true;
return keywordMatch && conflictMatch;
});
}, [conflictOnly, searchText, visibleModels]);
const pagedData = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return filteredModels.slice(start, start + PAGE_SIZE);
}, [currentPage, filteredModels]);
const selectedModel = useMemo(
() => visibleModels.find((model) => model.name === selectedModelName) || null,
[selectedModelName, visibleModels],
);
const selectedWarnings = useMemo(
() => getModelWarnings(selectedModel, t),
[selectedModel, t],
);
const previewRows = useMemo(
() => buildPreviewRows(selectedModel, t),
[selectedModel, t],
);
useEffect(() => {
setCurrentPage(1);
}, [searchText, conflictOnly, filterMode, candidateModelNames]);
useEffect(() => {
setSelectedModelNames((previous) =>
previous.filter((name) => visibleModels.some((model) => model.name === name)),
);
}, [visibleModels]);
useEffect(() => {
if (visibleModels.length === 0) {
setSelectedModelName('');
return;
}
if (!visibleModels.some((model) => model.name === selectedModelName)) {
setSelectedModelName(visibleModels[0].name);
}
}, [selectedModelName, visibleModels]);
const upsertModel = (name, updater) => {
setModels((previous) =>
previous.map((model) => {
if (model.name !== name) return model;
return typeof updater === 'function' ? updater(model) : updater;
}),
);
};
const isOptionalFieldEnabled = (model, field) => {
if (!model) return false;
const modelToggles = optionalFieldToggles[model.name];
if (modelToggles && typeof modelToggles[field] === 'boolean') {
return modelToggles[field];
}
return buildOptionalFieldToggles(model)[field];
};
const updateOptionalFieldToggle = (modelName, field, checked) => {
setOptionalFieldToggles((prev) => ({
...prev,
[modelName]: {
...(prev[modelName] || {}),
[field]: checked,
},
}));
};
const handleOptionalFieldToggle = (field, checked) => {
if (!selectedModel) return;
updateOptionalFieldToggle(selectedModel.name, field, checked);
if (checked) {
return;
}
upsertModel(selectedModel.name, (model) => {
const nextModel = { ...model, [field]: '' };
if (field === 'audioInputPrice') {
nextModel.audioOutputPrice = '';
setOptionalFieldToggles((prev) => ({
...prev,
[selectedModel.name]: {
...(prev[selectedModel.name] || {}),
audioInputPrice: false,
audioOutputPrice: false,
},
}));
}
return nextModel;
});
};
const fillDerivedPricesFromBase = (model, nextInputPrice) => {
const baseNumber = toNumberOrNull(nextInputPrice);
if (baseNumber === null) {
return model;
}
return {
...model,
completionPrice:
model.completionRatioLocked && hasValue(model.lockedCompletionRatio)
? formatNumber(baseNumber * Number(model.lockedCompletionRatio))
: !hasValue(model.completionPrice) &&
hasValue(model.rawRatios.completionRatio)
? formatNumber(baseNumber * Number(model.rawRatios.completionRatio))
: model.completionPrice,
cachePrice:
!hasValue(model.cachePrice) && hasValue(model.rawRatios.cacheRatio)
? formatNumber(baseNumber * Number(model.rawRatios.cacheRatio))
: model.cachePrice,
createCachePrice:
!hasValue(model.createCachePrice) &&
hasValue(model.rawRatios.createCacheRatio)
? formatNumber(baseNumber * Number(model.rawRatios.createCacheRatio))
: model.createCachePrice,
imagePrice:
!hasValue(model.imagePrice) && hasValue(model.rawRatios.imageRatio)
? formatNumber(baseNumber * Number(model.rawRatios.imageRatio))
: model.imagePrice,
audioInputPrice:
!hasValue(model.audioInputPrice) && hasValue(model.rawRatios.audioRatio)
? formatNumber(baseNumber * Number(model.rawRatios.audioRatio))
: model.audioInputPrice,
audioOutputPrice:
!hasValue(model.audioOutputPrice) &&
hasValue(model.rawRatios.audioRatio) &&
hasValue(model.rawRatios.audioCompletionRatio)
? formatNumber(
baseNumber *
Number(model.rawRatios.audioRatio) *
Number(model.rawRatios.audioCompletionRatio),
)
: model.audioOutputPrice,
};
};
const handleNumericFieldChange = (field, value) => {
if (!selectedModel || !NUMERIC_INPUT_REGEX.test(value)) {
return;
}
upsertModel(selectedModel.name, (model) => {
const updatedModel = { ...model, [field]: value };
if (field === 'inputPrice') {
return fillDerivedPricesFromBase(updatedModel, value);
}
return updatedModel;
});
};
const handleBillingModeChange = (value) => {
if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => ({
...model,
billingMode: value,
}));
};
const addModel = (modelName) => {
const trimmedName = modelName.trim();
if (!trimmedName) {
showError(t('请输入模型名称'));
return false;
}
if (models.some((model) => model.name === trimmedName)) {
showError(t('模型名称已存在'));
return false;
}
const nextModel = {
...EMPTY_MODEL,
name: trimmedName,
rawRatios: { ...EMPTY_MODEL.rawRatios },
};
setModels((previous) => [nextModel, ...previous]);
setOptionalFieldToggles((prev) => ({
...prev,
[trimmedName]: buildOptionalFieldToggles(nextModel),
}));
setSelectedModelName(trimmedName);
setCurrentPage(1);
return true;
};
const deleteModel = (name) => {
const nextModels = models.filter((model) => model.name !== name);
setModels(nextModels);
setOptionalFieldToggles((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
setSelectedModelNames((previous) => previous.filter((item) => item !== name));
if (selectedModelName === name) {
setSelectedModelName(nextModels[0]?.name || '');
}
};
const applySelectedModelPricing = () => {
if (!selectedModel) {
showError(t('请先选择一个作为模板的模型'));
return false;
}
if (selectedModelNames.length === 0) {
showError(t('请先勾选需要批量设置的模型'));
return false;
}
const sourceToggles = optionalFieldToggles[selectedModel.name] || {};
setModels((previous) =>
previous.map((model) => {
if (!selectedModelNames.includes(model.name)) {
return model;
}
const nextModel = {
...model,
billingMode: selectedModel.billingMode,
fixedPrice: selectedModel.fixedPrice,
inputPrice: selectedModel.inputPrice,
completionPrice: selectedModel.completionPrice,
cachePrice: selectedModel.cachePrice,
createCachePrice: selectedModel.createCachePrice,
imagePrice: selectedModel.imagePrice,
audioInputPrice: selectedModel.audioInputPrice,
audioOutputPrice: selectedModel.audioOutputPrice,
};
if (
nextModel.billingMode === 'per-token' &&
nextModel.completionRatioLocked &&
hasValue(nextModel.inputPrice) &&
hasValue(nextModel.lockedCompletionRatio)
) {
nextModel.completionPrice = formatNumber(
Number(nextModel.inputPrice) * Number(nextModel.lockedCompletionRatio),
);
}
return nextModel;
}),
);
setOptionalFieldToggles((previous) => {
const next = { ...previous };
selectedModelNames.forEach((modelName) => {
const targetModel = models.find((item) => item.name === modelName);
next[modelName] = {
completionPrice: targetModel?.completionRatioLocked
? true
: Boolean(sourceToggles.completionPrice),
cachePrice: Boolean(sourceToggles.cachePrice),
createCachePrice: Boolean(sourceToggles.createCachePrice),
imagePrice: Boolean(sourceToggles.imagePrice),
audioInputPrice: Boolean(sourceToggles.audioInputPrice),
audioOutputPrice:
Boolean(sourceToggles.audioInputPrice) &&
Boolean(sourceToggles.audioOutputPrice),
};
});
return next;
});
showSuccess(
t('已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型', {
name: selectedModel.name,
count: selectedModelNames.length,
}),
);
return true;
};
const handleSubmit = async () => {
setLoading(true);
try {
const output = {
ModelPrice: {},
ModelRatio: {},
CompletionRatio: {},
CacheRatio: {},
CreateCacheRatio: {},
ImageRatio: {},
AudioRatio: {},
AudioCompletionRatio: {},
};
for (const model of models) {
const serialized = serializeModel(model, t);
Object.entries(serialized).forEach(([key, value]) => {
if (value !== null) {
output[key][model.name] = value;
}
});
}
const requestQueue = Object.entries(output).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
}),
);
const results = await Promise.all(requestQueue);
for (const res of results) {
if (!res?.data?.success) {
throw new Error(res?.data?.message || t('保存失败,请重试'));
}
}
showSuccess(t('保存成功'));
await refresh();
} catch (error) {
console.error('保存失败:', error);
showError(error.message || t('保存失败,请重试'));
} finally {
setLoading(false);
}
};
return {
models,
selectedModel,
selectedModelName,
selectedModelNames,
setSelectedModelName,
setSelectedModelNames,
searchText,
setSearchText,
currentPage,
setCurrentPage,
loading,
conflictOnly,
setConflictOnly,
filteredModels,
pagedData,
selectedWarnings,
previewRows,
isOptionalFieldEnabled,
handleOptionalFieldToggle,
handleNumericFieldChange,
handleBillingModeChange,
handleSubmit,
addModel,
deleteModel,
applySelectedModelPricing,
};
}