refactor(MainlineTimeline): 提取常量、工具、子组件到子模块
- constants.js: COLORS 颜色配置, EVENTS_PER_LOAD 分页常量 - utils.js: formatEventTime, getChangeBgColor, getColorScheme - TimelineEventItem.js: 时间线事件项组件 - MainlineCard.js: 主线卡片组件 - index.js: 模块统一导出 主文件从 ~670 行精简到 ~200 行 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,294 @@
|
|||||||
|
// 单个主线卡片组件 - 支持懒加载
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Flex,
|
||||||
|
Icon,
|
||||||
|
Button,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { FireOutlined } from "@ant-design/icons";
|
||||||
|
import { COLORS, EVENTS_PER_LOAD } from "./constants";
|
||||||
|
import TimelineEventItem from "./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) => {
|
||||||
|
const change = event.related_max_chg ?? -Infinity;
|
||||||
|
if (change > maxChange) {
|
||||||
|
maxChange = change;
|
||||||
|
hot = event;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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 ? ChevronUp : ChevronDown}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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";
|
||||||
|
|
||||||
|
export default MainlineCard;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// 单个事件项组件 - 卡片式布局
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Box, HStack, Text } from "@chakra-ui/react";
|
||||||
|
import { COLORS } from "./constants";
|
||||||
|
import { formatEventTime, getChangeBgColor } from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个事件项组件
|
||||||
|
*/
|
||||||
|
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";
|
||||||
|
|
||||||
|
export default TimelineEventItem;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// MainlineTimelineView 常量定义
|
||||||
|
|
||||||
|
// 固定深色主题颜色
|
||||||
|
export const COLORS = {
|
||||||
|
containerBg: "#1a1d24",
|
||||||
|
cardBg: "#252a34",
|
||||||
|
cardBorderColor: "#3a3f4b",
|
||||||
|
headerHoverBg: "#2d323e",
|
||||||
|
textColor: "#e2e8f0",
|
||||||
|
secondaryTextColor: "#a0aec0",
|
||||||
|
scrollbarTrackBg: "#2d3748",
|
||||||
|
scrollbarThumbBg: "#718096",
|
||||||
|
scrollbarThumbHoverBg: "#a0aec0",
|
||||||
|
statBarBg: "#252a34",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每次加载的事件数量
|
||||||
|
export const EVENTS_PER_LOAD = 12;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// MainlineTimeline 模块导出
|
||||||
|
|
||||||
|
export { COLORS, EVENTS_PER_LOAD } from "./constants";
|
||||||
|
export { formatEventTime, getChangeBgColor, getColorScheme } from "./utils";
|
||||||
|
export { default as TimelineEventItem } from "./TimelineEventItem";
|
||||||
|
export { default as MainlineCard } from "./MainlineCard";
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// MainlineTimelineView 工具函数
|
||||||
|
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间显示 - 始终显示日期,避免跨天混淆
|
||||||
|
*/
|
||||||
|
export 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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据涨跌幅获取背景色
|
||||||
|
*/
|
||||||
|
export 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";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据主线类型获取配色方案
|
||||||
|
*/
|
||||||
|
export const getColorScheme = (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";
|
||||||
|
};
|
||||||
@@ -21,485 +21,15 @@ import {
|
|||||||
Center,
|
Center,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Button,
|
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { ChevronDown, ChevronUp, RefreshCw, TrendingUp, Zap } from "lucide-react";
|
import { ChevronDown, ChevronUp, RefreshCw, TrendingUp, Zap } from "lucide-react";
|
||||||
import { FireOutlined } from "@ant-design/icons";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { Select } from "antd";
|
import { Select } from "antd";
|
||||||
import { getApiBase } from "@utils/apiConfig";
|
import { getApiBase } from "@utils/apiConfig";
|
||||||
import { getChangeColor } from "@utils/colorUtils";
|
|
||||||
import "../../SearchFilters/CompactSearchBox.css";
|
import "../../SearchFilters/CompactSearchBox.css";
|
||||||
|
|
||||||
// 固定深色主题颜色
|
// 模块化导入
|
||||||
const COLORS = {
|
import { COLORS, getColorScheme } from "./MainlineTimeline";
|
||||||
containerBg: "#1a1d24",
|
import MainlineCard from "./MainlineTimeline/MainlineCard";
|
||||||
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 ? ChevronUp : ChevronDown}
|
|
||||||
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";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主线时间轴布局组件
|
* 主线时间轴布局组件
|
||||||
@@ -522,85 +52,10 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [mainlineData, setMainlineData] = useState(null);
|
const [mainlineData, setMainlineData] = useState(null);
|
||||||
const [expandedGroups, setExpandedGroups] = useState({});
|
const [expandedGroups, setExpandedGroups] = useState({});
|
||||||
// 概念级别选择: 'lv1' | 'lv2' | 'lv3' | 具体概念ID(如 L1_TMT, L2_AI_INFRA, L3_AI_CHIP)
|
|
||||||
const [groupBy, setGroupBy] = useState("lv3");
|
const [groupBy, setGroupBy] = useState("lv3");
|
||||||
// 层级选项(从 API 获取)
|
|
||||||
const [hierarchyOptions, setHierarchyOptions] = useState({ lv1: [], lv2: [], lv3: [] });
|
const [hierarchyOptions, setHierarchyOptions] = useState({ lv1: [], lv2: [], lv3: [] });
|
||||||
// 排序方式: 'event_count' | 'change_desc' | 'change_asc'
|
|
||||||
const [sortBy, setSortBy] = useState("event_count");
|
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 () => {
|
const fetchMainlineData = useCallback(async () => {
|
||||||
if (display === "none") return;
|
if (display === "none") return;
|
||||||
@@ -612,8 +67,6 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
const apiBase = getApiBase();
|
const apiBase = getApiBase();
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
// 添加筛选参数(主线模式支持时间范围筛选)
|
|
||||||
// 优先使用精确时间范围(start_date/end_date),其次使用 recent_days
|
|
||||||
if (filters.start_date) {
|
if (filters.start_date) {
|
||||||
params.append("start_date", filters.start_date);
|
params.append("start_date", filters.start_date);
|
||||||
}
|
}
|
||||||
@@ -621,34 +74,27 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
params.append("end_date", filters.end_date);
|
params.append("end_date", filters.end_date);
|
||||||
}
|
}
|
||||||
if (filters.recent_days && !filters.start_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("recent_days", filters.recent_days);
|
||||||
}
|
}
|
||||||
// 添加分组方式参数
|
|
||||||
params.append("group_by", groupBy);
|
params.append("group_by", groupBy);
|
||||||
|
|
||||||
const url = `${apiBase}/api/events/mainline?${params.toString()}`;
|
const url = `${apiBase}/api/events/mainline?${params.toString()}`;
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
// 兼容两种响应格式:{ success, data: {...} } 或 { success, mainlines, ... }
|
|
||||||
const responseData = result.data || result;
|
const responseData = result.data || result;
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 保存原始数据,排序在渲染时根据 sortBy 状态进行
|
|
||||||
setMainlineData(responseData);
|
setMainlineData(responseData);
|
||||||
|
|
||||||
// 保存层级选项供下拉框使用
|
|
||||||
if (responseData.hierarchy_options) {
|
if (responseData.hierarchy_options) {
|
||||||
setHierarchyOptions(responseData.hierarchy_options);
|
setHierarchyOptions(responseData.hierarchy_options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化展开状态(默认全部展开)
|
|
||||||
const initialExpanded = {};
|
const initialExpanded = {};
|
||||||
(responseData.mainlines || []).forEach((mainline) => {
|
(responseData.mainlines || []).forEach((mainline) => {
|
||||||
const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id;
|
const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id;
|
||||||
@@ -702,21 +148,18 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
[mainlineData]
|
[mainlineData]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 根据排序方式排序主线列表(必须在条件渲染之前,遵循 Hooks 规则)
|
// 根据排序方式排序主线列表
|
||||||
const sortedMainlines = useMemo(() => {
|
const sortedMainlines = useMemo(() => {
|
||||||
const rawMainlines = mainlineData?.mainlines;
|
const rawMainlines = mainlineData?.mainlines;
|
||||||
if (!rawMainlines) return [];
|
if (!rawMainlines) return [];
|
||||||
const sorted = [...rawMainlines];
|
const sorted = [...rawMainlines];
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case "change_desc":
|
case "change_desc":
|
||||||
// 按涨跌幅从高到低(涨幅大的在前)
|
|
||||||
return sorted.sort((a, b) => (b.avg_change_pct ?? -999) - (a.avg_change_pct ?? -999));
|
return sorted.sort((a, b) => (b.avg_change_pct ?? -999) - (a.avg_change_pct ?? -999));
|
||||||
case "change_asc":
|
case "change_asc":
|
||||||
// 按涨跌幅从低到高(跌幅大的在前)
|
|
||||||
return sorted.sort((a, b) => (a.avg_change_pct ?? 999) - (b.avg_change_pct ?? 999));
|
return sorted.sort((a, b) => (a.avg_change_pct ?? 999) - (b.avg_change_pct ?? 999));
|
||||||
case "event_count":
|
case "event_count":
|
||||||
default:
|
default:
|
||||||
// 按事件数量从多到少
|
|
||||||
return sorted.sort((a, b) => b.event_count - a.event_count);
|
return sorted.sort((a, b) => b.event_count - a.event_count);
|
||||||
}
|
}
|
||||||
}, [mainlineData?.mainlines, sortBy]);
|
}, [mainlineData?.mainlines, sortBy]);
|
||||||
@@ -771,22 +214,12 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { total_events, mainline_count, ungrouped_count } = mainlineData;
|
||||||
total_events,
|
|
||||||
mainline_count,
|
|
||||||
ungrouped_count,
|
|
||||||
} = mainlineData;
|
|
||||||
|
|
||||||
// 使用排序后的主线列表
|
|
||||||
const mainlines = sortedMainlines;
|
const mainlines = sortedMainlines;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box display={display} w="100%" bg={COLORS.containerBg}>
|
||||||
display={display}
|
{/* 顶部统计栏 */}
|
||||||
w="100%"
|
|
||||||
bg={COLORS.containerBg}
|
|
||||||
>
|
|
||||||
{/* 顶部统计栏 - 固定不滚动 */}
|
|
||||||
<Flex
|
<Flex
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
@@ -821,16 +254,9 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
value={groupBy}
|
value={groupBy}
|
||||||
onChange={setGroupBy}
|
onChange={setGroupBy}
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{ width: 180, backgroundColor: "transparent" }}
|
||||||
width: 180,
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
}}
|
|
||||||
popupClassName="dark-select-dropdown"
|
popupClassName="dark-select-dropdown"
|
||||||
dropdownStyle={{
|
dropdownStyle={{ backgroundColor: "#252a34", borderColor: "#3a3f4b", maxHeight: 400 }}
|
||||||
backgroundColor: "#252a34",
|
|
||||||
borderColor: "#3a3f4b",
|
|
||||||
maxHeight: 400,
|
|
||||||
}}
|
|
||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
options={[
|
options={[
|
||||||
@@ -843,37 +269,31 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
...(hierarchyOptions.lv1?.length > 0
|
...(hierarchyOptions.lv1?.length > 0
|
||||||
? [
|
? [{
|
||||||
{
|
label: "一级概念(展开)",
|
||||||
label: "一级概念(展开)",
|
options: hierarchyOptions.lv1.map((opt) => ({
|
||||||
options: hierarchyOptions.lv1.map((opt) => ({
|
value: opt.id,
|
||||||
value: opt.id,
|
label: opt.name,
|
||||||
label: opt.name,
|
})),
|
||||||
})),
|
}]
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
: []),
|
||||||
...(hierarchyOptions.lv2?.length > 0
|
...(hierarchyOptions.lv2?.length > 0
|
||||||
? [
|
? [{
|
||||||
{
|
label: "二级概念(展开)",
|
||||||
label: "二级概念(展开)",
|
options: hierarchyOptions.lv2.map((opt) => ({
|
||||||
options: hierarchyOptions.lv2.map((opt) => ({
|
value: opt.id,
|
||||||
value: opt.id,
|
label: `${opt.name}`,
|
||||||
label: `${opt.name}`,
|
})),
|
||||||
})),
|
}]
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
: []),
|
||||||
...(hierarchyOptions.lv3?.length > 0
|
...(hierarchyOptions.lv3?.length > 0
|
||||||
? [
|
? [{
|
||||||
{
|
label: "三级概念(展开)",
|
||||||
label: "三级概念(展开)",
|
options: hierarchyOptions.lv3.map((opt) => ({
|
||||||
options: hierarchyOptions.lv3.map((opt) => ({
|
value: opt.id,
|
||||||
value: opt.id,
|
label: `${opt.name}`,
|
||||||
label: `${opt.name}`,
|
})),
|
||||||
})),
|
}]
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -882,15 +302,9 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={setSortBy}
|
onChange={setSortBy}
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{ width: 140, backgroundColor: "transparent" }}
|
||||||
width: 140,
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
}}
|
|
||||||
popupClassName="dark-select-dropdown"
|
popupClassName="dark-select-dropdown"
|
||||||
dropdownStyle={{
|
dropdownStyle={{ backgroundColor: "#252a34", borderColor: "#3a3f4b" }}
|
||||||
backgroundColor: "#252a34",
|
|
||||||
borderColor: "#3a3f4b",
|
|
||||||
}}
|
|
||||||
options={[
|
options={[
|
||||||
{ value: "event_count", label: "按事件数量" },
|
{ value: "event_count", label: "按事件数量" },
|
||||||
{ value: "change_desc", label: "按涨幅↓" },
|
{ value: "change_desc", label: "按涨幅↓" },
|
||||||
@@ -933,9 +347,8 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* 横向滚动容器 - 滚动条在顶部 */}
|
{/* 横向滚动容器 */}
|
||||||
<Box className="mainline-scroll-container">
|
<Box className="mainline-scroll-container">
|
||||||
{/* 主线卡片横向排列容器 */}
|
|
||||||
<HStack
|
<HStack
|
||||||
className="mainline-scroll-content"
|
className="mainline-scroll-content"
|
||||||
spacing={3}
|
spacing={3}
|
||||||
@@ -945,16 +358,8 @@ const MainlineTimelineViewComponent = forwardRef(
|
|||||||
w="max-content"
|
w="max-content"
|
||||||
>
|
>
|
||||||
{mainlines.map((mainline) => {
|
{mainlines.map((mainline) => {
|
||||||
const groupId =
|
const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id || "ungrouped";
|
||||||
mainline.group_id ||
|
const groupName = mainline.group_name || mainline.lv2_name || mainline.lv1_name || "其他";
|
||||||
mainline.lv2_id ||
|
|
||||||
mainline.lv1_id ||
|
|
||||||
"ungrouped";
|
|
||||||
const groupName =
|
|
||||||
mainline.group_name ||
|
|
||||||
mainline.lv2_name ||
|
|
||||||
mainline.lv1_name ||
|
|
||||||
"其他";
|
|
||||||
return (
|
return (
|
||||||
<MainlineCard
|
<MainlineCard
|
||||||
key={groupId}
|
key={groupId}
|
||||||
|
|||||||
Reference in New Issue
Block a user