Merge branch 'feature_bugfix/251217_stock' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251217_stock
This commit is contained in:
@@ -74,9 +74,9 @@ module.exports = {
|
|||||||
priority: 20,
|
priority: 20,
|
||||||
reuseExistingChunk: true,
|
reuseExistingChunk: true,
|
||||||
},
|
},
|
||||||
// 日期/日历库
|
// 日期库
|
||||||
calendar: {
|
calendar: {
|
||||||
test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar)[\\/]/,
|
test: /[\\/]node_modules[\\/](dayjs|date-fns)[\\/]/,
|
||||||
name: 'calendar-lib',
|
name: 'calendar-lib',
|
||||||
priority: 18,
|
priority: 18,
|
||||||
reuseExistingChunk: true,
|
reuseExistingChunk: true,
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -15,14 +15,6 @@
|
|||||||
"@fontsource/raleway": "^4.5.0",
|
"@fontsource/raleway": "^4.5.0",
|
||||||
"@fontsource/roboto": "^4.5.0",
|
"@fontsource/roboto": "^4.5.0",
|
||||||
"@reduxjs/toolkit": "^2.9.2",
|
"@reduxjs/toolkit": "^2.9.2",
|
||||||
"@splidejs/react-splide": "^0.7.12",
|
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
|
||||||
"@tippyjs/react": "^4.2.6",
|
|
||||||
"@visx/responsive": "^3.12.0",
|
|
||||||
"@visx/scale": "^3.12.0",
|
|
||||||
"@visx/text": "^3.12.0",
|
|
||||||
"@visx/visx": "^3.12.0",
|
|
||||||
"@visx/wordcloud": "^3.12.0",
|
|
||||||
"antd": "^5.27.4",
|
"antd": "^5.27.4",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
@@ -32,34 +24,26 @@
|
|||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"echarts-wordcloud": "^2.1.0",
|
"echarts-wordcloud": "^2.1.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"fullcalendar": "^5.9.0",
|
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"klinecharts": "^10.0.0-beta1",
|
"klinecharts": "^10.0.0-beta1",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"posthog-js": "^1.295.0",
|
"posthog-js": "^1.295.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-custom-scrollbars-2": "^4.4.0",
|
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-force-graph-3d": "^1.29.0",
|
|
||||||
"react-github-btn": "^1.2.1",
|
"react-github-btn": "^1.2.1",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^4.12.0",
|
||||||
"react-input-pin-code": "^1.1.5",
|
"react-input-pin-code": "^1.1.5",
|
||||||
"react-is": "^19.0.0",
|
"react-is": "^19.0.0",
|
||||||
"react-just-parallax": "^3.1.16",
|
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-responsive": "^10.0.1",
|
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
"react-scroll": "^1.8.4",
|
"react-scroll": "^1.8.4",
|
||||||
"react-scroll-into-view": "^2.1.3",
|
|
||||||
"react-table": "^7.7.0",
|
"react-table": "^7.7.0",
|
||||||
"react-to-print": "^3.0.3",
|
"react-to-print": "^3.0.3",
|
||||||
"recharts": "^3.1.2",
|
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sass": "^1.49.9",
|
"sass": "^1.49.9",
|
||||||
"socket.io-client": "^4.7.4",
|
"socket.io-client": "^4.7.4",
|
||||||
"styled-components": "^5.3.11",
|
|
||||||
"stylis": "^4.0.10",
|
"stylis": "^4.0.10",
|
||||||
"stylis-plugin-rtl": "^2.1.1",
|
"stylis-plugin-rtl": "^2.1.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
/*!
|
|
||||||
FullCalendar v5.6.0
|
|
||||||
Docs & License: https://fullcalendar.io/
|
|
||||||
(c) 2020 Adam Shaw
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Creative Tim Changes
|
|
||||||
|
|
||||||
.fc-theme-standard .fc-scrollgrid,
|
|
||||||
.fc-scrollgrid {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-theme-standard td,
|
|
||||||
.fc-theme-standard th {
|
|
||||||
border-color: $gray-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-theme-standard thead tr th{
|
|
||||||
border-right: none;
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-theme-standard td{
|
|
||||||
&:last-child{
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-theme-standard tr{
|
|
||||||
&:last-child td{
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-h-event{
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-daygrid-block-event .fc-event-time,
|
|
||||||
.fc-daygrid-block-event .fc-event-title{
|
|
||||||
font-weight: $font-weight-bold;
|
|
||||||
padding: $fc-event-title-padding-y $fc-event-title-padding-x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc .fc-daygrid-body-natural .fc-daygrid-day-events{
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc .fc-col-header-cell-cushion{
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
font-weight: $font-weight-bold;
|
|
||||||
color: $gray-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc .fc-daygrid-day-number{
|
|
||||||
color: $gray-700;
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
font-weight: $font-weight-bold;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc .fc-toolbar-title{
|
|
||||||
font-size: $font-size-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc .fc-button{
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
padding-top: $spacer*0.25;
|
|
||||||
box-shadow: $btn-box-shadow;
|
|
||||||
transition: $btn-transition;
|
|
||||||
.fc-icon{
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc .fc-button-primary{
|
|
||||||
background-color: $secondary;
|
|
||||||
border-color: $secondary;
|
|
||||||
&:hover,
|
|
||||||
&:focus,
|
|
||||||
&:not(:disabled):active,
|
|
||||||
&:not(:disabled):active:focus{
|
|
||||||
transform: $btn-hover-transform;
|
|
||||||
box-shadow: $btn-hover-box-shadow;
|
|
||||||
background-color: $secondary;
|
|
||||||
border-color: $secondary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-event.fc-daygrid-event{
|
|
||||||
border-radius: $fc-daygrid-event-border-radius;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,6 @@
|
|||||||
@import "choices";
|
@import "choices";
|
||||||
@import "glidejs";
|
@import "glidejs";
|
||||||
@import "green-audio-player";
|
@import "green-audio-player";
|
||||||
@import "fullcalendar";
|
|
||||||
@import "fullcalendar-extend";
|
|
||||||
@import "datatable";
|
@import "datatable";
|
||||||
@import "datatable-extend";
|
@import "datatable-extend";
|
||||||
@import "dragula";
|
@import "dragula";
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import PropTypes from 'prop-types';
|
|||||||
let widgetInitialized = false;
|
let widgetInitialized = false;
|
||||||
let idleCallbackId = null;
|
let idleCallbackId = null;
|
||||||
|
|
||||||
|
// ⚡ STOMP WebSocket 重连限制(最多重试 3 次)
|
||||||
|
let stompRetryCount = 0;
|
||||||
|
const MAX_STOMP_RETRIES = 3;
|
||||||
|
|
||||||
const BytedeskWidget = ({
|
const BytedeskWidget = ({
|
||||||
config,
|
config,
|
||||||
autoLoad = true,
|
autoLoad = true,
|
||||||
@@ -118,18 +122,79 @@ const BytedeskWidget = ({
|
|||||||
// 5 秒后停止监听(避免性能问题)
|
// 5 秒后停止监听(避免性能问题)
|
||||||
setTimeout(() => observer.disconnect(), 5000);
|
setTimeout(() => observer.disconnect(), 5000);
|
||||||
|
|
||||||
// ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能)
|
// ⚡ 屏蔽 STOMP WebSocket 相关日志(控制台降噪)
|
||||||
|
// STOMP 连接失败是因为后端服务配置问题,不影响客服功能(使用 HTTP 轮询降级)
|
||||||
|
const isStompLog = (args) => {
|
||||||
|
const msg = args.map(a => String(a)).join(' ');
|
||||||
|
// 只屏蔽包含 /stomp 路径的日志,避免误屏蔽其他 WebSocket 日志
|
||||||
|
return msg.includes('/stomp') || msg.includes('stomp onWebSocketError');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 屏蔽 console.error
|
||||||
const originalConsoleError = console.error;
|
const originalConsoleError = console.error;
|
||||||
console.error = function(...args) {
|
console.error = function(...args) {
|
||||||
const errorMsg = args.join(' ');
|
if (isStompLog(args)) return;
|
||||||
if (errorMsg.includes('/stomp') ||
|
|
||||||
errorMsg.includes('stomp onWebSocketError') ||
|
|
||||||
(errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
originalConsoleError.apply(console, args);
|
originalConsoleError.apply(console, args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 屏蔽 console.warn
|
||||||
|
const originalConsoleWarn = console.warn;
|
||||||
|
console.warn = function(...args) {
|
||||||
|
if (isStompLog(args)) return;
|
||||||
|
originalConsoleWarn.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 屏蔽 console.log(仅屏蔽 STOMP 相关)
|
||||||
|
const originalConsoleLog = console.log;
|
||||||
|
console.log = function(...args) {
|
||||||
|
if (isStompLog(args)) return;
|
||||||
|
originalConsoleLog.apply(console, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ⚡ 限制 STOMP WebSocket 重连次数(最多 3 次)
|
||||||
|
// 通过代理 WebSocket 构造函数实现
|
||||||
|
const OriginalWebSocket = window.WebSocket;
|
||||||
|
const WebSocketProxy = function(url, protocols) {
|
||||||
|
// 检查是否是 STOMP 连接
|
||||||
|
if (url && url.includes('/stomp')) {
|
||||||
|
stompRetryCount++;
|
||||||
|
|
||||||
|
// 超过最大重试次数,阻止连接
|
||||||
|
if (stompRetryCount > MAX_STOMP_RETRIES) {
|
||||||
|
// 返回一个假的 WebSocket 对象,不实际连接
|
||||||
|
const fakeWs = Object.create(OriginalWebSocket.prototype);
|
||||||
|
fakeWs.url = url;
|
||||||
|
fakeWs.readyState = 3; // CLOSED
|
||||||
|
fakeWs.send = () => {};
|
||||||
|
fakeWs.close = () => {};
|
||||||
|
fakeWs.addEventListener = () => {};
|
||||||
|
fakeWs.removeEventListener = () => {};
|
||||||
|
fakeWs.onopen = null;
|
||||||
|
fakeWs.onclose = null;
|
||||||
|
fakeWs.onerror = null;
|
||||||
|
fakeWs.onmessage = null;
|
||||||
|
// 异步触发 onerror 和 onclose
|
||||||
|
setTimeout(() => {
|
||||||
|
if (fakeWs.onerror) fakeWs.onerror(new Event('error'));
|
||||||
|
if (fakeWs.onclose) fakeWs.onclose(new CloseEvent('close', { code: 1006 }));
|
||||||
|
}, 0);
|
||||||
|
return fakeWs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常创建 WebSocket
|
||||||
|
return protocols
|
||||||
|
? new OriginalWebSocket(url, protocols)
|
||||||
|
: new OriginalWebSocket(url);
|
||||||
|
};
|
||||||
|
// 保留原始 WebSocket 的静态属性和原型链
|
||||||
|
WebSocketProxy.prototype = OriginalWebSocket.prototype;
|
||||||
|
WebSocketProxy.CONNECTING = OriginalWebSocket.CONNECTING;
|
||||||
|
WebSocketProxy.OPEN = OriginalWebSocket.OPEN;
|
||||||
|
WebSocketProxy.CLOSING = OriginalWebSocket.CLOSING;
|
||||||
|
WebSocketProxy.CLOSED = OriginalWebSocket.CLOSED;
|
||||||
|
window.WebSocket = WebSocketProxy;
|
||||||
|
|
||||||
if (onLoad) {
|
if (onLoad) {
|
||||||
onLoad(bytedesk);
|
onLoad(bytedesk);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getImportanceConfig } from '@constants/importanceLevels';
|
|||||||
import { eventService } from '@services/eventService';
|
import { eventService } from '@services/eventService';
|
||||||
import { useEventStocks } from '@components/Charts/Stock';
|
import { useEventStocks } from '@components/Charts/Stock';
|
||||||
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
|
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
|
||||||
|
import { selectSubscriptionInfo } from '@store/slices/subscriptionSlice';
|
||||||
import { useAuth } from '@contexts/AuthContext';
|
import { useAuth } from '@contexts/AuthContext';
|
||||||
import EventHeaderInfo from './EventHeaderInfo';
|
import EventHeaderInfo from './EventHeaderInfo';
|
||||||
import CompactMetaBar from './CompactMetaBar';
|
import CompactMetaBar from './CompactMetaBar';
|
||||||
@@ -102,6 +103,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type)
|
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type)
|
||||||
const userTier = user?.subscription_type || 'free';
|
const userTier = user?.subscription_type || 'free';
|
||||||
|
|
||||||
|
// 获取订阅信息,用于判断会员是否过期
|
||||||
|
const subscriptionInfo = useSelector(selectSubscriptionInfo);
|
||||||
|
const isSubscriptionExpired = subscriptionInfo.type !== 'free' && !subscriptionInfo.is_active;
|
||||||
|
|
||||||
// 从 Redux 读取关注状态
|
// 从 Redux 读取关注状态
|
||||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||||
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
|
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
|
||||||
@@ -111,12 +116,16 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
const [fullEventDetail, setFullEventDetail] = useState(null);
|
const [fullEventDetail, setFullEventDetail] = useState(null);
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
|
||||||
// 权限判断函数
|
// 权限判断函数 - 会员过期时视为无权限
|
||||||
const hasAccess = useCallback((requiredTier) => {
|
const hasAccess = useCallback((requiredTier) => {
|
||||||
|
// 会员已过期,视为无权限
|
||||||
|
if (isSubscriptionExpired) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const tierLevel = { free: 0, pro: 1, max: 2 };
|
const tierLevel = { free: 0, pro: 1, max: 2 };
|
||||||
const result = tierLevel[userTier] >= tierLevel[requiredTier];
|
const result = tierLevel[userTier] >= tierLevel[requiredTier];
|
||||||
return result;
|
return result;
|
||||||
}, [userTier]);
|
}, [userTier, isSubscriptionExpired]);
|
||||||
|
|
||||||
// 升级弹窗状态
|
// 升级弹窗状态
|
||||||
const [upgradeModal, setUpgradeModal] = useState({
|
const [upgradeModal, setUpgradeModal] = useState({
|
||||||
@@ -169,14 +178,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
// 子区块折叠状态管理 - 使用 useReducer 整合
|
// 子区块折叠状态管理 - 使用 useReducer 整合
|
||||||
const [sectionState, dispatchSection] = useReducer(sectionReducer, initialSectionState);
|
const [sectionState, dispatchSection] = useReducer(sectionReducer, initialSectionState);
|
||||||
|
|
||||||
// 锁定点击处理 - 弹出升级弹窗
|
// 锁定点击处理 - 弹出升级弹窗(会员过期时显示续费提示)
|
||||||
const handleLockedClick = useCallback((featureName, requiredLevel) => {
|
const handleLockedClick = useCallback((featureName, requiredLevel) => {
|
||||||
setUpgradeModal({
|
setUpgradeModal({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
requiredLevel,
|
requiredLevel: isSubscriptionExpired ? subscriptionInfo.type : requiredLevel,
|
||||||
featureName
|
featureName: isSubscriptionExpired ? `${featureName}(会员已过期,请续费)` : featureName
|
||||||
});
|
});
|
||||||
}, []);
|
}, [isSubscriptionExpired, subscriptionInfo.type]);
|
||||||
|
|
||||||
// 关闭升级弹窗
|
// 关闭升级弹窗
|
||||||
const handleCloseUpgradeModal = useCallback(() => {
|
const handleCloseUpgradeModal = useCallback(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// 相关概念区组件 - 便当盒网格布局
|
// 相关概念区组件 - 便当盒网格布局
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Flex,
|
Flex,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { getApiBase } from '@utils/apiConfig';
|
import { getApiBase } from '@utils/apiConfig';
|
||||||
|
import { selectSubscriptionInfo } from '@store/slices/subscriptionSlice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单个概念卡片组件(便当盒样式)
|
* 单个概念卡片组件(便当盒样式)
|
||||||
@@ -100,6 +102,10 @@ const RelatedConceptsSection = ({
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 获取订阅信息,用于判断会员是否过期
|
||||||
|
const subscriptionInfo = useSelector(selectSubscriptionInfo);
|
||||||
|
const isSubscriptionExpired = subscriptionInfo.type !== 'free' && !subscriptionInfo.is_active;
|
||||||
|
|
||||||
// 颜色配置 - 使用深色主题固定颜色
|
// 颜色配置 - 使用深色主题固定颜色
|
||||||
const sectionBg = 'transparent';
|
const sectionBg = 'transparent';
|
||||||
const headingColor = '#e2e8f0';
|
const headingColor = '#e2e8f0';
|
||||||
@@ -107,7 +113,7 @@ const RelatedConceptsSection = ({
|
|||||||
const countBadgeBg = '#3182ce';
|
const countBadgeBg = '#3182ce';
|
||||||
const countBadgeColor = '#ffffff';
|
const countBadgeColor = '#ffffff';
|
||||||
|
|
||||||
// 获取相关概念
|
// 获取相关概念 - 如果被锁定或会员过期则跳过 API 请求
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchConcepts = async () => {
|
const fetchConcepts = async () => {
|
||||||
if (!eventId) {
|
if (!eventId) {
|
||||||
@@ -115,6 +121,13 @@ const RelatedConceptsSection = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果被锁定或会员已过期,不发起 API 请求
|
||||||
|
if (isLocked || isSubscriptionExpired) {
|
||||||
|
setLoading(false);
|
||||||
|
setConcepts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -152,7 +165,7 @@ const RelatedConceptsSection = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchConcepts();
|
fetchConcepts();
|
||||||
}, [eventId]);
|
}, [eventId, isLocked, isSubscriptionExpired]);
|
||||||
|
|
||||||
// 跳转到概念中心
|
// 跳转到概念中心
|
||||||
const handleNavigate = (concept) => {
|
const handleNavigate = (concept) => {
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export default function HomeNavbar() {
|
|||||||
borderColor={navbarBorder}
|
borderColor={navbarBorder}
|
||||||
py={{ base: 2, md: 3 }}
|
py={{ base: 2, md: 3 }}
|
||||||
>
|
>
|
||||||
<Container maxW="container.xl" px={{ base: 3, md: 4 }} style={{ paddingRight: 'max(16px, env(safe-area-inset-right))' }}>
|
<Box px={{ base: 4, md: 6, lg: '80px' }}>
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
{/* Logo - 价小前投研 */}
|
{/* Logo - 价小前投研 */}
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
@@ -177,7 +177,7 @@ export default function HomeNavbar() {
|
|||||||
followingEvents={followingEvents}
|
followingEvents={followingEvents}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Box>
|
||||||
|
|
||||||
{/* 移动端抽屉菜单 (Phase 5 优化) */}
|
{/* 移动端抽屉菜单 (Phase 5 优化) */}
|
||||||
<MobileDrawer
|
<MobileDrawer
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
/**
|
|
||||||
* StockChartKLineModal - K 线图表模态框组件
|
|
||||||
*
|
|
||||||
* 使用 KLineChart 库实现的专业金融图表组件
|
|
||||||
* 替换原有的 ECharts 实现(StockChartAntdModal.js)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useMemo } from 'react';
|
|
||||||
import { Modal, Button, Radio, Select, Space, Spin, Alert } from 'antd';
|
|
||||||
import type { RadioChangeEvent } from 'antd';
|
|
||||||
import {
|
|
||||||
LineChartOutlined,
|
|
||||||
BarChartOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { Box } from '@chakra-ui/react';
|
|
||||||
|
|
||||||
// 自定义 Hooks
|
|
||||||
import { useKLineChart, useKLineData, useEventMarker } from './hooks';
|
|
||||||
|
|
||||||
// 类型定义
|
|
||||||
import type { ChartType, StockInfo } from './types';
|
|
||||||
|
|
||||||
// 配置常量
|
|
||||||
import {
|
|
||||||
CHART_TYPE_CONFIG,
|
|
||||||
CHART_HEIGHTS,
|
|
||||||
INDICATORS,
|
|
||||||
DEFAULT_SUB_INDICATORS,
|
|
||||||
} from './config';
|
|
||||||
|
|
||||||
// 工具函数
|
|
||||||
import { createSubIndicators } from './utils';
|
|
||||||
|
|
||||||
// ==================== 组件 Props ====================
|
|
||||||
|
|
||||||
export interface StockChartKLineModalProps {
|
|
||||||
/** 是否显示模态框 */
|
|
||||||
visible: boolean;
|
|
||||||
/** 关闭模态框回调 */
|
|
||||||
onClose: () => void;
|
|
||||||
/** 股票信息 */
|
|
||||||
stock: StockInfo;
|
|
||||||
/** 事件时间(ISO 字符串,可选) */
|
|
||||||
eventTime?: string;
|
|
||||||
/** 事件标题(用于标记标签,可选) */
|
|
||||||
eventTitle?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 主组件 ====================
|
|
||||||
|
|
||||||
const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
stock,
|
|
||||||
eventTime,
|
|
||||||
eventTitle,
|
|
||||||
}) => {
|
|
||||||
// ==================== 状态管理 ====================
|
|
||||||
|
|
||||||
/** 图表类型(分时图/日K线) */
|
|
||||||
const [chartType, setChartType] = useState<ChartType>('daily');
|
|
||||||
|
|
||||||
/** 选中的副图指标 */
|
|
||||||
const [selectedIndicators, setSelectedIndicators] = useState<string[]>(
|
|
||||||
DEFAULT_SUB_INDICATORS
|
|
||||||
);
|
|
||||||
|
|
||||||
// ==================== 自定义 Hooks ====================
|
|
||||||
|
|
||||||
/** 图表实例管理 */
|
|
||||||
const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({
|
|
||||||
containerId: `kline-chart-${stock.stock_code}`,
|
|
||||||
height: CHART_HEIGHTS.main,
|
|
||||||
autoResize: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 数据加载管理 */
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
loading: dataLoading,
|
|
||||||
error: dataError,
|
|
||||||
loadData,
|
|
||||||
} = useKLineData({
|
|
||||||
chart,
|
|
||||||
stockCode: stock.stock_code,
|
|
||||||
chartType,
|
|
||||||
eventTime,
|
|
||||||
autoLoad: visible, // 模态框打开时自动加载
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 事件标记管理 */
|
|
||||||
const { marker } = useEventMarker({
|
|
||||||
chart,
|
|
||||||
data,
|
|
||||||
eventTime,
|
|
||||||
eventTitle,
|
|
||||||
autoCreate: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==================== 事件处理 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换图表类型(分时图 ↔ 日K线)
|
|
||||||
*/
|
|
||||||
const handleChartTypeChange = useCallback((e: RadioChangeEvent) => {
|
|
||||||
const newType = e.target.value as ChartType;
|
|
||||||
setChartType(newType);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换副图指标
|
|
||||||
*/
|
|
||||||
const handleIndicatorChange = useCallback(
|
|
||||||
(values: string[]) => {
|
|
||||||
setSelectedIndicators(values);
|
|
||||||
|
|
||||||
if (!chart) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先移除所有副图指标(KLineChart 会自动移除)
|
|
||||||
// 然后创建新的指标
|
|
||||||
createSubIndicators(chart, values);
|
|
||||||
},
|
|
||||||
[chart]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 刷新数据
|
|
||||||
*/
|
|
||||||
const handleRefresh = useCallback(() => {
|
|
||||||
loadData();
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
// ==================== 计算属性 ====================
|
|
||||||
|
|
||||||
/** 是否有错误 */
|
|
||||||
const hasError = useMemo(() => {
|
|
||||||
return !!chartError || !!dataError;
|
|
||||||
}, [chartError, dataError]);
|
|
||||||
|
|
||||||
/** 错误消息 */
|
|
||||||
const errorMessage = useMemo(() => {
|
|
||||||
if (chartError) {
|
|
||||||
return `图表初始化失败: ${chartError.message}`;
|
|
||||||
}
|
|
||||||
if (dataError) {
|
|
||||||
return `数据加载失败: ${dataError.message}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [chartError, dataError]);
|
|
||||||
|
|
||||||
/** 模态框标题 */
|
|
||||||
const modalTitle = useMemo(() => {
|
|
||||||
return `${stock.stock_name}(${stock.stock_code}) - ${CHART_TYPE_CONFIG[chartType].label}`;
|
|
||||||
}, [stock, chartType]);
|
|
||||||
|
|
||||||
/** 是否显示加载状态 */
|
|
||||||
const showLoading = useMemo(() => {
|
|
||||||
return dataLoading || !isInitialized;
|
|
||||||
}, [dataLoading, isInitialized]);
|
|
||||||
|
|
||||||
// ==================== 副作用 ====================
|
|
||||||
|
|
||||||
// 无副作用,都在 Hooks 中管理
|
|
||||||
|
|
||||||
// ==================== 渲染 ====================
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={modalTitle}
|
|
||||||
open={visible}
|
|
||||||
onCancel={onClose}
|
|
||||||
width={1200}
|
|
||||||
footer={null}
|
|
||||||
centered
|
|
||||||
destroyOnHidden // 关闭时销毁组件(释放图表资源)
|
|
||||||
>
|
|
||||||
{/* 工具栏 */}
|
|
||||||
<Box mb={4}>
|
|
||||||
<Space wrap>
|
|
||||||
{/* 图表类型切换 */}
|
|
||||||
<Radio.Group value={chartType} onChange={handleChartTypeChange}>
|
|
||||||
<Radio.Button value="timeline">
|
|
||||||
<LineChartOutlined /> 分时图
|
|
||||||
</Radio.Button>
|
|
||||||
<Radio.Button value="daily">
|
|
||||||
<BarChartOutlined /> 日K线
|
|
||||||
</Radio.Button>
|
|
||||||
</Radio.Group>
|
|
||||||
|
|
||||||
{/* 副图指标选择 */}
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
placeholder="选择副图指标"
|
|
||||||
value={selectedIndicators}
|
|
||||||
onChange={handleIndicatorChange}
|
|
||||||
style={{ minWidth: 200 }}
|
|
||||||
maxTagCount={2}
|
|
||||||
>
|
|
||||||
{INDICATORS.sub.map((indicator) => (
|
|
||||||
<Select.Option key={indicator.name} value={indicator.name}>
|
|
||||||
<SettingOutlined /> {indicator.label}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 刷新按钮 */}
|
|
||||||
<Button
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
loading={dataLoading}
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 错误提示 */}
|
|
||||||
{hasError && (
|
|
||||||
<Alert
|
|
||||||
message="加载失败"
|
|
||||||
description={errorMessage}
|
|
||||||
type="error"
|
|
||||||
closable
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 图表容器 */}
|
|
||||||
<Box position="relative">
|
|
||||||
{/* 加载遮罩 */}
|
|
||||||
{showLoading && (
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="50%"
|
|
||||||
left="50%"
|
|
||||||
transform="translate(-50%, -50%)"
|
|
||||||
zIndex={10}
|
|
||||||
>
|
|
||||||
<Spin size="large" tip="加载中..." />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* KLineChart 容器 */}
|
|
||||||
<div
|
|
||||||
ref={chartRef}
|
|
||||||
id={`kline-chart-${stock.stock_code}`}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
minHeight: '300px',
|
|
||||||
height: 'min(400px, 60vh)',
|
|
||||||
opacity: showLoading ? 0.5 : 1,
|
|
||||||
transition: 'opacity 0.3s',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 数据信息(调试用,生产环境可移除) */}
|
|
||||||
{process.env.NODE_ENV === 'development' && (
|
|
||||||
<Box mt={2} fontSize="12px" color="gray.500">
|
|
||||||
<Space split="|">
|
|
||||||
<span>数据点数: {data.length}</span>
|
|
||||||
<span>事件标记: {marker ? marker.label : '无'}</span>
|
|
||||||
<span>图表ID: {chart?.id || '未初始化'}</span>
|
|
||||||
</Space>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StockChartKLineModal;
|
|
||||||
@@ -1,558 +0,0 @@
|
|||||||
// src/components/StockChart/StockChartModal.js - 统一的股票图表组件
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
|
||||||
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react';
|
|
||||||
import ReactECharts from 'echarts-for-react';
|
|
||||||
import * as echarts from 'echarts';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { stockService } from '../../services/eventService';
|
|
||||||
import { logger } from '../../utils/logger';
|
|
||||||
import RiskDisclaimer from '../RiskDisclaimer';
|
|
||||||
import { RelationDescription } from '../StockRelation';
|
|
||||||
|
|
||||||
const StockChartModal = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
stock,
|
|
||||||
eventTime,
|
|
||||||
isChakraUI = true, // 是否使用Chakra UI,默认true;如果false则使用Antd
|
|
||||||
size = "6xl",
|
|
||||||
initialChartType = 'timeline' // 初始图表类型(timeline/daily)
|
|
||||||
}) => {
|
|
||||||
const chartRef = useRef(null);
|
|
||||||
const chartInstanceRef = useRef(null);
|
|
||||||
const [chartType, setChartType] = useState(initialChartType);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [chartData, setChartData] = useState(null);
|
|
||||||
const [preloadedData, setPreloadedData] = useState({});
|
|
||||||
|
|
||||||
// 预加载数据
|
|
||||||
const preloadData = async (type) => {
|
|
||||||
if (!stock || preloadedData[type]) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let adjustedEventTime = eventTime;
|
|
||||||
if (eventTime) {
|
|
||||||
try {
|
|
||||||
const eventMoment = dayjs(eventTime);
|
|
||||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
|
||||||
const nextDay = eventMoment.clone().add(1, 'day');
|
|
||||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
|
||||||
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('StockChartModal', '事件时间解析失败', {
|
|
||||||
eventTime,
|
|
||||||
error: e.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
|
|
||||||
setPreloadedData(prev => ({...prev, [type]: response}));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('StockChartModal', 'preloadData', err, {
|
|
||||||
stockCode: stock?.stock_code,
|
|
||||||
type
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && stock) {
|
|
||||||
// 预加载两种图表类型的数据
|
|
||||||
preloadData('timeline');
|
|
||||||
preloadData('daily');
|
|
||||||
|
|
||||||
// 清理图表实例
|
|
||||||
return () => {
|
|
||||||
if (chartInstanceRef.current) {
|
|
||||||
window.removeEventListener('resize', chartInstanceRef.current.resizeHandler);
|
|
||||||
chartInstanceRef.current.dispose();
|
|
||||||
chartInstanceRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isOpen, stock, eventTime]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && stock) {
|
|
||||||
loadChartData(chartType);
|
|
||||||
}
|
|
||||||
}, [chartType, isOpen, stock]);
|
|
||||||
|
|
||||||
const loadChartData = async (type) => {
|
|
||||||
if (!stock) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// 先尝试使用预加载的数据
|
|
||||||
let response = preloadedData[type];
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
// 如果预加载数据不存在,则立即请求
|
|
||||||
let adjustedEventTime = eventTime;
|
|
||||||
if (eventTime) {
|
|
||||||
try {
|
|
||||||
const eventMoment = dayjs(eventTime);
|
|
||||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
|
||||||
const nextDay = eventMoment.clone().add(1, 'day');
|
|
||||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
|
||||||
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('StockChartModal', '事件时间解析失败', {
|
|
||||||
eventTime,
|
|
||||||
error: e.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
setChartData(response);
|
|
||||||
|
|
||||||
// 初始化图表
|
|
||||||
if (chartRef.current && !chartInstanceRef.current) {
|
|
||||||
const chart = echarts.init(chartRef.current);
|
|
||||||
chart.resizeHandler = () => chart.resize();
|
|
||||||
window.addEventListener('resize', chart.resizeHandler);
|
|
||||||
chartInstanceRef.current = chart;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartInstanceRef.current) {
|
|
||||||
const option = generateChartOption(response, type, eventTime);
|
|
||||||
chartInstanceRef.current.setOption(option, true);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('StockChartModal', 'loadChartData', err, {
|
|
||||||
stockCode: stock?.stock_code,
|
|
||||||
chartType: type
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateChartOption = (data, type, originalEventTime, adjustedEventTime) => {
|
|
||||||
if (!data || !data.data || data.data.length === 0) {
|
|
||||||
return { title: { text: '暂无数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 16 } } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const stockData = data.data;
|
|
||||||
const tradeDate = data.trade_date;
|
|
||||||
|
|
||||||
// 分时图
|
|
||||||
if (type === 'timeline') {
|
|
||||||
const times = stockData.map(item => item.time);
|
|
||||||
const prices = stockData.map(item => item.close || item.price);
|
|
||||||
const avgPrices = stockData.map(item => item.avg_price);
|
|
||||||
const volumes = stockData.map(item => item.volume);
|
|
||||||
|
|
||||||
// 获取昨收盘价作为基准
|
|
||||||
const prevClose = data.prev_close || (prices.length > 0 ? prices[0] : 0);
|
|
||||||
|
|
||||||
// 计算涨跌幅数据
|
|
||||||
const changePercentData = prices.map(price => ((price - prevClose) / prevClose * 100));
|
|
||||||
const avgChangePercentData = avgPrices.map(avgPrice => ((avgPrice - prevClose) / prevClose * 100));
|
|
||||||
|
|
||||||
const currentPrice = prices[prices.length - 1];
|
|
||||||
const currentChange = ((currentPrice - prevClose) / prevClose * 100);
|
|
||||||
const isUp = currentChange >= 0;
|
|
||||||
const lineColor = isUp ? '#ef5350' : '#26a69a';
|
|
||||||
|
|
||||||
// 计算事件标记线位置
|
|
||||||
let eventMarkLineData = [];
|
|
||||||
if (originalEventTime && times.length > 0) {
|
|
||||||
const eventMoment = dayjs(originalEventTime);
|
|
||||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
|
||||||
const eventTime = eventMoment.format('HH:mm');
|
|
||||||
|
|
||||||
if (eventDate === tradeDate) {
|
|
||||||
// 找到最接近的时间点
|
|
||||||
let nearestIdx = 0;
|
|
||||||
const eventMinutes = eventMoment.hour() * 60 + eventMoment.minute();
|
|
||||||
|
|
||||||
for (let i = 0; i < times.length; i++) {
|
|
||||||
const [h, m] = times[i].split(':').map(Number);
|
|
||||||
const timeMinutes = h * 60 + m;
|
|
||||||
const currentDiff = Math.abs(timeMinutes - eventMinutes);
|
|
||||||
const nearestDiff = Math.abs(
|
|
||||||
(times[nearestIdx].split(':').map(Number)[0] * 60 + times[nearestIdx].split(':').map(Number)[1]) - eventMinutes
|
|
||||||
);
|
|
||||||
if (currentDiff < nearestDiff) {
|
|
||||||
nearestIdx = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eventMarkLineData = [{
|
|
||||||
name: '事件发生',
|
|
||||||
xAxis: nearestIdx,
|
|
||||||
label: {
|
|
||||||
formatter: '事件发生',
|
|
||||||
position: 'middle',
|
|
||||||
color: '#FFD700',
|
|
||||||
fontSize: 12
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
color: '#FFD700',
|
|
||||||
type: 'solid',
|
|
||||||
width: 2
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: {
|
|
||||||
text: `${stock.stock_name || stock.stock_code} - 分时图`,
|
|
||||||
left: 'center',
|
|
||||||
textStyle: { fontSize: 16, fontWeight: 'bold' }
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: { type: 'cross' },
|
|
||||||
formatter: function(params) {
|
|
||||||
if (!params || params.length === 0) return '';
|
|
||||||
const point = params[0];
|
|
||||||
const idx = point.dataIndex;
|
|
||||||
const priceChangePercent = ((prices[idx] - prevClose) / prevClose * 100);
|
|
||||||
const avgChangePercent = ((avgPrices[idx] - prevClose) / prevClose * 100);
|
|
||||||
const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a';
|
|
||||||
const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a';
|
|
||||||
|
|
||||||
return `时间:${times[idx]}<br/>现价:<span style="color: ${priceColor}">¥${prices[idx]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(2)}%)</span><br/>均价:<span style="color: ${avgColor}">¥${avgPrices[idx]?.toFixed(2)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(2)}%)</span><br/>昨收:¥${prevClose?.toFixed(2)}<br/>成交量:${Math.round(volumes[idx]/100)}手`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
grid: [
|
|
||||||
{ left: '10%', right: '10%', height: '60%', top: '15%' },
|
|
||||||
{ left: '10%', right: '10%', top: '80%', height: '15%' }
|
|
||||||
],
|
|
||||||
xAxis: [
|
|
||||||
{ type: 'category', data: times, gridIndex: 0, boundaryGap: false },
|
|
||||||
{ type: 'category', data: times, gridIndex: 1, axisLabel: { show: false } }
|
|
||||||
],
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
gridIndex: 0,
|
|
||||||
scale: false,
|
|
||||||
position: 'left',
|
|
||||||
axisLabel: {
|
|
||||||
formatter: function(value) {
|
|
||||||
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
show: true,
|
|
||||||
lineStyle: {
|
|
||||||
color: '#f0f0f0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
gridIndex: 0,
|
|
||||||
scale: false,
|
|
||||||
position: 'right',
|
|
||||||
axisLabel: {
|
|
||||||
formatter: function(value) {
|
|
||||||
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => Math.round(v/100) + '手' } }
|
|
||||||
],
|
|
||||||
dataZoom: [
|
|
||||||
{ type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
|
|
||||||
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%' }
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: '分时价',
|
|
||||||
type: 'line',
|
|
||||||
xAxisIndex: 0,
|
|
||||||
yAxisIndex: 0,
|
|
||||||
data: changePercentData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { color: lineColor, width: 2 },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)' },
|
|
||||||
{ offset: 1, color: isUp ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)' }
|
|
||||||
])
|
|
||||||
},
|
|
||||||
markLine: {
|
|
||||||
symbol: 'none',
|
|
||||||
data: [
|
|
||||||
// 昨收盘价基准线 (0%)
|
|
||||||
{
|
|
||||||
yAxis: 0,
|
|
||||||
lineStyle: {
|
|
||||||
color: '#666',
|
|
||||||
type: 'dashed',
|
|
||||||
width: 1.5,
|
|
||||||
opacity: 0.8
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
formatter: '昨收盘价',
|
|
||||||
position: 'insideEndTop',
|
|
||||||
color: '#666',
|
|
||||||
fontSize: 12
|
|
||||||
}
|
|
||||||
},
|
|
||||||
...eventMarkLineData
|
|
||||||
],
|
|
||||||
animation: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '均价线',
|
|
||||||
type: 'line',
|
|
||||||
xAxisIndex: 0,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
data: avgChangePercentData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { color: '#FFA500', width: 1 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '成交量',
|
|
||||||
type: 'bar',
|
|
||||||
xAxisIndex: 1,
|
|
||||||
yAxisIndex: 2,
|
|
||||||
data: volumes,
|
|
||||||
itemStyle: { color: '#b0c4de', opacity: 0.6 }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 日K线
|
|
||||||
if (type === 'daily') {
|
|
||||||
const dates = stockData.map(item => item.time || item.date);
|
|
||||||
const klineData = stockData.map(item => [item.open, item.close, item.low, item.high]);
|
|
||||||
const volumes = stockData.map(item => item.volume);
|
|
||||||
|
|
||||||
// 计算事件标记线位置(重要修复)
|
|
||||||
let eventMarkLineData = [];
|
|
||||||
if (originalEventTime && dates.length > 0) {
|
|
||||||
const eventMoment = dayjs(originalEventTime);
|
|
||||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
|
||||||
|
|
||||||
// 找到事件发生日期或最接近的交易日
|
|
||||||
let targetIndex = -1;
|
|
||||||
|
|
||||||
// 1. 先尝试找到完全匹配的日期
|
|
||||||
targetIndex = dates.findIndex(date => date === eventDate);
|
|
||||||
|
|
||||||
// 2. 如果没有完全匹配,找到第一个大于等于事件日期的交易日
|
|
||||||
if (targetIndex === -1) {
|
|
||||||
for (let i = 0; i < dates.length; i++) {
|
|
||||||
if (dates[i] >= eventDate) {
|
|
||||||
targetIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 如果事件日期晚于所有交易日,则标记在最后一个交易日
|
|
||||||
if (targetIndex === -1 && eventDate > dates[dates.length - 1]) {
|
|
||||||
targetIndex = dates.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 如果事件日期早于所有交易日,则标记在第一个交易日
|
|
||||||
if (targetIndex === -1 && eventDate < dates[0]) {
|
|
||||||
targetIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetIndex >= 0) {
|
|
||||||
let labelText = '事件发生';
|
|
||||||
let labelPosition = 'middle';
|
|
||||||
|
|
||||||
// 根据事件时间和交易日的关系调整标签
|
|
||||||
if (eventDate === dates[targetIndex]) {
|
|
||||||
if (eventMoment.hour() >= 15) {
|
|
||||||
labelText = '事件发生\n(盘后)';
|
|
||||||
} else if (eventMoment.hour() < 9 || (eventMoment.hour() === 9 && eventMoment.minute() < 30)) {
|
|
||||||
labelText = '事件发生\n(盘前)';
|
|
||||||
}
|
|
||||||
} else if (eventDate < dates[targetIndex]) {
|
|
||||||
labelText = '事件发生\n(前一日)';
|
|
||||||
labelPosition = 'start';
|
|
||||||
} else {
|
|
||||||
labelText = '事件发生\n(影响日)';
|
|
||||||
labelPosition = 'end';
|
|
||||||
}
|
|
||||||
|
|
||||||
eventMarkLineData = [{
|
|
||||||
name: '事件发生',
|
|
||||||
xAxis: targetIndex,
|
|
||||||
label: {
|
|
||||||
formatter: labelText,
|
|
||||||
position: labelPosition,
|
|
||||||
color: '#FFD700',
|
|
||||||
fontSize: 12,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
||||||
padding: [4, 8],
|
|
||||||
borderRadius: 4
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
color: '#FFD700',
|
|
||||||
type: 'solid',
|
|
||||||
width: 2
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: {
|
|
||||||
text: `${stock.stock_name || stock.stock_code} - 日K线`,
|
|
||||||
left: 'center',
|
|
||||||
textStyle: { fontSize: 16, fontWeight: 'bold' }
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: { type: 'cross' },
|
|
||||||
formatter: function(params) {
|
|
||||||
if (!params || params.length === 0) return '';
|
|
||||||
const kline = params[0];
|
|
||||||
const volume = params[1];
|
|
||||||
if (!kline || !kline.data) return '';
|
|
||||||
|
|
||||||
let tooltipHtml = `日期: ${kline.axisValue}<br/>开盘: ¥${kline.data[0]}<br/>收盘: ¥${kline.data[1]}<br/>最低: ¥${kline.data[2]}<br/>最高: ¥${kline.data[3]}`;
|
|
||||||
|
|
||||||
if (volume && volume.data) {
|
|
||||||
tooltipHtml += `<br/>成交量: ${Math.round(volume.data/100)}手`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return tooltipHtml;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
grid: [
|
|
||||||
{ left: '10%', right: '10%', height: '60%' },
|
|
||||||
{ left: '10%', right: '10%', top: '75%', height: '20%' }
|
|
||||||
],
|
|
||||||
xAxis: [
|
|
||||||
{ type: 'category', data: dates, scale: true, boundaryGap: true, gridIndex: 0 },
|
|
||||||
{ type: 'category', data: dates, gridIndex: 1, axisLabel: { show: false } }
|
|
||||||
],
|
|
||||||
yAxis: [
|
|
||||||
{ scale: true, splitArea: { show: true }, gridIndex: 0 },
|
|
||||||
{ scale: true, gridIndex: 1, axisLabel: { formatter: (value) => Math.round(value/100) + '手' } }
|
|
||||||
],
|
|
||||||
dataZoom: [
|
|
||||||
{ type: 'inside', xAxisIndex: [0, 1], start: 70, end: 100 },
|
|
||||||
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%', start: 70, end: 100 }
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'K线',
|
|
||||||
type: 'candlestick',
|
|
||||||
yAxisIndex: 0,
|
|
||||||
data: klineData,
|
|
||||||
markLine: {
|
|
||||||
symbol: 'none',
|
|
||||||
data: eventMarkLineData,
|
|
||||||
animation: false
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
color: '#ef5350',
|
|
||||||
color0: '#26a69a',
|
|
||||||
borderColor: '#ef5350',
|
|
||||||
borderColor0: '#26a69a'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '成交量',
|
|
||||||
type: 'bar',
|
|
||||||
xAxisIndex: 1,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
data: volumes.map((volume, index) => ({
|
|
||||||
value: volume,
|
|
||||||
itemStyle: {
|
|
||||||
color: stockData[index].close >= stockData[index].open ? '#ef5350' : '#26a69a'
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!stock) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size={size}>
|
|
||||||
<ModalOverlay />
|
|
||||||
<ModalContent maxW="90vw" maxH="90vh" overflow="hidden">
|
|
||||||
<ModalHeader pb={4}>
|
|
||||||
<VStack align="flex-start" spacing={2}>
|
|
||||||
<HStack>
|
|
||||||
<Text fontSize="lg" fontWeight="bold">
|
|
||||||
{stock.stock_name || stock.stock_code} ({stock.stock_code}) - 股票详情
|
|
||||||
</Text>
|
|
||||||
{chartData && (
|
|
||||||
<Badge colorScheme="blue">{chartData.trade_date}</Badge>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
<ButtonGroup size="sm">
|
|
||||||
<Button
|
|
||||||
variant={chartType === 'timeline' ? 'solid' : 'outline'}
|
|
||||||
onClick={() => setChartType('timeline')}
|
|
||||||
colorScheme="blue"
|
|
||||||
>
|
|
||||||
分时线
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={chartType === 'daily' ? 'solid' : 'outline'}
|
|
||||||
onClick={() => setChartType('daily')}
|
|
||||||
colorScheme="blue"
|
|
||||||
>
|
|
||||||
日K线
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</VStack>
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalCloseButton />
|
|
||||||
<ModalBody p={0} overflowY="auto" maxH="calc(90vh - 120px)">
|
|
||||||
{/* 图表区域 */}
|
|
||||||
<Box h="500px" w="100%" position="relative">
|
|
||||||
{loading && (
|
|
||||||
<Flex
|
|
||||||
position="absolute"
|
|
||||||
top="0" left="0" right="0" bottom="0"
|
|
||||||
bg="rgba(255, 255, 255, 0.7)"
|
|
||||||
zIndex="10"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
>
|
|
||||||
<VStack spacing={4}>
|
|
||||||
<CircularProgress isIndeterminate color="blue.300" />
|
|
||||||
<Text>加载图表数据...</Text>
|
|
||||||
</VStack>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 关联描述 */}
|
|
||||||
<RelationDescription relationDesc={stock?.relation_desc} />
|
|
||||||
|
|
||||||
{/* 风险提示 */}
|
|
||||||
<Box px={4} pb={4}>
|
|
||||||
<RiskDisclaimer variant="default" />
|
|
||||||
</Box>
|
|
||||||
</ModalBody>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StockChartModal;
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Pie } from "@visx/shape";
|
|
||||||
import { Group } from "@visx/group";
|
|
||||||
import { Text } from "@visx/text";
|
|
||||||
import { useColorModeValue } from "@chakra-ui/react";
|
|
||||||
|
|
||||||
const VisxPieChart = ({ data, title, width }) => {
|
|
||||||
const [active, setActive] = useState(null);
|
|
||||||
const half = width / 2;
|
|
||||||
|
|
||||||
const textColor = useColorModeValue("gray.700", "white");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg width={width} height={width}>
|
|
||||||
<Group top={half} left={half}>
|
|
||||||
<Pie
|
|
||||||
data={data}
|
|
||||||
pieValue={(data) => data.percentage}
|
|
||||||
outerRadius={half}
|
|
||||||
innerRadius={({ data }) => {
|
|
||||||
const size = active && active.name == data.name ? 12 : 8;
|
|
||||||
return half - size;
|
|
||||||
}}
|
|
||||||
padAngle={0.01}
|
|
||||||
>
|
|
||||||
{(pie) => {
|
|
||||||
return pie.arcs.map((arc) => {
|
|
||||||
return (
|
|
||||||
<g
|
|
||||||
key={arc.data.name}
|
|
||||||
onMouseEnter={() => setActive(arc.data)}
|
|
||||||
onMouseLeave={() => setActive(null)}
|
|
||||||
>
|
|
||||||
<path d={pie.path(arc)} fill={arc.data.color}></path>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
</Pie>
|
|
||||||
|
|
||||||
|
|
||||||
<>
|
|
||||||
|
|
||||||
|
|
||||||
<Text textAnchor="middle" fill={textColor} fontWeight="bold" fontSize={32} dy={20}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
|
|
||||||
</Group>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VisxPieChart;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Container, VStack, HStack, Text, Link, useColorModeValue } from '@chakra-ui/react';
|
import { Box, VStack, HStack, Text, Link, useColorModeValue } from '@chakra-ui/react';
|
||||||
import RiskDisclaimer from '../components/RiskDisclaimer';
|
import RiskDisclaimer from '../components/RiskDisclaimer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,8 +8,7 @@ import RiskDisclaimer from '../components/RiskDisclaimer';
|
|||||||
*/
|
*/
|
||||||
const AppFooter = () => {
|
const AppFooter = () => {
|
||||||
return (
|
return (
|
||||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={2}>
|
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={2} px={{ base: 4, md: 6, lg: '80px' }}>
|
||||||
<Container maxW="container.xl">
|
|
||||||
<VStack spacing={1}>
|
<VStack spacing={1}>
|
||||||
<RiskDisclaimer />
|
<RiskDisclaimer />
|
||||||
<Text color="gray.500" fontSize="sm">
|
<Text color="gray.500" fontSize="sm">
|
||||||
@@ -32,7 +31,6 @@ const AppFooter = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Container>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import BackToTopButton from "./components/BackToTopButton";
|
|||||||
import ErrorBoundary from "../components/ErrorBoundary";
|
import ErrorBoundary from "../components/ErrorBoundary";
|
||||||
import PageLoader from "../components/Loading/PageLoader";
|
import PageLoader from "../components/Loading/PageLoader";
|
||||||
import GlobalSidebar from "../components/GlobalSidebar";
|
import GlobalSidebar from "../components/GlobalSidebar";
|
||||||
import { BACK_TO_TOP_CONFIG, LAYOUT_SIZE } from "./config/layoutConfig";
|
import { BACK_TO_TOP_CONFIG, LAYOUT_SIZE, LAYOUT_PADDING } from "./config/layoutConfig";
|
||||||
|
|
||||||
// ✅ P0 性能优化:缓存静态组件,避免路由切换时不必要的重新渲染
|
// ✅ P0 性能优化:缓存静态组件,避免路由切换时不必要的重新渲染
|
||||||
// HomeNavbar (1623行) 和 AppFooter 不依赖路由参数,使用 memo 可大幅减少渲染次数
|
// HomeNavbar (1623行) 和 AppFooter 不依赖路由参数,使用 memo 可大幅减少渲染次数
|
||||||
@@ -40,7 +40,7 @@ export default function MainLayout() {
|
|||||||
<Box flex="1" bg="#1A202C" position="relative" overflow="hidden">
|
<Box flex="1" bg="#1A202C" position="relative" overflow="hidden">
|
||||||
{/* 页面内容区域 - 全宽度,与导航栏对齐 */}
|
{/* 页面内容区域 - 全宽度,与导航栏对齐 */}
|
||||||
<Box h="100%" overflowY="auto" display="flex" flexDirection="column">
|
<Box h="100%" overflowY="auto" display="flex" flexDirection="column">
|
||||||
<Box flex="1" pt={LAYOUT_SIZE.navbarHeight}>
|
<Box flex="1" pt={LAYOUT_SIZE.navbarHeight} px={LAYOUT_PADDING.x}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -170,6 +170,15 @@ export const LAYOUT_SIZE = {
|
|||||||
contentMinHeight: 'calc(100vh - 60px)', // 100vh - navbar高度
|
contentMinHeight: 'calc(100vh - 60px)', // 100vh - navbar高度
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布局内边距配置
|
||||||
|
* 统一控制页面内容的水平内边距
|
||||||
|
* 右侧预留空间给 GlobalSidebar(收起宽度 72px)
|
||||||
|
*/
|
||||||
|
export const LAYOUT_PADDING = {
|
||||||
|
x: { base: 4, md: 6, lg: '80px' }, // 移动端 16px,中屏 24px,大屏 80px(容纳工具栏)
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 响应式断点
|
* 响应式断点
|
||||||
* 与 Chakra UI 断点保持一致
|
* 与 Chakra UI 断点保持一致
|
||||||
@@ -189,5 +198,6 @@ export default {
|
|||||||
BACK_TO_TOP_CONFIG,
|
BACK_TO_TOP_CONFIG,
|
||||||
PAGE_LOADER_CONFIG,
|
PAGE_LOADER_CONFIG,
|
||||||
LAYOUT_SIZE,
|
LAYOUT_SIZE,
|
||||||
|
LAYOUT_PADDING,
|
||||||
BREAKPOINTS
|
BREAKPOINTS
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -216,24 +216,39 @@ const generateDailyAnalysis = (date) => {
|
|||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
const morningCount = Math.floor(totalStocks * 0.35); // 早盘涨停
|
const morningCount = Math.floor(totalStocks * 0.35); // 早盘涨停
|
||||||
|
const middayCount = Math.floor(totalStocks * 0.25); // 午盘涨停
|
||||||
|
const afternoonCount = totalStocks - morningCount - middayCount; // 尾盘涨停
|
||||||
const announcementCount = sectorData['公告']?.count || 0;
|
const announcementCount = sectorData['公告']?.count || 0;
|
||||||
const topSector = sectorNames.filter(s => s !== '公告' && s !== '其他')
|
const topSector = sectorNames.filter(s => s !== '公告' && s !== '其他')
|
||||||
.reduce((max, name) =>
|
.reduce((max, name) =>
|
||||||
(sectorData[name]?.count || 0) > (sectorData[max]?.count || 0) ? name : max
|
(sectorData[name]?.count || 0) > (sectorData[max]?.count || 0) ? name : max
|
||||||
, '人工智能');
|
, '人工智能');
|
||||||
|
|
||||||
|
// 生成 chart_data(板块分布饼图需要)
|
||||||
|
const sortedSectors = Object.entries(sectorData)
|
||||||
|
.filter(([name]) => name !== '其他' && name !== '公告')
|
||||||
|
.sort((a, b) => b[1].count - a[1].count)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: sortedSectors.map(([name]) => name),
|
||||||
|
counts: sortedSectors.map(([, info]) => info.count)
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: date,
|
date: date,
|
||||||
total_stocks: totalStocks,
|
total_stocks: totalStocks,
|
||||||
total_sectors: Object.keys(sectorData).length,
|
total_sectors: Object.keys(sectorData).length,
|
||||||
sector_data: sectorData, // 👈 SectorDetails 组件需要的数据
|
sector_data: sectorData, // 👈 SectorDetails 组件需要的数据
|
||||||
|
chart_data: chartData, // 👈 板块分布饼图需要的数据
|
||||||
summary: {
|
summary: {
|
||||||
top_sector: topSector,
|
top_sector: topSector,
|
||||||
top_sector_count: sectorData[topSector]?.count || 0,
|
top_sector_count: sectorData[topSector]?.count || 0,
|
||||||
announcement_stocks: announcementCount,
|
announcement_stocks: announcementCount,
|
||||||
zt_time_distribution: {
|
zt_time_distribution: {
|
||||||
morning: morningCount,
|
morning: morningCount,
|
||||||
afternoon: totalStocks - morningCount,
|
midday: middayCount,
|
||||||
|
afternoon: afternoonCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -382,6 +397,22 @@ const generateDailyJson = (date) => {
|
|||||||
{ name: '区块链', value: Math.floor(Math.random() * 5) + 2 },
|
{ name: '区块链', value: Math.floor(Math.random() * 5) + 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 生成 chart_data(板块分布饼图需要)
|
||||||
|
const sortedSectors = Object.entries(sectorData)
|
||||||
|
.filter(([name]) => name !== '其他' && name !== '公告')
|
||||||
|
.sort((a, b) => b[1].count - a[1].count)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: sortedSectors.map(([name]) => name),
|
||||||
|
counts: sortedSectors.map(([, info]) => info.count)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 时间分布(早盘、午盘、尾盘)
|
||||||
|
const morningCount = Math.floor(stocks.length * 0.35);
|
||||||
|
const middayCount = Math.floor(stocks.length * 0.25);
|
||||||
|
const afternoonCount = stocks.length - morningCount - middayCount;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: date,
|
date: date,
|
||||||
total_stocks: stocks.length,
|
total_stocks: stocks.length,
|
||||||
@@ -389,13 +420,15 @@ const generateDailyJson = (date) => {
|
|||||||
stocks: stocks,
|
stocks: stocks,
|
||||||
sector_data: sectorData,
|
sector_data: sectorData,
|
||||||
word_freq_data: wordFreqData,
|
word_freq_data: wordFreqData,
|
||||||
|
chart_data: chartData, // 👈 板块分布饼图需要的数据
|
||||||
summary: {
|
summary: {
|
||||||
top_sector: '人工智能',
|
top_sector: '人工智能',
|
||||||
top_sector_count: sectorData['人工智能']?.count || 0,
|
top_sector_count: sectorData['人工智能']?.count || 0,
|
||||||
announcement_stocks: sectorData['公告']?.count || 0,
|
announcement_stocks: sectorData['公告']?.count || 0,
|
||||||
zt_time_distribution: {
|
zt_time_distribution: {
|
||||||
morning: Math.floor(stocks.length * 0.4),
|
morning: morningCount, // 早盘 9:30-11:30
|
||||||
afternoon: Math.floor(stocks.length * 0.6),
|
midday: middayCount, // 午盘 11:30-13:00
|
||||||
|
afternoon: afternoonCount, // 尾盘 13:00-15:00
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,11 +58,25 @@ const saveWatchlistToCache = (data) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取事件相关股票(Redux 缓存)
|
* 获取事件相关股票(Redux 缓存)
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.eventId - 事件ID
|
||||||
|
* @param {boolean} params.forceRefresh - 是否强制刷新
|
||||||
|
* @param {boolean} params.skipIfNoAccess - 如果无权限则跳过请求(会员过期场景)
|
||||||
*/
|
*/
|
||||||
export const fetchEventStocks = createAsyncThunk(
|
export const fetchEventStocks = createAsyncThunk(
|
||||||
'stock/fetchEventStocks',
|
'stock/fetchEventStocks',
|
||||||
async ({ eventId, forceRefresh = false }, { getState }) => {
|
async ({ eventId, forceRefresh = false, skipIfNoAccess = false }, { getState }) => {
|
||||||
logger.debug('stockSlice', 'fetchEventStocks', { eventId, forceRefresh });
|
logger.debug('stockSlice', 'fetchEventStocks', { eventId, forceRefresh, skipIfNoAccess });
|
||||||
|
|
||||||
|
// 检查订阅状态,如果会员过期则跳过请求
|
||||||
|
if (skipIfNoAccess) {
|
||||||
|
const subscriptionInfo = getState().subscription?.info;
|
||||||
|
const isExpired = subscriptionInfo?.type !== 'free' && !subscriptionInfo?.is_active;
|
||||||
|
if (isExpired) {
|
||||||
|
logger.debug('stockSlice', '会员已过期,跳过 fetchEventStocks 请求', { eventId });
|
||||||
|
return { eventId, stocks: [], skipped: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redux 状态缓存
|
// Redux 状态缓存
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
@@ -132,11 +146,25 @@ export const fetchEventDetail = createAsyncThunk(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取历史事件对比(Redux 缓存)
|
* 获取历史事件对比(Redux 缓存)
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.eventId - 事件ID
|
||||||
|
* @param {boolean} params.forceRefresh - 是否强制刷新
|
||||||
|
* @param {boolean} params.skipIfNoAccess - 如果无权限则跳过请求(会员过期场景)
|
||||||
*/
|
*/
|
||||||
export const fetchHistoricalEvents = createAsyncThunk(
|
export const fetchHistoricalEvents = createAsyncThunk(
|
||||||
'stock/fetchHistoricalEvents',
|
'stock/fetchHistoricalEvents',
|
||||||
async ({ eventId, forceRefresh = false }, { getState }) => {
|
async ({ eventId, forceRefresh = false, skipIfNoAccess = false }, { getState }) => {
|
||||||
logger.debug('stockSlice', 'fetchHistoricalEvents', { eventId });
|
logger.debug('stockSlice', 'fetchHistoricalEvents', { eventId, skipIfNoAccess });
|
||||||
|
|
||||||
|
// 检查订阅状态,如果会员过期则跳过请求
|
||||||
|
if (skipIfNoAccess) {
|
||||||
|
const subscriptionInfo = getState().subscription?.info;
|
||||||
|
const isExpired = subscriptionInfo?.type !== 'free' && !subscriptionInfo?.is_active;
|
||||||
|
if (isExpired) {
|
||||||
|
logger.debug('stockSlice', '会员已过期,跳过 fetchHistoricalEvents 请求', { eventId });
|
||||||
|
return { eventId, events: [], skipped: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redux 缓存
|
// Redux 缓存
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
@@ -158,11 +186,25 @@ export const fetchHistoricalEvents = createAsyncThunk(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取传导链分析(Redux 缓存)
|
* 获取传导链分析(Redux 缓存)
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.eventId - 事件ID
|
||||||
|
* @param {boolean} params.forceRefresh - 是否强制刷新
|
||||||
|
* @param {boolean} params.skipIfNoAccess - 如果无权限则跳过请求(会员过期场景)
|
||||||
*/
|
*/
|
||||||
export const fetchChainAnalysis = createAsyncThunk(
|
export const fetchChainAnalysis = createAsyncThunk(
|
||||||
'stock/fetchChainAnalysis',
|
'stock/fetchChainAnalysis',
|
||||||
async ({ eventId, forceRefresh = false }, { getState }) => {
|
async ({ eventId, forceRefresh = false, skipIfNoAccess = false }, { getState }) => {
|
||||||
logger.debug('stockSlice', 'fetchChainAnalysis', { eventId });
|
logger.debug('stockSlice', 'fetchChainAnalysis', { eventId, skipIfNoAccess });
|
||||||
|
|
||||||
|
// 检查订阅状态,如果会员过期则跳过请求
|
||||||
|
if (skipIfNoAccess) {
|
||||||
|
const subscriptionInfo = getState().subscription?.info;
|
||||||
|
const isExpired = subscriptionInfo?.type !== 'free' && !subscriptionInfo?.is_active;
|
||||||
|
if (isExpired) {
|
||||||
|
logger.debug('stockSlice', '会员已过期,跳过 fetchChainAnalysis 请求', { eventId });
|
||||||
|
return { eventId, analysis: null, skipped: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redux 缓存
|
// Redux 缓存
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ const Center: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box bg={THEME.bg.primary} minH="100vh" overflowX="hidden">
|
<Box bg={THEME.bg.primary} minH="100vh" overflowX="hidden">
|
||||||
<Box px={{ base: 3, md: 4 }} py={{ base: 4, md: 6 }} maxW="container.xl" mx="auto">
|
{/* padding 由 MainLayout 统一设置 */}
|
||||||
|
<Box py={{ base: 4, md: 6 }}>
|
||||||
{/* 市场概览仪表盘 */}
|
{/* 市场概览仪表盘 */}
|
||||||
<Box mb={4}>
|
<Box mb={4}>
|
||||||
<MarketDashboard />
|
<MarketDashboard />
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import {
|
|||||||
} from '@/store/slices/communityDataSlice';
|
} from '@/store/slices/communityDataSlice';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
useBreakpointValue,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
@@ -45,23 +43,6 @@ const Community = () => {
|
|||||||
// Ref:用于首次滚动到内容区域
|
// Ref:用于首次滚动到内容区域
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
// 响应式容器宽度
|
|
||||||
const containerMaxW = useBreakpointValue({
|
|
||||||
base: '100%', // 移动端:全宽
|
|
||||||
sm: '100%', // 小屏:全宽
|
|
||||||
md: '100%', // 中屏:全宽
|
|
||||||
lg: '1200px', // 大屏:1200px
|
|
||||||
xl: '1400px', // 超大屏:1400px
|
|
||||||
});
|
|
||||||
|
|
||||||
// 响应式内边距
|
|
||||||
const containerPx = useBreakpointValue({
|
|
||||||
base: 2, // 移动端:最小内边距
|
|
||||||
sm: 3,
|
|
||||||
md: 4,
|
|
||||||
lg: 6,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ⚡ 通知权限引导
|
// ⚡ 通知权限引导
|
||||||
const { browserPermission, requestBrowserPermission, registerEventUpdateCallback } = useNotification();
|
const { browserPermission, requestBrowserPermission, registerEventUpdateCallback } = useNotification();
|
||||||
|
|
||||||
@@ -165,8 +146,8 @@ const Community = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box minH="100vh" bg={bgColor}>
|
<Box minH="100vh" bg={bgColor}>
|
||||||
{/* 主内容区域 */}
|
{/* 主内容区域 - padding 由 MainLayout 统一设置 */}
|
||||||
<Container ref={containerRef} maxW={containerMaxW} px={containerPx} pt={{ base: 3, md: 6 }} pb={{ base: 4, md: 8 }}>
|
<Box ref={containerRef} pt={{ base: 3, md: 6 }} pb={{ base: 4, md: 8 }}>
|
||||||
{/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */}
|
{/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */}
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<Box mb={6} p={4} borderRadius="xl" bg="rgba(255,255,255,0.02)">
|
<Box mb={6} p={4} borderRadius="xl" bg="rgba(255,255,255,0.02)">
|
||||||
@@ -200,7 +181,7 @@ const Community = () => {
|
|||||||
events={hotEvents}
|
events={hotEvents}
|
||||||
onEventClick={communityEvents.trackNewsArticleClicked}
|
onEventClick={communityEvents.trackNewsArticleClicked}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Box, Flex, HStack, VStack, Text } from '@chakra-ui/react';
|
|||||||
import { AutoComplete, Input, Spin } from 'antd';
|
import { AutoComplete, Input, Spin } from 'antd';
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
import { useStockSearch } from '@hooks/useStockSearch';
|
import { useStockSearch } from '@hooks/useStockSearch';
|
||||||
|
import { LAYOUT_PADDING } from '@/layouts/config/layoutConfig';
|
||||||
import { THEME } from '../../config';
|
import { THEME } from '../../config';
|
||||||
import { FUI_COLORS, FUI_GLOW } from '../../theme/fui';
|
import { FUI_COLORS, FUI_GLOW } from '../../theme/fui';
|
||||||
import type { CompanyHeaderProps, StockSearchResult } from '../../types';
|
import type { CompanyHeaderProps, StockSearchResult } from '../../types';
|
||||||
@@ -115,17 +116,17 @@ SearchBox.displayName = 'SearchBox';
|
|||||||
const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({ onStockChange }) => (
|
const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({ onStockChange }) => (
|
||||||
<Box
|
<Box
|
||||||
position="relative"
|
position="relative"
|
||||||
bg={FUI_COLORS.bg.primary}
|
border="1px solid"
|
||||||
borderBottom="1px solid"
|
borderRadius="8px"
|
||||||
borderColor={FUI_COLORS.line.default}
|
sx={{
|
||||||
px={6}
|
bg: FUI_COLORS.bg.primary,
|
||||||
py={4}
|
borderColor: FUI_COLORS.line.default,
|
||||||
|
padding: '20px 20px',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
position="relative"
|
position="relative"
|
||||||
zIndex={1}
|
zIndex={1}
|
||||||
maxW="container.xl"
|
|
||||||
mx="auto"
|
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -332,7 +332,8 @@ const CompanyIndex: React.FC = () => {
|
|||||||
position="relative"
|
position="relative"
|
||||||
bg={THEME.bg}
|
bg={THEME.bg}
|
||||||
minH="calc(100vh - 60px)"
|
minH="calc(100vh - 60px)"
|
||||||
overflow="hidden"
|
overflowX="visible"
|
||||||
|
overflowY="hidden"
|
||||||
>
|
>
|
||||||
{/* ========================================
|
{/* ========================================
|
||||||
全局环境光效果
|
全局环境光效果
|
||||||
@@ -371,12 +372,10 @@ const CompanyIndex: React.FC = () => {
|
|||||||
|
|
||||||
{/*
|
{/*
|
||||||
内容容器
|
内容容器
|
||||||
- maxW="container.xl": 最大宽度限制,保持内容可读性
|
|
||||||
- mx="auto": 水平居中
|
|
||||||
- px={4}: 左右内边距 16px
|
|
||||||
- py={6}: 上下内边距 24px
|
- py={6}: 上下内边距 24px
|
||||||
|
- 水平 padding 由 MainLayout 统一设置
|
||||||
*/}
|
*/}
|
||||||
<Box maxW="container.xl" mx="auto" px={4} py={6}>
|
<Box py={6}>
|
||||||
{/* ========================================
|
{/* ========================================
|
||||||
股票行情卡片
|
股票行情卡片
|
||||||
========================================
|
========================================
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { logger } from '../../utils/logger';
|
|||||||
import defaultEventImage from '../../assets/img/default-event.jpg';
|
import defaultEventImage from '../../assets/img/default-event.jpg';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
Heading,
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
Input,
|
Input,
|
||||||
@@ -1538,12 +1537,14 @@ const ConceptCenter = () => {
|
|||||||
{/* 导航栏已由 MainLayout 提供 */}
|
{/* 导航栏已由 MainLayout 提供 */}
|
||||||
|
|
||||||
{/* Hero Section - 精简版 */}
|
{/* Hero Section - 精简版 */}
|
||||||
|
{/* Hero Section - 使用负 margin 抵消 Layout 的 padding 实现全宽背景 */}
|
||||||
<Box
|
<Box
|
||||||
position="relative"
|
position="relative"
|
||||||
bgGradient="linear(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)"
|
bgGradient="linear(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)"
|
||||||
color="white"
|
color="white"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
zIndex={1}
|
zIndex={1}
|
||||||
|
mx={{ base: -4, md: -6, lg: '-80px' }}
|
||||||
>
|
>
|
||||||
{/* 科幻网格背景 */}
|
{/* 科幻网格背景 */}
|
||||||
<Box
|
<Box
|
||||||
@@ -1579,7 +1580,7 @@ const ConceptCenter = () => {
|
|||||||
filter="blur(50px)"
|
filter="blur(50px)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Container maxW="container.xl" position="relative" py={{ base: 8, md: 12 }}>
|
<Box px={{ base: 4, md: 6, lg: '80px' }} position="relative" py={{ base: 8, md: 12 }}>
|
||||||
<VStack spacing={6}>
|
<VStack spacing={6}>
|
||||||
{/* 标题区域 */}
|
{/* 标题区域 */}
|
||||||
<VStack spacing={3} textAlign="center">
|
<VStack spacing={3} textAlign="center">
|
||||||
@@ -1743,11 +1744,11 @@ const ConceptCenter = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Container>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 主内容区域 - 深色主题 */}
|
{/* 主内容区域 - padding 由 MainLayout 统一设置 */}
|
||||||
<Container maxW="container.xl" py={10} position="relative" zIndex={1}>
|
<Box py={10} position="relative" zIndex={1}>
|
||||||
<Box mb={6}>
|
<Box mb={6}>
|
||||||
<DateSelector />
|
<DateSelector />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -2102,7 +2103,7 @@ const ConceptCenter = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Box>
|
||||||
|
|
||||||
{/* 股票详情Modal - 复用通用组件 */}
|
{/* 股票详情Modal - 复用通用组件 */}
|
||||||
<ConceptStocksModal
|
<ConceptStocksModal
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
Flex,
|
Flex,
|
||||||
Text,
|
Text,
|
||||||
Input,
|
Input,
|
||||||
@@ -480,7 +479,8 @@ const DataBrowser: React.FC = () => {
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Container maxW="container.xl" position="relative" zIndex={1}>
|
{/* padding 由 MainLayout 统一设置 */}
|
||||||
|
<Box position="relative" zIndex={1}>
|
||||||
{/* 标题区域 */}
|
{/* 标题区域 */}
|
||||||
<MotionBox
|
<MotionBox
|
||||||
initial={{ opacity: 0, y: -20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
@@ -866,7 +866,7 @@ const DataBrowser: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</MotionBox>
|
</MotionBox>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Container>
|
</Box>
|
||||||
|
|
||||||
{/* 指标数据详情模态框 */}
|
{/* 指标数据详情模态框 */}
|
||||||
{selectedMetric && (
|
{selectedMetric && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -39,6 +40,7 @@ import CitedContent from '../../../components/Citation/CitedContent';
|
|||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { getApiBase } from '../../../utils/apiConfig';
|
import { getApiBase } from '../../../utils/apiConfig';
|
||||||
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
|
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
|
||||||
|
import { selectSubscriptionInfo } from '../../../store/slices/subscriptionSlice';
|
||||||
|
|
||||||
// 节点样式配置 - 完全复刻Flask版本
|
// 节点样式配置 - 完全复刻Flask版本
|
||||||
const NODE_STYLES = {
|
const NODE_STYLES = {
|
||||||
@@ -460,6 +462,10 @@ function getSankeyOption(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TransmissionChainAnalysis = ({ eventId }) => {
|
const TransmissionChainAnalysis = ({ eventId }) => {
|
||||||
|
// 获取订阅信息,用于判断会员是否过期
|
||||||
|
const subscriptionInfo = useSelector(selectSubscriptionInfo);
|
||||||
|
const isSubscriptionExpired = subscriptionInfo.type !== 'free' && !subscriptionInfo.is_active;
|
||||||
|
|
||||||
// 状态管理
|
// 状态管理
|
||||||
const [graphData, setGraphData] = useState(null);
|
const [graphData, setGraphData] = useState(null);
|
||||||
const [sankeyData, setSankeyData] = useState(null);
|
const [sankeyData, setSankeyData] = useState(null);
|
||||||
@@ -474,7 +480,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
|
|||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
totalNodes: 0,
|
totalNodes: 0,
|
||||||
involvedIndustries: 0,
|
involvedIndustries: 0,
|
||||||
relatedCompanies: 0,
|
relatedCompanies: 0,
|
||||||
positiveImpact: 0,
|
positiveImpact: 0,
|
||||||
negativeImpact: 0,
|
negativeImpact: 0,
|
||||||
circularEffect: 0
|
circularEffect: 0
|
||||||
@@ -514,9 +520,18 @@ const TransmissionChainAnalysis = ({ eventId }) => {
|
|||||||
}
|
}
|
||||||
}, [graphData]);
|
}, [graphData]);
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据 - 如果会员过期则跳过 API 请求
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
|
// 会员已过期,不发起 API 请求
|
||||||
|
if (isSubscriptionExpired) {
|
||||||
|
logger.debug('TransmissionChain', '会员已过期,跳过传导链数据加载', { eventId });
|
||||||
|
setLoading(false);
|
||||||
|
setGraphData(null);
|
||||||
|
setSankeyData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -563,7 +578,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
|
|||||||
if (eventId) {
|
if (eventId) {
|
||||||
fetchData();
|
fetchData();
|
||||||
}
|
}
|
||||||
}, [eventId]);
|
}, [eventId, isSubscriptionExpired]);
|
||||||
|
|
||||||
// BFS路径查找 - 完全复刻Flask版本
|
// BFS路径查找 - 完全复刻Flask版本
|
||||||
function findPath(nodes, edges, fromId, toId) {
|
function findPath(nodes, edges, fromId, toId) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
@@ -39,21 +39,6 @@ import {
|
|||||||
import { getFormattedTextProps } from '../../../utils/textUtils';
|
import { getFormattedTextProps } from '../../../utils/textUtils';
|
||||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||||
import RiskDisclaimer from '../../../components/RiskDisclaimer';
|
import RiskDisclaimer from '../../../components/RiskDisclaimer';
|
||||||
import {
|
|
||||||
BarChart, Bar,
|
|
||||||
PieChart, Pie, Cell,
|
|
||||||
XAxis, YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip as RechartsTooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Treemap,
|
|
||||||
Area, AreaChart,
|
|
||||||
} from 'recharts';
|
|
||||||
// 词云库 - 支持两种实现
|
|
||||||
import { Wordcloud } from '@visx/wordcloud';
|
|
||||||
import { scaleLog } from '@visx/scale';
|
|
||||||
import { Text as VisxText } from '@visx/text';
|
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import 'echarts-wordcloud';
|
import 'echarts-wordcloud';
|
||||||
// 颜色配置
|
// 颜色配置
|
||||||
@@ -65,97 +50,7 @@ const CHART_COLORS = [
|
|||||||
// 词云颜色常量
|
// 词云颜色常量
|
||||||
const WORDCLOUD_COLORS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
|
const WORDCLOUD_COLORS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
|
||||||
|
|
||||||
// ==================== 词云组件实现 1: @visx/wordcloud ====================
|
// 词云组件 - 使用 ECharts 实现
|
||||||
// 使用 SVG 渲染,React 18 原生支持,配置灵活
|
|
||||||
const VisxWordCloud = ({ data }) => {
|
|
||||||
const [dimensions, setDimensions] = useState({ width: 0, height: 400 });
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
|
|
||||||
// 监听容器尺寸变化
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
|
|
||||||
const updateDimensions = () => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
setDimensions({
|
|
||||||
width: containerRef.current.offsetWidth,
|
|
||||||
height: 400
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateDimensions();
|
|
||||||
const resizeObserver = new ResizeObserver(updateDimensions);
|
|
||||||
resizeObserver.observe(containerRef.current);
|
|
||||||
|
|
||||||
return () => resizeObserver.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<Center h="400px">
|
|
||||||
<VStack>
|
|
||||||
<Text color="gray.500">暂无词云数据</Text>
|
|
||||||
</VStack>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const words = data.slice(0, 100).map(item => ({
|
|
||||||
name: item.name || item.text,
|
|
||||||
value: item.value || item.count || 1
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 计算字体大小比例
|
|
||||||
const fontScale = scaleLog({
|
|
||||||
domain: [
|
|
||||||
Math.min(...words.map(w => w.value)),
|
|
||||||
Math.max(...words.map(w => w.value))
|
|
||||||
],
|
|
||||||
range: [16, 80],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fontSizeSetter = (datum) => fontScale(datum.value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box ref={containerRef} h="400px" w="100%">
|
|
||||||
{dimensions.width > 0 && (
|
|
||||||
<svg width={dimensions.width} height={dimensions.height}>
|
|
||||||
<Wordcloud
|
|
||||||
words={words}
|
|
||||||
width={dimensions.width}
|
|
||||||
height={dimensions.height}
|
|
||||||
fontSize={fontSizeSetter}
|
|
||||||
font="Microsoft YaHei, sans-serif"
|
|
||||||
padding={3}
|
|
||||||
spiral="archimedean"
|
|
||||||
rotate={0}
|
|
||||||
random={() => 0.5}
|
|
||||||
>
|
|
||||||
{(cloudWords) =>
|
|
||||||
cloudWords.map((w, i) => (
|
|
||||||
<VisxText
|
|
||||||
key={w.text}
|
|
||||||
fill={WORDCLOUD_COLORS[i % WORDCLOUD_COLORS.length]}
|
|
||||||
textAnchor="middle"
|
|
||||||
transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
|
|
||||||
fontSize={w.size}
|
|
||||||
fontFamily={w.font}
|
|
||||||
fontWeight="bold"
|
|
||||||
>
|
|
||||||
{w.text}
|
|
||||||
</VisxText>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Wordcloud>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== 词云组件实现 2: ECharts Wordcloud ====================
|
|
||||||
// 使用 Canvas 渲染,内置交互效果(tooltip、emphasis),配置简单
|
|
||||||
const EChartsWordCloud = ({ data }) => {
|
const EChartsWordCloud = ({ data }) => {
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -216,9 +111,8 @@ const EChartsWordCloud = ({ data }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== 词云组件包装器 ====================
|
// 词云组件
|
||||||
// 统一接口,支持切换两种实现方式
|
const WordCloud = ({ data }) => {
|
||||||
const WordCloud = ({ data, engine = 'echarts' }) => {
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Center h="400px">
|
<Center h="400px">
|
||||||
@@ -229,8 +123,150 @@ const WordCloud = ({ data, engine = 'echarts' }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据 engine 参数选择实现方式
|
return <EChartsWordCloud data={data} />;
|
||||||
return engine === 'visx' ? <VisxWordCloud data={data} /> : <EChartsWordCloud data={data} />;
|
};
|
||||||
|
|
||||||
|
// 板块分布饼图 - ECharts 实现
|
||||||
|
const SectorPieChart = ({ data }) => {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Center h="400px">
|
||||||
|
<Text color="gray.500">暂无数据</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: '{b}: {c} ({d}%)'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
orient: 'vertical',
|
||||||
|
right: 10,
|
||||||
|
top: 'center',
|
||||||
|
textStyle: { fontSize: 12 }
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['0%', '65%'],
|
||||||
|
center: ['40%', '50%'],
|
||||||
|
avoidLabelOverlap: true,
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 4,
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
formatter: '{b} {d}%',
|
||||||
|
fontSize: 12
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
show: true,
|
||||||
|
length: 10,
|
||||||
|
length2: 15
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: data.map((item, index) => ({
|
||||||
|
name: item.name,
|
||||||
|
value: item.value,
|
||||||
|
itemStyle: { color: CHART_COLORS[index % CHART_COLORS.length] }
|
||||||
|
})),
|
||||||
|
animationDuration: 800
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
style={{ height: '400px', width: '100%' }}
|
||||||
|
opts={{ renderer: 'canvas' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 时间分布环形图 - ECharts 实现
|
||||||
|
const TimeDistributionChart = ({ data }) => {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Center h="300px">
|
||||||
|
<Text color="gray.500">暂无数据</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: '{b}: {c} ({d}%)'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
orient: 'horizontal',
|
||||||
|
bottom: 10,
|
||||||
|
textStyle: { fontSize: 12 }
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['45%', '70%'],
|
||||||
|
center: ['50%', '45%'],
|
||||||
|
avoidLabelOverlap: true,
|
||||||
|
padAngle: 3,
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 6,
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'outside',
|
||||||
|
formatter: '{b}\n{c}只',
|
||||||
|
fontSize: 11
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
show: true,
|
||||||
|
length: 8,
|
||||||
|
length2: 12
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: data.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
value: item.value,
|
||||||
|
itemStyle: { color: item.color }
|
||||||
|
})),
|
||||||
|
animationDuration: 800
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
style={{ height: '300px', width: '100%' }}
|
||||||
|
opts={{ renderer: 'canvas' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 板块热力图组件
|
// 板块热力图组件
|
||||||
@@ -592,27 +628,7 @@ export const DataAnalysis = ({ dailyData, wordCloudData, totalStocks, dateStr })
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<SectorPieChart data={pieData} />
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={pieData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
|
||||||
outerRadius={120}
|
|
||||||
fill="#8884d8"
|
|
||||||
dataKey="value"
|
|
||||||
animationBegin={0}
|
|
||||||
animationDuration={800}
|
|
||||||
>
|
|
||||||
{pieData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<RechartsTooltip />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
@@ -641,25 +657,7 @@ export const DataAnalysis = ({ dailyData, wordCloudData, totalStocks, dateStr })
|
|||||||
|
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<VStack spacing={4}>
|
<VStack spacing={4}>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<TimeDistributionChart data={timeDistributionData} />
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={timeDistributionData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={60}
|
|
||||||
outerRadius={100}
|
|
||||||
fill="#8884d8"
|
|
||||||
paddingAngle={5}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{timeDistributionData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<RechartsTooltip />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<SimpleGrid columns={3} spacing={4} w="full">
|
<SimpleGrid columns={3} spacing={4} w="full">
|
||||||
{timeDistributionData.map((item, index) => (
|
{timeDistributionData.map((item, index) => (
|
||||||
<Stat key={index} textAlign="center">
|
<Stat key={index} textAlign="center">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
VStack,
|
VStack,
|
||||||
HStack,
|
HStack,
|
||||||
Heading,
|
Heading,
|
||||||
@@ -263,8 +262,7 @@ export default function LimitAnalyse() {
|
|||||||
{/* 导航栏已由 MainLayout 提供 */}
|
{/* 导航栏已由 MainLayout 提供 */}
|
||||||
|
|
||||||
{/* 顶部Header */}
|
{/* 顶部Header */}
|
||||||
<Box bgGradient="linear(to-br, blue.500, purple.600)" color="white" py={8}>
|
<Box bgGradient="linear(to-br, blue.500, purple.600)" color="white" py={8} px={6} borderRadius="xl">
|
||||||
<Container maxW="container.xl">
|
|
||||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6} alignItems="stretch">
|
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6} alignItems="stretch">
|
||||||
{/* 左侧:标题置顶,注释与图例贴底 */}
|
{/* 左侧:标题置顶,注释与图例贴底 */}
|
||||||
<Flex direction="column" minH="420px" justify="space-between">
|
<Flex direction="column" minH="420px" justify="space-between">
|
||||||
@@ -366,11 +364,10 @@ export default function LimitAnalyse() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Container>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 主内容区 */}
|
{/* 主内容区 - padding 由 MainLayout 统一设置 */}
|
||||||
<Container maxW="container.xl" py={8}>
|
<Box py={8}>
|
||||||
{/* 搜索框 */}
|
{/* 搜索框 */}
|
||||||
<AdvancedSearch onSearch={handleSearch} loading={loading} />
|
<AdvancedSearch onSearch={handleSearch} loading={loading} />
|
||||||
|
|
||||||
@@ -402,7 +399,7 @@ export default function LimitAnalyse() {
|
|||||||
|
|
||||||
{/* 高位股统计 */}
|
{/* 高位股统计 */}
|
||||||
<HighPositionStocks dateStr={dateStr} />
|
<HighPositionStocks dateStr={dateStr} />
|
||||||
</Container>
|
</Box>
|
||||||
|
|
||||||
{/* 弹窗 */}
|
{/* 弹窗 */}
|
||||||
<SearchResultsModal
|
<SearchResultsModal
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxW="container.xl" py={8}>
|
<Box py={8}>
|
||||||
<VStack spacing={8} align="stretch">
|
<VStack spacing={8} align="stretch">
|
||||||
{/* 页面标题 */}
|
{/* 页面标题 */}
|
||||||
<HStack justify="space-between">
|
<HStack justify="space-between">
|
||||||
@@ -627,6 +627,6 @@ export default function ProfilePage() {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Container>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,6 @@ const ProfilePage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box bg={forumColors.background.main} minH="100vh" py="8">
|
<Box bg={forumColors.background.main} minH="100vh" py="8">
|
||||||
<Container maxW="container.xl">
|
|
||||||
{/* 用户信息头部 */}
|
{/* 用户信息头部 */}
|
||||||
<Card
|
<Card
|
||||||
bg={forumColors.background.card}
|
bg={forumColors.background.card}
|
||||||
@@ -375,7 +374,6 @@ const ProfilePage = () => {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</Container>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
VStack,
|
VStack,
|
||||||
HStack,
|
HStack,
|
||||||
Text,
|
Text,
|
||||||
@@ -219,7 +218,7 @@ export default function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxW="container.xl" py={8}>
|
<Box py={8}>
|
||||||
<VStack spacing={8} align="stretch">
|
<VStack spacing={8} align="stretch">
|
||||||
{/* 页面标题 */}
|
{/* 页面标题 */}
|
||||||
<Heading size="lg" color={headingColor}>账户设置</Heading>
|
<Heading size="lg" color={headingColor}>账户设置</Heading>
|
||||||
@@ -543,6 +542,6 @@ export default function SettingsPage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
</Container>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { getApiBase } from '@utils/apiConfig';
|
import { getApiBase } from '@utils/apiConfig';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
Heading,
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
Input,
|
Input,
|
||||||
@@ -633,6 +632,7 @@ const StockOverview = () => {
|
|||||||
pt={{ base: 20, md: 24 }}
|
pt={{ base: 20, md: 24 }}
|
||||||
pb={{ base: 16, md: 20 }}
|
pb={{ base: 16, md: 20 }}
|
||||||
borderBottom={`1px solid rgba(139, 92, 246, 0.3)`}
|
borderBottom={`1px solid rgba(139, 92, 246, 0.3)`}
|
||||||
|
borderRadius="xl"
|
||||||
zIndex={1}
|
zIndex={1}
|
||||||
>
|
>
|
||||||
{/* 背景装饰 */}
|
{/* 背景装饰 */}
|
||||||
@@ -648,7 +648,7 @@ const StockOverview = () => {
|
|||||||
filter="blur(60px)"
|
filter="blur(60px)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Container maxW="container.xl" position="relative">
|
<Box px={6} position="relative">
|
||||||
<VStack spacing={8} align="center">
|
<VStack spacing={8} align="center">
|
||||||
<VStack spacing={4} textAlign="center" maxW="3xl">
|
<VStack spacing={4} textAlign="center" maxW="3xl">
|
||||||
<HStack spacing={3}>
|
<HStack spacing={3}>
|
||||||
@@ -855,11 +855,11 @@ const StockOverview = () => {
|
|||||||
</Stat>
|
</Stat>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Container>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 主内容区 */}
|
{/* 主内容区 */}
|
||||||
<Container maxW="container.xl" py={10} position="relative" zIndex={1}>
|
<Box py={10} px={6} position="relative" zIndex={1}>
|
||||||
{/* 日期选择器 */}
|
{/* 日期选择器 */}
|
||||||
<Box mb={6}>
|
<Box mb={6}>
|
||||||
<Flex align="center" gap={4} flexWrap="wrap">
|
<Flex align="center" gap={4} flexWrap="wrap">
|
||||||
@@ -1195,7 +1195,7 @@ const StockOverview = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Box>
|
||||||
|
|
||||||
{/* 个股列表弹窗 */}
|
{/* 个股列表弹窗 */}
|
||||||
<ConceptStocksModal
|
<ConceptStocksModal
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
Heading,
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
HStack,
|
HStack,
|
||||||
@@ -157,7 +156,6 @@ const PostDetail = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box minH="100vh" bg={forumColors.background.main} pt="80px" pb="20">
|
<Box minH="100vh" bg={forumColors.background.main} pt="80px" pb="20">
|
||||||
<Container maxW="container.xl">
|
|
||||||
{/* 返回按钮 */}
|
{/* 返回按钮 */}
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<ArrowLeft size={18} />}
|
leftIcon={<ArrowLeft size={18} />}
|
||||||
@@ -374,7 +372,6 @@ const PostDetail = () => {
|
|||||||
</MotionBox>
|
</MotionBox>
|
||||||
</Box>
|
</Box>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Container>
|
|
||||||
|
|
||||||
{/* 图片预览弹窗 */}
|
{/* 图片预览弹窗 */}
|
||||||
<ImagePreviewModal
|
<ImagePreviewModal
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
Heading,
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
Button,
|
Button,
|
||||||
@@ -224,7 +223,6 @@ const PredictionTopicDetail = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box minH="100vh" bg={forumColors.background.main} pt={LAYOUT_SIZE.navbarHeight} pb={{ base: "6", md: "20" }}>
|
<Box minH="100vh" bg={forumColors.background.main} pt={LAYOUT_SIZE.navbarHeight} pb={{ base: "6", md: "20" }}>
|
||||||
<Container maxW="container.xl" px={{ base: "3", sm: "4", md: "6" }}>
|
|
||||||
{/* 头部:返回按钮 */}
|
{/* 头部:返回按钮 */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -627,7 +625,6 @@ const PredictionTopicDetail = () => {
|
|||||||
</MotionBox>
|
</MotionBox>
|
||||||
</Box>
|
</Box>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Container>
|
|
||||||
|
|
||||||
{/* 交易模态框 */}
|
{/* 交易模态框 */}
|
||||||
<TradeModal
|
<TradeModal
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
|
||||||
Heading,
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
Button,
|
Button,
|
||||||
@@ -154,7 +153,8 @@ const ValueForum = () => {
|
|||||||
pt="80px"
|
pt="80px"
|
||||||
pb="20"
|
pb="20"
|
||||||
>
|
>
|
||||||
<Container maxW="container.xl">
|
{/* padding 由 MainLayout 统一设置 */}
|
||||||
|
<Box>
|
||||||
{/* 顶部横幅 */}
|
{/* 顶部横幅 */}
|
||||||
<MotionBox
|
<MotionBox
|
||||||
initial={{ opacity: 0, y: -20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
@@ -469,7 +469,7 @@ const ValueForum = () => {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Container>
|
</Box>
|
||||||
|
|
||||||
{/* 发帖模态框 */}
|
{/* 发帖模态框 */}
|
||||||
<CreatePostModal
|
<CreatePostModal
|
||||||
|
|||||||
Reference in New Issue
Block a user