From 5c57f692a579bcbe3d86bf94ef5aa3b9870b9213 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 25 Mar 2026 15:17:21 +0800 Subject: [PATCH] feat: enhance channel pricing with price mode and color picker - Add "by price" sub-mode for per-token pricing (auto-calculate ratios) - Add color picker with preset colors in tag manager - Display tags with colors in channel pricing view and selector - Remove unused function, extract hardcoded color constants - Fix pricing model filter (status==0 instead of status!=1) Co-Authored-By: Claude Opus 4.6 --- model/pricing.go | 4 +- web/src/components/settings/RatioSetting.jsx | 8 +- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh-CN.json | 1 + .../Setting/Ratio/ChannelPricingView.jsx | 216 +++++++++++++++--- .../pages/Setting/Ratio/PricingTagManager.jsx | 94 +++++++- 6 files changed, 278 insertions(+), 46 deletions(-) diff --git a/model/pricing.go b/model/pricing.go index 6dfe69b..e98c892 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -279,8 +279,8 @@ func updatePricing() { // 补充模型元数据(描述、标签、供应商、状态) if meta, ok := metaMap[model]; ok { - // 若模型被禁用(status!=1),则直接跳过,不返回给前端 - if meta.Status != 1 || meta.Type == 0 { + // 若模型被禁用(status==0),则直接跳过,不返回给前端 + if meta.Status == 0 { continue } pricing.Description = meta.Description diff --git a/web/src/components/settings/RatioSetting.jsx b/web/src/components/settings/RatioSetting.jsx index 24668ec..67336f2 100644 --- a/web/src/components/settings/RatioSetting.jsx +++ b/web/src/components/settings/RatioSetting.jsx @@ -19,7 +19,6 @@ For commercial licensing, please contact support@quantumnous.com import React, { useEffect, useState } from 'react'; import { Card, Spin, Tabs } from '@douyinfe/semi-ui'; -import { IconPriceTag } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings'; @@ -116,12 +115,7 @@ const RatioSetting = () => { - - {t('标签管理')} - - } + tab={t('标签管理')} itemKey='pricing_tags' > diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c20c20f..df9735b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2578,6 +2578,7 @@ "预览失败": "Preview failed", "预览更新": "Preview update", "预览请求体": "Preview request body", + "预设颜色": "Preset Colors", "预计结束": "Estimated End", "预警阈值必须为正数": "Warning threshold must be a positive number", "频率惩罚,减少重复词汇的出现": "Frequency penalty, reduces repeated vocabulary", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index b1f9dea..b42bbd9 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2601,6 +2601,7 @@ "预览失败": "预览失败", "预览更新": "预览更新", "预览请求体": "预览请求体", + "预设颜色": "预设颜色", "预计结束": "预计结束", "预警阈值必须为正数": "预警阈值必须为正数", "频率惩罚,减少重复词汇的出现": "频率惩罚,减少重复词汇的出现", diff --git a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx index edc7d60..77b580a 100644 --- a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx +++ b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx @@ -30,6 +30,9 @@ import { Empty, Popconfirm, Input, + RadioGroup, + Radio, + TreeSelect, } from '@douyinfe/semi-ui'; import { IconEdit, @@ -50,12 +53,35 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { 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.name}, + key: String(tag.id), + color: tag.color, + })); + }, [safeTags]); + // Build model -> channel pricing mapping const modelData = useMemo(() => { // Build pricing lookup map @@ -113,9 +139,59 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { ); }, [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, @@ -147,11 +223,18 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { 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: Array.isArray(values.tag_ids) ? values.tag_ids.join(',') : '', + tag_ids: tagIdsStr, }; if (editingRecord.pricing?.id) { @@ -226,7 +309,7 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { render: (_, record) => { if (!record.pricing?.tags?.length) return '-'; return record.pricing.tags.map((tag) => ( - + {tag.name} )); @@ -331,14 +414,27 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { ); } - // Parse tag_ids for editing + // Parse tag_ids for editing (returns flat array for TreeSelect multiple mode) 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); + 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); + } } - return []; + + // TreeSelect multiple mode needs flat array: ['1', '2'] + return ids; }; return ( @@ -378,10 +474,15 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { setEditModalVisible(false)} + onCancel={() => { + setEditModalVisible(false); + setPricingSubMode('ratio'); + setTokenPrices({ inputTokenPrice: '', outputTokenPrice: '' }); + }} onOk={handleSubmit} >
(formRef.current = api)} initValues={{ quota_type: editingRecord?.pricing?.quota_type ?? 0, @@ -415,22 +516,69 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { ? t('按量计费:根据 token 数量计费,使用模型倍率和补全倍率计算') : t('按次计费:每次请求使用固定价格计费')} - {currentQuotaType === 0 ? ( + {currentQuotaType === 0 && ( <> - - + +
+ { + 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); + }} + > + {t('按倍率设置')} + {t('按价格设置')} + +
+
+ +
+ + +
+ +
+ + +
- ) : ( + )} + {currentQuotaType === 1 && ( { step={0.01} /> )} - - {safeTags.map((tag) => ( - - {tag.name} - - ))} - + treeData={tagTreeData} + leafOnly + renderSelectedItem={(node, { onClose }) => ({ + isRenderInTag: false, + content: ( + + {node.label} + + ) + })} + />
diff --git a/web/src/pages/Setting/Ratio/PricingTagManager.jsx b/web/src/pages/Setting/Ratio/PricingTagManager.jsx index 5ab1711..e1843fd 100644 --- a/web/src/pages/Setting/Ratio/PricingTagManager.jsx +++ b/web/src/pages/Setting/Ratio/PricingTagManager.jsx @@ -28,7 +28,23 @@ import { Popconfirm, Typography, Tag as SemiTag, + ColorPicker, } from '@douyinfe/semi-ui'; + +// 预设颜色板(8列 x 4行 = 32个颜色) +const PRESET_COLORS = [ + // 蓝色系 + '#1890ff', '#096dd9', '#0050b3', '#003a8c', '#002766', '#e6f7ff', '#bae7ff', '#91d5ff', + // 绿色系 + '#52c41a', '#389e0d', '#237804', '#135200', '#f6ffed', '#d9f7be', '#b7eb8f', '#95de64', + // 红色系 + '#f5222d', '#cf1322', '#a8071a', '#820014', '#fff1f0', '#ffccc7', '#ffa39e', '#ff7875', + // 橙色/金色系 + '#fa8c16', '#d46b08', '#ad4e00', '#873800', '#fff7e6', '#ffe7ba', '#ffd591', '#ffc069', +]; +const DEFAULT_COLOR = '#1890ff'; +const SELECTION_BORDER_COLOR = '#1890ff'; + import { IconPlus, IconEdit, IconDelete } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; import { pricingTagApi } from '../../../helpers/api'; @@ -40,6 +56,7 @@ const PricingTagManager = () => { const [loading, setLoading] = useState(false); const [modalVisible, setModalVisible] = useState(false); const [editingTag, setEditingTag] = useState(null); + const [colorValue, setColorValue] = useState(DEFAULT_COLOR); const formRef = useRef(); const loadTags = useCallback(async () => { @@ -60,6 +77,15 @@ const PricingTagManager = () => { loadTags(); }, [loadTags]); + // 同步 editingTag 的颜色到 colorValue + useEffect(() => { + if (editingTag?.color) { + setColorValue(editingTag.color); + } else { + setColorValue(DEFAULT_COLOR); + } + }, [editingTag]); + const handleCreate = () => { setEditingTag(null); setModalVisible(true); @@ -192,11 +218,69 @@ const PricingTagManager = () => { label={t('标签名称')} rules={[{ required: true, message: t('请输入标签名称') }]} /> - + +
+ { + const hex = colorObj?.toHexString?.() || colorObj?.hex || DEFAULT_COLOR; + setColorValue(hex); + if (formRef.current) { + formRef.current.setValue('color', hex); + } + }} + /> + { + setColorValue(val); + if (formRef.current) { + formRef.current.setValue('color', val); + } + }} + placeholder={DEFAULT_COLOR} + style={{ width: 120 }} + /> +
+ + {t('预设颜色')} + +
+ {PRESET_COLORS.map((color) => ( +
{ + setColorValue(color); + if (formRef.current) { + formRef.current.setValue('color', color); + } + }} + style={{ + width: 28, + height: 28, + backgroundColor: color, + borderRadius: 4, + cursor: 'pointer', + border: colorValue === color ? `2px solid ${SELECTION_BORDER_COLOR}` : '1px solid #d9d9d9', + transition: 'transform 0.15s', + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'scale(1.1)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + }} + /> + ))} +
+