事件中心的涨停原因里面和事件相关

This commit is contained in:
2026-01-10 18:41:49 +08:00
parent 9827f86a85
commit 654c6cff04
6 changed files with 567 additions and 3 deletions

View File

@@ -48,6 +48,22 @@ export interface StockRecord {
[key: string]: unknown;
}
/** 板块关联事件(涨停归因) */
export interface RelatedEvent {
/** 事件ID */
event_id: number;
/** 事件标题 */
title: string;
/** 匹配的概念列表 */
matched_concepts?: string[];
/** 匹配分数(匹配的概念数量) */
match_score?: number;
/** 相关度分数 0-100 */
relevance_score?: number;
/** 相关原因说明 */
relevance_reason?: string;
}
/** 板块信息 */
export interface SectorInfo {
count: number;
@@ -55,6 +71,8 @@ export interface SectorInfo {
stock_codes?: string[];
net_inflow?: number;
leading_stock?: string;
/** 关联事件列表(涨停归因) */
related_events?: RelatedEvent[];
/** 允许额外字段 */
[key: string]: unknown;
}
@@ -354,7 +372,7 @@ export const fetchDailyAnalysis = async (date: string, forceRefresh = false): Pr
stockMap[stock.scode] = stock;
});
// 转换 sector_data 中的 stock_codes 为 stocks
// 转换 sector_data 中的 stock_codes 为 stocks,同时保留 related_events
const transformedSectorData: Record<string, SectorInfo> = {};
if (rawData.sector_data) {
Object.entries(rawData.sector_data).forEach(([sectorName, sectorInfo]) => {
@@ -364,6 +382,8 @@ export const fetchDailyAnalysis = async (date: string, forceRefresh = false): Pr
transformedSectorData[sectorName] = {
count: sectorInfo.count,
stocks: stocks,
// 保留关联事件数据(新数据结构)
related_events: sectorInfo.related_events,
};
});
}

View File

