import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Model } from '../types/models'; import { FileText } from '../icons/commonIcons'; import { getCategoryIcon } from '../icons/modelIcons'; import { getPricingMappingLookup, modelApi } from '../services/modelApi'; export interface ModelCardProps { model: Model; // 可选的额外数据(用于百炼模型) bailianData?: { image?: string; version?: string; date?: string; tag?: string; }; // 版本信息显示方式:'standard' 或 'bailian' versionDisplayMode?: 'standard' | 'bailian'; // 可选的名称解析函数(用于百炼模型) nameParser?: (model: Model, bailianModels?: any[]) => { mainName: string; versionInfo: string | null; }; bailianModels?: any[]; // 悬停延迟(毫秒),如果设置则鼠标悬停超过该时间才显示价格 hoverDelayMs?: number; } type PricingLine = { text: string; original?: string; discountLabel?: string; // 如 "8折" }; type PricingDisplay = { modelCode: string; lines: PricingLine[]; }; const pricingCache = new Map(); const pricingKeyLabels: Record = { input: '输入', output: '输出', input_thinking: '输入(思考)', output_thinking: '输出(思考)', input_cache_hit: '输入(缓存命中)', input_cache_hit_thinking: '输入(缓存命中·思考)', input_batch: '输入(批量)', input_batch_thinking: '输入(批量·思考)', output_batch: '输出(批量)', output_thinking_batch: '输出(批量·思考)', cache_write: '缓存写入', cache_read: '缓存读取', explicit_cache_create: '显式缓存写入', explicit_cache_hit: '显式缓存命中', explicit_cache_create_thinking: '显式缓存写入(思考)', explicit_cache_hit_thinking: '显式缓存命中(思考)', image_input: '图片输入', text_input: '文本输入' }; const categoryKeyLabels: Record = { image_generation: '图片生成', video_generation: '视频生成', audio_generation: '音频生成', text_generation: '文本生成', embedding: '向量生成' }; const normalizeMappingKey = (value: string): string => { return value .replace(/[-–—‑]/g, '-') .replace(/[\u200B-\u200D\uFEFF]/g, '') .replace(/\s*-\s*/g, '-') .replace(/\s+/g, ' ') .trim(); }; const formatPricingLines = (pricing: any): PricingLine[] => { if (!pricing) return []; const formatPrice = (price: number, unit?: string): number => { const unitStr = (unit || '').toLowerCase(); const isTokenUnit = unitStr.includes('token') || unitStr.includes('百万') || unitStr.includes('million'); if (isTokenUnit && price < 0.01) return price * 1000; return price; }; const makeLine = (label: string, price: number, originalPrice: number | undefined, unit: string, discountRate?: number): PricingLine => { const discounted = formatPrice(price, unit); const hasDiscount = originalPrice !== undefined && originalPrice > 0 && Math.abs(originalPrice - price) > 0.000001; const original = hasDiscount ? `${formatPrice(originalPrice!, unit)}` : undefined; // 折扣标签:discount_rate 是 0~1,如 0.1 = 1折 const discountLabel = hasDiscount && discountRate && discountRate < 1 ? `${Math.round(discountRate * 10)}折` : hasDiscount ? (() => { const rate = originalPrice && originalPrice > 0 ? price / originalPrice : 1; const tenths = Math.round(rate * 10); return tenths < 10 ? `${tenths}折` : undefined; })() : undefined; return { text: `${label}:${discounted}${unit}`, original: original ? `${label}:${original}${unit}` : undefined, discountLabel, }; }; const normalizeUnit = (u: string) => u.includes('token') && !u.includes('tokens') ? u.replace('token', 'tokens') : u; if (Array.isArray(pricing)) { // tier 数组:取第一个有效 tier 的输入/输出价格展示,忽略 input_range 技术细节 const lines: PricingLine[] = []; const firstTier = pricing.find(i => i !== null && i !== undefined) as any; if (!firstTier || typeof firstTier !== 'object') return []; const unit = firstTier.unit ? ` ${normalizeUnit(firstTier.unit)}` : ''; // 如果是 tier 格式(有 input_range),直接取 input/output if (firstTier.input_range || firstTier.range || firstTier.inputRange) { if (firstTier.input !== undefined && firstTier.input !== null) { lines.push(makeLine('输入', firstTier.input, firstTier.input_original, unit)); } if (firstTier.output !== undefined && firstTier.output !== null && firstTier.output > 0) { lines.push(makeLine('输出', firstTier.output, firstTier.output_original, unit)); } return lines; } const priceVal = firstTier.price ?? firstTier.amount ?? firstTier.value; if (firstTier.item && priceVal !== undefined) { lines.push(makeLine(firstTier.item, priceVal, firstTier.price_original, unit)); return lines; } const handled = new Set([...Object.keys(pricingKeyLabels), 'unit', 'input_range', 'range', 'inputRange', 'input_original', 'output_original', 'discount_rate', 'price_original']); Object.keys(pricingKeyLabels).forEach(key => { if (item[key] !== undefined && item[key] !== null) { const origKey = `${key}_original`; lines.push(makeLine(pricingKeyLabels[key], item[key], item[origKey], item.unit ? ` ${normalizeUnit(item.unit)}` : unit, item.discount_rate)); handled.add(key); } }); return lines; } if (typeof pricing === 'object') { const unit = pricing.unit ? ` ${normalizeUnit(pricing.unit)}` : ''; const lines: PricingLine[] = []; const simplePriceVal = pricing.price ?? pricing.amount ?? pricing.value; if (pricing.item && simplePriceVal !== undefined) { lines.push(makeLine(pricing.item, simplePriceVal, pricing.price_original, unit)); return lines; } const handled = new Set([...Object.keys(pricingKeyLabels), 'unit', 'input_original', 'output_original', 'discount_rate']); Object.keys(pricingKeyLabels).forEach(key => { if (pricing[key] !== undefined && pricing[key] !== null) { const origKey = `${key}_original`; lines.push(makeLine(pricingKeyLabels[key], pricing[key], pricing[origKey], unit, pricing.discount_rate)); handled.add(key); } }); Object.entries(pricing).forEach(([key, value]) => { if (handled.has(key)) return; if (Array.isArray(value)) { value.forEach(item => { if (!item) return; const label = item.item || categoryKeyLabels[key] || key; const price = item.price ?? item.amount ?? item.value; const unitText = item.unit ? ` ${normalizeUnit(item.unit)}` : unit; if (price === undefined || price === null) return; lines.push(makeLine(label, price, item.price_original, unitText)); }); } }); return lines; } return []; }; const ModelCard: React.FC = ({ model, bailianData, versionDisplayMode = 'standard', nameParser, bailianModels, hoverDelayMs }) => { const [pricingDisplay, setPricingDisplay] = useState(undefined); const [pricingLoading, setPricingLoading] = useState(false); const [showOverlay, setShowOverlay] = useState(false); const hoverTimerRef = useRef(null); const headerRef = useRef(null); const iconRef = useRef(null); const rootRef = useRef(null); const [overlayTopPx, setOverlayTopPx] = useState(0); const navigate = useNavigate(); // 解析模型名称和版本信息 let mainName = model.name; let versionInfo: string | null = null; if (nameParser) { const parsed = nameParser(model, bailianModels); mainName = parsed.mainName; versionInfo = parsed.versionInfo; } else if (versionDisplayMode === 'standard') { // 标准模式:解析模型名称,支持换行显示 // 格式:主名称\n最新版本\n版本号 const nameParts = model.name.split('\n'); mainName = nameParts[0] || model.name; const versionLabel = nameParts[1] || null; const versionCode = nameParts[2] || null; versionInfo = versionLabel && versionCode ? `${versionLabel} ${versionCode}` : (versionLabel || versionCode || null); } else if (versionDisplayMode === 'bailian' && bailianData) { // 百炼模式:从 bailianData 获取版本信息 const nameParts = bailianModels?.find((b: any) => b.标题 === model.id)?.名称.split('\n') || []; const version = nameParts[2] || bailianData.version || ''; versionInfo = version || null; } // 处理标签显示逻辑(根据 tag1 / tag2 推断“New”) const shouldShowNew = model.tag1 === 'New' || model.tag2 === 'New'; const versionTag = model.tag2; // 百炼模式的标签处理 const bailianTag = bailianData?.tag && bailianData.tag !== '最新版本' && bailianData.tag !== 'New' ? bailianData.tag : null; const pricingLookupKey = useMemo(() => { // 优先使用model.title作为查询键,因为后端存储的是title if (model.title) { const normalizedTitle = normalizeMappingKey(model.title); console.log('Using model.title as lookup key:', normalizedTitle); return normalizedTitle; } const normalizedName = normalizeMappingKey(mainName || ''); if (normalizedName) { console.log('Using mainName as lookup key:', normalizedName); return normalizedName; } const name = model.name || ''; const nameParts = name .split('\n') .map((part) => part.trim()) .filter(Boolean); const latestIndex = nameParts.findIndex((part) => part === '最新版本'); if (latestIndex !== -1 && nameParts[latestIndex + 1]) { const versionKey = normalizeMappingKey(nameParts[latestIndex + 1]); console.log('Using version from name as lookup key:', versionKey); return versionKey; } const inlineLatest = nameParts.find((part) => part.startsWith('最新版本')); if (inlineLatest) { const match = inlineLatest.match(/最新版本\s*([A-Za-z0-9._-]+)/); if (match?.[1]) { const matchKey = normalizeMappingKey(match[1]); console.log('Using matched version as lookup key:', matchKey); return matchKey; } } const fallbackKey = normalizeMappingKey(model.title || ''); console.log('Using fallback key:', fallbackKey); return fallbackKey; }, [mainName, model.name, model.title]); useEffect(() => { setPricingDisplay(undefined); }, [pricingLookupKey]); useEffect(() => { return () => { if (hoverTimerRef.current) { window.clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } }; }, []); useLayoutEffect(() => { const compute = () => { const headerEl = headerRef.current; const iconEl = iconRef.current; const rootEl = rootRef.current; if (headerEl && rootEl) { const headerRect = headerEl.getBoundingClientRect(); const rootRect = rootEl.getBoundingClientRect(); // 以标题底部或图标底部为基准,优先保证覆盖从图标下方开始 let topPx = headerRect.bottom - rootRect.top; if (iconEl) { const iconRect = iconEl.getBoundingClientRect(); topPx = Math.max(topPx, iconRect.bottom - rootRect.top + 12); // 保证距图标底部至少 12px } // 最小顶部留白,避免 overlay 紧贴边缘;提高到 80px 以避免覆盖图标 topPx = Math.max(topPx, 80); setOverlayTopPx(topPx); } else if (headerEl) { setOverlayTopPx(headerEl.offsetHeight + 8); } }; compute(); const onResize = () => compute(); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [headerRef, rootRef]); const loadPricing = useCallback(async () => { if (!pricingLookupKey) { console.log('No pricingLookupKey, setting display to null'); setPricingDisplay(null); return; } if (pricingDisplay !== undefined && pricingDisplay !== null) { console.log('Pricing display already set, returning'); return; } if (pricingCache.has(pricingLookupKey)) { const cached = pricingCache.get(pricingLookupKey); if (cached) { console.log('Using cached pricing:', cached); setPricingDisplay(cached); return; } } try { setPricingLoading(true); console.log('Loading pricing for:', pricingLookupKey); let resolvedCode = pricingLookupKey; try { const mapping = await getPricingMappingLookup(); resolvedCode = mapping.get(pricingLookupKey) || pricingLookupKey; console.log('Resolved code:', resolvedCode); } catch (mappingError) { console.log('Mapping error:', mappingError); resolvedCode = pricingLookupKey; } console.log('Fetching pricing for:', resolvedCode); const response = await modelApi.getParsedPricing(resolvedCode); console.log('Response:', response); const pricing = response?.data?.model_pricing; console.log('Pricing data:', pricing); const lines = formatPricingLines(pricing); console.log('Formatted lines:', lines); const display = lines.length ? { modelCode: resolvedCode, lines } : null; console.log('Display data:', display); pricingCache.set(pricingLookupKey, display); setPricingDisplay(display); } catch (error) { console.log('Error loading pricing:', error); setPricingDisplay(null); } finally { setPricingLoading(false); } }, [pricingDisplay, pricingLookupKey]); const pricingMaxLines = 4; // 取消悬停延迟,立即显示价格信息(去掉延迟动画) const hoverDelay = 0; return (
{ if (hoverTimerRef.current) { window.clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } if (hoverDelay <= 0) { setShowOverlay(true); loadPricing(); } else { hoverTimerRef.current = window.setTimeout(() => { setShowOverlay(true); loadPricing(); hoverTimerRef.current = null; }, hoverDelay); } }} onMouseLeave={() => { if (hoverTimerRef.current) { window.clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } setShowOverlay(false); }} > {/* 右上角多标签显示 */}
{shouldShowNew && (
New
)} {versionTag && versionTag !== 'New' && (
{versionTag}
)} {versionTag && versionTag === 'New' && !shouldShowNew && (
New
)} {bailianTag && (
{bailianTag}
)}
{/* ‘查看详情’ 按钮已移动到标题容器内以靠近模型名称(见下方) */} {/* 悬停时覆盖式显示价格与按钮(动态起始位置:基于标题高度,避免遮挡模型名称) */} {showOverlay ? (
价格信息
{pricingLoading &&
加载中...
} {!pricingLoading && pricingDisplay?.lines?.length ? (() => { // 只取有折扣的行,或第一条有价格的行,做简洁展示 const discountLines = pricingDisplay.lines.filter(l => l.original); const showLines = discountLines.length > 0 ? discountLines.slice(0, 2) : pricingDisplay.lines.filter(l => !l.text.includes('输入范围')).slice(0, 2); return (
    {showLines.map((line, idx) => (
  • {line.original ? (
    {line.original} {line.discountLabel && ( {line.discountLabel} )} {line.text}
    ) : ( {line.text} )}
  • ))}
  • 点击查看详情了解完整价格
); })() : null} {!pricingLoading && !pricingDisplay?.lines?.length && (
暂无价格信息
)}
) : null} {/* 左侧图标和标题区域 */}
{/* 图标 */}
{bailianData?.image ? ( {model.name} ) : model.img ? ( {model.name} ) : ( // 如果没有图片,则根据模型分类显示默认图标
{(() => { const Icon = getCategoryIcon(model.category); return ; })()}
)}
{/* 标题和版本(包含右侧的“查看详情”按钮) */}

{mainName}

{versionInfo && ( versionDisplayMode === 'bailian' ? (
最新版本 {versionInfo}
) : (
{versionInfo}
) )} {/* 查看详情 按钮已移动到标题行最右侧 */}
{/* 右上角靠左的“查看详情”入口(通过绝对定位显示,避免覆盖最右侧标签) */}
{/* 描述 */}

{model.description}

{/* 底部:关键词和日期 */}
{(model.keyword || '') .split('\n') .map(t => t.trim()) .filter(Boolean) .map(t => ( {t} ))}
{bailianData?.date && ( {bailianData.date} )}
{/* 右下角时间显示 */} {model.time && (
{model.time}
)}
); }; export default ModelCard;