// src/views/Community/components/DynamicNews/layouts/MainlineTimelineView.js // 主线时间轴布局组件 - 按 lv1/lv2 概念分组展示事件(横向滚动布局) import React, { useState, useEffect, forwardRef, useImperativeHandle, useCallback, useMemo, } from "react"; import { Box, VStack, HStack, Text, Badge, Flex, Icon, Spinner, Center, IconButton, Tooltip, Button, } from "@chakra-ui/react"; import { ChevronDownIcon, ChevronUpIcon, RepeatIcon } from "@chakra-ui/icons"; import { FiTrendingUp, FiZap } from "react-icons/fi"; import { FireOutlined } from "@ant-design/icons"; import dayjs from "dayjs"; import { Select } from "antd"; import { getApiBase } from "@utils/apiConfig"; import { getChangeColor } from "@utils/colorUtils"; import "../../SearchFilters/CompactSearchBox.css"; // 固定深色主题颜色 const COLORS = { containerBg: "#1a1d24", cardBg: "#252a34", cardBorderColor: "#3a3f4b", headerHoverBg: "#2d323e", textColor: "#e2e8f0", secondaryTextColor: "#a0aec0", scrollbarTrackBg: "#2d3748", scrollbarThumbBg: "#718096", scrollbarThumbHoverBg: "#a0aec0", statBarBg: "#252a34", }; // 每次加载的事件数量 const EVENTS_PER_LOAD = 12; /** * 格式化时间显示 - 始终显示日期,避免跨天混淆 */ const formatEventTime = (dateStr) => { if (!dateStr) return ""; const date = dayjs(dateStr); const now = dayjs(); const isToday = date.isSame(now, "day"); const isYesterday = date.isSame(now.subtract(1, "day"), "day"); // 始终显示日期,用标签区分今天/昨天 if (isToday) { return `今天 ${date.format("MM-DD HH:mm")}`; } else if (isYesterday) { return `昨天 ${date.format("MM-DD HH:mm")}`; } else { return date.format("MM-DD HH:mm"); } }; /** * 根据涨跌幅获取背景色 */ const getChangeBgColor = (value) => { if (value == null || isNaN(value)) return "transparent"; const absChange = Math.abs(value); if (value > 0) { if (absChange >= 5) return "rgba(239, 68, 68, 0.12)"; if (absChange >= 3) return "rgba(239, 68, 68, 0.08)"; return "rgba(239, 68, 68, 0.05)"; } else if (value < 0) { if (absChange >= 5) return "rgba(16, 185, 129, 0.12)"; if (absChange >= 3) return "rgba(16, 185, 129, 0.08)"; return "rgba(16, 185, 129, 0.05)"; } return "transparent"; }; /** * 单个事件项组件 - 卡片式布局 */ const TimelineEventItem = React.memo(({ event, isSelected, onEventClick }) => { // 使用 related_max_chg 作为主要涨幅显示 const maxChange = event.related_max_chg; const avgChange = event.related_avg_chg; const hasMaxChange = maxChange != null && !isNaN(maxChange); const hasAvgChange = avgChange != null && !isNaN(avgChange); // 用于背景色的涨幅(使用平均超额) const bgValue = avgChange; return ( onEventClick?.(event)} bg={isSelected ? "rgba(66, 153, 225, 0.15)" : getChangeBgColor(bgValue)} borderWidth="1px" borderColor={isSelected ? "#4299e1" : COLORS.cardBorderColor} borderRadius="lg" p={3} mb={2} _hover={{ bg: isSelected ? "rgba(66, 153, 225, 0.2)" : "rgba(255, 255, 255, 0.06)", borderColor: isSelected ? "#63b3ed" : "#5a6070", transform: "translateY(-1px)", }} transition="all 0.2s ease" > {/* 第一行:时间 */} {formatEventTime(event.created_at || event.event_time)} {/* 第二行:标题 */} {event.title} {/* 第三行:涨跌幅指标 */} {(hasMaxChange || hasAvgChange) && ( {/* 最大超额 */} {hasMaxChange && ( 0 ? "rgba(239, 68, 68, 0.15)" : "rgba(16, 185, 129, 0.15)"} borderWidth="1px" borderColor={maxChange > 0 ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 185, 129, 0.3)"} borderRadius="md" px={2} py={1} > 最大超额 0 ? "#fc8181" : "#68d391"} > {maxChange > 0 ? "+" : ""}{maxChange.toFixed(2)}% )} {/* 平均超额 */} {hasAvgChange && ( 0 ? "rgba(239, 68, 68, 0.15)" : "rgba(16, 185, 129, 0.15)"} borderWidth="1px" borderColor={avgChange > 0 ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 185, 129, 0.3)"} borderRadius="md" px={2} py={1} > 平均超额 0 ? "#fc8181" : "#68d391"} > {avgChange > 0 ? "+" : ""}{avgChange.toFixed(2)}% )} {/* 超预期得分 */} {event.expectation_surprise_score != null && ( = 60 ? "rgba(239, 68, 68, 0.15)" : event.expectation_surprise_score >= 40 ? "rgba(237, 137, 54, 0.15)" : "rgba(66, 153, 225, 0.15)"} borderWidth="1px" borderColor={event.expectation_surprise_score >= 60 ? "rgba(239, 68, 68, 0.3)" : event.expectation_surprise_score >= 40 ? "rgba(237, 137, 54, 0.3)" : "rgba(66, 153, 225, 0.3)"} borderRadius="md" px={2} py={1} > 超预期 = 60 ? "#fc8181" : event.expectation_surprise_score >= 40 ? "#ed8936" : "#63b3ed"} > {Math.round(event.expectation_surprise_score)}分 )} )} ); }); TimelineEventItem.displayName = "TimelineEventItem"; /** * 单个主线卡片组件 - 支持懒加载 */ const MainlineCard = React.memo( ({ mainline, colorScheme, isExpanded, onToggle, selectedEvent, onEventSelect, }) => { // 懒加载状态 const [displayCount, setDisplayCount] = useState(EVENTS_PER_LOAD); const [isLoadingMore, setIsLoadingMore] = useState(false); // 重置显示数量当折叠时 useEffect(() => { if (!isExpanded) { setDisplayCount(EVENTS_PER_LOAD); } }, [isExpanded]); // 找出最大超额涨幅最高的事件(HOT 事件) const hotEvent = useMemo(() => { if (!mainline.events || mainline.events.length === 0) return null; let maxChange = -Infinity; let hot = null; mainline.events.forEach((event) => { // 统一使用 related_max_chg(最大超额) const change = event.related_max_chg ?? -Infinity; if (change > maxChange) { maxChange = change; hot = event; } }); // 只有当最大超额 > 0 时才显示 HOT return maxChange > 0 ? hot : null; }, [mainline.events]); // 当前显示的事件 const displayedEvents = useMemo(() => { return mainline.events.slice(0, displayCount); }, [mainline.events, displayCount]); // 是否还有更多 const hasMore = displayCount < mainline.events.length; // 加载更多 const loadMore = useCallback( (e) => { e.stopPropagation(); setIsLoadingMore(true); setTimeout(() => { setDisplayCount((prev) => Math.min(prev + EVENTS_PER_LOAD, mainline.events.length) ); setIsLoadingMore(false); }, 50); }, [mainline.events.length] ); return ( {/* 卡片头部 */} {/* 第一行:概念名称 + 涨跌幅 + 事件数 */} {mainline.group_name || mainline.lv2_name || mainline.lv1_name || "其他"} {/* 涨跌幅显示 - 在概念名称旁边 */} {mainline.avg_change_pct != null && ( = 0 ? "#fc8181" : "#68d391"} flexShrink={0} > {mainline.avg_change_pct >= 0 ? "+" : ""} {mainline.avg_change_pct.toFixed(2)}% )} {mainline.event_count} {/* 显示上级概念名称作为副标题 */} {mainline.parent_name && ( {mainline.grandparent_name ? `${mainline.grandparent_name} > ` : ""} {mainline.parent_name} )} {/* HOT 事件展示区域 */} {hotEvent && ( { e.stopPropagation(); onEventSelect?.(hotEvent); }} _hover={{ bg: "rgba(245, 101, 101, 0.18)" }} transition="all 0.15s" > {/* 第一行:HOT 标签 + 最大超额 */} HOT {/* 最大超额涨幅 */} {hotEvent.related_max_chg != null && ( 最大超额 +{hotEvent.related_max_chg.toFixed(2)}% )} {/* 第二行:标题 */} {hotEvent.title} )} {/* 事件列表区域 */} {isExpanded ? ( {/* 事件列表 - 卡片式 */} {displayedEvents.map((event) => ( ))} {/* 加载更多按钮 */} {hasMore && ( )} ) : ( /* 折叠时显示简要信息 - 卡片式 */ {mainline.events.slice(0, 3).map((event) => ( ))} {mainline.events.length > 3 && ( ... 还有 {mainline.events.length - 3} 条 )} )} ); } ); MainlineCard.displayName = "MainlineCard"; /** * 主线时间轴布局组件 */ const MainlineTimelineViewComponent = forwardRef( ( { display = "block", filters = {}, selectedEvent, onEventSelect, eventFollowStatus = {}, onToggleFollow, borderColor, }, ref ) => { // 状态 const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [mainlineData, setMainlineData] = useState(null); const [expandedGroups, setExpandedGroups] = useState({}); // 概念级别选择: 'lv1' | 'lv2' | 'lv3' | 具体概念ID(如 L1_TMT, L2_AI_INFRA, L3_AI_CHIP) const [groupBy, setGroupBy] = useState("lv2"); // 层级选项(从 API 获取) const [hierarchyOptions, setHierarchyOptions] = useState({ lv1: [], lv2: [], lv3: [] }); // 排序方式: 'event_count' | 'change_desc' | 'change_asc' const [sortBy, setSortBy] = useState("event_count"); // 根据主线类型获取配色 const getColorScheme = useCallback((lv2Name) => { if (!lv2Name) return "gray"; const name = lv2Name.toLowerCase(); if ( name.includes("ai") || name.includes("人工智能") || name.includes("算力") || name.includes("大模型") ) return "purple"; if ( name.includes("半导体") || name.includes("芯片") || name.includes("光刻") ) return "blue"; if (name.includes("机器人") || name.includes("人形")) return "pink"; if ( name.includes("消费电子") || name.includes("手机") || name.includes("xr") ) return "cyan"; if ( name.includes("汽车") || name.includes("驾驶") || name.includes("新能源车") ) return "teal"; if ( name.includes("新能源") || name.includes("电力") || name.includes("光伏") || name.includes("储能") ) return "green"; if ( name.includes("低空") || name.includes("航天") || name.includes("卫星") ) return "orange"; if (name.includes("军工") || name.includes("国防")) return "red"; if ( name.includes("医药") || name.includes("医疗") || name.includes("生物") ) return "messenger"; if ( name.includes("消费") || name.includes("食品") || name.includes("白酒") ) return "yellow"; if ( name.includes("煤炭") || name.includes("石油") || name.includes("钢铁") ) return "blackAlpha"; if ( name.includes("金融") || name.includes("银行") || name.includes("券商") ) return "linkedin"; return "gray"; }, []); // 加载主线数据 const fetchMainlineData = useCallback(async () => { if (display === "none") return; setLoading(true); setError(null); try { const apiBase = getApiBase(); const params = new URLSearchParams(); // 添加筛选参数(主线模式支持时间范围筛选) // 优先使用精确时间范围(start_date/end_date),其次使用 recent_days if (filters.start_date) { params.append("start_date", filters.start_date); } if (filters.end_date) { params.append("end_date", filters.end_date); } if (filters.recent_days && !filters.start_date && !filters.end_date) { // 只有在没有精确时间范围时才使用 recent_days params.append("recent_days", filters.recent_days); } // 添加分组方式参数 params.append("group_by", groupBy); const url = `${apiBase}/api/events/mainline?${params.toString()}`; const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); // 兼容两种响应格式:{ success, data: {...} } 或 { success, mainlines, ... } const responseData = result.data || result; if (result.success) { // 保存原始数据,排序在渲染时根据 sortBy 状态进行 setMainlineData(responseData); // 保存层级选项供下拉框使用 if (responseData.hierarchy_options) { setHierarchyOptions(responseData.hierarchy_options); } // 初始化展开状态(默认全部展开) const initialExpanded = {}; (responseData.mainlines || []).forEach((mainline) => { const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id; initialExpanded[groupId] = true; }); setExpandedGroups(initialExpanded); } else { throw new Error(result.error || "获取数据失败"); } } catch (err) { console.error("[MainlineTimelineView] ❌ 请求失败:", err); setError(err.message); } finally { setLoading(false); } }, [display, filters.start_date, filters.end_date, filters.recent_days, groupBy]); // 初始加载 & 筛选变化时刷新 useEffect(() => { fetchMainlineData(); }, [fetchMainlineData]); // 暴露方法给父组件 useImperativeHandle( ref, () => ({ refresh: fetchMainlineData, getScrollPosition: () => null, }), [fetchMainlineData] ); // 切换分组展开/折叠 const toggleGroup = useCallback((lv2Id) => { setExpandedGroups((prev) => ({ ...prev, [lv2Id]: !prev[lv2Id], })); }, []); // 全部展开/折叠 const toggleAll = useCallback( (expand) => { if (!mainlineData?.mainlines) return; const newState = {}; mainlineData.mainlines.forEach((mainline) => { newState[mainline.lv2_id] = expand; }); setExpandedGroups(newState); }, [mainlineData] ); // 根据排序方式排序主线列表(必须在条件渲染之前,遵循 Hooks 规则) const sortedMainlines = useMemo(() => { const rawMainlines = mainlineData?.mainlines; if (!rawMainlines) return []; const sorted = [...rawMainlines]; switch (sortBy) { case "change_desc": // 按涨跌幅从高到低(涨幅大的在前) return sorted.sort((a, b) => (b.avg_change_pct ?? -999) - (a.avg_change_pct ?? -999)); case "change_asc": // 按涨跌幅从低到高(跌幅大的在前) return sorted.sort((a, b) => (a.avg_change_pct ?? 999) - (b.avg_change_pct ?? 999)); case "event_count": default: // 按事件数量从多到少 return sorted.sort((a, b) => b.event_count - a.event_count); } }, [mainlineData?.mainlines, sortBy]); // 渲染加载状态 if (loading) { return (
正在加载主线数据...
); } // 渲染错误状态 if (error) { return (
加载失败: {error} } colorScheme="blue" onClick={fetchMainlineData} aria-label="重试" />
); } // 渲染空状态 if (!mainlineData?.mainlines?.length) { return (
暂无主线数据 尝试调整筛选条件
); } const { total_events, mainline_count, ungrouped_count, } = mainlineData; // 使用排序后的主线列表 const mainlines = sortedMainlines; return ( {/* 顶部统计栏 - 固定不滚动 */} {mainline_count} 条主线 共 {total_events} 个事件 {ungrouped_count > 0 && ( {ungrouped_count} 个未归类 )} {/* 概念级别选择器 */} } size="sm" variant="ghost" color={COLORS.secondaryTextColor} onClick={() => toggleAll(true)} aria-label="全部展开" _hover={{ bg: COLORS.headerHoverBg }} /> } size="sm" variant="ghost" color={COLORS.secondaryTextColor} onClick={() => toggleAll(false)} aria-label="全部折叠" _hover={{ bg: COLORS.headerHoverBg }} /> } size="sm" variant="ghost" color={COLORS.secondaryTextColor} onClick={fetchMainlineData} aria-label="刷新" _hover={{ bg: COLORS.headerHoverBg }} /> {/* 横向滚动容器 - 滚动条在卡片上方 */} {/* 主线卡片横向排列容器 */} {mainlines.map((mainline) => { const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id || "ungrouped"; const groupName = mainline.group_name || mainline.lv2_name || mainline.lv1_name || "其他"; return ( toggleGroup(groupId)} selectedEvent={selectedEvent} onEventSelect={onEventSelect} /> ); })} ); } ); MainlineTimelineViewComponent.displayName = "MainlineTimelineView"; const MainlineTimelineView = React.memo(MainlineTimelineViewComponent); export default MainlineTimelineView;