From 00c1ff05deb0702975a4731ad736cfbfe93bdcd5 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Sun, 25 May 2025 17:27:45 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20refactor(ModelPricing):=20enhanc?= =?UTF-8?q?e=20UI/UX=20with=20modern=20design=20ModelPricing=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements a comprehensive UI/UX overhaul of the ModelPricing component, focusing on improved aesthetics and responsiveness while maintaining existing API logic. Key improvements: - Redesigned status card with gradient background and floating elements - Implemented responsive grid layout for pricing metrics - Enhanced visual hierarchy with Semi UI components - Added smooth transitions and hover effects - Optimized spacing and typography for better readability - Unified design language with PersonalSettings component - Integrated Tailwind CSS 3.0 utility classes - Added decorative elements for visual interest - Improved mobile responsiveness across all breakpoints - Enhanced accessibility with proper contrast ratios The redesign follows modern UI/UX best practices while maintaining consistency with the application's design system. --- web/src/components/ModelPricing.js | 492 ++++++++++++++++++--------- web/src/constants/index.js | 7 +- web/src/constants/model.constants.js | 145 ++++++++ web/src/i18n/locales/en.json | 13 +- web/src/index.css | 4 + 5 files changed, 499 insertions(+), 162 deletions(-) create mode 100644 web/src/constants/model.constants.js diff --git a/web/src/components/ModelPricing.js b/web/src/components/ModelPricing.js index 16eb08f1..ada7ba48 100644 --- a/web/src/components/ModelPricing.js +++ b/web/src/components/ModelPricing.js @@ -3,7 +3,6 @@ import { API, copy, showError, showInfo, showSuccess } from '../helpers'; import { useTranslation } from 'react-i18next'; import { - Banner, Input, Layout, Modal, @@ -14,15 +13,21 @@ import { Popover, ImagePreview, Button, + Card, + Tabs, + TabPane, + Dropdown, } from '@douyinfe/semi-ui'; import { - IconMore, IconVerify, - IconUploadError, IconHelpCircle, + IconSearch, + IconCopy, + IconInfoCircle, } from '@douyinfe/semi-icons'; import { UserContext } from '../context/User/index.js'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import { Settings, AlertCircle } from 'lucide-react'; +import { MODEL_CATEGORIES } from '../constants'; const ModelPricing = () => { const { t } = useTranslation(); @@ -32,6 +37,8 @@ const ModelPricing = () => { const [modalImageUrl, setModalImageUrl] = useState(''); const [isModalOpenurl, setIsModalOpenurl] = useState(false); const [selectedGroup, setSelectedGroup] = useState('default'); + const [activeKey, setActiveKey] = useState('all'); + const [pageSize, setPageSize] = useState(10); const rowSelection = useMemo( () => ({ @@ -49,6 +56,7 @@ const ModelPricing = () => { const newFilteredValue = value ? [value] : []; setFilteredValue(newFilteredValue); }; + const handleCompositionStart = () => { compositionRef.current.isComposition = true; }; @@ -61,17 +69,16 @@ const ModelPricing = () => { }; function renderQuotaType(type) { - // Ensure all cases are string literals by adding quotes. switch (type) { case 1: return ( - + {t('按次计费')} ); case 0: return ( - + {t('按量计费')} ); @@ -88,15 +95,9 @@ const ModelPricing = () => { } position='top' key={available} - style={{ - backgroundColor: 'rgba(var(--semi-blue-4),1)', - borderColor: 'rgba(var(--semi-blue-4),1)', - color: 'var(--semi-color-white)', - borderWidth: 1, - borderStyle: 'solid', - }} + className="bg-green-50" > - + ) : null; } @@ -106,7 +107,6 @@ const ModelPricing = () => { title: t('可用性'), dataIndex: 'available', render: (text, record, index) => { - // if record.enable_groups contains selectedGroup, then available is true return renderAvailable(record.enable_groups.includes(selectedGroup)); }, sorter: (a, b) => { @@ -115,28 +115,29 @@ const ModelPricing = () => { return Number(aAvailable) - Number(bAvailable); }, defaultSortOrder: 'descend', + width: 100, }, { title: t('模型名称'), dataIndex: 'model_name', render: (text, record, index) => { return ( - <> - { - copyText(text); - }} - > - {text} - - + { + copyText(text); + }} + > + {text} + ); }, onFilter: (value, record) => record.model_name.toLowerCase().includes(value.toLowerCase()), filteredValue, + width: 200, }, { title: t('计费类型'), @@ -145,19 +146,19 @@ const ModelPricing = () => { return renderQuotaType(parseInt(text)); }, sorter: (a, b) => a.quota_type - b.quota_type, + width: 120, }, { title: t('可用分组'), dataIndex: 'enable_groups', render: (text, record, index) => { - // enable_groups is a string array return ( - + {text.map((group) => { if (usableGroup[group]) { if (group === selectedGroup) { return ( - }> + }> {group} ); @@ -175,6 +176,7 @@ const ModelPricing = () => { }), ); }} + className="cursor-pointer hover:opacity-80 transition-opacity !rounded-full" > {group} @@ -188,56 +190,40 @@ const ModelPricing = () => { }, { title: () => ( - - {t('倍率')} - - {t('倍率是为了方便换算不同价格的模型')} -
- {t('点击查看倍率说明')} - - } - position='top' - style={{ - backgroundColor: 'rgba(var(--semi-blue-4),1)', - borderColor: 'rgba(var(--semi-blue-4),1)', - color: 'var(--semi-color-white)', - borderWidth: 1, - borderStyle: 'solid', - }} - > +
+ {t('倍率')} + { setModalImageUrl('/ratio.png'); setIsModalOpenurl(true); }} /> - - + +
), dataIndex: 'model_ratio', render: (text, record, index) => { let content = text; let completionRatio = parseFloat(record.completion_ratio.toFixed(3)); content = ( - <> - +
+
{t('模型倍率')}:{record.quota_type === 0 ? text : t('无')} - -
- +
+
{t('补全倍率')}: {record.quota_type === 0 ? completionRatio : t('无')} - -
- +
+
{t('分组倍率')}:{groupRatio[selectedGroup]} - - +
+
); - return
{content}
; + return content; }, + width: 200, }, { title: t('模型价格'), @@ -245,7 +231,6 @@ const ModelPricing = () => { render: (text, record, index) => { let content = text; if (record.quota_type === 0) { - // 这里的 *2 是因为 1倍率=0.002刀,请勿删除 let inputRatioPrice = record.model_ratio * 2 * groupRatio[selectedGroup]; let completionRatioPrice = @@ -254,26 +239,26 @@ const ModelPricing = () => { 2 * groupRatio[selectedGroup]; content = ( - <> - - {t('提示')} ${inputRatioPrice} / 1M tokens - -
- - {t('补全')} ${completionRatioPrice} / 1M tokens - - +
+
+ {t('提示')} ${inputRatioPrice.toFixed(3)} / 1M tokens +
+
+ {t('补全')} ${completionRatioPrice.toFixed(3)} / 1M tokens +
+
); } else { let price = parseFloat(text) * groupRatio[selectedGroup]; content = ( - <> - ${t('模型价格')}:${price} - +
+ ${t('模型价格')}:${price.toFixed(3)} +
); } - return
{content}
; + return content; }, + width: 250, }, ]; @@ -288,12 +273,10 @@ const ModelPricing = () => { models[i].key = models[i].model_name; models[i].group_ratio = groupRatio[models[i].model_name]; } - // sort by quota_type models.sort((a, b) => { return a.quota_type - b.quota_type; }); - // sort by model_name, start with gpt is max, other use localeCompare models.sort((a, b) => { if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) { return -1; @@ -312,9 +295,7 @@ const ModelPricing = () => { const loadPricing = async () => { setLoading(true); - - let url = ''; - url = `/api/pricing`; + let url = '/api/pricing'; const res = await API.get(url); const { success, message, data, group_ratio, usable_group } = res.data; if (success) { @@ -334,10 +315,9 @@ const ModelPricing = () => { const copyText = async (text) => { if (await copy(text)) { - showSuccess('已复制:' + text); + showSuccess(t('已复制:') + text); } else { - // setSearchKeyword(text); - Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text }); + Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); } }; @@ -345,88 +325,284 @@ const ModelPricing = () => { refresh().then(); }, []); - return ( - <> - - {userState.user ? ( - - ) : ( - - )} -
- - {t( - '按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)', - )} - - } - closeIcon='null' - /> -
- + const modelCategories = MODEL_CATEGORIES(t); + + const renderArrow = (items, pos, handleArrowClick) => { + const style = { + width: 32, + height: 32, + margin: '0 12px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '100%', + background: 'rgba(var(--semi-grey-1), 1)', + color: 'var(--semi-color-text)', + cursor: 'pointer', + }; + return ( + + {items.map(item => ( + setActiveKey(item.itemKey)} + icon={modelCategories[item.itemKey]?.icon} + > + {modelCategories[item.itemKey]?.label || item.itemKey} + + ))} + + } + > +
+ {pos === 'start' ? '←' : '→'} +
+
+ ); + }; + + // 检查分类是否有对应的模型 + const availableCategories = useMemo(() => { + if (!models.length) return ['all']; + + return Object.entries(modelCategories).filter(([key, category]) => { + if (key === 'all') return true; + return models.some(model => category.filter(model)); + }).map(([key]) => key); + }, [models]); + + // 渲染标签页 + const renderTabs = () => { + return ( + setActiveKey(key)} + > + {Object.entries(modelCategories) + .filter(([key]) => availableCategories.includes(key)) + .map(([key, category]) => ( + + {category.icon && {category.icon}} + {category.label} +
+ } + itemKey={key} + key={key} + /> + ))} + + ); + }; + + // 优化过滤逻辑 + const filteredModels = useMemo(() => { + let result = models; + + // 先按分类过滤 + if (activeKey !== 'all') { + result = result.filter(model => modelCategories[activeKey].filter(model)); + } + + // 再按搜索词过滤 + if (filteredValue.length > 0) { + const searchTerm = filteredValue[0].toLowerCase(); + result = result.filter(model => + model.model_name.toLowerCase().includes(searchTerm) + ); + } + + return result; + }, [activeKey, models, filteredValue]); + + // 搜索和操作区组件 + const SearchAndActions = useMemo(() => ( + +
+
} placeholder={t('模糊搜索模型名称')} - style={{ width: 200 }} + className="!rounded-lg" onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onChange={handleChange} showClear + size="large" /> - - - - t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: models.length, - }), - pageSize: models.length, - showSizeChanger: false, - }} - rowSelection={rowSelection} - /> - setIsModalOpenurl(visible)} - /> + + + + + ), [selectedRowKeys, t]); + + // 表格组件 + const ModelTable = useMemo(() => ( + +
+ t('第 {{start}} - {{end}} 条,共 {{total}} 条', { + start: page.currentStart, + end: page.currentEnd, + total: filteredModels.length, + }), + onPageSizeChange: (size) => setPageSize(size), + }} + /> + + ), [filteredModels, loading, columns, rowSelection, pageSize, t]); + + return ( +
+ + +
+
+ {/* 主卡片容器 */} + + {/* 顶部状态卡片 */} + + {/* 装饰性背景元素 */} +
+
+
+
+
+ +
+
+
+
+ +
+
+
+ {t('模型定价')} +
+
+ {userState.user ? ( +
+ + + {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]} + +
+ ) : ( +
+ + + {t('未登录,使用默认分组倍率')}: {groupRatio['default']} + +
+ )} +
+
+
+ +
+
+
{t('分组倍率')}
+
{groupRatio[selectedGroup] || '1.0'}x
+
+
+
{t('可用模型')}
+
+ {models.filter(m => m.enable_groups.includes(selectedGroup)).length} +
+
+
+
{t('计费类型')}
+
2
+
+
+
+ + {/* 计费说明 */} +
+
+
+ + + {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')} + +
+
+
+ +
+
+
+ + {/* 模型分类 Tabs */} +
+ {renderTabs()} + + {/* 搜索和表格区域 */} + {SearchAndActions} + {ModelTable} +
+ + {/* 倍率说明图预览 */} + setIsModalOpenurl(visible)} + /> +
+
+
+
- +
); }; diff --git a/web/src/constants/index.js b/web/src/constants/index.js index 1321207a..3f8c0232 100644 --- a/web/src/constants/index.js +++ b/web/src/constants/index.js @@ -1,4 +1,5 @@ -export * from './toast.constants'; -export * from './user.constants'; -export * from './common.constant'; export * from './channel.constants'; +export * from './user.constants'; +export * from './toast.constants'; +export * from './common.constant'; +export * from './model.constants'; diff --git a/web/src/constants/model.constants.js b/web/src/constants/model.constants.js new file mode 100644 index 00000000..f9002cea --- /dev/null +++ b/web/src/constants/model.constants.js @@ -0,0 +1,145 @@ +import { + OpenAI, + Claude, + Gemini, + Moonshot, + Zhipu, + Qwen, + DeepSeek, + Minimax, + Wenxin, + Spark, + Midjourney, + Hunyuan, + Cohere, + Cloudflare, + Ai360, + Yi, + Jina, + Mistral, + XAI, + Ollama, + Doubao, +} from '@lobehub/icons'; + +export const MODEL_CATEGORIES = (t) => ({ + all: { + label: t('全部模型'), + icon: null, + filter: () => true + }, + openai: { + label: 'OpenAI', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('gpt') || + model.model_name.toLowerCase().includes('dall-e') || + model.model_name.toLowerCase().includes('whisper') || + model.model_name.toLowerCase().includes('tts') || + model.model_name.toLowerCase().includes('text-') || + model.model_name.toLowerCase().includes('babbage') || + model.model_name.toLowerCase().includes('davinci') || + model.model_name.toLowerCase().includes('curie') || + model.model_name.toLowerCase().includes('ada') + }, + anthropic: { + label: 'Anthropic', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('claude') + }, + gemini: { + label: 'Gemini', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('gemini') + }, + moonshot: { + label: 'Moonshot', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('moonshot') + }, + zhipu: { + label: t('智谱'), + icon: , + filter: (model) => model.model_name.toLowerCase().includes('chatglm') || + model.model_name.toLowerCase().includes('glm-') + }, + qwen: { + label: t('通义千问'), + icon: , + filter: (model) => model.model_name.toLowerCase().includes('qwen') + }, + deepseek: { + label: 'DeepSeek', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('deepseek') + }, + minimax: { + label: 'MiniMax', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('abab') + }, + baidu: { + label: t('文心一言'), + icon: , + filter: (model) => model.model_name.toLowerCase().includes('ernie') + }, + xunfei: { + label: t('讯飞星火'), + icon: , + filter: (model) => model.model_name.toLowerCase().includes('spark') + }, + midjourney: { + label: 'Midjourney', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('mj_') + }, + tencent: { + label: t('腾讯混元'), + icon: , + filter: (model) => model.model_name.toLowerCase().includes('hunyuan') + }, + cohere: { + label: 'Cohere', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('command') + }, + cloudflare: { + label: 'Cloudflare', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('@cf/') + }, + ai360: { + label: t('360智脑'), + icon: , + filter: (model) => model.model_name.toLowerCase().includes('360') + }, + yi: { + label: t('零一万物'), + icon: , + filter: (model) => model.model_name.toLowerCase().includes('yi') + }, + jina: { + label: 'Jina', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('jina') + }, + mistral: { + label: 'Mistral AI', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('mistral') + }, + xai: { + label: 'xAI', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('grok') + }, + llama: { + label: 'Llama', + icon: , + filter: (model) => model.model_name.toLowerCase().includes('llama') + }, + doubao: { + label: t('豆包'), + icon: , + filter: (model) => model.model_name.toLowerCase().includes('doubao') + } +}); \ No newline at end of file diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 178e3229..1f984333 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1515,5 +1515,16 @@ "用户分组配置": "User group configuration", "请选择可以使用该渠道的分组,留空则不更改": "Please select the groups that can use this channel, leaving blank will not change", "启用全部": "Enable all", - "禁用全部": "Disable all" + "禁用全部": "Disable all", + "模型定价": "Model Pricing", + "当前分组": "Current group", + "全部模型": "All Models", + "智谱": "Zhipu AI", + "通义千问": "Qwen", + "文心一言": "ERNIE Bot", + "讯飞星火": "Spark Desk", + "腾讯混元": "Hunyuan", + "360智脑": "360 AI Brain", + "零一万物": "Yi", + "豆包": "Doubao" } \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css index 64906180..58a45de5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -276,3 +276,7 @@ code { .semi-datepicker-range-input { border-radius: 9999px; } + +.semi-tabs-content { + padding: 0 !important; +} \ No newline at end of file