Compare commits

...

20 Commits

Author SHA1 Message Date
xiezhouwei 59cc502e6e fix: homepage '开始使用' button now links to API Key (/console/token) instead of register 2026-06-21 19:00:13 +08:00
xiezhouwei f2da0cdb24 fix: vendor icons missing .Color suffix (8 entries) + add Ollama entry 2026-06-21 18:50:49 +08:00
xiezhouwei 86bb4ee0cf rename: 体验馆 → 体验 (menu, settings, i18n across 11 locales) 2026-06-21 18:26:24 +08:00
xiezhouwei eb045c9a46 fix: add Chinese vendor name keywords for doubao/seedance in model_meta_infer vendorKeywordAliases 2026-06-21 17:26:30 +08:00
xiezhouwei 4a5fa8aa3b debug: add diagnostic logging to getOrCreateVendor and initDefaultVendorMapping for seedance/minimax 2026-06-21 17:26:14 +08:00
xiezhouwei 20de5cb147 fix: add minimax keyword to defaultVendorRules so MiniMax-M1/M3/T1 series get vendor assignment 2026-06-21 16:59:20 +08:00
xiezhouwei 07a6e29c1e fix: allow vendor_id=0 to be passed as model_type in experience button jump 2026-06-21 16:47:42 +08:00
xiezhouwei 4f8cde37d8 refactor: replace api.tokendance.space with api.turingtoken.ai on homepage 2026-06-21 16:40:30 +08:00
xiezhouwei 6eec903060 refactor: remove about link and GitHub display from homepage DarkFooter 2026-06-21 16:37:23 +08:00
xiezhouwei 0d798c2fbc fix: convert model_type URL param to number so Select matches optionList value type 2026-06-19 10:37:41 +08:00
xiezhouwei 2165430d87 feat: pass model_type (vendor_id) from model detail to playground settings panel 2026-06-19 10:30:32 +08:00
xiezhouwei 977e9e8bf5 feat: add experience button in model detail side sheet to jump to playground with model preset 2026-06-19 10:17:00 +08:00
xiezhouwei 189f314734 fix: add DeepSeek-V3.2 default model ratio so it appears in pricing list 2026-06-18 21:13:15 +08:00
xiezhouwei 4e8d2b8057 fix: strip spaces in FormatMatchingModelName to match model name variants like Seedance 2.0 2026-06-18 20:48:07 +08:00
xiezhouwei 06aa707141 fix: add seedance vendor rule and improve vendor_id auto-mapping for existing records 2026-06-18 18:07:47 +08:00
xiezhouwei 46819578de fix: model page tab filter not working and sidebar tag order 2026-06-18 17:01:50 +08:00
xiezhouwei 77b885b190 refactor: streamline model card to show only logo, name, description, and IO prices 2026-06-18 16:50:02 +08:00
xiezhouwei 4fbdafee1c fix: remove supplier filter from model page sidebar 2026-06-18 16:36:52 +08:00
xiezhouwei 825954e43f fix: 模型页面右侧列表被固定导航栏遮挡\n\n根因:PageLayout.jsx 第 325 行 <Header> 为 position: fixed; top: 0\n(h-14, 56px),在 /pricing 路径下也显示。但 models-page-layout\n使用 height: calc(100vh - 60px) 而非 padding-top 偏移,导致\n布局从视口顶部(y=0)开始,内容被固定头部遮盖。\n\n修改:\n- models-page-layout: padding: 56px 24px 0 + height: 100vh +\n box-sizing: border-box,使内容从固定头部下方开始\n- models-sidebar: 移除顶部 padding(padding: 0 16px 24px 0),\n 避免与外层 padding-top 叠加 2026-06-17 16:28:04 +08:00
xiezhouwei 61b0d902b6 refactor: 模型页面布局调整为左右结构(类 tokendance.models)
新增:
- ModelsSidebar.jsx 左侧筛选面板(作者/服务商/输入模态/供应商)
- ModelsContent.jsx 右侧内容区(全部/文本/图像/音频/视频 Tab + 搜索)
- index.css models-* 样式类

修改:
- PricingPage.jsx 布局改为 flex 左右结构替代 Semi UI Layout
- PricingCardView.jsx 新增 gridCols 参数支持 2 列网格布局
  当 gridCols=2 时:grid gap-4 + 卡片 !w-full

布局: 左 280px 筛选栏 + 右自适应内容区
Tab: 全部/文本/图像/音频/视频 (映射到 filterEndpointType)
卡片: 每行 2 个模型卡片
2026-06-17 14:33:53 +08:00
34 changed files with 575 additions and 495 deletions

View File

