diff --git a/web/src/components/settings/RatioSetting.jsx b/web/src/components/settings/RatioSetting.jsx
index 1704130..078dbb9 100644
--- a/web/src/components/settings/RatioSetting.jsx
+++ b/web/src/components/settings/RatioSetting.jsx
@@ -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 = () => {
+
+
+ {t('标签管理')}
+
+ }
+ itemKey='pricing_tags'
+ >
+
+
diff --git a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx
new file mode 100644
index 0000000..68778e3
--- /dev/null
+++ b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx
@@ -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 .
+
+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) => (
+
toggleExpand(text)}
+ >
+ {expandedKeys.includes(text) ? (
+
+ ) : (
+
+ )}
+ {text}
+ {record.hasChannelPricing && (
+
+ {t('已配置')}
+
+ )}
+
+ ),
+ },
+ {
+ 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) => (
+
+ ),
+ },
+ ];
+
+ const channelColumns = [
+ {
+ title: t('渠道'),
+ dataIndex: 'channelName',
+ key: 'channelName',
+ render: (text) => (
+ └ {text}
+ ),
+ },
+ {
+ title: t('计费类型'),
+ key: 'quotaType',
+ width: 100,
+ render: (_, record) => {
+ if (!record.pricing) return -;
+ 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.name}
+
+ ));
+ },
+ },
+ {
+ title: t('操作'),
+ key: 'action',
+ width: 120,
+ render: (_, record) => (
+
+ }
+ onClick={() => handleEdit(record.modelName, record.channelId, record.pricing)}
+ />
+ {record.pricing && (
+ handleDelete(record.pricing.id)}
+ >
+ } type="danger" />
+
+ )}
+
+ ),
+ },
+ ];
+
+ if (modelData.length === 0) {
+ return (
+
+ );
+ }
+
+ // 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 (
+
+
+
+ {/* Expanded channel pricing details */}
+ {expandedKeys.map((modelName) => {
+ const model = modelData.find((m) => m.modelName === modelName);
+ if (!model) return null;
+
+ return (
+
+
+ {t('渠道定价详情')}: {modelName}
+
+
({
+ ...cp,
+ key: `${modelName}-${cp.channelId}`,
+ modelName,
+ }))}
+ pagination={false}
+ size="small"
+ />
+
+ );
+ })}
+
+ {/* Edit Modal */}
+ setEditModalVisible(false)}
+ onOk={handleSubmit}
+ >
+
+
+ {t('按量计费')}
+ {t('按次计费')}
+
+
+
+
+
+ {tags.map((tag) => (
+
+ {tag.name}
+
+ ))}
+
+
+
+
+ );
+};
+
+export default ChannelPricingView;
diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx
index b5ad3e5..b6dd112 100644
--- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx
+++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx
@@ -17,7 +17,7 @@ along with this program. If not, see .
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 (
<>
-
-
- }
- onClick={() => {
- resetModalState();
- setVisible(true);
- }}
- >
- {t('添加模型')}
-
- } onClick={SubmitData}>
- {t('应用更改')}
-
- }
- placeholder={t('搜索模型名称')}
- value={searchText}
- onChange={(value) => {
- setSearchText(value);
- setCurrentPage(1);
+ {/* View mode switch */}
+
+ setViewMode(e.target.value)}
+ type="button"
+ >
+ {t('全局定价')}
+ {t('渠道定价')}
+
+
+
+ {/* Global pricing view */}
+ {viewMode === 'global' && (
+
+
+ }
+ onClick={() => {
+ resetModalState();
+ setVisible(true);
+ }}
+ >
+ {t('添加模型')}
+
+ } onClick={SubmitData}>
+ {t('应用更改')}
+
+ }
+ placeholder={t('搜索模型名称')}
+ value={searchText}
+ onChange={(value) => {
+ setSearchText(value);
+ setCurrentPage(1);
+ }}
+ style={{ width: 200 }}
+ showClear
+ />
+ {
+ setConflictOnly(e.target.checked);
+ setCurrentPage(1);
+ }}
+ >
+ {t('仅显示矛盾倍率')}
+
+
+ setCurrentPage(page),
+ showTotal: true,
+ showSizeChanger: false,
}}
- style={{ width: 200 }}
- showClear
/>
- {
- setConflictOnly(e.target.checked);
- setCurrentPage(1);
- }}
- >
- {t('仅显示矛盾倍率')}
-
- setCurrentPage(page),
- showTotal: true,
- showSizeChanger: false,
- }}
+ )}
+
+ {/* Channel pricing view */}
+ {viewMode === 'channel' && (
+
-
+ )}