community增加事件详情

This commit is contained in:
2026-01-08 18:50:10 +08:00
parent 86158d1dd5
commit 08b4d67e12
2 changed files with 279 additions and 128 deletions

44
app.py
View File

@@ -11139,20 +11139,53 @@ def get_events_effectiveness_stats():
# 计算汇总统计 # 计算汇总统计
total_events = len(events_query) total_events = len(events_query)
event_ids = [e.id for e in events_query]
avg_chg_list = [e.related_avg_chg for e in events_query if e.related_avg_chg is not None] avg_chg_list = [e.related_avg_chg for e in events_query if e.related_avg_chg is not None]
max_chg_list = [e.related_max_chg for e in events_query if e.related_max_chg is not None] max_chg_list = [e.related_max_chg for e in events_query if e.related_max_chg is not None]
invest_scores = [e.invest_score for e in events_query if e.invest_score is not None]
surprise_scores = [e.expectation_surprise_score for e in events_query if e.expectation_surprise_score is not None]
positive_count = sum(1 for chg in avg_chg_list if chg > 0) positive_count = sum(1 for chg in avg_chg_list if chg > 0)
# 查询关联股票数据
stock_stats = []
total_stocks = 0
if event_ids:
# 查询所有关联股票
related_stocks = db.session.query(EventStock).filter(
EventStock.event_id.in_(event_ids)
).all()
# 统计股票数量(去重)
unique_stocks = {}
for rs in related_stocks:
stock_key = rs.stock_code
if stock_key not in unique_stocks:
unique_stocks[stock_key] = {
'stockCode': rs.stock_code,
'stockName': rs.stock_name,
'maxChg': rs.chg_pct or 0,
'eventId': rs.event_id,
}
else:
# 保留最大涨幅的记录
if (rs.chg_pct or 0) > unique_stocks[stock_key]['maxChg']:
unique_stocks[stock_key]['maxChg'] = rs.chg_pct or 0
unique_stocks[stock_key]['eventId'] = rs.event_id
total_stocks = len(unique_stocks)
# 按涨幅排序,取 TOP10
stock_stats = sorted(
unique_stocks.values(),
key=lambda x: x['maxChg'],
reverse=True
)[:10]
summary = { summary = {
'totalEvents': total_events, 'totalEvents': total_events,
'totalStocks': total_stocks,
'avgChg': round(sum(avg_chg_list) / len(avg_chg_list), 2) if avg_chg_list else 0, 'avgChg': round(sum(avg_chg_list) / len(avg_chg_list), 2) if avg_chg_list else 0,
'maxChg': round(max(max_chg_list), 2) if max_chg_list else 0, 'maxChg': round(max(max_chg_list), 2) if max_chg_list else 0,
'positiveRate': round(positive_count / len(avg_chg_list) * 100, 1) if avg_chg_list else 0, 'positiveRate': round(positive_count / len(avg_chg_list) * 100, 1) if avg_chg_list else 0,
'avgInvestScore': round(sum(invest_scores) / len(invest_scores)) if invest_scores else 0,
'avgSurpriseScore': round(sum(surprise_scores) / len(surprise_scores)) if surprise_scores else 0
} }
# 按日期分组统计 # 按日期分组统计
@@ -11225,7 +11258,8 @@ def get_events_effectiveness_stats():
'currentDate': current_trading_day.strftime('%Y-%m-%d') if hasattr(current_trading_day, 'strftime') else str(current_trading_day), 'currentDate': current_trading_day.strftime('%Y-%m-%d') if hasattr(current_trading_day, 'strftime') else str(current_trading_day),
'summary': summary, 'summary': summary,
'dailyStats': daily_stats, 'dailyStats': daily_stats,
'topPerformers': top_performers_list 'topPerformers': top_performers_list,
'topStocks': stock_stats
} }
}) })

View File

