Compare commits

...

20 Commits

Author SHA1 Message Date
zdl
986ec05eb1 Merge branch 'feature_bugfix/251217_stock' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251217_stock
* 'feature_bugfix/251217_stock' of https://git.valuefrontier.cn/vf/vf_react:
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
  更新Company页面的UI为FUI风格
2025-12-19 10:16:07 +08:00
zdl
02cc3eadd9 feat: 新增 financialService 类型声明和 EChartsWrapper 组件
- financialService.d.ts: 为 JS 服务文件提供 TypeScript 类型声明
- EChartsWrapper.tsx: 按需引入的 ECharts 包装组件,减小打包体积

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 10:15:59 +08:00
zdl
51721ce9bf perf(Company): 优化渲染性能和 API 请求
- StockQuoteCard: 添加 memo 包装减少重渲染
- Company/index: componentProps 使用 useMemo 缓存
- useCompanyEvents: 页面浏览事件只触发一次,避免重复追踪
- useCompanyData: 自选股状态改用单股票查询接口,减少数据传输
- CompanyHeader: inputCode 状态下移到 SearchActions,减少父组件重渲染
- CompanyHeader: 移除重复环境光效果,由全局 AmbientGlow 统一处理

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 10:14:07 +08:00
zdl
c979e775a5 perf(Company): 恢复 CompanyContent 的 memo 包装
- 将主内容区提取为独立的 memo 包装组件
- 避免父组件状态变化导致不必要的重渲染
- 提升页面性能

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:43:05 +08:00
zdl
2720946ccf fix(types): 修复 ECharts 类型导出和组件类型冲突
- echarts.ts: 将 EChartsOption 改为 EChartsCoreOption 的类型别名
- FuiCorners: 移除 extends BoxProps,position 重命名为 corner
- KLineChartModal/TimelineChartModal/ConcentrationCard: 使用导入的 EChartsOption
- LoadingState: 新增骨架屏 variant 支持
- FinancialPanorama: 使用骨架屏加载状态
- useFinancialData/financialService: 优化数据获取逻辑
- Company/index: 简化组件结构

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:42:19 +08:00
zdl
5331bc64b4 perf: 优化各 Tab 数据加载为按需请求
MarketDataView (股票行情):
- 初始只加载 summary + tradeData(2个接口)
- funding/bigDeal/unusual/pledge 数据在切换 Tab 时按需加载
- 新增 loadDataByType 方法支持懒加载

FinancialPanorama (财务全景):
- 初始只加载 stockInfo + metrics + comparison + mainBusiness(4个接口)
- 从9个接口优化到4个接口

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:32:14 +08:00
zdl
3953efc2ed refactor(theme): 统一黑金主题常量,减少硬编码
- theme/index.ts: 添加 COLORS, GLOW, GLASS 便捷常量
- theme/index.ts: 导出 glassCardStyle 可复用样式
- BusinessInfoPanel: 迁移到使用统一主题常量

迁移指南:
- import { COLORS, GLASS, glassCardStyle } from '@views/Company/theme'
- 替换 rgba(212, 175, 55, x) → COLORS.border / COLORS.borderHover
- 替换硬编码背景 → GLASS.bgDark / COLORS.bgGlass

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:29:42 +08:00
zdl
50d59fd2ad perf(DeepAnalysis): 优化初始加载,只请求 comprehensive 接口
- 移除初始加载时的 industryRank 请求
- 只加载默认 Tab(战略分析)需要的核心数据
- 其他数据按需懒加载

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:27:57 +08:00
zdl
eaa65b2328 fix(SubTabContainer): 移除外层 Suspense,Tab 内容直接展示
- SubTabContainer 内部为每个 Tab 添加 Suspense fallback={null}
- 移除 Company/index.tsx 外层 Suspense 和 TabLoadingFallback
- 切换一级 Tab 时不再显示整体 loading,直接展示内容

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:23:16 +08:00
zdl
79572fcc98 style(BusinessInfoPanel): 优化工商信息模块 UI
- 使用玻璃态卡片布局(Glassmorphism)
- 添加图标增强视觉效果
- 信息行使用悬停效果
- 服务机构使用独立卡片展示
- 主营业务/经营范围两列布局
- 统一 FUI 黑金主题风格

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:16:42 +08:00
zdl
997724e0b1 fix(FinancialPanorama): 优化 loading 状态,Tabs 立即显示
- 移除 SubTabContainer 的 loading 条件渲染,Tabs 始终可见
- 各 Tab 组件内部处理 loading 状态,显示 Spinner
- 传递 loading 和 loadingTab 到 componentProps
- 修改 BalanceSheetTab、IncomeStatementTab、CashflowTab、
  FinancialMetricsTab、MetricsCategoryTab 支持 loading 属性

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:15:30 +08:00
zdl
ec2270ca8e fix(mock): 修复股权集中度和实控人数据格式
- 移除 holding_ratio 除以 100 的错误转换
- 数据保持原始百分比格式(如 52.38 表示 52.38%)
- 修复饼图显示异常问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:14:42 +08:00
zdl
44ba2e24e8 fix(SubTabContainer): 保持 Tab 面板挂载状态,防止切换时状态丢失
- 添加 lazyBehavior="keepMounted" 属性
- 修复切换一级 Tab 后二级 Tab 状态被完全重置的问题
- 组件仍然懒加载,但首次渲染后保持挂载

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:09:36 +08:00
zdl
8e679b56f4 style(StockQuoteCard): 优化布局和样式
- 数据区块改为三列布局:估值指标 | 市值股本 | 主力动态
- 流通市值、发行总股本、52周波动 放在同一列
- 区块标题高亮显示(金色 + 发光效果)
- 注释掉公司信息模块(成立日期、注册资本、所在地等)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:03:28 +08:00
zdl
ae397ac904 feat(mock): 完善 Mock 数据,修复 API 返回格式
- event.js: 修复 /api/events 返回格式,匹配 useEventData 期望的结构
- stock.js: 添加 /api/stock/:code/quote-detail handler(完整行情数据含买卖盘)
- stock.js: 添加 /api/flex-screen/quotes handler(指数行情)
- stock.js: 修复 /api/index/:code/kline 支持 minute 类型

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:03:21 +08:00
zdl
a5bc1e1ce3 fix: 优化错误处理,减少控制台噪音
- axiosConfig: 忽略 CanceledError 错误日志(组件卸载时的正常行为)
- socketService: 首次连接失败使用 warn 级别,后续重试使用 debug 级别
- useEventData: 添加防御性检查,防止 pagination 为 undefined 时崩溃

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:03:10 +08:00
zdl
2ce74b4331 style: 移除主 Tab 内容区的 padding
- Company/index.tsx: contentPadding 从 6 改为 0

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:01:56 +08:00
zdl
7931abe89b style: 移除公司概览与股权结构之间的间距
- BasicInfoTab: 设置 contentPadding={0}

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:00:16 +08:00
zdl
9b8983869c style: 子 Tab 紧凑模式,移除多余边距
- SubTabContainer: 添加 compact 属性
  - 移除 TabList 的 mx/mb 外边距
  - 移除圆角和阴影
  - 减小垂直内边距
- BasicInfoTab: 启用 compact 模式,移除 Card 包裹

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 17:54:56 +08:00
zdl
4b3588e8de feat: 将 StockQuoteCard 提升到 Tab 容器上方 + 修复 TS 警告
功能变更:
- 将 StockQuoteCard 从 CompanyOverview 移至 Company/index.tsx
- 股票行情卡片现在在切换 Tab 时始终可见

TypeScript 警告修复:
- SubTabContainer: WebkitBackdropFilter 改用 sx 属性
- DetailTable: 重新定义 TableRowData 类型,支持 boolean 索引
- SubscriptionContentNew: 添加类型安全的 AGREEMENT_URLS 索引访问

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 17:25:21 +08:00
58 changed files with 1784 additions and 611 deletions

View File

@@ -0,0 +1,40 @@
/**
* ECharts 包装组件
*
* 基于 echarts-for-react使用按需引入的 echarts 实例
* 减少打包体积约 500KB
*
* @example
* ```tsx
* import ECharts from '@components/Charts/ECharts';
*
* <ECharts option={chartOption} style={{ height: 300 }} />
* ```
*/
import React, { forwardRef } from 'react';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import { echarts } from '@lib/echarts';
// Re-export ReactEChartsCore props type
import type { EChartsReactProps } from 'echarts-for-react';
export type EChartsProps = Omit<EChartsReactProps, 'echarts'>;
/**
* ECharts 图表组件
* 自动使用按需引入的 echarts 实例
*/
const ECharts = forwardRef<ReactEChartsCore, EChartsProps>((props, ref) => {
return (
<ReactEChartsCore
ref={ref}
echarts={echarts}
{...props}
/>
);
});
ECharts.displayName = 'ECharts';
export default ECharts;

View File

@@ -1,7 +1,7 @@
// src/components/Charts/Stock/MiniTimelineChart.js // src/components/Charts/Stock/MiniTimelineChart.js
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts'; import { echarts } from '@lib/echarts';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
fetchKlineData, fetchKlineData,

View File

@@ -3,7 +3,7 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { Box, useColorModeValue } from '@chakra-ui/react'; import { Box, useColorModeValue } from '@chakra-ui/react';
import * as echarts from 'echarts'; import { echarts } from '@lib/echarts';
/** /**
* ECharts 图表渲染组件 * ECharts 图表渲染组件

View File

@@ -0,0 +1,81 @@
/**
* 环境光效果组件
* James Turrell 风格的背景光晕效果
*/
import React, { memo } from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
export interface AmbientGlowProps extends Omit<BoxProps, 'bg'> {
/** 预设主题 */
variant?: 'default' | 'gold' | 'blue' | 'purple' | 'warm';
/** 自定义渐变(覆盖 variant */
customGradient?: string;
}
// 预设光效配置
const GLOW_VARIANTS = {
default: `
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(212, 175, 55, 0.08), transparent 50%),
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(100, 200, 255, 0.04), transparent 40%),
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(255, 200, 100, 0.04), transparent 40%)
`,
gold: `
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(212, 175, 55, 0.12), transparent 50%),
radial-gradient(ellipse 80% 60% at 20% 80%, rgba(212, 175, 55, 0.06), transparent 40%),
radial-gradient(ellipse 80% 60% at 80% 80%, rgba(255, 200, 100, 0.05), transparent 40%)
`,
blue: `
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(100, 200, 255, 0.1), transparent 50%),
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(60, 160, 255, 0.06), transparent 40%),
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(140, 220, 255, 0.05), transparent 40%)
`,
purple: `
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(160, 100, 255, 0.1), transparent 50%),
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(200, 150, 255, 0.05), transparent 40%),
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(120, 80, 255, 0.05), transparent 40%)
`,
warm: `
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(255, 150, 100, 0.1), transparent 50%),
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(255, 200, 150, 0.05), transparent 40%),
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(255, 180, 120, 0.05), transparent 40%)
`,
};
/**
* 环境光效果组件
* 创建 James Turrell 风格的微妙背景光晕
*
* @example
* ```tsx
* <Box position="relative">
* <AmbientGlow variant="gold" />
* {children}
* </Box>
* ```
*/
const AmbientGlow = memo<AmbientGlowProps>(({
variant = 'default',
customGradient,
...boxProps
}) => {
const gradient = customGradient || GLOW_VARIANTS[variant];
return (
<Box
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
pointerEvents="none"
zIndex={0}
bg={gradient}
{...boxProps}
/>
);
});
AmbientGlow.displayName = 'AmbientGlow';
export default AmbientGlow;

View File

@@ -0,0 +1,93 @@
/**
* FUI 毛玻璃容器组件
* 科幻风格的 Glassmorphism 容器,带角落装饰
*/
import React, { memo, ReactNode } from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
import FuiCorners, { FuiCornersProps } from './FuiCorners';
export interface FuiContainerProps extends Omit<BoxProps, 'children'> {
children: ReactNode;
/** 是否显示角落装饰 */
showCorners?: boolean;
/** 角落装饰配置 */
cornersProps?: FuiCornersProps;
/** 预设主题 */
variant?: 'default' | 'gold' | 'blue' | 'dark';
}
// 预设主题配置
const VARIANTS = {
default: {
bg: 'linear-gradient(145deg, rgba(26, 26, 46, 0.95) 0%, rgba(15, 15, 26, 0.98) 100%)',
borderColor: 'rgba(212, 175, 55, 0.15)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
cornerColor: 'rgba(212, 175, 55, 0.4)',
},
gold: {
bg: 'linear-gradient(145deg, rgba(26, 26, 46, 0.95) 0%, rgba(15, 15, 26, 0.98) 100%)',
borderColor: 'rgba(212, 175, 55, 0.2)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(212, 175, 55, 0.1)',
cornerColor: 'rgba(212, 175, 55, 0.5)',
},
blue: {
bg: 'linear-gradient(145deg, rgba(20, 30, 48, 0.95) 0%, rgba(10, 15, 26, 0.98) 100%)',
borderColor: 'rgba(100, 200, 255, 0.15)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(100, 200, 255, 0.05)',
cornerColor: 'rgba(100, 200, 255, 0.4)',
},
dark: {
bg: 'linear-gradient(145deg, rgba(18, 18, 28, 0.98) 0%, rgba(8, 8, 16, 0.99) 100%)',
borderColor: 'rgba(255, 255, 255, 0.08)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.03)',
cornerColor: 'rgba(255, 255, 255, 0.2)',
},
};
/**
* FUI 毛玻璃容器组件
* 带有科幻风格角落装饰的 Glassmorphism 容器
*
* @example
* ```tsx
* <FuiContainer variant="gold">
* <YourContent />
* </FuiContainer>
* ```
*/
const FuiContainer = memo<FuiContainerProps>(({
children,
showCorners = true,
cornersProps,
variant = 'default',
...boxProps
}) => {
const theme = VARIANTS[variant];
return (
<Box
position="relative"
bg={theme.bg}
borderRadius="xl"
border="1px solid"
borderColor={theme.borderColor}
overflow="hidden"
backdropFilter="blur(16px)"
boxShadow={theme.boxShadow}
{...boxProps}
>
{showCorners && (
<FuiCorners
borderColor={theme.cornerColor}
{...cornersProps}
/>
)}
{children}
</Box>
);
});
FuiContainer.displayName = 'FuiContainer';
export default FuiContainer;

View File

@@ -0,0 +1,126 @@
/**
* FUI 角落装饰组件
* Ash Thorp 风格的科幻 UI 角落装饰
*/
import React, { memo } from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
export interface FuiCornersProps {
/** 装饰框大小 */
size?: number;
/** 边框宽度 */
borderWidth?: number;
/** 边框颜色 */
borderColor?: string;
/** 透明度 */
opacity?: number;
/** 距离容器边缘的距离 */
offset?: number;
}
interface CornerBoxProps {
corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
size: number;
borderWidth: number;
borderColor: string;
opacity: number;
offset: number;
}
const CornerBox = memo<CornerBoxProps>(({
corner,
size,
borderWidth,
borderColor,
opacity,
offset,
}) => {
const cornerStyles: Record<string, BoxProps> = {
'top-left': {
top: `${offset}px`,
left: `${offset}px`,
borderTop: `${borderWidth}px solid`,
borderLeft: `${borderWidth}px solid`,
},
'top-right': {
top: `${offset}px`,
right: `${offset}px`,
borderTop: `${borderWidth}px solid`,
borderRight: `${borderWidth}px solid`,
},
'bottom-left': {
bottom: `${offset}px`,
left: `${offset}px`,
borderBottom: `${borderWidth}px solid`,
borderLeft: `${borderWidth}px solid`,
},
'bottom-right': {
bottom: `${offset}px`,
right: `${offset}px`,
borderBottom: `${borderWidth}px solid`,
borderRight: `${borderWidth}px solid`,
},
};
return (
<Box
position="absolute"
w={`${size}px`}
h={`${size}px`}
borderColor={borderColor}
opacity={opacity}
pointerEvents="none"
{...cornerStyles[corner]}
/>
);
});
CornerBox.displayName = 'CornerBox';
/**
* FUI 角落装饰组件
* 在容器四角添加科幻风格的装饰边框
*
* @example
* ```tsx
* <Box position="relative">
* <FuiCorners />
* {children}
* </Box>
* ```
*/
const FuiCorners = memo<FuiCornersProps>(({
size = 16,
borderWidth = 2,
borderColor = 'rgba(212, 175, 55, 0.4)',
opacity = 0.6,
offset = 12,
}) => {
const corners: CornerBoxProps['corner'][] = [
'top-left',
'top-right',
'bottom-left',
'bottom-right',
];
return (
<>
{corners.map((corner) => (
<CornerBox
key={corner}
corner={corner}
size={size}
borderWidth={borderWidth}
borderColor={borderColor}
opacity={opacity}
offset={offset}
/>
))}
</>
);
});
FuiCorners.displayName = 'FuiCorners';
export default FuiCorners;

