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:
zdl
2026-01-15 11:45:18 +08:00
parent da13cf08c5
commit a8dc68bddf
6 changed files with 640 additions and 631 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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";

View File

@@ -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";
};

View File

@@ -21,485 +21,15 @@ import {
Center,
IconButton,
Tooltip,
Button,
} from "@chakra-ui/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 { 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 ? 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";
// 模块化导入
import { COLORS, getColorScheme } from "./MainlineTimeline";
import MainlineCard from "./MainlineTimeline/MainlineCard";
/**
* 主线时间轴布局组件
@@ -522,85 +52,10 @@ const MainlineTimelineViewComponent = forwardRef(
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("lv3");
// 层级选项(从 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;
@@ -612,8 +67,6 @@ const MainlineTimelineViewComponent = forwardRef(
const apiBase = getApiBase();
const params = new URLSearchParams();
// 添加筛选参数(主线模式支持时间范围筛选)
// 优先使用精确时间范围start_date/end_date其次使用 recent_days
if (filters.start_date) {
params.append("start_date", filters.start_date);
}
@@ -621,34 +74,27 @@ const MainlineTimelineViewComponent = forwardRef(
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;
@@ -702,21 +148,18 @@ const MainlineTimelineViewComponent = forwardRef(
[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]);
@@ -771,22 +214,12 @@ const MainlineTimelineViewComponent = forwardRef(
);
}
const {
total_events,
mainline_count,
ungrouped_count,
} = mainlineData;
// 使用排序后的主线列表
const { total_events, mainline_count, ungrouped_count } = mainlineData;
const mainlines = sortedMainlines;
return (
<Box
display={display}
w="100%"
bg={COLORS.containerBg}
>
{/* 顶部统计栏 - 固定不滚动 */}
<Box display={display} w="100%" bg={COLORS.containerBg}>
{/* 顶部统计栏 */}
<Flex
justify="space-between"
align="center"
@@ -821,16 +254,9 @@ const MainlineTimelineViewComponent = forwardRef(
value={groupBy}
onChange={setGroupBy}
size="small"
style={{
width: 180,
backgroundColor: "transparent",
}}
style={{ width: 180, backgroundColor: "transparent" }}
popupClassName="dark-select-dropdown"
dropdownStyle={{
backgroundColor: "#252a34",
borderColor: "#3a3f4b",
maxHeight: 400,
}}
dropdownStyle={{ backgroundColor: "#252a34", borderColor: "#3a3f4b", maxHeight: 400 }}
showSearch
optionFilterProp="label"
options={[
@@ -843,37 +269,31 @@ const MainlineTimelineViewComponent = forwardRef(
],
},
...(hierarchyOptions.lv1?.length > 0
? [
{
label: "一级概念(展开)",
options: hierarchyOptions.lv1.map((opt) => ({
value: opt.id,
label: opt.name,
})),
},
]
? [{
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}`,
})),
},
]
? [{
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}`,
})),
},
]
? [{
label: "三级概念(展开)",
options: hierarchyOptions.lv3.map((opt) => ({
value: opt.id,
label: `${opt.name}`,
})),
}]
: []),
]}
/>
@@ -882,15 +302,9 @@ const MainlineTimelineViewComponent = forwardRef(
value={sortBy}
onChange={setSortBy}
size="small"
style={{
width: 140,
backgroundColor: "transparent",
}}
style={{ width: 140, backgroundColor: "transparent" }}
popupClassName="dark-select-dropdown"
dropdownStyle={{
backgroundColor: "#252a34",
borderColor: "#3a3f4b",
}}
dropdownStyle={{ backgroundColor: "#252a34", borderColor: "#3a3f4b" }}
options={[
{ value: "event_count", label: "按事件数量" },
{ value: "change_desc", label: "按涨幅↓" },
@@ -933,9 +347,8 @@ const MainlineTimelineViewComponent = forwardRef(
</HStack>
</Flex>
{/* 横向滚动容器 - 滚动条在顶部 */}
{/* 横向滚动容器 */}
<Box className="mainline-scroll-container">
{/* 主线卡片横向排列容器 */}
<HStack
className="mainline-scroll-content"
spacing={3}
@@ -945,16 +358,8 @@ const MainlineTimelineViewComponent = forwardRef(
w="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 ||
"其他";
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}