|
|
@@ -0,0 +1,386 @@ |
|
|
|
|
|
/* |
|
|
|
|
|
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, { useState, useMemo, useRef } from 'react'; |
|
|
|
|
|
import { |
|
|
|
|
|
Table, |
|
|
|
|
|
Button, |
|
|
|
|
|
Modal, |
|
|
|
|
|
Form, |
|
|
|
|
|
Select, |
|
|
|
|
|
Space, |
|
|
|
|
|
Tag, |
|
|
|
|
|
Typography, |
|
|
|
|
|
Empty, |
|
|
|
|
|
Popconfirm, |
|
|
|
|
|
InputNumber, |
|
|
|
|
|
} from '@douyinfe/semi-ui'; |
|
|
|
|
|
import { |
|
|
|
|
|
IconEdit, |
|
|
|
|
|
IconDelete, |
|
|
|
|
|
IconChevronDown, |
|
|
|
|
|
IconChevronRight, |
|
|
|
|
|
} from '@douyinfe/semi-icons'; |
|
|
|
|
|
import { useTranslation } from 'react-i18next'; |
|
|
|
|
|
import { channelPricingApi } from '../../../helpers/api'; |
|
|
|
|
|
import { showError, showSuccess } from '../../../helpers'; |
|
|
|
|
|
|
|
|
|
|
|
const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { |
|
|
|
|
|
const { t } = useTranslation(); |
|
|
|
|
|
const [expandedKeys, setExpandedKeys] = useState([]); |
|
|
|
|
|
const [editModalVisible, setEditModalVisible] = useState(false); |
|
|
|
|
|
const [editingRecord, setEditingRecord] = useState(null); |
|
|
|
|
|
const formRef = useRef(); |
|
|
|
|
|
|
|
|
|
|
|
// Build model -> channel pricing mapping |
|
|
|
|
|
const modelData = useMemo(() => { |
|
|
|
|
|
const pricingMap = new Map(); |
|
|
|
|
|
channelPricings.forEach((cp) => { |
|
|
|
|
|
const key = `${cp.model_name}:${cp.channel_id}`; |
|
|
|
|
|
pricingMap.set(key, cp); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
// Get all unique model names |
|
|
|
|
|
const modelNames = new Set(channelPricings.map((cp) => cp.model_name)); |
|
|
|
|
|
|
|
|
|
|
|
return Array.from(modelNames).map((modelName) => { |
|
|
|
|
|
const channelPrices = channels.map((ch) => { |
|
|
|
|
|
const key = `${modelName}:${ch.id}`; |
|
|
|
|
|
const pricing = pricingMap.get(key); |
|
|
|
|
|
return { |
|
|
|
|
|
channelId: ch.id, |
|
|
|
|
|
channelName: ch.name, |
|
|
|
|
|
channelType: ch.type, |
|
|
|
|
|
pricing: pricing || null, |
|
|
|
|
|
}; |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
return { |
|
|
|
|
|
modelName, |
|
|
|
|
|
channelPrices, |
|
|
|
|
|
hasChannelPricing: channelPrices.some((cp) => cp.pricing !== null), |
|
|
|
|
|
}; |
|
|
|
|
|
}); |
|
|
|
|
|
}, [channels, channelPricings]); |
|
|
|
|
|
|
|
|
|
|
|
const handleEdit = (modelName, channelId, pricing) => { |
|
|
|
|
|
setEditingRecord({ |
|
|
|
|
|
modelName, |
|
|
|
|
|
channelId, |
|
|
|
|
|
pricing: pricing || { |
|
|
|
|
|
model_name: modelName, |
|
|
|
|
|
channel_id: channelId, |
|
|
|
|
|
quota_type: 0, |
|
|
|
|
|
model_ratio: 0, |
|
|
|
|
|
completion_ratio: 0, |
|
|
|
|
|
model_price: 0, |
|
|
|
|
|
tag_ids: '', |
|
|
|
|
|
}, |
|
|
|
|
|
}); |
|
|
|
|
|
setEditModalVisible(true); |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = async (id) => { |
|
|
|
|
|
try { |
|
|
|
|
|
const res = await channelPricingApi.delete(id); |
|
|
|
|
|
if (res.data.success) { |
|
|
|
|
|
showSuccess(t('删除成功')); |
|
|
|
|
|
onRefresh(); |
|
|
|
|
|
} |
|
|
|
|
|
} catch (e) { |
|
|
|
|
|
showError(e); |
|
|
|
|
|
} |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => { |
|
|
|
|
|
try { |
|
|
|
|
|
const values = await formRef.current.validate(); |
|
|
|
|
|
const data = { |
|
|
|
|
|
model_name: editingRecord.modelName, |
|
|
|
|
|
channel_id: editingRecord.channelId, |
|
|
|
|
|
...values, |
|
|
|
|
|
tag_ids: Array.isArray(values.tag_ids) ? values.tag_ids.join(',') : '', |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
if (editingRecord.pricing?.id) { |
|
|
|
|
|
data.id = editingRecord.pricing.id; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const res = await channelPricingApi.create(data); |
|
|
|
|
|
if (res.data.success) { |
|
|
|
|
|
showSuccess(t('保存成功')); |
|
|
|
|
|
setEditModalVisible(false); |
|
|
|
|
|
onRefresh(); |
|
|
|
|
|
} |
|
|
|
|
|
} catch (e) { |
|
|
|
|
|
showError(e); |
|
|
|
|
|
} |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const toggleExpand = (modelName) => { |
|
|
|
|
|
setExpandedKeys((prev) => |
|
|
|
|
|
prev.includes(modelName) |
|
|
|
|
|
? prev.filter((k) => k !== modelName) |
|
|
|
|
|
: [...prev, modelName] |
|
|
|
|
|
); |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const mainColumns = [ |
|
|
|
|
|
{ |
|
|
|
|
|
title: t('模型名称'), |
|
|
|
|
|
dataIndex: 'modelName', |
|
|
|
|
|
key: 'modelName', |
|
|
|
|
|
render: (text, record) => ( |
|
|
|
|
|
<div |
|
|
|
|
|
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }} |
|
|
|
|
|
onClick={() => toggleExpand(text)} |
|
|
|
|
|
> |
|
|
|
|
|
{expandedKeys.includes(text) ? ( |
|
|
|
|
|
<IconChevronDown style={{ marginRight: 8 }} /> |
|
|
|
|
|
) : ( |
|
|
|
|
|
<IconChevronRight style={{ marginRight: 8 }} /> |
|
|
|
|
|
)} |
|
|
|
|
|
<Typography.Text strong>{text}</Typography.Text> |
|
|
|
|
|
{record.hasChannelPricing && ( |
|
|
|
|
|
<Tag color="blue" size="small" style={{ marginLeft: 8 }}> |
|
|
|
|
|
{t('已配置')} |
|
|
|
|
|
</Tag> |
|
|
|
|
|
)} |
|
|
|
|
|
</div> |
|
|
|
|
|
), |
|
|
|
|
|
}, |
|
|
|
|
|
{ |
|
|
|
|
|
title: t('渠道数量'), |
|
|
|
|
|
key: 'channelCount', |
|
|
|
|
|
width: 120, |
|
|
|
|
|
render: (_, record) => |
|
|
|
|
|
`${record.channelPrices.filter((cp) => cp.pricing).length} / ${record.channelPrices.length}`, |
|
|
|
|
|
}, |
|
|
|
|
|
{ |
|
|
|
|
|
title: t('操作'), |
|
|
|
|
|
key: 'action', |
|
|
|
|
|
width: 100, |
|
|
|
|
|
render: (_, record) => ( |
|
|
|
|
|
<Button |
|
|
|
|
|
size="small" |
|
|
|
|
|
onClick={() => toggleExpand(record.modelName)} |
|
|
|
|
|
> |
|
|
|
|
|
{expandedKeys.includes(record.modelName) ? t('收起') : t('展开')} |
|
|
|
|
|
</Button> |
|
|
|
|
|
), |
|
|
|
|
|
}, |
|
|
|
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
const channelColumns = [ |
|
|
|
|
|
{ |
|
|
|
|
|
title: t('渠道'), |
|
|
|
|
|
dataIndex: 'channelName', |
|
|
|
|
|
key: 'channelName', |
|
|
|
|
|
render: (text) => ( |
|
|
|
|
|
<Typography.Text type="tertiary">└ {text}</Typography.Text> |
|
|
|
|
|
), |
|
|
|
|
|
}, |
|
|
|
|
|
{ |
|
|
|
|
|
title: t('计费类型'), |
|
|
|
|
|
key: 'quotaType', |
|
|
|
|
|
width: 100, |
|
|
|
|
|
render: (_, record) => { |
|
|
|
|
|
if (!record.pricing) return <Typography.Text type="tertiary">-</Typography.Text>; |
|
|
|
|
|
return record.pricing.quota_type === 1 ? t('按次') : t('按量'); |
|
|
|
|
|
}, |
|
|
|
|
|
}, |
|
|
|
|
|
{ |
|
|
|
|
|
title: t('输入倍率'), |
|
|
|
|
|
key: 'modelRatio', |
|
|
|
|
|
width: 100, |
|
|
|
|
|
render: (_, record) => |
|
|
|
|
|
record.pricing ? record.pricing.model_ratio : '-', |
|
|
|
|
|
}, |
|
|
|
|
|
{ |
|
|
|
|
|
title: t('输出倍率'), |
|
|
|
|
|
key: 'completionRatio', |
|
|
|
|
|
width: 100, |
|
|
|
|
|
render: (_, record) => |
|
|
|
|
|
record.pricing ? record.pricing.completion_ratio : '-', |
|
|
|
|
|
}, |
|
|
|
|
|
{ |
|
|
|
|
|
title: t('固定价格'), |
|
|
|
|
|
key: 'modelPrice', |
|
|
|
|
|
width: 100, |
|
|
|
|
|
render: (_, record) => |
|
|
|
|
|
record.pricing?.quota_type === 1 ? record.pricing.model_price : '-', |
|
|
|
|
|
}, |
|
|
|
|
|
{ |
|
|
|
|
|
title: t('标签'), |
|
|
|
|
|
key: 'tags', |
|
|
|
|
|
width: 150, |
|
|
|
|
|
render: (_, record) => { |
|
|
|
|
|
if (!record.pricing?.tags?.length) return '-'; |
|
|
|
|
|
return record.pricing.tags.map((tag) => ( |
|
|
|
|
|
<Tag key={tag.id} color={tag.color} size="small" style={{ marginRight: 4 }}> |
|
|
|
|
|
{tag.name} |
|
|
|
|
|
</Tag> |
|
|
|
|
|
)); |
|
|
|
|
|
}, |
|
|
|
|
|
}, |
|
|
|
|
|
{ |
|
|
|
|
|
title: t('操作'), |
|
|
|
|
|
key: 'action', |
|
|
|
|
|
width: 120, |
|
|
|
|
|
render: (_, record) => ( |
|
|
|
|
|
<Space> |
|
|
|
|
|
<Button |
|
|
|
|
|
size="small" |
|
|
|
|
|
icon={<IconEdit />} |
|
|
|
|
|
onClick={() => handleEdit(record.modelName, record.channelId, record.pricing)} |
|
|
|
|
|
/> |
|
|
|
|
|
{record.pricing && ( |
|
|
|
|
|
<Popconfirm |
|
|
|
|
|
title={t('确定删除此渠道定价?')} |
|
|
|
|
|
onConfirm={() => handleDelete(record.pricing.id)} |
|
|
|
|
|
> |
|
|
|
|
|
<Button size="small" icon={<IconDelete />} type="danger" /> |
|
|
|
|
|
</Popconfirm> |
|
|
|
|
|
)} |
|
|
|
|
|
</Space> |
|
|
|
|
|
), |
|
|
|
|
|
}, |
|
|
|
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
if (modelData.length === 0) { |
|
|
|
|
|
return ( |
|
|
|
|
|
<Empty |
|
|
|
|
|
description={t('暂无渠道定价数据,请先创建渠道并设置模型')} |
|
|
|
|
|
/> |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Parse tag_ids for editing |
|
|
|
|
|
const getInitialTagIds = (pricing) => { |
|
|
|
|
|
if (!pricing?.tag_ids) return []; |
|
|
|
|
|
if (Array.isArray(pricing.tag_ids)) return pricing.tag_ids.map(String); |
|
|
|
|
|
if (typeof pricing.tag_ids === 'string' && pricing.tag_ids.length > 0) { |
|
|
|
|
|
return pricing.tag_ids.split(',').map(String); |
|
|
|
|
|
} |
|
|
|
|
|
return []; |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
|
<div> |
|
|
|
|
|
<Table |
|
|
|
|
|
columns={mainColumns} |
|
|
|
|
|
dataSource={modelData} |
|
|
|
|
|
pagination={false} |
|
|
|
|
|
rowKey="modelName" |
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
{/* Expanded channel pricing details */} |
|
|
|
|
|
{expandedKeys.map((modelName) => { |
|
|
|
|
|
const model = modelData.find((m) => m.modelName === modelName); |
|
|
|
|
|
if (!model) return null; |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
|
<div key={modelName} style={{ marginLeft: 24, marginTop: 8 }}> |
|
|
|
|
|
<Typography.Title heading={6}> |
|
|
|
|
|
{t('渠道定价详情')}: {modelName} |
|
|
|
|
|
</Typography.Title> |
|
|
|
|
|
<Table |
|
|
|
|
|
columns={channelColumns} |
|
|
|
|
|
dataSource={model.channelPrices.map((cp) => ({ |
|
|
|
|
|
...cp, |
|
|
|
|
|
key: `${modelName}-${cp.channelId}`, |
|
|
|
|
|
modelName, |
|
|
|
|
|
}))} |
|
|
|
|
|
pagination={false} |
|
|
|
|
|
size="small" |
|
|
|
|
|
/> |
|
|
|
|
|
</div> |
|
|
|
|
|
); |
|
|
|
|
|
})} |
|
|
|
|
|
|
|
|
|
|
|
{/* Edit Modal */} |
|
|
|
|
|
<Modal |
|
|
|
|
|
title={t('编辑渠道定价')} |
|
|
|
|
|
visible={editModalVisible} |
|
|
|
|
|
onCancel={() => setEditModalVisible(false)} |
|
|
|
|
|
onOk={handleSubmit} |
|
|
|
|
|
> |
|
|
|
|
|
<Form |
|
|
|
|
|
getFormApi={(api) => (formRef.current = api)} |
|
|
|
|
|
initValues={{ |
|
|
|
|
|
quota_type: editingRecord?.pricing?.quota_type ?? 0, |
|
|
|
|
|
model_ratio: editingRecord?.pricing?.model_ratio ?? 0, |
|
|
|
|
|
completion_ratio: editingRecord?.pricing?.completion_ratio ?? 0, |
|
|
|
|
|
model_price: editingRecord?.pricing?.model_price ?? 0, |
|
|
|
|
|
tag_ids: getInitialTagIds(editingRecord?.pricing), |
|
|
|
|
|
}} |
|
|
|
|
|
> |
|
|
|
|
|
<Form.Input |
|
|
|
|
|
field="model_name" |
|
|
|
|
|
label={t('模型名称')} |
|
|
|
|
|
disabled |
|
|
|
|
|
initValue={editingRecord?.modelName} |
|
|
|
|
|
/> |
|
|
|
|
|
<Form.Select |
|
|
|
|
|
field="quota_type" |
|
|
|
|
|
label={t('计费类型')} |
|
|
|
|
|
rules={[{ required: true }]} |
|
|
|
|
|
> |
|
|
|
|
|
<Select.Option value={0}>{t('按量计费')}</Select.Option> |
|
|
|
|
|
<Select.Option value={1}>{t('按次计费')}</Select.Option> |
|
|
|
|
|
</Form.Select> |
|
|
|
|
|
<Form.InputNumber |
|
|
|
|
|
field="model_ratio" |
|
|
|
|
|
label={t('输入倍率')} |
|
|
|
|
|
min={0} |
|
|
|
|
|
step={0.1} |
|
|
|
|
|
/> |
|
|
|
|
|
<Form.InputNumber |
|
|
|
|
|
field="completion_ratio" |
|
|
|
|
|
label={t('输出倍率')} |
|
|
|
|
|
min={0} |
|
|
|
|
|
step={0.1} |
|
|
|
|
|
/> |
|
|
|
|
|
<Form.InputNumber |
|
|
|
|
|
field="model_price" |
|
|
|
|
|
label={t('固定价格')} |
|
|
|
|
|
min={0} |
|
|
|
|
|
step={0.01} |
|
|
|
|
|
/> |
|
|
|
|
|
<Form.Select |
|
|
|
|
|
field="tag_ids" |
|
|
|
|
|
label={t('标签')} |
|
|
|
|
|
multiple |
|
|
|
|
|
filter |
|
|
|
|
|
style={{ width: '100%' }} |
|
|
|
|
|
placeholder={t('请选择标签')} |
|
|
|
|
|
> |
|
|
|
|
|
{tags.map((tag) => ( |
|
|
|
|
|
<Select.Option key={tag.id} value={String(tag.id)}> |
|
|
|
|
|
<Tag color={tag.color}>{tag.name}</Tag> |
|
|
|
|
|
</Select.Option> |
|
|
|
|
|
))} |
|
|
|
|
|
</Form.Select> |
|
|
|
|
|
</Form> |
|
|
|
|
|
</Modal> |
|
|
|
|
|
</div> |
|
|
|
|
|
); |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
export default ChannelPricingView; |