| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- 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<string, PricingDisplay | null>();
- const pricingKeyLabels: Record<string, string> = {
- 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<string, string> = {
- 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<ModelCardProps> = ({
- model,
- bailianData,
- versionDisplayMode = 'standard',
- nameParser,
- bailianModels,
- hoverDelayMs
- }) => {
- const [pricingDisplay, setPricingDisplay] = useState<PricingDisplay | null | undefined>(undefined);
- const [pricingLoading, setPricingLoading] = useState(false);
- const [showOverlay, setShowOverlay] = useState(false);
- const hoverTimerRef = useRef<number | null>(null);
- const headerRef = useRef<HTMLDivElement | null>(null);
- const iconRef = useRef<HTMLDivElement | null>(null);
- const rootRef = useRef<HTMLDivElement | null>(null);
- const [overlayTopPx, setOverlayTopPx] = useState<number>(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 (
- <div
- ref={rootRef}
- className="bg-white px-6 pt-6 pb-0 rounded-2xl border border-gray-100 hover:shadow-2xl hover:shadow-gray-200/50 transition-all duration-300 group flex flex-col h-full relative overflow-hidden"
- onMouseEnter={() => {
- 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);
- }}
- >
- {/* 右上角多标签显示 */}
- <div className="absolute top-0 right-3 z-20 flex flex-col items-end">
- <div className="flex items-center rounded-bl-xl rounded-tr-lg shadow-md overflow-hidden min-h-[36px]" style={{ clipPath: 'polygon(0 0, 100% 0, 100% 100%, 15% 100%)' }}>
- {shouldShowNew && (
- <div className="bg-orange-500 px-2.5 py-1 flex items-center">
- <span className="text-orange-50 text-[10px] font-bold whitespace-nowrap" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>New</span>
- </div>
- )}
- {versionTag && versionTag !== 'New' && (
- <div className={`bg-purple-500 px-2.5 py-1 flex items-center ${shouldShowNew ? 'ml-0.5' : ''}`}>
- <span className="text-purple-50 text-[10px] font-bold whitespace-nowrap" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>{versionTag}</span>
- </div>
- )}
- {versionTag && versionTag === 'New' && !shouldShowNew && (
- <div className="bg-orange-500 px-2.5 py-1 flex items-center">
- <span className="text-orange-50 text-[10px] font-bold whitespace-nowrap" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>New</span>
- </div>
- )}
- {bailianTag && (
- <div className={`bg-purple-500 px-2.5 py-1 flex items-center ${shouldShowNew ? 'ml-0.5' : ''}`}>
- <span className="text-purple-50 text-[10px] font-bold whitespace-nowrap" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>{bailianTag}</span>
- </div>
- )}
- </div>
- <button
- onClick={() => {
- const detailCode = pricingLookupKey || model.title || model.name;
- if (!detailCode) return;
- navigate(`/models/pricing/${encodeURIComponent(detailCode)}`);
- }}
- className="mt-1 px-3 py-1.5 text-xs text-blue-600 font-medium rounded-lg backdrop-blur-md bg-blue-50/70 border border-blue-100/50 hover:bg-blue-100/80 hover:text-blue-700 hover:border-blue-200/60 transition-all duration-200 shadow-sm z-30"
- >
- 查看详情
- </button>
- </div>
- {/* ‘查看详情’ 按钮已移动到标题容器内以靠近模型名称(见下方) */}
-
- {/* 悬停时覆盖式显示价格与按钮(动态起始位置:基于标题高度,避免遮挡模型名称) */}
- {showOverlay ? (
- <div
- className="absolute left-0 right-0 bottom-0 z-10"
- style={overlayTopPx ? { top: `${overlayTopPx}px`, height: `calc(100% - ${overlayTopPx}px)` } : undefined}
- >
- <div style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }}>
- <div className="h-full bg-white/95 rounded-b-2xl p-4 flex flex-col justify-between overflow-hidden">
- <div className="text-xs text-gray-700 overflow-auto">
- <div className="font-semibold text-gray-900 mb-2 text-xs">价格信息</div>
- {pricingLoading && <div className="text-gray-500 text-xs">加载中...</div>}
- {!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 (
- <ul className="space-y-1.5">
- {showLines.map((line, idx) => (
- <li key={idx}>
- {line.original ? (
- <div className="flex items-center gap-1 flex-wrap">
- <span className="line-through text-gray-400 text-[10px] leading-tight">{line.original}</span>
- {line.discountLabel && (
- <span className="px-1 py-0.5 bg-red-500 text-white text-[9px] font-bold rounded leading-none flex-shrink-0">
- {line.discountLabel}
- </span>
- )}
- <span className="text-blue-600 font-semibold text-[11px] leading-tight">{line.text}</span>
- </div>
- ) : (
- <span className="text-gray-700 text-[11px] leading-tight">{line.text}</span>
- )}
- </li>
- ))}
- <li className="text-[10px] text-gray-400 pt-0.5">点击查看详情了解完整价格</li>
- </ul>
- );
- })() : null}
- {!pricingLoading && !pricingDisplay?.lines?.length && (
- <div className="text-gray-500 text-xs">暂无价格信息</div>
- )}
- </div>
- </div>
- </div>
- </div>
- ) : null}
-
- {/* 左侧图标和标题区域 */}
- <div ref={headerRef} className="flex items-start mb-4 relative z-0 gap-2">
- <div className="flex items-start space-x-4 flex-1 min-w-0 pr-16">
- {/* 图标 */}
- <div ref={iconRef} className="w-14 h-14 bg-purple-50 rounded-xl flex items-center justify-center border border-purple-100 shadow-inner group-hover:scale-110 transition-transform duration-300 flex-shrink-0">
- {bailianData?.image ? (
- <img src={bailianData.image} alt={model.name} className="w-10 h-10 object-contain" />
- ) : model.img ? (
- <img src={model.img} alt={model.name} className="w-10 h-10 object-contain" />
- ) : (
- // 如果没有图片,则根据模型分类显示默认图标
- <div className="w-10 h-10 flex items-center justify-center rounded-lg bg-blue-50">
- {(() => {
- const Icon = getCategoryIcon(model.category);
- return <Icon className="w-6 h-6 text-blue-500" />;
- })()}
- </div>
- )}
- </div>
-
- {/* 标题和版本(包含右侧的“查看详情”按钮) */}
- <div className="flex flex-col flex-1 min-w-0 overflow-hidden relative">
- <div className="flex items-start justify-between gap-3">
- <h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors leading-tight mb-1 truncate flex-1">{mainName}</h3>
- </div>
- {versionInfo && (
- versionDisplayMode === 'bailian' ? (
- <div className="flex items-center gap-1.5 text-xs text-gray-500 whitespace-nowrap min-w-0">
- <FileText className="w-3 h-3 flex-shrink-0" />
- <span className="flex-shrink-0">最新版本</span>
- <span className="text-gray-400 truncate min-w-0">{versionInfo}</span>
- </div>
- ) : (
- <div className="flex items-center gap-1.5 text-sm text-gray-500 whitespace-nowrap min-w-0">
- <FileText className="w-3 h-3 flex-shrink-0" />
- <span className="truncate min-w-0">{versionInfo}</span>
- </div>
- )
- )}
- {/* 查看详情 按钮已移动到标题行最右侧 */}
- </div>
-
- </div>
- {/* 右上角靠左的“查看详情”入口(通过绝对定位显示,避免覆盖最右侧标签) */}
- </div>
- {/* 描述 */}
- <div className="relative mb-4 flex-1">
- <p className="text-sm text-gray-600 leading-relaxed max-h-[4.5rem] overflow-y-auto relative z-0 pr-1">
- {model.description}
- </p>
- </div>
- {/* 底部:关键词和日期 */}
- <div className="flex items-center justify-between pb-4 border-b border-gray-100 relative z-0">
- <div className="flex flex-wrap gap-1.5 flex-1">
- {(model.keyword || '')
- .split('\n')
- .map(t => t.trim())
- .filter(Boolean)
- .map(t => (
- <span key={t} className="px-2.5 py-1 bg-gray-50 text-gray-600 text-[10px] font-medium rounded-md border border-gray-100">
- {t}
- </span>
- ))}
- </div>
- {bailianData?.date && (
- <span className="text-xs text-gray-400 ml-2">{bailianData.date}</span>
- )}
- </div>
- {/* 右下角时间显示 */}
- {model.time && (
- <div className="absolute bottom-4 right-6 text-xs text-gray-400 z-0">
- {model.time}
- </div>
- )}
- </div>
- );
- };
- export default ModelCard;
|