@@ -12,25 +12,25 @@ import {
Center, Center,
Tooltip, Tooltip,
Badge, Badge,
Progress, Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { import {
FireOutlined, FireOutlined,
RiseOutlined, RiseOutlined,
CheckCircleOutlined,
ThunderboltOutlined, ThunderboltOutlined,
TrophyOutlined, TrophyOutlined,
StarOutlined, StockOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getApiBase } from '@utils/apiConfig'; import { getApiBase } from '@utils/apiConfig';
/** /**
* 生成事件详情页 URL * 生成事件详情页 URL
* @param {number} eventId - 事件ID
* @returns {string} 事件详情页 URL
*/ */
const getEventDetailUrl = (eventId) => { const getEventDetailUrl = (eventId) => {
// 使用 base64 编码 ID格式ev-{id} -> base64
const encodedId = btoa(`ev-${eventId}`); const encodedId = btoa(`ev-${eventId}`);
return `/event-detail?id=${encodedId}`; return `/event-detail?id=${encodedId}`;
}; };
@@ -58,18 +58,104 @@ const getChgColor = (val) => {
}; };
/** /**
* 获取胜率颜色 * 胜率仪表盘组件
*/ */
const getWinRateColor = (rate) => { const WinRateGauge = ({ rate }) => {
if (rate >= 70) return '#52C41A'; // 绿色-优秀 const validRate = Math.min(100, Math.max(0, rate || 0));
if (rate >= 50) return '#FFD700'; // 金色-良好 const angle = (validRate / 100) * 180; // 0-180度
return '#FF4D4F'; // 红色-待提升
// 根据胜率确定颜色
const getGaugeColor = (r) => {
if (r >= 70) return '#52C41A';
if (r >= 50) return '#FFD700';
return '#FF4D4F';
};
const gaugeColor = getGaugeColor(validRate);
return (
<Box position="relative" w="100%" h="90px" overflow="hidden">
{/* 仪表盘背景 */}
<Box
position="absolute"
bottom="0"
left="50%"
transform="translateX(-50%)"
w="140px"
h="70px"
borderTopLeftRadius="70px"
borderTopRightRadius="70px"
bg="rgba(255,255,255,0.05)"
border="3px solid rgba(255,255,255,0.1)"
borderBottom="none"
overflow="hidden"
>
{/* 填充弧 */}
<Box
position="absolute"
bottom="0"
left="0"
w="100%"
h="100%"
bg={`conic-gradient(from 180deg, ${gaugeColor} 0deg, ${gaugeColor} ${angle}deg, transparent ${angle}deg)`}
opacity="0.3"
style={{ transformOrigin: 'center bottom' }}
/>
</Box>
{/* 指针 */}
<Box
position="absolute"
bottom="3px"
left="50%"
w="3px"
h="50px"
bg={gaugeColor}
borderRadius="full"
transformOrigin="bottom center"
transform={`translateX(-50%) rotate(${angle - 90}deg)`}
boxShadow={`0 0 10px ${gaugeColor}`}
transition="transform 0.5s ease-out"
/>
{/* 中心点 */}
<Box
position="absolute"
bottom="0"
left="50%"
transform="translateX(-50%)"
w="12px"
h="12px"
bg={gaugeColor}
borderRadius="full"
boxShadow={`0 0 8px ${gaugeColor}`}
/>
{/* 数值显示 */}
<VStack
position="absolute"
bottom="15px"
left="50%"
transform="translateX(-50%)"
spacing={0}
>
<Text fontSize="2xl" fontWeight="bold" color={gaugeColor} lineHeight="1">
{validRate.toFixed(0)}%
</Text>
<Text fontSize="xs" color="gray.500">胜率</Text>
</VStack>
{/* 刻度标签 */}
<Text position="absolute" bottom="0" left="15px" fontSize="2xs" color="gray.600">0</Text>
<Text position="absolute" bottom="0" right="15px" fontSize="2xs" color="gray.600">100</Text>
</Box>
);
}; };
/** /**
* 紧凑数据卡片 * 紧凑数据卡片
*/ */
const CompactStatCard = ({ label, value, icon, color = '#FFD700', subText, progress }) => ( const CompactStatCard = ({ label, value, icon, color = '#FFD700', subText }) => (
<Box <Box
bg="rgba(0,0,0,0.25)" bg="rgba(0,0,0,0.25)"
borderRadius="md" borderRadius="md"
@@ -95,27 +181,15 @@ const CompactStatCard = ({ label, value, icon, color = '#FFD700', subText, progr
{subText} {subText}
</Text> </Text>
)} )}
{progress !== undefined && (
<Progress
value={progress}
size="xs"
colorScheme={progress >= 60 ? 'green' : progress >= 40 ? 'yellow' : 'red'}
mt={1.5}
borderRadius="full"
bg="rgba(255,255,255,0.08)"
h="3px"
/>
)}
</Box> </Box>
); );
/** /**
* TOP事件列表项 - 支持点击跳转 * TOP事件列表项
*/ */
const TopEventItem = ({ event, rank }) => { const TopEventItem = ({ event, rank }) => {
const handleClick = () => { const handleClick = () => {
if (event.id) { if (event.id) {
// 在新标签页打开事件详情
window.open(getEventDetailUrl(event.id), '_blank'); window.open(getEventDetailUrl(event.id), '_blank');
} }
}; };
@@ -130,9 +204,6 @@ const TopEventItem = ({ event, rank }) => {
_hover={{ bg: 'rgba(255,215,0,0.12)', cursor: 'pointer' }} _hover={{ bg: 'rgba(255,215,0,0.12)', cursor: 'pointer' }}
transition="all 0.15s" transition="all 0.15s"
onClick={handleClick} onClick={handleClick}
role="button"
tabIndex={0}
onKeyPress={(e) => e.key === 'Enter' && handleClick()}
> >
<Badge <Badge
colorScheme={rank === 1 ? 'yellow' : rank === 2 ? 'gray' : 'orange'} colorScheme={rank === 1 ? 'yellow' : rank === 2 ? 'gray' : 'orange'}
@@ -155,28 +226,61 @@ const TopEventItem = ({ event, rank }) => {
{event.title} {event.title}
</Text> </Text>
</Tooltip> </Tooltip>
<Text <Text fontSize="xs" fontWeight="bold" color={getChgColor(event.maxChg)}>
fontSize="xs"
fontWeight="bold"
color={getChgColor(event.maxChg)}
>
{formatChg(event.maxChg)} {formatChg(event.maxChg)}
</Text> </Text>
</HStack> </HStack>
); );
}; };
/**
* TOP股票列表项
*/
const TopStockItem = ({ stock, rank }) => {
return (
<HStack
spacing={2}
py={1.5}
px={2}
bg="rgba(0,0,0,0.2)"
borderRadius="md"
_hover={{ bg: 'rgba(255,215,0,0.12)' }}
transition="all 0.15s"
>
<Badge
colorScheme={rank === 1 ? 'yellow' : rank === 2 ? 'gray' : 'orange'}
fontSize="2xs"
px={1.5}
borderRadius="full"
minW="18px"
textAlign="center"
>
{rank}
</Badge>
<Text fontSize="xs" color="gray.400" w="60px">
{stock.stockCode?.split('.')[0] || '-'}
</Text>
<Text fontSize="xs" color="gray.300" flex="1" noOfLines={1}>
{stock.stockName || '-'}
</Text>
<Text fontSize="xs" fontWeight="bold" color={getChgColor(stock.maxChg)}>
{formatChg(stock.maxChg)}
</Text>
</HStack>
);
};
const EventDailyStats = () => { const EventDailyStats = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [activeTab, setActiveTab] = useState(0);
const fetchStats = useCallback(async () => { const fetchStats = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const apiBase = getApiBase(); const apiBase = getApiBase();
// 获取当日数据days=1 表示当前交易日)
const response = await fetch(`${apiBase}/api/v1/events/effectiveness-stats?days=1`); const response = await fetch(`${apiBase}/api/v1/events/effectiveness-stats?days=1`);
if (!response.ok) throw new Error('获取数据失败'); if (!response.ok) throw new Error('获取数据失败');
const data = await response.json(); const data = await response.json();
@@ -195,7 +299,6 @@ const EventDailyStats = () => {
useEffect(() => { useEffect(() => {
fetchStats(); fetchStats();
// 每5分钟刷新一次
const interval = setInterval(fetchStats, 5 * 60 * 1000); const interval = setInterval(fetchStats, 5 * 60 * 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchStats]); }, [fetchStats]);
@@ -239,7 +342,7 @@ const EventDailyStats = () => {
); );
} }
const { summary, topPerformers = [] } = stats; const { summary, topPerformers = [], topStocks = [] } = stats;
return ( return (
<Box <Box
@@ -264,38 +367,27 @@ const EventDailyStats = () => {
/> />
{/* 标题 */} {/* 标题 */}
<HStack spacing={2} mb={3}> <HStack spacing={2} mb={2}>
<Box <Box
w="3px" w="3px"
h="16px" h="16px"
bg="linear-gradient(180deg, #FFD700, #FFA500)" bg="linear-gradient(180deg, #FFD700, #FFA500)"
borderRadius="full" borderRadius="full"
/> />
<Text <Text fontSize="sm" fontWeight="bold" color="white" letterSpacing="wide">
fontSize="sm"
fontWeight="bold"
color="white"
letterSpacing="wide"
>
今日统计 今日统计
</Text> </Text>
<Badge <Badge colorScheme="yellow" variant="subtle" fontSize="2xs" px={1.5}>
colorScheme="yellow"
variant="subtle"
fontSize="2xs"
px={1.5}
>
实时 实时
</Badge> </Badge>
</HStack> </HStack>
<VStack spacing={3} align="stretch"> <VStack spacing={2} align="stretch">
{/* 胜率仪表盘 */}
<WinRateGauge rate={summary?.positiveRate || 0} />
{/* 核心指标 - 2x2 网格 */} {/* 核心指标 - 2x2 网格 */}
<Box <Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
display="grid"
gridTemplateColumns="repeat(2, 1fr)"
gap={2}
>
<CompactStatCard <CompactStatCard
label="事件数" label="事件数"
value={summary?.totalEvents || 0} value={summary?.totalEvents || 0}
@@ -304,11 +396,11 @@ const EventDailyStats = () => {
subText="今日追踪" subText="今日追踪"
/> />
<CompactStatCard <CompactStatCard
label="胜率" label="关联股票"
value={`${(summary?.positiveRate || 0).toFixed(0)}%`} value={summary?.totalStocks || 0}
icon={<CheckCircleOutlined />} icon={<StockOutlined />}
color={getWinRateColor(summary?.positiveRate || 0)} color="#1890FF"
progress={summary?.positiveRate || 0} subText="去重统计"
/> />
<CompactStatCard <CompactStatCard
label="平均超额" label="平均超额"
@@ -326,63 +418,59 @@ const EventDailyStats = () => {
/> />
</Box> </Box>
{/* 评分指标 */}
<Box
display="grid"
gridTemplateColumns="repeat(2, 1fr)"
gap={2}
>
<CompactStatCard
label="投资价值"
value={(summary?.avgInvestScore || 0).toFixed(0)}
icon={<StarOutlined />}
color="#F59E0B"
progress={summary?.avgInvestScore || 0}
/>
<CompactStatCard
label="超预期分"
value={(summary?.avgSurpriseScore || 0).toFixed(0)}
icon={<TrophyOutlined />}
color="#8B5CF6"
progress={summary?.avgSurpriseScore || 0}
/>
</Box>
{/* 分割线 */} {/* 分割线 */}
<Box h="1px" bg="rgba(255,215,0,0.1)" /> <Box h="1px" bg="rgba(255,215,0,0.1)" />
{/* TOP表现事件 - 显示 TOP10 */} {/* TOP 表现 - Tab 切换 */}
<Box flex="1" overflow="hidden"> <Box flex="1" overflow="hidden">
<HStack spacing={1.5} mb={2}> <Tabs
<TrophyOutlined style={{ color: '#FFD700', fontSize: '12px' }} /> variant="soft-rounded"
<Text fontSize="xs" fontWeight="bold" color="gray.400"> colorScheme="yellow"
今日 TOP 表现 size="sm"
</Text> index={activeTab}
<Text fontSize="2xs" color="gray.600"> onChange={setActiveTab}
(点击查看详情) >
</Text> <TabList mb={2}>
<Tab
fontSize="xs"
py={1}
px={3}
_selected={{ bg: 'rgba(255,215,0,0.2)', color: '#FFD700' }}
color="gray.500"
>
<HStack spacing={1}>
<TrophyOutlined style={{ fontSize: '11px' }} />
<Text>事件TOP10</Text>
</HStack> </HStack>
</Tab>
<Tab
fontSize="xs"
py={1}
px={3}
_selected={{ bg: 'rgba(255,215,0,0.2)', color: '#FFD700' }}
color="gray.500"
>
<HStack spacing={1}>
<StockOutlined style={{ fontSize: '11px' }} />
<Text>股票TOP10</Text>
</HStack>
</Tab>
</TabList>
<TabPanels>
{/* 事件 TOP10 */}
<TabPanel p={0}>
<VStack <VStack
spacing={1} spacing={1}
align="stretch" align="stretch"
maxH="220px" maxH="195px"
overflowY="auto" overflowY="auto"
pr={1} pr={1}
css={{ css={{
'&::-webkit-scrollbar': { '&::-webkit-scrollbar': { width: '4px' },
width: '4px', '&::-webkit-scrollbar-track': { background: 'rgba(255,255,255,0.05)', borderRadius: '2px' },
}, '&::-webkit-scrollbar-thumb': { background: 'rgba(255,215,0,0.3)', borderRadius: '2px' },
'&::-webkit-scrollbar-track': { '&::-webkit-scrollbar-thumb:hover': { background: 'rgba(255,215,0,0.5)' },
background: 'rgba(255,255,255,0.05)',
borderRadius: '2px',
},
'&::-webkit-scrollbar-thumb': {
background: 'rgba(255,215,0,0.3)',
borderRadius: '2px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: 'rgba(255,215,0,0.5)',
},
}} }}
> >
{topPerformers.slice(0, 10).map((event, idx) => ( {topPerformers.slice(0, 10).map((event, idx) => (
@@ -394,6 +482,35 @@ const EventDailyStats = () => {
</Text> </Text>
)} )}
</VStack> </VStack>
</TabPanel>
{/* 股票 TOP10 */}
<TabPanel p={0}>
<VStack
spacing={1}
align="stretch"
maxH="195px"
overflowY="auto"
pr={1}
css={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'rgba(255,255,255,0.05)', borderRadius: '2px' },
'&::-webkit-scrollbar-thumb': { background: 'rgba(255,215,0,0.3)', borderRadius: '2px' },
'&::-webkit-scrollbar-thumb:hover': { background: 'rgba(255,215,0,0.5)' },
}}
>
{topStocks.slice(0, 10).map((stock, idx) => (
<TopStockItem key={stock.stockCode || idx} stock={stock} rank={idx + 1} />
))}
{topStocks.length === 0 && (
<Text fontSize="xs" color="gray.600" textAlign="center" py={2}>
暂无数据
</Text>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Box> </Box>
</VStack> </VStack>
</Box> </Box>