@@ -60,6 +60,22 @@ export interface HighPositionStock {
// ==================== 板块相关 ====================
/** 板块关联事件(涨停归因) */
export interface RelatedEvent {
/** 事件ID */
event_id: number;
/** 事件标题 */
title: string;
/** 匹配的概念列表 */
matched_concepts?: string[];
/** 匹配分数(匹配的概念数量) */
match_score?: number;
/** 相关度分数 0-100 */
relevance_score?: number;
/** 相关原因说明 */
relevance_reason?: string;
}
/** 板块数据 */
export interface SectorData {
/** 涨停股票数量 */
@@ -72,6 +88,8 @@ export interface SectorData {
leading_stock?: string;
/** 股票代码列表 */
stock_codes?: string[];
/** 关联事件列表(涨停归因) */
related_events?: RelatedEvent[];
/** 允许额外字段 */
[key: string]: unknown;
}

View File

@@ -0,0 +1,239 @@
/**
* RelatedEventCard - 关联事件卡片组件
* 在板块卡片内显示涨停归因的关联事件
*/
import React, { memo } from 'react';
import {
Box,
HStack,
VStack,
Text,
Badge,
Icon,
Progress,
Tooltip,
} from '@chakra-ui/react';
import { Newspaper, TrendingUp, ExternalLink } from 'lucide-react';
import type { RelatedEvent } from '@/types/limitAnalyse';
import { textColors, bgColors } from '@/constants/limitAnalyseTheme';
interface RelatedEventCardProps {
/** 事件数据 */
event: RelatedEvent;
/** 是否为紧凑模式(板块卡片内摘要显示) */
compact?: boolean;
/** 点击事件回调 */
onClick?: (event: RelatedEvent) => void;
}
/**
* 根据相关度分数获取颜色
*/
const getRelevanceColor = (score: number): string => {
if (score >= 80) return '#10B981'; // 高相关 - 绿色
if (score >= 60) return '#F59E0B'; // 中相关 - 橙色
return '#6B7280'; // 低相关 - 灰色
};
/**
* 根据相关度分数获取标签
*/
const getRelevanceLabel = (score: number): string => {
if (score >= 80) return '高度相关';
if (score >= 60) return '中度相关';
return '参考';
};
/**
* RelatedEventCard 组件
*/
const RelatedEventCard: React.FC<RelatedEventCardProps> = memo(({
event,
compact = false,
onClick
}) => {
const relevanceColor = getRelevanceColor(event.relevance_score || 0);
const relevanceLabel = getRelevanceLabel(event.relevance_score || 0);
const handleClick = () => {
onClick?.(event);
};
// 紧凑模式(板块卡片内摘要)
if (compact) {
return (
<Box
p={2}
bg="rgba(96, 165, 250, 0.08)"
borderRadius="md"
cursor={onClick ? 'pointer' : 'default'}
onClick={handleClick}
_hover={onClick ? { bg: 'rgba(96, 165, 250, 0.15)' } : undefined}
transition="background 0.2s"
>
<HStack spacing={2} align="start">
<Icon as={Newspaper} boxSize={3.5} color="#60A5FA" mt={0.5} flexShrink={0} />
<VStack align="start" spacing={0.5} flex={1} minW={0}>
<Text
fontSize="xs"
color={textColors.secondary}
noOfLines={1}
fontWeight="500"
>
{event.title}
</Text>
<HStack spacing={2}>
<Badge
size="xs"
bg={`${relevanceColor}20`}
color={relevanceColor}
fontSize="10px"
px={1.5}
borderRadius="sm"
>
{relevanceLabel}
</Badge>
{event.match_score && event.match_score > 1 && (
<Text fontSize="10px" color={textColors.muted}>
{event.match_score}
</Text>
)}
</HStack>
</VStack>
</HStack>
</Box>
);
}
// 完整模式(弹窗列表)
return (
<Box
p={4}
bg={bgColors.item}
borderRadius="12px"
border="1px solid rgba(255, 255, 255, 0.06)"
cursor={onClick ? 'pointer' : 'default'}
onClick={handleClick}
_hover={onClick ? {
bg: bgColors.hover,
borderColor: 'rgba(96, 165, 250, 0.3)',
transform: 'translateY(-1px)',
} : undefined}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 标题和跳转图标 */}
<HStack justify="space-between" align="start">
<HStack spacing={2} flex={1}>
<Icon as={Newspaper} boxSize={4} color="#60A5FA" />
<Text
fontSize="sm"
fontWeight="600"
color={textColors.primary}
noOfLines={2}
>
{event.title}
</Text>
</HStack>
{onClick && (
<Icon
as={ExternalLink}
boxSize={4}
color={textColors.muted}
_hover={{ color: '#60A5FA' }}
/>
)}
</HStack>
{/* 相关度分数 */}
<HStack spacing={3}>
<HStack spacing={1.5}>
<Icon as={TrendingUp} boxSize={3.5} color={relevanceColor} />
<Text fontSize="xs" color={textColors.muted}>
</Text>
</HStack>
<Box flex={1}>
<Progress
value={event.relevance_score || 0}
size="xs"
borderRadius="full"
bg="rgba(255, 255, 255, 0.1)"
sx={{
'& > div': {
background: `linear-gradient(90deg, ${relevanceColor}80, ${relevanceColor})`,
},
}}
/>
</Box>
<Badge
bg={`${relevanceColor}20`}
color={relevanceColor}
fontSize="xs"
px={2}
borderRadius="md"
>
{event.relevance_score || 0}
</Badge>
</HStack>
{/* 匹配的概念 */}
{event.matched_concepts && event.matched_concepts.length > 0 && (
<Box>
<Text fontSize="xs" color={textColors.muted} mb={1.5}>
</Text>
<HStack spacing={1.5} flexWrap="wrap">
{event.matched_concepts.slice(0, 5).map((concept, idx) => (
<Badge
key={idx}
size="sm"
bg="rgba(139, 92, 246, 0.15)"
color="#A78BFA"
fontSize="10px"
px={2}
py={0.5}
borderRadius="md"
>
{concept}
</Badge>
))}
{event.matched_concepts.length > 5 && (
<Badge
size="sm"
bg="rgba(255, 255, 255, 0.1)"
color={textColors.muted}
fontSize="10px"
px={2}
py={0.5}
borderRadius="md"
>
+{event.matched_concepts.length - 5}
</Badge>
)}
</HStack>
</Box>
)}
{/* 相关原因 */}
{event.relevance_reason && (
<Tooltip label={event.relevance_reason} placement="top" hasArrow>
<Text
fontSize="xs"
color={textColors.muted}
noOfLines={2}
lineHeight="1.5"
cursor="help"
>
{event.relevance_reason}
</Text>
</Tooltip>
)}
</VStack>
</Box>
);
});
RelatedEventCard.displayName = 'RelatedEventCard';
export default RelatedEventCard;

View File

@@ -0,0 +1,150 @@
/**
* RelatedEventsModal - 关联事件弹窗
* 显示板块完整的关联事件列表
*/
import React, { memo, useCallback } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
VStack,
HStack,
Text,
Badge,
Icon,
Box,
Divider,
} from '@chakra-ui/react';
import { Newspaper, TrendingUp, Layers } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { RelatedEvent } from '@/types/limitAnalyse';
import { textColors, bgColors, borderColors, goldColors } from '@/constants/limitAnalyseTheme';
import { scrollbarStyles } from '@/styles/limitAnalyseStyles';
import RelatedEventCard from './RelatedEventCard';
interface RelatedEventsModalProps {
/** 是否打开 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 板块名称 */
sectorName: string;
/** 关联事件列表 */
events: RelatedEvent[];
/** 板块涨停数量 */
limitUpCount?: number;
}
/**
* RelatedEventsModal 组件
*/
const RelatedEventsModal: React.FC<RelatedEventsModalProps> = memo(({
isOpen,
onClose,
sectorName,
events,
limitUpCount = 0,
}) => {
const navigate = useNavigate();
// 点击事件跳转到事件详情
const handleEventClick = useCallback((event: RelatedEvent) => {
onClose();
navigate(`/community?event_id=${event.event_id}`);
}, [navigate, onClose]);
// 按相关度排序
const sortedEvents = [...events].sort(
(a, b) => (b.relevance_score || 0) - (a.relevance_score || 0)
);
// 高相关事件数量
const highRelevanceCount = events.filter(e => (e.relevance_score || 0) >= 80).length;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="xl"
isCentered
scrollBehavior="inside"
>
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(10px)" />
<ModalContent
bg={bgColors.card}
border={`1px solid ${borderColors.gold}`}
borderRadius="20px"
maxH="80vh"
>
{/* 头部 */}
<ModalHeader pb={2}>
<VStack align="start" spacing={2}>
<HStack spacing={3}>
<Icon as={Layers} boxSize={5} color={goldColors.primary} />
<Text fontSize="lg" fontWeight="bold" color={textColors.primary}>
{sectorName} -
</Text>
</HStack>
<HStack spacing={4}>
<HStack spacing={1.5}>
<Icon as={TrendingUp} boxSize={4} color="#EF4444" />
<Text fontSize="sm" color={textColors.secondary}>
<Text as="span" color="#EF4444" fontWeight="bold">{limitUpCount}</Text>
</Text>
</HStack>
<HStack spacing={1.5}>
<Icon as={Newspaper} boxSize={4} color="#60A5FA" />
<Text fontSize="sm" color={textColors.secondary}>
<Text as="span" color="#60A5FA" fontWeight="bold">{events.length}</Text>
</Text>
</HStack>
{highRelevanceCount > 0 && (
<Badge
bg="rgba(16, 185, 129, 0.15)"
color="#10B981"
fontSize="xs"
px={2}
borderRadius="md"
>
{highRelevanceCount}
</Badge>
)}
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton color={textColors.muted} />
<Divider borderColor="rgba(255, 255, 255, 0.1)" />
{/* 事件列表 */}
<ModalBody py={4} sx={scrollbarStyles}>
{sortedEvents.length === 0 ? (
<Box textAlign="center" py={10}>
<Icon as={Newspaper} boxSize={10} color={textColors.muted} mb={3} />
<Text color={textColors.muted}></Text>
</Box>
) : (
<VStack spacing={3} align="stretch">
{sortedEvents.map((event, index) => (
<RelatedEventCard
key={event.event_id || index}
event={event}
compact={false}
onClick={handleEventClick}
/>
))}
</VStack>
)}
</ModalBody>
</ModalContent>
</Modal>
);
});
RelatedEventsModal.displayName = 'RelatedEventsModal';
export default RelatedEventsModal;

