Merge branch 'feature_bugfix/20260112_count' into feature_bugfix/20260106

合并 HeroPanel 模块化重构及多个组件优化:
- HeroPanel: 3000+ 行拆分为模块化子组件 (~219行)
- ThemeCometChart/MarketOverviewBanner: 提取常量和子组件
- CompactSearchBox/TradingTimeFilter: 提取工具函数
- MainlineTimeline: 提取时间线子组件
- StockChangeIndicators: 修复 React Hooks 规则

冲突解决:保留重构后的精简版 HeroPanel.js

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2026-01-15 15:11:51 +08:00
69 changed files with 13193 additions and 9117 deletions

View File

@@ -4,14 +4,19 @@
* 使用 dayCellDidMount 钩子实现完整的单元格自定义内容
*/
import React, { useMemo, useRef, useCallback, useEffect } from 'react';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { EventInput, EventClickArg, DatesSetArg, DayCellMountArg } from '@fullcalendar/core';
import { Box, Text, VStack, Tooltip } from '@chakra-ui/react';
import { keyframes } from '@emotion/react';
import dayjs from 'dayjs';
import React, { useMemo, useRef, useCallback, useEffect } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import type {
EventInput,
EventClickArg,
DatesSetArg,
DayCellMountArg,
} from "@fullcalendar/core";
import { Box, Text, VStack, Tooltip } from "@chakra-ui/react";
import { keyframes } from "@emotion/react";
import dayjs from "dayjs";
// 动画定义
const shimmer = keyframes`
@@ -44,7 +49,12 @@ export interface FullCalendarProProps {
/** 日期点击回调 */
onDateClick?: (date: Date, data?: CalendarEventData) => void;
/** 事件点击回调(点击跨天条) */
onEventClick?: (event: { title: string; start: Date; end: Date; dates: string[] }) => void;
onEventClick?: (event: {
title: string;
start: Date;
end: Date;
dates: string[];
}) => void;
/** 月份变化回调 */
onMonthChange?: (year: number, month: number) => void;
/** 当前月份 */
@@ -56,18 +66,61 @@ export interface FullCalendarProProps {
/**
* 概念颜色映射 - 为不同概念生成不同的渐变色
*/
const CONCEPT_COLORS: Record<string, { bg: string; border: string; text: string }> = {};
const CONCEPT_COLORS: Record<
string,
{ bg: string; border: string; text: string }
> = {};
const COLOR_PALETTE = [
{ bg: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', border: '#FFD700', text: '#1a1a2e' }, // 金色 - 深色文字
{ bg: 'linear-gradient(135deg, #00CED1 0%, #20B2AA 100%)', border: '#00CED1', text: '#1a1a2e' }, // 青色 - 深色文字
{ bg: 'linear-gradient(135deg, #FF6B6B 0%, #EE5A5A 100%)', border: '#FF6B6B', text: '#fff' }, // 红色 - 白色文字
{ bg: 'linear-gradient(135deg, #A855F7 0%, #9333EA 100%)', border: '#A855F7', text: '#fff' }, // 紫色 - 白色文字
{ bg: 'linear-gradient(135deg, #3B82F6 0%, #2563EB 100%)', border: '#3B82F6', text: '#fff' }, // 色 - 色文字
{ bg: 'linear-gradient(135deg, #10B981 0%, #059669 100%)', border: '#10B981', text: '#1a1a2e' }, // 绿色 - 深色文字
{ bg: 'linear-gradient(135deg, #F59E0B 0%, #D97706 100%)', border: '#F59E0B', text: '#1a1a2e' }, // 橙色 - 深色文字
{ bg: 'linear-gradient(135deg, #EC4899 0%, #DB2777 100%)', border: '#EC4899', text: '#fff' }, // 粉色 - 白色文字
{ bg: 'linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)', border: '#6366F1', text: '#fff' }, // 靛蓝 - 白色文字
{ bg: 'linear-gradient(135deg, #14B8A6 0%, #0D9488 100%)', border: '#14B8A6', text: '#1a1a2e' }, // 青绿 - 深色文字
{
bg: "linear-gradient(135deg, #FFD700 0%, #FFA500 100%)",
border: "#FFD700",
text: "#1a1a2e",
}, // 色 - 色文字
{
bg: "linear-gradient(135deg, #00CED1 0%, #20B2AA 100%)",
border: "#00CED1",
text: "#1a1a2e",
}, // 青 - 深色文字
{
bg: "linear-gradient(135deg, #FF6B6B 0%, #EE5A5A 100%)",
border: "#FF6B6B",
text: "#fff",
}, // 红色 - 白色文字
{
bg: "linear-gradient(135deg, #A855F7 0%, #9333EA 100%)",
border: "#A855F7",
text: "#fff",
}, // 紫色 - 白色文字
{
bg: "linear-gradient(135deg, #3B82F6 0%, #2563EB 100%)",
border: "#3B82F6",
text: "#fff",
}, // 蓝色 - 白色文字
{
bg: "linear-gradient(135deg, #10B981 0%, #059669 100%)",
border: "#10B981",
text: "#1a1a2e",
}, // 绿色 - 深色文字
{
bg: "linear-gradient(135deg, #F59E0B 0%, #D97706 100%)",
border: "#F59E0B",
text: "#1a1a2e",
}, // 橙色 - 深色文字
{
bg: "linear-gradient(135deg, #EC4899 0%, #DB2777 100%)",
border: "#EC4899",
text: "#fff",
}, // 粉色 - 白色文字
{
bg: "linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)",
border: "#6366F1",
text: "#fff",
}, // 靛蓝 - 白色文字
{
bg: "linear-gradient(135deg, #14B8A6 0%, #0D9488 100%)",
border: "#14B8A6",
text: "#1a1a2e",
}, // 青绿 - 深色文字
];
let colorIndex = 0;
@@ -88,11 +141,17 @@ const mergeConsecutiveConcepts = (data: CalendarEventData[]): EventInput[] => {
// 按日期排序
const sorted = [...data]
.filter(d => d.topSector)
.filter((d) => d.topSector)
.sort((a, b) => a.date.localeCompare(b.date));
const events: EventInput[] = [];
let currentEvent: { concept: string; startDate: string; endDate: string; dates: string[]; totalCount: number } | null = null;
let currentEvent: {
concept: string;
startDate: string;
endDate: string;
dates: string[];
totalCount: number;
} | null = null;
sorted.forEach((item, index) => {
const dateStr = item.date;
@@ -100,7 +159,8 @@ const mergeConsecutiveConcepts = (data: CalendarEventData[]): EventInput[] => {
// 检查是否与前一天连续且概念相同
const prevItem = sorted[index - 1];
const isConsecutive = prevItem &&
const isConsecutive =
prevItem &&
concept === prevItem.topSector &&
isNextDay(prevItem.date, dateStr);
@@ -141,11 +201,11 @@ const mergeConsecutiveConcepts = (data: CalendarEventData[]): EventInput[] => {
* 检查两个日期是否连续(跳过周末)
*/
const isNextDay = (date1: string, date2: string): boolean => {
const d1 = dayjs(date1, 'YYYYMMDD');
const d2 = dayjs(date2, 'YYYYMMDD');
const d1 = dayjs(date1, "YYYYMMDD");
const d2 = dayjs(date2, "YYYYMMDD");
// 简单判断相差1-3天内考虑周末
const diff = d2.diff(d1, 'day');
const diff = d2.diff(d1, "day");
if (diff === 1) return true;
if (diff === 2 && d1.day() === 5) return true; // 周五到周日
if (diff === 3 && d1.day() === 5) return true; // 周五到周一
@@ -155,18 +215,24 @@ const isNextDay = (date1: string, date2: string): boolean => {
/**
* 创建 FullCalendar 事件对象
*/
const createEventInput = (event: { concept: string; startDate: string; endDate: string; dates: string[]; totalCount: number }): EventInput => {
const createEventInput = (event: {
concept: string;
startDate: string;
endDate: string;
dates: string[];
totalCount: number;
}): EventInput => {
const color = getConceptColor(event.concept);
const startDate = dayjs(event.startDate, 'YYYYMMDD');
const endDate = dayjs(event.endDate, 'YYYYMMDD').add(1, 'day'); // FullCalendar 的 end 是 exclusive
const startDate = dayjs(event.startDate, "YYYYMMDD");
const endDate = dayjs(event.endDate, "YYYYMMDD").add(1, "day"); // FullCalendar 的 end 是 exclusive
return {
id: `${event.concept}-${event.startDate}`,
title: event.concept,
start: startDate.format('YYYY-MM-DD'),
end: endDate.format('YYYY-MM-DD'),
backgroundColor: 'transparent',
borderColor: 'transparent',
start: startDate.format("YYYY-MM-DD"),
end: endDate.format("YYYY-MM-DD"),
backgroundColor: "transparent",
borderColor: "transparent",
textColor: color.text,
extendedProps: {
concept: event.concept,
@@ -192,66 +258,58 @@ const createCellContentHTML = (
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
const hasZtData = dateData && dateData.count > 0;
const hasEventCount = dateData?.eventCount && dateData.eventCount > 0;
const hasIndexChange = dateData?.indexChange !== undefined && dateData?.indexChange !== null;
const hasIndexChange =
dateData?.indexChange !== undefined && dateData?.indexChange !== null;
// 日期颜色
const dateColor = isToday ? '#FFD700' : isWeekend ? '#FB923C' : '#FFFFFF';
const dateFontWeight = isToday ? 'bold' : '600';
const dateColor = isToday ? "#FFD700" : isWeekend ? "#FB923C" : "#FFFFFF";
const dateFontWeight = isToday ? "bold" : "600";
// 上证涨跌幅
let indexChangeHTML = '';
let indexChangeHTML = "";
if (hasIndexChange) {
const indexChange = dateData.indexChange!;
const indexColor = indexChange >= 0 ? '#EF4444' : '#22C55E';
const sign = indexChange >= 0 ? '+' : '';
indexChangeHTML = `<span style="font-size: 13px; font-weight: 700; color: ${indexColor};">${sign}${indexChange.toFixed(2)}%</span>`;
const indexColor = indexChange >= 0 ? "#EF4444" : "#22C55E";
const sign = indexChange >= 0 ? "+" : "";
indexChangeHTML = `<span style="font-size: 12px; font-weight: 700; color: ${indexColor};">${sign}${indexChange.toFixed(1)}%</span>`;
}
// 涨停数据
let ztDataHTML = '';
// 涨停数据(热度)
let ztDataHTML = "";
if (hasZtData) {
const ztColor = dateData.count >= 60 ? '#EF4444' : '#F59E0B';
const ztColor = dateData.count >= 60 ? "#EF4444" : "#F59E0B";
ztDataHTML = `
<div style="display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 4px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${ztColor}" stroke-width="2">
<span style="display: inline-flex; align-items: center; gap: 2px;">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="${ztColor}" stroke-width="2">
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>
</svg>
<span style="font-size: 15px; font-weight: bold; color: ${ztColor};">${dateData.count}</span>
</div>
<span style="font-size: 12px; font-weight: bold; color: ${ztColor};">${dateData.count}</span>
</span>
`;
}
// 未来事件计数
let eventCountHTML = '';
let eventCountHTML = "";
if (hasEventCount) {
eventCountHTML = `
<div style="display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 4px;">
<div style="
width: 18px;
height: 18px;
border-radius: 50%;
background: linear-gradient(135deg, #22C55E 0%, #16A34A 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
">
<span style="font-size: 11px; font-weight: bold; color: white;">${dateData.eventCount}</span>
</div>
<span style="font-size: 13px; color: #22C55E; font-weight: 600;">事件</span>
</div>
<span style="display: inline-flex; align-items: center; gap: 1px;">
<span style="font-size: 12px; font-weight: bold; color: #22C55E;">${dateData.eventCount}</span>
<span style="font-size: 10px; color: #22C55E;">事件</span>
</span>
`;
}
return `
<div class="fc-custom-cell-content" style="width: 100%; padding: 4px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<span style="font-size: 18px; font-weight: ${dateFontWeight}; color: ${dateColor};">${dayNum}</span>
<div class="fc-custom-cell-content" style="width: 100%; padding: 2px 4px;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 3px;">
<span style="font-size: 13px; font-weight: ${dateFontWeight}; color: ${dateColor};">${dayNum}</span>
${indexChangeHTML}
</div>
<div style="display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 2px; height: 12px;">
${ztDataHTML}
${eventCountHTML}
</div>
</div>
`;
};
@@ -264,7 +322,7 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
onEventClick,
onMonthChange,
currentMonth,
height = '600px',
height = "auto",
}) => {
const calendarRef = useRef<FullCalendar>(null);
const dataMapRef = useRef<Map<string, CalendarEventData>>(new Map());
@@ -275,7 +333,7 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
// 创建日期数据映射
const dataMap = useMemo(() => {
const map = new Map<string, CalendarEventData>();
data.forEach(d => map.set(d.date, d));
data.forEach((d) => map.set(d.date, d));
return map;
}, [data]);
@@ -290,19 +348,19 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
// 获取所有日期单元格并更新内容
const calendarEl = calendarRef.current.getApi().el;
const dayCells = calendarEl?.querySelectorAll('.fc-daygrid-day');
const dayCells = calendarEl?.querySelectorAll(".fc-daygrid-day");
dayCells?.forEach((cell: Element) => {
const dateAttr = cell.getAttribute('data-date');
const dateAttr = cell.getAttribute("data-date");
if (!dateAttr) return;
const date = new Date(dateAttr);
const dateStr = dayjs(date).format('YYYYMMDD');
const dateStr = dayjs(date).format("YYYYMMDD");
const dateData = dataMapRef.current.get(dateStr);
const isToday = dayjs(date).isSame(dayjs(), 'day');
const isToday = dayjs(date).isSame(dayjs(), "day");
// 找到 day-top 容器并更新内容
const dayTop = cell.querySelector('.fc-daygrid-day-top');
const dayTop = cell.querySelector(".fc-daygrid-day-top");
if (dayTop) {
dayTop.innerHTML = createCellContentHTML(date, dateData, isToday);
}
@@ -310,14 +368,18 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
}, [dataMap]);
// 处理日期点击
const handleDateClick = useCallback((arg: { date: Date; dateStr: string }) => {
const dateStr = dayjs(arg.date).format('YYYYMMDD');
const handleDateClick = useCallback(
(arg: { date: Date; dateStr: string }) => {
const dateStr = dayjs(arg.date).format("YYYYMMDD");
const dateData = dataMapRef.current.get(dateStr);
onDateClick?.(arg.date, dateData);
}, [onDateClick]);
},
[onDateClick]
);
// 处理事件点击
const handleEventClick = useCallback((arg: EventClickArg) => {
const handleEventClick = useCallback(
(arg: EventClickArg) => {
const { extendedProps } = arg.event;
if (arg.event.start && arg.event.end) {
onEventClick?.({
@@ -327,36 +389,44 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
dates: extendedProps.dates as string[],
});
}
}, [onEventClick]);
},
[onEventClick]
);
// 处理月份变化
const handleDatesSet = useCallback((arg: DatesSetArg) => {
const handleDatesSet = useCallback(
(arg: DatesSetArg) => {
const visibleDate = arg.view.currentStart;
onMonthChange?.(visibleDate.getFullYear(), visibleDate.getMonth() + 1);
}, [onMonthChange]);
},
[onMonthChange]
);
// 单元格挂载时插入自定义内容
const handleDayCellDidMount = useCallback((arg: DayCellMountArg) => {
const { date, el, isToday } = arg;
const dateStr = dayjs(date).format('YYYYMMDD');
const dateStr = dayjs(date).format("YYYYMMDD");
const dateData = dataMapRef.current.get(dateStr);
// 找到 day-top 容器并插入自定义内容
const dayTop = el.querySelector('.fc-daygrid-day-top');
const dayTop = el.querySelector(".fc-daygrid-day-top");
if (dayTop) {
// 清空默认内容
dayTop.innerHTML = '';
dayTop.innerHTML = "";
// 插入自定义内容
dayTop.innerHTML = createCellContentHTML(date, dateData, isToday);
}
}, []);
// 自定义事件内容(跨天条)
const eventContent = useCallback((arg: { event: { title: string; extendedProps: Record<string, unknown> } }) => {
const eventContent = useCallback(
(arg: {
event: { title: string; extendedProps: Record<string, unknown> };
}) => {
const { extendedProps } = arg.event;
const daysCount = extendedProps.daysCount as number;
const totalCount = extendedProps.totalCount as number;
const textColor = (extendedProps.textColor as string) || '#fff';
const textColor = (extendedProps.textColor as string) || "#fff";
const gradient = extendedProps.gradient as string;
const borderColor = extendedProps.borderColor as string;
@@ -364,9 +434,9 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
<Tooltip
label={
<VStack spacing={1} align="start" p={1}>
<Text fontWeight="bold">{arg.event.title}</Text>
<Text fontSize="xs"> {daysCount} </Text>
<Text fontSize="xs"> {totalCount} </Text>
<Text fontWeight="bold" color="white">{arg.event.title}</Text>
<Text fontSize="xs" color="white"> {daysCount} </Text>
<Text fontSize="xs" color="white"> {totalCount} </Text>
</VStack>
}
placement="top"
@@ -377,9 +447,9 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
>
<Box
w="100%"
h="26px"
h="18px"
bg={gradient}
borderRadius="lg"
borderRadius="md"
border={`1px solid ${borderColor}`}
display="flex"
alignItems="center"
@@ -387,7 +457,7 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
cursor="pointer"
transition="all 0.2s"
_hover={{
transform: 'scale(1.02)',
transform: "scale(1.02)",
boxShadow: `0 0 12px ${borderColor}`,
}}
overflow="hidden"
@@ -406,17 +476,17 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
opacity={0.5}
/>
<Text
fontSize="sm"
fontSize="xs"
fontWeight="bold"
color={textColor}
noOfLines={1}
px={3}
px={2}
position="relative"
zIndex={1}
>
{arg.event.title}
{daysCount > 1 && (
<Text as="span" fontSize="xs" ml={1} opacity={0.8}>
<Text as="span" fontSize="2xs" ml={1} opacity={0.8}>
({daysCount})
</Text>
)}
@@ -424,7 +494,9 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
</Box>
</Tooltip>
);
}, []);
},
[]
);
return (
<Box
@@ -432,119 +504,130 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
position="relative"
sx={{
// FullCalendar 深色主题样式
'.fc': {
fontFamily: 'inherit',
".fc": {
fontFamily: "inherit",
},
'.fc-theme-standard': {
bg: 'transparent',
".fc-theme-standard": {
bg: "transparent",
},
'.fc-theme-standard td, .fc-theme-standard th': {
borderColor: 'rgba(212, 175, 55, 0.15)',
".fc-theme-standard td, .fc-theme-standard th": {
borderColor: "rgba(212, 175, 55, 0.15)",
},
'.fc-theme-standard .fc-scrollgrid': {
borderColor: 'rgba(212, 175, 55, 0.2)',
".fc-theme-standard .fc-scrollgrid": {
borderColor: "rgba(212, 175, 55, 0.2)",
},
// 工具栏
'.fc-toolbar-title': {
fontSize: '1.5rem !important',
fontWeight: 'bold !important',
background: 'linear-gradient(135deg, #FFD700 0%, #F5E6A3 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
".fc-toolbar": {
marginBottom: "0.5em !important",
},
'.fc-button': {
bg: 'rgba(212, 175, 55, 0.2) !important',
border: '1px solid rgba(212, 175, 55, 0.4) !important',
color: '#FFD700 !important',
borderRadius: '8px !important',
transition: 'all 0.2s !important',
'&:hover': {
bg: 'rgba(212, 175, 55, 0.3) !important',
transform: 'scale(1.05)',
".fc-toolbar-title": {
fontSize: "0.95rem !important",
fontWeight: "bold !important",
background: "linear-gradient(135deg, #FFD700 0%, #F5E6A3 100%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
color: "transparent",
},
'&:disabled': {
".fc-button": {
bg: "rgba(212, 175, 55, 0.2) !important",
border: "1px solid rgba(212, 175, 55, 0.4) !important",
color: "#FFD700 !important",
borderRadius: "6px !important",
padding: "4px 8px !important",
fontSize: "12px !important",
transition: "all 0.2s !important",
"&:hover": {
bg: "rgba(212, 175, 55, 0.3) !important",
transform: "scale(1.05)",
},
"&:disabled": {
opacity: 0.5,
},
},
'.fc-button-active': {
bg: 'rgba(212, 175, 55, 0.4) !important',
".fc-button-active": {
bg: "rgba(212, 175, 55, 0.4) !important",
},
// 星期头
'.fc-col-header-cell': {
bg: 'rgba(212, 175, 55, 0.1)',
py: '12px !important',
".fc-col-header": {
bg: "rgba(15, 15, 22, 0.95) !important",
},
'.fc-col-header-cell-cushion': {
color: '#FFD700 !important',
fontWeight: '600 !important',
fontSize: '14px',
".fc-col-header-cell": {
bg: "transparent !important",
py: "6px !important",
borderColor: "rgba(255, 215, 0, 0.1) !important",
},
".fc-col-header-cell-cushion": {
color: "white !important",
fontWeight: "600 !important",
fontSize: "12px",
},
".fc-scrollgrid-section-header": {
bg: "rgba(15, 15, 22, 0.95) !important",
},
".fc-scrollgrid-section-header > td": {
bg: "rgba(15, 15, 22, 0.95) !important",
borderColor: "rgba(255, 215, 0, 0.1) !important",
},
// 日期格子
'.fc-daygrid-day': {
bg: 'rgba(15, 15, 22, 0.4)',
transition: 'all 0.2s',
cursor: 'pointer',
'&:hover': {
bg: 'rgba(212, 175, 55, 0.1)',
".fc-daygrid-day": {
bg: "rgba(15, 15, 22, 0.4)",
transition: "all 0.2s",
cursor: "pointer",
"&:hover": {
bg: "rgba(212, 175, 55, 0.1)",
},
},
'.fc-daygrid-day.fc-day-today': {
bg: 'rgba(212, 175, 55, 0.15) !important',
".fc-daygrid-day.fc-day-today": {
bg: "rgba(212, 175, 55, 0.15) !important",
animation: `${glow} 2s ease-in-out infinite`,
},
// 单元格框架 - 保证足够高度且支持层级布局
'.fc-daygrid-day-frame': {
minHeight: '110px',
position: 'relative',
".fc-daygrid-day-frame": {
minHeight: "50px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
},
// 日期内容区域 - 在最上层,确保不被事件条遮挡
'.fc-daygrid-day-top': {
width: '100%',
padding: '0 !important',
flexDirection: 'column',
position: 'relative',
zIndex: 3,
".fc-daygrid-day-top": {
width: "100%",
padding: "0 !important",
flexDirection: "column",
},
// 隐藏 FullCalendar 默认的日期数字链接
'.fc-daygrid-day-top a.fc-daygrid-day-number': {
display: 'none !important',
".fc-daygrid-day-top a.fc-daygrid-day-number": {
display: "none !important",
},
// 非当月日期
'.fc-day-other': {
".fc-day-other": {
opacity: 0.4,
},
// 自定义单元格内容样式
'.fc-custom-cell-content': {
width: '100%',
".fc-custom-cell-content": {
width: "100%",
},
// 事件区域 - 在日期内容下方
'.fc-daygrid-day-events': {
marginTop: '0 !important',
position: 'relative',
zIndex: 2,
// 日期内容区域
".fc-daygrid-day-events": {
marginTop: "auto !important",
marginBottom: "0 !important",
paddingBottom: "0 !important",
},
// 事件条样式
'.fc-daygrid-event': {
borderRadius: '8px',
border: 'none !important',
margin: '2px 4px !important',
// 事件
".fc-daygrid-event": {
borderRadius: "6px",
border: "none !important",
margin: "0 3px !important",
marginBottom: "0 !important",
},
'.fc-event-main': {
padding: '0 !important',
".fc-event-main": {
padding: "0 !important",
},
'.fc-daygrid-event-harness': {
marginTop: '4px !important',
},
// 跨周事件底部容器
'.fc-daygrid-day-bottom': {
marginTop: '0 !important',
".fc-daygrid-event-harness": {
marginTop: "2px",
},
// 更多事件链接
'.fc-daygrid-more-link': {
color: '#FFD700 !important',
fontWeight: '600',
fontSize: '11px',
".fc-daygrid-more-link": {
color: "#FFD700 !important",
fontWeight: "600",
fontSize: "11px",
},
}}
>
@@ -555,12 +638,12 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
initialDate={currentMonth}
locale="zh-cn"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: '',
left: "prev,next today",
center: "title",
right: "",
}}
buttonText={{
today: '今天',
today: "今天",
}}
events={events}
dateClick={handleDateClick}
@@ -571,7 +654,8 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
dayMaxEvents={3}
moreLinkText={(n) => `+${n} 更多`}
fixedWeekCount={false}
height="100%"
height="auto"
contentHeight="auto"
/>
</Box>
);

View File

@@ -32,10 +32,14 @@ const CalendarButton = memo(() => {
<>
<Button
size="sm"
colorScheme="blue"
variant="solid"
borderRadius="full"
variant="ghost"
color="gray.700"
fontWeight="medium"
leftIcon={<Calendar size={16} />}
_hover={{
bg: 'gray.100',
color: 'gray.900',
}}
onClick={() => setIsModalOpen(true)}
>
投资日历

View File

@@ -2,13 +2,12 @@
// Navbar 右侧功能区组件
import React, { memo } from 'react';
import { HStack, IconButton, Box } from '@chakra-ui/react';
import { HStack, IconButton, Box, Divider } from '@chakra-ui/react';
import { Menu } from 'lucide-react';
// import ThemeToggleButton from '../ThemeToggleButton'; // ❌ 已删除 - 不再支持深色模式切换
import LoginButton from '../LoginButton';
import CalendarButton from '../CalendarButton';
// import CalendarButton from '../CalendarButton'; // 暂时注释
import { DesktopUserMenu, TabletUserMenu } from '../UserMenu';
import { PersonalCenterMenu, MoreMenu } from '../Navigation';
import { MySpaceButton, MoreMenu } from '../Navigation';
/**
* Navbar 右侧功能区组件
@@ -48,28 +47,30 @@ const NavbarActions = memo(({
) : isAuthenticated && user ? (
// 已登录状态 - 用户菜单 + 功能菜单排列
<HStack spacing={{ base: 2, md: 3 }}>
{/* 投资日历 - 仅大屏显示 */}
{isDesktop && <CalendarButton />}
{/* 投资日历 - 暂时注释 */}
{/* {isDesktop && <CalendarButton />} */}
{/* 头像区域 - 响应式 */}
{/* 桌面端布局:[我的空间] | [头像][用户名] */}
{isDesktop ? (
<DesktopUserMenu user={user} />
) : (
<TabletUserMenu
user={user}
handleLogout={handleLogout}
<>
<MySpaceButton />
<Divider
orientation="vertical"
h="24px"
borderColor="gray.300"
/>
)}
{/* 头像右侧的菜单 - 响应式(互斥逻辑,确保只渲染一个) */}
{isDesktop ? (
// 桌面端:个人中心下拉菜单
<PersonalCenterMenu user={user} handleLogout={handleLogout} />
<DesktopUserMenu user={user} handleLogout={handleLogout} />
</>
) : isTablet ? (
// 平板端MoreMenu 下拉菜单
// 平板端:头像 + MoreMenu
<>
<TabletUserMenu user={user} handleLogout={handleLogout} />
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
</>
) : (
// 移动端:汉堡菜单(打开抽屉)
// 移动端:头像 + 汉堡菜单
<>
<TabletUserMenu user={user} handleLogout={handleLogout} />
<IconButton
icon={<Menu size={20} />}
variant="ghost"
@@ -77,6 +78,7 @@ const NavbarActions = memo(({
aria-label="打开菜单"
size="md"
/>
</>
)}
</HStack>
) : (

View File

@@ -0,0 +1,38 @@
// src/components/Navbars/components/Navigation/MySpaceButton.js
// 「我的空间」独立跳转按钮 - 点击直接跳转至个人中心
import React, { memo } from 'react';
import { Button } from '@chakra-ui/react';
import { useNavigate } from 'react-router-dom';
/**
* 「我的空间」独立按钮组件
* 点击直接跳转至个人中心页面
*
* 黑金主题配色:
* - 默认:金色文字 (#CC9C00)
* - hover浅金背景 (#FFF9E6) + 深金文字 (#997500)
*/
const MySpaceButton = memo(() => {
const navigate = useNavigate();
return (
<Button
size="sm"
variant="ghost"
color="gray.700"
fontWeight="medium"
_hover={{
bg: 'gray.100',
color: 'gray.900',
}}
onClick={() => navigate('/home/center')}
>
我的主页
</Button>
);
});
MySpaceButton.displayName = 'MySpaceButton';
export default MySpaceButton;

View File

@@ -1,116 +0,0 @@
// src/components/Navbars/components/Navigation/PersonalCenterMenu.js
// 个人中心下拉菜单 - 仅桌面版显示
import React, { memo } from 'react';
import {
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
Button,
Box,
Text,
Badge,
useDisclosure
} from '@chakra-ui/react';
import { ChevronDown, Home, User, Settings, LogOut, Crown } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
/**
* 个人中心下拉菜单组件
* 仅在桌面版 (lg+) 显示
*
* @param {Object} props
* @param {Object} props.user - 用户信息
* @param {Function} props.handleLogout - 退出登录回调
*/
const PersonalCenterMenu = memo(({ user, handleLogout }) => {
const navigate = useNavigate();
// 🎯 为个人中心菜单创建 useDisclosure Hook
const { isOpen, onOpen, onClose } = useDisclosure();
// 获取显示名称
const getDisplayName = () => {
if (user.nickname) return user.nickname;
if (user.username) return user.username;
if (user.email) return user.email.split('@')[0];
if (typeof user.phone === 'string' && user.phone) return user.phone;
return '用户';
};
return (
<Menu isOpen={isOpen} onClose={onClose}>
<MenuButton
as={Button}
size="sm"
colorScheme="blue"
variant="solid"
borderRadius="full"
rightIcon={<ChevronDown size={16} />}
onMouseEnter={onOpen}
onMouseLeave={onClose}
>
个人中心
</MenuButton>
<MenuList onMouseEnter={onOpen}>
{/* 用户信息区 */}
<Box px={3} py={2} borderBottom="1px" borderColor="gray.200">
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
{typeof user.phone === 'string' && user.phone && (
<Text fontSize="xs" color="gray.500">{user.phone}</Text>
)}
{user.has_wechat && (
<Badge size="sm" colorScheme="green" mt={1}>微信已绑定</Badge>
)}
</Box>
{/* 前往个人中心 */}
<MenuItem icon={<Home size={16} />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/center');
}}>
前往个人中心
</MenuItem>
<MenuDivider />
{/* 账户管理组 */}
<MenuItem icon={<User size={16} />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/profile');
}}>
个人资料
</MenuItem>
<MenuItem icon={<Settings size={16} />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/settings');
}}>
账户设置
</MenuItem>
<MenuDivider />
{/* 功能入口组 */}
<MenuItem icon={<Crown size={16} />} onClick={() => {
onClose(); // 先关闭菜单
navigate('/home/pages/account/subscription');
}}>
订阅管理
</MenuItem>
<MenuDivider />
{/* 退出 */}
<MenuItem icon={<LogOut size={16} />} onClick={handleLogout} color="red.500">
退出登录
</MenuItem>
</MenuList>
</Menu>
);
});
PersonalCenterMenu.displayName = 'PersonalCenterMenu';
export default PersonalCenterMenu;

View File

@@ -3,4 +3,5 @@
export { default as DesktopNav } from './DesktopNav';
export { default as MoreMenu } from './MoreMenu';
export { default as PersonalCenterMenu } from './PersonalCenterMenu';
export { default as MySpaceButton } from './MySpaceButton';
// PersonalCenterMenu 已废弃,功能合并到 DesktopUserMenu

View File

@@ -1,71 +1,234 @@
// src/components/Navbars/components/UserMenu/DesktopUserMenu.js
// 桌面版用户菜单 - 头像点击跳转到订阅页面
// 桌面版用户菜单 - 头像+用户名组合,点击展开综合下拉面板
import React, { memo } from 'react';
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverArrow,
useColorModeValue
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
Box,
HStack,
VStack,
Text,
Button,
useDisclosure
} from '@chakra-ui/react';
import { Settings, LogOut } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import UserAvatar from './UserAvatar';
import { TooltipContent } from '../../../Subscription/CrownTooltip';
import { useSubscription } from '../../../../hooks/useSubscription';
/**
* 桌面版用户菜单组件
* 大屏幕 (md+) 显示,头像点击跳转到订阅页面
*
* @param {Object} props
* @param {Object} props.user - 用户信息
* 会员权益条组件
* 金色渐变背景,单行显示会员类型和到期时间
*/
const DesktopUserMenu = memo(({ user }) => {
const MembershipBar = memo(({ subscriptionInfo, onClose }) => {
const navigate = useNavigate();
const { subscriptionInfo } = useSubscription();
const { type, days_left } = subscriptionInfo;
const popoverBg = useColorModeValue('white', 'gray.800');
const popoverBorderColor = useColorModeValue('gray.200', 'gray.600');
const getMemberText = () => {
if (type === 'free') return '基础版';
if (type === 'pro') return 'Pro会员';
return 'Max会员';
};
const handleAvatarClick = () => {
const getMemberIcon = () => {
if (type === 'free') return '✨';
if (type === 'pro') return '💎';
return '👑';
};
const handleClick = () => {
onClose();
navigate('/home/pages/account/subscription');
};
// 金色渐变背景
const gradientBg = type === 'free'
? 'linear(to-r, gray.100, gray.200)'
: 'linear(to-r, #F6E5A3, #D4AF37)';
return (
<Popover
trigger="hover"
placement="bottom-end"
openDelay={100}
closeDelay={200}
gutter={8}
<Box
px={4}
py={2.5}
bgGradient={gradientBg}
cursor="pointer"
onClick={handleClick}
_hover={{ opacity: 0.9 }}
>
<PopoverTrigger>
<span>
<HStack justify="space-between">
<HStack spacing={1}>
<Text fontSize="sm">{getMemberIcon()}</Text>
<Text fontSize="sm" fontWeight="600" color={type === 'free' ? 'gray.700' : 'gray.800'}>
{getMemberText()}
{type !== 'free' && (
<Text as="span" fontWeight="normal" color="gray.700">
{' '}· {days_left}天后到期
</Text>
)}
</Text>
</HStack>
<Button
size="xs"
variant="outline"
borderColor={type === 'free' ? 'gray.400' : 'gray.700'}
color={type === 'free' ? 'gray.600' : 'gray.800'}
bg="transparent"
_hover={{ bg: 'whiteAlpha.500' }}
onClick={(e) => {
e.stopPropagation();
handleClick();
}}
>
{type === 'free' ? '升级会员' : '管理订阅'}
</Button>
</HStack>
</Box>
);
});
MembershipBar.displayName = 'MembershipBar';
/**
* 桌面版用户菜单组件
* 头像+用户名组合(去掉箭头),点击展开综合下拉面板
*
* 布局: [头像][用户名]
* 交互: hover 时显示浅色圆角背景,点击展开面板
*
* @param {Object} props
* @param {Object} props.user - 用户信息
* @param {Function} props.handleLogout - 退出登录回调
*/
const DesktopUserMenu = memo(({ user, handleLogout }) => {
const navigate = useNavigate();
const { subscriptionInfo } = useSubscription();
const { isOpen, onOpen, onClose } = useDisclosure();
// 获取显示名称(含手机号脱敏逻辑)
const getDisplayName = () => {
// 1. 优先显示昵称
if (user.nickname) return user.nickname;
// 2. 其次显示用户名
if (user.username) return user.username;
// 3. 手机号脱敏
if (typeof user.phone === 'string' && user.phone) {
return user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
// 4. 默认显示
return '股票新用户';
};
// 跳转到我的空间
const handleNavigateToMySpace = () => {
onClose();
navigate('/home/center');
};
// 跳转到账户设置
const handleNavigateToSettings = () => {
onClose();
navigate('/home/settings');
};
// 退出登录
const handleLogoutClick = () => {
onClose();
handleLogout();
};
return (
<Menu isOpen={isOpen} onClose={onClose}>
<MenuButton
as={Box}
px={2}
py={1}
borderRadius="full"
cursor="pointer"
onClick={onOpen}
transition="all 0.2s"
_hover={{
bg: 'rgba(0, 0, 0, 0.05)',
}}
>
{/* 使用 HStack 明确实现水平布局 */}
<HStack spacing={2}>
<UserAvatar
user={user}
subscriptionInfo={subscriptionInfo}
onClick={handleAvatarClick}
size="sm"
/>
</span>
</PopoverTrigger>
<PopoverContent
bg={popoverBg}
borderRadius="lg"
border="1px solid"
borderColor={popoverBorderColor}
boxShadow="lg"
p={3}
w="auto"
_focus={{ outline: 'none' }}
<Text
fontSize="sm"
fontWeight="medium"
maxW="80px"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
color="gray.700"
>
<PopoverArrow bg={popoverBg} />
<TooltipContent
{getDisplayName()}
</Text>
</HStack>
</MenuButton>
<MenuList minW="280px" py={0} overflow="hidden" borderRadius="lg">
{/* 顶部:用户信息区 - 深色背景 + 头像 + 用户名 */}
<Box
px={4}
py={4}
bg="gray.800"
cursor="pointer"
_hover={{ bg: 'gray.700' }}
onClick={handleNavigateToMySpace}
>
<HStack spacing={3}>
<UserAvatar
user={user}
subscriptionInfo={subscriptionInfo}
onNavigate={handleAvatarClick}
size="md"
/>
</PopoverContent>
</Popover>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="md" color="white">
{getDisplayName()}
</Text>
<Text fontSize="xs" color="gray.400">
ID: {user.phone || user.id || '---'}
</Text>
</VStack>
</HStack>
</Box>
{/* 会员权益条 - 金色渐变背景 */}
<MembershipBar subscriptionInfo={subscriptionInfo} onClose={onClose} />
<MenuDivider my={0} />
{/* 列表区:快捷功能 */}
<MenuItem
icon={<Settings size={16} />}
onClick={handleNavigateToSettings}
py={3}
>
账户设置
</MenuItem>
<MenuDivider my={0} />
{/* 底部:退出登录 */}
<MenuItem
icon={<LogOut size={16} />}
color="red.500"
onClick={handleLogoutClick}
py={3}
>
退出登录
</MenuItem>
</MenuList>
</Menu>
);
});

View File

@@ -5,7 +5,6 @@ import React from 'react';
import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
import { ChevronUp, ChevronDown } from 'lucide-react';
import { TbArrowBigUpFilled, TbArrowBigDownFilled } from 'react-icons/tb';
import { getChangeColor } from '../utils/colorUtils';
/**
* 股票涨跌幅指标组件3个指标平均超额、最大超额、超预期得分
@@ -27,50 +26,95 @@ const StockChangeIndicators = ({
const isComfortable = size === 'comfortable';
const isDefault = size === 'default';
// 根据涨跌幅获取数字颜色(动态深浅)
// ============ 在组件顶层调用所有 useColorModeValue ============
// 涨跌幅数字颜色
const colors = {
// 灰色系null 值、0 值)
grayText: useColorModeValue('gray.700', 'gray.400'),
grayTextAlt: useColorModeValue('gray.600', 'gray.400'),
grayBg: useColorModeValue('gray.50', 'gray.800'),
grayBorder: useColorModeValue('gray.200', 'gray.700'),
// 红色系(上涨)
redHigh: useColorModeValue('red.600', 'red.300'),
redMid: useColorModeValue('red.500', 'red.300'),
redLow: useColorModeValue('red.400', 'red.200'),
redBg: useColorModeValue('red.50', 'red.900'),
redBorder: useColorModeValue('red.200', 'red.700'),
redScore: useColorModeValue('red.600', 'red.400'),
// 绿色系(下跌)
greenHigh: useColorModeValue('green.600', 'green.300'),
greenMid: useColorModeValue('green.500', 'green.300'),
greenLow: useColorModeValue('green.400', 'green.200'),
greenBg: useColorModeValue('green.50', 'green.900'),
greenBorder: useColorModeValue('green.200', 'green.700'),
// 橙色系(中等分数)
orangeScore: useColorModeValue('orange.600', 'orange.400'),
orangeBg: useColorModeValue('orange.50', 'orange.900'),
orangeBorder: useColorModeValue('orange.200', 'orange.700'),
// 蓝色系(低分数)
blueScore: useColorModeValue('blue.600', 'blue.400'),
blueBg: useColorModeValue('blue.50', 'blue.900'),
blueBorder: useColorModeValue('blue.200', 'blue.700'),
};
// 标签颜色
const labelColor = colors.grayTextAlt;
// ============ 颜色选择函数(不调用 Hook只做选择============
// 根据涨跌幅获取数字颜色
const getNumberColor = (value) => {
if (value == null) {
return useColorModeValue('gray.700', 'gray.400');
}
if (value == null) return colors.grayText;
if (value === 0) return colors.grayTextAlt;
// 使用动态颜色函数
return getChangeColor(value);
const absValue = Math.abs(value);
if (value > 0) {
if (absValue >= 3) return colors.redHigh;
if (absValue >= 1) return colors.redMid;
return colors.redLow;
} else {
if (absValue >= 3) return colors.greenHigh;
if (absValue >= 1) return colors.greenMid;
return colors.greenLow;
}
};
// 根据涨跌幅获取背景色(永远比文字色浅)
// 根据涨跌幅获取背景色
const getBgColor = (value) => {
if (value == null) {
return useColorModeValue('gray.50', 'gray.800');
}
// 0值使用中性灰色背景
if (value === 0) {
return useColorModeValue('gray.50', 'gray.800');
}
// 统一背景色:上涨红色系,下跌绿色系
return value > 0
? useColorModeValue('red.50', 'red.900')
: useColorModeValue('green.50', 'green.900');
if (value == null || value === 0) return colors.grayBg;
return value > 0 ? colors.redBg : colors.greenBg;
};
// 根据涨跌幅获取边框色(比背景深,比文字浅)
// 根据涨跌幅获取边框色
const getBorderColor = (value) => {
if (value == null) {
return useColorModeValue('gray.200', 'gray.700');
}
// 0值使用中性灰色边框
if (value === 0) {
return useColorModeValue('gray.200', 'gray.700');
}
// 统一边框色:上涨红色系,下跌绿色系
return value > 0
? useColorModeValue('red.200', 'red.700')
: useColorModeValue('green.200', 'green.700');
if (value == null || value === 0) return colors.grayBorder;
return value > 0 ? colors.redBorder : colors.greenBorder;
};
// 根据分数获取颜色
const getScoreColor = (score) => {
if (score >= 60) return colors.redScore;
if (score >= 40) return colors.orangeScore;
if (score >= 20) return colors.blueScore;
return colors.grayTextAlt;
};
// 根据分数获取背景色
const getScoreBgColor = (score) => {
if (score >= 60) return colors.redBg;
if (score >= 40) return colors.orangeBg;
if (score >= 20) return colors.blueBg;
return colors.grayBg;
};
// 根据分数获取边框色
const getScoreBorderColor = (score) => {
if (score >= 60) return colors.redBorder;
if (score >= 40) return colors.orangeBorder;
if (score >= 20) return colors.blueBorder;
return colors.grayBorder;
};
// ============ 渲染函数 ============
// 渲染单个指标
const renderIndicator = (label, value) => {
if (value == null) return null;
@@ -81,7 +125,6 @@ const StockChangeIndicators = ({
const numberColor = getNumberColor(value);
const bgColor = getBgColor(value);
const borderColor = getBorderColor(value);
const labelColor = useColorModeValue('gray.600', 'gray.400');
return (
<Box
@@ -165,30 +208,6 @@ const StockChangeIndicators = ({
const renderScoreIndicator = (label, score) => {
if (score == null) return null;
const labelColor = useColorModeValue('gray.600', 'gray.400');
// 根据分数确定颜色:>=60红色>=40橙色>=20蓝色其他灰色
const getScoreColor = (s) => {
if (s >= 60) return useColorModeValue('red.600', 'red.400');
if (s >= 40) return useColorModeValue('orange.600', 'orange.400');
if (s >= 20) return useColorModeValue('blue.600', 'blue.400');
return useColorModeValue('gray.600', 'gray.400');
};
const getScoreBgColor = (s) => {
if (s >= 60) return useColorModeValue('red.50', 'red.900');
if (s >= 40) return useColorModeValue('orange.50', 'orange.900');
if (s >= 20) return useColorModeValue('blue.50', 'blue.900');
return useColorModeValue('gray.50', 'gray.800');
};
const getScoreBorderColor = (s) => {
if (s >= 60) return useColorModeValue('red.200', 'red.700');
if (s >= 40) return useColorModeValue('orange.200', 'orange.700');
if (s >= 20) return useColorModeValue('blue.200', 'blue.700');
return useColorModeValue('gray.200', 'gray.700');
};
const scoreColor = getScoreColor(score);
const bgColor = getScoreBgColor(score);
const borderColor = getScoreBorderColor(score);

View File

@@ -1105,6 +1105,121 @@ export const mockFutureEvents = [
],
concepts: ['新能源', '动力电池', '储能'],
is_following: false
},
// ==================== 2026年1月事件数据 ====================
{
id: 601,
data_id: 601,
title: '特斯拉Q4财报发布',
calendar_time: '2026-01-15T21:00:00Z',
type: 'data',
star: 5,
former: {
data: [
{
author: '特斯拉投资者关系',
sentences: '特斯拉将发布2025年第四季度及全年财务报告市场关注其全球交付量、毛利率表现以及2026年产能扩张计划',
query_part: '特斯拉将发布2025年第四季度财报',
report_title: 'Q4 2025 Earnings Release',
declare_date: '2026-01-10T00:00:00',
match_score: '好'
}
]
},
forecast: '预计营收超过260亿美元全年交付量超200万辆',
fact: null,
related_stocks: [
['002594', '比亚迪', { data: [{ author: '中信证券', sentences: '作为特斯拉主要竞争对手,比亚迪股价与特斯拉财报关联度高', query_part: '比亚迪与特斯拉竞争格局', report_title: '新能源汽车竞争分析', declare_date: '2026-01-12T00:00:00', match_score: '好' }] }, 0.85],
['300750', '宁德时代', { data: [{ author: '招商证券', sentences: '作为特斯拉动力电池供应商,宁德时代业绩与特斯拉销量高度相关', query_part: '宁德时代与特斯拉供应链关系', report_title: '动力电池产业链研究', declare_date: '2026-01-11T00:00:00', match_score: '好' }] }, 0.80]
],
concepts: ['新能源汽车', '特斯拉', '电动车'],
is_following: false
},
{
id: 602,
data_id: 602,
title: '央行MLF操作',
calendar_time: '2026-01-15T09:30:00Z',
type: 'event',
star: 4,
former: '央行将开展中期借贷便利(MLF)操作市场关注操作规模和利率变动。本次MLF到期规模约5000亿元预计央行将等量或超量续作以维护流动性合理充裕。\n\n(AI合成)',
forecast: '预计MLF利率维持2.5%不变',
fact: null,
related_stocks: [
['601398', '工商银行', { data: [{ author: '国泰君安', sentences: 'MLF利率影响银行负债成本和净息差表现', query_part: 'MLF对银行净息差影响', report_title: '货币政策对银行业影响', declare_date: '2026-01-14T00:00:00', match_score: '好' }] }, 0.75]
],
concepts: ['货币政策', 'MLF', '央行'],
is_following: true
},
{
id: 603,
data_id: 603,
title: '英伟达CES 2026新品发布',
calendar_time: '2026-01-14T10:00:00Z',
type: 'event',
star: 5,
former: {
data: [
{
author: '英伟达官方',
sentences: '英伟达CEO黄仁勋将在CES 2026发表主题演讲预计发布新一代RTX 50系列显卡和AI芯片产品线更新',
query_part: '英伟达CES 2026发布会',
report_title: 'CES 2026 Keynote Preview',
declare_date: '2026-01-08T00:00:00',
match_score: '好'
}
]
},
forecast: '新一代AI芯片性能预计提升2-3倍',
fact: null,
related_stocks: [
['603986', '兆易创新', { data: [{ author: '华泰证券', sentences: '英伟达新品发布带动国产芯片概念关注度提升', query_part: '国产芯片替代机遇', report_title: '半导体产业链研究', declare_date: '2026-01-10T00:00:00', match_score: '好' }] }, 0.78],
['002049', '紫光国微', { data: [{ author: '中金公司', sentences: '作为国产芯片龙头紫光国微受益于AI芯片需求增长', query_part: '国产芯片龙头分析', report_title: 'AI芯片产业链报告', declare_date: '2026-01-09T00:00:00', match_score: '好' }] }, 0.75]
],
concepts: ['人工智能', 'AI芯片', '半导体'],
is_following: false
},
{
id: 604,
data_id: 604,
title: '12月CPI/PPI数据公布',
calendar_time: '2026-01-13T09:30:00Z',
type: 'data',
star: 4,
former: '国家统计局将公布2025年12月居民消费价格指数(CPI)和工业生产者出厂价格指数(PPI)数据市场预期CPI同比上涨0.3%PPI同比下降2.5%。\n\n【详细背景分析】\n\n一、CPI数据展望\n\n1. 食品价格方面受季节性因素影响12月食品价格预计环比上涨。猪肉价格在供给偏紧的情况下维持高位运行鲜菜价格受寒潮天气影响有所上涨鲜果价格保持稳定。预计食品价格环比上涨0.8%左右。\n\n2. 非食品价格方面能源价格受国际油价波动影响12月成品油价格有所调整。服务价格保持平稳教育文化娱乐、医疗保健等服务价格环比持平。预计非食品价格环比微降0.1%。\n\n3. 核心CPI方面剔除食品和能源价格的核心CPI预计同比上涨0.6%,反映出内需恢复仍在进行中,但力度有待加强。\n\n二、PPI数据展望\n\n1. 生产资料价格受全球大宗商品价格波动影响12月生产资料价格预计环比下降。其中采掘工业、原材料工业价格降幅收窄加工工业价格基本持平。\n\n2. 生活资料价格:食品类价格小幅上涨,衣着类价格保持稳定,一般日用品类价格持平,耐用消费品类价格略有下降。\n\n3. 主要行业分析:\n - 石油和天然气开采业受国际油价影响价格环比下降约2%\n - 黑色金属冶炼钢材价格震荡运行环比微降0.5%\n - 有色金属冶炼:铜、铝等价格受供需影响有所波动\n - 化学原料制造:基础化工品价格整体平稳\n\n三、对市场的影响分析\n\n1. 货币政策方面:当前通胀压力不大,为货币政策提供了较大的操作空间。预计央行将继续保持流动性合理充裕,适时运用降准、降息等工具支持实体经济发展。\n\n2. 股市方面:\n - 消费板块CPI温和上涨利好食品饮料、农业等消费板块\n - 周期板块PPI降幅收窄显示工业品需求有所改善利好有色、钢铁等周期板块\n - 金融板块:货币政策宽松预期利好银行、保险等金融板块\n\n3. 债市方面:通胀压力可控,经济复苏温和,利率债配置价值凸显。\n\n四、历史数据回顾\n\n2025年全年CPI走势\n- 1月0.8%(春节因素)\n- 2月0.1%\n- 3月0.2%\n- 4月0.3%\n- 5月0.3%\n- 6月0.2%\n- 7月0.1%\n- 8月0.2%\n- 9月0.3%\n- 10月0.2%\n- 11月0.3%\n\n2025年全年PPI走势\n- 1月-2.8%\n- 2月-2.6%\n- 3月-2.5%\n- 4月-2.7%\n- 5月-2.9%\n- 6月-3.0%\n- 7月-2.8%\n- 8月-2.7%\n- 9月-2.6%\n- 10月-2.5%\n- 11月-2.4%\n\n五、投资策略建议\n\n1. 短期策略:关注数据公布后的市场反应,若数据好于预期,可适当增配周期板块;若数据不及预期,可关注防御性板块。\n\n2. 中期策略:在通胀温和、流动性充裕的环境下,建议均衡配置成长股和价值股,重点关注科技创新、消费升级、绿色转型等方向。\n\n3. 风险提示:需关注全球经济形势变化、地缘政治风险、国内政策调整等因素对市场的影响。\n\n(AI合成)',
forecast: 'CPI温和上涨PPI降幅收窄',
fact: null,
related_stocks: [],
concepts: ['宏观经济', 'CPI', 'PPI'],
is_following: false
},
{
id: 605,
data_id: 605,
title: '苹果Vision Pro 2发布会',
calendar_time: '2026-01-16T02:00:00Z',
type: 'event',
star: 5,
former: {
data: [
{
author: '苹果公司',
sentences: '苹果将举办特别活动预计发布第二代Vision Pro头显设备新产品将在性能、重量和价格方面实现重大突破',
query_part: '苹果Vision Pro 2发布',
report_title: 'Apple Special Event',
declare_date: '2026-01-12T00:00:00',
match_score: '好'
}
]
},
forecast: '售价预计下调30%重量减轻40%',
fact: null,
related_stocks: [
['002475', '立讯精密', { data: [{ author: '天风证券', sentences: '立讯精密是苹果Vision Pro核心代工厂新品发布将带动订单增长', query_part: '立讯精密Vision Pro代工', report_title: '消费电子产业链研究', declare_date: '2026-01-13T00:00:00', match_score: '好' }] }, 0.88],
['002241', '歌尔股份', { data: [{ author: '国盛证券', sentences: '歌尔股份在VR/AR光学模组领域具有领先地位', query_part: '歌尔股份VR光学布局', report_title: 'XR产业链深度报告', declare_date: '2026-01-12T00:00:00', match_score: '好' }] }, 0.82]
],
concepts: ['苹果', 'VR/AR', '消费电子'],
is_following: true
}
];

View File

@@ -768,6 +768,97 @@ export const accountHandlers = [
});
}),
// ==================== 账号绑定管理 ====================
// 手机号发送验证码
http.post('/api/account/phone/send-code', async ({ request }) => {
await delay(NETWORK_DELAY);
const body = await request.json();
console.log('[Mock] 发送手机验证码:', body.phone);
return HttpResponse.json({
success: true,
message: '验证码已发送'
});
}),
// 手机号绑定
http.post('/api/account/phone/bind', async ({ request }) => {
await delay(NETWORK_DELAY);
const body = await request.json();
console.log('[Mock] 绑定手机号:', body.phone);
return HttpResponse.json({
success: true,
message: '手机号绑定成功',
data: { phone: body.phone, phone_confirmed: true }
});
}),
// 手机号解绑
http.post('/api/account/phone/unbind', async () => {
await delay(NETWORK_DELAY);
console.log('[Mock] 解绑手机号');
return HttpResponse.json({
success: true,
message: '手机号解绑成功'
});
}),
// 邮箱发送验证码
http.post('/api/account/email/send-bind-code', async ({ request }) => {
await delay(NETWORK_DELAY);
const body = await request.json();
console.log('[Mock] 发送邮箱验证码:', body.email);
return HttpResponse.json({
success: true,
message: '验证码已发送'
});
}),
// 邮箱绑定
http.post('/api/account/email/bind', async ({ request }) => {
await delay(NETWORK_DELAY);
const body = await request.json();
console.log('[Mock] 绑定邮箱:', body.email);
return HttpResponse.json({
success: true,
message: '邮箱绑定成功',
user: { email: body.email, email_confirmed: true }
});
}),
// 微信获取二维码
http.get('/api/account/wechat/qrcode', async () => {
await delay(NETWORK_DELAY);
console.log('[Mock] 获取微信绑定二维码');
return HttpResponse.json({
success: true,
auth_url: 'https://open.weixin.qq.com/connect/qrconnect?mock=true',
session_id: 'mock_session_' + Date.now()
});
}),
// 微信绑定检查
http.post('/api/account/wechat/check', async ({ request }) => {
await delay(NETWORK_DELAY);
const body = await request.json();
console.log('[Mock] 检查微信绑定状态:', body.session_id);
// 模拟绑定成功
return HttpResponse.json({
success: true,
status: 'bind_ready'
});
}),
// 微信解绑
http.post('/api/account/wechat/unbind', async () => {
await delay(NETWORK_DELAY);
console.log('[Mock] 解绑微信');
return HttpResponse.json({
success: true,
message: '微信解绑成功'
});
}),
// 21. 获取订阅套餐列表
http.get('/api/subscription/plans', async () => {
await delay(NETWORK_DELAY);

File diff suppressed because it is too large Load Diff

View File

@@ -10,30 +10,86 @@ src/views/Community/
├── components/ # 组件目录
│ ├── SearchFilters/ # 搜索筛选模块
│ │ ├── CompactSearchBox.js
│ │ ├── CompactSearchBox.css
│ │ ├── TradingTimeFilter.js
│ │ └── index.js
│ ├── EventCard/ # 事件卡片模块
│ │ ├── atoms/ # 原子组件
│ │ └── index.js
│ ├── HotEvents/ # 热点事件模块
│ │ ├── HotEvents.js
│ │ ├── HotEvents.css
│ │ ├── HotEventsSection.js
│ │ │ ├── EventDescription.js
│ │ │ ├── EventEngagement.js
│ │ ├── EventFollowButton.js
│ │ ├── EventHeader.js
│ │ ├── EventImportanceBadge.js
│ │ │ ├── EventPriceDisplay.js
│ │ │ ├── EventStats.js
│ │ │ ├── EventTimeline.js
│ │ │ ├── ImportanceBadge.js
│ │ │ ├── ImportanceStamp.js
│ │ │ ├── KeywordsCarousel.js
│ │ │ └── index.js
│ │ ├── CompactEventCard.js
│ │ ├── DetailedEventCard.js
│ │ ├── DynamicNewsEventCard.js
│ │ ├── HorizontalDynamicNewsEventCard.js
│ │ ├── MiniEventCard.js
│ │ └── index.js
│ ├── DynamicNews/ # 动态新闻模块
│ │ ├── layouts/
│ │ ├── hooks/
│ │ │ ├── usePagination.js
│ │ │ └── index.js
│ │ ├── layouts/
│ │ │ ├── MainlineTimelineView.js
│ │ │ ├── VerticalModeLayout.js
│ │ │ └── index.js
│ │ ├── constants.js
│ │ ├── DynamicNewsCard.js
│ │ ├── EventDetailScrollPanel.js
│ │ ├── EventScrollList.js
│ │ ├── ModeToggleButtons.js
│ │ ├── PaginationControl.js
│ │ └── index.js
│ ├── EventDetailModal/ # 事件详情弹窗模块
│ │ ├── EventDetailModal.tsx
│ │ ├── EventDetailModal.less
│ │ └── index.ts
── HeroPanel.js # 英雄面板(独立组件
── HeroPanel/ # 英雄面板模块(重构版
│ │ ├── columns/ # 表格列定义
│ │ │ ├── index.js # 统一导出
│ │ │ ├── renderers.js # 通用渲染器
│ │ │ ├── stockColumns.js # 事件关联股票列
│ │ │ ├── sectorColumns.js # 涨停板块列
│ │ │ ├── ztStockColumns.js # 涨停个股列
│ │ │ └── eventColumns.js # 未来事件列
│ │ ├── components/ # 子组件
│ │ │ ├── DetailModal/ # 详情弹窗子模块
│ │ │ │ ├── EventsTabView.js
│ │ │ │ ├── RelatedEventsModal.js
│ │ │ │ ├── SectorStocksModal.js
│ │ │ │ ├── ZTSectorView.js
│ │ │ │ ├── ZTStockListView.js
│ │ │ │ └── index.js
│ │ │ ├── CalendarCell.js # 日历单元格
│ │ │ ├── CombinedCalendar.js # 组合日历视图
│ │ │ ├── InfoModal.js # 信息弹窗
│ │ │ ├── HotKeywordsCloud.js # 热门关键词云
│ │ │ ├── ZTStatsCards.js # 涨停统计卡片
│ │ │ └── index.js
│ │ ├── constants/ # 常量定义
│ │ │ └── index.js
│ │ ├── hooks/ # 自定义 Hooks
│ │ │ ├── useDetailModalState.js
│ │ │ └── index.js
│ │ ├── styles/ # 样式文件
│ │ │ └── animations.css
│ │ ├── utils/ # 工具函数
│ │ │ └── index.js
│ │ └── index.js
│ ├── EventDailyStats.js # 事件每日统计
│ ├── HeroPanel.js # 英雄面板(主入口)
│ ├── MarketOverviewBanner.js # 市场概览横幅
│ └── ThemeCometChart.js # 主题彗星图
└── hooks/ # 页面级 Hooks
├── useCommunityEvents.js
├── useEventData.js
── useEventFilters.js
└── useCommunityEvents.js
── useEventFilters.js
```
---
@@ -42,10 +98,10 @@ src/views/Community/
路径:`components/SearchFilters/`
| 文件 | 行数 | 功能 |
|------|------|------|
| `CompactSearchBox.js` | 612 | 紧凑搜索框,集成关键词搜索、概念/行业筛选 |
| `TradingTimeFilter.js` | 491 | 交易时间筛选器,被 CompactSearchBox 引用 |
| 文件 | 功能 |
|------|------|
| `CompactSearchBox.js` | 紧凑搜索框,集成关键词搜索、概念/行业筛选 |
| `TradingTimeFilter.js` | 交易时间筛选器,被 CompactSearchBox 引用 |
**使用方式**
```javascript
@@ -66,6 +122,7 @@ import { CompactSearchBox } from './components/SearchFilters';
| `DetailedEventCard.js` | 详细事件卡片(展开模式) |
| `DynamicNewsEventCard.js` | 动态新闻事件卡片 |
| `HorizontalDynamicNewsEventCard.js` | 水平布局新闻卡片 |
| `MiniEventCard.js` | 迷你事件卡片 |
### 原子组件atoms/
@@ -77,6 +134,7 @@ import { CompactSearchBox } from './components/SearchFilters';
| `EventPriceDisplay.js` | 股价显示 |
| `EventTimeline.js` | 事件时间线 |
| `EventFollowButton.js` | 关注按钮 |
| `EventEngagement.js` | 事件互动数据 |
| `EventImportanceBadge.js` | 重要性徽章 |
| `ImportanceBadge.js` | 通用重要性徽章 |
| `ImportanceStamp.js` | 重要性印章 |
@@ -93,23 +151,7 @@ import { EventHeader, EventTimeline } from './components/EventCard/atoms';
---
## 3. HotEvents 模块(热点事件
路径:`components/HotEvents/`
| 文件 | 功能 |
|------|------|
| `HotEvents.js` | 热点事件列表渲染 |
| `HotEventsSection.js` | 热点事件区块容器 |
**使用方式**
```javascript
import { HotEventsSection } from './components/HotEvents';
```
---
## 4. DynamicNews 模块(动态新闻)
## 3. DynamicNews 模块(动态新闻
路径:`components/DynamicNews/`
@@ -117,7 +159,7 @@ import { HotEventsSection } from './components/HotEvents';
| 文件 | 功能 |
|------|------|
| `DynamicNewsCard.js` | 主列表容器695行 |
| `DynamicNewsCard.js` | 主列表容器 |
| `EventScrollList.js` | 事件滚动列表 |
| `EventDetailScrollPanel.js` | 事件详情滚动面板 |
| `ModeToggleButtons.js` | 模式切换按钮 |
@@ -129,7 +171,7 @@ import { HotEventsSection } from './components/HotEvents';
| 文件 | 功能 |
|------|------|
| `VerticalModeLayout.js` | 垂直布局模式 |
| `VirtualizedFourRowGrid.js` | 虚拟滚动四行网格(性能优化) |
| `MainlineTimelineView.js` | 主线时间线视图 |
### Hookshooks/
@@ -145,14 +187,13 @@ import { usePagination } from './components/DynamicNews/hooks';
---
## 5. EventDetailModal 模块(事件详情弹窗)
## 4. EventDetailModal 模块(事件详情弹窗)
路径:`components/EventDetailModal/`
| 文件 | 功能 |
|------|------|
| `EventDetailModal.tsx` | 事件详情弹窗TypeScript |
| `EventDetailModal.less` | 弹窗样式 |
**使用方式**
```javascript
@@ -161,21 +202,115 @@ import EventDetailModal from './components/EventDetailModal';
---
## 5. HeroPanel 模块(英雄面板)
路径:`components/HeroPanel/` + `components/HeroPanel.js`
### 主入口
- `HeroPanel.js` - 英雄面板主组件首页指数K线 + 概念词云 + 日历)
### 子模块结构
| 目录 | 功能 |
|------|------|
| `columns/` | 表格列定义(工厂函数模式) |
| `components/` | 子组件集合 |
| `constants/` | 常量定义(颜色、热度等级等) |
| `hooks/` | 自定义 Hooks |
| `styles/` | 样式文件(动画 CSS |
| `utils/` | 工具函数(日期、股票代码处理等) |
### columns/ 表格列定义
| 文件 | 功能 |
|------|------|
| `stockColumns.js` | 事件关联股票表格列(现价、涨跌幅、关联理由、研报引用) |
| `sectorColumns.js` | 涨停板块表格列(排名、板块名称、涨停数、涨停股票、涨停归因) |
| `ztStockColumns.js` | 涨停个股表格列(股票信息、涨停时间、连板、核心板块、涨停简报) |
| `eventColumns.js` | 未来事件表格列(时间、重要度、标题、背景、未来推演、相关股票) |
| `renderers.js` | 通用列渲染器 |
### components/ 子组件
| 文件 | 功能 |
|------|------|
| `CalendarCell.js` | 日历单元格(显示涨停数/事件数热度) |
| `CombinedCalendar.js` | 组合日历视图FullCalendar 封装) |
| `InfoModal.js` | 信息弹窗 |
| `HotKeywordsCloud.js` | 热门关键词云(今日热词展示) |
| `ZTStatsCards.js` | 涨停统计卡片(连板分布、封板时间、公告驱动) |
| `DetailModal/` | 详情弹窗子模块 |
### DetailModal/ 详情弹窗
| 文件 | 功能 |
|------|------|
| `EventsTabView.js` | 事件标签页视图 |
| `RelatedEventsModal.js` | 相关事件弹窗(涨停归因详情) |
| `SectorStocksModal.js` | 板块股票弹窗 |
| `ZTSectorView.js` | 涨停板块视图 |
| `ZTStockListView.js` | 涨停股票列表视图 |
### hooks/ 自定义 Hooks
| 文件 | 功能 |
|------|------|
| `useDetailModalState.js` | 详情弹窗状态管理(整合 17 个状态) |
**使用方式**
```javascript
// 使用主组件
import HeroPanel from './components/HeroPanel';
// 使用列定义工厂函数
import { createStockColumns, createSectorColumns } from './components/HeroPanel/columns';
// 使用子组件
import { HotKeywordsCloud, ZTStatsCards } from './components/HeroPanel/components';
```
---
## 6. 独立组件
路径:`components/`
| 文件 | 行数 | 功能 |
|------|------|------|
| `HeroPanel.js` | 972 | 首页英雄面板指数K线 + 概念词云) |
| 文件 | 功能 |
|------|------|
| `MarketOverviewBanner.js` | 市场概览横幅,展示指数行情 |
| `ThemeCometChart.js` | 主题彗星图,可视化概念热度 |
| `EventDailyStats.js` | 事件每日统计面板 |
**说明**
- `HeroPanel.js` 使用懒加载,包含 ECharts (~600KB)
---
## 页面级 Hooks
路径:`hooks/`
| 文件 | 功能 |
|------|------|
| `useCommunityEvents.js` | 社区事件数据获取与管理 |
| `useEventData.js` | 事件数据处理 |
| `useEventFilters.js` | 事件筛选逻辑 |
---
## 更新日志
- **2026-01-13**: HeroPanel 模块重构优化
- 新增 `columns/` 表格列定义文件stockColumns、sectorColumns、ztStockColumns、eventColumns
- 新增 `HotKeywordsCloud.js` 热门关键词云组件
- 新增 `ZTStatsCards.js` 涨停统计卡片组件
- HeroPanel.js 从 2,299 行优化至 1,257 行(减少 45%
- 采用工厂函数模式提取列定义,支持 useMemo 缓存
- **2026-01-13**: 目录结构同步更新
- 移除已删除的 `HotEvents/` 模块
- 新增 `HeroPanel/` 模块结构说明
- 新增独立组件说明MarketOverviewBanner、ThemeCometChart、EventDailyStats
- 删除未引用组件 `EventEffectivenessStats.js`
- 完善 `EventCard/atoms/` 原子组件列表
- **2024-12-09**: 目录结构重组
- 创建 `SearchFilters/` 模块(含 CSS
- 创建 `EventCard/atoms/` 原子组件目录

View File

@@ -692,12 +692,10 @@ const [currentMode, setCurrentMode] = useState('vertical');
<Flex justify="space-between" align="center">
{/* 左侧:标题 + 模式切换按钮 */}
<HStack spacing={4}>
<Heading size={isMobile ? "sm" : "md"} color={PROFESSIONAL_COLORS.text.primary}>
<HStack spacing={2}>
<Clock size={20} color={PROFESSIONAL_COLORS.gold[500]} />
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
<Clock size={18} color={PROFESSIONAL_COLORS.gold[500]} />
<Text fontSize={isMobile ? "md" : "lg"} fontWeight="bold" bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
</HStack>
</Heading>
{/* 模式切换按钮(移动端隐藏) */}
{!isMobile && <ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />}
</HStack>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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";

View File

@@ -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";
};

View File

@@ -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,
})),
},
]
}]
: []),
...(hierarchyOptions.lv2?.length > 0
? [
{
? [{
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}`,
})),
},
]
}]
: []),
]}
/>
@@ -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}

View File

@@ -1,8 +1,8 @@
/**
* EventDailyStats - 当日事件统计面板
* 展示当前交易日的事件统计数据,证明系统推荐的胜率和市场热度
* EventDailyStats - 事件 TOP 排行面板
* 展示当日事件的表现排行
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
Box,
Text,
@@ -12,24 +12,11 @@ import {
Center,
Tooltip,
Badge,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
Input,
Flex,
} from '@chakra-ui/react';
import {
FireOutlined,
RiseOutlined,
ThunderboltOutlined,
TrophyOutlined,
StockOutlined,
CalendarOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { getApiBase } from '@utils/apiConfig';
} from "@chakra-ui/react";
import { motion, useAnimationControls } from "framer-motion";
import { getApiBase } from "@utils/apiConfig";
const MotionBox = motion.create(Box);
/**
* 生成事件详情页 URL
@@ -43,275 +30,31 @@ const getEventDetailUrl = (eventId) => {
* 格式化涨跌幅
*/
const formatChg = (val) => {
if (val === null || val === undefined) return '-';
if (val === null || val === undefined) return "-";
const num = parseFloat(val);
if (isNaN(num)) return '-';
return (num >= 0 ? '+' : '') + num.toFixed(2) + '%';
if (isNaN(num)) return "-";
return (num >= 0 ? "+" : "") + num.toFixed(2) + "%";
};
/**
* 获取涨跌幅颜色
*/
const getChgColor = (val) => {
if (val === null || val === undefined) return 'gray.400';
if (val === null || val === undefined) return "gray.400";
const num = parseFloat(val);
if (isNaN(num)) return 'gray.400';
if (num > 0) return '#FF4D4F';
if (num < 0) return '#52C41A';
return 'gray.400';
if (isNaN(num)) return "gray.400";
if (num > 0) return "#FF4D4F";
if (num < 0) return "#52C41A";
return "gray.400";
};
/**
* 获取胜率颜色(>50%红色,<50%绿色)
*/
const getRateColor = (rate) => {
if (rate >= 50) return '#F31260'; // HeroUI 红色
return '#17C964'; // HeroUI 绿色
};
/**
* HeroUI 风格圆环仪表盘
*/
const CircularGauge = ({ rate, label, icon }) => {
const validRate = Math.min(100, Math.max(0, rate || 0));
const gaugeColor = getRateColor(validRate);
const circumference = 2 * Math.PI * 42; // 半径42
const strokeDashoffset = circumference - (validRate / 100) * circumference;
return (
<Box
bg="rgba(255,255,255,0.03)"
backdropFilter="blur(20px)"
borderRadius="2xl"
p={4}
border="1px solid"
borderColor="rgba(255,255,255,0.08)"
position="relative"
overflow="hidden"
flex="1"
_before={{
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: `radial-gradient(circle at 30% 20%, ${gaugeColor}15 0%, transparent 50%)`,
pointerEvents: 'none',
}}
>
{/* 圆环仪表盘 */}
<Center>
<Box position="relative" w="100px" h="100px">
<svg width="100" height="100" style={{ transform: 'rotate(-90deg)' }}>
{/* 背景圆环 */}
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke="rgba(255,255,255,0.08)"
strokeWidth="8"
/>
{/* 渐变定义 */}
<defs>
<linearGradient id={`gauge-grad-${label}`} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={gaugeColor} stopOpacity="0.6" />
<stop offset="100%" stopColor={gaugeColor} />
</linearGradient>
</defs>
{/* 进度圆环 */}
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke={`url(#gauge-grad-${label})`}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
style={{
transition: 'stroke-dashoffset 0.8s ease-out',
filter: `drop-shadow(0 0 8px ${gaugeColor}60)`,
}}
/>
</svg>
{/* 中心数值 */}
<VStack
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
spacing={0}
>
<Text
fontSize="2xl"
fontWeight="bold"
color={gaugeColor}
lineHeight="1"
textShadow={`0 0 20px ${gaugeColor}40`}
>
{validRate.toFixed(1)}
</Text>
<Text fontSize="xs" color="whiteAlpha.600">%</Text>
</VStack>
</Box>
</Center>
{/* 标签 */}
<HStack justify="center" mt={2} spacing={2}>
<Box color={gaugeColor} fontSize="sm">{icon}</Box>
<Text fontSize="sm" color="whiteAlpha.800" fontWeight="medium">
{label}
</Text>
</HStack>
</Box>
);
};
/**
* HeroUI 风格胜率对比面板
*/
const WinRateGauge = ({ eventRate, marketRate, marketStats }) => {
const eventRateVal = eventRate || 0;
const marketRateVal = marketRate || 0;
return (
<Box>
{/* 双仪表盘对比 - HeroUI 毛玻璃卡片 */}
<HStack spacing={4} mb={4}>
<CircularGauge
rate={eventRateVal}
label="事件胜率"
icon={<TrophyOutlined />}
/>
<CircularGauge
rate={marketRateVal}
label="大盘上涨率"
icon={<RiseOutlined />}
/>
</HStack>
{/* 市场统计 - 毛玻璃条 */}
{marketStats && marketStats.totalCount > 0 && (
<Box
bg="rgba(255,255,255,0.03)"
backdropFilter="blur(10px)"
borderRadius="xl"
p={3}
border="1px solid rgba(255,255,255,0.06)"
>
<HStack justify="space-between" mb={2}>
<Text fontSize="xs" color="whiteAlpha.500">沪深两市实时</Text>
<Text fontSize="xs" color="whiteAlpha.400">{marketStats.totalCount} </Text>
</HStack>
{/* 进度条 */}
<Box position="relative" h="6px" borderRadius="full" overflow="hidden" bg="rgba(255,255,255,0.05)">
<Box
position="absolute"
left="0"
top="0"
h="100%"
w={`${(marketStats.risingCount / marketStats.totalCount) * 100}%`}
bg="linear-gradient(90deg, #F31260, #FF6B9D)"
borderRadius="full"
/>
<Box
position="absolute"
left={`${(marketStats.risingCount / marketStats.totalCount) * 100}%`}
top="0"
h="100%"
w={`${(marketStats.flatCount / marketStats.totalCount) * 100}%`}
bg="rgba(255,255,255,0.3)"
/>
</Box>
{/* 数字统计 */}
<HStack justify="space-between" mt={2}>
<HStack spacing={1}>
<Box w="8px" h="8px" borderRadius="full" bg="#F31260" boxShadow="0 0 8px #F3126060" />
<Text fontSize="sm" color="#FF6B9D" fontWeight="bold">{marketStats.risingCount}</Text>
<Text fontSize="xs" color="whiteAlpha.500"></Text>
</HStack>
<HStack spacing={1}>
<Box w="8px" h="8px" borderRadius="full" bg="whiteAlpha.400" />
<Text fontSize="sm" color="whiteAlpha.700" fontWeight="bold">{marketStats.flatCount}</Text>
<Text fontSize="xs" color="whiteAlpha.500"></Text>
</HStack>
<HStack spacing={1}>
<Box w="8px" h="8px" borderRadius="full" bg="#17C964" boxShadow="0 0 8px #17C96460" />
<Text fontSize="sm" color="#17C964" fontWeight="bold">{marketStats.fallingCount}</Text>
<Text fontSize="xs" color="whiteAlpha.500"></Text>
</HStack>
</HStack>
</Box>
)}
</Box>
);
};
/**
* HeroUI 风格紧凑数据卡片
*/
const CompactStatCard = ({ label, value, icon, color = '#7C3AED', subText }) => (
<Box
bg="rgba(255,255,255,0.03)"
backdropFilter="blur(10px)"
borderRadius="xl"
p={3}
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)',
transform: 'translateY(-2px)',
}}
transition="all 0.3s ease"
position="relative"
overflow="hidden"
_before={{
content: '""',
position: 'absolute',
top: 0,
right: 0,
w: '60px',
h: '60px',
background: `radial-gradient(circle, ${color}15 0%, transparent 70%)`,
pointerEvents: 'none',
}}
>
<HStack spacing={2} mb={1}>
<Box
color={color}
fontSize="sm"
p={1.5}
borderRadius="lg"
bg={`${color}15`}
>
{icon}
</Box>
<Text fontSize="xs" color="whiteAlpha.600" fontWeight="medium">
{label}
</Text>
</HStack>
<Text fontSize="lg" fontWeight="bold" color={color} lineHeight="1.2" textShadow={`0 0 20px ${color}30`}>
{value}
</Text>
{subText && (
<Text fontSize="2xs" color="gray.600" mt={0.5}>
{subText}
</Text>
)}
</Box>
);
/**
* TOP事件列表项
*/
const TopEventItem = ({ event, rank }) => {
const handleClick = () => {
if (event.id) {
window.open(getEventDetailUrl(event.id), '_blank');
window.open(getEventDetailUrl(event.id), "_blank");
}
};
@@ -322,12 +65,12 @@ const TopEventItem = ({ event, rank }) => {
px={2}
bg="rgba(0,0,0,0.2)"
borderRadius="md"
_hover={{ bg: 'rgba(255,215,0,0.12)', cursor: 'pointer' }}
_hover={{ bg: "rgba(255,215,0,0.12)", cursor: "pointer" }}
transition="all 0.15s"
onClick={handleClick}
>
<Badge
colorScheme={rank === 1 ? 'yellow' : rank === 2 ? 'gray' : 'orange'}
colorScheme={rank === 1 ? "yellow" : rank === 2 ? "gray" : "orange"}
fontSize="2xs"
px={1.5}
borderRadius="full"
@@ -342,7 +85,7 @@ const TopEventItem = ({ event, rank }) => {
color="gray.300"
flex="1"
noOfLines={1}
_hover={{ color: '#FFD700' }}
_hover={{ color: "#FFD700" }}
>
{event.title}
</Text>
@@ -354,52 +97,19 @@ const TopEventItem = ({ event, rank }) => {
);
};
/**
* TOP股票列表项
*/
const TopStockItem = ({ stock, rank }) => {
return (
<HStack
spacing={2}
py={1}
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="55px">
{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>
);
};
// 单个事件项高度py=1 约 8px * 2 + 内容约 20px + spacing 4px
const ITEM_HEIGHT = 32;
const VISIBLE_COUNT = 8;
const EventDailyStats = () => {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [, setRefreshing] = useState(false);
const [stats, setStats] = useState(null);
const [error, setError] = useState(null);
const [activeTab, setActiveTab] = useState(0);
const [selectedDate, setSelectedDate] = useState('');
const [isPaused, setIsPaused] = useState(false);
const controls = useAnimationControls();
const fetchStats = useCallback(async (dateStr = '', isRefresh = false) => {
const fetchStats = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
@@ -408,17 +118,18 @@ const EventDailyStats = () => {
setError(null);
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 response = await fetch(
`${apiBase}/api/v1/events/effectiveness-stats?days=1`
);
if (!response.ok) throw new Error("获取数据失败");
const data = await response.json();
if (data.success || data.code === 200) {
setStats(data.data);
} else {
throw new Error(data.message || '数据格式错误');
throw new Error(data.message || "数据格式错误");
}
} catch (err) {
console.error('获取事件统计失败:', err);
console.error("获取事件统计失败:", err);
setError(err.message);
} finally {
setLoading(false);
@@ -427,29 +138,50 @@ const EventDailyStats = () => {
}, []);
useEffect(() => {
fetchStats(selectedDate);
}, [fetchStats, selectedDate]);
fetchStats();
}, [fetchStats]);
// 自动刷新(仅当选择今天时,每60秒刷新一次
// 自动刷新每60秒刷新一次
useEffect(() => {
if (!selectedDate) {
const interval = setInterval(() => fetchStats('', true), 60 * 1000);
const interval = setInterval(() => fetchStats(true), 60 * 1000);
return () => clearInterval(interval);
}
}, [selectedDate, fetchStats]);
}, [fetchStats]);
const handleDateChange = (e) => {
setSelectedDate(e.target.value);
// 获取显示列表取前10个复制一份用于无缝循环
const displayList = useMemo(() => {
const topPerformers = stats?.topPerformers || [];
const list = topPerformers.slice(0, 10);
// 数据不足5个时不需要滚动
if (list.length <= VISIBLE_COUNT) return list;
// 复制一份用于无缝循环
return [...list, ...list];
}, [stats]);
const needScroll = displayList.length > VISIBLE_COUNT;
const originalCount = Math.min((stats?.topPerformers || []).length, 10);
const totalScrollHeight = originalCount * ITEM_HEIGHT;
// 滚动动画
useEffect(() => {
if (!needScroll || isPaused) {
controls.stop();
return;
}
const startAnimation = async () => {
await controls.start({
y: -totalScrollHeight,
transition: {
duration: originalCount * 2, // 每个item约2秒
ease: "linear",
repeat: Infinity,
repeatType: "loop",
},
});
};
// 手动刷新
const handleRefresh = () => {
if (!refreshing) {
fetchStats(selectedDate, true);
}
};
const isToday = !selectedDate;
startAnimation();
}, [needScroll, isPaused, controls, totalScrollHeight, originalCount]);
if (loading) {
return (
@@ -468,34 +200,14 @@ const EventDailyStats = () => {
);
}
if (error || !stats) {
return (
<Box
bg="linear-gradient(180deg, rgba(25, 32, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%)"
borderRadius="xl"
p={3}
border="1px solid"
borderColor="rgba(255, 215, 0, 0.15)"
h="100%"
>
<Center h="100%">
<VStack spacing={2}>
<Text color="gray.400" fontSize="sm">暂无数据</Text>
<Text fontSize="xs" color="gray.600">{error}</Text>
</VStack>
</Center>
</Box>
);
}
const { summary, marketStats, topPerformers = [], topStocks = [] } = stats;
const hasData = stats && displayList.length > 0;
return (
<Box
bg="linear-gradient(135deg, rgba(10, 10, 20, 0.9) 0%, rgba(20, 20, 40, 0.95) 50%, rgba(15, 15, 30, 0.9) 100%)"
backdropFilter="blur(20px)"
borderRadius="2xl"
p={4}
borderRadius="xl"
p={3}
border="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
position="relative"
@@ -528,266 +240,54 @@ const EventDailyStats = () => {
/>
{/* 标题行 */}
<Flex justify="space-between" align="center" mb={4}>
<HStack spacing={3}>
<HStack spacing={2} mb={2}>
<Box
w="4px"
h="20px"
w="3px"
h="14px"
bg="linear-gradient(180deg, #7C3AED, #06B6D4)"
borderRadius="full"
boxShadow="0 0 10px rgba(124, 58, 237, 0.5)"
boxShadow="0 0 8px rgba(124, 58, 237, 0.5)"
/>
<Text fontSize="md" fontWeight="bold" color="white" letterSpacing="wide">
{isToday ? '今日统计' : '历史统计'}
<Text fontSize="sm" fontWeight="bold" color="white">
事件 TOP 排行
</Text>
{isToday && (
<Box
px={2}
py={0.5}
bg="rgba(23, 201, 100, 0.15)"
border="1px solid rgba(23, 201, 100, 0.3)"
borderRadius="full"
>
<HStack spacing={1}>
<Box
w="6px"
h="6px"
borderRadius="full"
bg="#17C964"
animation="pulse 2s infinite"
boxShadow="0 0 8px #17C964"
/>
<Text fontSize="xs" color="#17C964" fontWeight="medium">实时</Text>
</HStack>
</Box>
)}
</HStack>
<HStack spacing={2}>
{/* 刷新按钮 */}
<Tooltip label="刷新数据" placement="bottom" hasArrow>
{/* 内容区域 - 固定高度显示8个向上滚动轮播 */}
<Box
p={1.5}
bg="rgba(255,255,255,0.03)"
border="1px solid rgba(255,255,255,0.08)"
borderRadius="lg"
cursor="pointer"
_hover={{ bg: 'rgba(6, 182, 212, 0.15)', borderColor: 'rgba(6, 182, 212, 0.3)', transform: 'scale(1.05)' }}
transition="all 0.2s"
onClick={handleRefresh}
h={`${ITEM_HEIGHT * VISIBLE_COUNT}px`}
maxH={`${ITEM_HEIGHT * VISIBLE_COUNT}px`}
overflow="hidden"
position="relative"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
<ReloadOutlined
style={{
color: 'rgba(6, 182, 212, 0.8)',
fontSize: '14px',
}}
spin={refreshing}
{hasData ? (
<MotionBox animate={controls} initial={{ y: 0 }}>
<VStack spacing={1} align="stretch">
{displayList.map((event, idx) => (
<TopEventItem
key={`${event.id || idx}-${idx}`}
event={event}
rank={(idx % originalCount) + 1}
/>
</Box>
</Tooltip>
{/* 今天按钮 - 仅在查看历史时显示 */}
{!isToday && (
<Box
px={3}
py={1}
bg="rgba(124, 58, 237, 0.15)"
border="1px solid rgba(124, 58, 237, 0.3)"
borderRadius="lg"
cursor="pointer"
_hover={{ bg: 'rgba(124, 58, 237, 0.25)', transform: 'scale(1.02)' }}
transition="all 0.2s"
onClick={() => setSelectedDate('')}
>
<Text fontSize="xs" color="#A78BFA" fontWeight="bold">返回今天</Text>
</Box>
)}
<Box
as="label"
display="flex"
alignItems="center"
gap={2}
px={3}
py={1.5}
bg="rgba(255,255,255,0.03)"
border="1px solid rgba(255,255,255,0.08)"
borderRadius="lg"
cursor="pointer"
_hover={{ bg: 'rgba(255,255,255,0.06)', borderColor: 'rgba(255,255,255,0.12)' }}
transition="all 0.2s"
>
<CalendarOutlined style={{ color: 'rgba(255,255,255,0.6)', fontSize: '14px' }} />
<Input
type="date"
size="xs"
value={selectedDate}
onChange={handleDateChange}
max={new Date().toISOString().split('T')[0]}
bg="transparent"
border="none"
color="whiteAlpha.800"
fontSize="xs"
w="100px"
h="20px"
p={0}
_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>
{/* 内容区域 - 固定高度滚动 */}
<Box
flex="1"
overflowY="auto"
pr={1}
css={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'rgba(255,255,255,0.02)', borderRadius: '2px' },
'&::-webkit-scrollbar-thumb': { background: 'rgba(124, 58, 237, 0.3)', borderRadius: '2px' },
'&::-webkit-scrollbar-thumb:hover': { background: 'rgba(124, 58, 237, 0.5)' },
}}
>
<VStack spacing={4} align="stretch">
{/* 胜率对比仪表盘 */}
<WinRateGauge
eventRate={summary?.positiveRate || 0}
marketRate={marketStats?.risingRate || 0}
marketStats={marketStats}
/>
{/* 核心指标 - 2x2 网格 */}
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={3}>
<CompactStatCard
label="事件数"
value={summary?.totalEvents || 0}
icon={<FireOutlined />}
color="#F59E0B"
/>
<CompactStatCard
label="关联股票"
value={summary?.totalStocks || 0}
icon={<StockOutlined />}
color="#06B6D4"
/>
<CompactStatCard
label="平均超额"
value={formatChg(summary?.avgChg)}
icon={<RiseOutlined />}
color={summary?.avgChg >= 0 ? '#F31260' : '#17C964'}
/>
<CompactStatCard
label="最大超额"
value={formatChg(summary?.maxChg)}
icon={<ThunderboltOutlined />}
color="#F31260"
/>
</Box>
{/* 分割线 */}
<Box h="1px" bg="rgba(255,255,255,0.06)" />
{/* TOP 表现 - Tab 切换 */}
<Box>
<Tabs
variant="soft-rounded"
colorScheme="yellow"
size="sm"
index={activeTab}
onChange={setActiveTab}
display="flex"
flexDirection="column"
flex="1"
>
<TabList mb={1} flexShrink={0}>
<Tab
fontSize="xs"
py={1}
px={2}
_selected={{ bg: 'rgba(255,215,0,0.2)', color: '#FFD700' }}
color="gray.500"
>
<HStack spacing={1}>
<TrophyOutlined style={{ fontSize: '10px' }} />
<Text>事件TOP10</Text>
</HStack>
</Tab>
<Tab
fontSize="xs"
py={1}
px={2}
_selected={{ bg: 'rgba(255,215,0,0.2)', color: '#FFD700' }}
color="gray.500"
>
<HStack spacing={1}>
<StockOutlined style={{ fontSize: '10px' }} />
<Text>股票TOP10</Text>
</HStack>
</Tab>
</TabList>
<TabPanels flex="1" minH={0}>
{/* 事件 TOP10 */}
<TabPanel p={0} h="100%">
<VStack
spacing={1}
align="stretch"
h="100%"
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)' },
}}
>
{topPerformers.slice(0, 10).map((event, idx) => (
<TopEventItem key={event.id || idx} event={event} rank={idx + 1} />
))}
{topPerformers.length === 0 && (
<Text fontSize="xs" color="gray.600" textAlign="center" py={2}>
</VStack>
</MotionBox>
) : (
<Center h="100%">
<VStack spacing={1}>
<Text color="gray.500" fontSize="sm">
暂无数据
</Text>
)}
</VStack>
</TabPanel>
{/* 股票 TOP10 */}
<TabPanel p={0} h="100%">
<VStack
spacing={1}
align="stretch"
h="100%"
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}>
暂无数据
{error && (
<Text fontSize="xs" color="gray.600">
{error}
</Text>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
</VStack>
</Center>
)}
</Box>
</Box>
);

View File

@@ -1,362 +0,0 @@
/**
* EventEffectivenessStats - 事件有效性统计
* 展示事件中心的事件有效性数据,证明系统推荐价值
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
Box,
Text,
VStack,
HStack,
Spinner,
Center,
useToast,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
Progress,
Badge,
Divider,
Tooltip,
Icon,
} from '@chakra-ui/react';
import {
TrophyOutlined,
RiseOutlined,
FireOutlined,
CheckCircleOutlined,
ThunderboltOutlined,
StarOutlined,
} from '@ant-design/icons';
import { getApiBase } from '@utils/apiConfig';
/**
* 格式化涨跌幅
*/
const formatChg = (val) => {
if (val === null || val === undefined) return '-';
const num = parseFloat(val);
if (isNaN(num)) return '-';
return (num >= 0 ? '+' : '') + num.toFixed(2) + '%';
};
/**
* 获取涨跌幅颜色
*/
const getChgColor = (val) => {
if (val === null || val === undefined) return 'gray.400';
const num = parseFloat(val);
if (isNaN(num)) return 'gray.400';
if (num > 0) return '#FF4D4F';
if (num < 0) return '#52C41A';
return 'gray.400';
};
/**
* 数据卡片组件
*/
const StatCard = ({ label, value, icon, color = '#FFD700', subText, trend, progress }) => (
<Box
bg="rgba(0,0,0,0.3)"
borderRadius="lg"
p={3}
border="1px solid"
borderColor="rgba(255,215,0,0.15)"
_hover={{ borderColor: 'rgba(255,215,0,0.3)', transform: 'translateY(-2px)' }}
transition="all 0.2s"
>
<HStack spacing={2} mb={1}>
<Box color={color} fontSize="md">
{icon}
</Box>
<Text fontSize="xs" color="gray.400" fontWeight="medium">
{label}
</Text>
</HStack>
<Text fontSize="xl" fontWeight="bold" color={color}>
{value}
</Text>
{subText && (
<Text fontSize="xs" color="gray.500" mt={1}>
{subText}
</Text>
)}
{trend !== undefined && (
<HStack spacing={1} mt={1}>
<StatArrow type={trend >= 0 ? 'increase' : 'decrease'} />
<Text fontSize="xs" color={trend >= 0 ? '#FF4D4F' : '#52C41A'}>
{Math.abs(trend).toFixed(1)}%
</Text>
</HStack>
)}
{progress !== undefined && (
<Progress
value={progress}
size="xs"
colorScheme={progress >= 60 ? 'green' : progress >= 40 ? 'yellow' : 'red'}
mt={2}
borderRadius="full"
bg="rgba(255,255,255,0.1)"
/>
)}
</Box>
);
/**
* 热门事件列表项
*/
const TopEventItem = ({ event, rank }) => (
<HStack
spacing={2}
py={1.5}
px={2}
bg="rgba(0,0,0,0.2)"
borderRadius="md"
_hover={{ bg: 'rgba(255,215,0,0.1)' }}
transition="all 0.2s"
>
<Badge
colorScheme={rank === 1 ? 'yellow' : rank === 2 ? 'gray' : 'orange'}
fontSize="xs"
px={2}
borderRadius="full"
>
{rank}
</Badge>
<Tooltip label={event.title} placement="top" hasArrow>
<Text
fontSize="xs"
color="gray.200"
flex="1"
noOfLines={1}
cursor="default"
>
{event.title}
</Text>
</Tooltip>
<Text
fontSize="xs"
fontWeight="bold"
color={getChgColor(event.max_chg)}
>
{formatChg(event.max_chg)}
</Text>
</HStack>
);
const EventEffectivenessStats = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState(null);
const [error, setError] = useState(null);
const toast = useToast();
const fetchStats = useCallback(async () => {
setLoading(true);
setError(null);
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/v1/events/effectiveness-stats?days=30`);
if (!response.ok) throw new Error('获取数据失败');
const data = await response.json();
if (data.code === 200) {
setStats(data.data);
} else {
throw new Error(data.message || '数据格式错误');
}
} catch (err) {
console.error('获取事件有效性统计失败:', err);
setError(err.message);
toast({
title: '获取统计数据失败',
description: err.message,
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
fetchStats();
}, [fetchStats]);
if (loading) {
return (
<Box
bg="linear-gradient(180deg, rgba(25, 32, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%)"
borderRadius="xl"
p={4}
border="1px solid"
borderColor="rgba(255, 215, 0, 0.15)"
minH="400px"
>
<Center h="350px">
<Spinner size="lg" color="yellow.400" thickness="3px" />
</Center>
</Box>
);
}
if (error || !stats) {
return (
<Box
bg="linear-gradient(180deg, rgba(25, 32, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%)"
borderRadius="xl"
p={4}
border="1px solid"
borderColor="rgba(255, 215, 0, 0.15)"
minH="400px"
>
<Center h="350px">
<VStack spacing={2}>
<Text color="gray.400">暂无数据</Text>
<Text fontSize="xs" color="gray.500">{error}</Text>
</VStack>
</Center>
</Box>
);
}
const { summary, topPerformers = [] } = stats;
return (
<Box
bg="linear-gradient(180deg, rgba(25, 32, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%)"
borderRadius="xl"
p={4}
border="1px solid"
borderColor="rgba(255, 215, 0, 0.15)"
position="relative"
overflow="hidden"
h="100%"
>
{/* 背景装饰 */}
<Box
position="absolute"
top="-50%"
right="-30%"
w="300px"
h="300px"
bg="radial-gradient(circle, rgba(255,215,0,0.08) 0%, transparent 70%)"
pointerEvents="none"
/>
{/* 标题 */}
<HStack spacing={2} mb={4}>
<Box
w="4px"
h="20px"
bg="linear-gradient(180deg, #FFD700, #FFA500)"
borderRadius="full"
/>
<Text
fontSize="md"
fontWeight="bold"
color="white"
letterSpacing="wide"
>
事件有效性统计
</Text>
<Badge
colorScheme="yellow"
variant="subtle"
fontSize="xs"
px={2}
>
近30天
</Badge>
</HStack>
<VStack spacing={4} align="stretch">
{/* 核心指标 - 2列网格 */}
<Box
display="grid"
gridTemplateColumns="repeat(2, 1fr)"
gap={3}
>
<StatCard
label="事件总数"
value={summary?.totalEvents || 0}
icon={<FireOutlined />}
color="#FFD700"
subText="活跃事件"
/>
<StatCard
label="正向率"
value={`${(summary?.positiveRate || 0).toFixed(1)}%`}
icon={<CheckCircleOutlined />}
color={summary?.positiveRate >= 50 ? '#52C41A' : '#FF4D4F'}
progress={summary?.positiveRate || 0}
/>
<StatCard
label="平均涨幅"
value={formatChg(summary?.avgChg)}
icon={<RiseOutlined />}
color={getChgColor(summary?.avgChg)}
subText="关联股票"
/>
<StatCard
label="最大涨幅"
value={formatChg(summary?.maxChg)}
icon={<ThunderboltOutlined />}
color="#FF4D4F"
subText="单事件最佳"
/>
</Box>
{/* 评分指标 */}
<Box
display="grid"
gridTemplateColumns="repeat(2, 1fr)"
gap={3}
>
<StatCard
label="投资价值"
value={(summary?.avgInvestScore || 0).toFixed(0)}
icon={<StarOutlined />}
color="#F59E0B"
progress={summary?.avgInvestScore || 0}
subText="平均评分"
/>
<StatCard
label="超预期"
value={(summary?.avgSurpriseScore || 0).toFixed(0)}
icon={<TrophyOutlined />}
color="#8B5CF6"
progress={summary?.avgSurpriseScore || 0}
subText="惊喜程度"
/>
</Box>
{/* 分割线 */}
<Divider borderColor="rgba(255,215,0,0.1)" />
{/* TOP表现事件 */}
<Box>
<HStack spacing={2} mb={3}>
<TrophyOutlined style={{ color: '#FFD700', fontSize: '14px' }} />
<Text fontSize="sm" fontWeight="bold" color="gray.300">
TOP 表现事件
</Text>
</HStack>
<VStack spacing={2} align="stretch">
{topPerformers.slice(0, 5).map((event, idx) => (
<TopEventItem key={event.id || idx} event={event} rank={idx + 1} />
))}
{topPerformers.length === 0 && (
<Text fontSize="xs" color="gray.500" textAlign="center" py={2}>
暂无数据
</Text>
)}
</VStack>
</Box>
</VStack>
</Box>
);
};
export default EventEffectivenessStats;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
// 事件表格列定义
// 用于未来事件 Tab
import React from "react";
import { Button, Space, Tooltip, Typography } from "antd";
import {
ClockCircleOutlined,
LinkOutlined,
RobotOutlined,
StockOutlined,
} from "@ant-design/icons";
import { StarFilled } from "@ant-design/icons";
import dayjs from "dayjs";
const { Text: AntText } = Typography;
// 渲染星级评分
const renderStars = (star) => {
const level = parseInt(star, 10) || 0;
return (
<span>
{[1, 2, 3, 4, 5].map((i) => (
<StarFilled
key={i}
style={{
color: i <= level ? "#faad14" : "#d9d9d9",
fontSize: "14px",
}}
/>
))}
</span>
);
};
/**
* 创建事件表格列
* @param {Object} options - 配置选项
* @param {Function} options.showContentDetail - 显示内容详情
* @param {Function} options.showRelatedStocks - 显示相关股票
*/
export const createEventColumns = ({ showContentDetail, showRelatedStocks }) => [
{
title: "时间",
dataIndex: "calendar_time",
key: "time",
width: 80,
render: (time) => (
<Space>
<ClockCircleOutlined />
<AntText>{dayjs(time).format("HH:mm")}</AntText>
</Space>
),
},
{
title: "重要度",
dataIndex: "star",
key: "star",
width: 120,
render: renderStars,
},
{
title: "标题",
dataIndex: "title",
key: "title",
ellipsis: true,
render: (text) => (
<Tooltip title={text}>
<AntText strong style={{ fontSize: "14px" }}>
{text}
</AntText>
</Tooltip>
),
},
{
title: "背景",
dataIndex: "former",
key: "former",
width: 80,
render: (text) => (
<Button
type="link"
size="small"
icon={<LinkOutlined />}
onClick={() => showContentDetail(text, "事件背景")}
disabled={!text}
>
查看
</Button>
),
},
{
title: "未来推演",
dataIndex: "forecast",
key: "forecast",
width: 90,
render: (text) => (
<Button
type="link"
size="small"
icon={<RobotOutlined />}
onClick={() => showContentDetail(text, "未来推演")}
disabled={!text}
>
{text ? "查看" : "无"}
</Button>
),
},
{
title: "相关股票",
dataIndex: "related_stocks",
key: "stocks",
width: 120,
render: (stocks, record) => {
const hasStocks = stocks && stocks.length > 0;
if (!hasStocks) {
return <AntText type="secondary"></AntText>;
}
return (
<Button
type="link"
size="small"
icon={<StockOutlined />}
onClick={() =>
showRelatedStocks(stocks, record.calendar_time, record.title)
}
>
{stocks.length}
</Button>
);
},
},
];

View File

@@ -0,0 +1,6 @@
// HeroPanel 表格列相关导出
export * from './renderers';
export { createStockColumns } from './stockColumns';
export { createSectorColumns } from './sectorColumns';
export { createZtStockColumns } from './ztStockColumns';
export { createEventColumns } from './eventColumns';

View File

@@ -0,0 +1,184 @@
// HeroPanel 表格列渲染器
import { Tag, Space, Button, Typography, Tooltip } from "antd";
import {
ClockCircleOutlined,
LinkOutlined,
RobotOutlined,
StockOutlined,
StarFilled,
StarOutlined,
LineChartOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
const { Text: AntText } = Typography;
/**
* 渲染星级评分
*/
export const renderStars = (star) => {
const level = parseInt(star, 10) || 0;
return (
<span>
{[1, 2, 3, 4, 5].map((i) => (
<span
key={i}
style={{
color: i <= level ? "#faad14" : "#d9d9d9",
fontSize: "14px",
}}
>
</span>
))}
</span>
);
};
/**
* 渲染涨跌幅
*/
export const renderChangePercent = (val) => {
if (val === null || val === undefined) return "-";
const num = parseFloat(val);
const color = num > 0 ? "#ff4d4f" : num < 0 ? "#52c41a" : "#888";
const prefix = num > 0 ? "+" : "";
return (
<span style={{ color, fontWeight: 600 }}>
{prefix}{num.toFixed(2)}%
</span>
);
};
/**
* 渲染时间
*/
export const renderTime = (time) => (
<Space>
<ClockCircleOutlined />
<AntText>{dayjs(time).format("HH:mm")}</AntText>
</Space>
);
/**
* 渲染标题带tooltip
*/
export const renderTitle = (text) => (
<Tooltip title={text}>
<AntText strong style={{ fontSize: "14px" }}>
{text}
</AntText>
</Tooltip>
);
/**
* 渲染排名样式
*/
export const getRankStyle = (index) => {
if (index === 0) {
return {
background: "linear-gradient(135deg, #FFD700 0%, #FFA500 100%)",
color: "#000",
};
}
if (index === 1) {
return {
background: "linear-gradient(135deg, #C0C0C0 0%, #A9A9A9 100%)",
color: "#000",
};
}
if (index === 2) {
return {
background: "linear-gradient(135deg, #CD7F32 0%, #8B4513 100%)",
color: "#fff",
};
}
return {
background: "rgba(255, 255, 255, 0.08)",
color: "#888",
};
};
/**
* 渲染排名徽章
*/
export const renderRankBadge = (index) => {
const style = getRankStyle(index);
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 24,
height: 24,
borderRadius: "50%",
fontSize: 12,
fontWeight: "bold",
...style,
}}
>
{index + 1}
</div>
);
};
/**
* 创建查看按钮渲染器
*/
export const createViewButtonRenderer = (onClick, iconType = "link") => {
const icons = {
link: <LinkOutlined />,
robot: <RobotOutlined />,
stock: <StockOutlined />,
chart: <LineChartOutlined />,
};
return (text, record) => (
<Button
type="link"
size="small"
icon={icons[iconType] || icons.link}
onClick={() => onClick(text, record)}
disabled={!text}
>
{text ? "查看" : "无"}
</Button>
);
};
/**
* 创建自选按钮渲染器
*/
export const createWatchlistButtonRenderer = (isInWatchlist, onAdd) => {
return (_, record) => {
const inWatchlist = isInWatchlist(record.code);
return (
<Button
type={inWatchlist ? "primary" : "default"}
size="small"
icon={inWatchlist ? <StarFilled /> : <StarOutlined />}
onClick={() => onAdd(record)}
disabled={inWatchlist}
>
{inWatchlist ? "已添加" : "加自选"}
</Button>
);
};
};
/**
* 创建K线按钮渲染器
*/
export const createKlineButtonRenderer = (showKline) => {
return (_, record) => (
<Button
type="primary"
size="small"
icon={<LineChartOutlined />}
onClick={() => showKline(record)}
>
查看
</Button>
);
};

View File

@@ -0,0 +1,368 @@
// 涨停板块表格列定义
// 用于涨停分析板块视图
import React from "react";
import { Tag, Button, Tooltip, Typography } from "antd";
import { FireOutlined } from "@ant-design/icons";
import { Box, HStack, VStack } from "@chakra-ui/react";
import { FileText } from "lucide-react";
const { Text: AntText } = Typography;
// 获取排名样式
const getRankStyle = (index) => {
if (index === 0) {
return {
background: "linear-gradient(135deg, #FFD700 0%, #FFA500 100%)",
color: "#000",
fontWeight: "bold",
};
}
if (index === 1) {
return {
background: "linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%)",
color: "#000",
fontWeight: "bold",
};
}
if (index === 2) {
return {
background: "linear-gradient(135deg, #CD7F32 0%, #A0522D 100%)",
color: "#fff",
fontWeight: "bold",
};
}
return { background: "rgba(255,255,255,0.1)", color: "#888" };
};
// 获取涨停数颜色
const getCountColor = (count) => {
if (count >= 8) return { bg: "#ff4d4f", text: "#fff" };
if (count >= 5) return { bg: "#fa541c", text: "#fff" };
if (count >= 3) return { bg: "#fa8c16", text: "#fff" };
return { bg: "rgba(255,215,0,0.2)", text: "#FFD700" };
};
// 获取相关度颜色
const getRelevanceColor = (score) => {
if (score >= 80) return "#10B981";
if (score >= 60) return "#F59E0B";
return "#6B7280";
};
/**
* 创建涨停板块表格列
* @param {Object} options - 配置选项
* @param {Array} options.stockList - 股票列表数据
* @param {Function} options.setSelectedSectorInfo - 设置选中板块信息
* @param {Function} options.setSectorStocksModalVisible - 设置板块股票弹窗可见性
* @param {Function} options.setSelectedRelatedEvents - 设置关联事件
* @param {Function} options.setRelatedEventsModalVisible - 设置关联事件弹窗可见性
*/
export const createSectorColumns = ({
stockList,
setSelectedSectorInfo,
setSectorStocksModalVisible,
setSelectedRelatedEvents,
setRelatedEventsModalVisible,
}) => [
{
title: "排名",
key: "rank",
width: 60,
align: "center",
render: (_, __, index) => {
const style = getRankStyle(index);
return (
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
margin: "0 auto",
...style,
}}
>
{index + 1}
</div>
);
},
},
{
title: "板块名称",
dataIndex: "name",
key: "name",
width: 130,
render: (name, record, index) => (
<HStack spacing={2}>
<Box
w="4px"
h="24px"
borderRadius="full"
bg={
index < 3
? "linear-gradient(180deg, #FFD700 0%, #FF8C00 100%)"
: "whiteAlpha.300"
}
/>
<AntText
strong
style={{
color: index < 3 ? "#FFD700" : "#E0E0E0",
fontSize: "14px",
}}
>
{name}
</AntText>
</HStack>
),
},
{
title: "涨停数",
dataIndex: "count",
key: "count",
width: 90,
align: "center",
render: (count) => {
const colors = getCountColor(count);
return (
<HStack justify="center" spacing={1}>
<Box
px={3}
py={1}
borderRadius="full"
bg={colors.bg}
display="flex"
alignItems="center"
gap={1}
>
<FireOutlined style={{ color: colors.text, fontSize: "12px" }} />
<span
style={{
color: colors.text,
fontWeight: "bold",
fontSize: "14px",
}}
>
{count}
</span>
</Box>
</HStack>
);
},
},
{
title: "涨停股票",
dataIndex: "stocks",
key: "stocks",
render: (stocks, record) => {
// 根据股票代码查找股票详情,并按连板天数排序
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 stockInfoList = getStockInfoList();
const displayStocks = stockInfoList.slice(0, 4);
const handleShowAll = (e) => {
e.stopPropagation();
setSelectedSectorInfo({
name: record.name,
count: record.count,
stocks: stockInfoList,
});
setSectorStocksModalVisible(true);
};
return (
<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",
}}
>
{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={handleShowAll}
style={{
padding: "0 4px",
height: "auto",
fontSize: "12px",
color: "#FFD700",
}}
>
查看全部 {stocks.length}
</Button>
)}
</HStack>
);
},
},
{
title: "涨停归因",
dataIndex: "related_events",
key: "related_events",
width: 280,
render: (events, record) => {
if (!events || events.length === 0) {
return (
<AntText style={{ color: "#666", fontSize: "12px" }}>-</AntText>
);
}
// 取相关度最高的事件
const sortedEvents = [...events].sort(
(a, b) => (b.relevance_score || 0) - (a.relevance_score || 0)
);
const topEvent = sortedEvents[0];
// 点击打开事件详情弹窗
const handleClick = (e) => {
e.stopPropagation();
setSelectedRelatedEvents({
sectorName: record.name,
events: sortedEvents,
count: record.count,
});
setRelatedEventsModalVisible(true);
};
return (
<VStack align="start" spacing={1}>
<Box
cursor="pointer"
p={1.5}
borderRadius="md"
bg="rgba(96, 165, 250, 0.1)"
_hover={{
bg: "rgba(96, 165, 250, 0.2)",
transform: "translateY(-1px)",
}}
transition="all 0.2s"
maxW="260px"
onClick={handleClick}
>
<HStack spacing={1.5} align="start">
<FileText
size={14}
color="#60A5FA"
style={{ flexShrink: 0, marginTop: 2 }}
/>
<VStack align="start" spacing={0.5} flex={1}>
<AntText
style={{
color: "#E0E0E0",
fontSize: "12px",
lineHeight: "1.3",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{topEvent.title}
</AntText>
<HStack spacing={2}>
<Tag
style={{
fontSize: "10px",
padding: "0 6px",
background: `${getRelevanceColor(
topEvent.relevance_score || 0
)}20`,
border: "none",
color: getRelevanceColor(topEvent.relevance_score || 0),
borderRadius: "4px",
}}
>
相关度 {topEvent.relevance_score || 0}
</Tag>
{events.length > 1 && (
<AntText style={{ fontSize: "10px", color: "#888" }}>
+{events.length - 1}
</AntText>
)}
</HStack>
</VStack>
</HStack>
</Box>
</VStack>
);
},
},
];

View File

@@ -0,0 +1,233 @@
// 相关股票表格列定义
// 用于事件关联股票弹窗
import React from "react";
import { Tag, Button, Tooltip, Typography } from "antd";
import { StarFilled, StarOutlined, LineChartOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import { getSixDigitCode } from "../utils";
const { Text: AntText } = Typography;
/**
* 创建相关股票表格列
* @param {Object} options - 配置选项
* @param {Object} options.stockQuotes - 股票行情数据
* @param {Object} options.expandedReasons - 展开状态
* @param {Function} options.setExpandedReasons - 设置展开状态
* @param {Function} options.showKline - 显示K线
* @param {Function} options.isStockInWatchlist - 检查是否在自选
* @param {Function} options.addSingleToWatchlist - 添加到自选
*/
export const createStockColumns = ({
stockQuotes,
expandedReasons,
setExpandedReasons,
showKline,
isStockInWatchlist,
addSingleToWatchlist,
}) => [
{
title: "代码",
dataIndex: "code",
key: "code",
width: 90,
render: (code) => {
const sixDigitCode = getSixDigitCode(code);
return (
<a
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#3B82F6" }}
>
{sixDigitCode}
</a>
);
},
},
{
title: "名称",
dataIndex: "name",
key: "name",
width: 100,
render: (name, record) => {
const sixDigitCode = getSixDigitCode(record.code);
return (
<a
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
target="_blank"
rel="noopener noreferrer"
>
<AntText strong>{name}</AntText>
</a>
);
},
},
{
title: "现价",
key: "price",
width: 80,
render: (_, record) => {
const quote = stockQuotes[record.code];
if (quote && quote.price !== undefined) {
return (
<AntText type={quote.change > 0 ? "danger" : "success"}>
{quote.price?.toFixed(2)}
</AntText>
);
}
return <AntText>-</AntText>;
},
},
{
title: "涨跌幅",
key: "change",
width: 100,
render: (_, record) => {
const quote = stockQuotes[record.code];
if (quote && quote.changePercent !== undefined) {
const changePercent = quote.changePercent || 0;
return (
<Tag
color={
changePercent > 0
? "red"
: changePercent < 0
? "green"
: "default"
}
>
{changePercent > 0 ? "+" : ""}
{changePercent.toFixed(2)}%
</Tag>
);
}
return <AntText>-</AntText>;
},
},
{
title: "关联理由",
dataIndex: "description",
key: "reason",
render: (description, record) => {
const stockCode = record.code;
const isExpanded = expandedReasons[stockCode] || false;
const reason = typeof description === "string" ? description : "";
const shouldTruncate = reason && reason.length > 80;
const toggleExpanded = () => {
setExpandedReasons((prev) => ({
...prev,
[stockCode]: !prev[stockCode],
}));
};
return (
<div>
<AntText style={{ fontSize: "13px", lineHeight: "1.6" }}>
{isExpanded || !shouldTruncate
? reason || "-"
: `${reason?.slice(0, 80)}...`}
</AntText>
{shouldTruncate && (
<Button
type="link"
size="small"
onClick={toggleExpanded}
style={{ padding: 0, marginLeft: 4, fontSize: "12px" }}
>
({isExpanded ? "收起" : "展开"})
</Button>
)}
{reason && (
<div style={{ marginTop: 4 }}>
<AntText type="secondary" style={{ fontSize: "11px" }}>
(AI合成)
</AntText>
</div>
)}
</div>
);
},
},
{
title: "研报引用",
dataIndex: "report",
key: "report",
width: 180,
render: (report) => {
if (!report || !report.title) {
return <AntText type="secondary">-</AntText>;
}
return (
<div style={{ fontSize: "12px" }}>
<Tooltip title={report.sentences || report.title}>
<div>
<AntText strong style={{ display: "block", marginBottom: 2 }}>
{report.title.length > 18
? `${report.title.slice(0, 18)}...`
: report.title}
</AntText>
{report.author && (
<AntText
type="secondary"
style={{ display: "block", fontSize: "11px" }}
>
{report.author}
</AntText>
)}
{report.declare_date && (
<AntText type="secondary" style={{ fontSize: "11px" }}>
{dayjs(report.declare_date).format("YYYY-MM-DD")}
</AntText>
)}
{report.match_score && (
<Tag
color={report.match_score === "好" ? "green" : "blue"}
style={{ marginLeft: 4, fontSize: "10px" }}
>
匹配度: {report.match_score}
</Tag>
)}
</div>
</Tooltip>
</div>
);
},
},
{
title: "K线图",
key: "kline",
width: 80,
render: (_, record) => (
<Button
type="primary"
size="small"
icon={<LineChartOutlined />}
onClick={() => showKline(record)}
>
查看
</Button>
),
},
{
title: "操作",
key: "action",
width: 90,
render: (_, record) => {
const inWatchlist = isStockInWatchlist(record.code);
return (
<Button
type={inWatchlist ? "primary" : "default"}
size="small"
icon={inWatchlist ? <StarFilled /> : <StarOutlined />}
onClick={() => addSingleToWatchlist(record)}
disabled={inWatchlist}
>
{inWatchlist ? "已添加" : "加自选"}
</Button>
);
},
},
];

View File

@@ -0,0 +1,284 @@
// 涨停股票详情表格列定义
// 用于涨停分析个股视图
import React from "react";
import { Tag, Button, Tooltip, Typography } from "antd";
import { StarFilled, StarOutlined, LineChartOutlined } from "@ant-design/icons";
import { Box, HStack, VStack } from "@chakra-ui/react";
import { getTimeStyle, getDaysStyle } from "../utils";
const { Text: AntText } = Typography;
/**
* 创建涨停股票详情表格列
* @param {Object} options - 配置选项
* @param {Function} options.showContentDetail - 显示内容详情
* @param {Function} options.setSelectedKlineStock - 设置K线股票
* @param {Function} options.setKlineModalVisible - 设置K线弹窗可见性
* @param {Function} options.isStockInWatchlist - 检查是否在自选
* @param {Function} options.addSingleToWatchlist - 添加到自选
*/
export const createZtStockColumns = ({
showContentDetail,
setSelectedKlineStock,
setKlineModalVisible,
isStockInWatchlist,
addSingleToWatchlist,
}) => [
{
title: "股票信息",
key: "stock",
width: 140,
fixed: "left",
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: "formatted_time",
key: "time",
width: 90,
align: "center",
render: (time) => {
const style = getTimeStyle(time || "15:00:00");
return (
<VStack spacing={0}>
<Box
px={2}
py={0.5}
borderRadius="md"
bg={style.bg}
fontSize="13px"
fontWeight="bold"
color={style.text}
>
{time?.substring(0, 5) || "-"}
</Box>
<AntText style={{ fontSize: "10px", color: "#888" }}>
{style.label}
</AntText>
</VStack>
);
},
},
{
title: "连板",
dataIndex: "continuous_days",
key: "continuous",
width: 70,
align: "center",
render: (text) => {
if (!text || text === "首板") {
return (
<Box
px={2}
py={0.5}
borderRadius="md"
bg="rgba(255,255,255,0.1)"
fontSize="12px"
color="#888"
>
首板
</Box>
);
}
const match = text.match(/(\d+)/);
const days = match ? parseInt(match[1]) : 1;
const style = getDaysStyle(days);
return (
<Box
px={2}
py={0.5}
borderRadius="md"
bg={style.bg}
fontSize="13px"
fontWeight="bold"
color={style.text}
>
{text}
</Box>
);
},
},
{
title: "核心板块",
dataIndex: "core_sectors",
key: "sectors",
width: 200,
render: (sectors) => (
<HStack spacing={1} flexWrap="wrap">
{(sectors || []).slice(0, 3).map((sector, idx) => (
<Tag
key={idx}
style={{
margin: "2px",
background:
idx === 0
? "linear-gradient(135deg, rgba(255,215,0,0.25) 0%, rgba(255,165,0,0.15) 100%)"
: "rgba(255,215,0,0.1)",
border:
idx === 0
? "1px solid rgba(255,215,0,0.5)"
: "1px solid rgba(255,215,0,0.2)",
borderRadius: "6px",
color: idx === 0 ? "#FFD700" : "#D4A84B",
fontSize: "12px",
fontWeight: idx === 0 ? "bold" : "normal",
}}
>
{sector}
</Tag>
))}
</HStack>
),
},
{
title: "涨停简报",
dataIndex: "brief",
key: "brief",
width: 200,
render: (text, record) => {
if (!text) return <AntText type="secondary">-</AntText>;
// 移除HTML标签
const cleanText = text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, "");
return (
<Tooltip
title={
<Box maxW="400px" p={2}>
<div
style={{
fontWeight: "bold",
marginBottom: 8,
color: "#FFD700",
}}
>
{record.sname} 涨停简报
</div>
<div
style={{
fontSize: "13px",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
}}
>
{cleanText}
</div>
</Box>
}
placement="topLeft"
overlayStyle={{ maxWidth: 450 }}
>
<Button
type="link"
size="small"
onClick={() =>
showContentDetail(
text.replace(/<br\s*\/?>/gi, "\n\n"),
`${record.sname} 涨停简报`
)
}
style={{
padding: 0,
height: "auto",
whiteSpace: "normal",
textAlign: "left",
color: "#60A5FA",
fontSize: "13px",
}}
>
{cleanText.length > 30
? cleanText.substring(0, 30) + "..."
: cleanText}
</Button>
</Tooltip>
);
},
},
{
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`;
else if (code.startsWith("688")) stockCode = `${code}.SH`;
}
setSelectedKlineStock({
stock_code: stockCode,
stock_name: record.sname,
});
setKlineModalVisible(true);
}}
style={{
background: "linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%)",
border: "none",
borderRadius: "6px",
}}
>
查看
</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",
}
: {
background: "rgba(255,255,255,0.1)",
border: "1px solid rgba(255,215,0,0.3)",
borderRadius: "6px",
color: "#FFD700",
}
}
>
{inWatchlist ? "已添加" : "加自选"}
</Button>
);
},
},
];

