From b9ccbbcdbed9b0bb5fe4c20a0d3c1a9fcdf65b39 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Tue, 24 Mar 2026 11:59:15 +0800 Subject: [PATCH] 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 --- web/src/components/settings/RatioSetting.jsx | 13 + .../Setting/Ratio/ChannelPricingView.jsx | 386 ++++++++++++++++++ .../Ratio/ModelSettingsVisualEditor.jsx | 168 ++++++-- 3 files changed, 522 insertions(+), 45 deletions(-) create mode 100644 web/src/pages/Setting/Ratio/ChannelPricingView.jsx 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) => ( + +