View File

@@ -0,0 +1,5 @@
/**
* RelatedEvents 组件导出
*/
export { default as RelatedEventCard } from './RelatedEventCard';
export { default as RelatedEventsModal } from './RelatedEventsModal';

View File

@@ -2,7 +2,7 @@
* 单个板块项组件
* 支持 accordion 和 card 两种显示模式
*/
import React, { memo, useCallback, useMemo } from "react";
import React, { memo, useCallback, useMemo, useState } from "react";
import {
Box,
VStack,
@@ -22,8 +22,11 @@ import {
AccordionButton,
AccordionPanel,
AccordionIcon,
Button,
useDisclosure,
Divider,
} from "@chakra-ui/react";
import { Star, Zap, ChevronDown, ChevronUp, Sparkles } from "lucide-react";
import { Star, Zap, ChevronDown, ChevronUp, Sparkles, Newspaper, ChevronRight } from "lucide-react";
import type { SectorData, LimitUpStock } from "@/types/limitAnalyse";
import { formatLimitUpTime, getSectorColor } from "@/utils/limitAnalyseUtils";
import {
@@ -34,6 +37,7 @@ import {
SECTOR_COLORS,
} from "@/constants/limitAnalyseTheme";
import StockListItem from "./StockListItem";
import { RelatedEventCard, RelatedEventsModal } from "./RelatedEvents";
/** 获取板块渐变色 */
function getSectorGradient(sector: string): string {
@@ -103,9 +107,13 @@ interface SectorItemProps {
const SectorItem = memo<SectorItemProps>(
({ sector, data, index, isExpanded, onToggle, displayMode = "card" }) => {
const stocks = data.stocks || [];
const relatedEvents = data.related_events || [];
const leadingStock = stocks[0];
const sectorColor = getSectorSingleColor(sector);
// 关联事件弹窗控制
const { isOpen: isEventsModalOpen, onOpen: onOpenEventsModal, onClose: onCloseEventsModal } = useDisclosure();
const firstLimitTime = leadingStock
? formatLimitUpTime(leadingStock)
: "-";
@@ -117,6 +125,19 @@ const SectorItem = memo<SectorItemProps>(
);
}, [stocks]);
// 按相关度排序的事件取前2条用于摘要显示
const topEvents = useMemo(() => {
return [...relatedEvents]
.sort((a, b) => (b.relevance_score || 0) - (a.relevance_score || 0))
.slice(0, 2);
}, [relatedEvents]);
// 点击查看更多事件
const handleViewMoreEvents = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onOpenEventsModal();
}, [onOpenEventsModal]);
// Card 模式
if (displayMode === "card") {
return (
@@ -250,6 +271,52 @@ const SectorItem = memo<SectorItemProps>(
{/* Body - 展开的个股明细 */}
<Collapse in={isExpanded} animateOpacity>
<Box borderTop="1px solid rgba(255, 255, 255, 0.06)" bg="rgba(0, 0, 0, 0.2)">
{/* 关联事件区域 */}
{relatedEvents.length > 0 && (
<Box px={4} py={3} borderBottom="1px solid rgba(255, 255, 255, 0.06)">
<HStack justify="space-between" mb={2}>
<HStack spacing={2}>
<Icon as={Newspaper} boxSize={4} color="#60A5FA" />
<Text fontSize="sm" fontWeight="600" color={textColors.secondary}>
</Text>
<Badge
size="sm"
bg="rgba(96, 165, 250, 0.15)"
color="#60A5FA"
fontSize="10px"
px={1.5}
borderRadius="sm"
>
{relatedEvents.length}
</Badge>
</HStack>
{relatedEvents.length > 2 && (
<Button
size="xs"
variant="ghost"
color="#60A5FA"
rightIcon={<ChevronRight size={14} />}
onClick={handleViewMoreEvents}
_hover={{ bg: "rgba(96, 165, 250, 0.1)" }}
>
</Button>
)}
</HStack>
<VStack spacing={2} align="stretch">
{topEvents.map((event) => (
<RelatedEventCard
key={event.event_id}
event={event}
compact
/>
))}
</VStack>
</Box>
)}
{/* 股票列表 */}
<VStack spacing={0} align="stretch">
{sortedStocks.map((stock, idx) => (
<StockListItem
@@ -263,6 +330,15 @@ const SectorItem = memo<SectorItemProps>(
</VStack>
</Box>
</Collapse>
{/* 关联事件弹窗 */}
<RelatedEventsModal
isOpen={isEventsModalOpen}
onClose={onCloseEventsModal}
sectorName={sector}
events={relatedEvents}
limitUpCount={data.count || stocks.length}
/>
</Box>
);
}
@@ -317,6 +393,53 @@ const SectorItem = memo<SectorItemProps>(
</AccordionButton>
<AccordionPanel pb={3} pt={2} px={2}>
{/* 关联事件区域 */}
{relatedEvents.length > 0 && (
<Box mb={3}>
<HStack justify="space-between" mb={2}>
<HStack spacing={2}>
<Icon as={Newspaper} boxSize={4} color="#60A5FA" />
<Text fontSize="sm" fontWeight="600" color={textColors.secondary}>
</Text>
<Badge
size="sm"
bg="rgba(96, 165, 250, 0.15)"
color="#60A5FA"
fontSize="10px"
px={1.5}
borderRadius="sm"
>
{relatedEvents.length}
</Badge>
</HStack>
{relatedEvents.length > 2 && (
<Button
size="xs"
variant="ghost"
color="#60A5FA"
rightIcon={<ChevronRight size={14} />}
onClick={handleViewMoreEvents}
_hover={{ bg: "rgba(96, 165, 250, 0.1)" }}
>
</Button>
)}
</HStack>
<VStack spacing={2} align="stretch">
{topEvents.map((event) => (
<RelatedEventCard
key={event.event_id}
event={event}
compact
/>
))}
</VStack>
<Divider borderColor="rgba(255, 255, 255, 0.1)" mt={3} />
</Box>
)}
{/* 股票列表 */}
<VStack align="stretch" spacing={2}>
{sortedStocks.map((stock, idx) => (
<StockListItem
@@ -329,6 +452,15 @@ const SectorItem = memo<SectorItemProps>(
))}
</VStack>
</AccordionPanel>
{/* 关联事件弹窗 */}
<RelatedEventsModal
isOpen={isEventsModalOpen}
onClose={onCloseEventsModal}
sectorName={sector}
events={relatedEvents}
limitUpCount={data.count || stocks.length}
/>
</AccordionItem>
);
}