@ -1,7 +1,10 @@
package model
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
)
// 简化的供应商映射规则
@ -20,6 +23,7 @@ var defaultVendorRules = map[string]string{
"qwen": "阿里巴巴",
"deepseek": "DeepSeek",
"abab": "MiniMax",
"minimax": "MiniMax",
"ernie": "百度",
"spark": "讯飞",
"hunyuan": "腾讯",
@ -32,6 +36,7 @@ var defaultVendorRules = map[string]string{
"grok": "xAI",
"llama": "Meta",
"doubao": "字节跳动",
"seedance": "字节跳动",
"kling": "快手",
"jimeng": "即梦",
"vidu": "Vidu",
@ -39,10 +44,10 @@ var defaultVendorRules = map[string]string{
// 供应商默认图标映射
var defaultVendorIcons = map[string]string{
"OpenAI": "OpenAI",
"OpenAI": "OpenAI.Color",
"Anthropic": "Claude.Color",
"Google": "Gemini.Color",
"Moonshot": "Moonshot",
"Moonshot": "Moonshot.Color",
"智谱": "Zhipu.Color",
"阿里巴巴": "Qwen.Color",
"DeepSeek": "DeepSeek.Color",
@ -54,26 +59,31 @@ var defaultVendorIcons = map[string]string{
"Cloudflare": "Cloudflare.Color",
"360": "Ai360.Color",
"零一万物": "Yi.Color",
"Jina": "Jina",
"Jina": "Jina.Color",
"Mistral": "Mistral.Color",
"xAI": "XAI",
"Meta": "Ollama",
"xAI": "XAI.Color",
"Ollama": "Ollama.Color",
"Meta": "Meta.Color",
"字节跳动": "Doubao.Color",
"快手": "Kling.Color",
"即梦": "Jimeng.Color",
"Vidu": "Vidu",
"微软": "AzureAI",
"Microsoft": "AzureAI",
"Azure": "AzureAI",
"Vidu": "Vidu.Color",
"微软": "AzureAI.Color",
"Microsoft": "AzureAI.Color",
"Azure": "AzureAI.Color",
}
// initDefaultVendorMapping 简化的默认供应商映射
func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vendor, enableAbilities []AbilityWithChannel) {
for _, ability := range enableAbilities {
modelName := ability.Model
if _, exists := metaMap[modelName]; exists {
if existing, exists := metaMap[modelName]; exists {
// 已有记录但 vendor_id 已赋值 → 跳过
if existing.VendorID != 0 {
continue
}
// vendor_id=0 → 继续尝试匹配(修复已有 DB 记录但缺供应商的场景)
}
// 匹配供应商
vendorID := 0
@ -85,7 +95,18 @@ func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vend
}
}
// 创建模型元数据
if vendorID == 0 {
// 仍未匹配到供应商 → 跳过(不给未匹配的模型写入 vendor_id=0 覆盖已有记录)
if strings.Contains(modelLower, "seedance") || strings.Contains(modelLower, "minimax") {
common.SysLog(fmt.Sprintf("initDefaultVendorMapping: model %q matched pattern but vendorID=0 (getOrCreateVendor failed)", modelName))
}
continue
}
// 写入或更新供应商 ID
if existing, exists := metaMap[modelName]; exists {
existing.VendorID = vendorID
} else {
metaMap[modelName] = &Model{
ModelName: modelName,
VendorID: vendorID,
@ -94,6 +115,7 @@ func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vend
}
}
}
}
// 查找或创建供应商
func getOrCreateVendor(vendorName string, vendorMap map[int]*Vendor) int {
@ -112,9 +134,11 @@ func getOrCreateVendor(vendorName string, vendorMap map[int]*Vendor) int {
}
if err := newVendor.Insert(); err != nil {
common.SysLog(fmt.Sprintf("getOrCreateVendor: insert vendor %q failed: %v", vendorName, err))
return 0
}
common.SysLog(fmt.Sprintf("getOrCreateVendor: created new vendor %q with id=%d", vendorName, newVendor.Id))
vendorMap[newVendor.Id] = newVendor
return newVendor.Id
}

View File

@ -323,7 +323,8 @@ var vendorKeywordAliases = []struct {
{"qwen", []string{"alibaba", "qwen", "tongyi", "aliyun"}},
{"moonshot", []string{"moonshot"}},
{"kimi", []string{"moonshot"}},
{"doubao", []string{"bytedance", "volcengine", "volcano"}},
{"doubao", []string{"bytedance", "volcengine", "volcano", "字节跳动", "字节"}},
{"seedance", []string{"bytedance", "volcengine", "volcano", "doubao", "字节跳动", "字节"}},
{"ernie", []string{"baidu"}},
{"wenxin", []string{"baidu"}},
{"hunyuan", []string{"tencent"}},

View File

@ -272,6 +272,7 @@ var defaultModelRatio = map[string]float64{
"deepseek-ai/DeepSeek-R1": 0.8,
"deepseek-ai/DeepSeek-V3-0324": 0.8,
"deepseek-ai/DeepSeek-V3.1": 0.8,
"DeepSeek-V3.2": 0.8,
}
var defaultModelPrice = map[string]float64{
@ -870,6 +871,8 @@ func GetCompletionRatioCopy() map[string]float64 {
// 转换模型名,减少渠道必须配置各种带参数模型
func FormatMatchingModelName(name string) string {
// 去除空格,使 "Seedance 2.0" 与 "Seedance2.0" 等变体能匹配
name = strings.ReplaceAll(name, " ", "")
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
name = handleThinkingBudgetModel(name, "gemini-2.5-flash-lite", "gemini-2.5-flash-lite-thinking-*")

View File

@ -378,7 +378,6 @@ const HomeModelList = () => {
<PricingCardView
filteredModels={pricingData.filteredModels}
loading={pricingData.loading}
rowSelection={null}
pageSize={pricingData.pageSize}
setPageSize={pricingData.setPageSize}
currentPage={pricingData.currentPage}
@ -387,9 +386,6 @@ const HomeModelList = () => {
groupRatio={pricingData.groupRatio}
groupModelPrice={pricingData.groupModelPrice}
groupModelRatio={pricingData.groupModelRatio}
copyText={pricingData.copyText}
setModalImageUrl={pricingData.setModalImageUrl}
setIsModalOpenurl={pricingData.setIsModalOpenurl}
currency={pricingData.currency}
siteDisplayType={pricingData.siteDisplayType}
tokenUnit={pricingData.tokenUnit}
@ -399,10 +395,7 @@ const HomeModelList = () => {
pricingData.channelVideoCompletionRatio
}
channelVideoPrice={pricingData.channelVideoPrice}
showRatio={false}
t={pricingData.t}
selectedRowKeys={[]}
setSelectedRowKeys={() => {}}
openModelDetail={pricingData.openModelDetail}
showSizeChanger={false}
blurPricing={blurPricing}

View File

@ -309,7 +309,7 @@ const SiderBar = ({ onNavigate = () => {} }) => {
const chatMenuItems = useMemo(() => {
const items = [
{
text: t('体验'),
text: t('体验'),
itemKey: 'playground',
to: '/playground',
},

View File

@ -23,7 +23,7 @@ import React, { useEffect, useRef, useState } from 'react';
const ROOT_MARGIN = '280px 0px 280px 0px';
/**
* 体验消息气泡进入或接近可视区域后再挂载子树减轻长对话下 DOM/媒体压力
* 体验消息气泡进入或接近可视区域后再挂载子树减轻长对话下 DOM/媒体压力
* variant=media更高占位避免图片/视频模式切换后列表高度不足导致滚不到底部
*/
const LazyVisibleMessage = ({

View File

@ -38,7 +38,7 @@ import {
import ParameterControl from './ParameterControl';
/**
* 体验分组下拉与全局 renderGroupOption 一致但不展示倍率角标
* 体验分组下拉与全局 renderGroupOption 一致但不展示倍率角标
* @param {Record<string, unknown>} item Semi Select 传入的选项渲染参数
*/
const renderPlaygroundGroupOption = (item) =>
@ -85,7 +85,7 @@ const SettingsPanel = ({
const mediaModeEnabled = isImageMode || isVideoMode;
const videoMediaHint = isVideoMode
? t(
'体验视频素材提示',
'体验视频素材提示',
'图片地址:第 1 张为首帧2 张为首尾帧,更多张时最后一张为尾帧。视频地址:填写则作为源视频参与生成。未填写的字段不会加入请求。',
)
: '';

View File

@ -83,11 +83,11 @@ const VideoUrlInput = ({
{!enabled ? (
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
{t('体验视频地址停用提示', '启用后可添加视频 URL视频生视频、视频编辑等')}
{t('体验视频地址停用提示', '启用后可添加视频 URL视频生视频、视频编辑等')}
</Typography.Text>
) : list.length === 0 ? (
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
{t('体验视频地址空列表提示', '点击 + 添加 .mp4 / .mov 等可访问的视频链接')}
{t('体验视频地址空列表提示', '点击 + 添加 .mp4 / .mov 等可访问的视频链接')}
</Typography.Text>
) : (
<Typography.Text className='text-xs text-gray-500 mb-2 block'>

View File

@ -212,11 +212,11 @@ const NotificationSettings = ({
{
key: 'chat',
title: t('聊天区域'),
description: t('体验和聊天功能'),
description: t('体验和聊天功能'),
modules: [
{
key: 'playground',
title: t('体验'),
title: t('体验'),
description: t('AI模型测试环境'),
},
{ key: 'chat', title: t('聊天'), description: t('聊天会话管理') },

View File

@ -0,0 +1,88 @@
/*
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.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Search } from 'lucide-react';
import PricingCardView from '../view/card/PricingCardView';
// Tab tag
const TAB_TO_TAG = {
all: 'all',
text: '文本',
image: '图片',
audio: '音频',
video: '视频',
};
const TYPE_TABS = [
{ key: 'all', labelKey: '全部' },
{ key: 'text', labelKey: '文本' },
{ key: 'image', labelKey: '图片' },
{ key: 'audio', labelKey: '音频' },
{ key: 'video', labelKey: '视频' },
];
const ModelsContent = ({
filteredModels,
filterTag, setFilterTag,
searchValue, setSearchValue,
loading,
isMobile,
blurPricing,
t,
...cardProps
}) => {
// filterTag tab key
const activeTabKey = filterTag === 'all' ? 'all'
: (Object.entries(TAB_TO_TAG).find(([, v]) => v === filterTag)?.[0] || 'all');
return (
<>
{/* Type Tabs */}
<div className='models-tabs-bar'>
{TYPE_TABS.map((tab) => (
<button
key={tab.key}
className={`models-tab-btn${activeTabKey === tab.key ? ' active' : ''}`}
onClick={() => setFilterTag(TAB_TO_TAG[tab.key])}
>
{t(tab.labelKey)}
</button>
))}
<div className='flex-1' />
<div className='relative'>
<Search size={15} className='absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/60' />
<input
type='text'
placeholder={t('搜索模型名称...')}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className='w-48 bg-muted/30 border border-input rounded-lg pl-9 pr-3 py-1.5 text-sm text-foreground placeholder-muted-foreground/50 focus:outline-none focus:border-ring focus:bg-muted/50 transition-all'
/>
</div>
</div>
{/* Model Cards Grid */}
<div className='flex-1 overflow-y-auto'>
<PricingCardView
filteredModels={filteredModels}
loading={loading}
blurPricing={blurPricing}
t={t}
gridCols={2}
{...cardProps}
/>
</div>
</>
);
};
export default ModelsContent;

View File

@ -0,0 +1,151 @@
/*
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.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
import { getLobeHubIcon } from '../../../../helpers';
const ModelsSidebar = ({
models,
filterVendor, setFilterVendor,
filterSupplierType, setFilterSupplierType,
filterTag, setFilterTag,
filterGroup, filterQuotaType, filterEndpointType, searchValue,
loading, t,
}) => {
const { vendorModels, tagModels, supplierTypeModels } = usePricingFilterCounts({
models,
filterGroup,
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
filterSupplierType,
searchValue,
});
// (Vendors)
const vendors = React.useMemo(() => {
const names = [...new Set(models.map((m) => m.vendor_name).filter(Boolean))].sort();
return names.map((name) => {
const icon = models.find((m) => m.vendor_name === name)?.vendor_icon;
return {
name,
icon,
count: vendorModels.filter((m) => m.vendor_name === name).length,
};
});
}, [models, vendorModels]);
// (Supplier Types)
const supplierTypes = React.useMemo(() => {
const types = new Set();
models.forEach((m) => {
(m.channel_list || []).forEach((ch) => {
if (ch.supplier_type) types.add(ch.supplier_type);
});
});
return [...types].sort();
}, [models]);
// (Tags)
const TAG_ORDER = ['文本', '图片', '音频', '视频'];
const tags = React.useMemo(() => {
const allTags = new Set();
models.forEach((m) => {
if (m.tags) {
String(m.tags).split(/[,;|]/).map(s => s.trim()).filter(Boolean).forEach(t => allTags.add(t));
}
});
// 使
const ordered = TAG_ORDER.filter(t => allTags.has(t));
const rest = [...allTags].filter(t => !TAG_ORDER.includes(t));
return [...ordered, ...rest];
}, [models]);
const FilterItem = ({ label, count, active, onClick }) => (
<div
className={`models-filter-item${active ? ' active' : ''}`}
onClick={onClick}
>
<span>{label}</span>
<span className='count'>{count}</span>
</div>
);
return (
<div>
{/* 作者 */}
<div className='models-filter-section'>
<div className='models-filter-title'>{t('作者')}</div>
<FilterItem label={t('全部')} count={models.length} active={filterVendor === 'all'} onClick={() => setFilterVendor('all')} />
{vendors.map((v) => (
<FilterItem
key={v.name}
label={
<span className='flex items-center gap-2'>
{v.icon && (
<img
src={getLobeHubIcon(v.icon)}
alt=''
className='w-4 h-4 rounded-full'
onError={(e) => { e.target.style.display = 'none'; }}
/>
)}
<span>{v.name}</span>
</span>
}
count={v.count}
active={filterVendor === v.name}
onClick={() => setFilterVendor(filterVendor === v.name ? 'all' : v.name)}
/>
))}
</div>
{/* 服务商 */}
<div className='models-filter-section'>
<div className='models-filter-title'>{t('服务商')}</div>
<FilterItem label={t('全部')} count={models.length} active={filterSupplierType === 'all'} onClick={() => setFilterSupplierType('all')} />
{supplierTypes.map((type) => (
<FilterItem
key={type}
label={type}
count={supplierTypeModels.filter((m) =>
(m.channel_list || []).some((ch) => ch.supplier_type === type)
).length}
active={filterSupplierType === type}
onClick={() => setFilterSupplierType(filterSupplierType === type ? 'all' : type)}
/>
))}
</div>
{/* 输入模态 */}
<div className='models-filter-section'>
<div className='models-filter-title'>{t('输入模态')}</div>
<FilterItem label={t('全部')} count={models.length} active={filterTag === 'all'} onClick={() => setFilterTag('all')} />
{tags.map((tag) => (
<FilterItem
key={tag}
label={tag}
count={tagModels.filter((m) =>
m.tags && String(m.tags).split(/[,;|]/).map(s => s.trim()).includes(tag)
).length}
active={filterTag === tag}
onClick={() => setFilterTag(filterTag === tag ? 'all' : tag)}
/>
))}
</div>
</div>
);
};
export default ModelsSidebar;

View File

@ -18,9 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useContext, useMemo } from 'react';
import { Layout, ImagePreview } from '@douyinfe/semi-ui';
import PricingSidebar from './PricingSidebar';
import PricingContent from './content/PricingContent';
import { ImagePreview } from '@douyinfe/semi-ui';
import ModelsSidebar from './ModelsSidebar';
import ModelsContent from './ModelsContent';
import ModelDetailSideSheet from '../modal/ModelDetailSideSheet';
import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
@ -29,7 +29,6 @@ import { UserContext } from '../../../../context/User';
const PricingPage = () => {
const pricingData = useModelPricingData();
const { Sider, Content } = Layout;
const isMobile = useIsMobile();
const [showRatio, setShowRatio] = React.useState(false);
const [viewMode, setViewMode] = React.useState('card');
@ -60,22 +59,22 @@ const PricingPage = () => {
};
return (
<div className='bg-white'>
<Layout className='pricing-layout'>
<div className='bg-background'>
<div className='models-page-layout'>
{!isMobile && (
<Sider className='pricing-scroll-hide pricing-sidebar'>
<PricingSidebar {...allProps} />
</Sider>
<aside className='models-sidebar'>
<ModelsSidebar {...allProps} />
</aside>
)}
<Content className='pricing-scroll-hide pricing-content'>
<PricingContent
<main className='models-content'>
<ModelsContent
{...allProps}
isMobile={isMobile}
sidebarProps={allProps}
/>
</Content>
</Layout>
</main>
</div>
<ImagePreview
src={pricingData.modalImageUrl}

View File

@ -18,8 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { SideSheet, Typography, Button } from '@douyinfe/semi-ui';
import { IconClose } from '@douyinfe/semi-icons';
import { IconClose, IconPlayCircle } from '@douyinfe/semi-icons';
import { API } from '../../../../helpers';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
@ -65,6 +66,21 @@ const ModelDetailSideSheet = ({
channelVideoPriceMap = {},
}) => {
const isMobile = useIsMobile();
const navigate = useNavigate();
const handleExperience = () => {
const modelName = modelData?.model_name;
const vendorId = modelData?.vendor_id;
if (modelName) {
const params = new URLSearchParams();
params.set('model', modelName);
if (vendorId != null) {
params.set('model_type', vendorId);
}
navigate(`/console/playground?${params.toString()}`);
}
};
/**
* channel_id -> 单测/运营展示 DTO打开详情时按需拉取不并入 /pricing
*/
@ -121,7 +137,18 @@ const ModelDetailSideSheet = ({
<SideSheet
placement='right'
title={
<div className='flex items-center justify-between w-full'>
<ModelHeader modelData={modelData} vendorsMap={vendorsMap} t={t} />
<Button
icon={<IconPlayCircle />}
theme='solid'
type='primary'
size='small'
onClick={handleExperience}
>
{t('体验')}
</Button>
</div>
}
bodyStyle={{
padding: '0',

View File

@ -22,8 +22,6 @@ import { Card, Skeleton } from '@douyinfe/semi-ui';
const PricingCardSkeleton = ({
skeletonCount = 100,
rowSelection = false,
showRatio = false,
}) => {
const placeholder = (
<div className='px-2 pt-2'>
@ -34,8 +32,8 @@ const PricingCardSkeleton = ({
className='!rounded-2xl border border-gray-200'
bodyStyle={{ padding: '24px' }}
>
{/* 头部:图标 + 模型名称 + 操作按钮 */}
<div className='flex items-start justify-between mb-3'>
{/* 头部:图标 + 模型名称 */}
<div className='flex items-start mb-3'>
<div className='flex items-start space-x-3 flex-1 min-w-0'>
{/* 模型图标骨架 */}
<div className='w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm'>
@ -44,41 +42,17 @@ const PricingCardSkeleton = ({
style={{ width: 48, height: 48, borderRadius: 16 }}
/>
</div>
{/* 模型名称和价格区域 */}
{/* 模型名称 */}
<div className='flex-1 min-w-0'>
{/* 模型名称骨架 */}
<Skeleton.Title
style={{
width: `${120 + (index % 3) * 30}px`,
height: 20,
marginBottom: 8,
}}
/>
{/* 价格信息骨架 */}
<Skeleton.Title
style={{
width: `${160 + (index % 4) * 20}px`,
height: 20,
marginBottom: 0,
}}
/>
</div>
</div>
<div className='flex items-center space-x-2 ml-3'>
{/* 复制按钮骨架 */}
<Skeleton.Button
size='small'
style={{ width: 16, height: 16, borderRadius: 4 }}
/>
{/* 勾选框骨架 */}
{rowSelection && (
<Skeleton.Button
size='small'
style={{ width: 16, height: 16, borderRadius: 2 }}
/>
)}
</div>
</div>
{/* 模型描述骨架 */}
@ -90,43 +64,25 @@ const PricingCardSkeleton = ({
/>
</div>
{/* 标签区域骨架 */}
<div className='flex flex-wrap gap-2'>
{Array.from({ length: 2 + (index % 3) }).map((_, tagIndex) => (
<Skeleton.Button
key={tagIndex}
size='small'
style={{
width: 64,
height: 18,
borderRadius: 10,
}}
/>
))}
</div>
{/* 倍率信息骨架(可选) */}
{showRatio && (
<div className='mt-4 pt-3 border-t border-gray-100'>
<div className='flex items-center space-x-1 mb-2'>
{/* 价格骨架:输入 + 输出并排 */}
<div className='flex gap-3'>
<div className='flex-1 rounded-xl px-3 py-2.5' style={{backgroundColor: 'var(--semi-color-fill-0)'}}>
<Skeleton.Title
style={{ width: 60, height: 12, marginBottom: 0 }}
style={{ width: '80%', height: 14, marginBottom: 8 }}
/>
<Skeleton.Button
size='small'
style={{ width: 14, height: 14, borderRadius: 7 }}
/>
</div>
<div className='grid grid-cols-3 gap-2'>
{Array.from({ length: 3 }).map((_, ratioIndex) => (
<Skeleton.Title
key={ratioIndex}
style={{ width: '100%', height: 12, marginBottom: 0 }}
style={{ width: '100%', height: 18, marginBottom: 0 }}
/>
</div>
<div className='flex-1 rounded-xl px-3 py-2.5' style={{backgroundColor: 'var(--semi-color-fill-0)'}}>
<Skeleton.Title
style={{ width: '80%', height: 14, marginBottom: 8 }}
/>
<Skeleton.Title
style={{ width: '100%', height: 18, marginBottom: 0 }}
/>
))}
</div>
</div>
)}
</Card>
))}
</div>

View File

@ -20,22 +20,15 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import {
Card,
Tag,
Tooltip,
Checkbox,
Empty,
Pagination,
Button,
Avatar,
} from '@douyinfe/semi-ui';
import { IconHelpCircle } from '@douyinfe/semi-icons';
import { Copy } from 'lucide-react';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import {
stringToColor,
calculateModelPrice,
getModelPriceItems,
getLobeHubIcon,
@ -45,7 +38,6 @@ import {
} from '../../../../../helpers';
import PricingCardSkeleton from './PricingCardSkeleton';
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
import { renderLimitedItems } from '../../../../common/ui/RenderUtils';
import { useIsMobile } from '../../../../../hooks/common/useIsMobile';
const CARD_STYLES = {
container:
@ -60,7 +52,6 @@ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const PricingCardView = ({
filteredModels,
loading,
rowSelection,
pageSize,
setPageSize,
currentPage,
@ -69,17 +60,11 @@ const PricingCardView = ({
groupRatio,
groupModelPrice,
groupModelRatio,
copyText,
setModalImageUrl,
setIsModalOpenurl,
currency,
siteDisplayType,
tokenUnit,
displayPrice,
showRatio,
t,
selectedRowKeys = [],
setSelectedRowKeys,
openModelDetail,
showSizeChanger = true,
blurPricing = false,
@ -87,6 +72,7 @@ const PricingCardView = ({
channelVideoRatio = {},
channelVideoCompletionRatio = {},
channelVideoPrice = {},
gridCols = 0, // > 0 使 grid gridCols=2 2
}) => {
const showSkeleton = useMinimumLoadingTime(loading);
const startIndex = (currentPage - 1) * pageSize;
@ -121,62 +107,6 @@ const PricingCardView = ({
);
};
const handleCheckboxChange = (model, checked) => {
if (!setSelectedRowKeys) return;
const modelKey = getModelKey(model);
const newKeys = checked
? Array.from(new Set([...selectedRowKeys, modelKey]))
: selectedRowKeys.filter((key) => key !== modelKey);
setSelectedRowKeys(newKeys);
rowSelection?.onChange?.(newKeys, null);
};
// supplier_type Tag
const getSupplierTypeColor = (supplierType) => {
switch (supplierType) {
case '公有云':
return 'green';
case 'AIDC':
return 'light-green';
case '企业中转站':
return 'lime';
case '个人中转站':
return 'yellow';
default:
return stringToColor(supplierType);
}
};
// channel_list
// logo supplier_alias
const getSupplierLogos = (model) => {
if (!model?.channel_list || model.channel_list.length === 0) return [];
const seen = new Set();
const items = [];
model.channel_list.forEach((ch, idx) => {
const logo =
(ch?.company_logo_url && String(ch.company_logo_url).trim()) || '';
const supplierType =
(ch?.supplier_type && String(ch.supplier_type).trim()) || '';
const alias =
(ch?.supplier_alias && String(ch.supplier_alias).trim()) || '';
const name = ch?.channel_name || '';
if (!logo && !supplierType) return;
const displayAlias = logo ? alias : '';
const dedupKey = `${logo}|${supplierType}|${displayAlias}`;
if (seen.has(dedupKey)) return;
seen.add(dedupKey);
items.push({
key: ch?.channel_id ?? `${dedupKey}-${idx}`,
logo,
supplierType,
alias: displayAlias,
name,
});
});
return items;
};
const calculateChannelPrices = (model, opts = {}) => {
const { skipSimpleVideoFlat = false, skipSimpleFixed = false } = opts;
if (!model.channel_list || model.channel_list.length === 0) {
@ -765,77 +695,11 @@ const PricingCardView = ({
return record.description || '';
};
//
const renderTags = (record) => {
// - 使 channel_list[0].quota_type
const channelQuotaType =
record.channel_list && record.channel_list.length > 0
? record.channel_list[0].quota_type
: record.quota_type;
let billingTag = (
<Tag key='billing' shape='circle' color='white' size='small'>
-
</Tag>
);
if (channelQuotaType === 1) {
billingTag = (
<Tag key='billing' shape='circle' color='teal' size='small'>
{t('按次计费')}
</Tag>
);
} else if (channelQuotaType === 0) {
billingTag = (
<Tag key='billing' shape='circle' color='violet' size='small'>
{t('按量计费')}
</Tag>
);
}
//
const customTags = [];
if (record.tags) {
const tagArr = record.tags.split(',').filter(Boolean);
tagArr.forEach((tg, idx) => {
customTags.push(
<Tag
key={`custom-${idx}`}
shape='circle'
color={stringToColor(tg)}
size='small'
>
{renderHighlightedText(tg)}
</Tag>,
);
});
}
return (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>{billingTag}</div>
<div className='flex items-center gap-1'>
{customTags.length > 0 &&
renderLimitedItems({
items: customTags.map((tag, idx) => ({
key: `custom-${idx}`,
element: tag,
})),
renderItem: (item, idx) => item.element,
maxDisplay: 3,
})}
</div>
</div>
);
};
//
if (showSkeleton) {
return (
<>
<PricingCardSkeleton
rowSelection={!!rowSelection}
showRatio={showRatio}
/>
<PricingCardSkeleton />
</>
);
}
@ -858,11 +722,10 @@ const PricingCardView = ({
return (
<>
<div className='px-2 pt-2'>
<div className='flex flex-wrap gap-4'>
<div className={gridCols > 0 ? '' : 'px-2 pt-2'}>
<div className={gridCols > 0 ? 'grid gap-4' : 'flex flex-wrap gap-4'} style={gridCols > 0 ? { gridTemplateColumns: `repeat(${gridCols}, 1fr)` } : undefined}>
{paginatedModels.map((model, index) => {
const modelKey = getModelKey(model);
const isSelected = selectedRowKeys.includes(modelKey);
const priceData = calculateModelPrice({
record: model,
@ -876,166 +739,31 @@ const PricingCardView = ({
quotaDisplayType: siteDisplayType,
});
const supplierLogos = getSupplierLogos(model);
const hasChannelList =
Array.isArray(model.channel_list) &&
model.channel_list.length > 0;
return (
<Card
key={modelKey || index}
className={`flex-1 min-w-[350px] max-w-[600px] !rounded-2xl transition-all duration-200 hover:shadow-lg border ${blurPricing ? '' : 'cursor-pointer'} ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
className={`${gridCols > 0 ? '!w-full' : 'flex-1 min-w-[350px] max-w-[600px]'} !rounded-2xl transition-all duration-200 hover:shadow-lg border ${blurPricing ? '' : 'cursor-pointer'} ${CARD_STYLES.default}`}
bodyStyle={{ height: '100%' }}
onClick={() =>
!blurPricing && openModelDetail && openModelDetail(model)
}
>
<div className='flex flex-col h-full'>
{/* 头部:图标 + 模型名称 + 操作按钮 */}
<div className='flex items-start justify-between mb-3'>
{/* 头部:图标 + 模型名称 */}
<div className='flex items-start mb-3'>
<div className='flex items-start space-x-3 flex-1 min-w-0'>
{getModelIcon(model)}
<div className='flex-1 min-w-0'>
<h3 className='text-lg font-bold text-gray-900 truncate'>
{renderHighlightedText(model.model_name)}
</h3>
<div
className='flex flex-col gap-1 text-xs mt-1'
style={
blurPricing
? {
filter: 'blur(6px)',
userSelect: 'none',
pointerEvents: 'none',
}
: undefined
}
>
{getModelPriceItemsForCard(model, priceData).map(
(item) => (
<div key={item.key} className='flex items-center'>
<span className='w-20 flex-shrink-0'>
{item.label}
</span>
<span className='flex-1 font-bold text-black inline-flex items-center flex-wrap gap-1'>
{item.valueNode ? (
item.valueNode
) : item.original ? (
<>
<span className='line-through text-gray-400 font-normal text-[10px]'>
<span
style={{
color: 'var(--semi-color-primary)',
}}
>
官方
</span>{' '}
{item.original.text}
</span>
<Tag
color='red'
size='small'
shape='circle'
>
-{item.original.discount}%
</Tag>
<span>
<span
style={{
color: 'var(--semi-color-warning)',
}}
>
我们
</span>{' '}
{item.value}
{item.suffix}
</span>
</>
) : (
<span>
{item.value}
{item.suffix}
</span>
)}
</span>
</div>
),
)}
<div className='flex items-center'>
<span className='w-20 flex-shrink-0'>
{t('供应商')}
</span>
<div className='flex-1 flex items-center flex-wrap gap-1'>
{supplierLogos.length === 0 ? (
hasChannelList ? null : (
<span className='font-bold text-black'>
{t('官方')}
</span>
)
) : (
supplierLogos.map((s) => (
<div
key={s.key}
className='h-7 rounded-md flex items-center overflow-hidden'
style={{backgroundColor: 'var(--semi-color-fill-0)'}}
>
{s.logo ? (
<img
src={s.logo}
alt={s.alias || s.name || ''}
className='w-7 h-7 object-contain rounded-md'
/>
) : null}
{s.supplierType && (
<Tag
size='small'
shape='circle'
color={getSupplierTypeColor(
s.supplierType,
)}
className='mx-1'
>
{s.supplierType}
</Tag>
)}
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
<div className='flex items-center space-x-2 ml-3'>
{/* 复制按钮 */}
<Button
size='small'
theme='outline'
type='tertiary'
icon={<Copy size={12} />}
onClick={(e) => {
e.stopPropagation();
copyText(model.model_name);
}}
/>
{/* 选择框 */}
{rowSelection && (
<Checkbox
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
handleCheckboxChange(model, e.target.checked);
}}
/>
)}
</div>
</div>
{/* 模型描述 - 占据剩余空间 */}
{/* 模型描述 */}
<div
className='flex-1 mb-4'
className='mb-4'
style={
blurPricing
? {
@ -1054,7 +782,7 @@ const PricingCardView = ({
</p>
</div>
{/* 底部区域 */}
{/* 价格:输入 + 输出并排 */}
<div
className='mt-auto'
style={
@ -1067,49 +795,39 @@ const PricingCardView = ({
: undefined
}
>
{/* 标签区域 */}
{renderTags(model)}
{/* 倍率信息(可选) */}
{showRatio && (
<div className='pt-3'>
<div className='flex items-center space-x-1 mb-2'>
<span className='text-xs font-medium text-gray-700'>
{t('倍率信息')}
</span>
<Tooltip
content={t('倍率是为了方便换算不同价格的模型')}
>
<IconHelpCircle
className='text-blue-500 cursor-pointer'
size='small'
onClick={(e) => {
e.stopPropagation();
setModalImageUrl('/ratio.png');
setIsModalOpenurl(true);
}}
/>
</Tooltip>
</div>
<div className='grid grid-cols-3 gap-2 text-xs text-gray-600'>
<div>
{t('模型')}:{' '}
{model.quota_type === 0
? (priceData?.inputRatio ?? model.model_ratio)
: t('无')}
</div>
<div>
{t('输出')}:{' '}
{model.quota_type === 0
? parseFloat(model.completion_ratio.toFixed(2))
: t('无')}
</div>
<div>
{t('分组')}: {priceData?.usedGroupRatio ?? '-'}
</div>
{(() => {
const items = getModelPriceItemsForCard(model, priceData);
const inputItem = items.find(i => i.key === 'input');
const outputItem = items.find(i => i.key === 'output');
return (
<div className='flex gap-3'>
{inputItem && (
<div className='flex-1 rounded-xl px-3 py-2.5' style={{backgroundColor: 'var(--semi-color-fill-0)'}}>
<div className='text-[10px] mb-0.5' style={{color: 'var(--semi-color-text-2)'}}>{inputItem.label}</div>
<div className='text-sm font-bold text-gray-900'>
{inputItem.value}{inputItem.suffix}
</div>
</div>
)}
{outputItem && (
<div className='flex-1 rounded-xl px-3 py-2.5' style={{backgroundColor: 'var(--semi-color-fill-0)'}}>
<div className='text-[10px] mb-0.5' style={{color: 'var(--semi-color-text-2)'}}>{outputItem.label}</div>
<div className='text-sm font-bold text-gray-900'>
{outputItem.value}{outputItem.suffix}
</div>
</div>
)}
{!inputItem && !outputItem && items.map(item => (
<div key={item.key} className='flex-1 rounded-xl px-3 py-2.5' style={{backgroundColor: 'var(--semi-color-fill-0)'}}>
<div className='text-[10px] mb-0.5' style={{color: 'var(--semi-color-text-2)'}}>{item.label}</div>
<div className='text-sm font-bold text-gray-900'>
{item.valueNode || (item.value + (item.suffix || ''))}
</div>
</div>
))}
</div>
);
})()}
</div>
</div>
</Card>

View File

@ -144,7 +144,7 @@ export const ERROR_MESSAGES = {
NETWORK_ERROR: '网络连接失败或服务器无响应',
};
/** 体验文生视频时长330默认 5 */
/** 体验文生视频时长330默认 5 */
export const PLAYGROUND_VIDEO_DURATION_OPTIONS = Array.from(
{ length: 28 },
(_, i) => {
@ -153,7 +153,7 @@ export const PLAYGROUND_VIDEO_DURATION_OPTIONS = Array.from(
},
);
// 体验图片分辨率value 为上游 sizelabel 仅展示 480p / 720p 等
// 体验图片分辨率value 为上游 sizelabel 仅展示 480p / 720p 等
export const PLAYGROUND_IMAGE_SIZE_OPTIONS = [
{ label: '480p', value: '854x480' },
{ label: '720p', value: '1280x720' },

View File

@ -111,7 +111,7 @@ export function buildImageMarkdown(sources) {
.join('\n\n');
}
/** 构造体验消息 patchgeneratedImages + markdown 正文 */
/** 构造体验消息 patchgeneratedImages + markdown 正文 */
export function buildImageMessageContentPatch(sources) {
const images = dedupeImageSources(sources);
if (!images.length) return null;

View File

@ -104,7 +104,7 @@ export function applyVideoFrameMetadata(metadata, imageUrls) {
}
/**
* 体验 metadata.input只传 promptmedia.type 由后端 alivideo adaptor 按模型规范化
* 体验 metadata.input只传 promptmedia.type 由后端 alivideo adaptor 按模型规范化
*/
export function buildVideoNativeInput(prompt) {
return { prompt: String(prompt || '').trim() };

View File

@ -94,7 +94,7 @@ const normalizeTagList = (csv) => {
const hasTag = (csv, tag) => normalizeTagList(csv).includes(tag);
/**
* 体验数据加载拉取用户模型与类型与模型广场同源元数据全部/类型在客户端筛模型同模型广场逻辑分组单独加载
* 体验数据加载拉取用户模型与类型与模型广场同源元数据全部/类型在客户端筛模型同模型广场逻辑分组单独加载
* @param {{ user?: object }} userState 已登录用户状态
* @param {{ model?: string, model_type?: string|number, group?: string, selected_route_slug?: string }} inputs 当前表单/配置
* @param {Array<{ label: string, value: string|number }>} modelTypes 类型下拉项与接口同步后的状态用于按类型重算模型列表

View File

@ -2339,8 +2339,8 @@
"操作暂时被禁用": "Operation temporarily disabled",
"操作确认": "Operation confirmation",
"操作类型": "Operation Type",
"体验": "Playground",
"体验和聊天功能": "Playground and chat functions",
"体验": "Playground",
"体验和聊天功能": "Playground and chat functions",
"支付": "Pay",
"支付地址": "Payment address",
"支付失败": "Payment failed",
@ -4245,7 +4245,7 @@
"视频本地按 token 计费": "Video billed locally by estimated tokens",
"视频本地按条/分辨率计价": "Video billed locally per clip / resolution",
"视频模式支持图片或视频 URL 作为素材": "Video mode supports image or video URLs as elements",
"体验视频素材提示": "Image URLs: 1st = first frame, 2 = first+last frame, 3+ = first, references in between, last = final frame. Video URLs: included when filled. Empty fields are omitted from the request.",
"体验视频素材提示": "Image URLs: 1st = first frame, 2 = first+last frame, 3+ = first, references in between, last = final frame. Video URLs: included when filled. Empty fields are omitted from the request.",
"视频地址": "Video URL",
"视频生成": "Video Generation",
"视频生成中,请稍后": "Generating video, please wait",

View File

@ -2306,8 +2306,8 @@
"操作暂时被禁用": "Opération temporairement désactivée",
"操作确认": "Operation confirmation",
"操作类型": "Type d'opération",
"体验": "Terrain de jeu",
"体验和聊天功能": "Terrain de jeu et fonctions de discussion",
"体验": "Terrain de jeu",
"体验和聊天功能": "Terrain de jeu et fonctions de discussion",
"支付": "Payer",
"支付地址": "Adresse de paiement",
"支付失败": "Paiement échoué",

View File

@ -2306,8 +2306,8 @@
"操作暂时被禁用": "Operasi sementara dinonaktifkan",
"操作确认": "Konfirmasi operasi",
"操作类型": "Jenis operasi",
"体验": "Arena latihan",
"体验和聊天功能": "Arena latihan dan fungsi obrolan",
"体验": "Arena latihan",
"体验和聊天功能": "Arena latihan dan fungsi obrolan",
"支付": "Bayar",
"支付地址": "Alamat pembayaran",
"支付失败": "Pembayaran gagal",

View File

@ -2306,8 +2306,8 @@
"操作暂时被禁用": "この操作は一時的に無効にされています",
"操作确认": "Operation confirmation",
"操作类型": "操作タイプ",
"体验": "Playground",
"体验和聊天功能": "プレイグラウンドとチャット機能",
"体验": "Playground",
"体验和聊天功能": "プレイグラウンドとチャット機能",
"支付": "支払う",
"支付地址": "決済URL",
"支付失败": "支払いに失敗しました",

View File

@ -2306,8 +2306,8 @@
"操作暂时被禁用": "Operasi dilumpuhkan buat sementara",
"操作确认": "Pengesahan operasi",
"操作类型": "Jenis operasi",
"体验": "Padang latihan",
"体验和聊天功能": "Padang latihan dan fungsi sembang",
"体验": "Padang latihan",
"体验和聊天功能": "Padang latihan dan fungsi sembang",
"支付": "Bayar",
"支付地址": "Alamat pembayaran",
"支付失败": "Pembayaran gagal",

View File

@ -2306,8 +2306,8 @@
"操作暂时被禁用": "Операция временно отключена",
"操作确认": "Operation confirmation",
"操作类型": "Тип операции",
"体验": "Тренировочная площадка",
"体验和聊天功能": "Тренировочная площадка и чат-функции",
"体验": "Тренировочная площадка",
"体验和聊天功能": "Тренировочная площадка и чат-функции",
"支付": "Оплатить",
"支付地址": "Адрес оплаты",
"支付失败": "Оплата не удалась",

View File

@ -2306,8 +2306,8 @@
"操作暂时被禁用": "Operesheni imezimwa kwa muda",
"操作确认": "Uthibitisho wa operesheni",
"操作类型": "Aina ya operesheni",
"体验": "Uwanja wa mazoezi",
"体验和聊天功能": "Uwanja wa mazoezi na mazungumzo",
"体验": "Uwanja wa mazoezi",
"体验和聊天功能": "Uwanja wa mazoezi na mazungumzo",
"支付": "Lipa",
"支付地址": "Anwani ya malipo",
"支付失败": "Malipo yameshindwa",

View File

@ -2306,8 +2306,8 @@
"操作暂时被禁用": "การดำเนินการถูกปิดชั่วคราว",
"操作确认": "ยืนยันการดำเนินการ",
"操作类型": "ประเภทการดำเนินการ",
"体验": "สนามฝึก",
"体验和聊天功能": "สนามฝึกและแชท",
"体验": "สนามฝึก",
"体验和聊天功能": "สนามฝึกและแชท",
"支付": "ชำระเงิน",
"支付地址": "ที่อยู่การชำระเงิน",
"支付失败": "ชำระเงินไม่สำเร็จ",

View File

@ -2306,8 +2306,8 @@
"操作暂时被禁用": "Thao tác tạm thời bị vô hiệu hóa",
"操作确认": "Operation confirmation",
"操作类型": "Loại thao tác",
"体验": "Sân chơi",
"体验和聊天功能": "Chức năng sân chơi và trò chuyện",
"体验": "Sân chơi",
"体验和聊天功能": "Chức năng sân chơi và trò chuyện",
"支付": "Thanh toán",
"支付地址": "Địa chỉ thanh toán",
"支付失败": "Thanh toán thất bại",

View File

@ -1268,8 +1268,8 @@
"操作失败,请重试": "操作失败,请重试",
"操作成功完成!": "操作成功完成!",
"操作暂时被禁用": "操作暂时被禁用",
"体验": "体验",
"体验和聊天功能": "体验和聊天功能",
"体验": "体验",
"体验和聊天功能": "体验和聊天功能",
"支付地址": "支付地址",
"支付宝": "支付宝",
"支付方式": "支付方式",
@ -5285,7 +5285,7 @@
"视频数量": "视频数量",
"视频时长(秒)": "视频时长(秒)",
"视频模式支持图片或视频 URL 作为素材": "视频模式支持图片或视频 URL 作为素材",
"体验视频素材提示": "图片地址:第 1 张为首帧2 张为首尾帧,更多张时最后一张为尾帧。视频地址:填写则作为源视频参与生成。未填写的字段不会加入请求。",
"体验视频素材提示": "图片地址:第 1 张为首帧2 张为首尾帧,更多张时最后一张为尾帧。视频地址:填写则作为源视频参与生成。未填写的字段不会加入请求。",
"视频地址": "视频地址",
"视频生成": "视频生成",
"视频生成中,请稍后": "视频生成中,请稍后",

View File

@ -2305,8 +2305,8 @@
"操作暂时被禁用": "操作暫時被禁用",
"操作确认": "操作確認",
"操作类型": "操作類型",
"体验": "操練場",
"体验和聊天功能": "操練場和聊天功能",
"体验": "操練場",
"体验和聊天功能": "操練場和聊天功能",
"支付": "付款",
"支付地址": "付款位址",
"支付失败": "付款失敗",

125
web/src/index.css vendored
View File

@ -1432,6 +1432,131 @@ html.dark .with-pastel-balls::before {
min-height: 0;
}
/* ─── Models 新布局 ─── */
.models-page-layout {
display: flex;
max-width: 1440px;
margin: 0 auto;
padding: 56px 24px 0;
gap: 32px;
height: 100vh;
overflow: hidden;
box-sizing: border-box;
}
.models-sidebar {
width: 280px;
flex-shrink: 0;
overflow-y: auto;
padding: 0 16px 24px 0;
border-right: 1px solid var(--border-color, #e5e7eb);
}
.models-sidebar::-webkit-scrollbar {
width: 4px;
}
.models-sidebar::-webkit-scrollbar-thumb {
background: transparent;
}
.models-sidebar:hover::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.15);
border-radius: 2px;
}
.models-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.models-tabs-bar {
display: flex;
gap: 8px;
padding: 16px 0;
flex-shrink: 0;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.models-tab-btn {
padding: 6px 20px;
border-radius: 20px;
border: 1px solid var(--border-color, #d1d5db);
background: transparent;
cursor: pointer;
font-size: 14px;
color: var(--text-muted, #6b7280);
transition: all 0.2s;
white-space: nowrap;
}
.models-tab-btn:hover {
border-color: var(--primary-color, #3b82f6);
color: var(--primary-color, #3b82f6);
}
.models-tab-btn.active {
background: var(--primary-color, #3b82f6);
color: white;
border-color: var(--primary-color, #3b82f6);
}
.models-card-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px 0;
overflow-y: auto;
flex: 1;
}
.models-card-grid::-webkit-scrollbar {
width: 6px;
}
.models-card-grid::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.12);
border-radius: 3px;
}
/* 筛选面板标题 */
.models-filter-section {
margin-bottom: 24px;
}
.models-filter-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #6b7280);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.models-filter-item {
display: flex;
align-items: center;
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
font-size: 14px;
color: var(--text-color, #374151);
gap: 8px;
}
.models-filter-item:hover {
background: var(--hover-bg, #f3f4f6);
}
.models-filter-item.active {
background: var(--primary-light, #eff6ff);
color: var(--primary-color, #3b82f6);
font-weight: 500;
}
.models-filter-item .count {
margin-left: auto;
font-size: 12px;
color: var(--text-muted, #9ca3af);
}
/* ==================== semi-ui 缁勪欢鑷畾涔夋牱寮?==================== */
.semi-card-header,
.semi-card-body {

View File

@ -1,4 +1,4 @@
/*
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
@ -194,7 +194,7 @@ function HeroSection() {
{t("兼容 OpenAI / Claude / Gemini 等协议,覆盖文本、图像、视频、语音,智能路由,统一计费。")}
</p>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 font-body">
<Link to="/register" className="inline-flex items-center bg-white text-black px-6 py-3 text-sm font-mono font-medium uppercase tracking-widest hover:bg-transparent hover:text-white border border-white transition-colors duration-200">
<Link to="/console/token" className="inline-flex items-center bg-white text-black px-6 py-3 text-sm font-mono font-medium uppercase tracking-widest hover:bg-transparent hover:text-white border border-white transition-colors duration-200">
{t("开始使用")}
</Link>
<Link to="/pricing" className="inline-flex items-center text-white/60 px-6 py-3 text-sm font-mono font-medium uppercase tracking-widest hover:text-white transition-colors duration-200">
@ -216,7 +216,7 @@ function HeroSection() {
</button>
</div>
<pre className="p-5 text-sm font-mono leading-relaxed text-white/80 overflow-x-auto">
<code>{`curl https://api.tokendance.space/v1/chat/completions \\
<code>{`curl https://api.turingtoken.ai/v1/chat/completions \\
-H "Authorization: Bearer \$YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
@ -259,7 +259,7 @@ function HeroSection() {
titleKey: '多协议兼容',
descKey:
'原生支持 OpenAI、Claude、Gemini 文本协议,覆盖图像 / 视频 / 文本转语音生成。无需修改代码,切换 Base URL 即可接入。',
code: 'baseURL: "api.tokendance.space"',
code: 'baseURL: "api.turingtoken.ai"',
},
{
titleKey: '智能路由',
@ -408,7 +408,7 @@ const LANG_LABELS = { curl: 'cURL', python: 'Python', nodejs: 'Node.js' };
const CODE_SAMPLES = {
openai: {
curl: `curl https://api.tokendance.space/v1/chat/completions \\
curl: `curl https://api.turingtoken.ai/v1/chat/completions \\
-H "Authorization: Bearer $OPENAI_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
@ -418,7 +418,7 @@ const CODE_SAMPLES = {
python: `from openai import OpenAI
client = OpenAI(
base_url="https://api.tokendance.space",
base_url="https://api.turingtoken.ai",
api_key="sk-your-key"
)
@ -430,7 +430,7 @@ print(response.choices[0].message.content)`,
nodejs: `import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.tokendance.space",
baseURL: "https://api.turingtoken.ai",
apiKey: "sk-your-key",
});
@ -441,7 +441,7 @@ const response = await client.chat.completions.create({
console.log(response.choices[0].message.content);`,
},
claude: {
curl: `curl https://api.tokendance.space/v1/messages \\
curl: `curl https://api.turingtoken.ai/v1/messages \\
-H "x-api-key: sk-your-key" \\
-H "anthropic-version: 2023-06-01" \\
-H "Content-Type: application/json" \\
@ -453,7 +453,7 @@ console.log(response.choices[0].message.content);`,
python: `import anthropic
client = anthropic.Anthropic(
base_url="https://api.tokendance.space",
base_url="https://api.turingtoken.ai",
api_key="sk-your-key"
)
@ -466,7 +466,7 @@ print(response.content[0].text)`,
nodejs: `import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
baseURL: "https://api.tokendance.space",
baseURL: "https://api.turingtoken.ai",
apiKey: "sk-your-key",
});
@ -478,7 +478,7 @@ const response = await client.messages.create({
console.log(response.content[0].text);`,
},
gemini: {
curl: `curl "https://api.tokendance.space/v1beta/models/gemini-2.0-flash:generateContent?key=sk-your-key" \\
curl: `curl "https://api.turingtoken.ai/v1beta/models/gemini-2.0-flash:generateContent?key=sk-your-key" \\
-H "Content-Type: application/json" \\
-d '{
"contents": [{"parts":[{"text": "Hello!"}]}]
@ -732,12 +732,6 @@ function DarkFooter() {
>
{t('定价')}
</Link>
<Link
to="/about"
className="font-mono text-xs text-background/40 hover:text-background transition-all duration-200"
>
{t('关于')}
</Link>
<Link
to="/console"
className="font-mono text-xs text-background/40 hover:text-background transition-all duration-200"
@ -750,16 +744,6 @@ function DarkFooter() {
<span className="font-mono text-xs text-background/30">
&copy; 2026 TokenFactory
</span>
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 md:gap-x-6">
<a
href="https://github.com/fyinfor/token-factory"
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-background/30 hover:text-background transition-all duration-200"
>
GitHub
</a>
</div>
</div>
</div>
</footer>

View File

@ -588,7 +588,18 @@ const Playground = () => {
if (searchParams.get('expired')) {
Toast.warning(t('登录过期,请重新登录!'));
}
}, [searchParams, t]);
const modelParam = searchParams.get('model');
const modelTypeParam = searchParams.get('model_type');
if (modelTypeParam) {
const v = Number(modelTypeParam);
if (!Number.isNaN(v)) {
handleInputChange('model_type', v);
}
}
if (modelParam) {
handleInputChange('model', modelParam);
}
}, [searchParams, t, handleInputChange]);
// Playground isMobile useIsMobile Hook

View File

@ -41,11 +41,11 @@ const sectionConfigs = [
{
key: 'chat',
title_key: '聊天区域',
desc_key: '体验和聊天功能',
desc_key: '体验和聊天功能',
modules: [
{
key: 'playground',
title_key: '体验',
title_key: '体验',
desc_key: 'AI模型测试环境',
},
{ key: 'chat', title_key: '聊天', desc_key: '聊天会话管理' },