// 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} 个未归类
)}
{/* 概念级别选择器 */}
{/* 横向滚动容器 - 滚动条在卡片上方 */}
{/* 主线卡片横向排列容器 */}
{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;