1007 lines
31 KiB
JavaScript
1007 lines
31 KiB
JavaScript
// 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;
|