feat(community): 新增 MarketOverviewBanner 组件

- 股票 TOP10 弹窗功能
  - 暗色主题表格样式
This commit is contained in:
zdl
2026-01-13 14:57:04 +08:00
parent de9dfeccca
commit 9bfdd56af1

View File

@@ -0,0 +1,725 @@
/**
* MarketOverviewBanner - 市场与事件概览通栏组件
* 顶部通栏展示市场涨跌分布和事件统计数据
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
Box,
Text,
HStack,
Spinner,
Tooltip,
Flex,
Grid,
Input,
} from "@chakra-ui/react";
import {
FireOutlined,
RiseOutlined,
FallOutlined,
ThunderboltOutlined,
TrophyOutlined,
BarChartOutlined,
CalendarOutlined,
StockOutlined,
} from "@ant-design/icons";
import { Modal, Table } from "antd";
import { getApiBase } from "@utils/apiConfig";
// 涨跌颜色常量
const UP_COLOR = "#FF4D4F"; // 涨 - 红色
const DOWN_COLOR = "#52C41A"; // 跌 - 绿色
const FLAT_COLOR = "#888888"; // 平 - 灰色
/**
* 判断是否在交易时间内 (9:30-15:00)
*/
const isInTradingTime = () => {
const now = new Date();
const hours = now.getHours();
const minutes = now.getMinutes();
const time = hours * 60 + minutes;
return time >= 570 && time <= 900; // 9:30-15:00
};
// 注入脉冲动画样式
if (typeof document !== "undefined") {
const styleId = "market-banner-animations";
if (!document.getElementById(styleId)) {
const styleSheet = document.createElement("style");
styleSheet.id = styleId;
styleSheet.innerText = `
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.1); }
}
`;
document.head.appendChild(styleSheet);
}
}
/**
* 格式化涨跌幅
*/
const formatChg = (val) => {
if (val === null || val === undefined) return "-";
const num = parseFloat(val);
if (isNaN(num)) return "-";
return (num >= 0 ? "+" : "") + num.toFixed(2) + "%";
};
/**
* 沪深实时涨跌条形图组件 - 紧凑版
*/
const MarketStatsBarCompact = ({ marketStats }) => {
if (!marketStats || marketStats.totalCount === 0) return null;
const {
risingCount = 0,
flatCount = 0,
fallingCount = 0,
totalCount = 0,
} = marketStats;
const risePercent = totalCount > 0 ? (risingCount / totalCount) * 100 : 0;
const flatPercent = totalCount > 0 ? (flatCount / totalCount) * 100 : 0;
const fallPercent = totalCount > 0 ? (fallingCount / totalCount) * 100 : 0;
return (
<Box>
{/* 标题 */}
<HStack justify="space-between" mb={2}>
<Text fontSize="xs" color="whiteAlpha.700" fontWeight="medium">
沪深实时涨跌
</Text>
<Text fontSize="xs" color="whiteAlpha.500">
({totalCount})
</Text>
</HStack>
{/* 进度条 */}
<Box
h="16px"
borderRadius="md"
overflow="hidden"
position="relative"
mb={2}
>
<Flex h="100%">
<Box
w={`${risePercent}%`}
h="100%"
bg={UP_COLOR}
transition="width 0.5s ease"
/>
<Box
w={`${flatPercent}%`}
h="100%"
bg={FLAT_COLOR}
transition="width 0.5s ease"
/>
<Box
w={`${fallPercent}%`}
h="100%"
bg={DOWN_COLOR}
transition="width 0.5s ease"
/>
</Flex>
</Box>
{/* 数值标签 */}
<Flex justify="space-between">
<HStack spacing={1}>
<Box w="6px" h="6px" borderRadius="sm" bg={UP_COLOR} />
<Text
fontSize="xs"
color={UP_COLOR}
fontWeight="bold"
fontFamily="monospace"
>
{risingCount}
</Text>
<Text fontSize="2xs" color="whiteAlpha.500">
</Text>
</HStack>
<HStack spacing={1}>
<Box w="6px" h="6px" borderRadius="sm" bg={FLAT_COLOR} />
<Text
fontSize="xs"
color={FLAT_COLOR}
fontWeight="bold"
fontFamily="monospace"
>
{flatCount}
</Text>
<Text fontSize="2xs" color="whiteAlpha.500">
</Text>
</HStack>
<HStack spacing={1}>
<Box w="6px" h="6px" borderRadius="sm" bg={DOWN_COLOR} />
<Text
fontSize="xs"
color={DOWN_COLOR}
fontWeight="bold"
fontFamily="monospace"
>
{fallingCount}
</Text>
<Text fontSize="2xs" color="whiteAlpha.500">
</Text>
</HStack>
</Flex>
</Box>
);
};
/**
* 环形进度图组件 - 仿图片样式
* @param {boolean} noBorder - 是否不显示边框(用于嵌套在其他容器中)
*/
const CircularProgressCard = ({ label, value, color = "#EC4899", size = 44, highlight = false, noBorder = false }) => {
const percentage = parseFloat(value) || 0;
const strokeWidth = 3;
const radius = (size - strokeWidth) / 2;
// 270度圆弧底部有缺口
const arcLength = (270 / 360) * 2 * Math.PI * radius;
const progressLength = (percentage / 100) * arcLength;
return (
<Box
bg={noBorder ? "transparent" : "rgba(255,255,255,0.03)"}
borderRadius="lg"
px={noBorder ? 1 : 2}
py={noBorder ? 0 : 1}
border={noBorder ? "none" : "1px solid"}
borderColor={noBorder ? "transparent" : "rgba(255,255,255,0.06)"}
_hover={noBorder ? {} : {
borderColor: "rgba(255,255,255,0.12)",
bg: "rgba(255,255,255,0.05)",
}}
transition="all 0.2s"
display="flex"
flexDirection="row"
alignItems="center"
justifyContent="center"
gap={2}
>
<Text fontSize="xs" color={highlight ? "white" : "whiteAlpha.600"} fontWeight={highlight ? "bold" : "medium"} whiteSpace="nowrap">
{label}
</Text>
<Box position="relative" w={`${size}px`} h={`${size}px`} flexShrink={0}>
<svg width={size} height={size} style={{ transform: "rotate(135deg)" }}>
{/* 背景圆弧 */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgba(255,255,255,0.15)"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${arcLength} ${2 * Math.PI * radius}`}
/>
{/* 进度圆弧 */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${progressLength} ${2 * Math.PI * radius}`}
style={{
transition: "stroke-dasharray 0.5s ease",
filter: `drop-shadow(0 0 4px ${color})`
}}
/>
</svg>
{/* 中心数值 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
textAlign="center"
>
<Text
fontSize="xs"
fontWeight="bold"
color={color}
lineHeight="1"
textShadow={`0 0 10px ${color}50`}
>
{percentage.toFixed(1)}%
</Text>
</Box>
</Box>
</Box>
);
};
/**
* 紧凑数据卡片 - 通栏版
*/
const BannerStatCard = ({ label, value, icon, color = "#7C3AED", highlight = false }) => (
<Box
bg="rgba(255,255,255,0.03)"
borderRadius="lg"
px={2}
py={1.5}
border="1px solid"
borderColor="rgba(255,255,255,0.06)"
_hover={{
borderColor: "rgba(255,255,255,0.12)",
bg: "rgba(255,255,255,0.05)",
}}
transition="all 0.2s"
position="relative"
overflow="hidden"
>
<HStack spacing={1.5} mb={0.5}>
<Box color={color} fontSize="xs" opacity={0.8}>
{icon}
</Box>
<Text fontSize="2xs" color={highlight ? "white" : "whiteAlpha.600"} fontWeight={highlight ? "bold" : "medium"}>
{label}
</Text>
</HStack>
<Text
fontSize="md"
fontWeight="bold"
color={color}
lineHeight="1.2"
textShadow={`0 0 15px ${color}25`}
>
{value}
</Text>
</Box>
);
const MarketOverviewBanner = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState(null);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split("T")[0]);
const [stockModalVisible, setStockModalVisible] = useState(false);
const dateInputRef = useRef(null);
const fetchStats = useCallback(async (dateStr = "", showLoading = false) => {
if (showLoading) {
setLoading(true);
}
try {
const apiBase = getApiBase();
const dateParam = dateStr ? `&date=${dateStr}` : "";
const response = await fetch(
`${apiBase}/api/v1/events/effectiveness-stats?days=1${dateParam}`
);
if (!response.ok) throw new Error("获取数据失败");
const data = await response.json();
if (data.success || data.code === 200) {
setStats(data.data);
}
} catch (err) {
console.error("获取市场统计失败:", err);
} finally {
setLoading(false);
}
}, []);
// 首次加载显示 loading
useEffect(() => {
fetchStats(selectedDate, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 日期变化时静默刷新(不显示 loading
useEffect(() => {
if (selectedDate) {
fetchStats(selectedDate, false);
}
}, [fetchStats, selectedDate]);
// 自动刷新每60秒仅当选择今天时
useEffect(() => {
if (!selectedDate) {
const interval = setInterval(() => fetchStats(""), 60 * 1000);
return () => clearInterval(interval);
}
}, [selectedDate, fetchStats]);
const handleDateChange = (e) => {
setSelectedDate(e.target.value);
};
const handleCalendarClick = () => {
dateInputRef.current?.showPicker?.();
};
if (loading) {
return (
<Box h="100px" display="flex" alignItems="center" justifyContent="center">
<Spinner size="sm" color="yellow.400" />
</Box>
);
}
if (!stats) return null;
const { summary, marketStats, topStocks = [] } = stats;
return (
<Box position="relative">
{/* 标题行 */}
<Flex justify="space-between" align="center" mb={2}>
<HStack spacing={3}>
<Text fontSize="xl" fontWeight="bold" color="white">
事件中心
</Text>
{/* 交易状态指示器 */}
{isInTradingTime() && (
<HStack
spacing={1.5}
px={2}
py={0.5}
borderRadius="full"
bg="rgba(0,218,60,0.1)"
border="1px solid rgba(0,218,60,0.3)"
>
<Box
w="6px"
h="6px"
borderRadius="full"
bg="#00da3c"
animation="pulse 1.5s infinite"
boxShadow="0 0 8px #00da3c"
/>
<Text fontSize="xs" color="#00da3c" fontWeight="bold">
交易中
</Text>
</HStack>
)}
{/* 实时标签 */}
{!selectedDate && (
<HStack
spacing={1}
px={2}
py={0.5}
borderRadius="full"
bg="rgba(124, 58, 237, 0.1)"
border="1px solid rgba(124, 58, 237, 0.3)"
>
<Box w="5px" h="5px" borderRadius="full" bg="#7C3AED" />
<Text fontSize="xs" color="#A78BFA">
实时
</Text>
</HStack>
)}
</HStack>
<HStack spacing={2}>
{/* 返回今天按钮 - 当选择的不是今天时显示 */}
{selectedDate !== new Date().toISOString().split("T")[0] && (
<Text
fontSize="xs"
color="white"
fontWeight="bold"
cursor="pointer"
px={2}
py={1}
borderRadius="md"
bg="rgba(255, 215, 0, 0.1)"
border="1px solid rgba(255, 215, 0, 0.4)"
_hover={{
bg: "rgba(255, 215, 0, 0.2)",
borderColor: "rgba(255, 215, 0, 0.6)",
}}
transition="all 0.2s"
onClick={() => setSelectedDate(new Date().toISOString().split("T")[0])}
>
返回今天
</Text>
)}
{/* 日期选择器 - 金色边框 */}
<Box
as="label"
display="flex"
alignItems="center"
gap={2}
px={3}
py={1}
cursor="pointer"
borderRadius="md"
border="1px solid"
borderColor="rgba(255, 215, 0, 0.4)"
bg="rgba(255, 215, 0, 0.05)"
_hover={{
borderColor: "rgba(255, 215, 0, 0.6)",
bg: "rgba(255, 215, 0, 0.1)",
}}
transition="all 0.2s"
>
<CalendarOutlined
style={{ color: "#FFD700", fontSize: "12px", cursor: "pointer" }}
onClick={handleCalendarClick}
/>
<Input
ref={dateInputRef}
type="date"
size="xs"
value={selectedDate}
onChange={handleDateChange}
onClick={handleCalendarClick}
max={new Date().toISOString().split("T")[0]}
bg="transparent"
border="none"
color="#FFD700"
fontSize="xs"
w="95px"
h="18px"
p={0}
cursor="pointer"
_hover={{ border: "none" }}
_focus={{ border: "none", boxShadow: "none" }}
css={{
"&::-webkit-calendar-picker-indicator": {
filter: "invert(0.8)",
cursor: "pointer",
opacity: 0.6,
},
}}
/>
</Box>
</HStack>
</Flex>
{/* 内容:左右布局 */}
<Flex gap={4} align="stretch">
{/* 左侧:涨跌条形图 */}
<Box w="220px" flexShrink={0}>
<MarketStatsBarCompact marketStats={marketStats} />
</Box>
{/* 右侧6个指标卡片 - 1行6列 */}
<Grid templateColumns="repeat(6, 1fr)" gap={1.5} flex="1">
<CircularProgressCard
label="事件胜率"
value={summary?.positiveRate || 0}
color="#EC4899"
highlight
/>
<CircularProgressCard
label="大盘上涨率"
value={marketStats?.risingRate || 0}
color="#EC4899"
highlight
/>
<BannerStatCard
label="平均超额"
value={formatChg(summary?.avgChg)}
icon={summary?.avgChg >= 0 ? <RiseOutlined /> : <FallOutlined />}
color={summary?.avgChg >= 0 ? UP_COLOR : DOWN_COLOR}
highlight
/>
<BannerStatCard
label="最大超额"
value={formatChg(summary?.maxChg)}
icon={summary?.maxChg >= 0 ? <RiseOutlined /> : <FallOutlined />}
color={summary?.maxChg >= 0 ? UP_COLOR : DOWN_COLOR}
highlight
/>
<BannerStatCard
label="事件数"
value={summary?.totalEvents || 0}
icon={<FireOutlined />}
color="#F59E0B"
highlight
/>
{/* 关联股票 + TOP10标签 - 与 BannerStatCard 样式统一 */}
<Box
bg="rgba(255,255,255,0.03)"
borderRadius="lg"
px={2}
py={1.5}
border="1px solid"
borderColor="rgba(255,255,255,0.06)"
_hover={{
borderColor: "rgba(255,255,255,0.12)",
bg: "rgba(255,255,255,0.05)",
}}
transition="all 0.2s"
position="relative"
overflow="hidden"
>
<HStack spacing={1.5} mb={0.5}>
<Box color="#06B6D4" fontSize="xs" opacity={0.8}>
<BarChartOutlined />
</Box>
<Text fontSize="2xs" color="white" fontWeight="bold">
关联股票
</Text>
<Tooltip label="查看股票TOP10" placement="top" hasArrow>
<Text
fontSize="2xs"
color="#EC4899"
fontWeight="bold"
cursor="pointer"
px={1}
py={0.5}
borderRadius="sm"
bg="rgba(236, 72, 153, 0.15)"
border="1px solid rgba(236, 72, 153, 0.3)"
lineHeight="1"
_hover={{
bg: "rgba(236, 72, 153, 0.25)",
transform: "scale(1.05)",
}}
transition="all 0.2s"
onClick={() => setStockModalVisible(true)}
>
TOP10
</Text>
</Tooltip>
</HStack>
<Text
fontSize="md"
fontWeight="bold"
color="#06B6D4"
lineHeight="1.2"
textShadow="0 0 15px rgba(6, 182, 212, 0.25)"
>
{summary?.totalStocks || 0}
</Text>
</Box>
</Grid>
</Flex>
{/* 股票TOP10弹窗 */}
<Modal
title={
<HStack spacing={2}>
<StockOutlined style={{ color: "white" }} />
<Text color="white">股票 TOP10</Text>
</HStack>
}
open={stockModalVisible}
onCancel={() => setStockModalVisible(false)}
footer={null}
width={600}
closeIcon={<span style={{ color: "white" }}>×</span>}
styles={{
content: {
background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)",
border: "1px solid rgba(236, 72, 153, 0.3)",
},
header: {
background: "transparent",
borderBottom: "1px solid rgba(255,255,255,0.1)",
},
}}
>
<style>
{`
.stock-top10-table .ant-table {
background: transparent !important;
}
.stock-top10-table .ant-table-thead > tr > th {
background: rgba(236, 72, 153, 0.1) !important;
color: rgba(255, 255, 255, 0.8) !important;
border-bottom: 1px solid rgba(236, 72, 153, 0.2) !important;
font-weight: 600;
}
.stock-top10-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
padding: 10px 8px !important;
}
.stock-top10-table .ant-table-tbody > tr:nth-child(odd) {
background: rgba(255, 255, 255, 0.02) !important;
}
.stock-top10-table .ant-table-tbody > tr:nth-child(even) {
background: rgba(0, 0, 0, 0.1) !important;
}
.stock-top10-table .ant-table-tbody > tr:hover > td {
background: rgba(236, 72, 153, 0.15) !important;
}
.stock-top10-table .ant-table-cell {
background: transparent !important;
}
`}
</style>
<Table
dataSource={topStocks.slice(0, 10)}
rowKey={(record) => record.stockCode || record.stockName}
pagination={false}
size="small"
className="stock-top10-table"
columns={[
{
title: "排名",
dataIndex: "rank",
key: "rank",
width: 60,
render: (_, __, index) => (
<Text
fontWeight="bold"
color={
index === 0
? "#FFD700"
: index === 1
? "#C0C0C0"
: index === 2
? "#CD7F32"
: "gray.400"
}
>
{index + 1}
</Text>
),
},
{
title: "股票代码",
dataIndex: "stockCode",
key: "stockCode",
width: 100,
render: (code) => (
<Text color="gray.400" fontSize="sm">
{code?.split(".")[0] || "-"}
</Text>
),
},
{
title: "股票名称",
dataIndex: "stockName",
key: "stockName",
render: (name) => (
<Text color="white" fontWeight="medium">
{name || "-"}
</Text>
),
},
{
title: "最大涨幅",
dataIndex: "maxChg",
key: "maxChg",
width: 100,
align: "right",
render: (val) => (
<Text
fontWeight="bold"
color={val >= 0 ? UP_COLOR : DOWN_COLOR}
>
{formatChg(val)}
</Text>
),
},
]}
/>
</Modal>
</Box>
);
};
export default MarketOverviewBanner;