Browse Source

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 <noreply@anthropic.com>
feat/alipay-payment
fengsilin 1 month ago
parent
commit
5c57f692a5
6 changed files with 278 additions and 46 deletions
  1. +2
    -2
      model/pricing.go
  2. +1
    -7
      web/src/components/settings/RatioSetting.jsx
  3. +1
    -0
      web/src/i18n/locales/en.json
  4. +1
    -0
      web/src/i18n/locales/zh-CN.json
  5. +184
    -32
      web/src/pages/Setting/Ratio/ChannelPricingView.jsx
  6. +89
    -5
      web/src/pages/Setting/Ratio/PricingTagManager.jsx

+ 2
- 2
model/pricing.go View File

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


+ 1
- 7
web/src/components/settings/RatioSetting.jsx View File

@@ -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 = () => {
<UpstreamRatioSync options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane
tab={
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<IconPriceTag size="small" />
{t('标签管理')}
</span>
}
tab={t('标签管理')}
itemKey='pricing_tags'
>
<PricingTagManager />


+ 1
- 0
web/src/i18n/locales/en.json View File

@@ -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",


+ 1
- 0
web/src/i18n/locales/zh-CN.json View File

@@ -2601,6 +2601,7 @@
"预览失败": "预览失败",
"预览更新": "预览更新",
"预览请求体": "预览请求体",
"预设颜色": "预设颜色",
"预计结束": "预计结束",
"预警阈值必须为正数": "预警阈值必须为正数",
"频率惩罚,减少重复词汇的出现": "频率惩罚,减少重复词汇的出现",


+ 184
- 32
web/src/pages/Setting/Ratio/ChannelPricingView.jsx View File

@@ -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 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
@@ -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 key={tag.id} color={tag.color} size="small" style={{ marginRight: 4 }}>
<Tag key={tag.id} size="small" style={getTagStyle(tag.color, { marginRight: 4 })}>
{tag.name}
</Tag>
));
@@ -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 }) => {
<Modal
title={t('编辑渠道定价')}
visible={editModalVisible}
onCancel={() => setEditModalVisible(false)}
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,
@@ -415,22 +516,69 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
? t('按量计费:根据 token 数量计费,使用模型倍率和补全倍率计算')
: t('按次计费:每次请求使用固定价格计费')}
</Typography.Text>
{currentQuotaType === 0 ? (
{currentQuotaType === 0 && (
<>
<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.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>
</>
) : (
)}
{currentQuotaType === 1 && (
<Form.InputNumber
field="model_price"
label={t('固定价格')}
@@ -438,20 +586,24 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => {
step={0.01}
/>
)}
<Form.Select
<Form.TreeSelect
field="tag_ids"
label={t('标签')}
multiple
filter
filterTreeNode
style={{ width: '100%' }}
placeholder={t('请选择标签')}
>
{safeTags.map((tag) => (
<Select.Option key={tag.id} value={String(tag.id)}>
<Tag color={tag.color}>{tag.name}</Tag>
</Select.Option>
))}
</Form.Select>
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>


+ 89
- 5
web/src/pages/Setting/Ratio/PricingTagManager.jsx View File

@@ -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('请输入标签名称') }]}
/>
<Form.Input
field="color"
label={t('颜色')}
placeholder="#1890ff"
/>
<Form.Section label={t('颜色')}>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<ColorPicker
usePopover={true}
presetColors={PRESET_COLORS}
onChange={(colorObj) => {
const hex = colorObj?.toHexString?.() || colorObj?.hex || DEFAULT_COLOR;
setColorValue(hex);
if (formRef.current) {
formRef.current.setValue('color', hex);
}
}}
/>
<Input
value={colorValue}
onChange={(val) => {
setColorValue(val);
if (formRef.current) {
formRef.current.setValue('color', val);
}
}}
placeholder={DEFAULT_COLOR}
style={{ width: 120 }}
/>
</div>
<Typography.Text type="tertiary" size="small" style={{ display: 'block', marginBottom: 8 }}>
{t('预设颜色')}
</Typography.Text>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(8, 28px)',
gap: 4,
}}
>
{PRESET_COLORS.map((color) => (
<div
key={color}
onClick={() => {
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)';
}}
/>
))}
</div>
</Form.Section>
<Form.TextArea
field="description"
label={t('描述')}


Loading…
Cancel
Save