View File

@@ -0,0 +1,276 @@
// HeroPanel - 日历单元格组件
import React, { memo } from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Tooltip,
Icon,
} from "@chakra-ui/react";
import { Flame, FileText, TrendingUp, TrendingDown } from "lucide-react";
import { goldColors, textColors } from "../constants";
import { getHeatColor } from "../utils";
/**
* 趋势图标
*/
const TrendIcon = memo(({ current, previous }) => {
if (!current || !previous) return null;
const diff = current - previous;
if (diff === 0) return null;
const isUp = diff > 0;
return (
<Icon
as={isUp ? TrendingUp : TrendingDown}
boxSize={3}
color={isUp ? "#22c55e" : "#ef4444"}
/>
);
});
TrendIcon.displayName = "TrendIcon";
/**
* 日历单元格 - 显示涨停数和事件数(加大尺寸)
* 新增连续概念连接展示connectLeft/connectRight 表示与左右格子是否同一概念)
*/
const CalendarCell = memo(
({
date,
ztData,
eventCount,
previousZtData,
isSelected,
isToday,
isWeekend,
onClick,
connectLeft,
connectRight,
}) => {
if (!date) {
return <Box minH="75px" />;
}
const hasZtData = !!ztData;
const hasEventData = eventCount > 0;
const ztCount = ztData?.count || 0;
const heatColors = getHeatColor(ztCount);
const topSector = ztData?.top_sector || "";
// 是否有连接线(连续概念)
const hasConnection = connectLeft || connectRight;
// 周末无数据显示"休市"
if (isWeekend && !hasZtData && !hasEventData) {
return (
<Box
p={2}
borderRadius="10px"
bg="rgba(30, 30, 40, 0.3)"
border="1px solid rgba(255, 255, 255, 0.03)"
textAlign="center"
minH="75px"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
>
<Text
fontSize="md"
fontWeight="400"
color="rgba(255, 255, 255, 0.25)"
>
{date.getDate()}
</Text>
<Text fontSize="xs" color="rgba(255, 255, 255, 0.2)">
休市
</Text>
</Box>
);
}
// 正常日期
return (
<Tooltip
label={
<VStack spacing={1} align="start" p={1}>
<Text fontWeight="bold" fontSize="md">{`${
date.getMonth() + 1
}${date.getDate()}`}</Text>
{hasZtData && (
<Text>
涨停: {ztCount} {topSector && `| ${topSector}`}
</Text>
)}
{hasEventData && <Text>未来事件: {eventCount}</Text>}
{!hasZtData && !hasEventData && (
<Text color="gray.400">暂无数据</Text>
)}
</VStack>
}
placement="top"
hasArrow
bg="rgba(15, 15, 22, 0.95)"
border="1px solid rgba(212, 175, 55, 0.3)"
borderRadius="10px"
>
<Box
as="button"
p={2}
borderRadius="10px"
bg={
hasZtData
? heatColors.bg
: hasEventData
? "rgba(34, 197, 94, 0.2)"
: "rgba(40, 40, 50, 0.3)"
}
border={
isSelected
? `2px solid ${goldColors.primary}`
: isToday
? `2px solid ${goldColors.light}`
: hasZtData
? `1px solid ${heatColors.border}`
: hasEventData
? "1px solid rgba(34, 197, 94, 0.4)"
: "1px solid rgba(255, 255, 255, 0.08)"
}
boxShadow={
isSelected
? `0 0 15px ${goldColors.glow}`
: isToday
? `0 0 10px ${goldColors.glow}`
: "none"
}
position="relative"
cursor="pointer"
transition="all 0.2s"
_hover={{
transform: "scale(1.05)",
boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
borderColor: goldColors.primary,
}}
onClick={() => onClick && onClick(date)}
w="full"
minH="75px"
>
{/* 今天标记 */}
{isToday && (
<Badge
position="absolute"
top="2px"
right="2px"
bg="rgba(239, 68, 68, 0.9)"
color="white"
fontSize="9px"
px={1}
borderRadius="sm"
>
今天
</Badge>
)}
<VStack spacing={0.5} align="center">
{/* 日期 */}
<Text
fontSize="lg"
fontWeight={isSelected || isToday ? "bold" : "600"}
color={
isSelected
? goldColors.primary
: isToday
? goldColors.light
: textColors.primary
}
>
{date.getDate()}
</Text>
{/* 涨停数 + 趋势 */}
{hasZtData && (
<HStack spacing={1} justify="center">
<Icon as={Flame} boxSize={3} color={heatColors.text} />
<Text fontSize="sm" fontWeight="bold" color={heatColors.text}>
{ztCount}
</Text>
<TrendIcon current={ztCount} previous={previousZtData?.count} />
</HStack>
)}
{/* 事件数 */}
{hasEventData && (
<HStack spacing={1} justify="center">
<Icon as={FileText} boxSize={3} color="#22c55e" />
<Text fontSize="sm" fontWeight="bold" color="#22c55e">
{eventCount}
</Text>
</HStack>
)}
{/* 主要板块 - 连续概念用连接样式 */}
{hasZtData && topSector && (
<Box
position="relative"
w="full"
display="flex"
justifyContent="center"
alignItems="center"
>
{/* 左连接线 */}
{connectLeft && (
<Box
position="absolute"
left="-12px"
top="50%"
transform="translateY(-50%)"
w="12px"
h="2px"
bgGradient="linear(to-r, rgba(212,175,55,0.6), rgba(212,175,55,0.3))"
/>
)}
<Text
fontSize="xs"
color={
hasConnection ? goldColors.primary : textColors.secondary
}
fontWeight={hasConnection ? "bold" : "normal"}
noOfLines={1}
maxW="70px"
bg={hasConnection ? "rgba(212,175,55,0.15)" : "transparent"}
px={hasConnection ? 1.5 : 0}
py={hasConnection ? 0.5 : 0}
borderRadius={hasConnection ? "full" : "none"}
border={
hasConnection ? "1px solid rgba(212,175,55,0.3)" : "none"
}
>
{topSector}
</Text>
{/* 右连接线 */}
{connectRight && (
<Box
position="absolute"
right="-12px"
top="50%"
transform="translateY(-50%)"
w="12px"
h="2px"
bgGradient="linear(to-l, rgba(212,175,55,0.6), rgba(212,175,55,0.3))"
/>
)}
</Box>
)}
</VStack>
</Box>
</Tooltip>
);
}
);
CalendarCell.displayName = "CalendarCell";
export default CalendarCell;

