Files
vf_react/src/views/Community/components/DynamicNews/layouts/MainlineTimelineView.js
2025-12-24 13:32:52 +08:00

1007 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 (
<Box
w="100%"
cursor="pointer"
onClick={() => 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"
>
{/* 第一行:时间 */}
<Text
fontSize="xs"
color={COLORS.secondaryTextColor}
mb={1.5}
>
{formatEventTime(event.created_at || event.event_time)}
</Text>
{/* 第二行:标题 */}
<Text
fontSize="sm"
color="#e2e8f0"
fontWeight="medium"
noOfLines={2}
lineHeight="1.5"
mb={2}
_hover={{ textDecoration: "underline", color: "#fff" }}
>
{event.title}
</Text>
{/* 第三行:涨跌幅指标 */}
{(hasMaxChange || hasAvgChange) && (
<HStack spacing={2} flexWrap="wrap">
{/* 最大超额 */}
{hasMaxChange && (
<Box
bg={maxChange > 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}
>
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
最大超额
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={maxChange > 0 ? "#fc8181" : "#68d391"}
>
{maxChange > 0 ? "+" : ""}{maxChange.toFixed(2)}%
</Text>
</Box>
)}
{/* 平均超额 */}
{hasAvgChange && (
<Box
bg={avgChange > 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}
>
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
平均超额
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={avgChange > 0 ? "#fc8181" : "#68d391"}
>
{avgChange > 0 ? "+" : ""}{avgChange.toFixed(2)}%
</Text>
</Box>
)}
{/* 超预期得分 */}
{event.expectation_surprise_score != null && (
<Box
bg={event.expectation_surprise_score >= 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}
>
<Text fontSize="xs" color={COLORS.secondaryTextColor} mb={0.5}>
超预期
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={event.expectation_surprise_score >= 60 ? "#fc8181" :
event.expectation_surprise_score >= 40 ? "#ed8936" : "#63b3ed"}
>
{Math.round(event.expectation_surprise_score)}
</Text>
</Box>
)}
</HStack>
)}
</Box>
);
});
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 (
<Box
bg={COLORS.cardBg}
borderRadius="lg"
borderWidth="1px"
borderColor={COLORS.cardBorderColor}
borderTopWidth="3px"
borderTopColor={`${colorScheme}.500`}
minW={isExpanded ? "320px" : "280px"}
maxW={isExpanded ? "380px" : "320px"}
display="flex"
flexDirection="column"
transition="all 0.3s ease"
flexShrink={0}
_hover={{
borderColor: `${colorScheme}.400`,
boxShadow: "lg",
}}
>
{/* 卡片头部 */}
<Box flexShrink={0}>
{/* 第一行:概念名称 + 涨跌幅 + 事件数 */}
<Flex
align="center"
justify="space-between"
px={3}
py={2}
cursor="pointer"
onClick={onToggle}
_hover={{ bg: COLORS.headerHoverBg }}
transition="all 0.15s"
>
<VStack align="start" spacing={0} flex={1} minW={0}>
<HStack spacing={2} w="100%">
<Text
fontWeight="bold"
fontSize="sm"
color={COLORS.textColor}
noOfLines={1}
flex={1}
>
{mainline.group_name || mainline.lv2_name || mainline.lv1_name || "其他"}
</Text>
{/* 涨跌幅显示 - 在概念名称旁边 */}
{mainline.avg_change_pct != null && (
<Text
fontSize="sm"
fontWeight="bold"
color={mainline.avg_change_pct >= 0 ? "#fc8181" : "#68d391"}
flexShrink={0}
>
{mainline.avg_change_pct >= 0 ? "+" : ""}
{mainline.avg_change_pct.toFixed(2)}%
</Text>
)}
<Badge
colorScheme={colorScheme}
fontSize="xs"
borderRadius="full"
px={2}
flexShrink={0}
>
{mainline.event_count}
</Badge>
</HStack>
{/* 显示上级概念名称作为副标题 */}
{mainline.parent_name && (
<Text
fontSize="xs"
color={COLORS.secondaryTextColor}
noOfLines={1}
>
{mainline.grandparent_name ? `${mainline.grandparent_name} > ` : ""}
{mainline.parent_name}
</Text>
)}
</VStack>
<Icon
as={isExpanded ? ChevronUpIcon : ChevronDownIcon}
boxSize={4}
color={COLORS.secondaryTextColor}
ml={2}
flexShrink={0}
/>
</Flex>
{/* HOT 事件展示区域 */}
{hotEvent && (
<Box
px={3}
py={3}
bg="rgba(245, 101, 101, 0.1)"
borderBottomWidth="1px"
borderBottomColor={COLORS.cardBorderColor}
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
onEventSelect?.(hotEvent);
}}
_hover={{ bg: "rgba(245, 101, 101, 0.18)" }}
transition="all 0.15s"
>
{/* 第一行HOT 标签 + 最大超额 */}
<HStack spacing={2} mb={1.5}>
<Badge
bg="linear-gradient(135deg, #f56565 0%, #ed8936 100%)"
color="white"
fontSize="xs"
px={2}
py={0.5}
borderRadius="sm"
display="flex"
alignItems="center"
gap="3px"
fontWeight="bold"
>
<FireOutlined style={{ fontSize: 11 }} />
HOT
</Badge>
{/* 最大超额涨幅 */}
{hotEvent.related_max_chg != null && (
<Box
bg="rgba(239, 68, 68, 0.2)"
borderRadius="md"
px={2}
py={0.5}
>
<Text fontSize="xs" color="#fc8181" fontWeight="bold">
最大超额 +{hotEvent.related_max_chg.toFixed(2)}%
</Text>
</Box>
)}
</HStack>
{/* 第二行:标题 */}
<Text
fontSize="sm"
color={COLORS.textColor}
noOfLines={2}
lineHeight="1.5"
fontWeight="medium"
>
{hotEvent.title}
</Text>
</Box>
)}
</Box>
{/* 事件列表区域 */}
{isExpanded ? (
<Box
px={2}
py={2}
flex={1}
overflowY="auto"
css={{
"&::-webkit-scrollbar": { width: "4px" },
"&::-webkit-scrollbar-track": {
background: COLORS.scrollbarTrackBg,
},
"&::-webkit-scrollbar-thumb": {
background: COLORS.scrollbarThumbBg,
borderRadius: "2px",
},
}}
>
{/* 事件列表 - 卡片式 */}
{displayedEvents.map((event) => (
<TimelineEventItem
key={event.id}
event={event}
isSelected={selectedEvent?.id === event.id}
onEventClick={onEventSelect}
/>
))}
{/* 加载更多按钮 */}
{hasMore && (
<Button
size="sm"
variant="ghost"
color={COLORS.secondaryTextColor}
onClick={loadMore}
isLoading={isLoadingMore}
loadingText="加载中..."
w="100%"
mt={1}
_hover={{ bg: COLORS.headerHoverBg }}
>
加载更多 ({mainline.events.length - displayCount} )
</Button>
)}
</Box>
) : (
/* 折叠时显示简要信息 - 卡片式 */
<Box px={2} py={2} flex={1} overflow="hidden">
{mainline.events.slice(0, 3).map((event) => (
<TimelineEventItem
key={event.id}
event={event}
isSelected={selectedEvent?.id === event.id}
onEventClick={onEventSelect}
/>
))}
{mainline.events.length > 3 && (
<Text fontSize="sm" color={COLORS.secondaryTextColor} textAlign="center" pt={1}>
... 还有 {mainline.events.length - 3}
</Text>
)}
</Box>
)}
</Box>
);
}
);
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 (
<Box display={display} p={8} bg={COLORS.containerBg} minH="400px">
<Center h="400px">
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color={COLORS.secondaryTextColor}>正在加载主线数据...</Text>
</VStack>
</Center>
</Box>
);
}
// 渲染错误状态
if (error) {
return (
<Box display={display} p={8} bg={COLORS.containerBg} minH="400px">
<Center h="400px">
<VStack spacing={4}>
<Text color="red.500">加载失败: {error}</Text>
<IconButton
icon={<RepeatIcon />}
colorScheme="blue"
onClick={fetchMainlineData}
aria-label="重试"
/>
</VStack>
</Center>
</Box>
);
}
// 渲染空状态
if (!mainlineData?.mainlines?.length) {
return (
<Box display={display} p={8} bg={COLORS.containerBg} minH="400px">
<Center h="400px">
<VStack spacing={4}>
<Icon as={FiZap} boxSize={12} color="gray.400" />
<Text color={COLORS.secondaryTextColor}>暂无主线数据</Text>
<Text fontSize="sm" color={COLORS.secondaryTextColor}>
尝试调整筛选条件
</Text>
</VStack>
</Center>
</Box>
);
}
const {
total_events,
mainline_count,
ungrouped_count,
} = mainlineData;
// 使用排序后的主线列表
const mainlines = sortedMainlines;
return (
<Box
display={display}
w="100%"
bg={COLORS.containerBg}
>
{/* 顶部统计栏 - 固定不滚动 */}
<Flex
justify="space-between"
align="center"
px={4}
py={2}
bg={COLORS.statBarBg}
borderBottomWidth="1px"
borderBottomColor={COLORS.cardBorderColor}
position="sticky"
left={0}
>
<HStack spacing={4}>
<HStack spacing={2}>
<Icon as={FiTrendingUp} color="blue.400" />
<Text fontWeight="bold" color={COLORS.textColor} fontSize="sm">
{mainline_count} 条主线
</Text>
</HStack>
<Text fontSize="sm" color={COLORS.secondaryTextColor}>
{total_events} 个事件
</Text>
{ungrouped_count > 0 && (
<Badge colorScheme="orange" fontSize="xs">
{ungrouped_count} 个未归类
</Badge>
)}
</HStack>
<HStack spacing={2}>
{/* 概念级别选择器 */}
<Select
value={groupBy}
onChange={setGroupBy}
size="small"
style={{
width: 180,
backgroundColor: "transparent",
}}
popupClassName="dark-select-dropdown"
dropdownStyle={{
backgroundColor: "#252a34",
borderColor: "#3a3f4b",
maxHeight: 400,
}}
showSearch
optionFilterProp="label"
options={[
{
label: "分组方式",
options: [
{ value: "lv1", label: "按一级概念(大类)" },
{ value: "lv2", label: "按二级概念(细分)" },
{ value: "lv3", label: "按三级概念(更细)" },
],
},
...(hierarchyOptions.lv1?.length > 0
? [
{
label: "一级概念(展开)",
options: hierarchyOptions.lv1.map((opt) => ({
value: opt.id,
label: opt.name,
})),
},
]
: []),
...(hierarchyOptions.lv2?.length > 0
? [
{
label: "二级概念(展开)",
options: hierarchyOptions.lv2.map((opt) => ({
value: opt.id,
label: `${opt.name}`,
})),
},
]
: []),
...(hierarchyOptions.lv3?.length > 0
? [
{
label: "三级概念(展开)",
options: hierarchyOptions.lv3.map((opt) => ({
value: opt.id,
label: `${opt.name}`,
})),
},
]
: []),
]}
/>
{/* 排序方式选择器 */}
<Select
value={sortBy}
onChange={setSortBy}
size="small"
style={{
width: 140,
backgroundColor: "transparent",
}}
popupClassName="dark-select-dropdown"
dropdownStyle={{
backgroundColor: "#252a34",
borderColor: "#3a3f4b",
}}
options={[
{ value: "event_count", label: "按事件数量" },
{ value: "change_desc", label: "按涨幅↓" },
{ value: "change_asc", label: "按跌幅↓" },
]}
/>
<Tooltip label="全部展开">
<IconButton
icon={<ChevronDownIcon />}
size="sm"
variant="ghost"
color={COLORS.secondaryTextColor}
onClick={() => toggleAll(true)}
aria-label="全部展开"
_hover={{ bg: COLORS.headerHoverBg }}
/>
</Tooltip>
<Tooltip label="全部折叠">
<IconButton
icon={<ChevronUpIcon />}
size="sm"
variant="ghost"
color={COLORS.secondaryTextColor}
onClick={() => toggleAll(false)}
aria-label="全部折叠"
_hover={{ bg: COLORS.headerHoverBg }}
/>
</Tooltip>
<Tooltip label="刷新">
<IconButton
icon={<RepeatIcon />}
size="sm"
variant="ghost"
color={COLORS.secondaryTextColor}
onClick={fetchMainlineData}
aria-label="刷新"
_hover={{ bg: COLORS.headerHoverBg }}
/>
</Tooltip>
</HStack>
</Flex>
{/* 横向滚动容器 - 滚动条在卡片上方 */}
<Box
overflowX="scroll"
overflowY="visible"
pb={2}
sx={{
"&::-webkit-scrollbar": {
height: "10px",
display: "block",
},
"&::-webkit-scrollbar-track": {
background: COLORS.scrollbarTrackBg,
borderRadius: "5px",
},
"&::-webkit-scrollbar-thumb": {
background: COLORS.scrollbarThumbBg,
borderRadius: "5px",
minWidth: "40px",
},
"&::-webkit-scrollbar-thumb:hover": {
background: COLORS.scrollbarThumbHoverBg,
},
scrollbarWidth: "thin",
scrollbarColor: `${COLORS.scrollbarThumbBg} ${COLORS.scrollbarTrackBg}`,
}}
>
{/* 主线卡片横向排列容器 */}
<HStack
spacing={3}
p={3}
pt={1}
align="stretch"
display="inline-flex"
minW="max-content"
>
{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 (
<MainlineCard
key={groupId}
mainline={mainline}
colorScheme={getColorScheme(groupName)}
isExpanded={expandedGroups[groupId]}
onToggle={() => toggleGroup(groupId)}
selectedEvent={selectedEvent}
onEventSelect={onEventSelect}
/>
);
})}
</HStack>
</Box>
</Box>
);
}
);
MainlineTimelineViewComponent.displayName = "MainlineTimelineView";
const MainlineTimelineView = React.memo(MainlineTimelineViewComponent);
export default MainlineTimelineView;