/*
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 .
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,
}) => (
{label}
{headerAction}
{!hidden ? (
) : null}
{extraText ? (
{extraText}
) : null}
);
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) => (
setSelectedModelName(record.name)}
style={{
padding: 0,
color:
record.name === selectedModelName
? 'var(--semi-color-primary)'
: undefined,
}}
>
{text}
{selectedModelNames.includes(record.name) ? (
{t('已勾选')}
) : null}
{record.hasConflict ? (
{t('矛盾')}
) : null}
),
},
{
title: t('计费方式'),
dataIndex: 'billingMode',
key: 'billingMode',
render: (_, record) => (
{record.billingMode === 'per-request'
? t('按次计费')
: t('按量计费')}
),
},
{
title: t('价格摘要'),
dataIndex: 'summary',
key: 'summary',
render: (_, record) => buildSummaryText(record, t),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
{allowDeleteModel ? (
}
onClick={() => deleteModel(record.name)}
/>
) : null}
),
},
],
[
allowDeleteModel,
deleteModel,
selectedModelName,
selectedModelNames,
setSelectedModelName,
t,
],
);
const handleAddModel = () => {
if (addModel(newModelName)) {
setNewModelName('');
setAddVisible(false);
}
};
const rowSelection = {
selectedRowKeys: selectedModelNames,
onChange: (selectedRowKeys) => setSelectedModelNames(selectedRowKeys),
};
return (
<>
{allowAddModel ? (
}
onClick={() => setAddVisible(true)}
style={isMobile ? { width: '100%' } : undefined}
>
{t('添加模型')}
) : null}
}
loading={loading}
onClick={handleSubmit}
style={isMobile ? { width: '100%' } : undefined}
>
{t('应用更改')}
setBatchVisible(true)}
style={isMobile ? { width: '100%' } : undefined}
>
{t('批量应用当前模型价格')}
{selectedModelNames.length > 0 ? ` (${selectedModelNames.length})` : ''}
}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={(value) => setSearchText(value)}
style={{ width: isMobile ? '100%' : 220 }}
showClear
/>
{showConflictFilter ? (
setConflictOnly(event.target.checked)}
>
{t('仅显示矛盾倍率')}
) : null}
{listDescription ? (
{listDescription}
) : null}
{selectedModelNames.length > 0 ? (
{t('已勾选 {{count}} 个模型', { count: selectedModelNames.length })}
) : null}
setCurrentPage(page),
showTotal: true,
showSizeChanger: false,
}}
empty={
{emptyTitle || t('暂无模型')}
}
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}
/>
{selectedModel.billingMode === 'per-request'
? t('按次计费')
: t('按量计费')}
) : null
}
>
{!selectedModel ? (
) : (
{t('计费方式')}
handleBillingModeChange(event.target.value)}
>
{t('按量计费')}
{t('按次计费')}
{t(
'这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。',
)}
{selectedWarnings.length > 0 ? (
{t('当前提示')}
{selectedWarnings.map((warning) => (
{warning}
))}
) : null}
{selectedModel.billingMode === 'per-request' ? (
handleNumericFieldChange('fixedPrice', value)}
extraText={t('适合 MJ / 任务类等按次收费模型。')}
/>
) : (
<>
{t('基础价格')}
handleNumericFieldChange('inputPrice', value)}
/>
{selectedModel.completionRatioLocked ? (
) : null}
handleNumericFieldChange('completionPrice', value)
}
headerAction={
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('当前未启用,需要时再打开即可。')
: ''
}
/>
handleNumericFieldChange('cachePrice', value)}
headerAction={
handleOptionalFieldToggle('cachePrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'cachePrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(selectedModel, 'cachePrice')
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
handleNumericFieldChange('createCachePrice', value)
}
headerAction={
handleOptionalFieldToggle('createCachePrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'createCachePrice')
}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'createCachePrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
{t('扩展价格')}
{t('这些价格都是可选项,不填也可以。')}
handleNumericFieldChange('imagePrice', value)}
headerAction={
handleOptionalFieldToggle('imagePrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'imagePrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(selectedModel, 'imagePrice')
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
handleNumericFieldChange('audioInputPrice', value)
}
headerAction={
handleOptionalFieldToggle('audioInputPrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'audioInputPrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
handleNumericFieldChange('audioOutputPrice', value)
}
headerAction={
handleOptionalFieldToggle('audioOutputPrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'audioOutputPrice')
}
disabled={!hasValue(selectedModel.audioInputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)
? t('请先开启并填写音频输入价格。')
: !isOptionalFieldEnabled(
selectedModel,
'audioOutputPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
>
)}
{t('保存预览')}
{t(
'下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。',
)}
{previewRows.map((row) => (
{row.label}
{row.value}
))}
)}
{allowAddModel ? (
{
setAddVisible(false);
setNewModelName('');
}}
onOk={handleAddModel}
>
setNewModelName(value)}
/>
) : null}
setBatchVisible(false)}
onOk={() => {
if (applySelectedModelPricing()) {
setBatchVisible(false);
}
}}
>
{selectedModel
? t(
'将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。',
{
name: selectedModel.name,
count: selectedModelNames.length,
},
)
: t('请先选择一个作为模板的模型')}
{selectedModel ? (
{t(
'适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。',
)}
) : null}
>
);
}