| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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> |
| </> |
| ); |
| } |
|
|