View File

@@ -0,0 +1,275 @@
// HeroPanel - 综合日历组件
import React, { useState, useEffect, useCallback, Suspense, lazy } from "react";
import {
Box,
HStack,
VStack,
Text,
Icon,
Center,
Spinner,
} from "@chakra-ui/react";
import { Flame } from "lucide-react";
import dayjs from "dayjs";
import { GLASS_BLUR } from "@/constants/glassConfig";
import { eventService } from "@services/eventService";
import { getApiBase } from "@utils/apiConfig";
import { getConceptHtmlUrl } from "@utils/textUtils";
import { textColors } from "../constants";
import { formatDateStr } from "../utils";
// 懒加载 FullCalendar
const FullCalendarPro = lazy(() =>
import("@components/Calendar").then((module) => ({
default: module.FullCalendarPro,
}))
);
/**
* 综合日历组件 - 使用 FullCalendarPro 实现跨天事件条效果
* @param {Object} props
* @param {React.ComponentType} props.DetailModal - 详情弹窗组件
*/
const CombinedCalendar = ({ DetailModal }) => {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState(null);
// 日历综合数据(涨停 + 事件 + 上证涨跌幅)- 使用新的综合 API
const [calendarData, setCalendarData] = useState([]);
const [ztDailyDetails, setZtDailyDetails] = useState({});
const [selectedZtDetail, setSelectedZtDetail] = useState(null);
const [selectedEvents, setSelectedEvents] = useState([]);
const [detailLoading, setDetailLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
// 加载日历综合数据(一次 API 调用获取所有数据)
useEffect(() => {
const loadCalendarCombinedData = async () => {
try {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth() + 1;
const response = await fetch(
`${getApiBase()}/api/v1/calendar/combined-data?year=${year}&month=${month}`
);
if (response.ok) {
const result = await response.json();
if (result.success && result.data) {
// 转换为 FullCalendarPro 需要的格式
const formattedData = result.data.map((item) => ({
date: item.date,
count: item.zt_count || 0,
topSector: item.top_sector || "",
eventCount: item.event_count || 0,
indexChange: item.index_change,
}));
console.log(
"[HeroPanel] 加载日历综合数据成功,数据条数:",
formattedData.length
);
setCalendarData(formattedData);
}
}
} catch (error) {
console.error("Failed to load calendar combined data:", error);
}
};
loadCalendarCombinedData();
}, [currentMonth]);
// 处理日期点击 - 打开弹窗
const handleDateClick = useCallback(
async (date) => {
setSelectedDate(date);
setModalOpen(true);
setDetailLoading(true);
const ztDateStr = formatDateStr(date);
const eventDateStr = dayjs(date).format("YYYY-MM-DD");
// 加载涨停详情
const detail = ztDailyDetails[ztDateStr];
if (detail?.fullData) {
setSelectedZtDetail(detail.fullData);
} else {
try {
const response = await fetch(`/data/zt/daily/${ztDateStr}.json`);
if (response.ok) {
const data = await response.json();
setSelectedZtDetail(data);
setZtDailyDetails((prev) => ({
...prev,
[ztDateStr]: { ...prev[ztDateStr], fullData: data },
}));
} else {
setSelectedZtDetail(null);
}
} catch {
setSelectedZtDetail(null);
}
}
// 加载事件详情
try {
const response = await eventService.calendar.getEventsForDate(
eventDateStr
);
if (response.success) {
setSelectedEvents(response.data || []);
} else {
setSelectedEvents([]);
}
} catch {
setSelectedEvents([]);
}
setDetailLoading(false);
},
[ztDailyDetails]
);
// 月份变化回调
const handleMonthChange = useCallback((year, month) => {
setCurrentMonth(new Date(year, month - 1, 1));
}, []);
// 处理概念条点击 - 打开概念详情页
const handleEventClick = useCallback((event) => {
// event.title 格式: "概念名 (N天)" 或 "概念名"
const conceptName = event.title.replace(/\s*\(\d+天\)$/, "");
const url = getConceptHtmlUrl(conceptName);
if (url) {
window.open(url, "_blank");
}
}, []);
return (
<>
<Box
bg="rgba(15, 15, 22, 0.6)"
backdropFilter={GLASS_BLUR.md}
borderRadius="xl"
pt={5}
px={5}
pb={1}
position="relative"
overflow="hidden"
>
{/* 顶部装饰条 */}
<Box
position="absolute"
top={0}
left={0}
right={0}
h="3px"
bgGradient="linear(to-r, transparent, #D4AF37, #F4D03F, #D4AF37, transparent)"
animation="shimmer 3s linear infinite"
backgroundSize="200% 100%"
/>
{/* 图例说明 - 右上角 */}
<HStack
spacing={3}
position="absolute"
top={4}
right={4}
flexWrap="wrap"
justify="flex-end"
zIndex={1}
>
<HStack spacing={1}>
<Box
w="16px"
h="8px"
borderRadius="sm"
bgGradient="linear(135deg, #FFD700 0%, #FFA500 100%)"
/>
<Text fontSize="2xs" color={textColors.muted}>
热门概念
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={Flame} boxSize={2.5} color="#EF4444" />
<Text fontSize="2xs" color={textColors.muted}>
60
</Text>
</HStack>
<HStack spacing={1}>
<Icon as={Flame} boxSize={2.5} color="#F59E0B" />
<Text fontSize="2xs" color={textColors.muted}>
&lt;60
</Text>
</HStack>
<HStack spacing={1}>
<Box
w="12px"
h="12px"
borderRadius="full"
bg="linear-gradient(135deg, #22C55E 0%, #16A34A 100%)"
display="flex"
alignItems="center"
justifyContent="center"
>
<Text fontSize="6px" fontWeight="bold" color="white">
N
</Text>
</Box>
<Text fontSize="2xs" color={textColors.muted}>
事件
</Text>
</HStack>
<HStack spacing={0.5}>
<Text fontSize="2xs" fontWeight="600" color="#EF4444">
+
</Text>
<Text fontSize="2xs" color={textColors.muted}>
/
</Text>
<Text fontSize="2xs" fontWeight="600" color="#22C55E">
-
</Text>
<Text fontSize="2xs" color={textColors.muted}>
上证
</Text>
</HStack>
</HStack>
{/* FullCalendar Pro - 炫酷跨天事件条日历(懒加载) */}
<Suspense
fallback={
<Center h="300px">
<VStack spacing={4}>
<Spinner size="xl" color="gold" thickness="3px" />
<Text color="whiteAlpha.600" fontSize="sm">
加载日历组件...
</Text>
</VStack>
</Center>
}
>
<FullCalendarPro
data={calendarData}
currentMonth={currentMonth}
onDateClick={handleDateClick}
onMonthChange={handleMonthChange}
onEventClick={handleEventClick}
/>
</Suspense>
</Box>
{/* 详情弹窗 */}
{DetailModal && (
<DetailModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
selectedDate={selectedDate}
ztDetail={selectedZtDetail}
events={selectedEvents}
loading={detailLoading}
/>
)}
</>
);
};
export default CombinedCalendar;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
// 未来事件视图组件
// 展示选定日期的未来事件列表
import React, { memo } from "react";
import { Table, Typography } from "antd";
import { CalendarOutlined } from "@ant-design/icons";
const { Text: AntText } = Typography;
/**
* 未来事件视图
* @param {Array} events - 事件列表
* @param {Array} columns - 表格列配置
*/
const EventsTabView = memo(({ events, columns }) => {
// 无数据时的空状态
if (!events?.length) {
return (
<div
style={{
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
<CalendarOutlined style={{ fontSize: 48, color: "#666" }} />
<AntText type="secondary" style={{ fontSize: 16 }}>
暂无事件数据
</AntText>
</div>
</div>
);
}
return (
<Table
dataSource={events}
columns={columns}
rowKey="id"
size="small"
pagination={false}
scroll={{ x: 900, y: 420 }}
/>
);
});
EventsTabView.displayName = "EventsTabView";
export default EventsTabView;

View File

@@ -0,0 +1,220 @@
// HeroPanel - 关联事件弹窗(涨停归因详情)
// 使用 Ant Design Modal 保持与现有代码风格一致
import React from "react";
import { Modal as AntModal, Tag, ConfigProvider, theme } from "antd";
import { FileText } from "lucide-react";
import { GLASS_BLUR } from "@/constants/glassConfig";
/**
* 获取相关度颜色
*/
const getRelevanceColor = (score) => {
if (score >= 80) return "#10B981";
if (score >= 60) return "#F59E0B";
return "#6B7280";
};
/**
* 关联事件弹窗 - 涨停归因详情
*/
const RelatedEventsModal = ({
visible,
onClose,
sectorName = "",
events = [],
count = 0,
}) => {
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: { colorBgElevated: "rgba(15,15,30,0.98)" },
}}
>
<AntModal
open={visible}
onCancel={onClose}
footer={null}
width={700}
centered
title={
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
padding: "8px",
background: "rgba(96,165,250,0.15)",
borderRadius: "8px",
border: "1px solid rgba(96,165,250,0.3)",
}}
>
<FileText size={18} color="#60A5FA" />
</div>
<div>
<div style={{ fontSize: "18px", fontWeight: "bold", color: "#60A5FA" }}>
{sectorName} - 涨停归因
</div>
<div style={{ display: "flex", gap: "12px", marginTop: "4px" }}>
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)" }}>
涨停 <span style={{ color: "#EF4444", fontWeight: "bold" }}>{count}</span>
</span>
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)" }}>
关联事件 <span style={{ color: "#60A5FA", fontWeight: "bold" }}>{events?.length || 0}</span>
</span>
</div>
</div>
</div>
}
styles={{
header: {
background: "rgba(25,25,50,0.98)",
borderBottom: "1px solid rgba(96,165,250,0.2)",
padding: "16px 24px",
},
body: {
background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)",
padding: "16px 24px",
maxHeight: "65vh",
overflowY: "auto",
},
content: {
background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)",
borderRadius: "16px",
border: "1px solid rgba(96,165,250,0.3)",
},
mask: { background: "rgba(0,0,0,0.7)", backdropFilter: GLASS_BLUR.sm },
}}
>
{events?.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{events.map((event, idx) => {
const relevanceColor = getRelevanceColor(event.relevance_score || 0);
return (
<div
key={event.event_id || idx}
style={{
padding: "16px",
background: "rgba(30,30,50,0.8)",
borderRadius: "12px",
border: "1px solid rgba(255,255,255,0.06)",
cursor: "pointer",
transition: "all 0.2s",
}}
onClick={() => {
window.open(`/community?event_id=${event.event_id}`, "_blank");
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(40,40,70,0.9)";
e.currentTarget.style.borderColor = "rgba(96,165,250,0.3)";
e.currentTarget.style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(30,30,50,0.8)";
e.currentTarget.style.borderColor = "rgba(255,255,255,0.06)";
e.currentTarget.style.transform = "translateY(0)";
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* 标题 */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div style={{ display: "flex", gap: "8px", flex: 1 }}>
<FileText size={16} color="#60A5FA" style={{ flexShrink: 0 }} />
<span
style={{
fontSize: "14px",
fontWeight: "600",
color: "#E0E0E0",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{event.title}
</span>
</div>
<span
style={{
background: `${relevanceColor}20`,
color: relevanceColor,
fontSize: "12px",
padding: "2px 8px",
borderRadius: "6px",
flexShrink: 0,
}}
>
相关度 {event.relevance_score || 0}
</span>
</div>
{/* 相关原因 */}
{event.relevance_reason && (
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.6)", lineHeight: "1.6" }}>
{event.relevance_reason}
</span>
)}
{/* 匹配概念 */}
{event.matched_concepts?.length > 0 && (
<div>
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.4)", marginBottom: "6px", display: "block" }}>
匹配概念
</span>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{event.matched_concepts.slice(0, 6).map((concept, i) => (
<Tag
key={i}
style={{
fontSize: "10px",
margin: "2px",
background: "rgba(139, 92, 246, 0.15)",
border: "none",
color: "#A78BFA",
borderRadius: "4px",
padding: "2px 8px",
}}
>
{concept}
</Tag>
))}
{event.matched_concepts.length > 6 && (
<Tag
style={{
fontSize: "10px",
margin: "2px",
background: "rgba(255,255,255,0.1)",
border: "none",
color: "#888",
borderRadius: "4px",
padding: "2px 8px",
}}
>
+{event.matched_concepts.length - 6}
</Tag>
)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
) : (
<div
style={{
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ color: "rgba(255,255,255,0.5)" }}>暂无关联事件</span>
</div>
)}
</AntModal>
</ConfigProvider>
);
};
export default RelatedEventsModal;

View File

@@ -0,0 +1,373 @@
// HeroPanel - 板块股票弹窗
// 使用 Ant Design Modal 保持与现有代码风格一致
import React from "react";
import { Modal as AntModal, Table, Tag, Button, Typography, ConfigProvider, theme } from "antd";
import { TagsOutlined, LineChartOutlined, StarFilled, StarOutlined } from "@ant-design/icons";
import { GLASS_BLUR } from "@/constants/glassConfig";
const { Text: AntText } = Typography;
/**
* 获取连板天数样式
*/
const getDaysStyle = (days) => {
if (days >= 5)
return {
bg: "linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)",
text: "#fff",
};
if (days >= 3)
return {
bg: "linear-gradient(135deg, #fa541c 0%, #ff7a45 100%)",
text: "#fff",
};
if (days >= 2)
return {
bg: "linear-gradient(135deg, #fa8c16 0%, #ffc53d 100%)",
text: "#fff",
};
return { bg: "rgba(255,255,255,0.1)", text: "#888" };
};
/**
* 获取涨停时间样式
*/
const getTimeStyle = (time) => {
if (time <= "09:30:00") return { bg: "#ff4d4f", text: "#fff" };
if (time <= "09:35:00") return { bg: "#fa541c", text: "#fff" };
if (time <= "10:00:00") return { bg: "#fa8c16", text: "#fff" };
return { bg: "rgba(255,255,255,0.1)", text: "#888" };
};
/**
* 板块股票弹窗
*/
const SectorStocksModal = ({
visible,
onClose,
sectorInfo,
onShowKline,
onAddToWatchlist,
isStockInWatchlist,
}) => {
if (!sectorInfo) return null;
const { name, count, stocks = [] } = sectorInfo;
// 连板统计
const stats = { 首板: 0, "2连板": 0, "3连板": 0, "4连板+": 0 };
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连板+"]++;
});
// 表格列定义
const columns = [
{
title: "股票",
key: "stock",
width: 130,
render: (_, record) => (
<div style={{ display: "flex", flexDirection: "column" }}>
<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>
</div>
),
},
{
title: "连板",
dataIndex: "continuous_days",
key: "continuous",
width: 90,
align: "center",
render: (text, record) => {
const days = record._continuousDays || 1;
const style = getDaysStyle(days);
return (
<span
style={{
padding: "4px 12px",
borderRadius: "6px",
background: style.bg,
fontSize: "13px",
fontWeight: "bold",
color: style.text,
display: "inline-block",
}}
>
{text || "首板"}
</span>
);
},
},
{
title: "涨停时间",
dataIndex: "formatted_time",
key: "time",
width: 90,
align: "center",
render: (time) => {
const style = getTimeStyle(time || "15:00:00");
return (
<span
style={{
padding: "2px 8px",
borderRadius: "6px",
background: style.bg,
fontSize: "12px",
color: style.text,
}}
>
{time?.substring(0, 5) || "-"}
</span>
);
},
},
{
title: "核心板块",
dataIndex: "core_sectors",
key: "sectors",
render: (sectors) => (
<div style={{ display: "flex", gap: "4px", 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>
))}
</div>
),
},
{
title: "K线图",
key: "kline",
width: 80,
align: "center",
render: (_, record) => (
<Button
type="primary"
size="small"
icon={<LineChartOutlined />}
onClick={() => onShowKline(record)}
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={() => onAddToWatchlist({ 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>
);
},
},
];
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: { colorBgElevated: "rgba(15,15,30,0.98)" },
}}
>
<AntModal
open={visible}
onCancel={onClose}
footer={null}
width={900}
centered
title={
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
padding: "8px",
background: "rgba(255,215,0,0.15)",
borderRadius: "8px",
border: "1px solid rgba(255,215,0,0.3)",
}}
>
<TagsOutlined style={{ color: "#FFD700", fontSize: "18px" }} />
</div>
<div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={{ fontSize: "18px", fontWeight: "bold", color: "#FFD700" }}>
{name}
</span>
<span
style={{
fontSize: "12px",
padding: "2px 8px",
background: "rgba(255,77,79,0.15)",
color: "#ff4d4f",
borderRadius: "12px",
}}
>
{count} 只涨停
</span>
</div>
<div style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)" }}>
按连板天数降序排列
</div>
</div>
</div>
}
styles={{
header: {
background: "rgba(25,25,50,0.98)",
borderBottom: "1px solid rgba(255,215,0,0.2)",
padding: "16px 24px",
},
body: {
background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)",
padding: "16px 24px",
maxHeight: "70vh",
overflowY: "auto",
},
content: {
background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)",
borderRadius: "16px",
border: "1px solid rgba(255,215,0,0.2)",
},
mask: { background: "rgba(0,0,0,0.7)", backdropFilter: GLASS_BLUR.sm },
}}
>
{stocks.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* 快速统计 */}
<div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
{Object.entries(stats).map(
([key, value]) =>
value > 0 && (
<span
key={key}
style={{
padding: "4px 12px",
borderRadius: "12px",
fontSize: "14px",
background:
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)"
}`,
color:
key === "4连板+"
? "#ff4d4f"
: key === "3连板"
? "#fa541c"
: key === "2连板"
? "#fa8c16"
: "#888",
}}
>
{key}: <strong>{value}</strong>
</span>
)
)}
</div>
{/* 股票列表 */}
<div
style={{
borderRadius: "12px",
border: "1px solid rgba(255,215,0,0.15)",
overflow: "hidden",
}}
className="sector-stocks-table-wrapper"
>
<Table
dataSource={stocks}
columns={columns}
rowKey="scode"
size="small"
pagination={false}
scroll={{ x: 650, y: 450 }}
/>
</div>
</div>
) : (
<div
style={{
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ color: "rgba(255,255,255,0.5)" }}>暂无股票数据</span>
</div>
)}
</AntModal>
</ConfigProvider>
);
};
export default SectorStocksModal;

View File

@@ -0,0 +1,39 @@
// 涨停板块视图组件
// 展示按板块分组的涨停数据表格
import React, { memo } from "react";
import { Table } from "antd";
/**
* 涨停板块视图
* @param {Array} sectorList - 板块列表数据
* @param {Array} columns - 表格列配置
*/
const ZTSectorView = memo(({ sectorList, columns }) => {
if (!sectorList?.length) {
return null;
}
return (
<div
style={{
borderRadius: "12px",
border: "1px solid rgba(255,215,0,0.15)",
overflow: "hidden",
}}
>
<Table
dataSource={sectorList}
columns={columns}
rowKey="name"
size="middle"
pagination={false}
scroll={{ y: 380 }}
/>
</div>
);
});
ZTSectorView.displayName = "ZTSectorView";
export default ZTSectorView;

View File

@@ -0,0 +1,134 @@
// 涨停个股视图组件
// 展示涨停个股列表,支持按板块筛选
import React, { memo } from "react";
import { Table } from "antd";
/**
* 涨停个股视图
* @param {Array} stockList - 完整股票列表
* @param {Array} filteredStockList - 筛选后的股票列表
* @param {Array} sectorList - 板块列表(用于筛选器)
* @param {Array} columns - 表格列配置
* @param {string|null} selectedSectorFilter - 当前选中的板块筛选
* @param {Function} onSectorFilterChange - 筛选变化回调
*/
const ZTStockListView = memo(({
stockList,
filteredStockList,
sectorList,
columns,
selectedSectorFilter,
onSectorFilterChange,
}) => {
if (!stockList?.length) {
return null;
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* 板块筛选器 */}
<div>
<div style={{ display: "flex", gap: "8px", alignItems: "center", marginBottom: "8px" }}>
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)" }}>
板块筛选
</span>
<button
style={{
padding: "4px 12px",
borderRadius: "9999px",
background: !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: "12px",
transition: "all 0.2s",
cursor: "pointer",
}}
onClick={() => onSectorFilterChange(null)}
>
全部 ({stockList.length})
</button>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{sectorList.slice(0, 10).map((sector) => (
<button
key={sector.name}
style={{
padding: "4px 10px",
borderRadius: "9999px",
background: 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: "12px",
transition: "all 0.2s",
cursor: "pointer",
}}
onClick={() => onSectorFilterChange(selectedSectorFilter === sector.name ? null : sector.name)}
>
{sector.name} ({sector.count})
</button>
))}
</div>
</div>
{/* 筛选结果提示 */}
{selectedSectorFilter && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px 12px",
background: "rgba(59,130,246,0.1)",
borderRadius: "8px",
border: "1px solid rgba(59,130,246,0.2)",
}}
>
<span style={{ color: "#60A5FA", fontSize: "13px" }}>
{selectedSectorFilter}
</span>
<span style={{ color: "rgba(255,255,255,0.5)", fontSize: "12px" }}>
{filteredStockList.length} 只涨停
</span>
<button
style={{
marginLeft: "auto",
padding: "2px 8px",
background: "transparent",
border: "1px solid rgba(255,255,255,0.2)",
borderRadius: "4px",
color: "#888",
fontSize: "12px",
cursor: "pointer",
}}
onClick={() => onSectorFilterChange(null)}
>
清除筛选
</button>
</div>
)}
{/* 股票表格 */}
<div
style={{
borderRadius: "12px",
border: "1px solid rgba(59,130,246,0.15)",
overflow: "hidden",
}}
>
<Table
dataSource={filteredStockList}
columns={columns}
rowKey="scode"
size="small"
pagination={false}
scroll={{ y: 320 }}
/>
</div>
</div>
);
});
ZTStockListView.displayName = "ZTStockListView";
export default ZTStockListView;

View File

@@ -0,0 +1,7 @@
// HeroPanel - DetailModal 子组件导出
export { default as DetailModal } from "./DetailModal";
export { default as RelatedEventsModal } from "./RelatedEventsModal";
export { default as SectorStocksModal } from "./SectorStocksModal";
export { default as ZTSectorView } from "./ZTSectorView";
export { default as ZTStockListView } from "./ZTStockListView";
export { default as EventsTabView } from "./EventsTabView";

View File

@@ -0,0 +1,122 @@
// 热门关键词云组件
// 用于涨停分析 Tab 显示今日热词
import React from "react";
import { FireOutlined } from "@ant-design/icons";
/**
* 获取关键词样式(根据排名)
*/
const getKeywordStyle = (index) => {
if (index < 3) {
return {
fontSize: "15px",
fontWeight: "bold",
background:
"linear-gradient(135deg, rgba(255,215,0,0.3) 0%, rgba(255,165,0,0.2) 100%)",
border: "1px solid rgba(255,215,0,0.5)",
color: "#FFD700",
padding: "6px 12px",
};
}
if (index < 6) {
return {
fontSize: "14px",
fontWeight: "600",
background: "rgba(255,215,0,0.15)",
border: "1px solid rgba(255,215,0,0.3)",
color: "#D4A84B",
padding: "4px 10px",
};
}
return {
fontSize: "13px",
fontWeight: "normal",
background: "rgba(255,255,255,0.08)",
border: "1px solid rgba(255,255,255,0.15)",
color: "#888",
padding: "2px 8px",
};
};
/**
* 热门关键词云组件
* @param {Object} props
* @param {Array} props.keywords - 关键词数组 [{ name: string }]
*/
const HotKeywordsCloud = ({ keywords }) => {
if (!keywords || keywords.length === 0) {
return null;
}
return (
<div
style={{
padding: "16px",
background:
"linear-gradient(135deg, rgba(255, 215, 0, 0.08) 0%, rgba(255, 140, 0, 0.05) 100%)",
borderRadius: "12px",
border: "1px solid rgba(255, 215, 0, 0.2)",
position: "relative",
overflow: "hidden",
}}
>
{/* 装饰线 */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "2px",
background:
"linear-gradient(to right, transparent, #FFD700, #FF8C00, #FFD700, transparent)",
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "12px",
}}
>
<div
style={{
padding: "6px",
background: "rgba(255,215,0,0.2)",
borderRadius: "6px",
}}
>
<FireOutlined style={{ color: "#FFD700", fontSize: "16px" }} />
</div>
<span style={{ fontSize: "16px", fontWeight: "bold", color: "gold" }}>
今日热词
</span>
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)" }}>
词频越高排名越前
</span>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{keywords.map((kw, idx) => {
const style = getKeywordStyle(idx);
return (
<span
key={kw.name}
style={{
...style,
borderRadius: "9999px",
transition: "all 0.2s",
cursor: "default",
}}
>
{kw.name}
</span>
);
})}
</div>
</div>
);
};
export default HotKeywordsCloud;

View File

@@ -0,0 +1,152 @@
// HeroPanel - 使用说明弹窗组件
import React, { useState } from "react";
import { HStack, Icon, Text } from "@chakra-ui/react";
import { Modal as AntModal, ConfigProvider, theme } from "antd";
import { Info } from "lucide-react";
import { GLASS_BLUR } from "@/constants/glassConfig";
/**
* 使用说明弹窗组件
*/
const InfoModal = () => {
const [isOpen, setIsOpen] = useState(false);
const onOpen = () => setIsOpen(true);
const onClose = () => setIsOpen(false);
return (
<>
<HStack
spacing={1.5}
px={3}
py={1.5}
bg="rgba(255,215,0,0.08)"
border="1px solid rgba(255,215,0,0.2)"
borderRadius="full"
cursor="pointer"
transition="all 0.2s"
_hover={{
bg: "rgba(255,215,0,0.15)",
borderColor: "rgba(255,215,0,0.4)",
transform: "scale(1.02)",
}}
onClick={onOpen}
>
<Icon as={Info} color="gold" boxSize={4} />
<Text fontSize="sm" color="gold" fontWeight="medium">
使用说明
</Text>
</HStack>
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorBgElevated: 'rgba(15,15,30,0.98)',
},
}}
>
<AntModal
open={isOpen}
onCancel={onClose}
footer={null}
width={550}
centered
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
padding: '8px',
background: 'rgba(255,215,0,0.15)',
borderRadius: '8px',
border: '1px solid rgba(255,215,0,0.3)',
display: 'flex',
}}
>
<Info size={20} color="gold" />
</div>
<span
style={{
fontSize: '20px',
fontWeight: 'bold',
background: 'linear-gradient(to right, #FFD700, #FFA500)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
事件中心使用指南
</span>
</div>
}
styles={{
header: {
background: 'rgba(25,25,50,0.98)',
borderBottom: '1px solid rgba(255,215,0,0.2)',
paddingBottom: '16px',
},
body: {
background: 'linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)',
padding: '24px',
},
content: {
background: '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.3)',
borderRadius: '16px',
boxShadow: '0 25px 80px rgba(0,0,0,0.8)',
},
mask: {
background: 'rgba(0,0,0,0.7)',
backdropFilter: GLASS_BLUR.sm,
},
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: 'rgba(255,255,255,0.9)', marginBottom: '8px' }}>
📅 综合日历
</div>
<div style={{ fontSize: '16px', color: 'rgba(255,255,255,0.8)', lineHeight: '1.8' }}>
日历同时展示
<span style={{ color: '#d8b4fe', fontWeight: 'bold' }}>历史涨停数据</span>
<span style={{ color: '#86efac', fontWeight: 'bold' }}>未来事件</span>
点击日期查看详细信息
</div>
</div>
<div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: 'rgba(255,255,255,0.9)', marginBottom: '8px' }}>
🔥 涨停板块
</div>
<div style={{ fontSize: '16px', color: 'rgba(255,255,255,0.8)', lineHeight: '1.8' }}>
点击历史日期查看当日涨停板块排行涨停数量涨停股票代码帮助理解市场主线
</div>
</div>
<div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: 'rgba(255,255,255,0.9)', marginBottom: '8px' }}>
📊 未来事件
</div>
<div style={{ fontSize: '16px', color: 'rgba(255,255,255,0.8)', lineHeight: '1.8' }}>
点击未来日期查看事件详情包括
<span style={{ color: '#67e8f9', fontWeight: 'bold' }}>背景分析</span>
<span style={{ color: '#fdba74', fontWeight: 'bold' }}>未来推演</span>
<span style={{ color: '#86efac', fontWeight: 'bold' }}>相关股票</span>
</div>
</div>
<div style={{ paddingTop: '12px', borderTop: '1px solid rgba(255,215,0,0.2)' }}>
<div style={{ fontSize: '16px', color: '#fde047', textAlign: 'center', fontWeight: '500' }}>
💡 颜色越深表示涨停数越多 · 绿色标记表示有未来事件
</div>
</div>
</div>
</AntModal>
</ConfigProvider>
</>
);
};
export default InfoModal;

View File

@@ -0,0 +1,171 @@
// 涨停统计卡片组件
// 显示连板分布、封板时间、公告驱动统计
import React from "react";
/**
* 获取连板颜色
*/
const getContinuousColor = (key) => {
if (key === "4连板+") return "#ff4d4f";
if (key === "3连板") return "#fa541c";
if (key === "2连板") return "#fa8c16";
return "#52c41a";
};
/**
* 获取时间颜色
*/
const getTimeColor = (key) => {
if (key === "秒板") return "#ff4d4f";
if (key === "早盘") return "#fa8c16";
if (key === "盘中") return "#52c41a";
return "#888";
};
/**
* 统计卡片基础样式
*/
const cardStyle = {
flex: 1,
minWidth: "200px",
padding: "12px",
background: "rgba(255,255,255,0.03)",
borderRadius: "12px",
border: "1px solid rgba(255,255,255,0.08)",
};
/**
* 涨停统计卡片组件
* @param {Object} props
* @param {Object} props.stats - 统计数据
* @param {Object} props.stats.continuousStats - 连板分布
* @param {Object} props.stats.timeStats - 时间分布
* @param {number} props.stats.announcementCount - 公告驱动数
* @param {number} props.stats.announcementRatio - 公告驱动占比
*/
const ZTStatsCards = ({ stats }) => {
if (!stats) {
return null;
}
return (
<div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
{/* 连板分布 */}
<div style={cardStyle}>
<span
style={{
fontSize: "12px",
color: "rgba(255,255,255,0.5)",
display: "block",
marginBottom: "8px",
}}
>
连板分布
</span>
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
{Object.entries(stats.continuousStats).map(([key, value]) => (
<div
key={key}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<span
style={{
fontSize: "18px",
fontWeight: "bold",
color: getContinuousColor(key),
}}
>
{value}
</span>
<span
style={{ fontSize: "10px", color: "rgba(255,255,255,0.5)" }}
>
{key}
</span>
</div>
))}
</div>
</div>
{/* 涨停时间分布 */}
<div style={cardStyle}>
<span
style={{
fontSize: "12px",
color: "rgba(255,255,255,0.5)",
display: "block",
marginBottom: "8px",
}}
>
封板时间
</span>
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
{Object.entries(stats.timeStats).map(([key, value]) => (
<div
key={key}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<span
style={{
fontSize: "18px",
fontWeight: "bold",
color: getTimeColor(key),
}}
>
{value}
</span>
<span
style={{ fontSize: "10px", color: "rgba(255,255,255,0.5)" }}
>
{key}
</span>
</div>
))}
</div>
</div>
{/* 公告驱动 */}
<div
style={{
padding: "12px",
background: "rgba(255,255,255,0.03)",
borderRadius: "12px",
border: "1px solid rgba(255,255,255,0.08)",
minWidth: "120px",
}}
>
<span
style={{
fontSize: "12px",
color: "rgba(255,255,255,0.5)",
display: "block",
marginBottom: "8px",
}}
>
公告驱动
</span>
<div style={{ display: "flex", gap: "8px", alignItems: "baseline" }}>
<span
style={{ fontSize: "20px", fontWeight: "bold", color: "#A855F7" }}
>
{stats.announcementCount}
</span>
<span style={{ fontSize: "12px", color: "rgba(255,255,255,0.5)" }}>
({stats.announcementRatio}%)
</span>
</div>
</div>
</div>
);
};
export default ZTStatsCards;

View File

@@ -0,0 +1,7 @@
// HeroPanel 子组件导出
export * from "./DetailModal";
export { default as CalendarCell } from "./CalendarCell";
export { default as InfoModal } from "./InfoModal";
export { default as CombinedCalendar } from "./CombinedCalendar";
export { default as HotKeywordsCloud } from "./HotKeywordsCloud";
export { default as ZTStatsCards } from "./ZTStatsCards";

View File

@@ -0,0 +1,68 @@
// HeroPanel 常量配置
// 主题色配置
export const goldColors = {
primary: "#D4AF37",
light: "#F4D03F",
dark: "#B8860B",
glow: "rgba(212, 175, 55, 0.4)",
};
export const textColors = {
primary: "#ffffff",
secondary: "rgba(255, 255, 255, 0.85)",
muted: "rgba(255, 255, 255, 0.5)",
};
// 热度级别配置
export const HEAT_LEVELS = [
{
key: "high",
threshold: 80,
colors: {
bg: "rgba(147, 51, 234, 0.55)",
text: "#d8b4fe",
border: "rgba(147, 51, 234, 0.65)",
},
},
{
key: "medium",
threshold: 60,
colors: {
bg: "rgba(239, 68, 68, 0.50)",
text: "#fca5a5",
border: "rgba(239, 68, 68, 0.60)",
},
},
{
key: "low",
threshold: 40,
colors: {
bg: "rgba(251, 146, 60, 0.45)",
text: "#fed7aa",
border: "rgba(251, 146, 60, 0.55)",
},
},
{
key: "cold",
threshold: 0,
colors: {
bg: "rgba(59, 130, 246, 0.35)",
text: "#93c5fd",
border: "rgba(59, 130, 246, 0.45)",
},
},
];
export const DEFAULT_HEAT_COLORS = {
bg: "rgba(60, 60, 70, 0.12)",
text: textColors.muted,
border: "transparent",
};
// 日期常量
export const WEEK_DAYS = ["日", "一", "二", "三", "四", "五", "六"];
export const MONTH_NAMES = [
"1月", "2月", "3月", "4月", "5月", "6月",
"7月", "8月", "9月", "10月", "11月", "12月",
];

View File

@@ -0,0 +1,2 @@
// HeroPanel Hooks 导出
export { useDetailModalState } from "./useDetailModalState";

View File

@@ -0,0 +1,197 @@
// HeroPanel - DetailModal 状态管理 Hook
// 整合 DetailModal 组件的所有状态,使主组件更简洁
import { useState, useCallback } from "react";
/**
* DetailModal 状态管理 Hook
* 将 17 个 useState 整合为一个自定义 hook
*/
export const useDetailModalState = () => {
// ========== UI 状态 ==========
// 视图模式:板块视图 | 个股视图
const [ztViewMode, setZtViewMode] = useState("sector"); // 'sector' | 'stock'
// 板块筛选(个股视图时使用)
const [selectedSectorFilter, setSelectedSectorFilter] = useState(null);
// 展开的涨停原因
const [expandedReasons, setExpandedReasons] = useState({});
// ========== 弹窗/抽屉状态 ==========
// 内容详情抽屉
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
const [selectedContent, setSelectedContent] = useState(null);
// 板块股票弹窗
const [sectorStocksModalVisible, setSectorStocksModalVisible] = useState(false);
const [selectedSectorInfo, setSelectedSectorInfo] = useState(null);
// 事件关联股票抽屉
const [stocksDrawerVisible, setStocksDrawerVisible] = useState(false);
const [selectedEventStocks, setSelectedEventStocks] = useState([]);
const [selectedEventTime, setSelectedEventTime] = useState(null);
const [selectedEventTitle, setSelectedEventTitle] = useState("");
// K线弹窗
const [klineModalVisible, setKlineModalVisible] = useState(false);
const [selectedKlineStock, setSelectedKlineStock] = useState(null);
// 关联事件弹窗
const [relatedEventsModalVisible, setRelatedEventsModalVisible] = useState(false);
const [selectedRelatedEvents, setSelectedRelatedEvents] = useState({
sectorName: "",
events: [],
});
// ========== 数据加载状态 ==========
const [stockQuotes, setStockQuotes] = useState({});
const [stockQuotesLoading, setStockQuotesLoading] = useState(false);
// ========== 操作方法 ==========
// 打开内容详情
const openContentDetail = useCallback((content) => {
setSelectedContent(content);
setDetailDrawerVisible(true);
}, []);
// 关闭内容详情
const closeContentDetail = useCallback(() => {
setDetailDrawerVisible(false);
setSelectedContent(null);
}, []);
// 打开板块股票弹窗
const openSectorStocks = useCallback((sectorInfo) => {
setSelectedSectorInfo(sectorInfo);
setSectorStocksModalVisible(true);
}, []);
// 关闭板块股票弹窗
const closeSectorStocks = useCallback(() => {
setSectorStocksModalVisible(false);
setSelectedSectorInfo(null);
}, []);
// 打开事件关联股票
const openEventStocks = useCallback((stocks, time, title) => {
setSelectedEventStocks(stocks);
setSelectedEventTime(time);
setSelectedEventTitle(title);
setStocksDrawerVisible(true);
}, []);
// 关闭事件关联股票
const closeEventStocks = useCallback(() => {
setStocksDrawerVisible(false);
setSelectedEventStocks([]);
setSelectedEventTime(null);
setSelectedEventTitle("");
}, []);
// 打开 K 线弹窗
const openKlineModal = useCallback((stock) => {
setSelectedKlineStock(stock);
setKlineModalVisible(true);
}, []);
// 关闭 K 线弹窗
const closeKlineModal = useCallback(() => {
setKlineModalVisible(false);
setSelectedKlineStock(null);
}, []);
// 打开关联事件弹窗
const openRelatedEvents = useCallback((sectorName, events) => {
setSelectedRelatedEvents({ sectorName, events });
setRelatedEventsModalVisible(true);
}, []);
// 关闭关联事件弹窗
const closeRelatedEvents = useCallback(() => {
setRelatedEventsModalVisible(false);
setSelectedRelatedEvents({ sectorName: "", events: [] });
}, []);
// 切换展开原因
const toggleExpandedReason = useCallback((stockCode) => {
setExpandedReasons((prev) => ({
...prev,
[stockCode]: !prev[stockCode],
}));
}, []);
// 重置所有状态(用于关闭弹窗时)
const resetAllState = useCallback(() => {
setZtViewMode("sector");
setSelectedSectorFilter(null);
setExpandedReasons({});
setDetailDrawerVisible(false);
setSelectedContent(null);
setSectorStocksModalVisible(false);
setSelectedSectorInfo(null);
setStocksDrawerVisible(false);
setSelectedEventStocks([]);
setSelectedEventTime(null);
setSelectedEventTitle("");
setKlineModalVisible(false);
setSelectedKlineStock(null);
setRelatedEventsModalVisible(false);
setSelectedRelatedEvents({ sectorName: "", events: [] });
setStockQuotes({});
setStockQuotesLoading(false);
}, []);
return {
// UI 状态 + setters
ztViewMode,
setZtViewMode,
selectedSectorFilter,
setSelectedSectorFilter,
expandedReasons,
setExpandedReasons,
// 弹窗状态 + setters
detailDrawerVisible,
setDetailDrawerVisible,
selectedContent,
setSelectedContent,
sectorStocksModalVisible,
setSectorStocksModalVisible,
selectedSectorInfo,
setSelectedSectorInfo,
stocksDrawerVisible,
setStocksDrawerVisible,
selectedEventStocks,
setSelectedEventStocks,
selectedEventTime,
setSelectedEventTime,
selectedEventTitle,
setSelectedEventTitle,
klineModalVisible,
setKlineModalVisible,
selectedKlineStock,
setSelectedKlineStock,
relatedEventsModalVisible,
setRelatedEventsModalVisible,
selectedRelatedEvents,
setSelectedRelatedEvents,
// 数据状态 + setters
stockQuotes,
setStockQuotes,
stockQuotesLoading,
setStockQuotesLoading,
// 操作方法(高级封装)
openContentDetail,
closeContentDetail,
openSectorStocks,
closeSectorStocks,
openEventStocks,
closeEventStocks,
openKlineModal,
closeKlineModal,
openRelatedEvents,
closeRelatedEvents,
toggleExpandedReason,
resetAllState,
};
};
export default useDetailModalState;

View File

@@ -0,0 +1,12 @@
// HeroPanel 模块入口
// 导出主组件
export { default } from '../HeroPanel';
// 导出常量
export * from './constants';
// 导出工具函数
export * from './utils';
// 导出表格渲染器
export * from './columns';

View File

@@ -0,0 +1,159 @@
/* HeroPanel 动画和深色主题样式 */
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.1); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Ant Design 深色主题覆盖 - 弹窗专用 */
.hero-panel-modal .ant-tabs {
color: rgba(255, 255, 255, 0.85);
}
.hero-panel-modal .ant-tabs-nav::before {
border-color: rgba(255, 215, 0, 0.2) !important;
}
.hero-panel-modal .ant-tabs-tab {
color: rgba(255, 255, 255, 0.65) !important;
font-size: 15px !important;
}
.hero-panel-modal .ant-tabs-tab:hover {
color: #FFD700 !important;
}
.hero-panel-modal .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #FFD700 !important;
}
.hero-panel-modal .ant-tabs-ink-bar {
background: linear-gradient(90deg, #FFD700, #FFA500) !important;
}
/* 表格深色主题 */
.hero-panel-modal .ant-table {
background: transparent !important;
color: rgba(255, 255, 255, 0.85) !important;
}
.hero-panel-modal .ant-table-thead > tr > th {
background: rgba(255, 215, 0, 0.1) !important;
color: #FFD700 !important;
border-bottom: 1px solid rgba(255, 215, 0, 0.2) !important;
font-weight: 600 !important;
font-size: 14px !important;
}
.hero-panel-modal .ant-table-tbody > tr > td {
background: transparent !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
color: rgba(255, 255, 255, 0.85) !important;
font-size: 14px !important;
}
.hero-panel-modal .ant-table-tbody > tr:hover > td {
background: rgba(255, 215, 0, 0.08) !important;
}
.hero-panel-modal .ant-table-tbody > tr.ant-table-row:hover > td {
background: rgba(255, 215, 0, 0.1) !important;
}
.hero-panel-modal .ant-table-cell-row-hover {
background: rgba(255, 215, 0, 0.08) !important;
}
.hero-panel-modal .ant-table-placeholder {
background: transparent !important;
}
.hero-panel-modal .ant-empty-description {
color: rgba(255, 255, 255, 0.45) !important;
}
/* 滚动条样式 */
.hero-panel-modal .ant-table-body::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.hero-panel-modal .ant-table-body::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
.hero-panel-modal .ant-table-body::-webkit-scrollbar-thumb {
background: rgba(255, 215, 0, 0.3);
border-radius: 3px;
}
.hero-panel-modal .ant-table-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 215, 0, 0.5);
}
/* 板块股票表格滚动 - 针对 Ant Design 5.x */
.sector-stocks-table-wrapper {
max-height: 450px;
overflow: hidden;
}
.sector-stocks-table-wrapper .ant-table-wrapper,
.sector-stocks-table-wrapper .ant-table,
.sector-stocks-table-wrapper .ant-table-container {
max-height: 100%;
}
.sector-stocks-table-wrapper .ant-table-body {
max-height: 380px !important;
overflow-y: auto !important;
scrollbar-width: thin;
scrollbar-color: rgba(255, 215, 0, 0.4) rgba(255, 255, 255, 0.05);
}
/* 相关股票表格滚动 */
.related-stocks-table-wrapper .ant-table-body {
scrollbar-width: thin;
scrollbar-color: rgba(255, 215, 0, 0.4) rgba(255, 255, 255, 0.05);
}
/* Tag 样式优化 */
.hero-panel-modal .ant-tag {
border-radius: 4px !important;
}
/* Button link 样式 */
.hero-panel-modal .ant-btn-link {
color: #FFD700 !important;
}
.hero-panel-modal .ant-btn-link:hover {
color: #FFA500 !important;
}
.hero-panel-modal .ant-btn-link:disabled {
color: rgba(255, 255, 255, 0.25) !important;
}
/* Typography 样式 */
.hero-panel-modal .ant-typography {
color: rgba(255, 255, 255, 0.85) !important;
}
.hero-panel-modal .ant-typography-secondary {
color: rgba(255, 255, 255, 0.45) !important;
}
/* Spin 加载样式 */
.hero-panel-modal .ant-spin-text {
color: #FFD700 !important;
}
.hero-panel-modal .ant-spin-dot-item {
background-color: #FFD700 !important;
}

View File

@@ -0,0 +1,84 @@
// HeroPanel 工具函数
import { HEAT_LEVELS, DEFAULT_HEAT_COLORS } from '../constants';
/**
* 判断当前是否在交易时间内 (9:30-15:00)
*/
export const isInTradingTime = () => {
const now = new Date();
const timeInMinutes = now.getHours() * 60 + now.getMinutes();
return timeInMinutes >= 570 && timeInMinutes <= 900;
};
/**
* 根据涨停数获取热度颜色
*/
export const getHeatColor = (count) => {
if (!count) return DEFAULT_HEAT_COLORS;
const level = HEAT_LEVELS.find((l) => count >= l.threshold);
return level?.colors || DEFAULT_HEAT_COLORS;
};
/**
* 日期格式化为 YYYYMMDD
*/
export const formatDateStr = (date) => {
if (!date) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
};
/**
* 获取六位股票代码(去掉后缀)
*/
export const getSixDigitCode = (code) => {
if (!code) return code;
return code.split(".")[0];
};
/**
* 添加交易所后缀
*/
export const addExchangeSuffix = (code) => {
const sixDigitCode = getSixDigitCode(code);
if (code.includes(".")) return code;
if (sixDigitCode.startsWith("6")) {
return `${sixDigitCode}.SH`;
} else if (sixDigitCode.startsWith("0") || sixDigitCode.startsWith("3")) {
return `${sixDigitCode}.SZ`;
}
return sixDigitCode;
};
/**
* 解析连板天数
*/
export const parseContinuousDays = (text) => {
if (!text || text === "首板") return 1;
const match = text.match(/(\d+)/);
return match ? parseInt(match[1]) : 1;
};
/**
* 获取涨停时间样式
*/
export const getTimeStyle = (time) => {
if (time <= "09:30:00") return { bg: "#ff4d4f", text: "#fff", label: "秒板" };
if (time <= "09:35:00") return { bg: "#fa541c", text: "#fff", label: "早板" };
if (time <= "10:00:00") return { bg: "#fa8c16", text: "#fff", label: "盘初" };
if (time <= "11:00:00") return { bg: "#52c41a", text: "#fff", label: "盘中" };
return { bg: "rgba(255,255,255,0.1)", text: "#888", label: "尾盘" };
};
/**
* 获取连板天数样式
*/
export const getDaysStyle = (days) => {
if (days >= 5) return { bg: "linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)", text: "#fff" };
if (days >= 3) return { bg: "linear-gradient(135deg, #fa541c 0%, #ff7a45 100%)", text: "#fff" };
if (days >= 2) return { bg: "linear-gradient(135deg, #fa8c16 0%, #ffc53d 100%)", text: "#fff" };
return { bg: "rgba(255,255,255,0.1)", text: "#888" };
};

View File

@@ -0,0 +1,348 @@
/**
* 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,
CalendarOutlined,
BarChartOutlined,
} from "@ant-design/icons";
import { getApiBase } from "@utils/apiConfig";
// 模块化导入
import {
UP_COLOR,
DOWN_COLOR,
isInTradingTime,
formatChg,
} from "./MarketOverviewBanner/constants";
import {
MarketStatsBarCompact,
CircularProgressCard,
BannerStatCard,
} from "./MarketOverviewBanner/components";
import StockTop10Modal from "./MarketOverviewBanner/StockTop10Modal";
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
}, []);
// 日期变化时静默刷新
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;
const today = new Date().toISOString().split("T")[0];
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 !== today && (
<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(today)}
>
返回今天
</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={today}
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个指标卡片 */}
<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
/>
{/* 关联股票卡片 */}
<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弹窗 */}
<StockTop10Modal
visible={stockModalVisible}
onClose={() => setStockModalVisible(false)}
topStocks={topStocks}
/>
</Box>
);
};
export default MarketOverviewBanner;

View File

@@ -0,0 +1,138 @@
// 股票 TOP10 弹窗组件
import React from "react";
import { Text, HStack } from "@chakra-ui/react";
import { StockOutlined } from "@ant-design/icons";
import { Modal, Table } from "antd";
import { UP_COLOR, DOWN_COLOR, formatChg } from "./constants";
// 弹窗内表格样式
const modalTableStyles = `
.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;
}
`;
// 表格列定义
const tableColumns = [
{
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>
),
},
];
/**
* 股票 TOP10 弹窗组件
*/
const StockTop10Modal = ({ visible, onClose, topStocks = [] }) => {
return (
<Modal
title={
<HStack spacing={2}>
<StockOutlined style={{ color: "white" }} />
<Text color="white">股票 TOP10</Text>
</HStack>
}
open={visible}
onCancel={onClose}
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>{modalTableStyles}</style>
<Table
dataSource={topStocks.slice(0, 10)}
rowKey={(record) => record.stockCode || record.stockName}
pagination={false}
size="small"
className="stock-top10-table"
columns={tableColumns}
/>
</Modal>
);
};
export default StockTop10Modal;

View File

@@ -0,0 +1,261 @@
// MarketOverviewBanner 子组件
import React from "react";
import { Box, Text, HStack, Flex } from "@chakra-ui/react";
import { UP_COLOR, DOWN_COLOR, FLAT_COLOR } from "./constants";
/**
* 沪深实时涨跌条形图组件 - 紧凑版
*/
export 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>
);
};
/**
* 环形进度图组件
*/
export 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;
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>
);
};
/**
* 紧凑数据卡片
*/
export 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>
);

View File

@@ -0,0 +1,43 @@
// MarketOverviewBanner 常量定义
// 涨跌颜色常量
export const UP_COLOR = "#FF4D4F"; // 涨 - 红色
export const DOWN_COLOR = "#52C41A"; // 跌 - 绿色
export const FLAT_COLOR = "#888888"; // 平 - 灰色
/**
* 判断是否在交易时间内 (9:30-15:00)
*/
export 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
};
/**
* 格式化涨跌幅
*/
export const formatChg = (val) => {
if (val === null || val === undefined) return "-";
const num = parseFloat(val);
if (isNaN(num)) return "-";
return (num >= 0 ? "+" : "") + num.toFixed(2) + "%";
};
// 注入脉冲动画样式
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);
}
}

View File

@@ -0,0 +1,5 @@
// MarketOverviewBanner 模块导出
export { UP_COLOR, DOWN_COLOR, FLAT_COLOR, isInTradingTime, formatChg } from "./constants";
export { MarketStatsBarCompact, CircularProgressCard, BannerStatCard } from "./components";
export { default as StockTop10Modal } from "./StockTop10Modal";

View File

@@ -1,45 +1,55 @@
// src/views/Community/components/SearchFilters/CompactSearchBox.js
// 紧凑版搜索和筛选组件 - 优化布局
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import React, { useState, useMemo, useEffect, useCallback, useRef } from "react";
import {
Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect,
Tooltip, Divider, Flex
} from 'antd';
Input,
Cascader,
Button,
Space,
AutoComplete,
Select as AntSelect,
Tooltip,
Divider,
Flex,
} from "antd";
import {
SearchOutlined, CloseCircleOutlined, StockOutlined, FilterOutlined,
CalendarOutlined, SortAscendingOutlined, ReloadOutlined, ThunderboltOutlined
} from '@ant-design/icons';
import dayjs from 'dayjs';
import debounce from 'lodash/debounce';
import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '@store/slices/industrySlice';
import { loadAllStocks } from '@store/slices/stockSlice';
import { stockService } from '@services/stockService';
import { logger } from '@utils/logger';
import TradingTimeFilter from './TradingTimeFilter';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import './CompactSearchBox.css';
SearchOutlined,
CloseCircleOutlined,
StockOutlined,
FilterOutlined,
CalendarOutlined,
SortAscendingOutlined,
ReloadOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import debounce from "lodash/debounce";
import { useSelector, useDispatch } from "react-redux";
import {
fetchIndustryData,
selectIndustryData,
selectIndustryLoading,
} from "@store/slices/industrySlice";
import { loadAllStocks } from "@store/slices/stockSlice";
import { stockService } from "@services/stockService";
import { logger } from "@utils/logger";
import TradingTimeFilter from "./TradingTimeFilter";
import { PROFESSIONAL_COLORS } from "@constants/professionalTheme";
import "./CompactSearchBox.css";
// 模块化导入
import {
SORT_OPTIONS,
IMPORTANCE_OPTIONS,
} from "./CompactSearchBox/constants";
import {
findIndustryPath,
inferTimeRangeFromFilters,
buildFilterParams,
} from "./CompactSearchBox/utils";
const { Option } = AntSelect;
// 排序选项常量
const SORT_OPTIONS = [
{ value: 'new', label: '最新排序', mobileLabel: '最新' },
{ value: 'hot', label: '最热排序', mobileLabel: '热门' },
{ value: 'importance', label: '重要性排序', mobileLabel: '重要' },
{ value: 'returns_avg', label: '平均收益', mobileLabel: '均收' },
{ value: 'returns_week', label: '周收益', mobileLabel: '周收' },
];
// 重要性等级常量
const IMPORTANCE_OPTIONS = [
{ value: 'S', label: 'S级' },
{ value: 'A', label: 'A级' },
{ value: 'B', label: 'B级' },
{ value: 'C', label: 'C级' },
];
const CompactSearchBox = ({
onSearch,
onSearchFocus,
@@ -47,16 +57,16 @@ const CompactSearchBox = ({
mode,
pageSize,
trackingFunctions = {},
isMobile = false
isMobile = false,
}) => {
// 状态
const [stockOptions, setStockOptions] = useState([]);
const [allStocks, setAllStocks] = useState([]);
const [industryValue, setIndustryValue] = useState([]);
const [sort, setSort] = useState('new');
const [sort, setSort] = useState("new");
const [importance, setImportance] = useState([]);
const [tradingTimeRange, setTradingTimeRange] = useState(null);
const [inputValue, setInputValue] = useState('');
const [inputValue, setInputValue] = useState("");
// Redux
const dispatch = useDispatch();
@@ -64,16 +74,35 @@ const CompactSearchBox = ({
const industryLoading = useSelector(selectIndustryLoading);
const reduxAllStocks = useSelector((state) => state.stock.allStocks);
// 防抖搜索
// Refs
const debouncedSearchRef = useRef(null);
// 存储股票选择时的显示值(代码+名称),用于 useEffect 同步时显示完整信息
const stockDisplayValueRef = useRef(null);
const triggerSearch = useCallback((params) => {
logger.debug('CompactSearchBox', '触发搜索', { params });
const triggerSearch = useCallback(
(params) => {
logger.debug("CompactSearchBox", "触发搜索", { params });
onSearch(params);
}, [onSearch]);
},
[onSearch]
);
// 创建构建参数的封装函数
const createFilterParams = useCallback(
(overrides = {}) =>
buildFilterParams({
overrides,
sort,
importance,
filtersQ: filters.q,
industryValue,
tradingTimeRange,
mode,
pageSize,
}),
[sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]
);
// 防抖搜索初始化
useEffect(() => {
debouncedSearchRef.current = debounce((params) => {
triggerSearch(params);
@@ -86,128 +115,102 @@ const CompactSearchBox = ({
};
}, [triggerSearch]);
// 加载股票数据(从 Redux 获取)
// 加载股票数据
useEffect(() => {
if (!reduxAllStocks || reduxAllStocks.length === 0) {
dispatch(loadAllStocks());
}
}, [dispatch, reduxAllStocks]);
// 同步 Redux 数据到本地状态
useEffect(() => {
if (reduxAllStocks && reduxAllStocks.length > 0) {
setAllStocks(reduxAllStocks);
}
}, [reduxAllStocks]);
// 预加载行业数据(解决第一次点击无数据问题)
// 预加载行业数据
useEffect(() => {
if (!industryData || industryData.length === 0) {
dispatch(fetchIndustryData());
}
}, [dispatch, industryData]);
// 初始化筛选条件
const findIndustryPath = useCallback((targetCode, data, currentPath = []) => {
if (!data || data.length === 0) return null;
for (const item of data) {
const newPath = [...currentPath, item.value];
if (item.value === targetCode) {
return newPath;
}
if (item.children && item.children.length > 0) {
const found = findIndustryPath(targetCode, item.children, newPath);
if (found) return found;
}
}
return null;
}, []);
// 同步外部 filters 到本地状态
useEffect(() => {
if (!filters) return;
// 优先使用 _sortDisplay原始排序值否则回退到 sort
// 这样可以正确显示 returns_avg, returns_week 等复合排序选项
// 排序
if (filters._sortDisplay || filters.sort) {
setSort(filters._sortDisplay || filters.sort);
}
// 重要性
if (filters.importance) {
const importanceArray = filters.importance === 'all'
const importanceArray =
filters.importance === "all"
? []
: filters.importance.split(',').map(v => v.trim()).filter(Boolean);
: filters.importance
.split(",")
.map((v) => v.trim())
.filter(Boolean);
setImportance(importanceArray);
} else {
setImportance([]);
}
if (filters.industry_code && industryData && industryData.length > 0 && (!industryValue || industryValue.length === 0)) {
// 行业
if (
filters.industry_code &&
industryData &&
industryData.length > 0 &&
(!industryValue || industryValue.length === 0)
) {
const path = findIndustryPath(filters.industry_code, industryData);
if (path) {
setIndustryValue(path);
}
} else if (!filters.industry_code && industryValue && industryValue.length > 0) {
} else if (
!filters.industry_code &&
industryValue &&
industryValue.length > 0
) {
setIndustryValue([]);
}
// 搜索关键词
if (filters.q) {
// 如果是股票选择触发的搜索,使用存储的显示值(代码+名称)
if (stockDisplayValueRef.current && stockDisplayValueRef.current.code === filters.q) {
if (
stockDisplayValueRef.current &&
stockDisplayValueRef.current.code === filters.q
) {
setInputValue(stockDisplayValueRef.current.displayValue);
} else {
setInputValue(filters.q);
// 清除已失效的显示值缓存
stockDisplayValueRef.current = null;
}
} else if (!filters.q) {
setInputValue('');
setInputValue("");
stockDisplayValueRef.current = null;
}
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days || filters.time_filter_key;
// 时间范围
const hasTimeInFilters =
filters.start_date ||
filters.end_date ||
filters.recent_days ||
filters.time_filter_key;
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
// 优先使用 time_filter_key来自 useEventFilters 的默认值)
let inferredKey = filters.time_filter_key || 'custom';
let inferredLabel = '';
if (filters.time_filter_key === 'current-trading-day') {
inferredKey = 'current-trading-day';
inferredLabel = '当前交易日';
} else if (filters.time_filter_key === 'all') {
inferredKey = 'all';
inferredLabel = '全部';
} else if (filters.recent_days) {
if (filters.recent_days === '7') {
inferredKey = 'week';
inferredLabel = '近一周';
} else if (filters.recent_days === '30') {
inferredKey = 'month';
inferredLabel = '近一月';
} else {
inferredLabel = `${filters.recent_days}`;
}
} else if (filters.start_date && filters.end_date) {
inferredLabel = `${dayjs(filters.start_date).format('MM-DD HH:mm')} - ${dayjs(filters.end_date).format('MM-DD HH:mm')}`;
}
const timeRange = {
start_date: filters.start_date || '',
end_date: filters.end_date || '',
recent_days: filters.recent_days || '',
label: inferredLabel,
key: inferredKey
};
const timeRange = inferTimeRangeFromFilters(filters);
if (timeRange) {
setTradingTimeRange(timeRange);
}
} else if (!hasTimeInFilters && tradingTimeRange) {
setTradingTimeRange(null);
}
}, [filters, industryData, findIndustryPath, industryValue, tradingTimeRange]);
}, [filters, industryData, industryValue, tradingTimeRange]);
// 搜索股票
// 股票搜索
const handleSearch = (value) => {
if (!value || !allStocks || allStocks.length === 0) {
setStockOptions([]);
@@ -215,231 +218,132 @@ const CompactSearchBox = ({
}
const results = stockService.fuzzySearch(value, allStocks, 10);
const options = results.map(stock => ({
const options = results.map((stock) => ({
value: stock.code,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StockOutlined style={{ color: '#1890ff' }} />
<span style={{ fontWeight: 500, color: '#333' }}>{stock.code}</span>
<span style={{ color: '#666' }}>{stock.name}</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<StockOutlined style={{ color: "#1890ff" }} />
<span style={{ fontWeight: 500, color: "#333" }}>{stock.code}</span>
<span style={{ color: "#666" }}>{stock.name}</span>
</div>
),
stockInfo: stock
stockInfo: stock,
}));
setStockOptions(options);
};
const buildFilterParams = useCallback((overrides = {}) => {
const sortValue = overrides.sort ?? sort;
let actualSort = sortValue;
let returnType;
if (sortValue === 'returns_avg') {
actualSort = 'returns';
returnType = 'avg';
} else if (sortValue === 'returns_week') {
actualSort = 'returns';
returnType = 'week';
}
let importanceValue = overrides.importance ?? importance;
if (Array.isArray(importanceValue)) {
importanceValue = importanceValue.length === 0
? 'all'
: importanceValue.join(',');
}
// 先展开 overrides再用处理后的值覆盖避免 overrides.sort 覆盖 actualSort
const result = {
...overrides,
sort: actualSort,
// 保留原始排序值用于 UI 显示(如 returns_avg, returns_week
_sortDisplay: sortValue,
importance: importanceValue,
q: (overrides.q ?? filters.q) ?? '',
industry_code: overrides.industry_code ?? (industryValue?.join(',') || ''),
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
page: 1,
};
// 移除不需要的字段
delete result.per_page;
// 添加 return_type 参数(用于收益排序)
if (returnType) {
result.return_type = returnType;
} else {
// 确保非收益排序时不带 return_type
delete result.return_type;
}
if (mode !== undefined && mode !== null) {
result.mode = mode;
}
if (pageSize !== undefined && pageSize !== null) {
result.per_page = pageSize;
}
return result;
}, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
// 股票选择
const handleStockSelect = (_value, option) => {
const stockInfo = option.stockInfo;
if (stockInfo) {
if (trackingFunctions.trackRelatedStockClicked) {
trackingFunctions.trackRelatedStockClicked({
trackingFunctions.trackRelatedStockClicked?.({
stockCode: stockInfo.code,
stockName: stockInfo.name,
source: 'search_box_autocomplete',
source: "search_box_autocomplete",
timestamp: new Date().toISOString(),
});
}
const displayValue = `${stockInfo.code} ${stockInfo.name}`;
setInputValue(displayValue);
// 存储显示值,供 useEffect 同步时使用
stockDisplayValueRef.current = { code: stockInfo.code, displayValue };
const params = buildFilterParams({
q: stockInfo.code, // 接口只传代码
industry_code: ''
});
const params = createFilterParams({ q: stockInfo.code, industry_code: "" });
triggerSearch(params);
}
};
// 重要性变更
const handleImportanceChange = (value) => {
setImportance(value);
debouncedSearchRef.current?.cancel();
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
const importanceStr = value.length === 0 ? 'all' : value.join(',');
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'importance',
const importanceStr = value.length === 0 ? "all" : value.join(",");
trackingFunctions.trackNewsFilterApplied?.({
filterType: "importance",
filterValue: importanceStr,
timestamp: new Date().toISOString(),
});
}
const params = buildFilterParams({ importance: importanceStr });
triggerSearch(params);
triggerSearch(createFilterParams({ importance: importanceStr }));
};
// 排序变更
const handleSortChange = (value) => {
setSort(value);
debouncedSearchRef.current?.cancel();
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
if (trackingFunctions.trackNewsSorted) {
trackingFunctions.trackNewsSorted({
trackingFunctions.trackNewsSorted?.({
sortBy: value,
previousSortBy: sort,
timestamp: new Date().toISOString(),
});
}
const params = buildFilterParams({ sort: value });
triggerSearch(params);
triggerSearch(createFilterParams({ sort: value }));
};
// 行业变更
const handleIndustryChange = (value) => {
setIndustryValue(value);
debouncedSearchRef.current?.cancel();
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'industry',
filterValue: value?.[value.length - 1] || '',
trackingFunctions.trackNewsFilterApplied?.({
filterType: "industry",
filterValue: value?.[value.length - 1] || "",
timestamp: new Date().toISOString(),
});
}
const params = buildFilterParams({
industry_code: value?.[value.length - 1] || ''
});
triggerSearch(params);
triggerSearch(createFilterParams({ industry_code: value?.[value.length - 1] || "" }));
};
// 时间筛选变更
const handleTradingTimeChange = (timeConfig) => {
if (!timeConfig) {
setTradingTimeRange(null);
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'time_range',
filterValue: 'cleared',
trackingFunctions.trackNewsFilterApplied?.({
filterType: "time_range",
filterValue: "cleared",
timestamp: new Date().toISOString(),
});
}
const params = buildFilterParams({
start_date: '',
end_date: '',
recent_days: ''
});
triggerSearch(params);
triggerSearch(createFilterParams({ start_date: "", end_date: "", recent_days: "" }));
return;
}
const { range, type, label, key } = timeConfig;
let params = {};
if (type === 'all') {
// "全部"按钮:清除所有时间限制
params.start_date = '';
params.end_date = '';
params.recent_days = '';
} else if (type === 'recent_days') {
params.recent_days = range;
params.start_date = '';
params.end_date = '';
if (type === "all") {
params = { start_date: "", end_date: "", recent_days: "" };
} else if (type === "recent_days") {
params = { recent_days: range, start_date: "", end_date: "" };
} else {
params.start_date = range[0].format('YYYY-MM-DD HH:mm:ss');
params.end_date = range[1].format('YYYY-MM-DD HH:mm:ss');
params.recent_days = '';
params = {
start_date: range[0].format("YYYY-MM-DD HH:mm:ss"),
end_date: range[1].format("YYYY-MM-DD HH:mm:ss"),
recent_days: "",
};
}
setTradingTimeRange({ ...params, label, key });
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'time_range',
trackingFunctions.trackNewsFilterApplied?.({
filterType: "time_range",
filterValue: label,
timeRangeType: type,
timestamp: new Date().toISOString(),
});
}
const searchParams = buildFilterParams({ ...params, mode });
triggerSearch(searchParams);
triggerSearch(createFilterParams({ ...params, mode }));
};
// 主搜索
const handleMainSearch = () => {
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
debouncedSearchRef.current?.cancel();
const params = createFilterParams({ q: inputValue, industry_code: "" });
const params = buildFilterParams({
q: inputValue,
industry_code: ''
});
if (trackingFunctions.trackNewsSearched && inputValue) {
trackingFunctions.trackNewsSearched({
if (inputValue) {
trackingFunctions.trackNewsSearched?.({
searchQuery: inputValue,
searchType: 'main_search',
searchType: "main_search",
filters: params,
timestamp: new Date().toISOString(),
});
@@ -448,39 +352,32 @@ const CompactSearchBox = ({
triggerSearch(params);
};
const handleInputChange = (value) => {
setInputValue(value);
};
// 重置
const handleReset = () => {
setInputValue('');
setInputValue("");
setStockOptions([]);
setIndustryValue([]);
setSort('new');
setSort("new");
setImportance([]);
setTradingTimeRange(null);
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'reset',
filterValue: 'all_filters_cleared',
trackingFunctions.trackNewsFilterApplied?.({
filterType: "reset",
filterValue: "all_filters_cleared",
timestamp: new Date().toISOString(),
});
}
const resetParams = {
q: '',
industry_code: '',
sort: 'new',
importance: 'all',
start_date: '',
end_date: '',
recent_days: '',
onSearch({
q: "",
industry_code: "",
sort: "new",
importance: "all",
start_date: "",
end_date: "",
recent_days: "",
page: 1,
_forceRefresh: Date.now()
};
onSearch(resetParams);
_forceRefresh: Date.now(),
});
};
const handleCascaderFocus = async () => {
@@ -488,57 +385,62 @@ const CompactSearchBox = ({
dispatch(fetchIndustryData());
}
};
return (
<div style={{ padding: 0, background: 'transparent' }}>
<div style={{ padding: 0, background: "transparent" }}>
{/* 第一行:搜索框 + 日期筛选 */}
<Flex
align="center"
gap={isMobile ? 8 : 12}
style={{
background: 'rgba(255, 255, 255, 0.03)',
background: "rgba(255, 255, 255, 0.03)",
border: `1px solid ${PROFESSIONAL_COLORS.gold[500]}`,
borderRadius: '24px',
padding: isMobile ? '2px 4px' : '8px 16px',
marginBottom: isMobile ? 8 : 12
borderRadius: "24px",
padding: isMobile ? "2px 4px" : "8px 16px",
marginBottom: isMobile ? 8 : 12,
}}
>
{/* 搜索框 - flex: 1 占满剩余空间 */}
<AutoComplete
value={inputValue}
onChange={handleInputChange}
onChange={setInputValue}
onSearch={handleSearch}
onSelect={handleStockSelect}
onFocus={onSearchFocus}
options={stockOptions}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleMainSearch();
}
}}
onKeyDown={(e) => e.key === "Enter" && handleMainSearch()}
style={{ flex: 1, minWidth: isMobile ? 100 : 200 }}
className="gold-placeholder"
allowClear={{
clearIcon: <CloseCircleOutlined style={{ color: PROFESSIONAL_COLORS.text.muted, fontSize: 14 }} />
clearIcon: (
<CloseCircleOutlined
style={{ color: PROFESSIONAL_COLORS.text.muted, fontSize: 14 }}
/>
),
}}
>
<Input
prefix={<SearchOutlined style={{ color: PROFESSIONAL_COLORS.gold[500] }} />}
placeholder="搜索股票/话题..."
style={{
border: 'none',
background: 'transparent',
border: "none",
background: "transparent",
color: PROFESSIONAL_COLORS.text.primary,
boxShadow: 'none'
boxShadow: "none",
}}
/>
</AutoComplete>
{/* 分隔线 - H5 时隐藏 */}
{!isMobile && <Divider type="vertical" style={{ height: 24, margin: '0 8px', borderColor: 'rgba(255,255,255,0.15)' }} />}
{!isMobile && (
<Divider
type="vertical"
style={{ height: 24, margin: "0 8px", borderColor: "rgba(255,255,255,0.15)" }}
/>
)}
{/* 日期筛选按钮组 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
<CalendarOutlined style={{ color: PROFESSIONAL_COLORS.gold[500], fontSize: 14, marginRight: 8 }} />
<div style={{ display: "flex", alignItems: "center", gap: 0 }}>
<CalendarOutlined
style={{ color: PROFESSIONAL_COLORS.gold[500], fontSize: 14, marginRight: 8 }}
/>
<TradingTimeFilter
value={tradingTimeRange?.key || null}
onChange={handleTradingTimeChange}
@@ -548,73 +450,74 @@ const CompactSearchBox = ({
</div>
</Flex>
{/* 第二行:筛选条件 - 主线模式下隐藏(主线模式有自己的筛选器) */}
{mode !== 'mainline' && (
{/* 第二行:筛选条件 */}
{mode !== "mainline" && (
<Flex justify="space-between" align="center">
{/* 左侧筛选 */}
<Space size={isMobile ? 4 : 8}>
{/* 行业筛选 */}
<Cascader
value={industryValue}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
options={industryData || []}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<FilterOutlined style={{ fontSize: 12 }} />
{isMobile ? '行业' : '行业筛选'}
{isMobile ? "行业" : "行业筛选"}
</span>
}
changeOnSelect
showSearch={{
filter: (inputValue, path) =>
path.some(option =>
path.some((option) =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
)
),
}}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels[labels.length - 1] || (isMobile ? '行业' : '行业筛选')}
displayRender={(labels) =>
labels[labels.length - 1] || (isMobile ? "行业" : "行业筛选")
}
disabled={industryLoading}
style={{ minWidth: isMobile ? 70 : 80 }}
suffixIcon={null}
className="transparent-cascader"
/>
{/* 事件等级 */}
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{ minWidth: isMobile ? 100 : 120 }}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<ThunderboltOutlined style={{ fontSize: 12 }} />
{isMobile ? '等级' : '事件等级'}
{isMobile ? "等级" : "事件等级"}
</span>
}
maxTagCount={0}
maxTagPlaceholder={(omittedValues) => isMobile ? `${omittedValues.length}` : `已选 ${omittedValues.length}`}
maxTagPlaceholder={(omittedValues) =>
isMobile ? `${omittedValues.length}` : `已选 ${omittedValues.length}`
}
className="bracket-select"
>
{IMPORTANCE_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
{IMPORTANCE_OPTIONS.map((opt) => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</AntSelect>
</Space>
{/* 右侧排序和重置 */}
<Space size={isMobile ? 4 : 8}>
{/* 排序 */}
<AntSelect
value={sort}
onChange={handleSortChange}
style={{ minWidth: isMobile ? 55 : 120 }}
className="bracket-select"
>
{SORT_OPTIONS.map(opt => (
{SORT_OPTIONS.map((opt) => (
<Option key={opt.value} value={opt.value}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<SortAscendingOutlined style={{ fontSize: 12 }} />
{isMobile ? opt.mobileLabel : opt.label}
</span>
@@ -622,14 +525,13 @@ const CompactSearchBox = ({
))}
</AntSelect>
{/* 重置按钮 */}
<Button
icon={<ReloadOutlined />}
onClick={handleReset}
type="text"
style={{ color: PROFESSIONAL_COLORS.text.secondary }}
>
{!isMobile && '重置筛选'}
{!isMobile && "重置筛选"}
</Button>
</Space>
</Flex>

View File

@@ -0,0 +1,18 @@
// CompactSearchBox 常量定义
// 排序选项常量
export const SORT_OPTIONS = [
{ value: "new", label: "最新排序", mobileLabel: "最新" },
{ value: "hot", label: "最热排序", mobileLabel: "热门" },
{ value: "importance", label: "重要性排序", mobileLabel: "重要" },
{ value: "returns_avg", label: "平均收益", mobileLabel: "均收" },
{ value: "returns_week", label: "周收益", mobileLabel: "周收" },
];
// 重要性等级常量
export const IMPORTANCE_OPTIONS = [
{ value: "S", label: "S级" },
{ value: "A", label: "A级" },
{ value: "B", label: "B级" },
{ value: "C", label: "C级" },
];

View File

@@ -0,0 +1,4 @@
// CompactSearchBox 模块导出
export { SORT_OPTIONS, IMPORTANCE_OPTIONS } from "./constants";
export { findIndustryPath, inferTimeRangeFromFilters, buildFilterParams } from "./utils";

View File

@@ -0,0 +1,140 @@
// CompactSearchBox 工具函数
import dayjs from "dayjs";
/**
* 在行业树中查找指定代码的完整路径
* @param {string} targetCode - 目标行业代码
* @param {Array} data - 行业数据树
* @param {Array} currentPath - 当前路径(递归用)
* @returns {Array|null} - 找到的完整路径,未找到返回 null
*/
export const findIndustryPath = (targetCode, data, currentPath = []) => {
if (!data || data.length === 0) return null;
for (const item of data) {
const newPath = [...currentPath, item.value];
if (item.value === targetCode) {
return newPath;
}
if (item.children && item.children.length > 0) {
const found = findIndustryPath(targetCode, item.children, newPath);
if (found) return found;
}
}
return null;
};
/**
* 从 filters 中推断时间范围配置
* @param {Object} filters - 筛选条件
* @returns {Object|null} - 时间范围配置
*/
export const inferTimeRangeFromFilters = (filters) => {
if (!filters) return null;
const hasTimeInFilters =
filters.start_date ||
filters.end_date ||
filters.recent_days ||
filters.time_filter_key;
if (!hasTimeInFilters) return null;
let inferredKey = filters.time_filter_key || "custom";
let inferredLabel = "";
if (filters.time_filter_key === "current-trading-day") {
inferredKey = "current-trading-day";
inferredLabel = "当前交易日";
} else if (filters.time_filter_key === "all") {
inferredKey = "all";
inferredLabel = "全部";
} else if (filters.recent_days) {
if (filters.recent_days === "7") {
inferredKey = "week";
inferredLabel = "近一周";
} else if (filters.recent_days === "30") {
inferredKey = "month";
inferredLabel = "近一月";
} else {
inferredLabel = `${filters.recent_days}`;
}
} else if (filters.start_date && filters.end_date) {
inferredLabel = `${dayjs(filters.start_date).format("MM-DD HH:mm")} - ${dayjs(filters.end_date).format("MM-DD HH:mm")}`;
}
return {
start_date: filters.start_date || "",
end_date: filters.end_date || "",
recent_days: filters.recent_days || "",
label: inferredLabel,
key: inferredKey,
};
};
/**
* 构建筛选参数
* @param {Object} options - 配置选项
* @returns {Object} - 构建的参数对象
*/
export const buildFilterParams = ({
overrides = {},
sort,
importance,
filtersQ,
industryValue,
tradingTimeRange,
mode,
pageSize,
}) => {
const sortValue = overrides.sort ?? sort;
let actualSort = sortValue;
let returnType;
if (sortValue === "returns_avg") {
actualSort = "returns";
returnType = "avg";
} else if (sortValue === "returns_week") {
actualSort = "returns";
returnType = "week";
}
let importanceValue = overrides.importance ?? importance;
if (Array.isArray(importanceValue)) {
importanceValue =
importanceValue.length === 0 ? "all" : importanceValue.join(",");
}
const result = {
...overrides,
sort: actualSort,
_sortDisplay: sortValue,
importance: importanceValue,
q: overrides.q ?? filtersQ ?? "",
industry_code: overrides.industry_code ?? (industryValue?.join(",") || ""),
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ""),
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ""),
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ""),
page: 1,
};
delete result.per_page;
if (returnType) {
result.return_type = returnType;
} else {
delete result.return_type;
}
if (mode !== undefined && mode !== null) {
result.mode = mode;
}
if (pageSize !== undefined && pageSize !== null) {
result.per_page = pageSize;
}
return result;
};

View File

@@ -1,239 +1,84 @@
// src/views/Community/components/TradingTimeFilter.js
// 交易时段智能筛选组件
import React, { useState, useMemo, useEffect } from 'react';
import { Space, Button, Tag, Tooltip, DatePicker, Popover, Select } from 'antd';
import { ClockCircleOutlined, CalendarOutlined, FilterOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import locale from 'antd/es/date-picker/locale/zh_CN';
import { logger } from '@utils/logger';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import tradingDayUtils from '@utils/tradingDayUtils';
import React, { useState, useMemo, useEffect } from "react";
import {
Space,
Button,
Tag,
Tooltip,
DatePicker,
Popover,
Select,
} from "antd";
import {
ClockCircleOutlined,
CalendarOutlined,
FilterOutlined,
} from "@ant-design/icons";
import locale from "antd/es/date-picker/locale/zh_CN";
import { logger } from "@utils/logger";
import { PROFESSIONAL_COLORS } from "@constants/professionalTheme";
// 模块化导入
import {
generateTimeRangeConfig,
disabledDate,
disabledTime,
} from "./TradingTimeFilter/utils";
const { RangePicker } = DatePicker;
const { Option } = Select;
/**
* 交易时段筛选组件
* @param {string} value - 当前选中的 key受控
* @param {Function} onChange - 时间范围变化回调 (timeConfig) => void
* @param {boolean} compact - 是否使用紧凑模式PC 端搜索栏内使用)
* @param {boolean} mobile - 是否使用移动端模式(下拉选择)
*/
const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false }) => {
const TradingTimeFilter = ({
value,
onChange,
compact = false,
mobile = false,
}) => {
const [selectedKey, setSelectedKey] = useState(null);
const [customRangeVisible, setCustomRangeVisible] = useState(false);
const [customRange, setCustomRange] = useState(null);
// 监听外部 value 变化,同步内部状态
// 监听外部 value 变化
useEffect(() => {
if (value === null || value === undefined) {
// 外部重置,清空内部状态
setSelectedKey(null);
setCustomRange(null);
logger.debug('TradingTimeFilter', '外部重置,清空选中状态');
logger.debug("TradingTimeFilter", "外部重置,清空选中状态");
} else {
// 外部选中值变化,同步内部状态
setSelectedKey(value);
logger.debug('TradingTimeFilter', '外部value变化同步内部状态', { value });
logger.debug("TradingTimeFilter", "外部value变化同步内部状态", { value });
}
}, [value]);
// 获取当前交易时段
const getCurrentTradingSession = () => {
const now = dayjs();
const hour = now.hour();
const minute = now.minute();
const currentMinutes = hour * 60 + minute;
// 转换为分钟数便于比较
const PRE_MARKET_END = 9 * 60 + 30; // 09:30
const MORNING_END = 11 * 60 + 30; // 11:30
const AFTERNOON_START = 13 * 60; // 13:00
const MARKET_CLOSE = 15 * 60; // 15:00
if (currentMinutes < PRE_MARKET_END) {
return 'pre-market'; // 盘前
} else if (currentMinutes < MORNING_END) {
return 'morning'; // 早盘
} else if (currentMinutes < AFTERNOON_START) {
return 'lunch'; // 午休
} else if (currentMinutes < MARKET_CLOSE) {
return 'afternoon'; // 午盘
} else {
return 'after-hours'; // 盘后
}
};
// 获取时间范围配置
const timeRangeConfig = useMemo(() => {
const session = getCurrentTradingSession();
const now = dayjs();
// 今日关键时间点
const today0930 = now.hour(9).minute(30).second(0);
const today1130 = now.hour(11).minute(30).second(0);
const today1300 = now.hour(13).minute(0).second(0);
const today1500 = now.hour(15).minute(0).second(0);
const todayStart = now.startOf('day');
const todayEnd = now.endOf('day');
// 昨日关键时间点
const yesterday1500 = now.subtract(1, 'day').hour(15).minute(0).second(0);
const yesterdayStart = now.subtract(1, 'day').startOf('day');
const yesterdayEnd = now.subtract(1, 'day').endOf('day');
// 动态按钮配置(根据时段返回不同按钮数组)
// 注意:"当前交易日"已在固定按钮中,这里只放特定时段的快捷按钮
const dynamicButtonsMap = {
'pre-market': [], // 盘前:使用"当前交易日"即可
'morning': [
{
key: 'intraday',
label: '盘中',
range: [today0930, today1500],
tooltip: '盘中交易时段',
timeHint: '今日 09:30 - 15:00',
color: 'blue',
type: 'precise'
}
],
'lunch': [], // 午休:使用"当前交易日"即可
'afternoon': [
{
key: 'intraday',
label: '盘中',
range: [today0930, today1500],
tooltip: '盘中交易时段',
timeHint: '今日 09:30 - 15:00',
color: 'blue',
type: 'precise'
},
{
key: 'afternoon',
label: '午盘',
range: [today1300, today1500],
tooltip: '午盘交易时段',
timeHint: '今日 13:00 - 15:00',
color: 'cyan',
type: 'precise'
}
],
'after-hours': [] // 盘后:使用"当前交易日"即可
};
// 获取上一个交易日(使用 tdays.csv 数据)
const getPrevTradingDay = () => {
try {
const prevTradingDay = tradingDayUtils.getPreviousTradingDay(now.toDate());
return dayjs(prevTradingDay);
} catch (e) {
// 降级:简单地减一天(不考虑周末节假日)
logger.warn('TradingTimeFilter', '获取上一交易日失败,降级处理', e);
return now.subtract(1, 'day');
}
};
const prevTradingDay = getPrevTradingDay();
const prevTradingDay1500 = prevTradingDay.hour(15).minute(0).second(0);
// 固定按钮配置(始终显示)
const fixedButtons = [
{
key: 'current-trading-day',
label: '当前交易日',
range: [prevTradingDay1500, now],
tooltip: '当前交易日事件',
timeHint: `${prevTradingDay.format('MM-DD')} 15:00 - 现在`,
color: 'green',
type: 'precise'
},
{
key: 'morning-fixed',
label: '早盘',
range: [today0930, today1130],
tooltip: '早盘交易时段',
timeHint: '09:30 - 11:30',
color: 'geekblue',
type: 'precise'
},
{
key: 'today',
label: '今日全天',
range: [todayStart, todayEnd],
tooltip: '今日全天',
timeHint: '今日 00:00 - 23:59',
color: 'purple',
type: 'precise'
},
{
key: 'yesterday',
label: '昨日',
range: [yesterdayStart, yesterdayEnd],
tooltip: '昨日全天',
timeHint: '昨日 00:00 - 23:59',
color: 'orange',
type: 'precise'
},
{
key: 'week',
label: '近一周',
range: 7, // 天数
tooltip: '过去7个交易日',
timeHint: '过去7天',
color: 'magenta',
type: 'recent_days'
},
{
key: 'month',
label: '近一月',
range: 30, // 天数
tooltip: '过去30个交易日',
timeHint: '过去30天',
color: 'volcano',
type: 'recent_days'
},
{
key: 'all',
label: '全部',
range: null, // 无时间限制
tooltip: '显示全部事件',
timeHint: '不限时间',
color: 'default',
type: 'all'
}
];
return {
dynamic: dynamicButtonsMap[session] || [],
fixed: fixedButtons
};
}, []); // 空依赖,首次渲染时计算
const timeRangeConfig = useMemo(() => generateTimeRangeConfig(), []);
// 按钮点击处理
const handleButtonClick = (config) => {
logger.debug('TradingTimeFilter', '按钮点击', {
logger.debug("TradingTimeFilter", "按钮点击", {
config,
currentSelectedKey: selectedKey,
willToggle: selectedKey === config.key
willToggle: selectedKey === config.key,
});
if (selectedKey === config.key) {
// 取消选中
setSelectedKey(null);
onChange(null);
logger.debug('TradingTimeFilter', '取消选中', { key: config.key });
logger.debug("TradingTimeFilter", "取消选中", { key: config.key });
} else {
// 选中
setSelectedKey(config.key);
const timeConfig = {
onChange({
range: config.range,
type: config.type,
label: config.label,
key: config.key
};
onChange(timeConfig);
logger.debug('TradingTimeFilter', '选中新按钮', { timeConfig });
key: config.key,
});
logger.debug("TradingTimeFilter", "选中新按钮", { key: config.key });
}
};
@@ -241,27 +86,29 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
const handleCustomRangeOk = (dates) => {
if (dates && dates.length === 2) {
setCustomRange(dates);
setSelectedKey('custom');
setSelectedKey("custom");
setCustomRangeVisible(false);
onChange({
range: dates,
type: 'precise',
label: `${dates[0].format('MM-DD HH:mm')} - ${dates[1].format('MM-DD HH:mm')}`,
key: 'custom'
type: "precise",
label: `${dates[0].format("MM-DD HH:mm")} - ${dates[1].format("MM-DD HH:mm")}`,
key: "custom",
});
logger.debug('TradingTimeFilter', '自定义范围', {
start: dates[0].format('YYYY-MM-DD HH:mm:ss'),
end: dates[1].format('YYYY-MM-DD HH:mm:ss')
logger.debug("TradingTimeFilter", "自定义范围", {
start: dates[0].format("YYYY-MM-DD HH:mm:ss"),
end: dates[1].format("YYYY-MM-DD HH:mm:ss"),
});
}
};
// 渲染紧凑模式按钮PC 端搜索栏内使用,文字按钮 + | 分隔符)
// 渲染紧凑模式按钮
const renderCompactButton = (config, showDivider = true) => {
const isSelected = selectedKey === config.key;
const fullTooltip = config.timeHint ? `${config.tooltip} · ${config.timeHint}` : config.tooltip;
const fullTooltip = config.timeHint
? `${config.tooltip} · ${config.timeHint}`
: config.tooltip;
return (
<React.Fragment key={config.key}>
@@ -269,41 +116,51 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
<span
onClick={() => handleButtonClick(config)}
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '13px',
cursor: "pointer",
padding: "4px 8px",
borderRadius: "4px",
fontSize: "13px",
fontWeight: isSelected ? 600 : 400,
color: isSelected ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.text.secondary,
background: isSelected ? 'rgba(255, 195, 0, 0.15)' : 'transparent',
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
color: isSelected
? PROFESSIONAL_COLORS.gold[500]
: PROFESSIONAL_COLORS.text.secondary,
background: isSelected
? "rgba(255, 195, 0, 0.15)"
: "transparent",
transition: "all 0.2s ease",
whiteSpace: "nowrap",
}}
>
{config.label}
</span>
</Tooltip>
{showDivider && (
<span style={{ color: 'rgba(255, 255, 255, 0.2)', margin: '0 2px' }}>|</span>
<span style={{ color: "rgba(255, 255, 255, 0.2)", margin: "0 2px" }}>
|
</span>
)}
</React.Fragment>
);
};
// 渲染按钮(默认模式)
// 渲染默认按钮
const renderButton = (config) => {
const isSelected = selectedKey === config.key;
// 构建完整的 tooltip 提示(文字 + 时间)
const fullTooltip = config.timeHint ? `${config.tooltip} · ${config.timeHint}` : config.tooltip;
const fullTooltip = config.timeHint
? `${config.tooltip} · ${config.timeHint}`
: config.tooltip;
if (isSelected) {
// 选中状态:只显示 Tag不显示下方时间
return (
<Tooltip title={fullTooltip} key={config.key}>
<Tag
color={config.color}
style={{ margin: 0, fontSize: 13, padding: '2px 8px', cursor: 'pointer' }}
style={{
margin: 0,
fontSize: 13,
padding: "2px 8px",
cursor: "pointer",
}}
onClick={() => handleButtonClick(config)}
>
<ClockCircleOutlined style={{ marginRight: 4 }} />
@@ -312,7 +169,6 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
</Tooltip>
);
} else {
// 未选中状态:普通按钮
return (
<Tooltip title={fullTooltip} key={config.key}>
<Button
@@ -327,75 +183,36 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
}
};
// 禁用未来日期
const disabledDate = (current) => {
return current && current > dayjs().endOf('day');
};
// 禁用未来时间(精确到分钟)
const disabledTime = (current) => {
if (!current) return {};
const now = dayjs();
const isToday = current.isSame(now, 'day');
if (!isToday) return {};
const currentHour = now.hour();
const currentMinute = now.minute();
return {
disabledHours: () => {
const hours = [];
for (let i = currentHour + 1; i < 24; i++) {
hours.push(i);
}
return hours;
},
disabledMinutes: (selectedHour) => {
if (selectedHour === currentHour) {
const minutes = [];
for (let i = currentMinute + 1; i < 60; i++) {
minutes.push(i);
}
return minutes;
}
return [];
}
};
};
// "更多时间" 按钮内容
// 自定义时间选择器内容
const customRangeContent = (
<div style={{ padding: 8 }}>
<RangePicker
showTime={{ format: 'HH:mm' }}
showTime={{ format: "HH:mm" }}
format="YYYY-MM-DD HH:mm"
locale={locale}
placeholder={['开始时间', '结束时间']}
placeholder={["开始时间", "结束时间"]}
onChange={handleCustomRangeOk}
value={customRange}
disabledDate={disabledDate}
disabledTime={disabledTime}
style={{ marginBottom: 8 }}
/>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
<div style={{ fontSize: 12, color: "#999", marginTop: 4 }}>
支持精确到分钟的时间范围选择不能超过当前时间
</div>
</div>
);
// 移动端模式:下拉选择器
// 移动端模式
if (mobile) {
const allButtons = [...timeRangeConfig.dynamic, ...timeRangeConfig.fixed];
const handleMobileSelect = (key) => {
if (key === selectedKey) {
// 取消选中
setSelectedKey(null);
onChange(null);
} else {
const config = allButtons.find(b => b.key === key);
const config = allButtons.find((b) => b.key === key);
if (config) {
handleButtonClick(config);
}
@@ -407,7 +224,7 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
value={selectedKey}
onChange={handleMobileSelect}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<FilterOutlined style={{ fontSize: 12 }} />
筛选
</span>
@@ -421,7 +238,7 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
className="transparent-select"
popupMatchSelectWidth={false}
>
{allButtons.map(config => (
{allButtons.map((config) => (
<Option key={config.key} value={config.key}>
{config.label}
</Option>
@@ -430,18 +247,20 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
);
}
// 紧凑模式PC 端搜索栏内的样式
// 紧凑模式
if (compact) {
// 合并所有按钮配置
const allButtons = [...timeRangeConfig.dynamic, ...timeRangeConfig.fixed];
return (
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap' }}>
<div
style={{ display: "flex", alignItems: "center", flexWrap: "nowrap" }}
>
{allButtons.map((config, index) =>
renderCompactButton(config, index < allButtons.length - 1)
)}
{/* 更多时间 */}
<span style={{ color: 'rgba(255, 255, 255, 0.2)', margin: '0 2px' }}>|</span>
<span style={{ color: "rgba(255, 255, 255, 0.2)", margin: "0 2px" }}>
|
</span>
<Popover
content={customRangeContent}
title="选择自定义时间范围"
@@ -453,18 +272,24 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
<Tooltip title="自定义时间范围">
<span
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '13px',
fontWeight: selectedKey === 'custom' ? 600 : 400,
color: selectedKey === 'custom' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.text.secondary,
background: selectedKey === 'custom' ? 'rgba(255, 195, 0, 0.15)' : 'transparent',
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: "pointer",
padding: "4px 8px",
borderRadius: "4px",
fontSize: "13px",
fontWeight: selectedKey === "custom" ? 600 : 400,
color:
selectedKey === "custom"
? PROFESSIONAL_COLORS.gold[500]
: PROFESSIONAL_COLORS.text.secondary,
background:
selectedKey === "custom"
? "rgba(255, 195, 0, 0.15)"
: "transparent",
transition: "all 0.2s ease",
whiteSpace: "nowrap",
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<CalendarOutlined style={{ fontSize: 12 }} />
@@ -476,16 +301,16 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
);
}
// 默认模式:移动端/独立使用
// 默认模式
return (
<Space wrap size={[8, 8]} style={{ display: 'flex', alignItems: 'flex-start' }}>
{/* 动态按钮(根据时段显示多个) */}
{timeRangeConfig.dynamic.map(config => renderButton(config))}
<Space
wrap
size={[8, 8]}
style={{ display: "flex", alignItems: "flex-start" }}
>
{timeRangeConfig.dynamic.map((config) => renderButton(config))}
{timeRangeConfig.fixed.map((config) => renderButton(config))}
{/* 固定按钮(始终显示) */}
{timeRangeConfig.fixed.map(config => renderButton(config))}
{/* 更多时间 - 自定义范围 */}
<Popover
content={customRangeContent}
title="选择自定义时间范围"
@@ -494,18 +319,36 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false })
onOpenChange={setCustomRangeVisible}
placement="bottomLeft"
>
{selectedKey === 'custom' ? (
{selectedKey === "custom" ? (
<Tooltip
title={customRange ? `自定义时间范围 · ${customRange[0].format('MM-DD HH:mm')} - ${customRange[1].format('MM-DD HH:mm')}` : '自定义时间范围'}
title={
customRange
? `自定义时间范围 · ${customRange[0].format("MM-DD HH:mm")} - ${customRange[1].format("MM-DD HH:mm")}`
: "自定义时间范围"
}
>
<Tag
color="gold"
style={{
margin: 0,
fontSize: 13,
padding: "2px 8px",
cursor: "pointer",
}}
>
<Tag color="gold" style={{ margin: 0, fontSize: 13, padding: '2px 8px', cursor: 'pointer' }}>
<CalendarOutlined style={{ marginRight: 4 }} />
{customRange ? `${customRange[0].format('MM-DD HH:mm')} - ${customRange[1].format('MM-DD HH:mm')}` : '自定义'}
{customRange
? `${customRange[0].format("MM-DD HH:mm")} - ${customRange[1].format("MM-DD HH:mm")}`
: "自定义"}
</Tag>
</Tooltip>
) : (
<Tooltip title="自定义时间范围">
<Button size="small" icon={<CalendarOutlined />} style={{ fontSize: 12 }}>
<Button
size="small"
icon={<CalendarOutlined />}
style={{ fontSize: 12 }}
>
更多时间
</Button>
</Tooltip>

View File

@@ -0,0 +1,18 @@
// TradingTimeFilter 常量定义
// 时段边界(分钟数)
export const TIME_BOUNDARIES = {
PRE_MARKET_END: 9 * 60 + 30, // 09:30
MORNING_END: 11 * 60 + 30, // 11:30
AFTERNOON_START: 13 * 60, // 13:00
MARKET_CLOSE: 15 * 60, // 15:00
};
// 时段类型
export const TRADING_SESSIONS = {
PRE_MARKET: "pre-market", // 盘前
MORNING: "morning", // 早盘
LUNCH: "lunch", // 午休
AFTERNOON: "afternoon", // 午盘
AFTER_HOURS: "after-hours", // 盘后
};

View File

@@ -0,0 +1,10 @@
// TradingTimeFilter 模块导出
export { TIME_BOUNDARIES, TRADING_SESSIONS } from "./constants";
export {
getCurrentTradingSession,
getPrevTradingDay,
generateTimeRangeConfig,
disabledDate,
disabledTime,
} from "./utils";

View File

@@ -0,0 +1,224 @@
// TradingTimeFilter 工具函数
import dayjs from "dayjs";
import { TIME_BOUNDARIES, TRADING_SESSIONS } from "./constants";
import { logger } from "@utils/logger";
import tradingDayUtils from "@utils/tradingDayUtils";
/**
* 获取当前交易时段
* @returns {string} 时段标识
*/
export const getCurrentTradingSession = () => {
const now = dayjs();
const hour = now.hour();
const minute = now.minute();
const currentMinutes = hour * 60 + minute;
if (currentMinutes < TIME_BOUNDARIES.PRE_MARKET_END) {
return TRADING_SESSIONS.PRE_MARKET;
} else if (currentMinutes < TIME_BOUNDARIES.MORNING_END) {
return TRADING_SESSIONS.MORNING;
} else if (currentMinutes < TIME_BOUNDARIES.AFTERNOON_START) {
return TRADING_SESSIONS.LUNCH;
} else if (currentMinutes < TIME_BOUNDARIES.MARKET_CLOSE) {
return TRADING_SESSIONS.AFTERNOON;
} else {
return TRADING_SESSIONS.AFTER_HOURS;
}
};
/**
* 获取上一个交易日
* @param {dayjs.Dayjs} now - 当前时间
* @returns {dayjs.Dayjs} 上一交易日
*/
export const getPrevTradingDay = (now) => {
try {
const prevTradingDay = tradingDayUtils.getPreviousTradingDay(now.toDate());
return dayjs(prevTradingDay);
} catch (e) {
logger.warn("TradingTimeFilter", "获取上一交易日失败,降级处理", e);
return now.subtract(1, "day");
}
};
/**
* 生成时间范围配置
* @returns {Object} 包含 dynamic 和 fixed 按钮配置
*/
export const generateTimeRangeConfig = () => {
const session = getCurrentTradingSession();
const now = dayjs();
// 今日关键时间点
const today0930 = now.hour(9).minute(30).second(0);
const today1130 = now.hour(11).minute(30).second(0);
const today1300 = now.hour(13).minute(0).second(0);
const today1500 = now.hour(15).minute(0).second(0);
const todayStart = now.startOf("day");
const todayEnd = now.endOf("day");
// 昨日关键时间点
const yesterdayStart = now.subtract(1, "day").startOf("day");
const yesterdayEnd = now.subtract(1, "day").endOf("day");
// 动态按钮配置(根据时段)
const dynamicButtonsMap = {
[TRADING_SESSIONS.PRE_MARKET]: [],
[TRADING_SESSIONS.MORNING]: [
{
key: "intraday",
label: "盘中",
range: [today0930, today1500],
tooltip: "盘中交易时段",
timeHint: "今日 09:30 - 15:00",
color: "blue",
type: "precise",
},
],
[TRADING_SESSIONS.LUNCH]: [],
[TRADING_SESSIONS.AFTERNOON]: [
{
key: "intraday",
label: "盘中",
range: [today0930, today1500],
tooltip: "盘中交易时段",
timeHint: "今日 09:30 - 15:00",
color: "blue",
type: "precise",
},
{
key: "afternoon",
label: "午盘",
range: [today1300, today1500],
tooltip: "午盘交易时段",
timeHint: "今日 13:00 - 15:00",
color: "cyan",
type: "precise",
},
],
[TRADING_SESSIONS.AFTER_HOURS]: [],
};
const prevTradingDay = getPrevTradingDay(now);
const prevTradingDay1500 = prevTradingDay.hour(15).minute(0).second(0);
// 固定按钮配置
const fixedButtons = [
{
key: "current-trading-day",
label: "当前交易日",
range: [prevTradingDay1500, now],
tooltip: "当前交易日事件",
timeHint: `${prevTradingDay.format("MM-DD")} 15:00 - 现在`,
color: "green",
type: "precise",
},
{
key: "morning-fixed",
label: "早盘",
range: [today0930, today1130],
tooltip: "早盘交易时段",
timeHint: "09:30 - 11:30",
color: "geekblue",
type: "precise",
},
{
key: "today",
label: "今日全天",
range: [todayStart, todayEnd],
tooltip: "今日全天",
timeHint: "今日 00:00 - 23:59",
color: "purple",
type: "precise",
},
{
key: "yesterday",
label: "昨日",
range: [yesterdayStart, yesterdayEnd],
tooltip: "昨日全天",
timeHint: "昨日 00:00 - 23:59",
color: "orange",
type: "precise",
},
{
key: "week",
label: "近一周",
range: 7,
tooltip: "过去7个交易日",
timeHint: "过去7天",
color: "magenta",
type: "recent_days",
},
{
key: "month",
label: "近一月",
range: 30,
tooltip: "过去30个交易日",
timeHint: "过去30天",
color: "volcano",
type: "recent_days",
},
{
key: "all",
label: "全部",
range: null,
tooltip: "显示全部事件",
timeHint: "不限时间",
color: "default",
type: "all",
},
];
return {
dynamic: dynamicButtonsMap[session] || [],
fixed: fixedButtons,
};
};
/**
* 禁用未来日期
* @param {dayjs.Dayjs} current - 当前日期
* @returns {boolean} 是否禁用
*/
export const disabledDate = (current) => {
return current && current > dayjs().endOf("day");
};
/**
* 禁用未来时间(精确到分钟)
* @param {dayjs.Dayjs} current - 当前时间
* @returns {Object} 禁用配置
*/
export const disabledTime = (current) => {
if (!current) return {};
const now = dayjs();
const isToday = current.isSame(now, "day");
if (!isToday) return {};
const currentHour = now.hour();
const currentMinute = now.minute();
return {
disabledHours: () => {
const hours = [];
for (let i = currentHour + 1; i < 24; i++) {
hours.push(i);
}
return hours;
},
disabledMinutes: (selectedHour) => {
if (selectedHour === currentHour) {
const minutes = [];
for (let i = currentMinute + 1; i < 60; i++) {
minutes.push(i);
}
return minutes;
}
return [];
},
};
};

View File

@@ -4,7 +4,7 @@
* Y轴板块热度涨停家数
* 支持时间滑动条查看历史数据
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from "react";
import {
Box,
Text,
@@ -13,189 +13,34 @@ import {
Spinner,
Center,
useToast,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Badge,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
Tooltip as ChakraTooltip,
} from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { ThunderboltOutlined } from '@ant-design/icons';
import { getApiBase } from '@utils/apiConfig';
} from "@chakra-ui/react";
import ReactECharts from "echarts-for-react";
import {
ThunderboltOutlined,
CalendarOutlined,
CaretUpFilled,
} from "@ant-design/icons";
import { getApiBase } from "@utils/apiConfig";
// 板块状态配置
const STATUS_CONFIG = {
rising: { name: '主升', color: '#FF4D4F' },
declining: { name: '退潮', color: '#52C41A' },
lurking: { name: '潜伏', color: '#1890FF' },
clustering: { name: '抱团', color: '#722ED1' },
};
/**
* 生成 ECharts 配置
*/
const generateChartOption = (themes) => {
if (!themes || themes.length === 0) return {};
const groupedData = {
主升: [],
退潮: [],
潜伏: [],
抱团: [],
};
themes.forEach((theme) => {
const statusName = STATUS_CONFIG[theme.status]?.name || '抱团';
groupedData[statusName].push({
name: theme.label,
value: [theme.x, theme.y],
countTrend: theme.countTrend,
boardTrend: theme.boardTrend,
themeColor: theme.color,
history: theme.history,
});
});
const series = Object.entries(STATUS_CONFIG).map(([key, config]) => ({
name: config.name,
type: 'scatter',
data: groupedData[config.name] || [],
symbolSize: (data) => Math.max(25, Math.min(70, data[1] * 3.5)),
itemStyle: {
color: (params) => params.data.themeColor || config.color,
shadowBlur: 12,
shadowColor: (params) => params.data.themeColor || config.color,
opacity: 0.85,
},
emphasis: {
itemStyle: { opacity: 1, shadowBlur: 25 },
},
label: {
show: true,
formatter: (params) => params.data.name,
position: 'right',
color: '#fff',
fontSize: 14,
fontWeight: 'bold',
textShadowColor: 'rgba(0,0,0,0.8)',
textShadowBlur: 4,
},
}));
const maxX = Math.max(...themes.map((t) => t.x), 5) + 1;
const maxY = Math.max(...themes.map((t) => t.y), 10) + 3;
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 15, 30, 0.95)',
borderColor: 'rgba(255, 215, 0, 0.3)',
borderWidth: 1,
textStyle: { color: '#fff' },
formatter: (params) => {
const { name, value, countTrend, boardTrend, history } = params.data;
const countTrendText = countTrend > 0 ? `+${countTrend}` : countTrend;
const boardTrendText = boardTrend > 0 ? `+${boardTrend}` : boardTrend;
const countIcon = countTrend > 0 ? '🔥' : countTrend < 0 ? '❄️' : '➡️';
const historyText = (history || [])
.slice(0, 5)
.map((h) => `${h.date?.slice(5) || ''}: ${h.count}家/${h.maxBoard}`)
.join('<br>');
return `
<div style="font-weight:bold;margin-bottom:8px;color:#FFD700;font-size:14px;">
${name} ${countIcon}
</div>
<div style="margin:4px 0;">
<span style="color:#aaa">涨停家数:</span>
<span style="color:#fff;margin-left:8px;">${value[1]}家</span>
<span style="color:${countTrend >= 0 ? '#52c41a' : '#ff4d4f'};margin-left:8px;">(${countTrendText})</span>
</div>
<div style="margin:4px 0;">
<span style="color:#aaa">最高连板:</span>
<span style="color:#fff;margin-left:8px;">${value[0]}板</span>
<span style="color:${boardTrend >= 0 ? '#52c41a' : '#ff4d4f'};margin-left:8px;">(${boardTrendText})</span>
</div>
<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.1);">
<div style="color:#aaa;font-size:11px;margin-bottom:4px;">近5日趋势:</div>
<div style="font-size:11px;line-height:1.6;">${historyText}</div>
</div>
<div style="margin-top:8px;color:#888;font-size:10px;">点击查看详情</div>
`;
},
},
legend: {
show: true,
top: 5,
right: 10,
orient: 'horizontal',
textStyle: { color: 'rgba(255, 255, 255, 0.7)', fontSize: 13 },
itemWidth: 14,
itemHeight: 14,
data: Object.values(STATUS_CONFIG).map((s) => ({
name: s.name,
icon: 'circle',
itemStyle: { color: s.color },
})),
},
grid: {
left: '10%',
right: '8%',
top: '12%',
bottom: '8%',
containLabel: true,
},
xAxis: {
type: 'value',
name: '辨识度(最高板)',
nameLocation: 'middle',
nameGap: 28,
nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 13 },
min: 0,
max: maxX,
interval: 1,
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.2)' } },
axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12, formatter: '{value}板' },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } },
},
yAxis: {
type: 'value',
name: '热度(家数)',
nameLocation: 'middle',
nameGap: 40,
nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 13 },
min: 0,
max: maxY,
axisLine: { show: false },
axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } },
},
series,
};
};
// 模块化导入
import {
STATUS_CONFIG,
generateChartOption,
ThemeDetailModal,
} from "./ThemeCometChart/index";
/**
* ThemeCometChart 主组件
*/
const ThemeCometChart = ({ onThemeSelect }) => {
const [loading, setLoading] = useState(true);
const [allDatesData, setAllDatesData] = useState({}); // 缓存所有日期的数据
const [allDatesData, setAllDatesData] = useState({});
const [availableDates, setAvailableDates] = useState([]);
const [selectedTheme, setSelectedTheme] = useState(null);
const [sliderIndex, setSliderIndex] = useState(0);
@@ -208,7 +53,6 @@ const ThemeCometChart = ({ onThemeSelect }) => {
setLoading(true);
try {
const apiBase = getApiBase();
// 先获取最新数据,拿到可用日期列表
const response = await fetch(`${apiBase}/api/v1/zt/theme-scatter?days=5`);
const result = await response.json();
@@ -216,13 +60,15 @@ const ThemeCometChart = ({ onThemeSelect }) => {
const dates = result.data.availableDates || [];
setAvailableDates(dates);
// 缓存第一个日期(最新)的数据
const latestDate = dates[0]?.date;
const dataCache = {};
if (latestDate) {
dataCache[latestDate] = {
themes: result.data.themes || [],
currentDate: result.data.currentDate || '',
currentDate: result.data.currentDate || "",
totalLimitUp: result.data.totalLimitUp || 0,
totalEvents: result.data.totalEvents || 0,
indexChange: result.data.indexChange || 0,
};
}
@@ -230,13 +76,18 @@ const ThemeCometChart = ({ onThemeSelect }) => {
const otherDates = dates.slice(1);
const promises = otherDates.map(async (dateInfo) => {
try {
const res = await fetch(`${apiBase}/api/v1/zt/theme-scatter?date=${dateInfo.date}&days=5`);
const res = await fetch(
`${apiBase}/api/v1/zt/theme-scatter?date=${dateInfo.date}&days=5`
);
const data = await res.json();
if (data.success && data.data) {
return {
date: dateInfo.date,
themes: data.data.themes || [],
currentDate: data.data.currentDate || '',
currentDate: data.data.currentDate || "",
totalLimitUp: data.data.totalLimitUp || 0,
totalEvents: data.data.totalEvents || 0,
indexChange: data.data.indexChange || 0,
};
}
} catch (e) {
@@ -251,44 +102,61 @@ const ThemeCometChart = ({ onThemeSelect }) => {
dataCache[item.date] = {
themes: item.themes,
currentDate: item.currentDate,
totalLimitUp: item.totalLimitUp,
totalEvents: item.totalEvents,
indexChange: item.indexChange,
};
}
});
setAllDatesData(dataCache);
setSliderIndex(0); // 默认显示最新日期
setSliderIndex(0);
} else {
throw new Error(result.error || '加载失败');
throw new Error(result.error || "加载失败");
}
} catch (error) {
console.error('加载题材数据失败:', error);
toast({ title: '加载数据失败', description: error.message, status: 'error', duration: 3000 });
console.error("加载题材数据失败:", error);
toast({
title: "加载数据失败",
description: error.message,
status: "error",
duration: 3000,
});
} finally {
setLoading(false);
}
}, [toast]);
// 初始加载所有数据
useEffect(() => {
loadAllData();
}, [loadAllData]);
// 滑动条变化时实时切换数据
const handleSliderChange = useCallback((value) => {
setSliderIndex(value);
}, []);
// 获取当前显示的数据
const currentDateStr = availableDates[sliderIndex]?.date;
const currentData = allDatesData[currentDateStr] || { themes: [], currentDate: '' };
const currentData = allDatesData[currentDateStr] || {
themes: [],
currentDate: "",
totalLimitUp: 0,
totalEvents: 0,
indexChange: 0,
};
const isCurrentDateLoaded = currentDateStr && allDatesData[currentDateStr];
const chartOption = useMemo(() => generateChartOption(currentData.themes), [currentData.themes]);
const chartOption = useMemo(
() => generateChartOption(currentData.themes),
[currentData.themes]
);
const handleChartClick = useCallback(
(params) => {
if (params.data) {
const theme = currentData.themes.find((t) => t.label === params.data.name);
const theme = currentData.themes.find(
(t) => t.label === params.data.name
);
if (theme) {
setSelectedTheme(theme);
onOpen();
@@ -298,10 +166,13 @@ const ThemeCometChart = ({ onThemeSelect }) => {
[currentData.themes, onOpen]
);
const onChartEvents = useMemo(() => ({ click: handleChartClick }), [handleChartClick]);
const onChartEvents = useMemo(
() => ({ click: handleChartClick }),
[handleChartClick]
);
// 当前滑动条对应的日期
const currentSliderDate = availableDates[sliderIndex]?.formatted || currentData.currentDate;
const currentSliderDate =
availableDates[sliderIndex]?.formatted || currentData.currentDate;
if (loading && Object.keys(allDatesData).length === 0) {
return (
@@ -324,22 +195,66 @@ const ThemeCometChart = ({ onThemeSelect }) => {
minH="350px"
>
{/* 标题栏 */}
<HStack spacing={3} mb={2}>
<Box p={2} bg="rgba(255,215,0,0.15)" borderRadius="lg" border="1px solid rgba(255,215,0,0.3)">
<ThunderboltOutlined style={{ color: '#FFD700', fontSize: '20px' }} />
<VStack spacing={1} mb={2} align="stretch">
<HStack justify="space-between">
<HStack spacing={1}>
<CalendarOutlined
style={{ color: "rgba(255,255,255,0.5)", fontSize: "12px" }}
/>
<Text fontSize="xs" color="whiteAlpha.600">
{currentSliderDate || currentData.currentDate}
</Text>
</HStack>
<HStack spacing={3}>
{Object.values(STATUS_CONFIG).map((status) => (
<HStack key={status.name} spacing={1}>
<Box w="8px" h="8px" borderRadius="full" bg={status.color} />
<Text fontSize="xs" color="whiteAlpha.700">
{status.name}
</Text>
</HStack>
))}
</HStack>
</HStack>
<HStack spacing={2}>
<Box
p={1.5}
bg="rgba(255,215,0,0.15)"
borderRadius="md"
border="1px solid rgba(255,215,0,0.3)"
>
<ThunderboltOutlined style={{ color: "#FFD700", fontSize: "14px" }} />
</Box>
<VStack align="start" spacing={0} flex={1}>
<HStack>
<Text fontSize="lg" fontWeight="bold" color="#FFD700">
<HStack spacing={4}>
<HStack spacing={1}>
<Text fontSize="sm" fontWeight="bold" color="#FFD700">
连板情绪监测
</Text>
{loading && <Spinner size="sm" color="yellow.400" />}
</HStack>
<Text fontSize="sm" color="whiteAlpha.500">
{currentSliderDate || currentData.currentDate}
<HStack spacing={1}>
<CaretUpFilled style={{ color: "#52C41A", fontSize: "12px" }} />
<Text fontSize="xs" color="whiteAlpha.600">
热度
</Text>
<Text fontSize="xs" fontWeight="bold" color="#52C41A">
{currentData.totalLimitUp}
</Text>
</VStack>
</HStack>
<HStack spacing={1}>
<ThunderboltOutlined
style={{ color: "#FFD700", fontSize: "12px" }}
/>
<Text fontSize="xs" color="whiteAlpha.600">
事件
</Text>
<Text fontSize="xs" fontWeight="bold" color="#FFD700">
{currentData.totalEvents}
</Text>
</HStack>
</HStack>
</HStack>
</VStack>
{/* 图表区域 */}
<Box h="calc(100% - 100px)" position="relative">
@@ -347,15 +262,17 @@ const ThemeCometChart = ({ onThemeSelect }) => {
<Center h="100%">
<VStack spacing={2}>
<Spinner size="md" color="yellow.400" />
<Text color="whiteAlpha.500" fontSize="sm">加载中...</Text>
<Text color="whiteAlpha.500" fontSize="sm">
加载中...
</Text>
</VStack>
</Center>
) : currentData.themes.length > 0 ? (
<ReactECharts
option={chartOption}
style={{ height: '100%', width: '100%' }}
style={{ height: "100%", width: "100%" }}
onEvents={onChartEvents}
opts={{ renderer: 'canvas' }}
opts={{ renderer: "canvas" }}
/>
) : (
<Center h="100%">
@@ -369,7 +286,8 @@ const ThemeCometChart = ({ onThemeSelect }) => {
<Box px={2} pt={2}>
<HStack spacing={3}>
<Text fontSize="sm" color="whiteAlpha.500" whiteSpace="nowrap">
{availableDates[availableDates.length - 1]?.formatted?.slice(5) || ''}
{availableDates[availableDates.length - 1]?.formatted?.slice(5) ||
""}
</Text>
<ChakraTooltip
hasArrow
@@ -398,109 +316,23 @@ const ThemeCometChart = ({ onThemeSelect }) => {
bg="#FFD700"
border="2px solid"
borderColor="orange.400"
_focus={{ boxShadow: '0 0 0 3px rgba(255,215,0,0.3)' }}
_focus={{ boxShadow: "0 0 0 3px rgba(255,215,0,0.3)" }}
/>
</Slider>
</ChakraTooltip>
<Text fontSize="sm" color="whiteAlpha.500" whiteSpace="nowrap">
{availableDates[0]?.formatted?.slice(5) || ''}
{availableDates[0]?.formatted?.slice(5) || ""}
</Text>
</HStack>
</Box>
)}
{/* 详情弹窗 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
<ModalOverlay bg="blackAlpha.700" />
<ModalContent bg="gray.900" border="1px solid" borderColor="yellow.500" maxH="80vh">
<ModalHeader color="yellow.400">
{selectedTheme?.label} - 近5日趋势
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody pb={6} overflowY="auto">
{selectedTheme && (
<VStack spacing={4} align="stretch">
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>历史数据</Text>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th color="whiteAlpha.600">日期</Th>
<Th color="whiteAlpha.600" isNumeric>涨停家数</Th>
<Th color="whiteAlpha.600" isNumeric>最高连板</Th>
<Th color="whiteAlpha.600">变化</Th>
</Tr>
</Thead>
<Tbody>
{selectedTheme.history?.map((h, idx) => {
const prev = selectedTheme.history[idx + 1];
const countChange = prev ? h.count - prev.count : 0;
return (
<Tr key={h.date}>
<Td color="white">{h.date}</Td>
<Td color="white" isNumeric>{h.count}</Td>
<Td color="white" isNumeric>{h.maxBoard}</Td>
<Td>
{countChange !== 0 && (
<Badge colorScheme={countChange > 0 ? 'green' : 'red'}>
{countChange > 0 ? '+' : ''}{countChange}
</Badge>
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>
涨停股票{selectedTheme.stocks?.length || 0}
</Text>
<Box maxH="200px" overflowY="auto">
<Table size="sm" variant="simple">
<Thead position="sticky" top={0} bg="gray.900">
<Tr>
<Th color="whiteAlpha.600">代码</Th>
<Th color="whiteAlpha.600">名称</Th>
<Th color="whiteAlpha.600">连板</Th>
</Tr>
</Thead>
<Tbody>
{selectedTheme.stocks?.slice(0, 20).map((stock) => (
<Tr key={stock.scode}>
<Td color="whiteAlpha.800" fontSize="xs">{stock.scode}</Td>
<Td color="white">{stock.sname}</Td>
<Td>
<Badge colorScheme={stock.continuous_days >= 3 ? 'red' : 'gray'}>
{stock.continuous_days}
</Badge>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
{selectedTheme.matchedSectors?.length > 0 && (
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>匹配板块</Text>
<HStack flexWrap="wrap" spacing={2}>
{selectedTheme.matchedSectors.map((sector) => (
<Badge key={sector} colorScheme="purple" variant="subtle">
{sector}
</Badge>
))}
</HStack>
</Box>
)}
</VStack>
)}
</ModalBody>
</ModalContent>
</Modal>
<ThemeDetailModal
isOpen={isOpen}
onClose={onClose}
theme={selectedTheme}
/>
</Box>
);
};

View File

@@ -0,0 +1,150 @@
// 题材详情弹窗组件
import React from "react";
import {
Box,
Text,
VStack,
HStack,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Badge,
} from "@chakra-ui/react";
/**
* 题材详情弹窗
*/
const ThemeDetailModal = ({ isOpen, onClose, theme }) => {
if (!theme) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
<ModalOverlay bg="blackAlpha.700" />
<ModalContent
bg="gray.900"
border="1px solid"
borderColor="yellow.500"
maxH="80vh"
>
<ModalHeader color="yellow.400">{theme.label} - 近5日趋势</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody pb={6} overflowY="auto">
<VStack spacing={4} align="stretch">
{/* 历史数据表格 */}
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>
历史数据
</Text>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th color="whiteAlpha.600">日期</Th>
<Th color="whiteAlpha.600" isNumeric>
涨停家数
</Th>
<Th color="whiteAlpha.600" isNumeric>
最高连板
</Th>
<Th color="whiteAlpha.600">变化</Th>
</Tr>
</Thead>
<Tbody>
{theme.history?.map((h, idx) => {
const prev = theme.history[idx + 1];
const countChange = prev ? h.count - prev.count : 0;
return (
<Tr key={h.date}>
<Td color="white">{h.date}</Td>
<Td color="white" isNumeric>
{h.count}
</Td>
<Td color="white" isNumeric>
{h.maxBoard}
</Td>
<Td>
{countChange !== 0 && (
<Badge
colorScheme={countChange > 0 ? "green" : "red"}
>
{countChange > 0 ? "+" : ""}
{countChange}
</Badge>
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
{/* 涨停股票表格 */}
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>
涨停股票{theme.stocks?.length || 0}
</Text>
<Box maxH="200px" overflowY="auto">
<Table size="sm" variant="simple">
<Thead position="sticky" top={0} bg="gray.900">
<Tr>
<Th color="whiteAlpha.600">代码</Th>
<Th color="whiteAlpha.600">名称</Th>
<Th color="whiteAlpha.600">连板</Th>
</Tr>
</Thead>
<Tbody>
{theme.stocks?.slice(0, 20).map((stock) => (
<Tr key={stock.scode}>
<Td color="whiteAlpha.800" fontSize="xs">
{stock.scode}
</Td>
<Td color="white">{stock.sname}</Td>
<Td>
<Badge
colorScheme={
stock.continuous_days >= 3 ? "red" : "gray"
}
>
{stock.continuous_days}
</Badge>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
{/* 匹配板块 */}
{theme.matchedSectors?.length > 0 && (
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>
匹配板块
</Text>
<HStack flexWrap="wrap" spacing={2}>
{theme.matchedSectors.map((sector) => (
<Badge key={sector} colorScheme="purple" variant="subtle">
{sector}
</Badge>
))}
</HStack>
</Box>
)}
</VStack>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default ThemeDetailModal;

View File

@@ -0,0 +1,143 @@
// ThemeCometChart ECharts 配置生成
import { STATUS_CONFIG } from "./constants";
/**
* 生成 ECharts 配置
* @param {Array} themes - 题材数据列表
* @returns {Object} - ECharts 配置对象
*/
export const generateChartOption = (themes) => {
if (!themes || themes.length === 0) return {};
const groupedData = {
主升: [],
退潮: [],
潜伏: [],
抱团: [],
};
themes.forEach((theme) => {
const statusName = STATUS_CONFIG[theme.status]?.name || "抱团";
groupedData[statusName].push({
name: theme.label,
value: [theme.x, theme.y],
countTrend: theme.countTrend,
boardTrend: theme.boardTrend,
themeColor: theme.color,
history: theme.history,
});
});
const series = Object.entries(STATUS_CONFIG).map(([key, config]) => ({
name: config.name,
type: "scatter",
data: groupedData[config.name] || [],
symbolSize: (data) => Math.max(25, Math.min(70, data[1] * 3.5)),
itemStyle: {
color: (params) => params.data.themeColor || config.color,
shadowBlur: 12,
shadowColor: (params) => params.data.themeColor || config.color,
opacity: 0.85,
},
emphasis: {
itemStyle: { opacity: 1, shadowBlur: 25 },
},
label: {
show: true,
formatter: (params) => params.data.name,
position: "right",
color: "#fff",
fontSize: 14,
fontWeight: "bold",
textShadowColor: "rgba(0,0,0,0.8)",
textShadowBlur: 4,
},
}));
const maxX = Math.max(...themes.map((t) => t.x), 5) + 1;
const maxY = Math.max(...themes.map((t) => t.y), 10) + 3;
return {
backgroundColor: "transparent",
tooltip: {
trigger: "item",
backgroundColor: "rgba(15, 15, 30, 0.95)",
borderColor: "rgba(255, 215, 0, 0.3)",
borderWidth: 1,
textStyle: { color: "#fff" },
formatter: (params) => {
const { name, value, countTrend, boardTrend, history } = params.data;
const countTrendText = countTrend > 0 ? `+${countTrend}` : countTrend;
const boardTrendText = boardTrend > 0 ? `+${boardTrend}` : boardTrend;
const countIcon = countTrend > 0 ? "🔥" : countTrend < 0 ? "❄️" : "➡️";
const historyText = (history || [])
.slice(0, 5)
.map((h) => `${h.date?.slice(5) || ""}: ${h.count}家/${h.maxBoard}`)
.join("<br>");
return `
<div style="font-weight:bold;margin-bottom:8px;color:#FFD700;font-size:14px;">
${name} ${countIcon}
</div>
<div style="margin:4px 0;">
<span style="color:#aaa">涨停家数:</span>
<span style="color:#fff;margin-left:8px;">${value[1]}家</span>
<span style="color:${countTrend >= 0 ? "#52c41a" : "#ff4d4f"};margin-left:8px;">(${countTrendText})</span>
</div>
<div style="margin:4px 0;">
<span style="color:#aaa">最高连板:</span>
<span style="color:#fff;margin-left:8px;">${value[0]}板</span>
<span style="color:${boardTrend >= 0 ? "#52c41a" : "#ff4d4f"};margin-left:8px;">(${boardTrendText})</span>
</div>
<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.1);">
<div style="color:#aaa;font-size:11px;margin-bottom:4px;">近5日趋势:</div>
<div style="font-size:11px;line-height:1.6;">${historyText}</div>
</div>
<div style="margin-top:8px;color:#888;font-size:10px;">点击查看详情</div>
`;
},
},
legend: {
show: false,
},
grid: {
left: "10%",
right: "8%",
top: "12%",
bottom: "8%",
containLabel: true,
},
xAxis: {
type: "value",
name: "辨识度(最高板)",
nameLocation: "middle",
nameGap: 28,
nameTextStyle: { color: "rgba(255, 255, 255, 0.6)", fontSize: 13 },
min: 0,
max: maxX,
interval: 1,
axisLine: { lineStyle: { color: "rgba(255, 255, 255, 0.2)" } },
axisLabel: {
color: "rgba(255, 255, 255, 0.6)",
fontSize: 12,
formatter: "{value}板",
},
splitLine: { lineStyle: { color: "rgba(255, 255, 255, 0.05)" } },
},
yAxis: {
type: "value",
name: "热度(家数)",
nameLocation: "middle",
nameGap: 40,
nameTextStyle: { color: "rgba(255, 255, 255, 0.6)", fontSize: 13 },
min: 0,
max: maxY,
axisLine: { show: false },
axisLabel: { color: "rgba(255, 255, 255, 0.6)", fontSize: 12 },
splitLine: { lineStyle: { color: "rgba(255, 255, 255, 0.05)" } },
},
series,
};
};

View File

@@ -0,0 +1,9 @@
// ThemeCometChart 常量定义
// 板块状态配置
export const STATUS_CONFIG = {
rising: { name: "主升", color: "#FF4D4F" },
declining: { name: "退潮", color: "#52C41A" },
lurking: { name: "潜伏", color: "#1890FF" },
clustering: { name: "抱团", color: "#722ED1" },
};

View File

@@ -0,0 +1,5 @@
// ThemeCometChart 模块导出
export { STATUS_CONFIG } from "./constants";
export { generateChartOption } from "./chartOptions";
export { default as ThemeDetailModal } from "./ThemeDetailModal";

View File

@@ -151,7 +151,7 @@ const Community = () => {
return (
<Box minH="100vh" bg={bgColor}>
{/* 主内容区域 - padding 由 MainLayout 统一设置 */}
<Box ref={containerRef} pt={{ base: 3, md: 6 }} pb={{ base: 4, md: 8 }}>
<Box ref={containerRef} pt={0} pb={{ base: 4, md: 8 }}>
{/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */}
<Suspense fallback={
<Box mb={6} p={4} borderRadius="xl" bg="rgba(255,255,255,0.02)">

View File

@@ -1,7 +1,7 @@
/**
* 发票管理页面
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback } from "react";
import {
Box,
Flex,
@@ -31,38 +31,49 @@ import {
StatLabel,
StatNumber,
StatHelpText,
} from '@chakra-ui/react';
import { FileText, Plus, RefreshCw, Clock, CheckCircle, AlertCircle } from 'lucide-react';
import Card from '@components/Card/Card';
import CardHeader from '@components/Card/CardHeader';
import { InvoiceCard, InvoiceApplyModal } from '@components/Invoice';
} from "@chakra-ui/react";
import {
FileText,
Plus,
RefreshCw,
Clock,
CheckCircle,
AlertCircle,
} from "lucide-react";
import Card from "@components/Card/Card";
import CardHeader from "@components/Card/CardHeader";
import { InvoiceCard, InvoiceApplyModal } from "@components/Invoice";
import {
getInvoiceList,
getInvoiceStats,
cancelInvoice,
downloadInvoice,
} from '@/services/invoiceService';
import type { InvoiceInfo, InvoiceStatus, InvoiceStats } from '@/types/invoice';
} from "@/services/invoiceService";
import type { InvoiceInfo, InvoiceStatus, InvoiceStats } from "@/types/invoice";
type TabType = 'all' | 'pending' | 'processing' | 'completed';
type TabType = "all" | "pending" | "processing" | "completed";
const tabConfig: { key: TabType; label: string; status?: InvoiceStatus }[] = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待处理', status: 'pending' },
{ key: 'processing', label: '处理中', status: 'processing' },
{ key: 'completed', label: '已完成', status: 'completed' },
{ key: "all", label: "全部" },
{ key: "pending", label: "待处理", status: "pending" },
{ key: "processing", label: "处理中", status: "processing" },
{ key: "completed", label: "已完成", status: "completed" },
];
export default function InvoicePage() {
interface InvoicePageProps {
embedded?: boolean;
}
export default function InvoicePage({ embedded = false }: InvoicePageProps) {
const [invoices, setInvoices] = useState<InvoiceInfo[]>([]);
const [stats, setStats] = useState<InvoiceStats | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabType>('all');
const [activeTab, setActiveTab] = useState<TabType>("all");
const [cancelingId, setCancelingId] = useState<string | null>(null);
const toast = useToast();
const textColor = useColorModeValue('gray.700', 'white');
const bgCard = useColorModeValue('white', 'gray.800');
const textColor = useColorModeValue("gray.700", "white");
const bgCard = useColorModeValue("white", "gray.800");
const cancelDialogRef = React.useRef<HTMLButtonElement>(null);
const {
@@ -87,11 +98,11 @@ export default function InvoicePage() {
setInvoices(res.data.list || []);
}
} catch (error) {
console.error('加载发票列表失败:', error);
console.error("加载发票列表失败:", error);
toast({
title: '加载失败',
description: '无法获取发票列表',
status: 'error',
title: "加载失败",
description: "无法获取发票列表",
status: "error",
duration: 3000,
});
} finally {
@@ -107,7 +118,7 @@ export default function InvoicePage() {
setStats(res.data);
}
} catch (error) {
console.error('加载发票统计失败:', error);
console.error("加载发票统计失败:", error);
}
}, []);
@@ -124,25 +135,25 @@ export default function InvoicePage() {
const res = await cancelInvoice(cancelingId);
if (res.code === 200) {
toast({
title: '取消成功',
status: 'success',
title: "取消成功",
status: "success",
duration: 2000,
});
loadInvoices();
loadStats();
} else {
toast({
title: '取消失败',
title: "取消失败",
description: res.message,
status: 'error',
status: "error",
duration: 3000,
});
}
} catch (error) {
toast({
title: '取消失败',
description: '网络错误',
status: 'error',
title: "取消失败",
description: "网络错误",
status: "error",
duration: 3000,
});
} finally {
@@ -156,7 +167,7 @@ export default function InvoicePage() {
try {
const blob = await downloadInvoice(invoice.id);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement("a");
a.href = url;
a.download = `发票_${invoice.invoiceNo || invoice.id}.pdf`;
document.body.appendChild(a);
@@ -165,9 +176,9 @@ export default function InvoicePage() {
window.URL.revokeObjectURL(url);
} catch (error) {
toast({
title: '下载失败',
description: '无法下载发票文件',
status: 'error',
title: "下载失败",
description: "无法下载发票文件",
status: "error",
duration: 3000,
});
}
@@ -186,11 +197,17 @@ export default function InvoicePage() {
};
return (
<Flex direction="column" pt={{ base: '120px', md: '75px' }}>
<Flex direction="column" pt={embedded ? 0 : { base: "120px", md: "75px" }}>
{/* 统计卡片 */}
{stats && (
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} mb={6}>
<Card p={4}>
<Card
p={4}
bg="transparent"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.3)"
backdropFilter="blur(10px)"
>
<Stat>
<StatLabel color="gray.500"></StatLabel>
<StatNumber color={textColor}>{stats.total}</StatNumber>
@@ -200,7 +217,13 @@ export default function InvoicePage() {
</StatHelpText>
</Stat>
</Card>
<Card p={4}>
<Card
p={4}
bg="transparent"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.3)"
backdropFilter="blur(10px)"
>
<Stat>
<StatLabel color="gray.500"></StatLabel>
<StatNumber color="yellow.500">{stats.pending}</StatNumber>
@@ -210,7 +233,13 @@ export default function InvoicePage() {
</StatHelpText>
</Stat>
</Card>
<Card p={4}>
<Card
p={4}
bg="transparent"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.3)"
backdropFilter="blur(10px)"
>
<Stat>
<StatLabel color="gray.500"></StatLabel>
<StatNumber color="blue.500">{stats.processing}</StatNumber>
@@ -220,7 +249,13 @@ export default function InvoicePage() {
</StatHelpText>
</Stat>
</Card>
<Card p={4}>
<Card
p={4}
bg="transparent"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.3)"
backdropFilter="blur(10px)"
>
<Stat>
<StatLabel color="gray.500"></StatLabel>
<StatNumber color="green.500">{stats.completed}</StatNumber>
@@ -234,7 +269,12 @@ export default function InvoicePage() {
)}
{/* 主内容区 */}
<Card>
<Card
bg="transparent"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.3)"
backdropFilter="blur(10px)"
>
<CardHeader>
<Flex justify="space-between" align="center" w="100%" mb={4}>
<HStack>
@@ -340,7 +380,9 @@ export default function InvoicePage() {
</AlertDialogHeader>
<AlertDialogBody></AlertDialogBody>
<AlertDialogBody>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelDialogRef} onClick={onCancelClose}>

View File

@@ -12,7 +12,7 @@ import {
Icon,
Button,
} from '@chakra-ui/react';
import { CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react';
import type { PaymentStatus as PaymentStatusType } from '../hooks/useWechatPay';
interface PaymentStatusProps {
@@ -94,7 +94,24 @@ export const PaymentStatus: React.FC<PaymentStatusProps> = ({
maxW="400px"
w="full"
textAlign="center"
position="relative"
>
{/* 右上角关闭按钮 */}
{onBack && (
<Box
position="absolute"
top={3}
right={3}
cursor="pointer"
p={1}
borderRadius="full"
_hover={{ bg: 'whiteAlpha.200' }}
onClick={onBack}
>
<Icon as={X} boxSize={5} color="gray.400" />
</Box>
)}
{/* 图标或加载动画 */}
{config.showSpinner ? (
<Spinner size="xl" color="gold.400" thickness="4px" />
@@ -135,27 +152,16 @@ export const PaymentStatus: React.FC<PaymentStatusProps> = ({
)}
{/* 操作按钮 */}
<VStack spacing={3} w="full" pt={4}>
{(status === 'failed' || status === 'cancelled') && onRetry && (
<Button
w="full"
colorScheme="yellow"
onClick={onRetry}
mt={4}
>
</Button>
)}
{onBack && (
<Button
w="full"
variant="ghost"
color="gray.400"
onClick={onBack}
>
</Button>
)}
</VStack>
{/* 支付中提示 */}
{status === 'paying' && (

File diff suppressed because it is too large Load Diff