|
- /*
- 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,
- Input,
- RadioGroup,
- Radio,
- TreeSelect,
- } from '@douyinfe/semi-ui';
- import {
- IconEdit,
- IconDelete,
- IconChevronDown,
- IconChevronRight,
- IconSearch,
- } 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 [expandedRowKeys, setExpandedRowKeys] = useState([]);
- const [editModalVisible, setEditModalVisible] = useState(false);
- const [editingRecord, setEditingRecord] = useState(null);
- const [currentQuotaType, setCurrentQuotaType] = useState(0); // 0: 按量计费, 1: 按次计费
- const [searchText, setSearchText] = useState('');
- const formRef = useRef();
- const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' | 'token-price'
- const [tokenPrices, setTokenPrices] = useState({
- inputTokenPrice: '',
- outputTokenPrice: ''
- });
-
- // Ensure arrays are valid
- const safeChannels = Array.isArray(channels) ? channels : [];
- const safeChannelPricings = Array.isArray(channelPricings) ? channelPricings : [];
- const safeTags = Array.isArray(tags) ? tags : [];
-
- // Helper for colored tag style
- const getTagStyle = (color, extra = {}) => ({
- backgroundColor: color,
- color: '#fff',
- borderColor: color,
- ...extra
- });
-
- // Convert tags to TreeSelect tree structure
- const tagTreeData = useMemo(() => {
- return safeTags.map((tag) => ({
- value: String(tag.id),
- label: <Tag style={getTagStyle(tag.color)}>{tag.name}</Tag>,
- key: String(tag.id),
- color: tag.color,
- }));
- }, [safeTags]);
-
- // Build model -> channel pricing mapping
- const modelData = useMemo(() => {
- // Build pricing lookup map
- const pricingMap = new Map();
- safeChannelPricings.forEach((cp) => {
- const key = `${cp.model_name}:${cp.channel_id}`;
- pricingMap.set(key, cp);
- });
-
- // Get all unique model names from channels' models field
- const modelNames = new Set();
- const channelModelsMap = new Map(); // channelId -> Set of models
-
- safeChannels.forEach((ch) => {
- if (ch.models) {
- const models = typeof ch.models === 'string' ? ch.models.split(',') : ch.models;
- const modelSet = new Set(models.map((m) => m.trim()).filter(Boolean));
- channelModelsMap.set(ch.id, modelSet);
- modelSet.forEach((modelName) => modelNames.add(modelName));
- }
- });
-
- // Build model data with channel pricing info
- return Array.from(modelNames).map((modelName) => {
- const channelPrices = safeChannels
- .filter((ch) => {
- const channelModels = channelModelsMap.get(ch.id);
- return channelModels && channelModels.has(modelName);
- })
- .map((ch) => {
- const key = `${modelName}:${ch.id}`;
- const pricing = pricingMap.get(key);
- return {
- channelId: ch.id,
- channelName: ch.name,
- channelType: ch.type,
- pricing: pricing || null,
- };
- });
-
- return {
- modelName,
- channelPrices,
- hasChannelPricing: channelPrices.some((cp) => cp.pricing !== null),
- };
- });
- }, [safeChannels, safeChannelPricings]);
-
- // Filter model data by search text
- const filteredModelData = useMemo(() => {
- if (!searchText) return modelData;
- const lowerSearch = searchText.toLowerCase();
- return modelData.filter(item =>
- item.modelName.toLowerCase().includes(lowerSearch)
- );
- }, [modelData, searchText]);
-
- // 价格转倍率计算函数(参考 ModelSettingsVisualEditor.jsx)
- const calculateRatioFromTokenPrice = (tokenPrice) => {
- return tokenPrice / 2;
- };
-
- const calculateCompletionRatioFromPrices = (modelTokenPrice, completionTokenPrice) => {
- if (!modelTokenPrice || modelTokenPrice === 0) {
- return 0;
- }
- return completionTokenPrice / modelTokenPrice;
- };
-
- // 价格变化处理函数
- const handleInputTokenPriceChange = (value) => {
- const price = parseFloat(value) || 0;
- const ratio = calculateRatioFromTokenPrice(price);
- setTokenPrices(prev => ({ ...prev, inputTokenPrice: value }));
- if (formRef.current) {
- formRef.current.setValue('model_ratio', ratio);
- }
- };
-
- const handleOutputTokenPriceChange = (value) => {
- const outputPrice = parseFloat(value) || 0;
- const inputPrice = parseFloat(tokenPrices.inputTokenPrice) || 0;
- setTokenPrices(prev => ({ ...prev, outputTokenPrice: value }));
- if (inputPrice > 0 && formRef.current) {
- const completionRatio = calculateCompletionRatioFromPrices(inputPrice, outputPrice);
- formRef.current.setValue('completion_ratio', completionRatio);
- }
- };
-
- const handleEdit = (modelName, channelId, pricing) => {
- const quotaType = pricing?.quota_type ?? 0;
- setCurrentQuotaType(quotaType);
-
- // 初始化子模式和价格(根据现有倍率反推)
- if (quotaType === 0 && pricing) {
- const ratio = pricing.model_ratio || 0;
- const completionRatio = pricing.completion_ratio || 0;
- const inputTokenPrice = ratio * 2;
- const outputTokenPrice = inputTokenPrice * completionRatio;
-
- setTokenPrices({
- inputTokenPrice: inputTokenPrice.toString(),
- outputTokenPrice: outputTokenPrice.toString()
- });
- setPricingSubMode('ratio'); // 默认显示倍率模式
- } else {
- setTokenPrices({ inputTokenPrice: '', outputTokenPrice: '' });
- setPricingSubMode('ratio');
- }
-
- 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();
-
- // Convert TreeSelect array ['1', '2'] to comma-separated string '1,2'
- let tagIdsStr = '';
- if (Array.isArray(values.tag_ids) && values.tag_ids.length > 0) {
- tagIdsStr = values.tag_ids.filter(Boolean).join(',');
- }
-
- const data = {
- model_name: editingRecord.modelName,
- channel_id: editingRecord.channelId,
- ...values,
- tag_ids: tagIdsStr,
- };
-
- 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);
- }
- };
-
- // Channel pricing sub-table columns
- const channelColumns = [
- {
- title: t('渠道'),
- dataIndex: 'channelName',
- key: 'channelName',
- width: 200,
- },
- {
- 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) => {
- if (!record.pricing) return '-';
- // 按次计费时不显示倍率
- if (record.pricing.quota_type === 1) return '-';
- return record.pricing.model_ratio;
- },
- },
- {
- title: t('补全倍率'),
- key: 'completionRatio',
- width: 100,
- render: (_, record) => {
- if (!record.pricing) return '-';
- // 按次计费时不显示倍率
- if (record.pricing.quota_type === 1) return '-';
- return record.pricing.completion_ratio;
- },
- },
- {
- title: t('固定价格'),
- key: 'modelPrice',
- width: 100,
- render: (_, record) => {
- if (!record.pricing) return '-';
- // 按量计费时不显示固定价格
- if (record.pricing.quota_type === 0) return '-';
- return 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} size="small" style={getTagStyle(tag.color, { 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>
- ),
- },
- ];
-
- // Main table columns
- const mainColumns = [
- {
- title: t('模型名称'),
- dataIndex: 'modelName',
- key: 'modelName',
- render: (text, record) => (
- <div style={{ display: 'flex', alignItems: 'center' }}>
- <Typography.Text strong>{text}</Typography.Text>
- {record.hasChannelPricing && (
- <Tag color="blue" size="small" style={{ marginLeft: 8 }}>
- {t('已配置')}
- </Tag>
- )}
- </div>
- ),
- },
- {
- title: t('渠道数量'),
- key: 'channelCount',
- width: 120,
- render: (_, record) =>
- `${record.channelPrices.filter((cp) => cp.pricing).length} / ${record.channelPrices.length}`,
- },
- {
- title: t('操作'),
- key: 'action',
- width: 80,
- render: (_, record) => {
- const isExpanded = expandedRowKeys.includes(record.modelName);
- return (
- <Button
- size="small"
- icon={isExpanded ? <IconChevronDown /> : <IconChevronRight />}
- onClick={(e) => {
- e.stopPropagation();
- if (isExpanded) {
- setExpandedRowKeys(expandedRowKeys.filter(k => k !== record.modelName));
- } else {
- setExpandedRowKeys([...expandedRowKeys, record.modelName]);
- }
- }}
- >
- {isExpanded ? t('收起') : t('展开')}
- </Button>
- );
- },
- },
- ];
-
- // Expanded row render
- const expandedRowRender = (record) => {
- return (
- <Table
- columns={channelColumns}
- dataSource={record.channelPrices.map((cp) => ({
- ...cp,
- key: `${record.modelName}-${cp.channelId}`,
- modelName: record.modelName,
- }))}
- pagination={false}
- size="small"
- bordered
- />
- );
- };
-
- if (modelData.length === 0) {
- return (
- <Empty
- description={t('暂无渠道定价数据,请先创建渠道并设置模型')}
- />
- );
- }
-
- // Parse tag_ids for editing (returns flat array for TreeSelect multiple mode)
- const getInitialTagIds = (pricing) => {
- if (!pricing) return [];
-
- let ids = [];
-
- // 如果有 tags 数组(包含完整标签对象),从中提取 ID
- if (pricing.tags && Array.isArray(pricing.tags) && pricing.tags.length > 0) {
- ids = pricing.tags.map(tag => String(tag.id));
- }
- // 否则从 tag_ids 字段解析
- else if (pricing.tag_ids) {
- if (Array.isArray(pricing.tag_ids)) {
- ids = pricing.tag_ids.map(String);
- } else if (typeof pricing.tag_ids === 'string' && pricing.tag_ids.length > 0) {
- ids = pricing.tag_ids.split(',').map(String);
- }
- }
-
- // TreeSelect multiple mode needs flat array: ['1', '2']
- return ids;
- };
-
- return (
- <div>
- {/* Search Bar */}
- <div style={{ marginBottom: 16 }}>
- <Input
- prefix={<IconSearch />}
- placeholder={t('搜索模型名称')}
- value={searchText}
- onChange={(value) => setSearchText(value)}
- style={{ width: 250 }}
- showClear
- />
- {searchText && (
- <Typography.Text type="tertiary" style={{ marginLeft: 12 }}>
- {t('找到 {{count}} 个模型', { count: filteredModelData.length })}
- </Typography.Text>
- )}
- </div>
-
- <Table
- columns={mainColumns}
- dataSource={filteredModelData}
- pagination={false}
- rowKey="modelName"
- expandedRowKeys={expandedRowKeys}
- onExpandedRowsChange={(keys) => {
- setExpandedRowKeys(keys);
- }}
- expandedRowRender={expandedRowRender}
- expandIcon={false}
- />
-
- {/* Edit Modal */}
- <Modal
- title={t('编辑渠道定价')}
- visible={editModalVisible}
- onCancel={() => {
- setEditModalVisible(false);
- setPricingSubMode('ratio');
- setTokenPrices({ inputTokenPrice: '', outputTokenPrice: '' });
- }}
- onOk={handleSubmit}
- >
- <Form
- key={editingRecord?.pricing?.id || 'new'}
- 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),
- cache_ratio: editingRecord?.pricing?.cache_ratio ?? 0,
- cache_creation_ratio: editingRecord?.pricing?.cache_creation_ratio ?? 0,
- image_ratio: editingRecord?.pricing?.image_ratio ?? 0,
- audio_ratio: editingRecord?.pricing?.audio_ratio ?? 0,
- audio_completion_ratio: editingRecord?.pricing?.audio_completion_ratio ?? 0,
- }}
- >
- <Form.Input
- field="model_name"
- label={t('模型名称')}
- disabled
- initValue={editingRecord?.modelName}
- />
- <Form.Select
- field="quota_type"
- label={t('计费类型')}
- rules={[{ required: true }]}
- onChange={(value) => setCurrentQuotaType(value)}
- >
- <Select.Option value={0}>{t('按量计费')}</Select.Option>
- <Select.Option value={1}>{t('按次计费')}</Select.Option>
- </Form.Select>
- <Typography.Text
- size="small"
- type="tertiary"
- style={{ display: 'block', marginTop: -8, marginBottom: 8 }}
- >
- {currentQuotaType === 0
- ? t('按量计费:根据 token 数量计费,使用模型倍率和补全倍率计算')
- : t('按次计费:每次请求使用固定价格计费')}
- </Typography.Text>
- {currentQuotaType === 0 && (
- <>
- <Form.Section text={t('价格设置方式')}>
- <div style={{ marginBottom: '16px' }}>
- <RadioGroup
- type="button"
- value={pricingSubMode}
- onChange={(e) => {
- const newMode = e.target.value;
- // 切换到倍率模式时,从价格计算倍率
- if (newMode === 'ratio' && formRef.current) {
- const inputPrice = parseFloat(tokenPrices.inputTokenPrice) || 0;
- const outputPrice = parseFloat(tokenPrices.outputTokenPrice) || 0;
- if (inputPrice > 0) {
- formRef.current.setValue('model_ratio', calculateRatioFromTokenPrice(inputPrice));
- formRef.current.setValue('completion_ratio', calculateCompletionRatioFromPrices(inputPrice, outputPrice));
- }
- }
- setPricingSubMode(newMode);
- }}
- >
- <Radio value="ratio">{t('按倍率设置')}</Radio>
- <Radio value="token-price">{t('按价格设置')}</Radio>
- </RadioGroup>
- </div>
- </Form.Section>
-
- <div style={{ display: pricingSubMode === 'ratio' ? 'block' : 'none' }}>
- <Form.InputNumber
- field="model_ratio"
- label={t('模型倍率')}
- min={0}
- step={0.1}
- />
- <Form.InputNumber
- field="completion_ratio"
- label={t('补全倍率')}
- min={0}
- step={0.1}
- />
- </div>
-
- <div style={{ display: pricingSubMode === 'token-price' ? 'block' : 'none' }}>
- <Form.Input
- field="input_token_price"
- label={t('输入价格')}
- suffix={t('$/1M tokens')}
- placeholder="0"
- onChange={handleInputTokenPriceChange}
- initValue={tokenPrices.inputTokenPrice}
- />
- <Form.Input
- field="output_token_price"
- label={t('输出价格')}
- suffix={t('$/1M tokens')}
- placeholder="0"
- onChange={handleOutputTokenPriceChange}
- initValue={tokenPrices.outputTokenPrice}
- />
- </div>
-
- <Form.Section text={t('高级比例(留空使用全局默认值)')}>
- <Form.InputNumber field="cache_ratio" label={t('缓存读取倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
- <Form.InputNumber field="cache_creation_ratio" label={t('缓存创建倍率(5分钟)')} min={0} step={0.01} placeholder={t('全局默认值(1小时自动按 1.6x 计算)')} />
- <Form.InputNumber field="image_ratio" label={t('图片倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
- <Form.InputNumber field="audio_ratio" label={t('音频输入倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
- <Form.InputNumber field="audio_completion_ratio" label={t('音频输出倍率')} min={0} step={0.01} placeholder={t('全局默认值')} />
- </Form.Section>
- </>
- )}
- {currentQuotaType === 1 && (
- <Form.InputNumber
- field="model_price"
- label={t('固定价格')}
- min={0}
- step={0.01}
- />
- )}
- <Form.TreeSelect
- field="tag_ids"
- label={t('标签')}
- multiple
- filterTreeNode
- style={{ width: '100%' }}
- placeholder={t('请选择标签')}
- treeData={tagTreeData}
- leafOnly
- renderSelectedItem={(node, { onClose }) => ({
- isRenderInTag: false,
- content: (
- <Tag closable onClose={onClose} style={getTagStyle(node.color, { marginRight: 4 })}>
- {node.label}
- </Tag>
- )
- })}
- />
- </Form>
- </Modal>
- </div>
- );
- };
-
- export default ChannelPricingView;
|