ModelCard.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
  2. import { useNavigate } from 'react-router-dom';
  3. import { Model } from '../types/models';
  4. import { FileText } from '../icons/commonIcons';
  5. import { getCategoryIcon } from '../icons/modelIcons';
  6. import { getPricingMappingLookup, modelApi } from '../services/modelApi';
  7. export interface ModelCardProps {
  8. model: Model;
  9. // 可选的额外数据(用于百炼模型)
  10. bailianData?: {
  11. image?: string;
  12. version?: string;
  13. date?: string;
  14. tag?: string;
  15. };
  16. // 版本信息显示方式:'standard' 或 'bailian'
  17. versionDisplayMode?: 'standard' | 'bailian';
  18. // 可选的名称解析函数(用于百炼模型)
  19. nameParser?: (model: Model, bailianModels?: any[]) => {
  20. mainName: string;
  21. versionInfo: string | null;
  22. };
  23. bailianModels?: any[];
  24. // 悬停延迟(毫秒),如果设置则鼠标悬停超过该时间才显示价格
  25. hoverDelayMs?: number;
  26. }
  27. type PricingLine = {
  28. text: string;
  29. original?: string;
  30. discountLabel?: string; // 如 "8折"
  31. };
  32. type PricingDisplay = {
  33. modelCode: string;
  34. lines: PricingLine[];
  35. };
  36. const pricingCache = new Map<string, PricingDisplay | null>();
  37. const pricingKeyLabels: Record<string, string> = {
  38. input: '输入',
  39. output: '输出',
  40. input_thinking: '输入(思考)',
  41. output_thinking: '输出(思考)',
  42. input_cache_hit: '输入(缓存命中)',
  43. input_cache_hit_thinking: '输入(缓存命中·思考)',
  44. input_batch: '输入(批量)',
  45. input_batch_thinking: '输入(批量·思考)',
  46. output_batch: '输出(批量)',
  47. output_thinking_batch: '输出(批量·思考)',
  48. cache_write: '缓存写入',
  49. cache_read: '缓存读取',
  50. explicit_cache_create: '显式缓存写入',
  51. explicit_cache_hit: '显式缓存命中',
  52. explicit_cache_create_thinking: '显式缓存写入(思考)',
  53. explicit_cache_hit_thinking: '显式缓存命中(思考)',
  54. image_input: '图片输入',
  55. text_input: '文本输入'
  56. };
  57. const categoryKeyLabels: Record<string, string> = {
  58. image_generation: '图片生成',
  59. video_generation: '视频生成',
  60. audio_generation: '音频生成',
  61. text_generation: '文本生成',
  62. embedding: '向量生成'
  63. };
  64. const normalizeMappingKey = (value: string): string => {
  65. return value
  66. .replace(/[-–—‑]/g, '-')
  67. .replace(/[\u200B-\u200D\uFEFF]/g, '')
  68. .replace(/\s*-\s*/g, '-')
  69. .replace(/\s+/g, ' ')
  70. .trim();
  71. };
  72. const formatPricingLines = (pricing: any): PricingLine[] => {
  73. if (!pricing) return [];
  74. const formatPrice = (price: number, unit?: string): number => {
  75. const unitStr = (unit || '').toLowerCase();
  76. const isTokenUnit = unitStr.includes('token') || unitStr.includes('百万') || unitStr.includes('million');
  77. if (isTokenUnit && price < 0.01) return price * 1000;
  78. return price;
  79. };
  80. const makeLine = (label: string, price: number, originalPrice: number | undefined, unit: string, discountRate?: number): PricingLine => {
  81. const discounted = formatPrice(price, unit);
  82. const hasDiscount = originalPrice !== undefined && originalPrice > 0 && Math.abs(originalPrice - price) > 0.000001;
  83. const original = hasDiscount ? `${formatPrice(originalPrice!, unit)}` : undefined;
  84. // 折扣标签:discount_rate 是 0~1,如 0.1 = 1折
  85. const discountLabel = hasDiscount && discountRate && discountRate < 1
  86. ? `${Math.round(discountRate * 10)}折`
  87. : hasDiscount
  88. ? (() => {
  89. const rate = originalPrice && originalPrice > 0 ? price / originalPrice : 1;
  90. const tenths = Math.round(rate * 10);
  91. return tenths < 10 ? `${tenths}折` : undefined;
  92. })()
  93. : undefined;
  94. return {
  95. text: `${label}:${discounted}${unit}`,
  96. original: original ? `${label}:${original}${unit}` : undefined,
  97. discountLabel,
  98. };
  99. };
  100. const normalizeUnit = (u: string) => u.includes('token') && !u.includes('tokens') ? u.replace('token', 'tokens') : u;
  101. if (Array.isArray(pricing)) {
  102. // tier 数组:取第一个有效 tier 的输入/输出价格展示,忽略 input_range 技术细节
  103. const lines: PricingLine[] = [];
  104. const firstTier = pricing.find(i => i !== null && i !== undefined) as any;
  105. if (!firstTier || typeof firstTier !== 'object') return [];
  106. const unit = firstTier.unit ? ` ${normalizeUnit(firstTier.unit)}` : '';
  107. // 如果是 tier 格式(有 input_range),直接取 input/output
  108. if (firstTier.input_range || firstTier.range || firstTier.inputRange) {
  109. if (firstTier.input !== undefined && firstTier.input !== null) {
  110. lines.push(makeLine('输入', firstTier.input, firstTier.input_original, unit));
  111. }
  112. if (firstTier.output !== undefined && firstTier.output !== null && firstTier.output > 0) {
  113. lines.push(makeLine('输出', firstTier.output, firstTier.output_original, unit));
  114. }
  115. return lines;
  116. }
  117. const priceVal = firstTier.price ?? firstTier.amount ?? firstTier.value;
  118. if (firstTier.item && priceVal !== undefined) {
  119. lines.push(makeLine(firstTier.item, priceVal, firstTier.price_original, unit));
  120. return lines;
  121. }
  122. const handled = new Set([...Object.keys(pricingKeyLabels), 'unit', 'input_range', 'range', 'inputRange',
  123. 'input_original', 'output_original', 'discount_rate', 'price_original']);
  124. Object.keys(pricingKeyLabels).forEach(key => {
  125. if (item[key] !== undefined && item[key] !== null) {
  126. const origKey = `${key}_original`;
  127. lines.push(makeLine(pricingKeyLabels[key], item[key], item[origKey], item.unit ? ` ${normalizeUnit(item.unit)}` : unit, item.discount_rate));
  128. handled.add(key);
  129. }
  130. });
  131. return lines;
  132. }
  133. if (typeof pricing === 'object') {
  134. const unit = pricing.unit ? ` ${normalizeUnit(pricing.unit)}` : '';
  135. const lines: PricingLine[] = [];
  136. const simplePriceVal = pricing.price ?? pricing.amount ?? pricing.value;
  137. if (pricing.item && simplePriceVal !== undefined) {
  138. lines.push(makeLine(pricing.item, simplePriceVal, pricing.price_original, unit));
  139. return lines;
  140. }
  141. const handled = new Set([...Object.keys(pricingKeyLabels), 'unit', 'input_original', 'output_original', 'discount_rate']);
  142. Object.keys(pricingKeyLabels).forEach(key => {
  143. if (pricing[key] !== undefined && pricing[key] !== null) {
  144. const origKey = `${key}_original`;
  145. lines.push(makeLine(pricingKeyLabels[key], pricing[key], pricing[origKey], unit, pricing.discount_rate));
  146. handled.add(key);
  147. }
  148. });
  149. Object.entries(pricing).forEach(([key, value]) => {
  150. if (handled.has(key)) return;
  151. if (Array.isArray(value)) {
  152. value.forEach(item => {
  153. if (!item) return;
  154. const label = item.item || categoryKeyLabels[key] || key;
  155. const price = item.price ?? item.amount ?? item.value;
  156. const unitText = item.unit ? ` ${normalizeUnit(item.unit)}` : unit;
  157. if (price === undefined || price === null) return;
  158. lines.push(makeLine(label, price, item.price_original, unitText));
  159. });
  160. }
  161. });
  162. return lines;
  163. }
  164. return [];
  165. };
  166. const ModelCard: React.FC<ModelCardProps> = ({
  167. model,
  168. bailianData,
  169. versionDisplayMode = 'standard',
  170. nameParser,
  171. bailianModels,
  172. hoverDelayMs
  173. }) => {
  174. const [pricingDisplay, setPricingDisplay] = useState<PricingDisplay | null | undefined>(undefined);
  175. const [pricingLoading, setPricingLoading] = useState(false);
  176. const [showOverlay, setShowOverlay] = useState(false);
  177. const hoverTimerRef = useRef<number | null>(null);
  178. const headerRef = useRef<HTMLDivElement | null>(null);
  179. const iconRef = useRef<HTMLDivElement | null>(null);
  180. const rootRef = useRef<HTMLDivElement | null>(null);
  181. const [overlayTopPx, setOverlayTopPx] = useState<number>(0);
  182. const navigate = useNavigate();
  183. // 解析模型名称和版本信息
  184. let mainName = model.name;
  185. let versionInfo: string | null = null;
  186. if (nameParser) {
  187. const parsed = nameParser(model, bailianModels);
  188. mainName = parsed.mainName;
  189. versionInfo = parsed.versionInfo;
  190. } else if (versionDisplayMode === 'standard') {
  191. // 标准模式:解析模型名称,支持换行显示
  192. // 格式:主名称\n最新版本\n版本号
  193. const nameParts = model.name.split('\n');
  194. mainName = nameParts[0] || model.name;
  195. const versionLabel = nameParts[1] || null;
  196. const versionCode = nameParts[2] || null;
  197. versionInfo = versionLabel && versionCode ? `${versionLabel} ${versionCode}` : (versionLabel || versionCode || null);
  198. } else if (versionDisplayMode === 'bailian' && bailianData) {
  199. // 百炼模式:从 bailianData 获取版本信息
  200. const nameParts = bailianModels?.find((b: any) => b.标题 === model.id)?.名称.split('\n') || [];
  201. const version = nameParts[2] || bailianData.version || '';
  202. versionInfo = version || null;
  203. }
  204. // 处理标签显示逻辑(根据 tag1 / tag2 推断“New”)
  205. const shouldShowNew = model.tag1 === 'New' || model.tag2 === 'New';
  206. const versionTag = model.tag2;
  207. // 百炼模式的标签处理
  208. const bailianTag = bailianData?.tag && bailianData.tag !== '最新版本' && bailianData.tag !== 'New' ? bailianData.tag : null;
  209. const pricingLookupKey = useMemo(() => {
  210. // 优先使用model.title作为查询键,因为后端存储的是title
  211. if (model.title) {
  212. const normalizedTitle = normalizeMappingKey(model.title);
  213. console.log('Using model.title as lookup key:', normalizedTitle);
  214. return normalizedTitle;
  215. }
  216. const normalizedName = normalizeMappingKey(mainName || '');
  217. if (normalizedName) {
  218. console.log('Using mainName as lookup key:', normalizedName);
  219. return normalizedName;
  220. }
  221. const name = model.name || '';
  222. const nameParts = name
  223. .split('\n')
  224. .map((part) => part.trim())
  225. .filter(Boolean);
  226. const latestIndex = nameParts.findIndex((part) => part === '最新版本');
  227. if (latestIndex !== -1 && nameParts[latestIndex + 1]) {
  228. const versionKey = normalizeMappingKey(nameParts[latestIndex + 1]);
  229. console.log('Using version from name as lookup key:', versionKey);
  230. return versionKey;
  231. }
  232. const inlineLatest = nameParts.find((part) => part.startsWith('最新版本'));
  233. if (inlineLatest) {
  234. const match = inlineLatest.match(/最新版本\s*([A-Za-z0-9._-]+)/);
  235. if (match?.[1]) {
  236. const matchKey = normalizeMappingKey(match[1]);
  237. console.log('Using matched version as lookup key:', matchKey);
  238. return matchKey;
  239. }
  240. }
  241. const fallbackKey = normalizeMappingKey(model.title || '');
  242. console.log('Using fallback key:', fallbackKey);
  243. return fallbackKey;
  244. }, [mainName, model.name, model.title]);
  245. useEffect(() => {
  246. setPricingDisplay(undefined);
  247. }, [pricingLookupKey]);
  248. useEffect(() => {
  249. return () => {
  250. if (hoverTimerRef.current) {
  251. window.clearTimeout(hoverTimerRef.current);
  252. hoverTimerRef.current = null;
  253. }
  254. };
  255. }, []);
  256. useLayoutEffect(() => {
  257. const compute = () => {
  258. const headerEl = headerRef.current;
  259. const iconEl = iconRef.current;
  260. const rootEl = rootRef.current;
  261. if (headerEl && rootEl) {
  262. const headerRect = headerEl.getBoundingClientRect();
  263. const rootRect = rootEl.getBoundingClientRect();
  264. // 以标题底部或图标底部为基准,优先保证覆盖从图标下方开始
  265. let topPx = headerRect.bottom - rootRect.top;
  266. if (iconEl) {
  267. const iconRect = iconEl.getBoundingClientRect();
  268. topPx = Math.max(topPx, iconRect.bottom - rootRect.top + 12); // 保证距图标底部至少 12px
  269. }
  270. // 最小顶部留白,避免 overlay 紧贴边缘;提高到 80px 以避免覆盖图标
  271. topPx = Math.max(topPx, 80);
  272. setOverlayTopPx(topPx);
  273. } else if (headerEl) {
  274. setOverlayTopPx(headerEl.offsetHeight + 8);
  275. }
  276. };
  277. compute();
  278. const onResize = () => compute();
  279. window.addEventListener('resize', onResize);
  280. return () => window.removeEventListener('resize', onResize);
  281. }, [headerRef, rootRef]);
  282. const loadPricing = useCallback(async () => {
  283. if (!pricingLookupKey) {
  284. console.log('No pricingLookupKey, setting display to null');
  285. setPricingDisplay(null);
  286. return;
  287. }
  288. if (pricingDisplay !== undefined && pricingDisplay !== null) {
  289. console.log('Pricing display already set, returning');
  290. return;
  291. }
  292. if (pricingCache.has(pricingLookupKey)) {
  293. const cached = pricingCache.get(pricingLookupKey);
  294. if (cached) {
  295. console.log('Using cached pricing:', cached);
  296. setPricingDisplay(cached);
  297. return;
  298. }
  299. }
  300. try {
  301. setPricingLoading(true);
  302. console.log('Loading pricing for:', pricingLookupKey);
  303. let resolvedCode = pricingLookupKey;
  304. try {
  305. const mapping = await getPricingMappingLookup();
  306. resolvedCode = mapping.get(pricingLookupKey) || pricingLookupKey;
  307. console.log('Resolved code:', resolvedCode);
  308. } catch (mappingError) {
  309. console.log('Mapping error:', mappingError);
  310. resolvedCode = pricingLookupKey;
  311. }
  312. console.log('Fetching pricing for:', resolvedCode);
  313. const response = await modelApi.getParsedPricing(resolvedCode);
  314. console.log('Response:', response);
  315. const pricing = response?.data?.model_pricing;
  316. console.log('Pricing data:', pricing);
  317. const lines = formatPricingLines(pricing);
  318. console.log('Formatted lines:', lines);
  319. const display = lines.length ? { modelCode: resolvedCode, lines } : null;
  320. console.log('Display data:', display);
  321. pricingCache.set(pricingLookupKey, display);
  322. setPricingDisplay(display);
  323. } catch (error) {
  324. console.log('Error loading pricing:', error);
  325. setPricingDisplay(null);
  326. } finally {
  327. setPricingLoading(false);
  328. }
  329. }, [pricingDisplay, pricingLookupKey]);
  330. const pricingMaxLines = 4;
  331. // 取消悬停延迟,立即显示价格信息(去掉延迟动画)
  332. const hoverDelay = 0;
  333. return (
  334. <div
  335. ref={rootRef}
  336. 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"
  337. onMouseEnter={() => {
  338. if (hoverTimerRef.current) {
  339. window.clearTimeout(hoverTimerRef.current);
  340. hoverTimerRef.current = null;
  341. }
  342. if (hoverDelay <= 0) {
  343. setShowOverlay(true);
  344. loadPricing();
  345. } else {
  346. hoverTimerRef.current = window.setTimeout(() => {
  347. setShowOverlay(true);
  348. loadPricing();
  349. hoverTimerRef.current = null;
  350. }, hoverDelay);
  351. }
  352. }}
  353. onMouseLeave={() => {
  354. if (hoverTimerRef.current) {
  355. window.clearTimeout(hoverTimerRef.current);
  356. hoverTimerRef.current = null;
  357. }
  358. setShowOverlay(false);
  359. }}
  360. >
  361. {/* 右上角多标签显示 */}
  362. <div className="absolute top-0 right-3 z-20 flex flex-col items-end">
  363. <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%)' }}>
  364. {shouldShowNew && (
  365. <div className="bg-orange-500 px-2.5 py-1 flex items-center">
  366. <span className="text-orange-50 text-[10px] font-bold whitespace-nowrap" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>New</span>
  367. </div>
  368. )}
  369. {versionTag && versionTag !== 'New' && (
  370. <div className={`bg-purple-500 px-2.5 py-1 flex items-center ${shouldShowNew ? 'ml-0.5' : ''}`}>
  371. <span className="text-purple-50 text-[10px] font-bold whitespace-nowrap" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>{versionTag}</span>
  372. </div>
  373. )}
  374. {versionTag && versionTag === 'New' && !shouldShowNew && (
  375. <div className="bg-orange-500 px-2.5 py-1 flex items-center">
  376. <span className="text-orange-50 text-[10px] font-bold whitespace-nowrap" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>New</span>
  377. </div>
  378. )}
  379. {bailianTag && (
  380. <div className={`bg-purple-500 px-2.5 py-1 flex items-center ${shouldShowNew ? 'ml-0.5' : ''}`}>
  381. <span className="text-purple-50 text-[10px] font-bold whitespace-nowrap" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}>{bailianTag}</span>
  382. </div>
  383. )}
  384. </div>
  385. <button
  386. onClick={() => {
  387. const detailCode = pricingLookupKey || model.title || model.name;
  388. if (!detailCode) return;
  389. navigate(`/models/pricing/${encodeURIComponent(detailCode)}`);
  390. }}
  391. 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"
  392. >
  393. 查看详情
  394. </button>
  395. </div>
  396. {/* ‘查看详情’ 按钮已移动到标题容器内以靠近模型名称(见下方) */}
  397. {/* 悬停时覆盖式显示价格与按钮(动态起始位置:基于标题高度,避免遮挡模型名称) */}
  398. {showOverlay ? (
  399. <div
  400. className="absolute left-0 right-0 bottom-0 z-10"
  401. style={overlayTopPx ? { top: `${overlayTopPx}px`, height: `calc(100% - ${overlayTopPx}px)` } : undefined}
  402. >
  403. <div style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }}>
  404. <div className="h-full bg-white/95 rounded-b-2xl p-4 flex flex-col justify-between overflow-hidden">
  405. <div className="text-xs text-gray-700 overflow-auto">
  406. <div className="font-semibold text-gray-900 mb-2 text-xs">价格信息</div>
  407. {pricingLoading && <div className="text-gray-500 text-xs">加载中...</div>}
  408. {!pricingLoading && pricingDisplay?.lines?.length ? (() => {
  409. // 只取有折扣的行,或第一条有价格的行,做简洁展示
  410. const discountLines = pricingDisplay.lines.filter(l => l.original);
  411. const showLines = discountLines.length > 0
  412. ? discountLines.slice(0, 2)
  413. : pricingDisplay.lines.filter(l => !l.text.includes('输入范围')).slice(0, 2);
  414. return (
  415. <ul className="space-y-1.5">
  416. {showLines.map((line, idx) => (
  417. <li key={idx}>
  418. {line.original ? (
  419. <div className="flex items-center gap-1 flex-wrap">
  420. <span className="line-through text-gray-400 text-[10px] leading-tight">{line.original}</span>
  421. {line.discountLabel && (
  422. <span className="px-1 py-0.5 bg-red-500 text-white text-[9px] font-bold rounded leading-none flex-shrink-0">
  423. {line.discountLabel}
  424. </span>
  425. )}
  426. <span className="text-blue-600 font-semibold text-[11px] leading-tight">{line.text}</span>
  427. </div>
  428. ) : (
  429. <span className="text-gray-700 text-[11px] leading-tight">{line.text}</span>
  430. )}
  431. </li>
  432. ))}
  433. <li className="text-[10px] text-gray-400 pt-0.5">点击查看详情了解完整价格</li>
  434. </ul>
  435. );
  436. })() : null}
  437. {!pricingLoading && !pricingDisplay?.lines?.length && (
  438. <div className="text-gray-500 text-xs">暂无价格信息</div>
  439. )}
  440. </div>
  441. </div>
  442. </div>
  443. </div>
  444. ) : null}
  445. {/* 左侧图标和标题区域 */}
  446. <div ref={headerRef} className="flex items-start mb-4 relative z-0 gap-2">
  447. <div className="flex items-start space-x-4 flex-1 min-w-0 pr-16">
  448. {/* 图标 */}
  449. <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">
  450. {bailianData?.image ? (
  451. <img src={bailianData.image} alt={model.name} className="w-10 h-10 object-contain" />
  452. ) : model.img ? (
  453. <img src={model.img} alt={model.name} className="w-10 h-10 object-contain" />
  454. ) : (
  455. // 如果没有图片,则根据模型分类显示默认图标
  456. <div className="w-10 h-10 flex items-center justify-center rounded-lg bg-blue-50">
  457. {(() => {
  458. const Icon = getCategoryIcon(model.category);
  459. return <Icon className="w-6 h-6 text-blue-500" />;
  460. })()}
  461. </div>
  462. )}
  463. </div>
  464. {/* 标题和版本(包含右侧的“查看详情”按钮) */}
  465. <div className="flex flex-col flex-1 min-w-0 overflow-hidden relative">
  466. <div className="flex items-start justify-between gap-3">
  467. <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>
  468. </div>
  469. {versionInfo && (
  470. versionDisplayMode === 'bailian' ? (
  471. <div className="flex items-center gap-1.5 text-xs text-gray-500 whitespace-nowrap min-w-0">
  472. <FileText className="w-3 h-3 flex-shrink-0" />
  473. <span className="flex-shrink-0">最新版本</span>
  474. <span className="text-gray-400 truncate min-w-0">{versionInfo}</span>
  475. </div>
  476. ) : (
  477. <div className="flex items-center gap-1.5 text-sm text-gray-500 whitespace-nowrap min-w-0">
  478. <FileText className="w-3 h-3 flex-shrink-0" />
  479. <span className="truncate min-w-0">{versionInfo}</span>
  480. </div>
  481. )
  482. )}
  483. {/* 查看详情 按钮已移动到标题行最右侧 */}
  484. </div>
  485. </div>
  486. {/* 右上角靠左的“查看详情”入口(通过绝对定位显示,避免覆盖最右侧标签) */}
  487. </div>
  488. {/* 描述 */}
  489. <div className="relative mb-4 flex-1">
  490. <p className="text-sm text-gray-600 leading-relaxed max-h-[4.5rem] overflow-y-auto relative z-0 pr-1">
  491. {model.description}
  492. </p>
  493. </div>
  494. {/* 底部:关键词和日期 */}
  495. <div className="flex items-center justify-between pb-4 border-b border-gray-100 relative z-0">
  496. <div className="flex flex-wrap gap-1.5 flex-1">
  497. {(model.keyword || '')
  498. .split('\n')
  499. .map(t => t.trim())
  500. .filter(Boolean)
  501. .map(t => (
  502. <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">
  503. {t}
  504. </span>
  505. ))}
  506. </div>
  507. {bailianData?.date && (
  508. <span className="text-xs text-gray-400 ml-2">{bailianData.date}</span>
  509. )}
  510. </div>
  511. {/* 右下角时间显示 */}
  512. {model.time && (
  513. <div className="absolute bottom-4 right-6 text-xs text-gray-400 z-0">
  514. {model.time}
  515. </div>
  516. )}
  517. </div>
  518. );
  519. };
  520. export default ModelCard;