community增加事件详情

This commit is contained in:
2026-01-07 14:30:17 +08:00
parent b18208379e
commit 42a116ee42

View File

@@ -418,7 +418,9 @@ const DetailModal = ({ isOpen, onClose, selectedDate, ztDetail, events, loading
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
const [selectedContent, setSelectedContent] = useState(null);
const [ztViewMode, setZtViewMode] = useState('sector'); // 'sector' | 'stock'
const [expandedSectors, setExpandedSectors] = useState({}); // 跟踪展开的板块
const [sectorStocksModalVisible, setSectorStocksModalVisible] = useState(false); // 板块股票弹窗
const [selectedSectorInfo, setSelectedSectorInfo] = useState(null); // 选中的板块信息
const [selectedSectorFilter, setSelectedSectorFilter] = useState(null); // 按个股视图的板块筛选
const [stocksDrawerVisible, setStocksDrawerVisible] = useState(false);
const [selectedEventStocks, setSelectedEventStocks] = useState([]);
const [selectedEventTime, setSelectedEventTime] = useState(null);
@@ -464,6 +466,16 @@ const DetailModal = ({ isOpen, onClose, selectedDate, ztDetail, events, loading
.sort((a, b) => b._continuousDays - a._continuousDays); // 降序排列
}, [ztDetail]);
// 筛选后的股票列表(按板块筛选)
const filteredStockList = useMemo(() => {
if (!selectedSectorFilter) return stockList;
// 根据选中板块筛选
const sectorData = ztDetail?.sector_data?.[selectedSectorFilter];
if (!sectorData?.stock_codes) return stockList;
const sectorStockCodes = new Set(sectorData.stock_codes);
return stockList.filter(stock => sectorStockCodes.has(stock.scode));
}, [stockList, selectedSectorFilter, ztDetail]);
// 热门关键词
const hotKeywords = useMemo(() => {
if (!ztDetail?.word_freq_data) return [];
@@ -956,89 +968,99 @@ const DetailModal = ({ isOpen, onClose, selectedDate, ztDetail, events, loading
dataIndex: 'stocks',
key: 'stocks',
render: (stocks, record) => {
// 根据股票代码查找股票详情
const getStockInfo = (code) => {
const stockInfo = stockList.find(s => s.scode === code);
return stockInfo || { sname: code, scode: code };
// 根据股票代码查找股票详情,并按连板天数排序
const getStockInfoList = () => {
return stocks
.map(code => {
const stockInfo = stockList.find(s => s.scode === code);
return stockInfo || { sname: code, scode: code, _continuousDays: 1 };
})
.sort((a, b) => (b._continuousDays || 1) - (a._continuousDays || 1));
};
const sectorName = record.name;
const isExpanded = expandedSectors[sectorName];
const displayStocks = isExpanded ? stocks : stocks.slice(0, 5);
const hasMore = stocks.length > 5;
const stockInfoList = getStockInfoList();
const displayStocks = stockInfoList.slice(0, 4);
const toggleExpand = (e) => {
const handleShowAll = (e) => {
e.stopPropagation();
setExpandedSectors(prev => ({
...prev,
[sectorName]: !prev[sectorName]
}));
setSelectedSectorInfo({
name: record.name,
count: record.count,
stocks: stockInfoList,
});
setSectorStocksModalVisible(true);
};
return (
<VStack align="start" spacing={1}>
<HStack spacing={2} flexWrap="wrap">
{displayStocks.map((code) => {
const info = getStockInfo(code);
return (
<Tooltip
key={code}
title={
<Box>
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{info.sname}</div>
<div style={{ fontSize: '12px', color: '#888' }}>{code}</div>
{info.core_sectors && (
<div style={{ fontSize: '11px', marginTop: 4 }}>
{info.core_sectors.slice(0, 2).join(' · ')}
</div>
)}
{info.continuous_days && (
<div style={{ fontSize: '11px', marginTop: 2, color: '#fa8c16' }}>
{info.continuous_days}
</div>
)}
</Box>
}
placement="top"
<HStack spacing={1} flexWrap="wrap" align="center">
{displayStocks.map((info) => (
<Tooltip
key={info.scode}
title={
<Box>
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{info.sname}</div>
<div style={{ fontSize: '12px', color: '#888' }}>{info.scode}</div>
{info.continuous_days && (
<div style={{ fontSize: '12px', marginTop: 4, color: '#fa8c16' }}>
{info.continuous_days}
</div>
)}
</Box>
}
placement="top"
>
<Tag
style={{
cursor: 'pointer',
margin: '2px',
background: info._continuousDays >= 3
? 'rgba(255, 77, 79, 0.2)'
: info._continuousDays >= 2
? 'rgba(250, 140, 22, 0.2)'
: 'rgba(59, 130, 246, 0.15)',
border: info._continuousDays >= 3
? '1px solid rgba(255, 77, 79, 0.4)'
: info._continuousDays >= 2
? '1px solid rgba(250, 140, 22, 0.4)'
: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '6px',
}}
>
<a
href={`https://valuefrontier.cn/company?scode=${info.scode}`}
target="_blank"
rel="noopener noreferrer"
style={{
color: info._continuousDays >= 3 ? '#ff4d4f' : info._continuousDays >= 2 ? '#fa8c16' : '#60A5FA',
fontSize: '13px'
}}
>
<Tag
style={{
cursor: 'pointer',
margin: '2px',
background: 'rgba(59, 130, 246, 0.15)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '6px',
}}
>
<a
href={`https://valuefrontier.cn/company?scode=${code}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#60A5FA', fontSize: '13px' }}
>
{info.sname}
</a>
</Tag>
</Tooltip>
);
})}
</HStack>
{hasMore && (
{info.sname}
{info._continuousDays > 1 && (
<span style={{ fontSize: '10px', marginLeft: 2 }}>
({info._continuousDays})
</span>
)}
</a>
</Tag>
</Tooltip>
))}
{stocks.length > 4 && (
<Button
type="link"
size="small"
onClick={toggleExpand}
onClick={handleShowAll}
style={{
padding: 0,
padding: '0 4px',
height: 'auto',
fontSize: '12px',
color: isExpanded ? '#888' : '#FFD700',
color: '#FFD700',
}}
>
{isExpanded ? '收起 ▲' : `展开全部 ${stocks.length} 只 ▼`}
查看全部 {stocks.length}
</Button>
)}
</VStack>
</HStack>
);
},
},
@@ -1702,20 +1724,97 @@ const DetailModal = ({ isOpen, onClose, selectedDate, ztDetail, events, loading
{/* 个股视图 */}
{ztViewMode === 'stock' && (
<Box
borderRadius="xl"
border="1px solid rgba(59,130,246,0.15)"
overflow="hidden"
>
<Table
dataSource={stockList}
columns={ztStockColumns}
rowKey="scode"
size="middle"
pagination={false}
scroll={{ x: 950, y: 380 }}
/>
</Box>
<VStack spacing={3} align="stretch">
{/* 板块筛选器 */}
<Box>
<HStack spacing={2} mb={2}>
<Text fontSize="xs" color="whiteAlpha.500">板块筛选</Text>
<Box
as="button"
px={3}
py={1}
borderRadius="full"
bg={!selectedSectorFilter ? 'rgba(255,215,0,0.2)' : 'rgba(255,255,255,0.05)'}
border={!selectedSectorFilter ? '1px solid rgba(255,215,0,0.4)' : '1px solid rgba(255,255,255,0.1)'}
color={!selectedSectorFilter ? '#FFD700' : '#888'}
fontSize="xs"
onClick={() => setSelectedSectorFilter(null)}
transition="all 0.2s"
_hover={{ bg: 'rgba(255,215,0,0.15)' }}
>
全部 ({stockList.length})
</Box>
</HStack>
<HStack spacing={2} flexWrap="wrap">
{sectorList.slice(0, 10).map((sector) => (
<Box
key={sector.name}
as="button"
px={2.5}
py={1}
borderRadius="full"
bg={selectedSectorFilter === sector.name
? 'rgba(59,130,246,0.2)'
: 'rgba(255,255,255,0.03)'}
border={selectedSectorFilter === sector.name
? '1px solid rgba(59,130,246,0.4)'
: '1px solid rgba(255,255,255,0.08)'}
color={selectedSectorFilter === sector.name ? '#60A5FA' : '#888'}
fontSize="xs"
onClick={() => setSelectedSectorFilter(
selectedSectorFilter === sector.name ? null : sector.name
)}
transition="all 0.2s"
_hover={{ bg: 'rgba(59,130,246,0.1)' }}
>
{sector.name} ({sector.count})
</Box>
))}
</HStack>
</Box>
{/* 筛选结果提示 */}
{selectedSectorFilter && (
<HStack
px={3}
py={2}
bg="rgba(59,130,246,0.1)"
borderRadius="lg"
border="1px solid rgba(59,130,246,0.2)"
>
<TagsOutlined style={{ color: '#60A5FA' }} />
<Text fontSize="sm" color="#60A5FA">
当前筛选<strong>{selectedSectorFilter}</strong>
</Text>
<Text fontSize="sm" color="whiteAlpha.600">
{filteredStockList.length}
</Text>
<Button
type="link"
size="small"
onClick={() => setSelectedSectorFilter(null)}
style={{ color: '#888', fontSize: '12px' }}
>
清除筛选
</Button>
</HStack>
)}
<Box
borderRadius="xl"
border="1px solid rgba(59,130,246,0.15)"
overflow="hidden"
>
<Table
dataSource={filteredStockList}
columns={ztStockColumns}
rowKey="scode"
size="middle"
pagination={false}
scroll={{ x: 950, y: selectedSectorFilter ? 320 : 380 }}
/>
</Box>
</VStack>
)}
</VStack>
) : (
@@ -1885,6 +1984,285 @@ const DetailModal = ({ isOpen, onClose, selectedDate, ztDetail, events, loading
size="5xl"
/>
)}
{/* 板块股票弹窗 */}
<Modal
isOpen={sectorStocksModalVisible}
onClose={() => {
setSectorStocksModalVisible(false);
setSelectedSectorInfo(null);
}}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay bg="blackAlpha.700" backdropFilter={GLASS_BLUR.sm} />
<ModalContent
maxW="900px"
maxH="85vh"
bg="linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)"
border="1px solid rgba(255,215,0,0.2)"
borderRadius="2xl"
>
<ModalHeader borderBottom="1px solid rgba(255,215,0,0.2)" py={4}>
<HStack spacing={3}>
<Box
p={2}
bg="rgba(255,215,0,0.15)"
borderRadius="lg"
border="1px solid rgba(255,215,0,0.3)"
>
<TagsOutlined style={{ color: '#FFD700', fontSize: '18px' }} />
</Box>
<VStack align="start" spacing={0}>
<HStack spacing={2}>
<Text fontSize="lg" fontWeight="bold" color="#FFD700">
{selectedSectorInfo?.name}
</Text>
<Badge
colorScheme="red"
fontSize="sm"
px={2}
borderRadius="full"
>
{selectedSectorInfo?.count} 只涨停
</Badge>
</HStack>
<Text fontSize="xs" color="whiteAlpha.500">
按连板天数降序排列
</Text>
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton color="whiteAlpha.600" _hover={{ color: 'white' }} />
<ModalBody py={4} className="hero-panel-modal">
{selectedSectorInfo?.stocks?.length > 0 ? (
<VStack spacing={3} align="stretch">
{/* 快速统计 */}
<HStack spacing={4} flexWrap="wrap">
{(() => {
const stats = { '首板': 0, '2连板': 0, '3连板': 0, '4连板+': 0 };
selectedSectorInfo.stocks.forEach(s => {
const days = s._continuousDays || 1;
if (days === 1) stats['首板']++;
else if (days === 2) stats['2连板']++;
else if (days === 3) stats['3连板']++;
else stats['4连板+']++;
});
return Object.entries(stats).map(([key, value]) => (
value > 0 && (
<Box
key={key}
px={3}
py={1}
borderRadius="full"
bg={key === '4连板+' ? 'rgba(255,77,79,0.15)' : key === '3连板' ? 'rgba(250,84,28,0.15)' : key === '2连板' ? 'rgba(250,140,22,0.15)' : 'rgba(255,255,255,0.05)'}
border={`1px solid ${key === '4连板+' ? 'rgba(255,77,79,0.3)' : key === '3连板' ? 'rgba(250,84,28,0.3)' : key === '2连板' ? 'rgba(250,140,22,0.3)' : 'rgba(255,255,255,0.1)'}`}
>
<Text
fontSize="sm"
color={key === '4连板+' ? '#ff4d4f' : key === '3连板' ? '#fa541c' : key === '2连板' ? '#fa8c16' : '#888'}
>
{key}: <strong>{value}</strong>
</Text>
</Box>
)
));
})()}
</HStack>
{/* 股票列表 */}
<Box
borderRadius="xl"
border="1px solid rgba(255,215,0,0.15)"
overflow="hidden"
>
<Table
dataSource={selectedSectorInfo.stocks}
columns={[
{
title: '股票',
key: 'stock',
width: 130,
render: (_, record) => (
<VStack align="start" spacing={0}>
<a
href={`https://valuefrontier.cn/company?scode=${record.scode}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#FFD700', fontWeight: 'bold', fontSize: '14px' }}
>
{record.sname}
</a>
<AntText style={{ color: '#60A5FA', fontSize: '12px' }}>{record.scode}</AntText>
</VStack>
),
},
{
title: '连板',
dataIndex: 'continuous_days',
key: 'continuous',
width: 90,
align: 'center',
render: (text, record) => {
const days = record._continuousDays || 1;
const getDaysStyle = (d) => {
if (d >= 5) return { bg: 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)', text: '#fff' };
if (d >= 3) return { bg: 'linear-gradient(135deg, #fa541c 0%, #ff7a45 100%)', text: '#fff' };
if (d >= 2) return { bg: 'linear-gradient(135deg, #fa8c16 0%, #ffc53d 100%)', text: '#fff' };
return { bg: 'rgba(255,255,255,0.1)', text: '#888' };
};
const style = getDaysStyle(days);
return (
<Box
px={3}
py={1}
borderRadius="md"
bg={style.bg}
fontSize="13px"
fontWeight="bold"
color={style.text}
display="inline-block"
>
{text || '首板'}
</Box>
);
},
},
{
title: '涨停时间',
dataIndex: 'formatted_time',
key: 'time',
width: 90,
align: 'center',
render: (time) => {
const getTimeStyle = (t) => {
if (t <= '09:30:00') return { bg: '#ff4d4f', text: '#fff' };
if (t <= '09:35:00') return { bg: '#fa541c', text: '#fff' };
if (t <= '10:00:00') return { bg: '#fa8c16', text: '#fff' };
return { bg: 'rgba(255,255,255,0.1)', text: '#888' };
};
const style = getTimeStyle(time || '15:00:00');
return (
<Box
px={2}
py={0.5}
borderRadius="md"
bg={style.bg}
fontSize="12px"
color={style.text}
>
{time?.substring(0, 5) || '-'}
</Box>
);
},
},
{
title: '核心板块',
dataIndex: 'core_sectors',
key: 'sectors',
render: (sectors) => (
<HStack spacing={1} flexWrap="wrap">
{(sectors || []).slice(0, 2).map((sector, idx) => (
<Tag
key={idx}
style={{
margin: '2px',
background: 'rgba(255,215,0,0.1)',
border: '1px solid rgba(255,215,0,0.2)',
borderRadius: '4px',
color: '#D4A84B',
fontSize: '11px',
}}
>
{sector}
</Tag>
))}
</HStack>
),
},
{
title: 'K线图',
key: 'kline',
width: 80,
align: 'center',
render: (_, record) => (
<Button
type="primary"
size="small"
icon={<LineChartOutlined />}
onClick={() => {
const code = record.scode;
let stockCode = code;
if (!code.includes('.')) {
if (code.startsWith('6')) stockCode = `${code}.SH`;
else if (code.startsWith('0') || code.startsWith('3')) stockCode = `${code}.SZ`;
}
setSelectedKlineStock({
stock_code: stockCode,
stock_name: record.sname
});
setKlineModalVisible(true);
}}
style={{
background: 'linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%)',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
}}
>
查看
</Button>
),
},
{
title: '操作',
key: 'action',
width: 90,
align: 'center',
render: (_, record) => {
const code = record.scode;
const inWatchlist = isStockInWatchlist(code);
return (
<Button
type={inWatchlist ? 'primary' : 'default'}
size="small"
icon={inWatchlist ? <StarFilled /> : <StarOutlined />}
onClick={() => addSingleToWatchlist({ code, name: record.sname })}
disabled={inWatchlist}
style={inWatchlist ? {
background: 'linear-gradient(135deg, #faad14 0%, #fa8c16 100%)',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
} : {
background: 'rgba(255,255,255,0.1)',
border: '1px solid rgba(255,215,0,0.3)',
borderRadius: '6px',
color: '#FFD700',
fontSize: '12px',
}}
>
{inWatchlist ? '已添加' : '加自选'}
</Button>
);
},
},
]}
rowKey="scode"
size="small"
pagination={false}
scroll={{ y: 400 }}
/>
</Box>
</VStack>
) : (
<Center h="200px">
<Text color="whiteAlpha.500">暂无股票数据</Text>
</Center>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
);
};