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,
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user