Browse Source

feat: integrate channel pricing UI into pricing settings

- Add view mode switch (global vs channel) to ModelSettingsVisualEditor
- Create ChannelPricingView component for channel-specific pricing
- Add pricing tag management tab to RatioSetting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat/alipay-payment
fengsilin 1 month ago
parent
commit
b9ccbbcdbe
3 changed files with 522 additions and 45 deletions
  1. +13
    -0
      web/src/components/settings/RatioSetting.jsx
  2. +386
    -0
      web/src/pages/Setting/Ratio/ChannelPricingView.jsx
  3. +123
    -45
      web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx

+ 13
- 0
web/src/components/settings/RatioSetting.jsx View File

@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com

import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { Tag } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';

import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
@@ -26,6 +27,7 @@ import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings';
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor';
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
import PricingTagManager from '../../pages/Setting/Ratio/PricingTagManager';

import { API, showError, toBoolean } from '../../helpers';

@@ -113,6 +115,17 @@ const RatioSetting = () => {
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
<UpstreamRatioSync options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane
tab={
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<Tag size="small" />
{t('标签管理')}
</span>
}
itemKey='pricing_tags'
>
<PricingTagManager />
</Tabs.TabPane>
</Tabs>
</Card>
</Spin>


+ 386
- 0
web/src/pages/Setting/Ratio/ChannelPricingView.jsx View File

@@ -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;

+ 123
- 45
web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx View File

@@ -17,7 +17,7 @@ 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 React, { useEffect, useState, useRef, useCallback } from 'react';
import {
Table,
Button,
@@ -38,7 +38,9 @@ import {
IconEdit,
} from '@douyinfe/semi-icons';
import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers';
import { channelPricingApi, pricingTagApi } from '../../../helpers/api';
import { useTranslation } from 'react-i18next';
import ChannelPricingView from './ChannelPricingView';

export default function ModelSettingsVisualEditor(props) {
const { t } = useTranslation();
@@ -56,6 +58,12 @@ export default function ModelSettingsVisualEditor(props) {
const pageSize = 10;
const quotaPerUnit = getQuotaPerUnit();

// Channel pricing view mode
const [viewMode, setViewMode] = useState('global'); // 'global' | 'channel'
const [channels, setChannels] = useState([]);
const [channelPricings, setChannelPricings] = useState([]);
const [tags, setTags] = useState([]);

useEffect(() => {
try {
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
@@ -90,6 +98,51 @@ export default function ModelSettingsVisualEditor(props) {
}
}, [props.options]);

// Load channels for channel pricing view
const loadChannels = useCallback(async () => {
try {
const res = await API.get('/api/channel/');
if (res.data.success) {
setChannels(res.data.data || []);
}
} catch (e) {
console.error('Failed to load channels', e);
}
}, []);

// Load channel pricings
const loadChannelPricings = useCallback(async () => {
try {
const res = await channelPricingApi.getWithTags(1, 1000);
if (res.data.success) {
setChannelPricings(res.data.data.items || []);
}
} catch (e) {
console.error('Failed to load channel pricings', e);
}
}, []);

// Load tags
const loadTags = useCallback(async () => {
try {
const res = await pricingTagApi.getAll();
if (res.data.success) {
setTags(res.data.data || []);
}
} catch (e) {
console.error('Failed to load tags', e);
}
}, []);

// Load channel data when switching to channel view
useEffect(() => {
if (viewMode === 'channel') {
loadChannels();
loadChannelPricings();
loadTags();
}
}, [viewMode, loadChannels, loadChannelPricings, loadTags]);

// 首先声明分页相关的工具函数
const getPagedData = (data, currentPage, pageSize) => {
const start = (currentPage - 1) * pageSize;
@@ -455,54 +508,79 @@ export default function ModelSettingsVisualEditor(props) {

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);
{/* View mode switch */}
<div style={{ marginBottom: 16 }}>
<RadioGroup
value={viewMode}
onChange={(e) => setViewMode(e.target.value)}
type="button"
>
<Radio value="global">{t('全局定价')}</Radio>
<Radio value="channel">{t('渠道定价')}</Radio>
</RadioGroup>
</div>

{/* Global pricing view */}
{viewMode === 'global' && (
<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,
}}
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,
}}
)}

{/* Channel pricing view */}
{viewMode === 'channel' && (
<ChannelPricingView
channels={channels}
channelPricings={channelPricings}
tags={tags}
onRefresh={loadChannelPricings}
/>
</Space>
)}

<Modal
title={isEditMode ? t('编辑模型') : t('添加模型')}


Loading…
Cancel
Save