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:
@@ -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`
|
||||
@@ -28,11 +33,11 @@ const glow = keyframes`
|
||||
* 事件数据接口
|
||||
*/
|
||||
export interface CalendarEventData {
|
||||
date: string; // YYYYMMDD 格式
|
||||
count: number; // 涨停数
|
||||
topSector: string; // 最热概念
|
||||
eventCount?: number; // 未来事件数
|
||||
indexChange?: number; // 上证指数涨跌幅
|
||||
date: string; // YYYYMMDD 格式
|
||||
count: number; // 涨停数
|
||||
topSector: string; // 最热概念
|
||||
eventCount?: number; // 未来事件数
|
||||
indexChange?: number; // 上证指数涨跌幅
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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,65 +258,57 @@ 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>
|
||||
${ztDataHTML}
|
||||
${eventCountHTML}
|
||||
<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,121 +368,135 @@ export const FullCalendarPro: React.FC<FullCalendarProProps> = ({
|
||||
}, [dataMap]);
|
||||
|
||||
// 处理日期点击
|
||||
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]);
|
||||
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]
|
||||
);
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = useCallback((arg: EventClickArg) => {
|
||||
const { extendedProps } = arg.event;
|
||||
if (arg.event.start && arg.event.end) {
|
||||
onEventClick?.({
|
||||
title: arg.event.title,
|
||||
start: arg.event.start,
|
||||
end: arg.event.end,
|
||||
dates: extendedProps.dates as string[],
|
||||
});
|
||||
}
|
||||
}, [onEventClick]);
|
||||
const handleEventClick = useCallback(
|
||||
(arg: EventClickArg) => {
|
||||
const { extendedProps } = arg.event;
|
||||
if (arg.event.start && arg.event.end) {
|
||||
onEventClick?.({
|
||||
title: arg.event.title,
|
||||
start: arg.event.start,
|
||||
end: arg.event.end,
|
||||
dates: extendedProps.dates as string[],
|
||||
});
|
||||
}
|
||||
},
|
||||
[onEventClick]
|
||||
);
|
||||
|
||||
// 处理月份变化
|
||||
const handleDatesSet = useCallback((arg: DatesSetArg) => {
|
||||
const visibleDate = arg.view.currentStart;
|
||||
onMonthChange?.(visibleDate.getFullYear(), visibleDate.getMonth() + 1);
|
||||
}, [onMonthChange]);
|
||||
const handleDatesSet = useCallback(
|
||||
(arg: DatesSetArg) => {
|
||||
const visibleDate = arg.view.currentStart;
|
||||
onMonthChange?.(visibleDate.getFullYear(), visibleDate.getMonth() + 1);
|
||||
},
|
||||
[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 { extendedProps } = arg.event;
|
||||
const daysCount = extendedProps.daysCount as number;
|
||||
const totalCount = extendedProps.totalCount as number;
|
||||
const textColor = (extendedProps.textColor as string) || '#fff';
|
||||
const gradient = extendedProps.gradient as string;
|
||||
const borderColor = extendedProps.borderColor as string;
|
||||
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 gradient = extendedProps.gradient as string;
|
||||
const borderColor = extendedProps.borderColor as string;
|
||||
|
||||
return (
|
||||
<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>
|
||||
</VStack>
|
||||
}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="rgba(15, 15, 22, 0.95)"
|
||||
border="1px solid rgba(212, 175, 55, 0.3)"
|
||||
borderRadius="md"
|
||||
>
|
||||
<Box
|
||||
w="100%"
|
||||
h="26px"
|
||||
bg={gradient}
|
||||
borderRadius="lg"
|
||||
border={`1px solid ${borderColor}`}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'scale(1.02)',
|
||||
boxShadow: `0 0 12px ${borderColor}`,
|
||||
}}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
<VStack spacing={1} align="start" p={1}>
|
||||
<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"
|
||||
hasArrow
|
||||
bg="rgba(15, 15, 22, 0.95)"
|
||||
border="1px solid rgba(212, 175, 55, 0.3)"
|
||||
borderRadius="md"
|
||||
>
|
||||
{/* 闪光效果 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bgGradient="linear(to-r, transparent, rgba(255,255,255,0.3), transparent)"
|
||||
backgroundSize="200% 100%"
|
||||
animation={`${shimmer} 3s linear infinite`}
|
||||
opacity={0.5}
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
noOfLines={1}
|
||||
px={3}
|
||||
w="100%"
|
||||
h="18px"
|
||||
bg={gradient}
|
||||
borderRadius="md"
|
||||
border={`1px solid ${borderColor}`}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: "scale(1.02)",
|
||||
boxShadow: `0 0 12px ${borderColor}`,
|
||||
}}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
>
|
||||
{arg.event.title}
|
||||
{daysCount > 1 && (
|
||||
<Text as="span" fontSize="xs" ml={1} opacity={0.8}>
|
||||
({daysCount}天)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}, []);
|
||||
{/* 闪光效果 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bgGradient="linear(to-r, transparent, rgba(255,255,255,0.3), transparent)"
|
||||
backgroundSize="200% 100%"
|
||||
animation={`${shimmer} 3s linear infinite`}
|
||||
opacity={0.5}
|
||||
/>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
noOfLines={1}
|
||||
px={2}
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
>
|
||||
{arg.event.title}
|
||||
{daysCount > 1 && (
|
||||
<Text as="span" fontSize="2xs" ml={1} opacity={0.8}>
|
||||
({daysCount}天)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</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",
|
||||
},
|
||||
".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': {
|
||||
"&: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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
投资日历
|
||||
|
||||
@@ -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,35 +47,38 @@ const NavbarActions = memo(({
|
||||
) : isAuthenticated && user ? (
|
||||
// 已登录状态 - 用户菜单 + 功能菜单排列
|
||||
<HStack spacing={{ base: 2, md: 3 }}>
|
||||
{/* 投资日历 - 仅大屏显示 */}
|
||||
{isDesktop && <CalendarButton />}
|
||||
{/* 投资日历 - 暂时注释 */}
|
||||
{/* {isDesktop && <CalendarButton />} */}
|
||||
|
||||
{/* 头像区域 - 响应式 */}
|
||||
{/* 桌面端布局:[我的空间] | [头像][用户名] */}
|
||||
{isDesktop ? (
|
||||
<DesktopUserMenu user={user} />
|
||||
) : (
|
||||
<TabletUserMenu
|
||||
user={user}
|
||||
handleLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 头像右侧的菜单 - 响应式(互斥逻辑,确保只渲染一个) */}
|
||||
{isDesktop ? (
|
||||
// 桌面端:个人中心下拉菜单
|
||||
<PersonalCenterMenu user={user} handleLogout={handleLogout} />
|
||||
<>
|
||||
<MySpaceButton />
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
h="24px"
|
||||
borderColor="gray.300"
|
||||
/>
|
||||
<DesktopUserMenu user={user} handleLogout={handleLogout} />
|
||||
</>
|
||||
) : isTablet ? (
|
||||
// 平板端:MoreMenu 下拉菜单
|
||||
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
||||
// 平板端:头像 + MoreMenu
|
||||
<>
|
||||
<TabletUserMenu user={user} handleLogout={handleLogout} />
|
||||
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
||||
</>
|
||||
) : (
|
||||
// 移动端:汉堡菜单(打开抽屉)
|
||||
<IconButton
|
||||
icon={<Menu size={20} />}
|
||||
variant="ghost"
|
||||
onClick={onMenuOpen}
|
||||
aria-label="打开菜单"
|
||||
size="md"
|
||||
/>
|
||||
// 移动端:头像 + 汉堡菜单
|
||||
<>
|
||||
<TabletUserMenu user={user} handleLogout={handleLogout} />
|
||||
<IconButton
|
||||
icon={<Menu size={20} />}
|
||||
variant="ghost"
|
||||
onClick={onMenuOpen}
|
||||
aria-label="打开菜单"
|
||||
size="md"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
>
|
||||
<PopoverArrow bg={popoverBg} />
|
||||
<TooltipContent
|
||||
subscriptionInfo={subscriptionInfo}
|
||||
onNavigate={handleAvatarClick}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
maxW="80px"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
color="gray.700"
|
||||
>
|
||||
{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}
|
||||
size="md"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -6,34 +6,90 @@
|
||||
|
||||
```
|
||||
src/views/Community/
|
||||
├── index.js # 页面入口
|
||||
├── components/ # 组件目录
|
||||
│ ├── SearchFilters/ # 搜索筛选模块
|
||||
├── index.js # 页面入口
|
||||
├── components/ # 组件目录
|
||||
│ ├── SearchFilters/ # 搜索筛选模块
|
||||
│ │ ├── CompactSearchBox.js
|
||||
│ │ ├── CompactSearchBox.css
|
||||
│ │ ├── TradingTimeFilter.js
|
||||
│ │ └── index.js
|
||||
│ ├── EventCard/ # 事件卡片模块
|
||||
│ │ ├── atoms/ # 原子组件
|
||||
│ ├── EventCard/ # 事件卡片模块
|
||||
│ │ ├── atoms/ # 原子组件
|
||||
│ │ │ ├── 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
|
||||
│ ├── HotEvents/ # 热点事件模块
|
||||
│ │ ├── HotEvents.js
|
||||
│ │ ├── HotEvents.css
|
||||
│ │ ├── HotEventsSection.js
|
||||
│ │ └── index.js
|
||||
│ ├── DynamicNews/ # 动态新闻模块
|
||||
│ │ ├── layouts/
|
||||
│ ├── DynamicNews/ # 动态新闻模块
|
||||
│ │ ├── 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/ # 事件详情弹窗模块
|
||||
│ │ ├── EventDetailModal.tsx
|
||||
│ │ ├── EventDetailModal.less
|
||||
│ │ └── index.ts
|
||||
│ └── HeroPanel.js # 英雄面板(独立组件)
|
||||
└── hooks/ # 页面级 Hooks
|
||||
│ ├── 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` | 主线时间线视图 |
|
||||
|
||||
### Hooks(hooks/)
|
||||
|
||||
@@ -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/` 原子组件目录
|
||||
|
||||
@@ -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>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack spacing={2}>
|
||||
<Clock size={18} color={PROFESSIONAL_COLORS.gold[500]} />
|
||||
<Text fontSize={isMobile ? "md" : "lg"} fontWeight="bold" bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
|
||||
</HStack>
|
||||
{/* 模式切换按钮(移动端隐藏) */}
|
||||
{!isMobile && <ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />}
|
||||
</HStack>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
]
|
||||
? [{
|
||||
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}`,
|
||||
})),
|
||||
},
|
||||
]
|
||||
? [{
|
||||
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}`,
|
||||
})),
|
||||
},
|
||||
]
|
||||
? [{
|
||||
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}
|
||||
|
||||
@@ -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);
|
||||
return () => clearInterval(interval);
|
||||
const interval = setInterval(() => fetchStats(true), 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStats]);
|
||||
|
||||
// 获取显示列表(取前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;
|
||||
}
|
||||
}, [selectedDate, fetchStats]);
|
||||
|
||||
const handleDateChange = (e) => {
|
||||
setSelectedDate(e.target.value);
|
||||
};
|
||||
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}>
|
||||
<Box
|
||||
w="4px"
|
||||
h="20px"
|
||||
bg="linear-gradient(180deg, #7C3AED, #06B6D4)"
|
||||
borderRadius="full"
|
||||
boxShadow="0 0 10px rgba(124, 58, 237, 0.5)"
|
||||
/>
|
||||
<Text fontSize="md" fontWeight="bold" color="white" letterSpacing="wide">
|
||||
{isToday ? '今日统计' : '历史统计'}
|
||||
</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>
|
||||
<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}
|
||||
>
|
||||
<ReloadOutlined
|
||||
style={{
|
||||
color: 'rgba(6, 182, 212, 0.8)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
spin={refreshing}
|
||||
/>
|
||||
</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>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Box
|
||||
w="3px"
|
||||
h="14px"
|
||||
bg="linear-gradient(180deg, #7C3AED, #06B6D4)"
|
||||
borderRadius="full"
|
||||
boxShadow="0 0 8px rgba(124, 58, 237, 0.5)"
|
||||
/>
|
||||
<Text fontSize="sm" fontWeight="bold" color="white">
|
||||
事件 TOP 排行
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 内容区域 - 固定高度滚动 */}
|
||||
{/* 内容区域 - 固定高度显示8个,向上滚动轮播 */}
|
||||
<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)' },
|
||||
}}
|
||||
h={`${ITEM_HEIGHT * VISIBLE_COUNT}px`}
|
||||
maxH={`${ITEM_HEIGHT * VISIBLE_COUNT}px`}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
>
|
||||
<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}>
|
||||
暂无数据
|
||||
</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}>
|
||||
暂无数据
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
</VStack>
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</MotionBox>
|
||||
) : (
|
||||
<Center h="100%">
|
||||
<VStack spacing={1}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
暂无数据
|
||||
</Text>
|
||||
{error && (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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
132
src/views/Community/components/HeroPanel/columns/eventColumns.js
Normal file
132
src/views/Community/components/HeroPanel/columns/eventColumns.js
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// HeroPanel 表格列相关导出
|
||||
export * from './renderers';
|
||||
export { createStockColumns } from './stockColumns';
|
||||
export { createSectorColumns } from './sectorColumns';
|
||||
export { createZtStockColumns } from './ztStockColumns';
|
||||
export { createEventColumns } from './eventColumns';
|
||||
184
src/views/Community/components/HeroPanel/columns/renderers.js
Normal file
184
src/views/Community/components/HeroPanel/columns/renderers.js
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
233
src/views/Community/components/HeroPanel/columns/stockColumns.js
Normal file
233
src/views/Community/components/HeroPanel/columns/stockColumns.js
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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}>
|
||||
<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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
152
src/views/Community/components/HeroPanel/components/InfoModal.js
Normal file
152
src/views/Community/components/HeroPanel/components/InfoModal.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
68
src/views/Community/components/HeroPanel/constants/index.js
Normal file
68
src/views/Community/components/HeroPanel/constants/index.js
Normal 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月",
|
||||
];
|
||||
2
src/views/Community/components/HeroPanel/hooks/index.js
Normal file
2
src/views/Community/components/HeroPanel/hooks/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// HeroPanel Hooks 导出
|
||||
export { useDetailModalState } from "./useDetailModalState";
|
||||
@@ -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;
|
||||
12
src/views/Community/components/HeroPanel/index.js
Normal file
12
src/views/Community/components/HeroPanel/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// HeroPanel 模块入口
|
||||
// 导出主组件
|
||||
export { default } from '../HeroPanel';
|
||||
|
||||
// 导出常量
|
||||
export * from './constants';
|
||||
|
||||
// 导出工具函数
|
||||
export * from './utils';
|
||||
|
||||
// 导出表格渲染器
|
||||
export * from './columns';
|
||||
159
src/views/Community/components/HeroPanel/styles/animations.css
Normal file
159
src/views/Community/components/HeroPanel/styles/animations.css
Normal 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;
|
||||
}
|
||||
84
src/views/Community/components/HeroPanel/utils/index.js
Normal file
84
src/views/Community/components/HeroPanel/utils/index.js
Normal 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" };
|
||||
};
|
||||
348
src/views/Community/components/MarketOverviewBanner.js
Normal file
348
src/views/Community/components/MarketOverviewBanner.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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级" },
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
// CompactSearchBox 模块导出
|
||||
|
||||
export { SORT_OPTIONS, IMPORTANCE_OPTIONS } from "./constants";
|
||||
export { findIndustryPath, inferTimeRangeFromFilters, buildFilterParams } from "./utils";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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", // 盘后
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// TradingTimeFilter 模块导出
|
||||
|
||||
export { TIME_BOUNDARIES, TRADING_SESSIONS } from "./constants";
|
||||
export {
|
||||
getCurrentTradingSession,
|
||||
getPrevTradingDay,
|
||||
generateTimeRangeConfig,
|
||||
disabledDate,
|
||||
disabledTime,
|
||||
} from "./utils";
|
||||
@@ -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 [];
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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' }} />
|
||||
</Box>
|
||||
<VStack align="start" spacing={0} flex={1}>
|
||||
<HStack>
|
||||
<Text fontSize="lg" fontWeight="bold" color="#FFD700">
|
||||
连板情绪监测
|
||||
<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>
|
||||
{loading && <Spinner size="sm" color="yellow.400" />}
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="whiteAlpha.500">
|
||||
{currentSliderDate || currentData.currentDate}
|
||||
</Text>
|
||||
</VStack>
|
||||
</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>
|
||||
<HStack spacing={4}>
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" color="#FFD700">
|
||||
连板情绪监测
|
||||
</Text>
|
||||
{loading && <Spinner size="sm" color="yellow.400" />}
|
||||
</HStack>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
143
src/views/Community/components/ThemeCometChart/chartOptions.js
Normal file
143
src/views/Community/components/ThemeCometChart/chartOptions.js
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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" },
|
||||
};
|
||||
5
src/views/Community/components/ThemeCometChart/index.js
Normal file
5
src/views/Community/components/ThemeCometChart/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// ThemeCometChart 模块导出
|
||||
|
||||
export { STATUS_CONFIG } from "./constants";
|
||||
export { generateChartOption } from "./chartOptions";
|
||||
export { default as ThemeDetailModal } from "./ThemeDetailModal";
|
||||
@@ -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)">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
重新支付
|
||||
</Button>
|
||||
)}
|
||||
{onBack && (
|
||||
<Button
|
||||
w="full"
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
{(status === 'failed' || status === 'cancelled') && onRetry && (
|
||||
<Button
|
||||
w="full"
|
||||
colorScheme="yellow"
|
||||
onClick={onRetry}
|
||||
mt={4}
|
||||
>
|
||||
重新支付
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 支付中提示 */}
|
||||
{status === 'paying' && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user