View File

@@ -0,0 +1,12 @@
/**
* FUI (Futuristic UI) 组件集合
* Ash Thorp 风格的科幻 UI 组件
*/
export { default as FuiCorners } from './FuiCorners';
export { default as FuiContainer } from './FuiContainer';
export { default as AmbientGlow } from './AmbientGlow';
export type { FuiCornersProps } from './FuiCorners';
export type { FuiContainerProps } from './FuiContainer';
export type { AmbientGlowProps } from './AmbientGlow';

View File

@@ -2,7 +2,8 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import * as echarts from 'echarts'; import { echarts } from '@lib/echarts';
import type { ECharts, EChartsOption } from '@lib/echarts';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { stockService } from '@services/eventService'; import { stockService } from '@services/eventService';
import { selectIsMobile } from '@store/slices/deviceSlice'; import { selectIsMobile } from '@store/slices/deviceSlice';
@@ -295,7 +296,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
} }
// 图表配置H5 响应式) // 图表配置H5 响应式)
const option: echarts.EChartsOption = { const option: EChartsOption = {
backgroundColor: '#1a1a1a', backgroundColor: '#1a1a1a',
title: { title: {
text: `${stock?.stock_name || stock?.stock_code} - 日K线`, text: `${stock?.stock_name || stock?.stock_code} - 日K线`,

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Modal, Button, Spin, Typography } from 'antd'; import { Modal, Button, Spin, Typography } from 'antd';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts'; import { echarts } from '@lib/echarts';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { stockService } from '../../services/eventService'; import { stockService } from '../../services/eventService';
import CitedContent from '../Citation/CitedContent'; import CitedContent from '../Citation/CitedContent';

View File

@@ -17,7 +17,7 @@ import {
Alert, Alert,
AlertIcon, AlertIcon,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import * as echarts from 'echarts'; import { echarts, type ECharts, type EChartsOption } from '@lib/echarts';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { klineDataCache, getCacheKey, fetchKlineData } from '@utils/stock/klineDataCache'; import { klineDataCache, getCacheKey, fetchKlineData } from '@utils/stock/klineDataCache';
import { selectIsMobile } from '@store/slices/deviceSlice'; import { selectIsMobile } from '@store/slices/deviceSlice';
@@ -186,7 +186,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
} }
// 图表配置H5 响应式) // 图表配置H5 响应式)
const option: echarts.EChartsOption = { const option: EChartsOption = {
backgroundColor: '#1a1a1a', backgroundColor: '#1a1a1a',
title: { title: {
text: `${stock?.stock_name || stock?.stock_code} - 分时图`, text: `${stock?.stock_name || stock?.stock_code} - 分时图`,

View File

@@ -19,7 +19,7 @@
* ``` * ```
*/ */
import React, { useState, useCallback, memo } from 'react'; import React, { useState, useCallback, memo, Suspense } from 'react';
import { import {
Box, Box,
Tabs, Tabs,
@@ -31,6 +31,8 @@ import {
HStack, HStack,
Text, Text,
Spacer, Spacer,
Center,
Spinner,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { IconType } from 'react-icons'; import type { IconType } from 'react-icons';
@@ -134,6 +136,8 @@ export interface SubTabContainerProps {
isLazy?: boolean; isLazy?: boolean;
/** TabList 右侧自定义内容 */ /** TabList 右侧自定义内容 */
rightElement?: React.ReactNode; rightElement?: React.ReactNode;
/** 紧凑模式 - 移除 TabList 的外边距 */
compact?: boolean;
} }
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
@@ -147,6 +151,7 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
contentPadding = 4, contentPadding = 4,
isLazy = true, isLazy = true,
rightElement, rightElement,
compact = false,
}) => { }) => {
// 内部状态(非受控模式) // 内部状态(非受控模式)
const [internalIndex, setInternalIndex] = useState(defaultIndex); const [internalIndex, setInternalIndex] = useState(defaultIndex);
@@ -190,6 +195,7 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
<Box> <Box>
<Tabs <Tabs
isLazy={isLazy} isLazy={isLazy}
lazyBehavior="keepMounted"
variant="unstyled" variant="unstyled"
index={currentIndex} index={currentIndex}
onChange={handleTabChange} onChange={handleTabChange}
@@ -198,20 +204,20 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
<TabList <TabList
bg={theme.bg} bg={theme.bg}
backdropFilter="blur(20px)" backdropFilter="blur(20px)"
WebkitBackdropFilter="blur(20px)" sx={{ WebkitBackdropFilter: 'blur(20px)' }}
borderBottom="1px solid" borderBottom="1px solid"
borderColor={theme.borderColor} borderColor={theme.borderColor}
borderRadius={DEEP_SPACE.radiusLG} borderRadius={compact ? 0 : DEEP_SPACE.radiusLG}
mx={2} mx={compact ? 0 : 2}
mb={2} mb={compact ? 0 : 2}
px={3} px={3}
py={3} py={compact ? 2 : 3}
flexWrap="nowrap" flexWrap="nowrap"
gap={2} gap={2}
alignItems="center" alignItems="center"
overflowX="auto" overflowX="auto"
position="relative" position="relative"
boxShadow={DEEP_SPACE.innerGlow} boxShadow={compact ? 'none' : DEEP_SPACE.innerGlow}
css={{ css={{
'&::-webkit-scrollbar': { display: 'none' }, '&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none', scrollbarWidth: 'none',
@@ -307,7 +313,20 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
return ( return (
<TabPanel key={tab.key} p={0}> <TabPanel key={tab.key} p={0}>
{shouldRender && Component ? ( {shouldRender && Component ? (
<Suspense
fallback={
<Center py={20}>
<Spinner
size="lg"
color={DEEP_SPACE.textGold}
thickness="3px"
speed="0.8s"
/>
</Center>
}
>
<Component {...componentProps} /> <Component {...componentProps} />
</Suspense>
) : null} ) : null}
</TabPanel> </TabPanel>
); );

View File

@@ -1632,14 +1632,17 @@ export default function SubscriptionContentNew() {
<Text fontSize="sm" color="rgba(255, 255, 255, 0.7)"> <Text fontSize="sm" color="rgba(255, 255, 255, 0.7)">
<ChakraLink <ChakraLink
href={AGREEMENT_URLS[(selectedPlan as any)?.name?.toLowerCase()] || AGREEMENT_URLS.pro} href={(() => {
const planName = (selectedPlan as { name?: string } | null)?.name?.toLowerCase();
return planName === 'pro' || planName === 'max' ? AGREEMENT_URLS[planName] : AGREEMENT_URLS.pro;
})()}
isExternal isExternal
color="#3182CE" color="#3182CE"
textDecoration="underline" textDecoration="underline"
mx={1} mx={1}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{(selectedPlan as any)?.name?.toLowerCase() === 'max' ? 'MAX' : 'PRO'} {(selectedPlan as { name?: string } | null)?.name?.toLowerCase() === 'max' ? 'MAX' : 'PRO'}
</ChakraLink> </ChakraLink>
</Text> </Text>
</Checkbox> </Checkbox>

View File

@@ -0,0 +1,80 @@
/**
* 动态设置网页标题的 Hook
*/
import { useEffect } from 'react';
export interface UseDocumentTitleOptions {
/** 基础标题(默认:价值前沿) */
baseTitle?: string;
/** 是否在组件卸载时恢复基础标题 */
restoreOnUnmount?: boolean;
}
/**
* 动态设置网页标题
*
* @param title - 要显示的标题(会与 baseTitle 组合)
* @param options - 配置选项
*
* @example
* ```tsx
* // 基础用法
* useDocumentTitle('我的页面');
* // 结果: "我的页面 - 价值前沿"
*
* // 股票页面
* useDocumentTitle(stockName ? `${stockName}(${stockCode})` : stockCode);
* // 结果: "平安银行(000001) - 价值前沿"
*
* // 自定义基础标题
* useDocumentTitle('Dashboard', { baseTitle: 'My App' });
* // 结果: "Dashboard - My App"
* ```
*/
export const useDocumentTitle = (
title?: string | null,
options: UseDocumentTitleOptions = {}
): void => {
const { baseTitle = '价值前沿', restoreOnUnmount = true } = options;
useEffect(() => {
if (title) {
document.title = `${title} - ${baseTitle}`;
} else {
document.title = baseTitle;
}
// 组件卸载时恢复默认标题
if (restoreOnUnmount) {
return () => {
document.title = baseTitle;
};
}
}, [title, baseTitle, restoreOnUnmount]);
};
/**
* 股票页面专用的标题 Hook
*
* @param stockCode - 股票代码
* @param stockName - 股票名称(可选)
*
* @example
* ```tsx
* useStockDocumentTitle('000001', '平安银行');
* // 结果: "平安银行(000001) - 价值前沿"
* ```
*/
export const useStockDocumentTitle = (
stockCode: string,
stockName?: string | null
): void => {
const title = stockName
? `${stockName}(${stockCode})`
: stockCode || null;
useDocumentTitle(title);
};
export default useDocumentTitle;

124
src/lib/echarts.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* ECharts 按需导入配置
*
* 使用方式:
* import { echarts } from '@lib/echarts';
*
* 优势:
* - 减小打包体积(从 ~800KB 降至 ~200-300KB
* - Tree-shaking 支持
* - 统一管理图表类型和组件
*/
// 核心模块
import * as echarts from 'echarts/core';
// 图表类型 - 按需导入
import {
LineChart,
BarChart,
PieChart,
CandlestickChart,
ScatterChart,
} from 'echarts/charts';
// 组件 - 按需导入
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
ToolboxComponent,
MarkLineComponent,
MarkPointComponent,
MarkAreaComponent,
DatasetComponent,
TransformComponent,
} from 'echarts/components';
// 渲染器
import { CanvasRenderer } from 'echarts/renderers';
// 类型导出
import type {
ECharts,
EChartsCoreOption,
SetOptionOpts,
ComposeOption,
} from 'echarts/core';
import type {
LineSeriesOption,
BarSeriesOption,
PieSeriesOption,
CandlestickSeriesOption,
ScatterSeriesOption,
} from 'echarts/charts';
import type {
TitleComponentOption,
TooltipComponentOption,
LegendComponentOption,
GridComponentOption,
DataZoomComponentOption,
ToolboxComponentOption,
MarkLineComponentOption,
MarkPointComponentOption,
MarkAreaComponentOption,
DatasetComponentOption,
} from 'echarts/components';
// 注册必需的组件
echarts.use([
// 图表类型
LineChart,
BarChart,
PieChart,
CandlestickChart,
ScatterChart,
// 组件
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
ToolboxComponent,
MarkLineComponent,
MarkPointComponent,
MarkAreaComponent,
DatasetComponent,
TransformComponent,
// 渲染器
CanvasRenderer,
]);
// 组合类型定义(用于 TypeScript 类型推断)
export type ECOption = ComposeOption<
| LineSeriesOption
| BarSeriesOption
| PieSeriesOption
| CandlestickSeriesOption
| ScatterSeriesOption
| TitleComponentOption
| TooltipComponentOption
| LegendComponentOption
| GridComponentOption
| DataZoomComponentOption
| ToolboxComponentOption
| MarkLineComponentOption
| MarkPointComponentOption
| MarkAreaComponentOption
| DatasetComponentOption
>;
// 导出
export { echarts };
// EChartsOption 类型别名(兼容旧代码)
export type EChartsOption = EChartsCoreOption;
export type { ECharts, SetOptionOpts };
// 默认导出(兼容 import * as echarts from 'echarts' 的用法)
export default echarts;

View File

@@ -69,13 +69,8 @@ export const companyHandlers = [
const data = getCompanyData(stockCode); const data = getCompanyData(stockCode);
const raw = data.actualControl; const raw = data.actualControl;
// 数据已经是数组格式只做数值转换holding_ratio 从 0-100 转为 0-1 // 数据保持原始百分比格式(如 52.38 表示 52.38%
const formatted = Array.isArray(raw) const formatted = Array.isArray(raw) ? raw : [];
? raw.map(item => ({
...item,
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
}))
: [];
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,
@@ -90,13 +85,8 @@ export const companyHandlers = [
const data = getCompanyData(stockCode); const data = getCompanyData(stockCode);
const raw = data.concentration; const raw = data.concentration;
// 数据已经是数组格式只做数值转换holding_ratio 从 0-100 转为 0-1 // 数据保持原始百分比格式(如 52.38 表示 52.38%
const formatted = Array.isArray(raw) const formatted = Array.isArray(raw) ? raw : [];
? raw.map(item => ({
...item,
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
}))
: [];
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,

View File

@@ -120,12 +120,14 @@ export const eventHandlers = [
try { try {
const result = generateMockEvents(params); const result = generateMockEvents(params);
// 返回格式兼容 NewsPanel 期望的结构 // 返回格式兼容 useEventData 期望的结构
// NewsPanel 期望: { success, data: [], pagination: {} } // useEventData 期望: { success, data: { events: [], pagination: {} } }
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,
data: result.events, // 事件数组 data: {
pagination: result.pagination, // 分页信息 events: result.events, // 事件数组
pagination: result.pagination // 分页信息
},
message: '获取成功' message: '获取成功'
}); });
} catch (error) { } catch (error) {

View File

@@ -263,15 +263,15 @@ export const stockHandlers = [
try { try {
let data; let data;
if (type === 'timeline') { if (type === 'timeline' || type === 'minute') {
// timeline 和 minute 都使用分时数据
data = generateTimelineData(indexCode); data = generateTimelineData(indexCode);
} else if (type === 'daily') { } else if (type === 'daily') {
data = generateDailyData(indexCode, 30); data = generateDailyData(indexCode, 30);
} else { } else {
return HttpResponse.json( // 其他类型也降级使用 timeline 数据
{ error: '不支持的类型' }, console.log('[Mock Stock] 未知类型,降级使用 timeline:', type);
{ status: 400 } data = generateTimelineData(indexCode);
);
} }
return HttpResponse.json({ return HttpResponse.json({
@@ -558,4 +558,133 @@ export const stockHandlers = [
); );
} }
}), }),
// 获取股票详细行情quote-detail
http.get('/api/stock/:stockCode/quote-detail', async ({ params }) => {
await delay(200);
const { stockCode } = params;
console.log('[Mock Stock] 获取股票详细行情:', { stockCode });
const stocks = generateStockList();
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
const stockInfo = stocks.find(s => s.code === codeWithoutSuffix);
const stockName = stockInfo?.name || `股票${stockCode}`;
// 生成基础价格10-200之间
const basePrice = parseFloat((Math.random() * 190 + 10).toFixed(2));
// 涨跌幅(-10% 到 +10%
const changePercent = parseFloat((Math.random() * 20 - 10).toFixed(2));
// 涨跌额
const change = parseFloat((basePrice * changePercent / 100).toFixed(2));
// 昨收
const prevClose = parseFloat((basePrice - change).toFixed(2));
return HttpResponse.json({
success: true,
data: {
stock_code: stockCode,
stock_name: stockName,
price: basePrice,
change: change,
change_percent: changePercent,
prev_close: prevClose,
open: parseFloat((prevClose * (1 + (Math.random() * 0.02 - 0.01))).toFixed(2)),
high: parseFloat((basePrice * (1 + Math.random() * 0.05)).toFixed(2)),
low: parseFloat((basePrice * (1 - Math.random() * 0.05)).toFixed(2)),
volume: Math.floor(Math.random() * 100000000),
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
turnover_rate: parseFloat((Math.random() * 10).toFixed(2)),
amplitude: parseFloat((Math.random() * 8).toFixed(2)),
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
update_time: new Date().toISOString(),
// 买卖盘口
bid1: parseFloat((basePrice * 0.998).toFixed(2)),
bid1_volume: Math.floor(Math.random() * 10000),
bid2: parseFloat((basePrice * 0.996).toFixed(2)),
bid2_volume: Math.floor(Math.random() * 10000),
bid3: parseFloat((basePrice * 0.994).toFixed(2)),
bid3_volume: Math.floor(Math.random() * 10000),
bid4: parseFloat((basePrice * 0.992).toFixed(2)),
bid4_volume: Math.floor(Math.random() * 10000),
bid5: parseFloat((basePrice * 0.990).toFixed(2)),
bid5_volume: Math.floor(Math.random() * 10000),
ask1: parseFloat((basePrice * 1.002).toFixed(2)),
ask1_volume: Math.floor(Math.random() * 10000),
ask2: parseFloat((basePrice * 1.004).toFixed(2)),
ask2_volume: Math.floor(Math.random() * 10000),
ask3: parseFloat((basePrice * 1.006).toFixed(2)),
ask3_volume: Math.floor(Math.random() * 10000),
ask4: parseFloat((basePrice * 1.008).toFixed(2)),
ask4_volume: Math.floor(Math.random() * 10000),
ask5: parseFloat((basePrice * 1.010).toFixed(2)),
ask5_volume: Math.floor(Math.random() * 10000),
// 关键指标
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
circulating_market_cap: `${(Math.random() * 3000 + 50).toFixed(0)}亿`,
total_shares: `${(Math.random() * 100 + 10).toFixed(2)}亿`,
circulating_shares: `${(Math.random() * 80 + 5).toFixed(2)}亿`,
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
week52_low: parseFloat((basePrice * 0.7).toFixed(2))
},
message: '获取成功'
});
}),
// FlexScreen 行情数据
http.get('/api/flex-screen/quotes', async ({ request }) => {
await delay(200);
const url = new URL(request.url);
const codes = url.searchParams.get('codes')?.split(',') || [];
console.log('[Mock Stock] 获取 FlexScreen 行情:', { codes });
// 默认主要指数
const defaultIndices = ['000001', '399001', '399006'];
const targetCodes = codes.length > 0 ? codes : defaultIndices;
const indexData = {
'000001': { name: '上证指数', basePrice: 3200 },
'399001': { name: '深证成指', basePrice: 10500 },
'399006': { name: '创业板指', basePrice: 2100 },
'000300': { name: '沪深300', basePrice: 3800 },
'000016': { name: '上证50', basePrice: 2600 },
'000905': { name: '中证500', basePrice: 5800 },
};
const quotesData = {};
targetCodes.forEach(code => {
const codeWithoutSuffix = code.replace(/\.(SH|SZ)$/i, '');
const info = indexData[codeWithoutSuffix] || { name: `指数${code}`, basePrice: 3000 };
const changePercent = parseFloat((Math.random() * 4 - 2).toFixed(2));
const price = parseFloat((info.basePrice * (1 + changePercent / 100)).toFixed(2));
const change = parseFloat((price - info.basePrice).toFixed(2));
quotesData[code] = {
code: code,
name: info.name,
price: price,
change: change,
change_percent: changePercent,
prev_close: info.basePrice,
open: parseFloat((info.basePrice * (1 + (Math.random() * 0.01 - 0.005))).toFixed(2)),
high: parseFloat((price * (1 + Math.random() * 0.01)).toFixed(2)),
low: parseFloat((price * (1 - Math.random() * 0.01)).toFixed(2)),
volume: parseFloat((Math.random() * 5000 + 2000).toFixed(2)), // 亿手
amount: parseFloat((Math.random() * 8000 + 3000).toFixed(2)), // 亿元
update_time: new Date().toISOString()
};
});
return HttpResponse.json({
success: true,
data: quotesData,
message: '获取成功'
});
}),
]; ];

49
src/services/financialService.d.ts vendored Normal file
View File

@@ -0,0 +1,49 @@
// financialService 类型声明
export interface RequestOptions {
signal?: AbortSignal;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface FinancialService {
getStockInfo(seccode: string, options?: RequestOptions): Promise<ApiResponse<any>>;
getBalanceSheet(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
getIncomeStatement(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
getCashflow(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
getFinancialMetrics(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
getMainBusiness(seccode: string, periods?: number, options?: RequestOptions): Promise<ApiResponse<any>>;
getForecast(seccode: string, options?: RequestOptions): Promise<ApiResponse<any>>;
getIndustryRank(seccode: string, limit?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
getPeriodComparison(seccode: string, periods?: number, options?: RequestOptions): Promise<ApiResponse<any[]>>;
}
export const financialService: FinancialService;
export interface FormatUtils {
formatLargeNumber(num: number, decimal?: number): string;
formatPercent(num: number, decimal?: number): string;
formatDate(dateStr: string): string;
getReportType(dateStr: string): string;
getGrowthColor(value: number): string;
getTrendIcon(current: number, previous: number): 'up' | 'down' | 'stable';
calculateYoY(current: number, yearAgo: number): number | null;
calculateQoQ(current: number, previous: number): number | null;
getFinancialHealthScore(metrics: any): { score: number; level: string; color: string } | null;
getTableColumns(type: string): any[];
}
export const formatUtils: FormatUtils;
export interface ChartUtils {
prepareTrendData(data: any[], metrics: any[]): any[];
preparePieData(data: any[], valueKey: string, nameKey: string): any[];
prepareComparisonData(data: any[], periods: any[], metrics: any[]): any[];
getChartColors(theme?: string): string[];
}
export const chartUtils: ChartUtils;

View File

@@ -1,133 +1,137 @@
import { getApiBase } from '../utils/apiConfig';
// src/services/financialService.js // src/services/financialService.js
/** /**
* 完整的财务数据服务层 * 完整的财务数据服务层
* 对应Flask后端的所有财务API接口 * 对应Flask后端的所有财务API接口
*/ */
import { logger } from '../utils/logger'; import axios from '@utils/axiosConfig';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = getApiBase();
/**
* 统一的 API 请求函数
* axios 拦截器已自动处理日志记录
* @param {string} url - 请求 URL
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号,用于取消请求
*/
const apiRequest = async (url, options = {}) => { const apiRequest = async (url, options = {}) => {
try { const { method = 'GET', body, signal, ...rest } = options;
logger.debug('financialService', 'API请求', {
url: `${API_BASE_URL}${url}`,
method: options.method || 'GET'
});
const response = await fetch(`${API_BASE_URL}${url}`, { const config = {
...options, method,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include', // 包含 cookies以便后端识别登录状态
});
if (!response.ok) {
const errorText = await response.text();
logger.error('financialService', 'apiRequest', new Error(`HTTP ${response.status}`), {
url, url,
status: response.status, signal,
errorText: errorText.substring(0, 200) ...rest,
}); };
throw new Error(`HTTP error! status: ${response.status}`);
// 如果有 body根据方法设置 data 或 params
if (body) {
if (method === 'GET') {
config.params = typeof body === 'string' ? JSON.parse(body) : body;
} else {
config.data = typeof body === 'string' ? JSON.parse(body) : body;
}
} }
const data = await response.json(); const response = await axios(config);
logger.debug('financialService', 'API响应', { return response.data;
url,
success: data.success,
hasData: !!data.data
});
return data;
} catch (error) {
logger.error('financialService', 'apiRequest', error, { url });
throw error;
}
}; };
export const financialService = { export const financialService = {
/** /**
* 获取股票基本信息和最新财务摘要 * 获取股票基本信息和最新财务摘要
* @param {string} seccode - 股票代码 * @param {string} seccode - 股票代码
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/ */
getStockInfo: async (seccode) => { getStockInfo: async (seccode, options = {}) => {
return await apiRequest(`/api/financial/stock-info/${seccode}`); return await apiRequest(`/api/financial/stock-info/${seccode}`, options);
}, },
/** /**
* 获取完整的资产负债表数据 * 获取完整的资产负债表数据
* @param {string} seccode - 股票代码 * @param {string} seccode - 股票代码
* @param {number} limit - 获取的报告期数量 * @param {number} limit - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/ */
getBalanceSheet: async (seccode, limit = 12) => { getBalanceSheet: async (seccode, limit = 12, options = {}) => {
return await apiRequest(`/api/financial/balance-sheet/${seccode}?limit=${limit}`); return await apiRequest(`/api/financial/balance-sheet/${seccode}?limit=${limit}`, options);
}, },
/** /**
* 获取完整的利润表数据 * 获取完整的利润表数据
* @param {string} seccode - 股票代码 * @param {string} seccode - 股票代码
* @param {number} limit - 获取的报告期数量 * @param {number} limit - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/ */
getIncomeStatement: async (seccode, limit = 12) => { getIncomeStatement: async (seccode, limit = 12, options = {}) => {
return await apiRequest(`/api/financial/income-statement/${seccode}?limit=${limit}`); return await apiRequest(`/api/financial/income-statement/${seccode}?limit=${limit}`, options);
}, },
/** /**
* 获取完整的现金流量表数据 * 获取完整的现金流量表数据
* @param {string} seccode - 股票代码 * @param {string} seccode - 股票代码
* @param {number} limit - 获取的报告期数量 * @param {number} limit - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/ */
getCashflow: async (seccode, limit = 12) => { getCashflow: async (seccode, limit = 12, options = {}) => {
return await apiRequest(`/api/financial/cashflow/${seccode}?limit=${limit}`); return await apiRequest(`/api/financial/cashflow/${seccode}?limit=${limit}`, options);
}, },
/** /**
* 获取完整的财务指标数据 * 获取完整的财务指标数据
* @param {string} seccode - 股票代码 * @param {string} seccode - 股票代码
* @param {number} limit - 获取的报告期数量 * @param {number} limit - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/ */
getFinancialMetrics: async (seccode, limit = 12) => { getFinancialMetrics: async (seccode, limit = 12, options = {}) => {
return await apiRequest(`/api/financial/financial-metrics/${seccode}?limit=${limit}`); return await apiRequest(`/api/financial/financial-metrics/${seccode}?limit=${limit}`, options);
}, },
/** /**
* 获取主营业务构成数据 * 获取主营业务构成数据
* @param {string} seccode - 股票代码 * @param {string} seccode - 股票代码
* @param {number} periods - 获取的报告期数量 * @param {number} periods - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/ */
getMainBusiness: async (seccode, periods = 4) => { getMainBusiness: async (seccode, periods = 4, options = {}) => {
return await apiRequest(`/api/financial/main-business/${seccode}?periods=${periods}`); return await apiRequest(`/api/financial/main-business/${seccode}?periods=${periods}`, options);
}, },
/** /**
* 获取业绩预告和预披露时间 * 获取业绩预告和预披露时间
* @param {string} seccode - 股票代码 * @param {string} seccode - 股票代码
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/ */
getForecast: async (seccode) => { getForecast: async (seccode, options = {}) => {
return await apiRequest(`/api/financial/forecast/${seccode}`); return await apiRequest(`/api/financial/forecast/${seccode}`, options);
}, },
/** /**
* 获取行业排名数据 * 获取行业排名数据
* @param {string} seccode - 股票代码 * @param {string} seccode - 股票代码
* @param {number} limit - 获取的报告期数量 * @param {number} limit - 获取的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/ */
getIndustryRank: async (seccode, limit = 4) => { getIndustryRank: async (seccode, limit = 4, options = {}) => {
return await apiRequest(`/api/financial/industry-rank/${seccode}?limit=${limit}`); return await apiRequest(`/api/financial/industry-rank/${seccode}?limit=${limit}`, options);
}, },
/** /**
* 获取不同报告期的对比数据 * 获取不同报告期的对比数据
* @param {string} seccode - 股票代码 * @param {string} seccode - 股票代码
* @param {number} periods - 对比的报告期数量 * @param {number} periods - 对比的报告期数量
* @param {object} options - 请求选项
* @param {AbortSignal} options.signal - AbortController 信号
*/ */
getPeriodComparison: async (seccode, periods = 8) => { getPeriodComparison: async (seccode, periods = 8, options = {}) => {
return await apiRequest(`/api/financial/comparison/${seccode}?periods=${periods}`); return await apiRequest(`/api/financial/comparison/${seccode}?periods=${periods}`, options);
}, },
}; };

View File

@@ -1,53 +1,36 @@
import { getApiBase } from '../utils/apiConfig';
// src/services/marketService.js // src/services/marketService.js
/** /**
* 完整的市场行情数据服务层 * 完整的市场行情数据服务层
* 对应Flask后端的所有市场API接口 * 对应Flask后端的所有市场API接口
*/ */
import axios from '@utils/axiosConfig';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
const isProduction = process.env.NODE_ENV === 'production'; /**
const API_BASE_URL = getApiBase(); * 统一的 API 请求函数
* axios 拦截器已自动处理日志记录
*/
const apiRequest = async (url, options = {}) => { const apiRequest = async (url, options = {}) => {
try { const { method = 'GET', body, ...rest } = options;
logger.debug('marketService', 'API请求', {
url: `${API_BASE_URL}${url}`,
method: options.method || 'GET'
});
const response = await fetch(`${API_BASE_URL}${url}`, { const config = {
...options, method,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include', // 包含 cookies以便后端识别登录状态
});
if (!response.ok) {
const errorText = await response.text();
logger.error('marketService', 'apiRequest', new Error(`HTTP ${response.status}`), {
url, url,
status: response.status, ...rest,
errorText: errorText.substring(0, 200) };
});
throw new Error(`HTTP error! status: ${response.status}`); // 如果有 body根据方法设置 data 或 params
if (body) {
if (method === 'GET') {
config.params = typeof body === 'string' ? JSON.parse(body) : body;
} else {
config.data = typeof body === 'string' ? JSON.parse(body) : body;
}
} }
const data = await response.json(); const response = await axios(config);
logger.debug('marketService', 'API响应', { return response.data;
url,
success: data.success,
hasData: !!data.data
});
return data;
} catch (error) {
logger.error('marketService', 'apiRequest', error, { url });
throw error;
}
}; };
export const marketService = { export const marketService = {

View File

@@ -92,9 +92,18 @@ class SocketService {
// 监听连接错误 // 监听连接错误
this.socket.on('connect_error', (error) => { this.socket.on('connect_error', (error) => {
this.reconnectAttempts++; this.reconnectAttempts++;
logger.error('socketService', 'connect_error', error, {
attempts: this.reconnectAttempts, // 首次连接失败使用 warn 级别,后续使用 debug 级别减少日志噪音
if (this.reconnectAttempts === 1) {
logger.warn('socketService', `Socket 连接失败,将在后台重试`, {
url: API_BASE_URL,
error: error.message,
}); });
} else {
logger.debug('socketService', `Socket 重连尝试 #${this.reconnectAttempts}`, {
error: error.message,
});
}
// 使用指数退避策略安排下次重连 // 使用指数退避策略安排下次重连
this.scheduleReconnection(); this.scheduleReconnection();

View File

@@ -52,6 +52,11 @@ axios.interceptors.response.use(
return response; return response;
}, },
(error) => { (error) => {
// 忽略取消请求的错误(组件卸载时正常行为)
if (error.name === 'CanceledError' || axios.isCancel(error)) {
return Promise.reject(error);
}
const method = error.config?.method?.toUpperCase() || 'UNKNOWN'; const method = error.config?.method?.toUpperCase() || 'UNKNOWN';
const url = error.config?.url || 'UNKNOWN'; const url = error.config?.url || 'UNKNOWN';
const requestData = error.config?.data || error.config?.params || null; const requestData = error.config?.data || error.config?.params || null;

View File

@@ -52,18 +52,21 @@ export const useEventData = (filters, pageSize = 10) => {
total: response.data?.pagination?.total total: response.data?.pagination?.total
}); });
if (response.success) { if (response.success && response.data) {
setEvents(response.data.events); const events = response.data.events || [];
const paginationData = response.data.pagination || {};
setEvents(events);
setPagination({ setPagination({
current: response.data.pagination.page, current: paginationData.page || page,
pageSize: response.data.pagination.per_page, pageSize: paginationData.per_page || pagination.pageSize,
total: response.data.pagination.total total: paginationData.total || 0
}); });
setLastUpdateTime(new Date()); setLastUpdateTime(new Date());
logger.debug('useEventData', 'loadEvents 成功', { logger.debug('useEventData', 'loadEvents 成功', {
count: response.data.events.length, count: events.length,
total: response.data.pagination.total total: paginationData.total || 0
}); });
} }
} catch (error) { } catch (error) {

View File

@@ -137,25 +137,29 @@ const StockInfoDisplay = memo<{
StockInfoDisplay.displayName = 'StockInfoDisplay'; StockInfoDisplay.displayName = 'StockInfoDisplay';
/** /**
* 搜索操作区组件 * 搜索操作区组件(状态自管理,减少父组件重渲染)
*/ */
const SearchActions = memo<{ const SearchActions = memo<{
inputCode: string; stockCode: string;
onInputChange: (value: string) => void; onStockChange: (value: string) => void;
onSearch: () => void;
onSelect: (value: string) => void;
isInWatchlist: boolean; isInWatchlist: boolean;
watchlistLoading: boolean; watchlistLoading: boolean;
onWatchlistToggle: () => void; onWatchlistToggle: () => void;
}>(({ }>(({
inputCode, stockCode,
onInputChange, onStockChange,
onSearch,
onSelect,
isInWatchlist, isInWatchlist,
watchlistLoading, watchlistLoading,
onWatchlistToggle, onWatchlistToggle,
}) => { }) => {
// 输入状态自管理(避免父组件重渲染)
const [inputCode, setInputCode] = useState(stockCode);
// 同步外部 stockCode 变化
React.useEffect(() => {
setInputCode(stockCode);
}, [stockCode]);
// 股票搜索 Hook // 股票搜索 Hook
const searchHook = useStockSearch({ const searchHook = useStockSearch({
limit: 10, limit: 10,
@@ -190,18 +194,28 @@ const SearchActions = memo<{
})); }));
}, [searchResults]); }, [searchResults]);
// 处理搜索按钮点击
const handleSearch = useCallback(() => {
if (inputCode && inputCode !== stockCode) {
onStockChange(inputCode);
}
}, [inputCode, stockCode, onStockChange]);
// 选中股票 // 选中股票
const handleSelect = useCallback((value: string) => { const handleSelect = useCallback((value: string) => {
clearSearch(); clearSearch();
onSelect(value); setInputCode(value);
}, [clearSearch, onSelect]); if (value !== stockCode) {
onStockChange(value);
}
}, [clearSearch, stockCode, onStockChange]);
// 键盘事件 // 键盘事件
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
onSearch(); handleSearch();
} }
}, [onSearch]); }, [handleSearch]);
return ( return (
<HStack spacing={3}> <HStack spacing={3}>
@@ -241,7 +255,7 @@ const SearchActions = memo<{
options={stockOptions} options={stockOptions}
onSearch={doSearch} onSearch={doSearch}
onSelect={handleSelect} onSelect={handleSelect}
onChange={onInputChange} onChange={setInputCode}
placeholder="输入代码、名称或拼音" placeholder="输入代码、名称或拼音"
style={{ width: 240 }} style={{ width: 240 }}
dropdownStyle={{ dropdownStyle={{
@@ -271,7 +285,7 @@ const SearchActions = memo<{
size="md" size="md"
h="42px" h="42px"
px={5} px={5}
onClick={onSearch} onClick={handleSearch}
leftIcon={<Icon as={Search} boxSize={4} />} leftIcon={<Icon as={Search} boxSize={4} />}
fontWeight="bold" fontWeight="bold"
borderRadius="10px" borderRadius="10px"
@@ -335,28 +349,6 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
onStockChange, onStockChange,
onWatchlistToggle, onWatchlistToggle,
}) => { }) => {
const [inputCode, setInputCode] = useState(stockCode);
// 处理搜索
const handleSearch = useCallback(() => {
if (inputCode && inputCode !== stockCode) {
onStockChange(inputCode);
}
}, [inputCode, stockCode, onStockChange]);
// 处理选中
const handleSelect = useCallback((value: string) => {
setInputCode(value);
if (value !== stockCode) {
onStockChange(value);
}
}, [stockCode, onStockChange]);
// 同步 stockCode 变化
React.useEffect(() => {
setInputCode(stockCode);
}, [stockCode]);
return ( return (
<Box <Box
position="relative" position="relative"
@@ -368,20 +360,7 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
backdropFilter={FUI_GLASS.blur.md} backdropFilter={FUI_GLASS.blur.md}
overflow="hidden" overflow="hidden"
> >
{/* 环境光效果 - James Turrell 风格 */} {/* 顶部发光线(环境光效果由全局 AmbientGlow 提供) */}
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
pointerEvents="none"
bg={`radial-gradient(ellipse 80% 50% at 20% 40%, ${FUI_COLORS.ambient.warm}, transparent),
radial-gradient(ellipse 60% 40% at 80% 60%, ${FUI_COLORS.ambient.cool}, transparent)`}
opacity={0.6}
/>
{/* 顶部发光线 */}
<Box <Box
position="absolute" position="absolute"
top={0} top={0}
@@ -413,10 +392,8 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
{/* 右侧:搜索和操作 */} {/* 右侧:搜索和操作 */}
<SearchActions <SearchActions
inputCode={inputCode} stockCode={stockCode}
onInputChange={setInputCode} onStockChange={onStockChange}
onSearch={handleSearch}
onSelect={handleSelect}
isInWatchlist={isInWatchlist} isInWatchlist={isInWatchlist}
watchlistLoading={watchlistLoading} watchlistLoading={watchlistLoading}
onWatchlistToggle={onWatchlistToggle} onWatchlistToggle={onWatchlistToggle}

View File

@@ -1,5 +1,5 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx // src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx
// 工商信息 Tab Panel // 工商信息 Tab Panel - FUI 风格
import React from "react"; import React from "react";
import { import {
@@ -7,14 +7,24 @@ import {
VStack, VStack,
HStack, HStack,
Text, Text,
Heading,
SimpleGrid, SimpleGrid,
Divider,
Center, Center,
Code, Icon,
Spinner, Spinner,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import {
FaBuilding,
FaMapMarkerAlt,
FaIdCard,
FaUsers,
FaBalanceScale,
FaCalculator,
FaBriefcase,
FaFileAlt,
} from "react-icons/fa";
// 使用统一主题
import { COLORS, GLASS, glassCardStyle } from "@views/Company/theme";
import { THEME } from "../config"; import { THEME } from "../config";
import { useBasicInfo } from "../../hooks/useBasicInfo"; import { useBasicInfo } from "../../hooks/useBasicInfo";
@@ -22,6 +32,124 @@ interface BusinessInfoPanelProps {
stockCode: string; stockCode: string;
} }
// 区块标题组件
const SectionTitle: React.FC<{ icon: React.ElementType; title: string }> = ({
icon,
title,
}) => (
<HStack spacing={2} mb={4}>
<Icon as={icon} color={COLORS.gold} boxSize={4} />
<Text
fontSize="14px"
fontWeight="700"
color={COLORS.gold}
textTransform="uppercase"
letterSpacing="0.05em"
>
{title}
</Text>
<Box flex={1} h="1px" bg={`linear-gradient(90deg, ${COLORS.border}, transparent)`} />
</HStack>
);
// 信息行组件
const InfoRow: React.FC<{
icon?: React.ElementType;
label: string;
value: string | undefined;
isCode?: boolean;
isMultiline?: boolean;
}> = ({ icon, label, value, isCode, isMultiline }) => (
<HStack
w="full"
align={isMultiline ? "start" : "center"}
spacing={3}
py={2}
px={3}
borderRadius="8px"
bg={GLASS.bgDark}
_hover={{ bg: GLASS.bgGold }}
transition="all 0.15s ease"
>
{icon && <Icon as={icon} color={COLORS.goldLight} boxSize={3.5} opacity={0.8} />}
<Text fontSize="13px" color={COLORS.textSecondary} minW="70px" flexShrink={0}>
{label}
</Text>
{isCode ? (
<Text
fontSize="12px"
fontFamily="mono"
bg="rgba(212, 175, 55, 0.15)"
color={COLORS.goldLight}
px={2}
py={0.5}
borderRadius="4px"
letterSpacing="0.05em"
>
{value || "-"}
</Text>
) : (
<Text
fontSize="13px"
color={COLORS.textPrimary}
fontWeight="500"
noOfLines={isMultiline ? 2 : 1}
flex={1}
>
{value || "-"}
</Text>
)}
</HStack>
);
// 服务机构卡片
const ServiceCard: React.FC<{
icon: React.ElementType;
label: string;
value: string | undefined;
}> = ({ icon, label, value }) => (
<Box
p={4}
borderRadius="10px"
bg={GLASS.bgDark}
border={`1px solid ${COLORS.borderSubtle}`}
_hover={{ borderColor: COLORS.border }}
transition="all 0.15s ease"
>
<HStack spacing={2} mb={2}>
<Icon as={icon} color={COLORS.goldLight} boxSize={3.5} />
<Text fontSize="12px" color={COLORS.textSecondary}>
{label}
</Text>
</HStack>
<Text fontSize="13px" color={COLORS.textPrimary} fontWeight="500" noOfLines={2}>
{value || "-"}
</Text>
</Box>
);
// 文本区块组件
const TextSection: React.FC<{
icon: React.ElementType;
title: string;
content: string | undefined;
}> = ({ icon, title, content }) => (
<Box {...glassCardStyle} p={4}>
<SectionTitle icon={icon} title={title} />
<Text
fontSize="13px"
lineHeight="1.8"
color={COLORS.textSecondary}
sx={{
textIndent: "2em",
textAlign: "justify",
}}
>
{content || "暂无信息"}
</Text>
</Box>
);
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => { const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => {
const { basicInfo, loading } = useBasicInfo(stockCode); const { basicInfo, loading } = useBasicInfo(stockCode);
@@ -43,77 +171,69 @@ const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => {
return ( return (
<VStack spacing={4} align="stretch"> <VStack spacing={4} align="stretch">
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}> {/* 上半部分:工商信息 + 服务机构 */}
<Box> <SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading> {/* 工商信息卡片 */}
<VStack align="start" spacing={2}> <Box {...glassCardStyle} p={4}>
<HStack w="full"> <SectionTitle icon={FaBuilding} title="工商信息" />
<Text fontSize="sm" color={THEME.textSecondary} minW="80px"> <VStack spacing={2} align="stretch">
<InfoRow
</Text> icon={FaIdCard}
<Code fontSize="xs" bg={THEME.tableHoverBg} color={THEME.goldLight}> label="信用代码"
{basicInfo.credit_code} value={basicInfo.credit_code}
</Code> isCode
</HStack> />
<HStack w="full"> <InfoRow
<Text fontSize="sm" color={THEME.textSecondary} minW="80px"> icon={FaUsers}
label="公司规模"
</Text> value={basicInfo.company_size}
<Text fontSize="sm" color={THEME.textPrimary}>{basicInfo.company_size}</Text> />
</HStack> <InfoRow
<HStack w="full" align="start"> icon={FaMapMarkerAlt}
<Text fontSize="sm" color={THEME.textSecondary} minW="80px"> label="注册地址"
value={basicInfo.reg_address}
</Text> isMultiline
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}> />
{basicInfo.reg_address} <InfoRow
</Text> icon={FaMapMarkerAlt}
</HStack> label="办公地址"
<HStack w="full" align="start"> value={basicInfo.office_address}
<Text fontSize="sm" color={THEME.textSecondary} minW="80px"> isMultiline
/>
</Text>
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
{basicInfo.office_address}
</Text>
</HStack>
</VStack> </VStack>
</Box> </Box>
<Box> {/* 服务机构卡片 */}
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading> <Box {...glassCardStyle} p={4}>
<VStack align="start" spacing={2}> <SectionTitle icon={FaBalanceScale} title="服务机构" />
<Box> <VStack spacing={3} align="stretch">
<Text fontSize="sm" color={THEME.textSecondary}></Text> <ServiceCard
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}> icon={FaCalculator}
{basicInfo.accounting_firm} label="会计师事务所"
</Text> value={basicInfo.accounting_firm}
</Box> />
<Box> <ServiceCard
<Text fontSize="sm" color={THEME.textSecondary}></Text> icon={FaBalanceScale}
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}> label="律师事务所"
{basicInfo.law_firm} value={basicInfo.law_firm}
</Text> />
</Box>
</VStack> </VStack>
</Box> </Box>
</SimpleGrid> </SimpleGrid>
<Divider borderColor={THEME.border} /> {/* 下半部分:主营业务 + 经营范围 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
<Box> <TextSection
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading> icon={FaBriefcase}
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}> title="主营业务"
{basicInfo.main_business} content={basicInfo.main_business}
</Text> />
</Box> <TextSection
icon={FaFileAlt}
<Box> title="经营范围"
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading> content={basicInfo.business_scope}
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}> />
{basicInfo.business_scope} </SimpleGrid>
</Text>
</Box>
</VStack> </VStack>
); );
}; };

View File

@@ -2,7 +2,6 @@
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件 // 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { Card, CardBody } from "@chakra-ui/react";
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer"; import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config"; import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
@@ -66,17 +65,15 @@ const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]); const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
return ( return (
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
<CardBody p={0}>
<SubTabContainer <SubTabContainer
tabs={tabs} tabs={tabs}
componentProps={{ stockCode }} componentProps={{ stockCode }}
defaultIndex={defaultTabIndex} defaultIndex={defaultTabIndex}
onTabChange={onTabChange} onTabChange={onTabChange}
themePreset="blackGold" themePreset="blackGold"
compact
contentPadding={0}
/> />
</CardBody>
</Card>
); );
}; };

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/components/shareholder/ConcentrationCard.tsx // src/views/Company/components/CompanyOverview/components/shareholder/ConcentrationCard.tsx
// 股权集中度卡片组件 // 股权集中度卡片组件
import React, { useMemo, useRef, useEffect } from "react"; import React, { useMemo, useRef, useEffect, memo } from "react";
import { import {
Box, Box,
VStack, VStack,
@@ -16,7 +16,7 @@ import {
SimpleGrid, SimpleGrid,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { FaChartPie, FaArrowUp, FaArrowDown } from "react-icons/fa"; import { FaChartPie, FaArrowUp, FaArrowDown } from "react-icons/fa";
import * as echarts from "echarts"; import { echarts, type ECharts, type EChartsOption } from '@lib/echarts';
import type { Concentration } from "../../types"; import type { Concentration } from "../../types";
import { THEME } from "../../BasicInfoTab/config"; import { THEME } from "../../BasicInfoTab/config";
@@ -99,7 +99,7 @@ const ConcentrationCard: React.FC<ConcentrationCardProps> = ({ concentration = [
chartInstance.current = echarts.init(chartRef.current); chartInstance.current = echarts.init(chartRef.current);
} }
const option: echarts.EChartsOption = { const option: EChartsOption = {
backgroundColor: "transparent", backgroundColor: "transparent",
tooltip: { tooltip: {
trigger: "item", trigger: "item",
@@ -233,4 +233,4 @@ const ConcentrationCard: React.FC<ConcentrationCardProps> = ({ concentration = [
); );
}; };
export default ConcentrationCard; export default memo(ConcentrationCard);

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/CompanyOverview/components/shareholder/ShareholdersTable.tsx // src/views/Company/components/CompanyOverview/components/shareholder/ShareholdersTable.tsx
// 股东表格组件(合并版)- 支持十大股东和十大流通股东 // 股东表格组件(合并版)- 支持十大股东和十大流通股东
import React, { useMemo } from "react"; import React, { useMemo, memo } from "react";
import { Box, HStack, Heading, Badge, Icon, useBreakpointValue } from "@chakra-ui/react"; import { Box, HStack, Heading, Badge, Icon, useBreakpointValue } from "@chakra-ui/react";
import { Table, Tag, Tooltip, ConfigProvider } from "antd"; import { Table, Tag, Tooltip, ConfigProvider } from "antd";
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
@@ -225,4 +225,4 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
); );
}; };
export default ShareholdersTable; export default memo(ShareholdersTable);

View File

@@ -1,35 +1,31 @@
// src/views/Company/components/CompanyOverview/index.tsx // src/views/Company/components/CompanyOverview/index.tsx
// 公司档案 - 主组件(组合层) // 公司档案 - 主组件(组合层)
// 注StockQuoteCard 已移至 Company/index.tsx放在 Tab 容器上方,切换 Tab 时始终可见
import React from "react"; import React from "react";
import { VStack } from "@chakra-ui/react";
import type { CompanyOverviewProps } from "./types"; import type { CompanyOverviewProps } from "./types";
// 子组件 // 子组件
import StockQuoteCard from "../StockQuoteCard";
import BasicInfoTab from "./BasicInfoTab"; import BasicInfoTab from "./BasicInfoTab";
/** /**
* 公司档案组件 * 公司档案组件
* *
* 功能: * 功能:
* - 显示股票行情卡片(个股详情)
* - 显示基本信息 Tab内部懒加载各子 Tab 数据) * - 显示基本信息 Tab内部懒加载各子 Tab 数据)
* *
* 注意:
* - StockQuoteCard 已提升到 Company/index.tsx 中渲染
* - 确保切换 Tab 时股票行情卡片始终可见
*
* 懒加载策略: * 懒加载策略:
* - BasicInfoTab 内部根据 Tab 切换懒加载数据 * - BasicInfoTab 内部根据 Tab 切换懒加载数据
* - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo * - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo
*/ */
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => { const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
return ( return (
<VStack spacing={6} align="stretch">
{/* 股票行情卡片 - 个股详情 */}
<StockQuoteCard stockCode={stockCode} />
{/* 基本信息内容 - 传入 stockCode内部懒加载各 Tab 数据 */}
<BasicInfoTab stockCode={stockCode} /> <BasicInfoTab stockCode={stockCode} />
</VStack>
); );
}; };

View File

@@ -189,9 +189,8 @@ const DeepAnalysis = ({ stockCode }) => {
// 重置为默认 Tab 并加载数据 // 重置为默认 Tab 并加载数据
setActiveTab("strategy"); setActiveTab("strategy");
// 加载默认 Tab 的数据(战略分析需要 comprehensive 和 industryRank // 加载默认 Tab 的核心数据comprehensive),其他数据按需加载
loadApiData("comprehensive"); loadApiData("comprehensive");
loadApiData("industryRank");
} }
}, [stockCode, loadApiData]); }, [stockCode, loadApiData]);

View File

@@ -0,0 +1,48 @@
/**
* ECharts 包装组件 - 按需引入版本
*
* 使用方式:
* import EChartsWrapper from '../EChartsWrapper';
* <EChartsWrapper option={option} style={{ height: '400px' }} />
*
* 优势:
* - 减小打包体积(从 ~800KB 降至 ~200-300KB
* - 统一管理图表实例
* - 兼容原有 ReactECharts 的所有属性
*/
import React, { memo } from 'react';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import { echarts, type EChartsOption } from '@lib/echarts';
import type { EChartsReactProps } from 'echarts-for-react';
// 重新导出类型
export type { EChartsOption };
interface EChartsWrapperProps extends Omit<EChartsReactProps, 'echarts' | 'option'> {
option: EChartsOption;
}
/**
* ECharts 包装组件
*
* 使用按需引入的 echarts 实例,减小打包体积
*/
const EChartsWrapper: React.FC<EChartsWrapperProps> = memo((props) => {
const { option, ...restProps } = props;
return (
<ReactEChartsCore
echarts={echarts}
option={option}
notMerge={true}
lazyUpdate={true}
opts={{ renderer: 'canvas' }}
{...restProps}
/>
);
});
EChartsWrapper.displayName = 'EChartsWrapper';
export default EChartsWrapper;

View File

@@ -5,6 +5,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useToast } from '@chakra-ui/react'; import { useToast } from '@chakra-ui/react';
import axios from 'axios';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
import { financialService } from '@services/financialService'; import { financialService } from '@services/financialService';
import type { import type {
@@ -19,6 +20,11 @@ import type {
ComparisonData, ComparisonData,
} from '../types'; } from '../types';
// 判断是否为取消请求的错误
const isCancelError = (error: unknown): boolean => {
return axios.isCancel(error) || (error instanceof Error && error.name === 'CanceledError');
};
// Tab key 到数据类型的映射 // Tab key 到数据类型的映射
export type DataTypeKey = export type DataTypeKey =
| 'balance' | 'balance'
@@ -102,6 +108,10 @@ export const useFinancialData = (
const isInitialLoad = useRef(true); const isInitialLoad = useRef(true);
const prevPeriods = useRef(selectedPeriods); const prevPeriods = useRef(selectedPeriods);
// AbortController refs - 用于取消请求
const coreDataControllerRef = useRef<AbortController | null>(null);
const tabDataControllerRef = useRef<AbortController | null>(null);
// 判断 Tab key 对应的数据类型 // 判断 Tab key 对应的数据类型
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => { const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
switch (tabKey) { switch (tabKey) {
@@ -120,32 +130,36 @@ export const useFinancialData = (
// 按数据类型加载数据 // 按数据类型加载数据
const loadDataByType = useCallback(async ( const loadDataByType = useCallback(async (
dataType: 'balance' | 'income' | 'cashflow' | 'metrics', dataType: 'balance' | 'income' | 'cashflow' | 'metrics',
periods: number periods: number,
signal?: AbortSignal
) => { ) => {
const options: { signal?: AbortSignal } = signal ? { signal } : {};
try { try {
switch (dataType) { switch (dataType) {
case 'balance': { case 'balance': {
const res = await financialService.getBalanceSheet(stockCode, periods); const res = await financialService.getBalanceSheet(stockCode, periods, options);
if (res.success) setBalanceSheet(res.data); if (res.success) setBalanceSheet(res.data);
break; break;
} }
case 'income': { case 'income': {
const res = await financialService.getIncomeStatement(stockCode, periods); const res = await financialService.getIncomeStatement(stockCode, periods, options);
if (res.success) setIncomeStatement(res.data); if (res.success) setIncomeStatement(res.data);
break; break;
} }
case 'cashflow': { case 'cashflow': {
const res = await financialService.getCashflow(stockCode, periods); const res = await financialService.getCashflow(stockCode, periods, options);
if (res.success) setCashflow(res.data); if (res.success) setCashflow(res.data);
break; break;
} }
case 'metrics': { case 'metrics': {
const res = await financialService.getFinancialMetrics(stockCode, periods); const res = await financialService.getFinancialMetrics(stockCode, periods, options);
if (res.success) setFinancialMetrics(res.data); if (res.success) setFinancialMetrics(res.data);
break; break;
} }
} }
} catch (err) { } catch (err) {
// 取消请求不作为错误处理
if (isCancelError(err)) return;
logger.error('useFinancialData', 'loadDataByType', err, { dataType, periods }); logger.error('useFinancialData', 'loadDataByType', err, { dataType, periods });
throw err; throw err;
} }
@@ -157,6 +171,11 @@ export const useFinancialData = (
return; return;
} }
// 取消之前的 Tab 数据请求
tabDataControllerRef.current?.abort();
const controller = new AbortController();
tabDataControllerRef.current = controller;
const dataType = getDataTypeForTab(tabKey); const dataType = getDataTypeForTab(tabKey);
logger.debug('useFinancialData', '刷新单个 Tab 数据', { tabKey, dataType, selectedPeriods }); logger.debug('useFinancialData', '刷新单个 Tab 数据', { tabKey, dataType, selectedPeriods });
@@ -164,14 +183,19 @@ export const useFinancialData = (
setError(null); setError(null);
try { try {
await loadDataByType(dataType, selectedPeriods); await loadDataByType(dataType, selectedPeriods, controller.signal);
logger.info('useFinancialData', `${tabKey} 数据刷新成功`); logger.info('useFinancialData', `${tabKey} 数据刷新成功`);
} catch (err) { } catch (err) {
// 取消请求不作为错误处理
if (isCancelError(err)) return;
const errorMessage = err instanceof Error ? err.message : '未知错误'; const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage); setError(errorMessage);
} finally { } finally {
// 只有当前请求没有被取消时才设置 loading 状态
if (!controller.signal.aborted) {
setLoadingTab(null); setLoadingTab(null);
} }
}
}, [stockCode, selectedPeriods, loadDataByType]); }, [stockCode, selectedPeriods, loadDataByType]);
// 设置期数(只刷新当前 Tab // 设置期数(只刷新当前 Tab
@@ -179,8 +203,8 @@ export const useFinancialData = (
setSelectedPeriodsState(periods); setSelectedPeriodsState(periods);
}, []); }, []);
// 加载所有财务数据(初始加载) // 加载核心财务数据(初始加载stockInfo + metrics + comparison
const loadAllFinancialData = useCallback(async () => { const loadCoreFinancialData = useCallback(async () => {
if (!stockCode || stockCode.length !== 6) { if (!stockCode || stockCode.length !== 6) {
logger.warn('useFinancialData', '无效的股票代码', { stockCode }); logger.warn('useFinancialData', '无效的股票代码', { stockCode });
toast({ toast({
@@ -191,55 +215,56 @@ export const useFinancialData = (
return; return;
} }
logger.debug('useFinancialData', '开始加载全部财务数据', { stockCode, selectedPeriods }); // 取消之前的核心数据请求
coreDataControllerRef.current?.abort();
const controller = new AbortController();
coreDataControllerRef.current = controller;
const options = { signal: controller.signal };
logger.debug('useFinancialData', '开始加载核心财务数据', { stockCode, selectedPeriods });
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// 并行加载所有数据 // 只加载核心数据(概览面板需要的)
const [ const [
stockInfoRes, stockInfoRes,
balanceRes,
incomeRes,
cashflowRes,
metricsRes, metricsRes,
businessRes,
forecastRes,
rankRes,
comparisonRes, comparisonRes,
businessRes,
] = await Promise.all([ ] = await Promise.all([
financialService.getStockInfo(stockCode), financialService.getStockInfo(stockCode, options),
financialService.getBalanceSheet(stockCode, selectedPeriods), financialService.getFinancialMetrics(stockCode, selectedPeriods, options),
financialService.getIncomeStatement(stockCode, selectedPeriods), financialService.getPeriodComparison(stockCode, selectedPeriods, options),
financialService.getCashflow(stockCode, selectedPeriods), financialService.getMainBusiness(stockCode, 4, options),
financialService.getFinancialMetrics(stockCode, selectedPeriods),
financialService.getMainBusiness(stockCode, 4),
financialService.getForecast(stockCode),
financialService.getIndustryRank(stockCode, 4),
financialService.getPeriodComparison(stockCode, selectedPeriods),
]); ]);
// 设置数据 // 设置数据
if (stockInfoRes.success) setStockInfo(stockInfoRes.data); if (stockInfoRes.success) setStockInfo(stockInfoRes.data);
if (balanceRes.success) setBalanceSheet(balanceRes.data);
if (incomeRes.success) setIncomeStatement(incomeRes.data);
if (cashflowRes.success) setCashflow(cashflowRes.data);
if (metricsRes.success) setFinancialMetrics(metricsRes.data); if (metricsRes.success) setFinancialMetrics(metricsRes.data);
if (businessRes.success) setMainBusiness(businessRes.data);
if (forecastRes.success) setForecast(forecastRes.data);
if (rankRes.success) setIndustryRank(rankRes.data);
if (comparisonRes.success) setComparison(comparisonRes.data); if (comparisonRes.success) setComparison(comparisonRes.data);
if (businessRes.success) setMainBusiness(businessRes.data);
logger.info('useFinancialData', '全部财务数据加载成功', { stockCode }); logger.info('useFinancialData', '核心财务数据加载成功', { stockCode });
} catch (err) { } catch (err) {
// 取消请求不作为错误处理
if (isCancelError(err)) return;
const errorMessage = err instanceof Error ? err.message : '未知错误'; const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage); setError(errorMessage);
logger.error('useFinancialData', 'loadAllFinancialData', err, { stockCode, selectedPeriods }); logger.error('useFinancialData', 'loadCoreFinancialData', err, { stockCode, selectedPeriods });
} finally { } finally {
// 只有当前请求没有被取消时才设置 loading 状态
if (!controller.signal.aborted) {
setLoading(false); setLoading(false);
} }
}
}, [stockCode, selectedPeriods, toast]); }, [stockCode, selectedPeriods, toast]);
// 加载所有财务数据(用于刷新)
const loadAllFinancialData = useCallback(async () => {
await loadCoreFinancialData();
}, [loadCoreFinancialData]);
// 监听 props 中的 stockCode 变化 // 监听 props 中的 stockCode 变化
useEffect(() => { useEffect(() => {
if (initialStockCode && initialStockCode !== stockCode) { if (initialStockCode && initialStockCode !== stockCode) {
@@ -263,6 +288,14 @@ export const useFinancialData = (
} }
}, [selectedPeriods, activeTab, refetchByTab]); }, [selectedPeriods, activeTab, refetchByTab]);
// 组件卸载时取消所有进行中的请求
useEffect(() => {
return () => {
coreDataControllerRef.current?.abort();
tabDataControllerRef.current?.abort();
};
}, []);
return { return {
// 数据状态 // 数据状态
stockInfo, stockInfo,

View File

@@ -122,8 +122,8 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
// 颜色配置 // 颜色配置
const { bgColor, hoverBg, positiveColor, negativeColor } = COLORS; const { bgColor, hoverBg, positiveColor, negativeColor } = COLORS;
// 点击指标行显示图表 // 点击指标行显示图表(使用 useCallback 避免不必要的重渲染)
const showMetricChart = ( const showMetricChart = useCallback((
metricName: string, metricName: string,
_metricKey: string, _metricKey: string,
data: Array<{ period: string; [key: string]: unknown }>, data: Array<{ period: string; [key: string]: unknown }>,
@@ -221,7 +221,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
</Box> </Box>
); );
onOpen(); onOpen();
}; }, [onOpen, positiveColor, negativeColor]);
// Tab 配置 - 财务指标分类 + 三大财务报表 // Tab 配置 - 财务指标分类 + 三大财务报表
const tabConfigs: SubTabConfig[] = useMemo( const tabConfigs: SubTabConfig[] = useMemo(
@@ -250,6 +250,9 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
incomeStatement, incomeStatement,
cashflow, cashflow,
financialMetrics, financialMetrics,
// 加载状态
loading,
loadingTab,
// 工具函数 // 工具函数
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,
@@ -265,6 +268,8 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
incomeStatement, incomeStatement,
cashflow, cashflow,
financialMetrics, financialMetrics,
loading,
loadingTab,
showMetricChart, showMetricChart,
positiveColor, positiveColor,
negativeColor, negativeColor,
@@ -278,7 +283,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
<VStack spacing={6} align="stretch"> <VStack spacing={6} align="stretch">
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */} {/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
{loading ? ( {loading ? (
<LoadingState message="加载财务数据中..." height="300px" /> <LoadingState message="加载财务数据中..." height="300px" variant="skeleton" skeletonRows={6} />
) : ( ) : (
<FinancialOverviewPanel <FinancialOverviewPanel
stockInfo={stockInfo} stockInfo={stockInfo}
@@ -302,7 +307,6 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
)} )}
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */} {/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}
{!loading && stockInfo && (
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)"> <Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
<CardBody p={0}> <CardBody p={0}>
<SubTabContainer <SubTabContainer
@@ -316,13 +320,12 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
selectedPeriods={selectedPeriods} selectedPeriods={selectedPeriods}
onPeriodsChange={setSelectedPeriods} onPeriodsChange={setSelectedPeriods}
onRefresh={handleRefresh} onRefresh={handleRefresh}
isLoading={loadingTab !== null} isLoading={loadingTab !== null || loading}
/> />
} }
/> />
</CardBody> </CardBody>
</Card> </Card>
)}
{/* 错误提示 */} {/* 错误提示 */}
{error && ( {error && (

View File

@@ -3,12 +3,13 @@
*/ */
import React from 'react'; import React from 'react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react'; import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
import { BalanceSheetTable } from '../components'; import { BalanceSheetTable } from '../components';
import type { BalanceSheetData } from '../types'; import type { BalanceSheetData } from '../types';
export interface BalanceSheetTabProps { export interface BalanceSheetTabProps {
balanceSheet: BalanceSheetData[]; balanceSheet: BalanceSheetData[];
loading?: boolean;
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void; showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number }; calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string; getCellBackground: (change: number, intensity: number) => string;
@@ -20,6 +21,7 @@ export interface BalanceSheetTabProps {
const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({ const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
balanceSheet, balanceSheet,
loading,
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,
getCellBackground, getCellBackground,
@@ -28,6 +30,15 @@ const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
bgColor, bgColor,
hoverBg, hoverBg,
}) => { }) => {
// 加载中状态
if (loading && (!Array.isArray(balanceSheet) || balanceSheet.length === 0)) {
return (
<Center py={12}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
</Center>
);
}
const tableProps = { const tableProps = {
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,

View File

@@ -3,12 +3,13 @@
*/ */
import React from 'react'; import React from 'react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react'; import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
import { CashflowTable } from '../components'; import { CashflowTable } from '../components';
import type { CashflowData } from '../types'; import type { CashflowData } from '../types';
export interface CashflowTabProps { export interface CashflowTabProps {
cashflow: CashflowData[]; cashflow: CashflowData[];
loading?: boolean;
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void; showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number }; calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string; getCellBackground: (change: number, intensity: number) => string;
@@ -20,6 +21,7 @@ export interface CashflowTabProps {
const CashflowTab: React.FC<CashflowTabProps> = ({ const CashflowTab: React.FC<CashflowTabProps> = ({
cashflow, cashflow,
loading,
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,
getCellBackground, getCellBackground,
@@ -28,6 +30,15 @@ const CashflowTab: React.FC<CashflowTabProps> = ({
bgColor, bgColor,
hoverBg, hoverBg,
}) => { }) => {
// 加载中状态
if (loading && (!Array.isArray(cashflow) || cashflow.length === 0)) {
return (
<Center py={12}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
</Center>
);
}
const tableProps = { const tableProps = {
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,

View File

@@ -3,11 +3,13 @@
*/ */
import React from 'react'; import React from 'react';
import { Spinner, Center } from '@chakra-ui/react';
import { FinancialMetricsTable } from '../components'; import { FinancialMetricsTable } from '../components';
import type { FinancialMetricsData } from '../types'; import type { FinancialMetricsData } from '../types';
export interface FinancialMetricsTabProps { export interface FinancialMetricsTabProps {
financialMetrics: FinancialMetricsData[]; financialMetrics: FinancialMetricsData[];
loading?: boolean;
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void; showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number }; calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string; getCellBackground: (change: number, intensity: number) => string;
@@ -19,6 +21,7 @@ export interface FinancialMetricsTabProps {
const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({ const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
financialMetrics, financialMetrics,
loading,
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,
getCellBackground, getCellBackground,
@@ -27,6 +30,15 @@ const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
bgColor, bgColor,
hoverBg, hoverBg,
}) => { }) => {
// 加载中状态
if (loading && (!Array.isArray(financialMetrics) || financialMetrics.length === 0)) {
return (
<Center py={12}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
</Center>
);
}
const tableProps = { const tableProps = {
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,

View File

@@ -3,12 +3,13 @@
*/ */
import React from 'react'; import React from 'react';
import { Box, VStack, HStack, Heading, Badge, Text } from '@chakra-ui/react'; import { Box, VStack, HStack, Heading, Badge, Text, Spinner, Center } from '@chakra-ui/react';
import { IncomeStatementTable } from '../components'; import { IncomeStatementTable } from '../components';
import type { IncomeStatementData } from '../types'; import type { IncomeStatementData } from '../types';
export interface IncomeStatementTabProps { export interface IncomeStatementTabProps {
incomeStatement: IncomeStatementData[]; incomeStatement: IncomeStatementData[];
loading?: boolean;
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void; showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number }; calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string; getCellBackground: (change: number, intensity: number) => string;
@@ -20,6 +21,7 @@ export interface IncomeStatementTabProps {
const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({ const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
incomeStatement, incomeStatement,
loading,
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,
getCellBackground, getCellBackground,
@@ -28,6 +30,15 @@ const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
bgColor, bgColor,
hoverBg, hoverBg,
}) => { }) => {
// 加载中状态
if (loading && (!Array.isArray(incomeStatement) || incomeStatement.length === 0)) {
return (
<Center py={12}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
</Center>
);
}
const tableProps = { const tableProps = {
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,

View File

@@ -4,7 +4,7 @@
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Box, Text, HStack, Badge as ChakraBadge } from '@chakra-ui/react'; import { Box, Text, HStack, Badge as ChakraBadge, Spinner, Center } from '@chakra-ui/react';
import { Table, ConfigProvider, Tooltip } from 'antd'; import { Table, ConfigProvider, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { Eye } from 'lucide-react'; import { Eye } from 'lucide-react';
@@ -86,6 +86,7 @@ const tableStyles = `
export interface MetricsCategoryTabProps { export interface MetricsCategoryTabProps {
categoryKey: CategoryKey; categoryKey: CategoryKey;
financialMetrics: FinancialMetricsData[]; financialMetrics: FinancialMetricsData[];
loading?: boolean;
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void; showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number }; calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
getCellBackground: (change: number, intensity: number) => string; getCellBackground: (change: number, intensity: number) => string;
@@ -107,9 +108,19 @@ interface TableRowData {
const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({ const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
categoryKey, categoryKey,
financialMetrics, financialMetrics,
loading,
showMetricChart, showMetricChart,
calculateYoYChange, calculateYoYChange,
}) => { }) => {
// 加载中状态
if (loading && (!Array.isArray(financialMetrics) || financialMetrics.length === 0)) {
return (
<Center py={12}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
</Center>
);
}
// 数组安全检查 // 数组安全检查
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) { if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
return ( return (

View File

@@ -114,10 +114,13 @@ const tableStyles = `
} }
`; `;
interface TableRowData extends DetailTableRow { // 表格行数据类型 - 扩展索引签名以支持 boolean
type TableRowData = {
key: string; key: string;
isImportant?: boolean; isImportant?: boolean;
} 指标: string;
[year: string]: string | number | boolean | null | undefined;
};
const DetailTable: React.FC<DetailTableProps> = ({ data }) => { const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
const { years, rows } = data; const { years, rows } = data;

View File

@@ -1,29 +1,97 @@
// src/views/Company/components/LoadingState.tsx // src/views/Company/components/LoadingState.tsx
// 统一的加载状态组件 - 黑金主题 // 统一的加载状态组件 - 黑金主题
import React from "react"; import React, { memo } from "react";
import { Center, VStack, Spinner, Text } from "@chakra-ui/react"; import { Center, VStack, Spinner, Text, Box, Skeleton, SimpleGrid } from "@chakra-ui/react";
// 黑金主题配置 // 黑金主题配置
const THEME = { const THEME = {
gold: "#D4AF37", gold: "#D4AF37",
goldLight: "rgba(212, 175, 55, 0.3)",
bgInset: "rgba(26, 32, 44, 0.6)",
borderGlass: "rgba(212, 175, 55, 0.2)",
textSecondary: "gray.400", textSecondary: "gray.400",
radiusSM: "md",
radiusMD: "lg",
}; };
interface LoadingStateProps { interface LoadingStateProps {
message?: string; message?: string;
height?: string; height?: string;
/** 使用骨架屏模式(更好的视觉体验) */
variant?: "spinner" | "skeleton";
/** 骨架屏行数 */
skeletonRows?: number;
} }
/**
* 骨架屏组件(黑金主题)
*/
const SkeletonContent: React.FC<{ rows: number }> = memo(({ rows }) => (
<VStack align="stretch" spacing={4} w="100%">
{/* 头部骨架 */}
<Box display="flex" justifyContent="space-between" alignItems="center">
<Skeleton
height="28px"
width="180px"
startColor={THEME.bgInset}
endColor={THEME.borderGlass}
borderRadius={THEME.radiusSM}
/>
<Skeleton
height="24px"
width="100px"
startColor={THEME.bgInset}
endColor={THEME.borderGlass}
borderRadius={THEME.radiusSM}
/>
</Box>
{/* 内容骨架行 */}
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
{Array.from({ length: Math.min(rows, 8) }).map((_, i) => (
<Skeleton
key={i}
height="60px"
startColor={THEME.bgInset}
endColor={THEME.borderGlass}
borderRadius={THEME.radiusMD}
/>
))}
</SimpleGrid>
{/* 图表区域骨架 */}
<Skeleton
height="200px"
startColor={THEME.bgInset}
endColor={THEME.borderGlass}
borderRadius={THEME.radiusMD}
/>
</VStack>
));
SkeletonContent.displayName = "SkeletonContent";
/** /**
* 统一的加载状态组件(黑金主题) * 统一的加载状态组件(黑金主题)
* *
* 用于所有一级 Tab 的 loading 状态展示 * 用于所有一级 Tab 的 loading 状态展示
* @param variant - "spinner"(默认)或 "skeleton"(骨架屏)
*/ */
const LoadingState: React.FC<LoadingStateProps> = ({ const LoadingState: React.FC<LoadingStateProps> = memo(({
message = "加载中...", message = "加载中...",
height = "300px", height = "300px",
variant = "spinner",
skeletonRows = 4,
}) => { }) => {
if (variant === "skeleton") {
return (
<Box h={height} p={4}>
<SkeletonContent rows={skeletonRows} />
</Box>
);
}
return ( return (
<Center h={height}> <Center h={height}>
<VStack spacing={4}> <VStack spacing={4}>
@@ -39,6 +107,8 @@ const LoadingState: React.FC<LoadingStateProps> = ({
</VStack> </VStack>
</Center> </Center>
); );
}; });
LoadingState.displayName = "LoadingState";
export default LoadingState; export default LoadingState;

View File

@@ -1,5 +1,5 @@
// 指标卡片组件 - FUI 科幻风格 // 指标卡片组件 - FUI 科幻风格
import React from 'react'; import React, { memo } from 'react';
import { Box, VStack } from '@chakra-ui/react'; import { Box, VStack } from '@chakra-ui/react';
import { DarkGoldCard, CardTitle, MetricValue } from './atoms'; import { DarkGoldCard, CardTitle, MetricValue } from './atoms';
import { darkGoldTheme } from '../../constants'; import { darkGoldTheme } from '../../constants';
@@ -125,4 +125,4 @@ const MetricCard: React.FC<MetricCardProps> = ({
); );
}; };
export default MetricCard; export default memo(MetricCard);

View File

@@ -1,5 +1,5 @@
// 股票信息卡片组件4列布局版本- FUI 科幻风格 // 股票信息卡片组件4列布局版本- FUI 科幻风格
import React from 'react'; import React, { memo } from 'react';
import { Box, HStack, VStack, Text, Icon, Badge } from '@chakra-ui/react'; import { Box, HStack, VStack, Text, Icon, Badge } from '@chakra-ui/react';
import { TrendingUp, TrendingDown, Activity } from 'lucide-react'; import { TrendingUp, TrendingDown, Activity } from 'lucide-react';
import { DarkGoldCard } from './atoms'; import { DarkGoldCard } from './atoms';
@@ -206,4 +206,4 @@ const StockHeaderCard: React.FC<StockHeaderCardProps> = ({
); );
}; };
export default StockHeaderCard; export default memo(StockHeaderCard);

View File

@@ -1,5 +1,5 @@
// StockSummaryCard 主组件 // StockSummaryCard 主组件
import React from 'react'; import React, { memo } from 'react';
import { SimpleGrid, HStack, Text, VStack } from '@chakra-ui/react'; import { SimpleGrid, HStack, Text, VStack } from '@chakra-ui/react';
import { Flame, Coins, DollarSign, Shield } from 'lucide-react'; import { Flame, Coins, DollarSign, Shield } from 'lucide-react';
import StockHeaderCard from './StockHeaderCard'; import StockHeaderCard from './StockHeaderCard';
@@ -111,4 +111,4 @@ const StockSummaryCard: React.FC<StockSummaryCardProps> = ({ summary }) => {
); );
}; };
export default StockSummaryCard; export default memo(StockSummaryCard);

View File

@@ -86,6 +86,27 @@ const DRAWING_OPTIONS: { value: DrawingType; label: string; description: string
{ value: 'ALL', label: '全部显示', description: '显示所有参考线' }, { value: 'ALL', label: '全部显示', description: '显示所有参考线' },
]; ];
// 黑金主题按钮样式(提取到组件外部避免每次渲染重建)
const ACTIVE_BUTTON_STYLE = {
bg: `linear-gradient(135deg, ${darkGoldTheme.gold} 0%, ${darkGoldTheme.orange} 100%)`,
color: '#1a1a2e',
borderColor: darkGoldTheme.gold,
_hover: {
bg: `linear-gradient(135deg, ${darkGoldTheme.goldLight} 0%, ${darkGoldTheme.gold} 100%)`,
},
} as const;
const INACTIVE_BUTTON_STYLE = {
bg: 'transparent',
color: darkGoldTheme.textMuted,
borderColor: darkGoldTheme.border,
_hover: {
bg: 'rgba(212, 175, 55, 0.1)',
borderColor: darkGoldTheme.gold,
color: darkGoldTheme.gold,
},
} as const;
const KLineModule: React.FC<KLineModuleProps> = ({ const KLineModule: React.FC<KLineModuleProps> = ({
theme, theme,
tradeData, tradeData,
@@ -151,34 +172,13 @@ const KLineModule: React.FC<KLineModuleProps> = ({
setOverlayMetrics(prev => prev.filter(m => m.metric_id !== metricId)); setOverlayMetrics(prev => prev.filter(m => m.metric_id !== metricId));
}, []); }, []);
// 切换到分时模式时自动加载数据 // 切换到分时模式时自动加载数据(使用 useCallback 避免不必要的重渲染)
const handleModeChange = (newMode: ChartMode) => { const handleModeChange = useCallback((newMode: ChartMode) => {
setMode(newMode); setMode(newMode);
if (newMode === 'minute' && !hasMinuteData && !minuteLoading) { if (newMode === 'minute' && !hasMinuteData && !minuteLoading) {
onLoadMinuteData(); onLoadMinuteData();
} }
}; }, [hasMinuteData, minuteLoading, onLoadMinuteData]);
// 黑金主题按钮样式
const activeButtonStyle = {
bg: `linear-gradient(135deg, ${darkGoldTheme.gold} 0%, ${darkGoldTheme.orange} 100%)`,
color: '#1a1a2e',
borderColor: darkGoldTheme.gold,
_hover: {
bg: `linear-gradient(135deg, ${darkGoldTheme.goldLight} 0%, ${darkGoldTheme.gold} 100%)`,
},
};
const inactiveButtonStyle = {
bg: 'transparent',
color: darkGoldTheme.textMuted,
borderColor: darkGoldTheme.border,
_hover: {
bg: 'rgba(212, 175, 55, 0.1)',
borderColor: darkGoldTheme.gold,
color: darkGoldTheme.gold,
},
};
return ( return (
<Box <Box
@@ -263,7 +263,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
variant="outline" variant="outline"
leftIcon={showAnalysis ? <ViewOffIcon /> : <ViewIcon />} leftIcon={showAnalysis ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowAnalysis(!showAnalysis)} onClick={() => setShowAnalysis(!showAnalysis)}
{...(showAnalysis ? inactiveButtonStyle : activeButtonStyle)} {...(showAnalysis ? INACTIVE_BUTTON_STYLE : ACTIVE_BUTTON_STYLE)}
minW="90px" minW="90px"
> >
{showAnalysis ? '隐藏分析' : '显示分析'} {showAnalysis ? '隐藏分析' : '显示分析'}
@@ -278,7 +278,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
size="sm" size="sm"
variant="outline" variant="outline"
rightIcon={<ChevronDownIcon />} rightIcon={<ChevronDownIcon />}
{...inactiveButtonStyle} {...INACTIVE_BUTTON_STYLE}
minW="90px" minW="90px"
> >
{MAIN_INDICATOR_OPTIONS.find(o => o.value === mainIndicator)?.label || 'MA均线'} {MAIN_INDICATOR_OPTIONS.find(o => o.value === mainIndicator)?.label || 'MA均线'}
@@ -319,7 +319,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
variant="outline" variant="outline"
rightIcon={<ChevronDownIcon />} rightIcon={<ChevronDownIcon />}
leftIcon={<Activity size={14} />} leftIcon={<Activity size={14} />}
{...inactiveButtonStyle} {...INACTIVE_BUTTON_STYLE}
minW="100px" minW="100px"
> >
{SUB_INDICATOR_OPTIONS.find(o => o.value === subIndicator)?.label || 'MACD'} {SUB_INDICATOR_OPTIONS.find(o => o.value === subIndicator)?.label || 'MACD'}
@@ -360,7 +360,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
variant="outline" variant="outline"
rightIcon={<ChevronDownIcon />} rightIcon={<ChevronDownIcon />}
leftIcon={<Pencil size={14} />} leftIcon={<Pencil size={14} />}
{...(drawingType !== 'NONE' ? activeButtonStyle : inactiveButtonStyle)} {...(drawingType !== 'NONE' ? ACTIVE_BUTTON_STYLE : INACTIVE_BUTTON_STYLE)}
minW="90px" minW="90px"
> >
{DRAWING_OPTIONS.find(o => o.value === drawingType)?.label || '绘图'} {DRAWING_OPTIONS.find(o => o.value === drawingType)?.label || '绘图'}
@@ -411,7 +411,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setShowOrderBook(!showOrderBook)} onClick={() => setShowOrderBook(!showOrderBook)}
{...(showOrderBook ? activeButtonStyle : inactiveButtonStyle)} {...(showOrderBook ? ACTIVE_BUTTON_STYLE : INACTIVE_BUTTON_STYLE)}
minW="80px" minW="80px"
> >
{showOrderBook ? '隐藏盘口' : '显示盘口'} {showOrderBook ? '隐藏盘口' : '显示盘口'}
@@ -426,7 +426,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
onClick={onLoadMinuteData} onClick={onLoadMinuteData}
isLoading={minuteLoading} isLoading={minuteLoading}
loadingText="获取中" loadingText="获取中"
{...inactiveButtonStyle} {...INACTIVE_BUTTON_STYLE}
> >
</Button> </Button>
@@ -438,14 +438,14 @@ const KLineModule: React.FC<KLineModuleProps> = ({
<Button <Button
leftIcon={<BarChart2 size={14} />} leftIcon={<BarChart2 size={14} />}
onClick={() => handleModeChange('daily')} onClick={() => handleModeChange('daily')}
{...(mode === 'daily' ? activeButtonStyle : inactiveButtonStyle)} {...(mode === 'daily' ? ACTIVE_BUTTON_STYLE : INACTIVE_BUTTON_STYLE)}
> >
K K
</Button> </Button>
<Button <Button
leftIcon={<LineChart size={14} />} leftIcon={<LineChart size={14} />}
onClick={() => handleModeChange('minute')} onClick={() => handleModeChange('minute')}
{...(mode === 'minute' ? activeButtonStyle : inactiveButtonStyle)} {...(mode === 'minute' ? ACTIVE_BUTTON_STYLE : INACTIVE_BUTTON_STYLE)}
> >
</Button> </Button>

View File

@@ -84,37 +84,36 @@ export const useMarketData = (
} }
}, [stockCode]); }, [stockCode]);
// 记录已加载的数据类型
const loadedDataRef = useRef({
summary: false,
trade: false,
funding: false,
bigDeal: false,
unusual: false,
pledge: false,
});
/** /**
* 加载所有市场数据(涨幅分析延迟加载 * 加载核心市场数据(仅 summary 和 trade
*/ */
const loadMarketData = useCallback(async () => { const loadCoreData = useCallback(async () => {
if (!stockCode) return; if (!stockCode) return;
logger.debug('useMarketData', '开始加载市场数据', { stockCode, period }); logger.debug('useMarketData', '开始加载核心市场数据', { stockCode, period });
setLoading(true); setLoading(true);
setAnalysisMap({}); // 清空旧的分析数据 setAnalysisMap({}); // 清空旧的分析数据
try { try {
// 先加载核心数据(不含涨幅分析) const [summaryRes, tradeRes] = await Promise.all([
const [
summaryRes,
tradeRes,
fundingRes,
bigDealRes,
unusualRes,
pledgeRes,
] = await Promise.all([
marketService.getMarketSummary(stockCode), marketService.getMarketSummary(stockCode),
marketService.getTradeData(stockCode, period), marketService.getTradeData(stockCode, period),
marketService.getFundingData(stockCode, 30),
marketService.getBigDealData(stockCode, 30),
marketService.getUnusualData(stockCode, 30),
marketService.getPledgeData(stockCode),
]); ]);
// 设置概览数据 // 设置概览数据
if (summaryRes.success) { if (summaryRes.success) {
setSummary(summaryRes.data); setSummary(summaryRes.data);
loadedDataRef.current.summary = true;
} }
// 设置交易数据 // 设置交易数据
@@ -122,41 +121,79 @@ export const useMarketData = (
if (tradeRes.success) { if (tradeRes.success) {
loadedTradeData = tradeRes.data; loadedTradeData = tradeRes.data;
setTradeData(loadedTradeData); setTradeData(loadedTradeData);
loadedDataRef.current.trade = true;
} }
// 设置融资融券数据 logger.info('useMarketData', '核心市场数据加载成功', { stockCode });
if (fundingRes.success) {
setFundingData(fundingRes.data);
}
// 设置大宗交易数据(包含 daily_stats
if (bigDealRes.success) {
setBigDealData(bigDealRes);
}
// 设置龙虎榜数据(包含 grouped_data
if (unusualRes.success) {
setUnusualData(unusualRes);
}
// 设置股权质押数据
if (pledgeRes.success) {
setPledgeData(pledgeRes.data);
}
logger.info('useMarketData', '市场数据加载成功', { stockCode });
// 核心数据加载完成后,异步加载涨幅分析(不阻塞界面) // 核心数据加载完成后,异步加载涨幅分析(不阻塞界面)
if (loadedTradeData.length > 0) { if (loadedTradeData.length > 0) {
loadRiseAnalysis(loadedTradeData); loadRiseAnalysis(loadedTradeData);
} }
} catch (error) { } catch (error) {
logger.error('useMarketData', 'loadMarketData', error, { stockCode, period }); logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [stockCode, period, loadRiseAnalysis]); }, [stockCode, period, loadRiseAnalysis]);
/**
* 按需加载指定类型的数据
*/
const loadDataByType = useCallback(async (dataType: 'funding' | 'bigDeal' | 'unusual' | 'pledge') => {
if (!stockCode) return;
if (loadedDataRef.current[dataType]) return; // 已加载则跳过
logger.debug('useMarketData', `按需加载 ${dataType} 数据`, { stockCode });
try {
switch (dataType) {
case 'funding': {
const res = await marketService.getFundingData(stockCode, 30);
if (res.success) {
setFundingData(res.data);
loadedDataRef.current.funding = true;
}
break;
}
case 'bigDeal': {
const res = await marketService.getBigDealData(stockCode, 30);
if (res.success) {
setBigDealData(res);
loadedDataRef.current.bigDeal = true;
}
break;
}
case 'unusual': {
const res = await marketService.getUnusualData(stockCode, 30);
if (res.success) {
setUnusualData(res);
loadedDataRef.current.unusual = true;
}
break;
}
case 'pledge': {
const res = await marketService.getPledgeData(stockCode);
if (res.success) {
setPledgeData(res.data);
loadedDataRef.current.pledge = true;
}
break;
}
}
logger.info('useMarketData', `${dataType} 数据加载成功`, { stockCode });
} catch (error) {
logger.error('useMarketData', `loadDataByType:${dataType}`, error, { stockCode });
}
}, [stockCode]);
/**
* 加载所有市场数据(用于刷新)
*/
const loadMarketData = useCallback(async () => {
await loadCoreData();
}, [loadCoreData]);
/** /**
* 加载分钟K线数据 * 加载分钟K线数据
*/ */
@@ -234,19 +271,28 @@ export const useMarketData = (
await Promise.all([loadMarketData(), loadMinuteData()]); await Promise.all([loadMarketData(), loadMinuteData()]);
}, [loadMarketData, loadMinuteData]); }, [loadMarketData, loadMinuteData]);
// 监听股票代码变化,加载所有数据(首次加载或切换股票) // 监听股票代码变化,加载核心数据(首次加载或切换股票)
useEffect(() => { useEffect(() => {
if (stockCode) { if (stockCode) {
// stockCode 变化时,加载所有数据 // stockCode 变化时,重置已加载状态并加载核心数据
if (stockCode !== prevStockCodeRef.current || !isInitializedRef.current) { if (stockCode !== prevStockCodeRef.current || !isInitializedRef.current) {
// 重置已加载状态
loadedDataRef.current = {
summary: false,
trade: false,
funding: false,
bigDeal: false,
unusual: false,
pledge: false,
};
// 只加载核心数据summary + trade
loadMarketData(); loadMarketData();
loadMinuteData();
prevStockCodeRef.current = stockCode; prevStockCodeRef.current = stockCode;
prevPeriodRef.current = period; // 同步重置 period ref避免切换股票后误触发 refreshTradeData prevPeriodRef.current = period;
isInitializedRef.current = true; isInitializedRef.current = true;
} }
} }
}, [stockCode, period, loadMarketData, loadMinuteData]); }, [stockCode, period, loadMarketData]);
// 监听时间周期变化只刷新日K线数据 // 监听时间周期变化只刷新日K线数据
useEffect(() => { useEffect(() => {
@@ -273,6 +319,7 @@ export const useMarketData = (
refetch, refetch,
loadMinuteData, loadMinuteData,
refreshTradeData, refreshTradeData,
loadDataByType,
}; };
}; };

View File

@@ -68,8 +68,25 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
analysisMap, analysisMap,
refetch, refetch,
loadMinuteData, loadMinuteData,
loadDataByType,
} = useMarketData(stockCode, selectedPeriod); } = useMarketData(stockCode, selectedPeriod);
// Tab 切换时按需加载数据
const handleTabChange = useCallback((index: number) => {
setActiveTab(index);
// 根据 tab index 加载对应数据
const tabDataMap: Record<number, 'funding' | 'bigDeal' | 'unusual' | 'pledge'> = {
0: 'funding',
1: 'bigDeal',
2: 'unusual',
3: 'pledge',
};
const dataType = tabDataMap[index];
if (dataType) {
loadDataByType(dataType);
}
}, [loadDataByType]);
// 监听 props 中的 stockCode 变化 // 监听 props 中的 stockCode 变化
useEffect(() => { useEffect(() => {
if (propStockCode && propStockCode !== stockCode) { if (propStockCode && propStockCode !== stockCode) {
@@ -173,7 +190,7 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
componentProps={componentProps} componentProps={componentProps}
themePreset="blackGold" themePreset="blackGold"
index={activeTab} index={activeTab}
onTabChange={(index) => setActiveTab(index)} onTabChange={handleTabChange}
isLazy isLazy
/> />
)} )}

View File

@@ -364,6 +364,11 @@ export interface OverlayMetricData {
color?: string; color?: string;
} }
/**
* 按需加载的数据类型
*/
export type LazyDataType = 'funding' | 'bigDeal' | 'unusual' | 'pledge';
/** /**
* useMarketData Hook 返回值 * useMarketData Hook 返回值
*/ */
@@ -383,4 +388,5 @@ export interface UseMarketDataReturn {
refetch: () => Promise<void>; refetch: () => Promise<void>;
loadMinuteData: () => Promise<void>; loadMinuteData: () => Promise<void>;
refreshTradeData: () => Promise<void>; refreshTradeData: () => Promise<void>;
loadDataByType: (dataType: LazyDataType) => Promise<void>;
} }

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/MarketDataView/utils/chartOptions.ts // src/views/Company/components/MarketDataView/utils/chartOptions.ts
// MarketDataView ECharts 图表配置生成器 // MarketDataView ECharts 图表配置生成器
import type { EChartsOption } from 'echarts'; import type { EChartsOption } from '@lib/echarts';
import type { import type {
Theme, Theme,
TradeDayData, TradeDayData,

View File

@@ -15,7 +15,7 @@
* - 公司信息(成立、注册资本、所在地、官网、简介) * - 公司信息(成立、注册资本、所在地、官网、简介)
*/ */
import React from 'react'; import React, { memo } from 'react';
import { import {
Box, Box,
Flex, Flex,
@@ -147,12 +147,13 @@ const GlassSection: React.FC<GlassSectionProps> = ({ title, children, flex = 1 }
/> />
<Text <Text
fontSize="13px" fontSize="14px"
fontWeight="600" fontWeight="700"
color={T.textSecondary} color={T.gold}
mb={3} mb={3}
textTransform="uppercase" textTransform="uppercase"
letterSpacing="0.1em" letterSpacing="0.1em"
textShadow={`0 0 12px ${T.gold}60`}
> >
{title} {title}
</Text> </Text>
@@ -369,10 +370,10 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
</Text> </Text>
</HStack> </HStack>
{/* ========== 数据区块(Bento Grid========== */} {/* ========== 数据区块(三列布局========== */}
<Flex gap={4} flexWrap={{ base: 'wrap', lg: 'nowrap' }}> <Flex gap={4} flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
{/* 关键指标 */} {/* 第一列:估值指标 */}
<GlassSection title="关键指标" flex={1}> <GlassSection title="估值指标" flex={1}>
<VStack align="stretch" spacing={2}> <VStack align="stretch" spacing={2}>
<MetricRow <MetricRow
label="市盈率 (PE)" label="市盈率 (PE)"
@@ -380,6 +381,21 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
valueColor={T.cyan} valueColor={T.cyan}
highlight highlight
/> />
<MetricRow
label="流通股本"
value={quoteData.floatShares ? `${quoteData.floatShares}亿股` : '-'}
/>
<MetricRow
label="换手率"
value={quoteData.turnoverRate !== undefined ? `${quoteData.turnoverRate.toFixed(2)}%` : '-'}
valueColor={quoteData.turnoverRate && quoteData.turnoverRate > 5 ? T.orange : T.textWhite}
/>
</VStack>
</GlassSection>
{/* 第二列:市值股本 */}
<GlassSection title="市值股本" flex={1}>
<VStack align="stretch" spacing={2}>
<MetricRow <MetricRow
label="流通市值" label="流通市值"
value={quoteData.marketCap || '-'} value={quoteData.marketCap || '-'}
@@ -390,15 +406,6 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
label="发行总股本" label="发行总股本"
value={quoteData.totalShares ? `${quoteData.totalShares}亿股` : '-'} value={quoteData.totalShares ? `${quoteData.totalShares}亿股` : '-'}
/> />
<MetricRow
label="流通股本"
value={quoteData.floatShares ? `${quoteData.floatShares}亿股` : '-'}
/>
<MetricRow
label="换手率"
value={quoteData.turnoverRate !== undefined ? `${quoteData.turnoverRate.toFixed(2)}%` : '-'}
valueColor={quoteData.turnoverRate && quoteData.turnoverRate > 5 ? T.orange : T.textWhite}
/>
<MetricRow <MetricRow
label="52周波动" label="52周波动"
value={`${formatPrice(quoteData.week52Low)} - ${formatPrice(quoteData.week52High)}`} value={`${formatPrice(quoteData.week52Low)} - ${formatPrice(quoteData.week52High)}`}
@@ -406,7 +413,7 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
</VStack> </VStack>
</GlassSection> </GlassSection>
{/* 主力动态 */} {/* 第三列:主力动态 */}
<GlassSection title="主力动态" flex={1}> <GlassSection title="主力动态" flex={1}>
<VStack align="stretch" spacing={2}> <VStack align="stretch" spacing={2}>
<MetricRow <MetricRow
@@ -450,17 +457,15 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
</GlassSection> </GlassSection>
</Flex> </Flex>
{/* ========== 公司信息 ========== */} {/* ========== 公司信息(已注释)========== */}
{basicInfo && ( {/* {basicInfo && (
<> <>
{/* 分隔线 */}
<Box <Box
h="1px" h="1px"
bg={`linear-gradient(90deg, transparent, ${T.gold}30, transparent)`} bg={`linear-gradient(90deg, transparent, ${T.gold}30, transparent)`}
/> />
<Flex gap={8} flexWrap={{ base: 'wrap', lg: 'nowrap' }}> <Flex gap={8} flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
{/* 公司属性 */}
<HStack spacing={6} flex={1} flexWrap="wrap" fontSize="14px"> <HStack spacing={6} flex={1} flexWrap="wrap" fontSize="14px">
<HStack spacing={2}> <HStack spacing={2}>
<Icon as={Calendar} color={T.textMuted} boxSize={4} /> <Icon as={Calendar} color={T.textMuted} boxSize={4} />
@@ -501,7 +506,6 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
</HStack> </HStack>
</HStack> </HStack>
{/* 公司简介 */}
<Box <Box
flex={2} flex={2}
borderLeftWidth="1px" borderLeftWidth="1px"
@@ -518,7 +522,7 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
</Box> </Box>
</Flex> </Flex>
</> </>
)} )} */}
</VStack> </VStack>
</Box> </Box>
@@ -536,4 +540,4 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
); );
}; };
export default StockQuoteCard; export default memo(StockQuoteCard);

View File

@@ -39,15 +39,27 @@ export const THEME: CompanyTheme = {
}; };
// ============================================ // ============================================
// Tab 懒加载组件 // Tab 懒加载组件(带 webpack chunk 命名)
// ============================================ // ============================================
const CompanyOverview = lazy(() => import('./components/CompanyOverview')); const CompanyOverview = lazy(() =>
const DeepAnalysis = lazy(() => import('./components/DeepAnalysis')); import(/* webpackChunkName: "company-overview" */ './components/CompanyOverview')
const MarketDataView = lazy(() => import('./components/MarketDataView')); );
const FinancialPanorama = lazy(() => import('./components/FinancialPanorama')); const DeepAnalysis = lazy(() =>
const ForecastReport = lazy(() => import('./components/ForecastReport')); import(/* webpackChunkName: "company-deep-analysis" */ './components/DeepAnalysis')
const DynamicTracking = lazy(() => import('./components/DynamicTracking')); );
const MarketDataView = lazy(() =>
import(/* webpackChunkName: "company-market-data" */ './components/MarketDataView')
);
const FinancialPanorama = lazy(() =>
import(/* webpackChunkName: "company-financial" */ './components/FinancialPanorama')
);
const ForecastReport = lazy(() =>
import(/* webpackChunkName: "company-forecast" */ './components/ForecastReport')
);
const DynamicTracking = lazy(() =>
import(/* webpackChunkName: "company-tracking" */ './components/DynamicTracking')
);
// ============================================ // ============================================
// Tab 配置 // Tab 配置

View File

@@ -76,27 +76,43 @@ export const useCompanyData = ({
}, [stockCode]); }, [stockCode]);
/** /**
* 加载自选股状态 * 加载自选股状态(优化:只检查单个股票,避免加载整个列表)
*/ */
const loadWatchlistStatus = useCallback(async () => { const loadWatchlistStatus = useCallback(async () => {
if (!isAuthenticated) { if (!isAuthenticated || !stockCode) {
setIsInWatchlist(false); setIsInWatchlist(false);
return; return;
} }
try { try {
const { data } = await axios.get<ApiResponse<WatchlistItem[]>>( const { data } = await axios.get<ApiResponse<{ is_in_watchlist: boolean }>>(
'/api/account/watchlist' `/api/account/watchlist/check/${stockCode}`
); );
if (data.success && Array.isArray(data.data)) { if (data.success && data.data) {
const codes = new Set(data.data.map((item) => item.stock_code)); setIsInWatchlist(data.data.is_in_watchlist);
setIsInWatchlist(codes.has(stockCode)); } else {
setIsInWatchlist(false);
} }
} catch (error: any) { } catch (error: any) {
// 接口不存在时降级到原方案
if (error.response?.status === 404) {
try {
const { data: listData } = await axios.get<ApiResponse<WatchlistItem[]>>(
'/api/account/watchlist'
);
if (listData.success && Array.isArray(listData.data)) {
const codes = new Set(listData.data.map((item) => item.stock_code));
setIsInWatchlist(codes.has(stockCode));
}
} catch {
setIsInWatchlist(false);
}
} else {
logger.error('useCompanyData', 'loadWatchlistStatus', error); logger.error('useCompanyData', 'loadWatchlistStatus', error);
setIsInWatchlist(false); setIsInWatchlist(false);
} }
}
}, [stockCode, isAuthenticated]); }, [stockCode, isAuthenticated]);
/** /**

View File

@@ -1,7 +1,7 @@
// src/views/Company/hooks/useCompanyEvents.js // src/views/Company/hooks/useCompanyEvents.js
// 公司详情页面事件追踪 Hook // 公司详情页面事件追踪 Hook
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { usePostHogTrack } from '../../../hooks/usePostHogRedux'; import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants'; import { RETENTION_EVENTS } from '../../../lib/constants';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
@@ -14,9 +14,13 @@ import { logger } from '../../../utils/logger';
*/ */
export const useCompanyEvents = ({ stockCode } = {}) => { export const useCompanyEvents = ({ stockCode } = {}) => {
const { track } = usePostHogTrack(); const { track } = usePostHogTrack();
const hasTrackedPageView = useRef(false);
// 🎯 页面浏览事件 - 页面加载时触发 // 🎯 页面浏览事件 - 页面首次加载时触发一次
useEffect(() => { useEffect(() => {
if (hasTrackedPageView.current) return;
hasTrackedPageView.current = true;
track(RETENTION_EVENTS.COMPANY_PAGE_VIEWED, { track(RETENTION_EVENTS.COMPANY_PAGE_VIEWED, {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
stock_code: stockCode || null, stock_code: stockCode || null,

View File

@@ -9,135 +9,72 @@
* - HeroUI 现代组件风格 * - HeroUI 现代组件风格
*/ */
import React, { memo, useCallback, useRef, useEffect, Suspense } from 'react'; import React, { memo, useCallback, useRef, useEffect, useMemo } from 'react';
// FUI 动画样式 // FUI 动画样式
import './theme/fui-animations.css'; import './theme/fui-animations.css';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Box, Spinner, Center } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import SubTabContainer from '@components/SubTabContainer'; import SubTabContainer from '@components/SubTabContainer';
import { FuiContainer, AmbientGlow } from '@components/FUI';
import { useStockDocumentTitle } from '@hooks/useDocumentTitle';
import { useCompanyEvents } from './hooks/useCompanyEvents'; import { useCompanyEvents } from './hooks/useCompanyEvents';
import { useCompanyData } from './hooks/useCompanyData'; import { useCompanyData } from './hooks/useCompanyData';
import CompanyHeader from './components/CompanyHeader'; import CompanyHeader from './components/CompanyHeader';
import StockQuoteCard from './components/StockQuoteCard';
import { THEME, TAB_CONFIG } from './config'; import { THEME, TAB_CONFIG } from './config';
// ============================================
// 加载状态组件
// ============================================
const TabLoadingFallback = memo(() => (
<Center py={20}>
<Spinner size="xl" color={THEME.gold} thickness="3px" />
</Center>
));
TabLoadingFallback.displayName = 'TabLoadingFallback';
// ============================================ // ============================================
// 主内容区组件 - FUI 风格 // 主内容区组件 - FUI 风格
// ============================================ // ============================================
interface CompanyContentProps { interface CompanyContentProps {
stockCode: string; stockCode: string;
isInWatchlist: boolean;
watchlistLoading: boolean;
onWatchlistToggle: () => void;
onTabChange: (index: number, tabKey: string) => void; onTabChange: (index: number, tabKey: string) => void;
} }
const CompanyContent = memo<CompanyContentProps>(({ stockCode, onTabChange }) => ( const CompanyContent: React.FC<CompanyContentProps> = memo(({
<Box maxW="container.xl" mx="auto" px={4} py={6}> stockCode,
<Box isInWatchlist,
position="relative" watchlistLoading,
bg={`linear-gradient(145deg, rgba(26, 26, 46, 0.95) 0%, rgba(15, 15, 26, 0.98) 100%)`} onWatchlistToggle,
borderRadius="xl" onTabChange,
border="1px solid" }) => {
borderColor="rgba(212, 175, 55, 0.15)" // 缓存 componentProps避免每次渲染创建新对象
overflow="hidden" const memoizedComponentProps = useMemo(() => ({ stockCode }), [stockCode]);
backdropFilter="blur(16px)"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.05)"
>
{/* 角落装饰 - FUI 风格 */}
<Box
position="absolute"
top="12px"
left="12px"
w="16px"
h="16px"
borderTop="2px solid"
borderLeft="2px solid"
borderColor="rgba(212, 175, 55, 0.4)"
opacity={0.6}
/>
<Box
position="absolute"
top="12px"
right="12px"
w="16px"
h="16px"
borderTop="2px solid"
borderRight="2px solid"
borderColor="rgba(212, 175, 55, 0.4)"
opacity={0.6}
/>
<Box
position="absolute"
bottom="12px"
left="12px"
w="16px"
h="16px"
borderBottom="2px solid"
borderLeft="2px solid"
borderColor="rgba(212, 175, 55, 0.4)"
opacity={0.6}
/>
<Box
position="absolute"
bottom="12px"
right="12px"
w="16px"
h="16px"
borderBottom="2px solid"
borderRight="2px solid"
borderColor="rgba(212, 175, 55, 0.4)"
opacity={0.6}
/>
<Suspense fallback={<TabLoadingFallback />}> return (
<Box maxW="container.xl" mx="auto" px={4} py={6}>
{/* 股票行情卡片 - 放在 Tab 切换器上方,始终可见 */}
<Box mb={6}>
<StockQuoteCard
stockCode={stockCode}
isInWatchlist={isInWatchlist}
isWatchlistLoading={watchlistLoading}
onWatchlistToggle={onWatchlistToggle}
/>
</Box>
{/* Tab 内容区 - 使用 FuiContainer */}
<FuiContainer variant="default">
<SubTabContainer <SubTabContainer
tabs={TAB_CONFIG} tabs={TAB_CONFIG}
componentProps={{ stockCode }} componentProps={memoizedComponentProps}
onTabChange={onTabChange} onTabChange={onTabChange}
themePreset="blackGold" themePreset="blackGold"
contentPadding={6} contentPadding={0}
isLazy={true} isLazy={true}
/> />
</Suspense> </FuiContainer>
</Box> </Box>
</Box> );
)); });
CompanyContent.displayName = 'CompanyContent'; CompanyContent.displayName = 'CompanyContent';
// ============================================
// 网页标题 Hook
// ============================================
const useDocumentTitle = (stockCode: string, stockName?: string) => {
useEffect(() => {
const baseTitle = '价值前沿';
if (stockName) {
document.title = `${stockName}(${stockCode}) - ${baseTitle}`;
} else if (stockCode) {
document.title = `${stockCode} - ${baseTitle}`;
} else {
document.title = baseTitle;
}
// 组件卸载时恢复默认标题
return () => {
document.title = baseTitle;
};
}, [stockCode, stockName]);
};
// ============================================ // ============================================
// 主页面组件 // 主页面组件
// ============================================ // ============================================
@@ -168,7 +105,7 @@ const CompanyIndex: React.FC = () => {
const { trackStockSearched, trackTabChanged, trackWatchlistAdded, trackWatchlistRemoved } = companyEvents; const { trackStockSearched, trackTabChanged, trackWatchlistAdded, trackWatchlistRemoved } = companyEvents;
// 设置网页标题 // 设置网页标题
useDocumentTitle(stockCode, stockInfo?.stock_name); useStockDocumentTitle(stockCode, stockInfo?.stock_name);
// 股票代码变化追踪 // 股票代码变化追踪
useEffect(() => { useEffect(() => {
@@ -213,20 +150,7 @@ const CompanyIndex: React.FC = () => {
overflow="hidden" overflow="hidden"
> >
{/* 全局环境光效果 - James Turrell 风格 */} {/* 全局环境光效果 - James Turrell 风格 */}
<Box <AmbientGlow variant="default" />
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
pointerEvents="none"
zIndex={0}
bg={`
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(212, 175, 55, 0.08), transparent 50%),
radial-gradient(ellipse 60% 50% at 0% 50%, rgba(100, 200, 255, 0.04), transparent 40%),
radial-gradient(ellipse 60% 50% at 100% 50%, rgba(255, 200, 100, 0.04), transparent 40%)
`}
/>
{/* 顶部搜索栏 */} {/* 顶部搜索栏 */}
<Box position="relative" zIndex={1}> <Box position="relative" zIndex={1}>
@@ -245,6 +169,9 @@ const CompanyIndex: React.FC = () => {
<Box position="relative" zIndex={1}> <Box position="relative" zIndex={1}>
<CompanyContent <CompanyContent
stockCode={stockCode} stockCode={stockCode}
isInWatchlist={isInWatchlist}
watchlistLoading={watchlistLoading}
onWatchlistToggle={handleWatchlistToggle}
onTabChange={handleTabChange} onTabChange={handleTabChange}
/> />
</Box> </Box>

View File

@@ -1,8 +1,12 @@
/** /**
* Company 页面 FUI 主题统一导出 * Company 页面 FUI 主题统一导出
*
* 使用方式:
* import { COLORS, GLOW, GLASS } from '@views/Company/theme';
* import { FUI_COLORS, FUI_THEME } from '@views/Company/theme';
*/ */
// 主题配置 // 完整主题对象
export { default as FUI_THEME } from './fui'; export { default as FUI_THEME } from './fui';
export { export {
FUI_COLORS, FUI_COLORS,
@@ -15,3 +19,85 @@ export {
// 主题组件 // 主题组件
export * from './components'; export * from './components';
// ============================================
// 便捷常量导出(推荐使用)
// ============================================
import { FUI_COLORS, FUI_GLOW, FUI_GLASS } from './fui';
/**
* 常用颜色常量
* 用于替换硬编码的 rgba(212, 175, 55, x) 等值
*/
export const COLORS = {
// 金色系
gold: '#D4AF37',
goldLight: '#F0D78C',
goldDark: '#B8960C',
goldMuted: 'rgba(212, 175, 55, 0.5)',
// 背景
bgDeep: '#0A0A14',
bgPrimary: '#0F0F1A',
bgElevated: '#1A1A2E',
bgSurface: '#252540',
bgOverlay: 'rgba(26, 26, 46, 0.95)',
bgGlass: 'rgba(15, 18, 35, 0.6)',
// 边框
border: 'rgba(212, 175, 55, 0.2)',
borderHover: 'rgba(212, 175, 55, 0.4)',
borderSubtle: 'rgba(212, 175, 55, 0.1)',
borderEmphasis: 'rgba(212, 175, 55, 0.6)',
// 文字
textPrimary: 'rgba(255, 255, 255, 0.95)',
textSecondary: 'rgba(255, 255, 255, 0.7)',
textMuted: 'rgba(255, 255, 255, 0.5)',
textDim: 'rgba(255, 255, 255, 0.3)',
// 状态
positive: '#EF4444',
negative: '#22C55E',
warning: '#F59E0B',
info: '#3B82F6',
} as const;
/**
* 发光效果
*/
export const GLOW = {
goldSm: '0 0 8px rgba(212, 175, 55, 0.3)',
goldMd: '0 0 16px rgba(212, 175, 55, 0.4)',
goldLg: '0 0 32px rgba(212, 175, 55, 0.5)',
goldPulse: '0 0 20px rgba(212, 175, 55, 0.6), 0 0 40px rgba(212, 175, 55, 0.3)',
textGold: '0 0 10px rgba(212, 175, 55, 0.5)',
} as const;
/**
* 玻璃效果
*/
export const GLASS = {
blur: 'blur(16px)',
blurSm: 'blur(8px)',
blurLg: 'blur(24px)',
bgLight: 'rgba(255, 255, 255, 0.03)',
bgDark: 'rgba(0, 0, 0, 0.2)',
bgGold: 'rgba(212, 175, 55, 0.05)',
} as const;
/**
* 玻璃态卡片样式(可直接 spread 到组件)
*/
export const glassCardStyle = {
bg: COLORS.bgGlass,
borderRadius: '12px',
border: `1px solid ${COLORS.border}`,
backdropFilter: GLASS.blur,
transition: 'all 0.2s ease',
_hover: {
borderColor: COLORS.borderHover,
bg: 'rgba(15, 18, 35, 0.7)',
},
} as const;

View File

@@ -47,7 +47,7 @@ import {
FaRedo, FaRedo,
FaSearch FaSearch
} from 'react-icons/fa'; } from 'react-icons/fa';
import * as echarts from 'echarts'; import { echarts } from '@lib/echarts';
import StockChartModal from '../../../components/StockChart/StockChartModal'; import StockChartModal from '../../../components/StockChart/StockChartModal';
import { eventService, stockService } from '../../../services/eventService'; import { eventService, stockService } from '../../../services/eventService';

View File

@@ -4,8 +4,7 @@
*/ */
import React, { useEffect, useRef, useState, useMemo } from 'react'; import React, { useEffect, useRef, useState, useMemo } from 'react';
import { Box, Spinner, Center, Text } from '@chakra-ui/react'; import { Box, Spinner, Center, Text } from '@chakra-ui/react';
import * as echarts from 'echarts'; import { echarts, type ECharts, type EChartsOption } from '@lib/echarts';
import type { ECharts, EChartsOption } from 'echarts';
import { getApiBase } from '@utils/apiConfig'; import { getApiBase } from '@utils/apiConfig';
import type { MiniTimelineChartProps, TimelineDataPoint } from '../types'; import type { MiniTimelineChartProps, TimelineDataPoint } from '../types';

View File

@@ -4,7 +4,7 @@
*/ */
import React, { useRef, useEffect, useCallback, useMemo } from 'react'; import React, { useRef, useEffect, useCallback, useMemo } from 'react';
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import * as echarts from 'echarts'; import { echarts } from '@lib/echarts';
import { getAlertMarkPointsGrouped } from '../utils/chartHelpers'; import { getAlertMarkPointsGrouped } from '../utils/chartHelpers';
import { colors, glassEffect } from '../../../theme/glassTheme'; import { colors, glassEffect } from '../../../theme/glassTheme';

View File

@@ -56,7 +56,7 @@ import TradeDatePicker from '@components/TradeDatePicker';
import HotspotOverview from './components/HotspotOverview'; import HotspotOverview from './components/HotspotOverview';
import FlexScreen from './components/FlexScreen'; import FlexScreen from './components/FlexScreen';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs'; import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import * as echarts from 'echarts'; import { echarts } from '@lib/echarts';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import tradingDays from '../../data/tradingDays.json'; import tradingDays from '../../data/tradingDays.json';
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents'; import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';