Compare commits
20 Commits
25b2c2af49
...
986ec05eb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
986ec05eb1 | ||
|
|
02cc3eadd9 | ||
|
|
51721ce9bf | ||
|
|
c979e775a5 | ||
|
|
2720946ccf | ||
|
|
5331bc64b4 | ||
|
|
3953efc2ed | ||
|
|
50d59fd2ad | ||
|
|
eaa65b2328 | ||
|
|
79572fcc98 | ||
|
|
997724e0b1 | ||
|
|
ec2270ca8e | ||
|
|
44ba2e24e8 | ||
|
|
8e679b56f4 | ||
|
|
ae397ac904 | ||
|
|
a5bc1e1ce3 | ||
|
|
2ce74b4331 | ||
|
|
7931abe89b | ||
|
|
9b8983869c | ||
|
|
4b3588e8de |
40
src/components/Charts/ECharts.tsx
Normal file
40
src/components/Charts/ECharts.tsx
Normal 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;
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/Charts/Stock/MiniTimelineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from '@lib/echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box, useColorModeValue } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from '@lib/echarts';
|
||||
|
||||
/**
|
||||
* ECharts 图表渲染组件
|
||||
|
||||
81
src/components/FUI/AmbientGlow.tsx
Normal file
81
src/components/FUI/AmbientGlow.tsx
Normal 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;
|
||||
93
src/components/FUI/FuiContainer.tsx
Normal file
93
src/components/FUI/FuiContainer.tsx
Normal 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;
|
||||
126
src/components/FUI/FuiCorners.tsx
Normal file
126
src/components/FUI/FuiCorners.tsx
Normal 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;
|
||||
12
src/components/FUI/index.ts
Normal file
12
src/components/FUI/index.ts
Normal 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';
|
||||
@@ -2,7 +2,8 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
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 { stockService } from '@services/eventService';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
@@ -295,7 +296,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
}
|
||||
|
||||
// 图表配置(H5 响应式)
|
||||
const option: echarts.EChartsOption = {
|
||||
const option: EChartsOption = {
|
||||
backgroundColor: '#1a1a1a',
|
||||
title: {
|
||||
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Button, Spin, Typography } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from '@lib/echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import CitedContent from '../Citation/CitedContent';
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts, type ECharts, type EChartsOption } from '@lib/echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import { klineDataCache, getCacheKey, fetchKlineData } from '@utils/stock/klineDataCache';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
@@ -186,7 +186,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
}
|
||||
|
||||
// 图表配置(H5 响应式)
|
||||
const option: echarts.EChartsOption = {
|
||||
const option: EChartsOption = {
|
||||
backgroundColor: '#1a1a1a',
|
||||
title: {
|
||||
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, memo } from 'react';
|
||||
import React, { useState, useCallback, memo, Suspense } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
HStack,
|
||||
Text,
|
||||
Spacer,
|
||||
Center,
|
||||
Spinner,
|
||||
} from '@chakra-ui/react';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
@@ -134,6 +136,8 @@ export interface SubTabContainerProps {
|
||||
isLazy?: boolean;
|
||||
/** TabList 右侧自定义内容 */
|
||||
rightElement?: React.ReactNode;
|
||||
/** 紧凑模式 - 移除 TabList 的外边距 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
@@ -147,6 +151,7 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
contentPadding = 4,
|
||||
isLazy = true,
|
||||
rightElement,
|
||||
compact = false,
|
||||
}) => {
|
||||
// 内部状态(非受控模式)
|
||||
const [internalIndex, setInternalIndex] = useState(defaultIndex);
|
||||
@@ -190,6 +195,7 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
<Box>
|
||||
<Tabs
|
||||
isLazy={isLazy}
|
||||
lazyBehavior="keepMounted"
|
||||
variant="unstyled"
|
||||
index={currentIndex}
|
||||
onChange={handleTabChange}
|
||||
@@ -198,20 +204,20 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
<TabList
|
||||
bg={theme.bg}
|
||||
backdropFilter="blur(20px)"
|
||||
WebkitBackdropFilter="blur(20px)"
|
||||
sx={{ WebkitBackdropFilter: 'blur(20px)' }}
|
||||
borderBottom="1px solid"
|
||||
borderColor={theme.borderColor}
|
||||
borderRadius={DEEP_SPACE.radiusLG}
|
||||
mx={2}
|
||||
mb={2}
|
||||
borderRadius={compact ? 0 : DEEP_SPACE.radiusLG}
|
||||
mx={compact ? 0 : 2}
|
||||
mb={compact ? 0 : 2}
|
||||
px={3}
|
||||
py={3}
|
||||
py={compact ? 2 : 3}
|
||||
flexWrap="nowrap"
|
||||
gap={2}
|
||||
alignItems="center"
|
||||
overflowX="auto"
|
||||
position="relative"
|
||||
boxShadow={DEEP_SPACE.innerGlow}
|
||||
boxShadow={compact ? 'none' : DEEP_SPACE.innerGlow}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
@@ -307,7 +313,20 @@ const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
|
||||
return (
|
||||
<TabPanel key={tab.key} p={0}>
|
||||
{shouldRender && Component ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py={20}>
|
||||
<Spinner
|
||||
size="lg"
|
||||
color={DEEP_SPACE.textGold}
|
||||
thickness="3px"
|
||||
speed="0.8s"
|
||||
/>
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<Component {...componentProps} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</TabPanel>
|
||||
);
|
||||
|
||||
@@ -1632,14 +1632,17 @@ export default function SubscriptionContentNew() {
|
||||
<Text fontSize="sm" color="rgba(255, 255, 255, 0.7)">
|
||||
我已阅读并同意
|
||||
<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
|
||||
color="#3182CE"
|
||||
textDecoration="underline"
|
||||
mx={1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
《{(selectedPlan as any)?.name?.toLowerCase() === 'max' ? 'MAX' : 'PRO'}会员服务协议》
|
||||
《{(selectedPlan as { name?: string } | null)?.name?.toLowerCase() === 'max' ? 'MAX' : 'PRO'}会员服务协议》
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</Checkbox>
|
||||
|
||||
80
src/hooks/useDocumentTitle.ts
Normal file
80
src/hooks/useDocumentTitle.ts
Normal 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
124
src/lib/echarts.ts
Normal 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;
|
||||
@@ -69,13 +69,8 @@ export const companyHandlers = [
|
||||
const data = getCompanyData(stockCode);
|
||||
const raw = data.actualControl;
|
||||
|
||||
// 数据已经是数组格式,只做数值转换(holding_ratio 从 0-100 转为 0-1)
|
||||
const formatted = Array.isArray(raw)
|
||||
? raw.map(item => ({
|
||||
...item,
|
||||
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
|
||||
}))
|
||||
: [];
|
||||
// 数据保持原始百分比格式(如 52.38 表示 52.38%)
|
||||
const formatted = Array.isArray(raw) ? raw : [];
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
@@ -90,13 +85,8 @@ export const companyHandlers = [
|
||||
const data = getCompanyData(stockCode);
|
||||
const raw = data.concentration;
|
||||
|
||||
// 数据已经是数组格式,只做数值转换(holding_ratio 从 0-100 转为 0-1)
|
||||
const formatted = Array.isArray(raw)
|
||||
? raw.map(item => ({
|
||||
...item,
|
||||
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
|
||||
}))
|
||||
: [];
|
||||
// 数据保持原始百分比格式(如 52.38 表示 52.38%)
|
||||
const formatted = Array.isArray(raw) ? raw : [];
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -120,12 +120,14 @@ export const eventHandlers = [
|
||||
try {
|
||||
const result = generateMockEvents(params);
|
||||
|
||||
// 返回格式兼容 NewsPanel 期望的结构
|
||||
// NewsPanel 期望: { success, data: [], pagination: {} }
|
||||
// 返回格式兼容 useEventData 期望的结构
|
||||
// useEventData 期望: { success, data: { events: [], pagination: {} } }
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: result.events, // 事件数组
|
||||
pagination: result.pagination, // 分页信息
|
||||
data: {
|
||||
events: result.events, // 事件数组
|
||||
pagination: result.pagination // 分页信息
|
||||
},
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -263,15 +263,15 @@ export const stockHandlers = [
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (type === 'timeline') {
|
||||
if (type === 'timeline' || type === 'minute') {
|
||||
// timeline 和 minute 都使用分时数据
|
||||
data = generateTimelineData(indexCode);
|
||||
} else if (type === 'daily') {
|
||||
data = generateDailyData(indexCode, 30);
|
||||
} else {
|
||||
return HttpResponse.json(
|
||||
{ error: '不支持的类型' },
|
||||
{ status: 400 }
|
||||
);
|
||||
// 其他类型也降级使用 timeline 数据
|
||||
console.log('[Mock Stock] 未知类型,降级使用 timeline:', type);
|
||||
data = generateTimelineData(indexCode);
|
||||
}
|
||||
|
||||
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
49
src/services/financialService.d.ts
vendored
Normal 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;
|
||||
@@ -1,133 +1,137 @@
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
// src/services/financialService.js
|
||||
/**
|
||||
* 完整的财务数据服务层
|
||||
* 对应Flask后端的所有财务API接口
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = getApiBase();
|
||||
import axios from '@utils/axiosConfig';
|
||||
|
||||
/**
|
||||
* 统一的 API 请求函数
|
||||
* axios 拦截器已自动处理日志记录
|
||||
* @param {string} url - 请求 URL
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号,用于取消请求
|
||||
*/
|
||||
const apiRequest = async (url, options = {}) => {
|
||||
try {
|
||||
logger.debug('financialService', 'API请求', {
|
||||
url: `${API_BASE_URL}${url}`,
|
||||
method: options.method || 'GET'
|
||||
});
|
||||
const { method = 'GET', body, signal, ...rest } = options;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
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}`), {
|
||||
const config = {
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
errorText: errorText.substring(0, 200)
|
||||
});
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
signal,
|
||||
...rest,
|
||||
};
|
||||
|
||||
// 如果有 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();
|
||||
logger.debug('financialService', 'API响应', {
|
||||
url,
|
||||
success: data.success,
|
||||
hasData: !!data.data
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('financialService', 'apiRequest', error, { url });
|
||||
throw error;
|
||||
}
|
||||
const response = await axios(config);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const financialService = {
|
||||
/**
|
||||
* 获取股票基本信息和最新财务摘要
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getStockInfo: async (seccode) => {
|
||||
return await apiRequest(`/api/financial/stock-info/${seccode}`);
|
||||
getStockInfo: async (seccode, options = {}) => {
|
||||
return await apiRequest(`/api/financial/stock-info/${seccode}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取完整的资产负债表数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} limit - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getBalanceSheet: async (seccode, limit = 12) => {
|
||||
return await apiRequest(`/api/financial/balance-sheet/${seccode}?limit=${limit}`);
|
||||
getBalanceSheet: async (seccode, limit = 12, options = {}) => {
|
||||
return await apiRequest(`/api/financial/balance-sheet/${seccode}?limit=${limit}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取完整的利润表数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} limit - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getIncomeStatement: async (seccode, limit = 12) => {
|
||||
return await apiRequest(`/api/financial/income-statement/${seccode}?limit=${limit}`);
|
||||
getIncomeStatement: async (seccode, limit = 12, options = {}) => {
|
||||
return await apiRequest(`/api/financial/income-statement/${seccode}?limit=${limit}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取完整的现金流量表数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} limit - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getCashflow: async (seccode, limit = 12) => {
|
||||
return await apiRequest(`/api/financial/cashflow/${seccode}?limit=${limit}`);
|
||||
getCashflow: async (seccode, limit = 12, options = {}) => {
|
||||
return await apiRequest(`/api/financial/cashflow/${seccode}?limit=${limit}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取完整的财务指标数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} limit - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getFinancialMetrics: async (seccode, limit = 12) => {
|
||||
return await apiRequest(`/api/financial/financial-metrics/${seccode}?limit=${limit}`);
|
||||
getFinancialMetrics: async (seccode, limit = 12, options = {}) => {
|
||||
return await apiRequest(`/api/financial/financial-metrics/${seccode}?limit=${limit}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取主营业务构成数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} periods - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getMainBusiness: async (seccode, periods = 4) => {
|
||||
return await apiRequest(`/api/financial/main-business/${seccode}?periods=${periods}`);
|
||||
getMainBusiness: async (seccode, periods = 4, options = {}) => {
|
||||
return await apiRequest(`/api/financial/main-business/${seccode}?periods=${periods}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取业绩预告和预披露时间
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getForecast: async (seccode) => {
|
||||
return await apiRequest(`/api/financial/forecast/${seccode}`);
|
||||
getForecast: async (seccode, options = {}) => {
|
||||
return await apiRequest(`/api/financial/forecast/${seccode}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取行业排名数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} limit - 获取的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getIndustryRank: async (seccode, limit = 4) => {
|
||||
return await apiRequest(`/api/financial/industry-rank/${seccode}?limit=${limit}`);
|
||||
getIndustryRank: async (seccode, limit = 4, options = {}) => {
|
||||
return await apiRequest(`/api/financial/industry-rank/${seccode}?limit=${limit}`, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取不同报告期的对比数据
|
||||
* @param {string} seccode - 股票代码
|
||||
* @param {number} periods - 对比的报告期数量
|
||||
* @param {object} options - 请求选项
|
||||
* @param {AbortSignal} options.signal - AbortController 信号
|
||||
*/
|
||||
getPeriodComparison: async (seccode, periods = 8) => {
|
||||
return await apiRequest(`/api/financial/comparison/${seccode}?periods=${periods}`);
|
||||
getPeriodComparison: async (seccode, periods = 8, options = {}) => {
|
||||
return await apiRequest(`/api/financial/comparison/${seccode}?periods=${periods}`, options);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,53 +1,36 @@
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
// src/services/marketService.js
|
||||
/**
|
||||
* 完整的市场行情数据服务层
|
||||
* 对应Flask后端的所有市场API接口
|
||||
*/
|
||||
|
||||
import axios from '@utils/axiosConfig';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
/**
|
||||
* 统一的 API 请求函数
|
||||
* axios 拦截器已自动处理日志记录
|
||||
*/
|
||||
const apiRequest = async (url, options = {}) => {
|
||||
try {
|
||||
logger.debug('marketService', 'API请求', {
|
||||
url: `${API_BASE_URL}${url}`,
|
||||
method: options.method || 'GET'
|
||||
});
|
||||
const { method = 'GET', body, ...rest } = options;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
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}`), {
|
||||
const config = {
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
errorText: errorText.substring(0, 200)
|
||||
});
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
...rest,
|
||||
};
|
||||
|
||||
// 如果有 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();
|
||||
logger.debug('marketService', 'API响应', {
|
||||
url,
|
||||
success: data.success,
|
||||
hasData: !!data.data
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('marketService', 'apiRequest', error, { url });
|
||||
throw error;
|
||||
}
|
||||
const response = await axios(config);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const marketService = {
|
||||
|
||||
@@ -92,9 +92,18 @@ class SocketService {
|
||||
// 监听连接错误
|
||||
this.socket.on('connect_error', (error) => {
|
||||
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();
|
||||
|
||||
@@ -52,6 +52,11 @@ axios.interceptors.response.use(
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// 忽略取消请求的错误(组件卸载时正常行为)
|
||||
if (error.name === 'CanceledError' || axios.isCancel(error)) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const method = error.config?.method?.toUpperCase() || 'UNKNOWN';
|
||||
const url = error.config?.url || 'UNKNOWN';
|
||||
const requestData = error.config?.data || error.config?.params || null;
|
||||
|
||||
@@ -52,18 +52,21 @@ export const useEventData = (filters, pageSize = 10) => {
|
||||
total: response.data?.pagination?.total
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setEvents(response.data.events);
|
||||
if (response.success && response.data) {
|
||||
const events = response.data.events || [];
|
||||
const paginationData = response.data.pagination || {};
|
||||
|
||||
setEvents(events);
|
||||
setPagination({
|
||||
current: response.data.pagination.page,
|
||||
pageSize: response.data.pagination.per_page,
|
||||
total: response.data.pagination.total
|
||||
current: paginationData.page || page,
|
||||
pageSize: paginationData.per_page || pagination.pageSize,
|
||||
total: paginationData.total || 0
|
||||
});
|
||||
setLastUpdateTime(new Date());
|
||||
|
||||
logger.debug('useEventData', 'loadEvents 成功', {
|
||||
count: response.data.events.length,
|
||||
total: response.data.pagination.total
|
||||
count: events.length,
|
||||
total: paginationData.total || 0
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -137,25 +137,29 @@ const StockInfoDisplay = memo<{
|
||||
StockInfoDisplay.displayName = 'StockInfoDisplay';
|
||||
|
||||
/**
|
||||
* 搜索操作区组件
|
||||
* 搜索操作区组件(状态自管理,减少父组件重渲染)
|
||||
*/
|
||||
const SearchActions = memo<{
|
||||
inputCode: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onSearch: () => void;
|
||||
onSelect: (value: string) => void;
|
||||
stockCode: string;
|
||||
onStockChange: (value: string) => void;
|
||||
isInWatchlist: boolean;
|
||||
watchlistLoading: boolean;
|
||||
onWatchlistToggle: () => void;
|
||||
}>(({
|
||||
inputCode,
|
||||
onInputChange,
|
||||
onSearch,
|
||||
onSelect,
|
||||
stockCode,
|
||||
onStockChange,
|
||||
isInWatchlist,
|
||||
watchlistLoading,
|
||||
onWatchlistToggle,
|
||||
}) => {
|
||||
// 输入状态自管理(避免父组件重渲染)
|
||||
const [inputCode, setInputCode] = useState(stockCode);
|
||||
|
||||
// 同步外部 stockCode 变化
|
||||
React.useEffect(() => {
|
||||
setInputCode(stockCode);
|
||||
}, [stockCode]);
|
||||
|
||||
// 股票搜索 Hook
|
||||
const searchHook = useStockSearch({
|
||||
limit: 10,
|
||||
@@ -190,18 +194,28 @@ const SearchActions = memo<{
|
||||
}));
|
||||
}, [searchResults]);
|
||||
|
||||
// 处理搜索按钮点击
|
||||
const handleSearch = useCallback(() => {
|
||||
if (inputCode && inputCode !== stockCode) {
|
||||
onStockChange(inputCode);
|
||||
}
|
||||
}, [inputCode, stockCode, onStockChange]);
|
||||
|
||||
// 选中股票
|
||||
const handleSelect = useCallback((value: string) => {
|
||||
clearSearch();
|
||||
onSelect(value);
|
||||
}, [clearSearch, onSelect]);
|
||||
setInputCode(value);
|
||||
if (value !== stockCode) {
|
||||
onStockChange(value);
|
||||
}
|
||||
}, [clearSearch, stockCode, onStockChange]);
|
||||
|
||||
// 键盘事件
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSearch();
|
||||
handleSearch();
|
||||
}
|
||||
}, [onSearch]);
|
||||
}, [handleSearch]);
|
||||
|
||||
return (
|
||||
<HStack spacing={3}>
|
||||
@@ -241,7 +255,7 @@ const SearchActions = memo<{
|
||||
options={stockOptions}
|
||||
onSearch={doSearch}
|
||||
onSelect={handleSelect}
|
||||
onChange={onInputChange}
|
||||
onChange={setInputCode}
|
||||
placeholder="输入代码、名称或拼音"
|
||||
style={{ width: 240 }}
|
||||
dropdownStyle={{
|
||||
@@ -271,7 +285,7 @@ const SearchActions = memo<{
|
||||
size="md"
|
||||
h="42px"
|
||||
px={5}
|
||||
onClick={onSearch}
|
||||
onClick={handleSearch}
|
||||
leftIcon={<Icon as={Search} boxSize={4} />}
|
||||
fontWeight="bold"
|
||||
borderRadius="10px"
|
||||
@@ -335,28 +349,6 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
|
||||
onStockChange,
|
||||
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 (
|
||||
<Box
|
||||
position="relative"
|
||||
@@ -368,20 +360,7 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
|
||||
backdropFilter={FUI_GLASS.blur.md}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 环境光效果 - James Turrell 风格 */}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* 顶部发光线 */}
|
||||
{/* 顶部发光线(环境光效果由全局 AmbientGlow 提供) */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
@@ -413,10 +392,8 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
|
||||
|
||||
{/* 右侧:搜索和操作 */}
|
||||
<SearchActions
|
||||
inputCode={inputCode}
|
||||
onInputChange={setInputCode}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
stockCode={stockCode}
|
||||
onStockChange={onStockChange}
|
||||
isInWatchlist={isInWatchlist}
|
||||
watchlistLoading={watchlistLoading}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx
|
||||
// 工商信息 Tab Panel
|
||||
// 工商信息 Tab Panel - FUI 风格
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
@@ -7,14 +7,24 @@ import {
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
Center,
|
||||
Code,
|
||||
Icon,
|
||||
Spinner,
|
||||
} 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 { useBasicInfo } from "../../hooks/useBasicInfo";
|
||||
|
||||
@@ -22,6 +32,124 @@ interface BusinessInfoPanelProps {
|
||||
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 { basicInfo, loading } = useBasicInfo(stockCode);
|
||||
|
||||
@@ -43,77 +171,69 @@ const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => {
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
|
||||
<Box>
|
||||
<Heading size="sm" mb={3} color={THEME.textPrimary}>工商信息</Heading>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack w="full">
|
||||
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
|
||||
统一信用代码
|
||||
</Text>
|
||||
<Code fontSize="xs" bg={THEME.tableHoverBg} color={THEME.goldLight}>
|
||||
{basicInfo.credit_code}
|
||||
</Code>
|
||||
</HStack>
|
||||
<HStack w="full">
|
||||
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
|
||||
公司规模
|
||||
</Text>
|
||||
<Text fontSize="sm" color={THEME.textPrimary}>{basicInfo.company_size}</Text>
|
||||
</HStack>
|
||||
<HStack w="full" align="start">
|
||||
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
|
||||
注册地址
|
||||
</Text>
|
||||
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
|
||||
{basicInfo.reg_address}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack w="full" align="start">
|
||||
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
|
||||
办公地址
|
||||
</Text>
|
||||
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
|
||||
{basicInfo.office_address}
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 上半部分:工商信息 + 服务机构 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
|
||||
{/* 工商信息卡片 */}
|
||||
<Box {...glassCardStyle} p={4}>
|
||||
<SectionTitle icon={FaBuilding} title="工商信息" />
|
||||
<VStack spacing={2} align="stretch">
|
||||
<InfoRow
|
||||
icon={FaIdCard}
|
||||
label="信用代码"
|
||||
value={basicInfo.credit_code}
|
||||
isCode
|
||||
/>
|
||||
<InfoRow
|
||||
icon={FaUsers}
|
||||
label="公司规模"
|
||||
value={basicInfo.company_size}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={FaMapMarkerAlt}
|
||||
label="注册地址"
|
||||
value={basicInfo.reg_address}
|
||||
isMultiline
|
||||
/>
|
||||
<InfoRow
|
||||
icon={FaMapMarkerAlt}
|
||||
label="办公地址"
|
||||
value={basicInfo.office_address}
|
||||
isMultiline
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={3} color={THEME.textPrimary}>服务机构</Heading>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Box>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>会计师事务所</Text>
|
||||
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}>
|
||||
{basicInfo.accounting_firm}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color={THEME.textSecondary}>律师事务所</Text>
|
||||
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}>
|
||||
{basicInfo.law_firm}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 服务机构卡片 */}
|
||||
<Box {...glassCardStyle} p={4}>
|
||||
<SectionTitle icon={FaBalanceScale} title="服务机构" />
|
||||
<VStack spacing={3} align="stretch">
|
||||
<ServiceCard
|
||||
icon={FaCalculator}
|
||||
label="会计师事务所"
|
||||
value={basicInfo.accounting_firm}
|
||||
/>
|
||||
<ServiceCard
|
||||
icon={FaBalanceScale}
|
||||
label="律师事务所"
|
||||
value={basicInfo.law_firm}
|
||||
/>
|
||||
</VStack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider borderColor={THEME.border} />
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={3} color={THEME.textPrimary}>主营业务</Heading>
|
||||
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}>
|
||||
{basicInfo.main_business}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={3} color={THEME.textPrimary}>经营范围</Heading>
|
||||
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}>
|
||||
{basicInfo.business_scope}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 下半部分:主营业务 + 经营范围 */}
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={4}>
|
||||
<TextSection
|
||||
icon={FaBriefcase}
|
||||
title="主营业务"
|
||||
content={basicInfo.main_business}
|
||||
/>
|
||||
<TextSection
|
||||
icon={FaFileAlt}
|
||||
title="经营范围"
|
||||
content={basicInfo.business_scope}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Card, CardBody } from "@chakra-ui/react";
|
||||
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
|
||||
|
||||
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
|
||||
@@ -66,17 +65,15 @@ const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
|
||||
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
|
||||
|
||||
return (
|
||||
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
|
||||
<CardBody p={0}>
|
||||
<SubTabContainer
|
||||
tabs={tabs}
|
||||
componentProps={{ stockCode }}
|
||||
defaultIndex={defaultTabIndex}
|
||||
onTabChange={onTabChange}
|
||||
themePreset="blackGold"
|
||||
compact
|
||||
contentPadding={0}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 {
|
||||
Box,
|
||||
VStack,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
SimpleGrid,
|
||||
} from "@chakra-ui/react";
|
||||
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 { THEME } from "../../BasicInfoTab/config";
|
||||
|
||||
@@ -99,7 +99,7 @@ const ConcentrationCard: React.FC<ConcentrationCardProps> = ({ concentration = [
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
const option: EChartsOption = {
|
||||
backgroundColor: "transparent",
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
@@ -233,4 +233,4 @@ const ConcentrationCard: React.FC<ConcentrationCardProps> = ({ concentration = [
|
||||
);
|
||||
};
|
||||
|
||||
export default ConcentrationCard;
|
||||
export default memo(ConcentrationCard);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 { Table, Tag, Tooltip, ConfigProvider } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
@@ -225,4 +225,4 @@ const ShareholdersTable: React.FC<ShareholdersTableProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareholdersTable;
|
||||
export default memo(ShareholdersTable);
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
// src/views/Company/components/CompanyOverview/index.tsx
|
||||
// 公司档案 - 主组件(组合层)
|
||||
// 注:StockQuoteCard 已移至 Company/index.tsx,放在 Tab 容器上方,切换 Tab 时始终可见
|
||||
|
||||
import React from "react";
|
||||
import { VStack } from "@chakra-ui/react";
|
||||
|
||||
import type { CompanyOverviewProps } from "./types";
|
||||
|
||||
// 子组件
|
||||
import StockQuoteCard from "../StockQuoteCard";
|
||||
import BasicInfoTab from "./BasicInfoTab";
|
||||
|
||||
/**
|
||||
* 公司档案组件
|
||||
*
|
||||
* 功能:
|
||||
* - 显示股票行情卡片(个股详情)
|
||||
* - 显示基本信息 Tab(内部懒加载各子 Tab 数据)
|
||||
*
|
||||
* 注意:
|
||||
* - StockQuoteCard 已提升到 Company/index.tsx 中渲染
|
||||
* - 确保切换 Tab 时股票行情卡片始终可见
|
||||
*
|
||||
* 懒加载策略:
|
||||
* - BasicInfoTab 内部根据 Tab 切换懒加载数据
|
||||
* - 各 Panel 组件自行获取所需数据(如 BusinessInfoPanel 调用 useBasicInfo)
|
||||
*/
|
||||
const CompanyOverview: React.FC<CompanyOverviewProps> = ({ stockCode }) => {
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 股票行情卡片 - 个股详情 */}
|
||||
<StockQuoteCard stockCode={stockCode} />
|
||||
|
||||
{/* 基本信息内容 - 传入 stockCode,内部懒加载各 Tab 数据 */}
|
||||
<BasicInfoTab stockCode={stockCode} />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -189,9 +189,8 @@ const DeepAnalysis = ({ stockCode }) => {
|
||||
|
||||
// 重置为默认 Tab 并加载数据
|
||||
setActiveTab("strategy");
|
||||
// 加载默认 Tab 的数据(战略分析需要 comprehensive 和 industryRank)
|
||||
// 只加载默认 Tab 的核心数据(comprehensive),其他数据按需加载
|
||||
loadApiData("comprehensive");
|
||||
loadApiData("industryRank");
|
||||
}
|
||||
}, [stockCode, loadApiData]);
|
||||
|
||||
|
||||
48
src/views/Company/components/EChartsWrapper.tsx
Normal file
48
src/views/Company/components/EChartsWrapper.tsx
Normal 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;
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import axios from 'axios';
|
||||
import { logger } from '@utils/logger';
|
||||
import { financialService } from '@services/financialService';
|
||||
import type {
|
||||
@@ -19,6 +20,11 @@ import type {
|
||||
ComparisonData,
|
||||
} from '../types';
|
||||
|
||||
// 判断是否为取消请求的错误
|
||||
const isCancelError = (error: unknown): boolean => {
|
||||
return axios.isCancel(error) || (error instanceof Error && error.name === 'CanceledError');
|
||||
};
|
||||
|
||||
// Tab key 到数据类型的映射
|
||||
export type DataTypeKey =
|
||||
| 'balance'
|
||||
@@ -102,6 +108,10 @@ export const useFinancialData = (
|
||||
const isInitialLoad = useRef(true);
|
||||
const prevPeriods = useRef(selectedPeriods);
|
||||
|
||||
// AbortController refs - 用于取消请求
|
||||
const coreDataControllerRef = useRef<AbortController | null>(null);
|
||||
const tabDataControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// 判断 Tab key 对应的数据类型
|
||||
const getDataTypeForTab = (tabKey: DataTypeKey): 'balance' | 'income' | 'cashflow' | 'metrics' => {
|
||||
switch (tabKey) {
|
||||
@@ -120,32 +130,36 @@ export const useFinancialData = (
|
||||
// 按数据类型加载数据
|
||||
const loadDataByType = useCallback(async (
|
||||
dataType: 'balance' | 'income' | 'cashflow' | 'metrics',
|
||||
periods: number
|
||||
periods: number,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const options: { signal?: AbortSignal } = signal ? { signal } : {};
|
||||
try {
|
||||
switch (dataType) {
|
||||
case 'balance': {
|
||||
const res = await financialService.getBalanceSheet(stockCode, periods);
|
||||
const res = await financialService.getBalanceSheet(stockCode, periods, options);
|
||||
if (res.success) setBalanceSheet(res.data);
|
||||
break;
|
||||
}
|
||||
case 'income': {
|
||||
const res = await financialService.getIncomeStatement(stockCode, periods);
|
||||
const res = await financialService.getIncomeStatement(stockCode, periods, options);
|
||||
if (res.success) setIncomeStatement(res.data);
|
||||
break;
|
||||
}
|
||||
case 'cashflow': {
|
||||
const res = await financialService.getCashflow(stockCode, periods);
|
||||
const res = await financialService.getCashflow(stockCode, periods, options);
|
||||
if (res.success) setCashflow(res.data);
|
||||
break;
|
||||
}
|
||||
case 'metrics': {
|
||||
const res = await financialService.getFinancialMetrics(stockCode, periods);
|
||||
const res = await financialService.getFinancialMetrics(stockCode, periods, options);
|
||||
if (res.success) setFinancialMetrics(res.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 取消请求不作为错误处理
|
||||
if (isCancelError(err)) return;
|
||||
logger.error('useFinancialData', 'loadDataByType', err, { dataType, periods });
|
||||
throw err;
|
||||
}
|
||||
@@ -157,6 +171,11 @@ export const useFinancialData = (
|
||||
return;
|
||||
}
|
||||
|
||||
// 取消之前的 Tab 数据请求
|
||||
tabDataControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
tabDataControllerRef.current = controller;
|
||||
|
||||
const dataType = getDataTypeForTab(tabKey);
|
||||
logger.debug('useFinancialData', '刷新单个 Tab 数据', { tabKey, dataType, selectedPeriods });
|
||||
|
||||
@@ -164,14 +183,19 @@ export const useFinancialData = (
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await loadDataByType(dataType, selectedPeriods);
|
||||
await loadDataByType(dataType, selectedPeriods, controller.signal);
|
||||
logger.info('useFinancialData', `${tabKey} 数据刷新成功`);
|
||||
} catch (err) {
|
||||
// 取消请求不作为错误处理
|
||||
if (isCancelError(err)) return;
|
||||
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
// 只有当前请求没有被取消时才设置 loading 状态
|
||||
if (!controller.signal.aborted) {
|
||||
setLoadingTab(null);
|
||||
}
|
||||
}
|
||||
}, [stockCode, selectedPeriods, loadDataByType]);
|
||||
|
||||
// 设置期数(只刷新当前 Tab)
|
||||
@@ -179,8 +203,8 @@ export const useFinancialData = (
|
||||
setSelectedPeriodsState(periods);
|
||||
}, []);
|
||||
|
||||
// 加载所有财务数据(初始加载)
|
||||
const loadAllFinancialData = useCallback(async () => {
|
||||
// 加载核心财务数据(初始加载:stockInfo + metrics + comparison)
|
||||
const loadCoreFinancialData = useCallback(async () => {
|
||||
if (!stockCode || stockCode.length !== 6) {
|
||||
logger.warn('useFinancialData', '无效的股票代码', { stockCode });
|
||||
toast({
|
||||
@@ -191,55 +215,56 @@ export const useFinancialData = (
|
||||
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);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 并行加载所有数据
|
||||
// 只加载核心数据(概览面板需要的)
|
||||
const [
|
||||
stockInfoRes,
|
||||
balanceRes,
|
||||
incomeRes,
|
||||
cashflowRes,
|
||||
metricsRes,
|
||||
businessRes,
|
||||
forecastRes,
|
||||
rankRes,
|
||||
comparisonRes,
|
||||
businessRes,
|
||||
] = await Promise.all([
|
||||
financialService.getStockInfo(stockCode),
|
||||
financialService.getBalanceSheet(stockCode, selectedPeriods),
|
||||
financialService.getIncomeStatement(stockCode, selectedPeriods),
|
||||
financialService.getCashflow(stockCode, selectedPeriods),
|
||||
financialService.getFinancialMetrics(stockCode, selectedPeriods),
|
||||
financialService.getMainBusiness(stockCode, 4),
|
||||
financialService.getForecast(stockCode),
|
||||
financialService.getIndustryRank(stockCode, 4),
|
||||
financialService.getPeriodComparison(stockCode, selectedPeriods),
|
||||
financialService.getStockInfo(stockCode, options),
|
||||
financialService.getFinancialMetrics(stockCode, selectedPeriods, options),
|
||||
financialService.getPeriodComparison(stockCode, selectedPeriods, options),
|
||||
financialService.getMainBusiness(stockCode, 4, options),
|
||||
]);
|
||||
|
||||
// 设置数据
|
||||
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 (businessRes.success) setMainBusiness(businessRes.data);
|
||||
if (forecastRes.success) setForecast(forecastRes.data);
|
||||
if (rankRes.success) setIndustryRank(rankRes.data);
|
||||
if (comparisonRes.success) setComparison(comparisonRes.data);
|
||||
if (businessRes.success) setMainBusiness(businessRes.data);
|
||||
|
||||
logger.info('useFinancialData', '全部财务数据加载成功', { stockCode });
|
||||
logger.info('useFinancialData', '核心财务数据加载成功', { stockCode });
|
||||
} catch (err) {
|
||||
// 取消请求不作为错误处理
|
||||
if (isCancelError(err)) return;
|
||||
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
||||
setError(errorMessage);
|
||||
logger.error('useFinancialData', 'loadAllFinancialData', err, { stockCode, selectedPeriods });
|
||||
logger.error('useFinancialData', 'loadCoreFinancialData', err, { stockCode, selectedPeriods });
|
||||
} finally {
|
||||
// 只有当前请求没有被取消时才设置 loading 状态
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [stockCode, selectedPeriods, toast]);
|
||||
|
||||
// 加载所有财务数据(用于刷新)
|
||||
const loadAllFinancialData = useCallback(async () => {
|
||||
await loadCoreFinancialData();
|
||||
}, [loadCoreFinancialData]);
|
||||
|
||||
// 监听 props 中的 stockCode 变化
|
||||
useEffect(() => {
|
||||
if (initialStockCode && initialStockCode !== stockCode) {
|
||||
@@ -263,6 +288,14 @@ export const useFinancialData = (
|
||||
}
|
||||
}, [selectedPeriods, activeTab, refetchByTab]);
|
||||
|
||||
// 组件卸载时取消所有进行中的请求
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
coreDataControllerRef.current?.abort();
|
||||
tabDataControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 数据状态
|
||||
stockInfo,
|
||||
|
||||
@@ -122,8 +122,8 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
// 颜色配置
|
||||
const { bgColor, hoverBg, positiveColor, negativeColor } = COLORS;
|
||||
|
||||
// 点击指标行显示图表
|
||||
const showMetricChart = (
|
||||
// 点击指标行显示图表(使用 useCallback 避免不必要的重渲染)
|
||||
const showMetricChart = useCallback((
|
||||
metricName: string,
|
||||
_metricKey: string,
|
||||
data: Array<{ period: string; [key: string]: unknown }>,
|
||||
@@ -221,7 +221,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
</Box>
|
||||
);
|
||||
onOpen();
|
||||
};
|
||||
}, [onOpen, positiveColor, negativeColor]);
|
||||
|
||||
// Tab 配置 - 财务指标分类 + 三大财务报表
|
||||
const tabConfigs: SubTabConfig[] = useMemo(
|
||||
@@ -250,6 +250,9 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
incomeStatement,
|
||||
cashflow,
|
||||
financialMetrics,
|
||||
// 加载状态
|
||||
loading,
|
||||
loadingTab,
|
||||
// 工具函数
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
@@ -265,6 +268,8 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
incomeStatement,
|
||||
cashflow,
|
||||
financialMetrics,
|
||||
loading,
|
||||
loadingTab,
|
||||
showMetricChart,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
@@ -278,7 +283,7 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 财务全景面板(三列布局:成长能力、盈利与回报、风险与运营) */}
|
||||
{loading ? (
|
||||
<LoadingState message="加载财务数据中..." height="300px" />
|
||||
<LoadingState message="加载财务数据中..." height="300px" variant="skeleton" skeletonRows={6} />
|
||||
) : (
|
||||
<FinancialOverviewPanel
|
||||
stockInfo={stockInfo}
|
||||
@@ -302,7 +307,6 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
)}
|
||||
|
||||
{/* 三大财务报表 - 使用 SubTabContainer 二级导航 */}
|
||||
{!loading && stockInfo && (
|
||||
<Card bg="gray.900" shadow="md" border="1px solid" borderColor="rgba(212, 175, 55, 0.3)">
|
||||
<CardBody p={0}>
|
||||
<SubTabContainer
|
||||
@@ -316,13 +320,12 @@ const FinancialPanorama: React.FC<FinancialPanoramaProps> = ({ stockCode: propSt
|
||||
selectedPeriods={selectedPeriods}
|
||||
onPeriodsChange={setSelectedPeriods}
|
||||
onRefresh={handleRefresh}
|
||||
isLoading={loadingTab !== null}
|
||||
isLoading={loadingTab !== null || loading}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
*/
|
||||
|
||||
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 type { BalanceSheetData } from '../types';
|
||||
|
||||
export interface BalanceSheetTabProps {
|
||||
balanceSheet: BalanceSheetData[];
|
||||
loading?: boolean;
|
||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||
getCellBackground: (change: number, intensity: number) => string;
|
||||
@@ -20,6 +21,7 @@ export interface BalanceSheetTabProps {
|
||||
|
||||
const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
|
||||
balanceSheet,
|
||||
loading,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
@@ -28,6 +30,15 @@ const BalanceSheetTab: React.FC<BalanceSheetTabProps> = ({
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
// 加载中状态
|
||||
if (loading && (!Array.isArray(balanceSheet) || balanceSheet.length === 0)) {
|
||||
return (
|
||||
<Center py={12}>
|
||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
*/
|
||||
|
||||
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 type { CashflowData } from '../types';
|
||||
|
||||
export interface CashflowTabProps {
|
||||
cashflow: CashflowData[];
|
||||
loading?: boolean;
|
||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||
getCellBackground: (change: number, intensity: number) => string;
|
||||
@@ -20,6 +21,7 @@ export interface CashflowTabProps {
|
||||
|
||||
const CashflowTab: React.FC<CashflowTabProps> = ({
|
||||
cashflow,
|
||||
loading,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
@@ -28,6 +30,15 @@ const CashflowTab: React.FC<CashflowTabProps> = ({
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
// 加载中状态
|
||||
if (loading && (!Array.isArray(cashflow) || cashflow.length === 0)) {
|
||||
return (
|
||||
<Center py={12}>
|
||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Spinner, Center } from '@chakra-ui/react';
|
||||
import { FinancialMetricsTable } from '../components';
|
||||
import type { FinancialMetricsData } from '../types';
|
||||
|
||||
export interface FinancialMetricsTabProps {
|
||||
financialMetrics: FinancialMetricsData[];
|
||||
loading?: boolean;
|
||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||
getCellBackground: (change: number, intensity: number) => string;
|
||||
@@ -19,6 +21,7 @@ export interface FinancialMetricsTabProps {
|
||||
|
||||
const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
|
||||
financialMetrics,
|
||||
loading,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
@@ -27,6 +30,15 @@ const FinancialMetricsTab: React.FC<FinancialMetricsTabProps> = ({
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
// 加载中状态
|
||||
if (loading && (!Array.isArray(financialMetrics) || financialMetrics.length === 0)) {
|
||||
return (
|
||||
<Center py={12}>
|
||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
*/
|
||||
|
||||
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 type { IncomeStatementData } from '../types';
|
||||
|
||||
export interface IncomeStatementTabProps {
|
||||
incomeStatement: IncomeStatementData[];
|
||||
loading?: boolean;
|
||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||
getCellBackground: (change: number, intensity: number) => string;
|
||||
@@ -20,6 +21,7 @@ export interface IncomeStatementTabProps {
|
||||
|
||||
const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
|
||||
incomeStatement,
|
||||
loading,
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
getCellBackground,
|
||||
@@ -28,6 +30,15 @@ const IncomeStatementTab: React.FC<IncomeStatementTabProps> = ({
|
||||
bgColor,
|
||||
hoverBg,
|
||||
}) => {
|
||||
// 加载中状态
|
||||
if (loading && (!Array.isArray(incomeStatement) || incomeStatement.length === 0)) {
|
||||
return (
|
||||
<Center py={12}>
|
||||
<Spinner size="lg" color="#D4AF37" thickness="3px" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const tableProps = {
|
||||
showMetricChart,
|
||||
calculateYoYChange,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
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 type { ColumnsType } from 'antd/es/table';
|
||||
import { Eye } from 'lucide-react';
|
||||
@@ -86,6 +86,7 @@ const tableStyles = `
|
||||
export interface MetricsCategoryTabProps {
|
||||
categoryKey: CategoryKey;
|
||||
financialMetrics: FinancialMetricsData[];
|
||||
loading?: boolean;
|
||||
showMetricChart: (name: string, key: string, data: unknown[], path: string) => void;
|
||||
calculateYoYChange: (value: number, period: string, data: unknown[], path: string) => { change: number; intensity: number };
|
||||
getCellBackground: (change: number, intensity: number) => string;
|
||||
@@ -107,9 +108,19 @@ interface TableRowData {
|
||||
const MetricsCategoryTab: React.FC<MetricsCategoryTabProps> = ({
|
||||
categoryKey,
|
||||
financialMetrics,
|
||||
loading,
|
||||
showMetricChart,
|
||||
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) {
|
||||
return (
|
||||
|
||||
@@ -114,10 +114,13 @@ const tableStyles = `
|
||||
}
|
||||
`;
|
||||
|
||||
interface TableRowData extends DetailTableRow {
|
||||
// 表格行数据类型 - 扩展索引签名以支持 boolean
|
||||
type TableRowData = {
|
||||
key: string;
|
||||
isImportant?: boolean;
|
||||
}
|
||||
指标: string;
|
||||
[year: string]: string | number | boolean | null | undefined;
|
||||
};
|
||||
|
||||
const DetailTable: React.FC<DetailTableProps> = ({ data }) => {
|
||||
const { years, rows } = data;
|
||||
|
||||
@@ -1,29 +1,97 @@
|
||||
// src/views/Company/components/LoadingState.tsx
|
||||
// 统一的加载状态组件 - 黑金主题
|
||||
|
||||
import React from "react";
|
||||
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
|
||||
import React, { memo } from "react";
|
||||
import { Center, VStack, Spinner, Text, Box, Skeleton, SimpleGrid } from "@chakra-ui/react";
|
||||
|
||||
// 黑金主题配置
|
||||
const THEME = {
|
||||
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",
|
||||
radiusSM: "md",
|
||||
radiusMD: "lg",
|
||||
};
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: 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 状态展示
|
||||
* @param variant - "spinner"(默认)或 "skeleton"(骨架屏)
|
||||
*/
|
||||
const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
const LoadingState: React.FC<LoadingStateProps> = memo(({
|
||||
message = "加载中...",
|
||||
height = "300px",
|
||||
variant = "spinner",
|
||||
skeletonRows = 4,
|
||||
}) => {
|
||||
if (variant === "skeleton") {
|
||||
return (
|
||||
<Box h={height} p={4}>
|
||||
<SkeletonContent rows={skeletonRows} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Center h={height}>
|
||||
<VStack spacing={4}>
|
||||
@@ -39,6 +107,8 @@ const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
LoadingState.displayName = "LoadingState";
|
||||
|
||||
export default LoadingState;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 指标卡片组件 - FUI 科幻风格
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { Box, VStack } from '@chakra-ui/react';
|
||||
import { DarkGoldCard, CardTitle, MetricValue } from './atoms';
|
||||
import { darkGoldTheme } from '../../constants';
|
||||
@@ -125,4 +125,4 @@ const MetricCard: React.FC<MetricCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricCard;
|
||||
export default memo(MetricCard);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 股票信息卡片组件(4列布局版本)- FUI 科幻风格
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { Box, HStack, VStack, Text, Icon, Badge } from '@chakra-ui/react';
|
||||
import { TrendingUp, TrendingDown, Activity } from 'lucide-react';
|
||||
import { DarkGoldCard } from './atoms';
|
||||
@@ -206,4 +206,4 @@ const StockHeaderCard: React.FC<StockHeaderCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default StockHeaderCard;
|
||||
export default memo(StockHeaderCard);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// StockSummaryCard 主组件
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { SimpleGrid, HStack, Text, VStack } from '@chakra-ui/react';
|
||||
import { Flame, Coins, DollarSign, Shield } from 'lucide-react';
|
||||
import StockHeaderCard from './StockHeaderCard';
|
||||
@@ -111,4 +111,4 @@ const StockSummaryCard: React.FC<StockSummaryCardProps> = ({ summary }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default StockSummaryCard;
|
||||
export default memo(StockSummaryCard);
|
||||
|
||||
@@ -86,6 +86,27 @@ const DRAWING_OPTIONS: { value: DrawingType; label: string; description: string
|
||||
{ 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> = ({
|
||||
theme,
|
||||
tradeData,
|
||||
@@ -151,34 +172,13 @@ const KLineModule: React.FC<KLineModuleProps> = ({
|
||||
setOverlayMetrics(prev => prev.filter(m => m.metric_id !== metricId));
|
||||
}, []);
|
||||
|
||||
// 切换到分时模式时自动加载数据
|
||||
const handleModeChange = (newMode: ChartMode) => {
|
||||
// 切换到分时模式时自动加载数据(使用 useCallback 避免不必要的重渲染)
|
||||
const handleModeChange = useCallback((newMode: ChartMode) => {
|
||||
setMode(newMode);
|
||||
if (newMode === 'minute' && !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,
|
||||
},
|
||||
};
|
||||
}, [hasMinuteData, minuteLoading, onLoadMinuteData]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -263,7 +263,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
|
||||
variant="outline"
|
||||
leftIcon={showAnalysis ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowAnalysis(!showAnalysis)}
|
||||
{...(showAnalysis ? inactiveButtonStyle : activeButtonStyle)}
|
||||
{...(showAnalysis ? INACTIVE_BUTTON_STYLE : ACTIVE_BUTTON_STYLE)}
|
||||
minW="90px"
|
||||
>
|
||||
{showAnalysis ? '隐藏分析' : '显示分析'}
|
||||
@@ -278,7 +278,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
{...inactiveButtonStyle}
|
||||
{...INACTIVE_BUTTON_STYLE}
|
||||
minW="90px"
|
||||
>
|
||||
{MAIN_INDICATOR_OPTIONS.find(o => o.value === mainIndicator)?.label || 'MA均线'}
|
||||
@@ -319,7 +319,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
|
||||
variant="outline"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
leftIcon={<Activity size={14} />}
|
||||
{...inactiveButtonStyle}
|
||||
{...INACTIVE_BUTTON_STYLE}
|
||||
minW="100px"
|
||||
>
|
||||
{SUB_INDICATOR_OPTIONS.find(o => o.value === subIndicator)?.label || 'MACD'}
|
||||
@@ -360,7 +360,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
|
||||
variant="outline"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
leftIcon={<Pencil size={14} />}
|
||||
{...(drawingType !== 'NONE' ? activeButtonStyle : inactiveButtonStyle)}
|
||||
{...(drawingType !== 'NONE' ? ACTIVE_BUTTON_STYLE : INACTIVE_BUTTON_STYLE)}
|
||||
minW="90px"
|
||||
>
|
||||
{DRAWING_OPTIONS.find(o => o.value === drawingType)?.label || '绘图'}
|
||||
@@ -411,7 +411,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowOrderBook(!showOrderBook)}
|
||||
{...(showOrderBook ? activeButtonStyle : inactiveButtonStyle)}
|
||||
{...(showOrderBook ? ACTIVE_BUTTON_STYLE : INACTIVE_BUTTON_STYLE)}
|
||||
minW="80px"
|
||||
>
|
||||
{showOrderBook ? '隐藏盘口' : '显示盘口'}
|
||||
@@ -426,7 +426,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
|
||||
onClick={onLoadMinuteData}
|
||||
isLoading={minuteLoading}
|
||||
loadingText="获取中"
|
||||
{...inactiveButtonStyle}
|
||||
{...INACTIVE_BUTTON_STYLE}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -438,14 +438,14 @@ const KLineModule: React.FC<KLineModuleProps> = ({
|
||||
<Button
|
||||
leftIcon={<BarChart2 size={14} />}
|
||||
onClick={() => handleModeChange('daily')}
|
||||
{...(mode === 'daily' ? activeButtonStyle : inactiveButtonStyle)}
|
||||
{...(mode === 'daily' ? ACTIVE_BUTTON_STYLE : INACTIVE_BUTTON_STYLE)}
|
||||
>
|
||||
日K
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<LineChart size={14} />}
|
||||
onClick={() => handleModeChange('minute')}
|
||||
{...(mode === 'minute' ? activeButtonStyle : inactiveButtonStyle)}
|
||||
{...(mode === 'minute' ? ACTIVE_BUTTON_STYLE : INACTIVE_BUTTON_STYLE)}
|
||||
>
|
||||
分时
|
||||
</Button>
|
||||
|
||||
@@ -84,37 +84,36 @@ export const useMarketData = (
|
||||
}
|
||||
}, [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;
|
||||
|
||||
logger.debug('useMarketData', '开始加载市场数据', { stockCode, period });
|
||||
logger.debug('useMarketData', '开始加载核心市场数据', { stockCode, period });
|
||||
setLoading(true);
|
||||
setAnalysisMap({}); // 清空旧的分析数据
|
||||
|
||||
try {
|
||||
// 先加载核心数据(不含涨幅分析)
|
||||
const [
|
||||
summaryRes,
|
||||
tradeRes,
|
||||
fundingRes,
|
||||
bigDealRes,
|
||||
unusualRes,
|
||||
pledgeRes,
|
||||
] = await Promise.all([
|
||||
const [summaryRes, tradeRes] = await Promise.all([
|
||||
marketService.getMarketSummary(stockCode),
|
||||
marketService.getTradeData(stockCode, period),
|
||||
marketService.getFundingData(stockCode, 30),
|
||||
marketService.getBigDealData(stockCode, 30),
|
||||
marketService.getUnusualData(stockCode, 30),
|
||||
marketService.getPledgeData(stockCode),
|
||||
]);
|
||||
|
||||
// 设置概览数据
|
||||
if (summaryRes.success) {
|
||||
setSummary(summaryRes.data);
|
||||
loadedDataRef.current.summary = true;
|
||||
}
|
||||
|
||||
// 设置交易数据
|
||||
@@ -122,41 +121,79 @@ export const useMarketData = (
|
||||
if (tradeRes.success) {
|
||||
loadedTradeData = tradeRes.data;
|
||||
setTradeData(loadedTradeData);
|
||||
loadedDataRef.current.trade = true;
|
||||
}
|
||||
|
||||
// 设置融资融券数据
|
||||
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 });
|
||||
logger.info('useMarketData', '核心市场数据加载成功', { stockCode });
|
||||
|
||||
// 核心数据加载完成后,异步加载涨幅分析(不阻塞界面)
|
||||
if (loadedTradeData.length > 0) {
|
||||
loadRiseAnalysis(loadedTradeData);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('useMarketData', 'loadMarketData', error, { stockCode, period });
|
||||
logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [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线数据
|
||||
*/
|
||||
@@ -234,19 +271,28 @@ export const useMarketData = (
|
||||
await Promise.all([loadMarketData(), loadMinuteData()]);
|
||||
}, [loadMarketData, loadMinuteData]);
|
||||
|
||||
// 监听股票代码变化,加载所有数据(首次加载或切换股票)
|
||||
// 监听股票代码变化,加载核心数据(首次加载或切换股票)
|
||||
useEffect(() => {
|
||||
if (stockCode) {
|
||||
// stockCode 变化时,加载所有数据
|
||||
// stockCode 变化时,重置已加载状态并加载核心数据
|
||||
if (stockCode !== prevStockCodeRef.current || !isInitializedRef.current) {
|
||||
// 重置已加载状态
|
||||
loadedDataRef.current = {
|
||||
summary: false,
|
||||
trade: false,
|
||||
funding: false,
|
||||
bigDeal: false,
|
||||
unusual: false,
|
||||
pledge: false,
|
||||
};
|
||||
// 只加载核心数据(summary + trade)
|
||||
loadMarketData();
|
||||
loadMinuteData();
|
||||
prevStockCodeRef.current = stockCode;
|
||||
prevPeriodRef.current = period; // 同步重置 period ref,避免切换股票后误触发 refreshTradeData
|
||||
prevPeriodRef.current = period;
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [stockCode, period, loadMarketData, loadMinuteData]);
|
||||
}, [stockCode, period, loadMarketData]);
|
||||
|
||||
// 监听时间周期变化,只刷新日K线数据
|
||||
useEffect(() => {
|
||||
@@ -273,6 +319,7 @@ export const useMarketData = (
|
||||
refetch,
|
||||
loadMinuteData,
|
||||
refreshTradeData,
|
||||
loadDataByType,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -68,8 +68,25 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
||||
analysisMap,
|
||||
refetch,
|
||||
loadMinuteData,
|
||||
loadDataByType,
|
||||
} = 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 变化
|
||||
useEffect(() => {
|
||||
if (propStockCode && propStockCode !== stockCode) {
|
||||
@@ -173,7 +190,7 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
||||
componentProps={componentProps}
|
||||
themePreset="blackGold"
|
||||
index={activeTab}
|
||||
onTabChange={(index) => setActiveTab(index)}
|
||||
onTabChange={handleTabChange}
|
||||
isLazy
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -364,6 +364,11 @@ export interface OverlayMetricData {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按需加载的数据类型
|
||||
*/
|
||||
export type LazyDataType = 'funding' | 'bigDeal' | 'unusual' | 'pledge';
|
||||
|
||||
/**
|
||||
* useMarketData Hook 返回值
|
||||
*/
|
||||
@@ -383,4 +388,5 @@ export interface UseMarketDataReturn {
|
||||
refetch: () => Promise<void>;
|
||||
loadMinuteData: () => Promise<void>;
|
||||
refreshTradeData: () => Promise<void>;
|
||||
loadDataByType: (dataType: LazyDataType) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/components/MarketDataView/utils/chartOptions.ts
|
||||
// MarketDataView ECharts 图表配置生成器
|
||||
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import type { EChartsOption } from '@lib/echarts';
|
||||
import type {
|
||||
Theme,
|
||||
TradeDayData,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* - 公司信息(成立、注册资本、所在地、官网、简介)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
@@ -147,12 +147,13 @@ const GlassSection: React.FC<GlassSectionProps> = ({ title, children, flex = 1 }
|
||||
/>
|
||||
|
||||
<Text
|
||||
fontSize="13px"
|
||||
fontWeight="600"
|
||||
color={T.textSecondary}
|
||||
fontSize="14px"
|
||||
fontWeight="700"
|
||||
color={T.gold}
|
||||
mb={3}
|
||||
textTransform="uppercase"
|
||||
letterSpacing="0.1em"
|
||||
textShadow={`0 0 12px ${T.gold}60`}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
@@ -369,10 +370,10 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* ========== 数据区块(Bento Grid)========== */}
|
||||
{/* ========== 数据区块(三列布局)========== */}
|
||||
<Flex gap={4} flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
|
||||
{/* 关键指标 */}
|
||||
<GlassSection title="关键指标" flex={1}>
|
||||
{/* 第一列:估值指标 */}
|
||||
<GlassSection title="估值指标" flex={1}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<MetricRow
|
||||
label="市盈率 (PE)"
|
||||
@@ -380,6 +381,21 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
valueColor={T.cyan}
|
||||
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
|
||||
label="流通市值"
|
||||
value={quoteData.marketCap || '-'}
|
||||
@@ -390,15 +406,6 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
label="发行总股本"
|
||||
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
|
||||
label="52周波动"
|
||||
value={`${formatPrice(quoteData.week52Low)} - ${formatPrice(quoteData.week52High)}`}
|
||||
@@ -406,7 +413,7 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
</VStack>
|
||||
</GlassSection>
|
||||
|
||||
{/* 主力动态 */}
|
||||
{/* 第三列:主力动态 */}
|
||||
<GlassSection title="主力动态" flex={1}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<MetricRow
|
||||
@@ -450,17 +457,15 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
</GlassSection>
|
||||
</Flex>
|
||||
|
||||
{/* ========== 公司信息 ========== */}
|
||||
{basicInfo && (
|
||||
{/* ========== 公司信息(已注释)========== */}
|
||||
{/* {basicInfo && (
|
||||
<>
|
||||
{/* 分隔线 */}
|
||||
<Box
|
||||
h="1px"
|
||||
bg={`linear-gradient(90deg, transparent, ${T.gold}30, transparent)`}
|
||||
/>
|
||||
|
||||
<Flex gap={8} flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
|
||||
{/* 公司属性 */}
|
||||
<HStack spacing={6} flex={1} flexWrap="wrap" fontSize="14px">
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Calendar} color={T.textMuted} boxSize={4} />
|
||||
@@ -501,7 +506,6 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 公司简介 */}
|
||||
<Box
|
||||
flex={2}
|
||||
borderLeftWidth="1px"
|
||||
@@ -518,7 +522,7 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
)} */}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
@@ -536,4 +540,4 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default StockQuoteCard;
|
||||
export default memo(StockQuoteCard);
|
||||
|
||||
@@ -39,15 +39,27 @@ export const THEME: CompanyTheme = {
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Tab 懒加载组件
|
||||
// Tab 懒加载组件(带 webpack chunk 命名)
|
||||
// ============================================
|
||||
|
||||
const CompanyOverview = lazy(() => import('./components/CompanyOverview'));
|
||||
const DeepAnalysis = lazy(() => import('./components/DeepAnalysis'));
|
||||
const MarketDataView = lazy(() => import('./components/MarketDataView'));
|
||||
const FinancialPanorama = lazy(() => import('./components/FinancialPanorama'));
|
||||
const ForecastReport = lazy(() => import('./components/ForecastReport'));
|
||||
const DynamicTracking = lazy(() => import('./components/DynamicTracking'));
|
||||
const CompanyOverview = lazy(() =>
|
||||
import(/* webpackChunkName: "company-overview" */ './components/CompanyOverview')
|
||||
);
|
||||
const DeepAnalysis = lazy(() =>
|
||||
import(/* webpackChunkName: "company-deep-analysis" */ './components/DeepAnalysis')
|
||||
);
|
||||
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 配置
|
||||
|
||||
@@ -76,27 +76,43 @@ export const useCompanyData = ({
|
||||
}, [stockCode]);
|
||||
|
||||
/**
|
||||
* 加载自选股状态
|
||||
* 加载自选股状态(优化:只检查单个股票,避免加载整个列表)
|
||||
*/
|
||||
const loadWatchlistStatus = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
if (!isAuthenticated || !stockCode) {
|
||||
setIsInWatchlist(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.get<ApiResponse<WatchlistItem[]>>(
|
||||
'/api/account/watchlist'
|
||||
const { data } = await axios.get<ApiResponse<{ is_in_watchlist: boolean }>>(
|
||||
`/api/account/watchlist/check/${stockCode}`
|
||||
);
|
||||
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
const codes = new Set(data.data.map((item) => item.stock_code));
|
||||
setIsInWatchlist(codes.has(stockCode));
|
||||
if (data.success && data.data) {
|
||||
setIsInWatchlist(data.data.is_in_watchlist);
|
||||
} else {
|
||||
setIsInWatchlist(false);
|
||||
}
|
||||
} 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);
|
||||
setIsInWatchlist(false);
|
||||
}
|
||||
}
|
||||
}, [stockCode, isAuthenticated]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Company/hooks/useCompanyEvents.js
|
||||
// 公司详情页面事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
@@ -14,9 +14,13 @@ import { logger } from '../../../utils/logger';
|
||||
*/
|
||||
export const useCompanyEvents = ({ stockCode } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
const hasTrackedPageView = useRef(false);
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
// 🎯 页面浏览事件 - 仅页面首次加载时触发一次
|
||||
useEffect(() => {
|
||||
if (hasTrackedPageView.current) return;
|
||||
hasTrackedPageView.current = true;
|
||||
|
||||
track(RETENTION_EVENTS.COMPANY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
stock_code: stockCode || null,
|
||||
|
||||
@@ -9,135 +9,72 @@
|
||||
* - HeroUI 现代组件风格
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useRef, useEffect, Suspense } from 'react';
|
||||
import React, { memo, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
|
||||
// FUI 动画样式
|
||||
import './theme/fui-animations.css';
|
||||
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 { FuiContainer, AmbientGlow } from '@components/FUI';
|
||||
import { useStockDocumentTitle } from '@hooks/useDocumentTitle';
|
||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||||
import { useCompanyData } from './hooks/useCompanyData';
|
||||
import CompanyHeader from './components/CompanyHeader';
|
||||
import StockQuoteCard from './components/StockQuoteCard';
|
||||
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 风格
|
||||
// ============================================
|
||||
|
||||
interface CompanyContentProps {
|
||||
stockCode: string;
|
||||
isInWatchlist: boolean;
|
||||
watchlistLoading: boolean;
|
||||
onWatchlistToggle: () => void;
|
||||
onTabChange: (index: number, tabKey: string) => void;
|
||||
}
|
||||
|
||||
const CompanyContent = memo<CompanyContentProps>(({ stockCode, onTabChange }) => (
|
||||
<Box maxW="container.xl" mx="auto" px={4} py={6}>
|
||||
<Box
|
||||
position="relative"
|
||||
bg={`linear-gradient(145deg, rgba(26, 26, 46, 0.95) 0%, rgba(15, 15, 26, 0.98) 100%)`}
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor="rgba(212, 175, 55, 0.15)"
|
||||
overflow="hidden"
|
||||
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}
|
||||
/>
|
||||
const CompanyContent: React.FC<CompanyContentProps> = memo(({
|
||||
stockCode,
|
||||
isInWatchlist,
|
||||
watchlistLoading,
|
||||
onWatchlistToggle,
|
||||
onTabChange,
|
||||
}) => {
|
||||
// 缓存 componentProps,避免每次渲染创建新对象
|
||||
const memoizedComponentProps = useMemo(() => ({ stockCode }), [stockCode]);
|
||||
|
||||
<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
|
||||
tabs={TAB_CONFIG}
|
||||
componentProps={{ stockCode }}
|
||||
componentProps={memoizedComponentProps}
|
||||
onTabChange={onTabChange}
|
||||
themePreset="blackGold"
|
||||
contentPadding={6}
|
||||
contentPadding={0}
|
||||
isLazy={true}
|
||||
/>
|
||||
</Suspense>
|
||||
</FuiContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
));
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// 设置网页标题
|
||||
useDocumentTitle(stockCode, stockInfo?.stock_name);
|
||||
useStockDocumentTitle(stockCode, stockInfo?.stock_name);
|
||||
|
||||
// 股票代码变化追踪
|
||||
useEffect(() => {
|
||||
@@ -213,20 +150,7 @@ const CompanyIndex: React.FC = () => {
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 全局环境光效果 - James Turrell 风格 */}
|
||||
<Box
|
||||
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%)
|
||||
`}
|
||||
/>
|
||||
<AmbientGlow variant="default" />
|
||||
|
||||
{/* 顶部搜索栏 */}
|
||||
<Box position="relative" zIndex={1}>
|
||||
@@ -245,6 +169,9 @@ const CompanyIndex: React.FC = () => {
|
||||
<Box position="relative" zIndex={1}>
|
||||
<CompanyContent
|
||||
stockCode={stockCode}
|
||||
isInWatchlist={isInWatchlist}
|
||||
watchlistLoading={watchlistLoading}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* 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 {
|
||||
FUI_COLORS,
|
||||
@@ -15,3 +19,85 @@ export {
|
||||
|
||||
// 主题组件
|
||||
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;
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
FaRedo,
|
||||
FaSearch
|
||||
} from 'react-icons/fa';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from '@lib/echarts';
|
||||
import StockChartModal from '../../../components/StockChart/StockChartModal';
|
||||
|
||||
import { eventService, stockService } from '../../../services/eventService';
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
*/
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Box, Spinner, Center, Text } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import type { ECharts, EChartsOption } from 'echarts';
|
||||
import { echarts, type ECharts, type EChartsOption } from '@lib/echarts';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
import type { MiniTimelineChartProps, TimelineDataPoint } from '../types';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from '@lib/echarts';
|
||||
import { getAlertMarkPointsGrouped } from '../utils/chartHelpers';
|
||||
import { colors, glassEffect } from '../../../theme/glassTheme';
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ import TradeDatePicker from '@components/TradeDatePicker';
|
||||
import HotspotOverview from './components/HotspotOverview';
|
||||
import FlexScreen from './components/FlexScreen';
|
||||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from '@lib/echarts';
|
||||
import { logger } from '../../utils/logger';
|
||||
import tradingDays from '../../data/tradingDays.json';
|
||||
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
|
||||
|
||||
Reference in New Issue
Block a user