Compare commits
8 Commits
986ec05eb1
...
010ed9b5bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
010ed9b5bf | ||
|
|
afc6d16119 | ||
|
|
61e159f29b | ||
|
|
82290e8a63 | ||
|
|
029a61e42c | ||
|
|
958222e75f | ||
|
|
5b7534f6a5 | ||
|
|
1730a59ca2 |
140
src/components/FUI/CardGlow.tsx
Normal file
140
src/components/FUI/CardGlow.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* CardGlow - 卡片级装饰光效组件
|
||||||
|
*
|
||||||
|
* 为卡片提供 FUI 风格的装饰元素:
|
||||||
|
* - 顶部光条(Ash Thorp 风格)
|
||||||
|
* - 角落发光效果(James Turrell 风格)
|
||||||
|
* - 可选背景网格
|
||||||
|
*
|
||||||
|
* 与 AmbientGlow 的区别:
|
||||||
|
* - AmbientGlow: 页面级环境光,position: fixed
|
||||||
|
* - CardGlow: 卡片级装饰光,相对于父容器定位
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Box position="relative" overflow="hidden">
|
||||||
|
* <CardGlow variant="gold" />
|
||||||
|
* {children}
|
||||||
|
* </Box>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Box } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
export interface CardGlowProps {
|
||||||
|
/** 预设主题 */
|
||||||
|
variant?: 'gold' | 'cyan' | 'purple' | 'default';
|
||||||
|
/** 是否显示背景网格 */
|
||||||
|
showGrid?: boolean;
|
||||||
|
/** 自定义主色(覆盖 variant) */
|
||||||
|
primaryColor?: string;
|
||||||
|
/** 自定义次色(覆盖 variant) */
|
||||||
|
secondaryColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预设颜色配置
|
||||||
|
const COLOR_PRESETS = {
|
||||||
|
gold: {
|
||||||
|
primary: 'rgba(212, 175, 55, 1)',
|
||||||
|
secondary: 'rgba(0, 212, 255, 0.1)',
|
||||||
|
grid: 'rgba(212, 175, 55, 0.03)',
|
||||||
|
},
|
||||||
|
cyan: {
|
||||||
|
primary: 'rgba(0, 212, 255, 1)',
|
||||||
|
secondary: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
grid: 'rgba(0, 212, 255, 0.03)',
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
primary: 'rgba(168, 85, 247, 1)',
|
||||||
|
secondary: 'rgba(0, 212, 255, 0.1)',
|
||||||
|
grid: 'rgba(168, 85, 247, 0.03)',
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
primary: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
secondary: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
grid: 'rgba(255, 255, 255, 0.02)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片装饰光效组件
|
||||||
|
*
|
||||||
|
* 纯展示组件,需要父容器设置 position: relative 和 overflow: hidden
|
||||||
|
*/
|
||||||
|
const CardGlow = memo<CardGlowProps>(({
|
||||||
|
variant = 'gold',
|
||||||
|
showGrid = true,
|
||||||
|
primaryColor,
|
||||||
|
secondaryColor,
|
||||||
|
}) => {
|
||||||
|
const preset = COLOR_PRESETS[variant];
|
||||||
|
const primary = primaryColor || preset.primary;
|
||||||
|
const secondary = secondaryColor || preset.secondary;
|
||||||
|
const gridColor = preset.grid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 顶部光条 - Ash Thorp 风格数据终端效果 */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left="50%"
|
||||||
|
transform="translateX(-50%)"
|
||||||
|
width="60%"
|
||||||
|
height="1px"
|
||||||
|
background={`linear-gradient(90deg, transparent, ${primary}, transparent)`}
|
||||||
|
opacity={0.6}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 左上角光晕 - James Turrell 风格光影效果 */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top="-40px"
|
||||||
|
left="-40px"
|
||||||
|
width="80px"
|
||||||
|
height="80px"
|
||||||
|
borderRadius="50%"
|
||||||
|
background={`radial-gradient(circle, ${primary.replace('1)', '0.15)')} 0%, transparent 70%)`}
|
||||||
|
filter="blur(20px)"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 右下角光晕 - 补充色,增加层次感 */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
bottom="-40px"
|
||||||
|
right="-40px"
|
||||||
|
width="80px"
|
||||||
|
height="80px"
|
||||||
|
borderRadius="50%"
|
||||||
|
background={`radial-gradient(circle, ${secondary} 0%, transparent 70%)`}
|
||||||
|
filter="blur(20px)"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 背景网格 - 微妙的科技感纹理 */}
|
||||||
|
{showGrid && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
backgroundImage={`
|
||||||
|
linear-gradient(${gridColor} 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, ${gridColor} 1px, transparent 1px)
|
||||||
|
`}
|
||||||
|
backgroundSize="40px 40px"
|
||||||
|
pointerEvents="none"
|
||||||
|
opacity={0.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CardGlow.displayName = 'CardGlow';
|
||||||
|
|
||||||
|
export default CardGlow;
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* FUI (Futuristic UI) 组件集合
|
* FUI (Futuristic UI) 组件集合
|
||||||
* Ash Thorp 风格的科幻 UI 组件
|
* Ash Thorp 风格的科幻 UI 组件
|
||||||
|
*
|
||||||
|
* 组件说明:
|
||||||
|
* - FuiCorners: 科幻角落装饰
|
||||||
|
* - FuiContainer: FUI 风格容器
|
||||||
|
* - AmbientGlow: 页面级环境光效果(position: fixed)
|
||||||
|
* - CardGlow: 卡片级装饰光效(相对定位,用于卡片内部)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default as FuiCorners } from './FuiCorners';
|
export { default as FuiCorners } from './FuiCorners';
|
||||||
export { default as FuiContainer } from './FuiContainer';
|
export { default as FuiContainer } from './FuiContainer';
|
||||||
export { default as AmbientGlow } from './AmbientGlow';
|
export { default as AmbientGlow } from './AmbientGlow';
|
||||||
|
export { default as CardGlow } from './CardGlow';
|
||||||
|
|
||||||
export type { FuiCornersProps } from './FuiCorners';
|
export type { FuiCornersProps } from './FuiCorners';
|
||||||
export type { FuiContainerProps } from './FuiContainer';
|
export type { FuiContainerProps } from './FuiContainer';
|
||||||
export type { AmbientGlowProps } from './AmbientGlow';
|
export type { AmbientGlowProps } from './AmbientGlow';
|
||||||
|
export type { CardGlowProps } from './CardGlow';
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
// src/views/Company/components/CompanyHeader/SearchBar.js
|
|
||||||
// 股票搜索栏组件 - 金色主题 + 模糊搜索下拉
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
HStack,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputLeftElement,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { SearchIcon } from '@chakra-ui/icons';
|
|
||||||
import { useStockSearch } from '../../hooks/useStockSearch';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 股票搜索栏组件(带模糊搜索下拉)
|
|
||||||
*
|
|
||||||
* @param {Object} props
|
|
||||||
* @param {string} props.inputCode - 输入框当前值
|
|
||||||
* @param {Function} props.onInputChange - 输入变化回调
|
|
||||||
* @param {Function} props.onSearch - 搜索按钮点击回调
|
|
||||||
* @param {Function} props.onKeyDown - 键盘事件回调
|
|
||||||
*/
|
|
||||||
const SearchBar = ({
|
|
||||||
inputCode,
|
|
||||||
onInputChange,
|
|
||||||
onSearch,
|
|
||||||
onKeyDown,
|
|
||||||
}) => {
|
|
||||||
// 下拉状态
|
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
|
|
||||||
// 从 Redux 获取全部股票列表
|
|
||||||
const allStocks = useSelector(state => state.stock.allStocks);
|
|
||||||
|
|
||||||
// 使用共享的搜索 Hook
|
|
||||||
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
|
|
||||||
|
|
||||||
// 根据搜索结果更新下拉显示状态
|
|
||||||
useEffect(() => {
|
|
||||||
setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
|
|
||||||
}, [filteredStocks, inputCode]);
|
|
||||||
|
|
||||||
// 点击外部关闭下拉
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
|
||||||
setShowDropdown(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 选择股票 - 直接触发搜索跳转
|
|
||||||
const handleSelectStock = (stock) => {
|
|
||||||
onInputChange(stock.code);
|
|
||||||
setShowDropdown(false);
|
|
||||||
onSearch(stock.code);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理键盘事件
|
|
||||||
const handleKeyDownWrapper = (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
setShowDropdown(false);
|
|
||||||
}
|
|
||||||
onKeyDown?.(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box ref={containerRef} position="relative" w="300px">
|
|
||||||
<InputGroup size="lg">
|
|
||||||
<InputLeftElement pointerEvents="none">
|
|
||||||
<SearchIcon color="#C9A961" />
|
|
||||||
</InputLeftElement>
|
|
||||||
<Input
|
|
||||||
placeholder="输入股票代码或名称"
|
|
||||||
value={inputCode}
|
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDownWrapper}
|
|
||||||
onFocus={() => inputCode && filteredStocks.length > 0 && setShowDropdown(true)}
|
|
||||||
borderRadius="md"
|
|
||||||
color="white"
|
|
||||||
borderColor="#C9A961"
|
|
||||||
_placeholder={{ color: '#C9A961' }}
|
|
||||||
_focus={{
|
|
||||||
borderColor: '#F4D03F',
|
|
||||||
boxShadow: '0 0 0 1px #F4D03F',
|
|
||||||
}}
|
|
||||||
_hover={{
|
|
||||||
borderColor: '#F4D03F',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
|
|
||||||
{/* 模糊搜索下拉列表 */}
|
|
||||||
{showDropdown && (
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="100%"
|
|
||||||
left={0}
|
|
||||||
mt={1}
|
|
||||||
w="100%"
|
|
||||||
bg="#1A202C"
|
|
||||||
border="1px solid #C9A961"
|
|
||||||
borderRadius="md"
|
|
||||||
maxH="300px"
|
|
||||||
overflowY="auto"
|
|
||||||
zIndex={1000}
|
|
||||||
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
|
|
||||||
>
|
|
||||||
<VStack align="stretch" spacing={0}>
|
|
||||||
{filteredStocks.map((stock) => (
|
|
||||||
<Box
|
|
||||||
key={stock.code}
|
|
||||||
px={4}
|
|
||||||
py={2}
|
|
||||||
cursor="pointer"
|
|
||||||
_hover={{ bg: 'whiteAlpha.100' }}
|
|
||||||
onClick={() => handleSelectStock(stock)}
|
|
||||||
borderBottom="1px solid"
|
|
||||||
borderColor="whiteAlpha.100"
|
|
||||||
_last={{ borderBottom: 'none' }}
|
|
||||||
>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<Text color="#F4D03F" fontWeight="bold" fontSize="sm">
|
|
||||||
{stock.code}
|
|
||||||
</Text>
|
|
||||||
<Text color="#C9A961" fontSize="sm" noOfLines={1} maxW="180px">
|
|
||||||
{stock.name}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SearchBar;
|
|
||||||
70
src/views/Company/components/CompanyHeader/constants.ts
Normal file
70
src/views/Company/components/CompanyHeader/constants.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* CompanyHeader 组件常量
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FUI_COLORS, FUI_GLOW, FUI_ANIMATION } from '../../theme/fui';
|
||||||
|
|
||||||
|
/** 下拉菜单样式 */
|
||||||
|
export const DROPDOWN_STYLE: React.CSSProperties = {
|
||||||
|
backgroundColor: FUI_COLORS.bg.elevated,
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: `1px solid ${FUI_COLORS.gold[400]}`,
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 搜索图标样式 */
|
||||||
|
export const SEARCH_ICON_STYLE: React.CSSProperties = {
|
||||||
|
color: FUI_COLORS.gold[400],
|
||||||
|
fontSize: 16,
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 输入框样式 */
|
||||||
|
export const INPUT_STYLE: React.CSSProperties = {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderColor: FUI_COLORS.gold[400],
|
||||||
|
borderRadius: 6,
|
||||||
|
height: 44,
|
||||||
|
color: FUI_COLORS.gold[400],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** AutoComplete 宽度样式 */
|
||||||
|
export const AUTOCOMPLETE_STYLE: React.CSSProperties = {
|
||||||
|
width: 320,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 搜索框容器样式 */
|
||||||
|
export const SEARCH_BOX_SX = {
|
||||||
|
'.ant-select': {
|
||||||
|
width: '320px !important',
|
||||||
|
},
|
||||||
|
'.ant-input-affix-wrapper': {
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
borderColor: `${FUI_COLORS.gold[400]} !important`,
|
||||||
|
borderWidth: '1px !important',
|
||||||
|
borderRadius: '6px !important',
|
||||||
|
height: '44px !important',
|
||||||
|
padding: '0 12px !important',
|
||||||
|
transition: `all ${FUI_ANIMATION.duration.fast} ${FUI_ANIMATION.easing.default}`,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: `${FUI_COLORS.gold[300]} !important`,
|
||||||
|
boxShadow: FUI_GLOW.gold.sm,
|
||||||
|
},
|
||||||
|
'&:focus-within, &.ant-input-affix-wrapper-focused': {
|
||||||
|
borderColor: `${FUI_COLORS.gold[300]} !important`,
|
||||||
|
boxShadow: `${FUI_GLOW.gold.md} !important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'.ant-input': {
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
color: `${FUI_COLORS.gold[400]} !important`,
|
||||||
|
fontSize: '14px !important',
|
||||||
|
'&::placeholder': {
|
||||||
|
color: `${FUI_COLORS.gold[400]} !important`,
|
||||||
|
opacity: '0.7 !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'.ant-input-prefix': {
|
||||||
|
marginRight: '8px !important',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -1,170 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* Company 页面顶部搜索栏组件 - FUI 科幻风格
|
* Company 页面顶部搜索栏组件 - FUI 科幻风格
|
||||||
*
|
|
||||||
* 设计特点:
|
|
||||||
* - Glassmorphism 毛玻璃背景
|
|
||||||
* - 发光效果和微动画
|
|
||||||
* - Ash Thorp 风格的数据展示
|
|
||||||
* - James Turrell 柔和光影
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo, useMemo, useCallback, useState } from 'react';
|
import React, { memo, useMemo, useCallback, useState } from 'react';
|
||||||
import {
|
import { Box, Flex, HStack, VStack, Text } from '@chakra-ui/react';
|
||||||
Box,
|
import { AutoComplete, Input, Spin } from 'antd';
|
||||||
Flex,
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
HStack,
|
|
||||||
VStack,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
Icon,
|
|
||||||
Skeleton,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { AutoComplete, Spin } from 'antd';
|
|
||||||
import { Search, Star, TrendingUp, TrendingDown } from 'lucide-react';
|
|
||||||
import { useStockSearch } from '@hooks/useStockSearch';
|
import { useStockSearch } from '@hooks/useStockSearch';
|
||||||
import { THEME, getSearchBoxStyles } from '../../config';
|
import { THEME } from '../../config';
|
||||||
import { FUI_COLORS, FUI_GLOW, FUI_ANIMATION, FUI_GLASS } from '../../theme/fui';
|
import { FUI_COLORS, FUI_GLOW } from '../../theme/fui';
|
||||||
import type { CompanyHeaderProps, StockSearchResult } from '../../types';
|
import type { CompanyHeaderProps, StockSearchResult } from '../../types';
|
||||||
|
import {
|
||||||
|
DROPDOWN_STYLE,
|
||||||
|
SEARCH_ICON_STYLE,
|
||||||
|
INPUT_STYLE,
|
||||||
|
AUTOCOMPLETE_STYLE,
|
||||||
|
SEARCH_BOX_SX,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
/**
|
// ============================================
|
||||||
* 股票信息展示组件 - FUI 风格
|
// SearchBox 子组件
|
||||||
*/
|
// ============================================
|
||||||
const StockInfoDisplay = memo<{
|
|
||||||
stockCode: string;
|
|
||||||
stockName?: string;
|
|
||||||
price?: number | null;
|
|
||||||
change?: number | null;
|
|
||||||
loading: boolean;
|
|
||||||
}>(({ stockCode, stockName, price, change, loading }) => {
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<VStack align="start" spacing={2}>
|
|
||||||
<Skeleton height="36px" width="220px" startColor={FUI_COLORS.bg.surface} endColor={FUI_COLORS.bg.elevated} />
|
|
||||||
<Skeleton height="28px" width="160px" startColor={FUI_COLORS.bg.surface} endColor={FUI_COLORS.bg.elevated} />
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPositive = change !== null && change !== undefined && change >= 0;
|
const SearchBox = memo<{
|
||||||
const TrendIcon = isPositive ? TrendingUp : TrendingDown;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack align="start" spacing={1}>
|
|
||||||
{/* 股票代码 & 名称 */}
|
|
||||||
<HStack spacing={3} align="baseline">
|
|
||||||
<Text
|
|
||||||
fontSize="2xl"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={FUI_COLORS.gold[400]}
|
|
||||||
letterSpacing="wider"
|
|
||||||
textShadow={FUI_GLOW.text.gold}
|
|
||||||
>
|
|
||||||
{stockCode}
|
|
||||||
</Text>
|
|
||||||
{stockName && (
|
|
||||||
<Text
|
|
||||||
fontSize="xl"
|
|
||||||
fontWeight="medium"
|
|
||||||
color={FUI_COLORS.text.primary}
|
|
||||||
letterSpacing="wide"
|
|
||||||
>
|
|
||||||
{stockName}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 价格 & 涨跌幅 */}
|
|
||||||
{price !== null && price !== undefined && (
|
|
||||||
<HStack spacing={4} mt={1}>
|
|
||||||
{/* 价格 */}
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Text
|
|
||||||
fontSize="xs"
|
|
||||||
color={FUI_COLORS.text.muted}
|
|
||||||
textTransform="uppercase"
|
|
||||||
letterSpacing="wider"
|
|
||||||
>
|
|
||||||
Price
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
fontSize="xl"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={FUI_COLORS.text.primary}
|
|
||||||
fontFamily="mono"
|
|
||||||
>
|
|
||||||
¥{price.toFixed(2)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 涨跌幅 Badge */}
|
|
||||||
{change !== null && change !== undefined && (
|
|
||||||
<Box
|
|
||||||
display="inline-flex"
|
|
||||||
alignItems="center"
|
|
||||||
gap={1}
|
|
||||||
px={3}
|
|
||||||
py={1}
|
|
||||||
borderRadius="full"
|
|
||||||
bg={isPositive ? 'rgba(239, 68, 68, 0.15)' : 'rgba(34, 197, 94, 0.15)'}
|
|
||||||
border="1px solid"
|
|
||||||
borderColor={isPositive ? 'rgba(239, 68, 68, 0.3)' : 'rgba(34, 197, 94, 0.3)'}
|
|
||||||
boxShadow={isPositive
|
|
||||||
? '0 0 12px rgba(239, 68, 68, 0.2)'
|
|
||||||
: '0 0 12px rgba(34, 197, 94, 0.2)'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
as={TrendIcon}
|
|
||||||
boxSize={3.5}
|
|
||||||
color={isPositive ? FUI_COLORS.status.positive : FUI_COLORS.status.negative}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
fontSize="sm"
|
|
||||||
fontWeight="bold"
|
|
||||||
fontFamily="mono"
|
|
||||||
color={isPositive ? FUI_COLORS.status.positive : FUI_COLORS.status.negative}
|
|
||||||
>
|
|
||||||
{isPositive ? '+' : ''}{change.toFixed(2)}%
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
StockInfoDisplay.displayName = 'StockInfoDisplay';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 搜索操作区组件(状态自管理,减少父组件重渲染)
|
|
||||||
*/
|
|
||||||
const SearchActions = memo<{
|
|
||||||
stockCode: string;
|
|
||||||
onStockChange: (value: string) => void;
|
onStockChange: (value: string) => void;
|
||||||
isInWatchlist: boolean;
|
}>(({ onStockChange }) => {
|
||||||
watchlistLoading: boolean;
|
const [inputCode, setInputCode] = useState('');
|
||||||
onWatchlistToggle: () => void;
|
|
||||||
}>(({
|
|
||||||
stockCode,
|
|
||||||
onStockChange,
|
|
||||||
isInWatchlist,
|
|
||||||
watchlistLoading,
|
|
||||||
onWatchlistToggle,
|
|
||||||
}) => {
|
|
||||||
// 输入状态自管理(避免父组件重渲染)
|
|
||||||
const [inputCode, setInputCode] = useState(stockCode);
|
|
||||||
|
|
||||||
// 同步外部 stockCode 变化
|
const {
|
||||||
React.useEffect(() => {
|
searchResults,
|
||||||
setInputCode(stockCode);
|
isSearching,
|
||||||
}, [stockCode]);
|
handleSearch: doSearch,
|
||||||
|
clearSearch,
|
||||||
// 股票搜索 Hook
|
} = useStockSearch({
|
||||||
const searchHook = useStockSearch({
|
|
||||||
limit: 10,
|
limit: 10,
|
||||||
debounceMs: 300,
|
debounceMs: 300,
|
||||||
onSearch: () => {}, // 空回调,追踪在父组件处理
|
onSearch: () => {},
|
||||||
}) as {
|
}) as {
|
||||||
searchResults: StockSearchResult[];
|
searchResults: StockSearchResult[];
|
||||||
isSearching: boolean;
|
isSearching: boolean;
|
||||||
@@ -172,11 +43,8 @@ const SearchActions = memo<{
|
|||||||
clearSearch: () => void;
|
clearSearch: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { searchResults, isSearching, handleSearch: doSearch, clearSearch } = searchHook;
|
const stockOptions = useMemo(() => (
|
||||||
|
searchResults.map((stock: StockSearchResult) => ({
|
||||||
// 转换为 AutoComplete options
|
|
||||||
const stockOptions = useMemo(() => {
|
|
||||||
return searchResults.map((stock: StockSearchResult) => ({
|
|
||||||
value: stock.stock_code,
|
value: stock.stock_code,
|
||||||
label: (
|
label: (
|
||||||
<Flex justify="space-between" align="center" py={1}>
|
<Flex justify="space-between" align="center" py={1}>
|
||||||
@@ -191,64 +59,31 @@ const SearchActions = memo<{
|
|||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
),
|
),
|
||||||
}));
|
}))
|
||||||
}, [searchResults]);
|
), [searchResults]);
|
||||||
|
|
||||||
// 处理搜索按钮点击
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
if (inputCode && inputCode !== stockCode) {
|
if (inputCode) {
|
||||||
onStockChange(inputCode);
|
onStockChange(inputCode);
|
||||||
}
|
}
|
||||||
}, [inputCode, stockCode, onStockChange]);
|
}, [inputCode, onStockChange]);
|
||||||
|
|
||||||
// 选中股票
|
|
||||||
const handleSelect = useCallback((value: string) => {
|
const handleSelect = useCallback((value: string) => {
|
||||||
clearSearch();
|
clearSearch();
|
||||||
setInputCode(value);
|
setInputCode(value);
|
||||||
if (value !== stockCode) {
|
|
||||||
onStockChange(value);
|
onStockChange(value);
|
||||||
}
|
}, [clearSearch, onStockChange]);
|
||||||
}, [clearSearch, stockCode, onStockChange]);
|
|
||||||
|
|
||||||
// 键盘事件
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') handleSearch();
|
||||||
handleSearch();
|
|
||||||
}
|
|
||||||
}, [handleSearch]);
|
}, [handleSearch]);
|
||||||
|
|
||||||
|
const searchIcon = useMemo(() => (
|
||||||
|
<SearchOutlined style={SEARCH_ICON_STYLE} onClick={handleSearch} />
|
||||||
|
), [handleSearch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack spacing={3}>
|
<Box sx={SEARCH_BOX_SX}>
|
||||||
{/* 搜索框 - FUI 风格 */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
...getSearchBoxStyles(THEME),
|
|
||||||
'.ant-select-selector': {
|
|
||||||
backgroundColor: `${FUI_COLORS.bg.primary} !important`,
|
|
||||||
borderColor: `${FUI_COLORS.line.default} !important`,
|
|
||||||
borderRadius: '10px !important',
|
|
||||||
height: '42px !important',
|
|
||||||
backdropFilter: FUI_GLASS.blur.sm,
|
|
||||||
transition: `all ${FUI_ANIMATION.duration.fast} ${FUI_ANIMATION.easing.default}`,
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: `${FUI_COLORS.line.emphasis} !important`,
|
|
||||||
boxShadow: FUI_GLOW.gold.sm,
|
|
||||||
},
|
|
||||||
'&:focus-within': {
|
|
||||||
borderColor: `${FUI_COLORS.gold[400]} !important`,
|
|
||||||
boxShadow: FUI_GLOW.gold.md,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'.ant-select-selection-search-input': {
|
|
||||||
color: `${FUI_COLORS.text.primary} !important`,
|
|
||||||
height: '40px !important',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
},
|
|
||||||
'.ant-select-selection-placeholder': {
|
|
||||||
color: `${FUI_COLORS.text.muted} !important`,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
popupClassName="fui-autocomplete-dropdown"
|
popupClassName="fui-autocomplete-dropdown"
|
||||||
value={inputCode}
|
value={inputCode}
|
||||||
@@ -256,121 +91,36 @@ const SearchActions = memo<{
|
|||||||
onSearch={doSearch}
|
onSearch={doSearch}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onChange={setInputCode}
|
onChange={setInputCode}
|
||||||
placeholder="输入代码、名称或拼音"
|
style={AUTOCOMPLETE_STYLE}
|
||||||
style={{ width: 240 }}
|
dropdownStyle={DROPDOWN_STYLE}
|
||||||
dropdownStyle={{
|
|
||||||
backgroundColor: FUI_COLORS.bg.elevated,
|
|
||||||
borderRadius: '10px',
|
|
||||||
border: `1px solid ${FUI_COLORS.line.emphasis}`,
|
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
|
|
||||||
}}
|
|
||||||
notFoundContent={isSearching ? <Spin size="small" /> : null}
|
notFoundContent={isSearching ? <Spin size="small" /> : null}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="输入股票代码或名称"
|
||||||
|
prefix={searchIcon}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
style={INPUT_STYLE}
|
||||||
/>
|
/>
|
||||||
|
</AutoComplete>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 搜索按钮 - 发光效果 */}
|
|
||||||
<Button
|
|
||||||
bg={`linear-gradient(135deg, ${FUI_COLORS.gold[500]} 0%, ${FUI_COLORS.gold[400]} 100%)`}
|
|
||||||
color={FUI_COLORS.bg.deep}
|
|
||||||
_hover={{
|
|
||||||
bg: `linear-gradient(135deg, ${FUI_COLORS.gold[400]} 0%, ${FUI_COLORS.gold[300]} 100%)`,
|
|
||||||
boxShadow: FUI_GLOW.gold.md,
|
|
||||||
transform: 'translateY(-1px)',
|
|
||||||
}}
|
|
||||||
_active={{
|
|
||||||
bg: FUI_COLORS.gold[600],
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
}}
|
|
||||||
size="md"
|
|
||||||
h="42px"
|
|
||||||
px={5}
|
|
||||||
onClick={handleSearch}
|
|
||||||
leftIcon={<Icon as={Search} boxSize={4} />}
|
|
||||||
fontWeight="bold"
|
|
||||||
borderRadius="10px"
|
|
||||||
transition={`all ${FUI_ANIMATION.duration.fast} ${FUI_ANIMATION.easing.default}`}
|
|
||||||
>
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 自选按钮 - FUI 风格 */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
bg={isInWatchlist ? 'rgba(212, 175, 55, 0.2)' : 'transparent'}
|
|
||||||
color={FUI_COLORS.gold[400]}
|
|
||||||
borderColor={isInWatchlist ? FUI_COLORS.gold[400] : FUI_COLORS.line.emphasis}
|
|
||||||
borderWidth="1px"
|
|
||||||
_hover={{
|
|
||||||
bg: 'rgba(212, 175, 55, 0.15)',
|
|
||||||
borderColor: FUI_COLORS.gold[400],
|
|
||||||
boxShadow: FUI_GLOW.gold.sm,
|
|
||||||
transform: 'translateY(-1px)',
|
|
||||||
}}
|
|
||||||
_active={{
|
|
||||||
bg: 'rgba(212, 175, 55, 0.25)',
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
}}
|
|
||||||
size="md"
|
|
||||||
h="42px"
|
|
||||||
px={5}
|
|
||||||
onClick={onWatchlistToggle}
|
|
||||||
isLoading={watchlistLoading}
|
|
||||||
leftIcon={
|
|
||||||
<Icon
|
|
||||||
as={Star}
|
|
||||||
boxSize={4}
|
|
||||||
fill={isInWatchlist ? 'currentColor' : 'none'}
|
|
||||||
filter={isInWatchlist ? 'drop-shadow(0 0 4px rgba(212, 175, 55, 0.6))' : 'none'}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
fontWeight="bold"
|
|
||||||
borderRadius="10px"
|
|
||||||
transition={`all ${FUI_ANIMATION.duration.fast} ${FUI_ANIMATION.easing.default}`}
|
|
||||||
sx={isInWatchlist ? { animation: 'glowPulse 3s ease-in-out infinite' } : undefined}
|
|
||||||
>
|
|
||||||
{isInWatchlist ? '已自选' : '自选'}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
SearchActions.displayName = 'SearchActions';
|
SearchBox.displayName = 'SearchBox';
|
||||||
|
|
||||||
/**
|
// ============================================
|
||||||
* Company 页面顶部组件
|
// CompanyHeader 主组件
|
||||||
*/
|
// ============================================
|
||||||
const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
|
|
||||||
stockCode,
|
const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({ onStockChange }) => (
|
||||||
stockInfo,
|
|
||||||
stockInfoLoading,
|
|
||||||
isInWatchlist,
|
|
||||||
watchlistLoading,
|
|
||||||
onStockChange,
|
|
||||||
onWatchlistToggle,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Box
|
<Box
|
||||||
position="relative"
|
position="relative"
|
||||||
bg={`linear-gradient(180deg, ${FUI_COLORS.bg.elevated} 0%, ${FUI_COLORS.bg.primary} 100%)`}
|
bg={FUI_COLORS.bg.primary}
|
||||||
borderBottom="1px solid"
|
borderBottom="1px solid"
|
||||||
borderColor={FUI_COLORS.line.default}
|
borderColor={FUI_COLORS.line.default}
|
||||||
px={6}
|
px={6}
|
||||||
py={5}
|
py={4}
|
||||||
backdropFilter={FUI_GLASS.blur.md}
|
|
||||||
overflow="hidden"
|
|
||||||
>
|
>
|
||||||
{/* 顶部发光线(环境光效果由全局 AmbientGlow 提供) */}
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
left="10%"
|
|
||||||
right="10%"
|
|
||||||
h="1px"
|
|
||||||
bg={`linear-gradient(90deg, transparent 0%, ${FUI_COLORS.gold[400]} 50%, transparent 100%)`}
|
|
||||||
opacity={0.4}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Flex
|
<Flex
|
||||||
position="relative"
|
position="relative"
|
||||||
zIndex={1}
|
zIndex={1}
|
||||||
@@ -378,30 +128,25 @@ const CompanyHeader: React.FC<CompanyHeaderProps> = memo(({
|
|||||||
mx="auto"
|
mx="auto"
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
wrap="wrap"
|
|
||||||
gap={4}
|
|
||||||
>
|
>
|
||||||
{/* 左侧:股票信息 */}
|
<VStack align="start" spacing={1}>
|
||||||
<StockInfoDisplay
|
<Text
|
||||||
stockCode={stockCode}
|
fontSize="2xl"
|
||||||
stockName={stockInfo?.stock_name}
|
fontWeight="bold"
|
||||||
price={stockInfo?.close_price}
|
color={FUI_COLORS.gold[400]}
|
||||||
change={stockInfo?.change_pct}
|
letterSpacing="wider"
|
||||||
loading={stockInfoLoading}
|
textShadow={FUI_GLOW.text.gold}
|
||||||
/>
|
>
|
||||||
|
个股详情
|
||||||
{/* 右侧:搜索和操作 */}
|
</Text>
|
||||||
<SearchActions
|
<Text fontSize="sm" color={FUI_COLORS.text.muted} letterSpacing="wide">
|
||||||
stockCode={stockCode}
|
查看股票实时行情、财务数据和盈利预测
|
||||||
onStockChange={onStockChange}
|
</Text>
|
||||||
isInWatchlist={isInWatchlist}
|
</VStack>
|
||||||
watchlistLoading={watchlistLoading}
|
<SearchBox onStockChange={onStockChange} />
|
||||||
onWatchlistToggle={onWatchlistToggle}
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
));
|
||||||
});
|
|
||||||
|
|
||||||
CompanyHeader.displayName = 'CompanyHeader';
|
CompanyHeader.displayName = 'CompanyHeader';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Company/components/MarketDataView/components/panels/BigDealPanel.tsx
|
// src/views/Company/components/MarketDataView/components/panels/BigDealPanel.tsx
|
||||||
// 大宗交易面板 - 黑金主题
|
// 大宗交易面板 - 黑金主题
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
@@ -12,73 +12,23 @@ import {
|
|||||||
Th,
|
Th,
|
||||||
Td,
|
Td,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
Center,
|
|
||||||
VStack,
|
VStack,
|
||||||
HStack,
|
HStack,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Heading,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import { formatNumber } from '../../utils/formatUtils';
|
import { formatNumber } from '../../utils/formatUtils';
|
||||||
import { darkGoldTheme } from '../../constants';
|
import { darkGoldTheme } from '../../constants';
|
||||||
import type { Theme, BigDealData } from '../../types';
|
import { DarkGoldCard, DarkGoldBadge, EmptyState } from '../shared';
|
||||||
|
import type { BigDealData } from '../../types';
|
||||||
|
|
||||||
export interface BigDealPanelProps {
|
export interface BigDealPanelProps {
|
||||||
theme: Theme;
|
|
||||||
bigDealData: BigDealData;
|
bigDealData: BigDealData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 黑金卡片样式
|
|
||||||
const darkGoldCardStyle = {
|
|
||||||
bg: darkGoldTheme.bgCard,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: darkGoldTheme.border,
|
|
||||||
borderRadius: 'xl',
|
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
_hover: {
|
|
||||||
borderColor: darkGoldTheme.borderHover,
|
|
||||||
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 黑金徽章样式
|
|
||||||
const DarkGoldBadge: React.FC<{ children: React.ReactNode; variant?: 'gold' | 'orange' | 'green' | 'purple' }> = ({
|
|
||||||
children,
|
|
||||||
variant = 'gold',
|
|
||||||
}) => {
|
|
||||||
const colors = {
|
|
||||||
gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold },
|
|
||||||
orange: { bg: 'rgba(255, 149, 0, 0.15)', color: darkGoldTheme.orange },
|
|
||||||
green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green },
|
|
||||||
purple: { bg: 'rgba(160, 120, 220, 0.15)', color: '#A078DC' },
|
|
||||||
};
|
|
||||||
const style = colors[variant];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
px={2}
|
|
||||||
py={1}
|
|
||||||
bg={style.bg}
|
|
||||||
color={style.color}
|
|
||||||
borderRadius="md"
|
|
||||||
fontSize="xs"
|
|
||||||
fontWeight="medium"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BigDealPanel: React.FC<BigDealPanelProps> = ({ bigDealData }) => {
|
const BigDealPanel: React.FC<BigDealPanelProps> = ({ bigDealData }) => {
|
||||||
return (
|
return (
|
||||||
<Box {...darkGoldCardStyle} overflow="hidden">
|
<DarkGoldCard title="大宗交易记录">
|
||||||
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
|
||||||
<Heading size="md" color={darkGoldTheme.gold}>
|
|
||||||
大宗交易记录
|
|
||||||
</Heading>
|
|
||||||
</Box>
|
|
||||||
<Box p={4}>
|
|
||||||
{bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? (
|
{bigDealData?.daily_stats && bigDealData.daily_stats.length > 0 ? (
|
||||||
<VStack spacing={4} align="stretch">
|
<VStack spacing={4} align="stretch">
|
||||||
{bigDealData.daily_stats.map((dayStats, idx) => (
|
{bigDealData.daily_stats.map((dayStats, idx) => (
|
||||||
@@ -175,13 +125,10 @@ const BigDealPanel: React.FC<BigDealPanelProps> = ({ bigDealData }) => {
|
|||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
) : (
|
) : (
|
||||||
<Center h="200px">
|
<EmptyState message="暂无大宗交易数据" />
|
||||||
<Text color={darkGoldTheme.textMuted}>暂无大宗交易数据</Text>
|
|
||||||
</Center>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</DarkGoldCard>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BigDealPanel;
|
export default memo(BigDealPanel);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx
|
// src/views/Company/components/MarketDataView/components/panels/FundingPanel.tsx
|
||||||
// 融资融券面板 - 黑金主题
|
// 融资融券面板 - 黑金主题
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
@@ -10,41 +10,27 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Heading,
|
Heading,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ECharts from '@components/Charts/ECharts';
|
||||||
|
|
||||||
import { formatNumber } from '../../utils/formatUtils';
|
import { formatNumber } from '../../utils/formatUtils';
|
||||||
import { getFundingDarkGoldOption } from '../../utils/chartOptions';
|
import { getFundingDarkGoldOption } from '../../utils/chartOptions';
|
||||||
import { darkGoldTheme } from '../../constants';
|
import { darkGoldTheme } from '../../constants';
|
||||||
import type { Theme, FundingDayData } from '../../types';
|
import { DarkGoldCard } from '../shared';
|
||||||
|
import { darkGoldCardFullStyle } from '../shared/styles';
|
||||||
|
import type { FundingDayData } from '../../types';
|
||||||
|
|
||||||
export interface FundingPanelProps {
|
export interface FundingPanelProps {
|
||||||
theme: Theme;
|
|
||||||
fundingData: FundingDayData[];
|
fundingData: FundingDayData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 黑金卡片样式
|
|
||||||
const darkGoldCardStyle = {
|
|
||||||
bg: darkGoldTheme.bgCard,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: darkGoldTheme.border,
|
|
||||||
borderRadius: 'xl',
|
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
_hover: {
|
|
||||||
borderColor: darkGoldTheme.borderHover,
|
|
||||||
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
|
||||||
transform: 'translateY(-2px)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
|
const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
|
||||||
return (
|
return (
|
||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
{/* 图表卡片 */}
|
{/* 图表卡片 */}
|
||||||
<Box {...darkGoldCardStyle} p={6}>
|
<Box sx={darkGoldCardFullStyle} p={6}>
|
||||||
{fundingData.length > 0 && (
|
{fundingData.length > 0 && (
|
||||||
<Box h="400px">
|
<Box h="400px">
|
||||||
<ReactECharts
|
<ECharts
|
||||||
option={getFundingDarkGoldOption(fundingData)}
|
option={getFundingDarkGoldOption(fundingData)}
|
||||||
style={{ height: '100%', width: '100%' }}
|
style={{ height: '100%', width: '100%' }}
|
||||||
theme="dark"
|
theme="dark"
|
||||||
@@ -55,13 +41,7 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
|
|||||||
|
|
||||||
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
|
||||||
{/* 融资数据 */}
|
{/* 融资数据 */}
|
||||||
<Box {...darkGoldCardStyle} overflow="hidden">
|
<DarkGoldCard title="融资数据" titleColor={darkGoldTheme.gold}>
|
||||||
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
|
||||||
<Heading size="md" color={darkGoldTheme.gold}>
|
|
||||||
融资数据
|
|
||||||
</Heading>
|
|
||||||
</Box>
|
|
||||||
<Box p={4}>
|
|
||||||
<VStack spacing={3} align="stretch">
|
<VStack spacing={3} align="stretch">
|
||||||
{fundingData
|
{fundingData
|
||||||
.slice(-5)
|
.slice(-5)
|
||||||
@@ -97,17 +77,10 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</DarkGoldCard>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 融券数据 */}
|
{/* 融券数据 */}
|
||||||
<Box {...darkGoldCardStyle} overflow="hidden">
|
<DarkGoldCard title="融券数据" titleColor={darkGoldTheme.orange}>
|
||||||
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
|
||||||
<Heading size="md" color={darkGoldTheme.orange}>
|
|
||||||
融券数据
|
|
||||||
</Heading>
|
|
||||||
</Box>
|
|
||||||
<Box p={4}>
|
|
||||||
<VStack spacing={3} align="stretch">
|
<VStack spacing={3} align="stretch">
|
||||||
{fundingData
|
{fundingData
|
||||||
.slice(-5)
|
.slice(-5)
|
||||||
@@ -143,11 +116,10 @@ const FundingPanel: React.FC<FundingPanelProps> = ({ fundingData }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</DarkGoldCard>
|
||||||
</Box>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FundingPanel;
|
export default memo(FundingPanel);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx
|
// src/views/Company/components/MarketDataView/components/panels/PledgePanel.tsx
|
||||||
// 股权质押面板 - 黑金主题
|
// 股权质押面板 - 黑金主题
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
@@ -13,42 +13,28 @@ import {
|
|||||||
Td,
|
Td,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
VStack,
|
VStack,
|
||||||
Heading,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ECharts from '@components/Charts/ECharts';
|
||||||
|
|
||||||
import { formatNumber, formatPercent } from '../../utils/formatUtils';
|
import { formatNumber, formatPercent } from '../../utils/formatUtils';
|
||||||
import { getPledgeDarkGoldOption } from '../../utils/chartOptions';
|
import { getPledgeDarkGoldOption } from '../../utils/chartOptions';
|
||||||
import { darkGoldTheme } from '../../constants';
|
import { darkGoldTheme } from '../../constants';
|
||||||
import type { Theme, PledgeData } from '../../types';
|
import { DarkGoldCard } from '../shared';
|
||||||
|
import { darkGoldCardFullStyle } from '../shared/styles';
|
||||||
|
import type { PledgeData } from '../../types';
|
||||||
|
|
||||||
export interface PledgePanelProps {
|
export interface PledgePanelProps {
|
||||||
theme: Theme;
|
|
||||||
pledgeData: PledgeData[];
|
pledgeData: PledgeData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 黑金卡片样式
|
|
||||||
const darkGoldCardStyle = {
|
|
||||||
bg: darkGoldTheme.bgCard,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: darkGoldTheme.border,
|
|
||||||
borderRadius: 'xl',
|
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
_hover: {
|
|
||||||
borderColor: darkGoldTheme.borderHover,
|
|
||||||
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
|
const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
|
||||||
return (
|
return (
|
||||||
<VStack spacing={6} align="stretch">
|
<VStack spacing={6} align="stretch">
|
||||||
{/* 图表卡片 */}
|
{/* 图表卡片 */}
|
||||||
<Box {...darkGoldCardStyle} p={6}>
|
<Box sx={darkGoldCardFullStyle} p={6}>
|
||||||
{pledgeData.length > 0 && (
|
{pledgeData.length > 0 && (
|
||||||
<Box h="400px">
|
<Box h="400px">
|
||||||
<ReactECharts
|
<ECharts
|
||||||
option={getPledgeDarkGoldOption(pledgeData)}
|
option={getPledgeDarkGoldOption(pledgeData)}
|
||||||
style={{ height: '100%', width: '100%' }}
|
style={{ height: '100%', width: '100%' }}
|
||||||
theme="dark"
|
theme="dark"
|
||||||
@@ -58,13 +44,7 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 质押明细表格 */}
|
{/* 质押明细表格 */}
|
||||||
<Box {...darkGoldCardStyle} overflow="hidden">
|
<DarkGoldCard title="质押明细">
|
||||||
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
|
||||||
<Heading size="md" color={darkGoldTheme.gold}>
|
|
||||||
质押明细
|
|
||||||
</Heading>
|
|
||||||
</Box>
|
|
||||||
<Box p={4}>
|
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<Table variant="unstyled" size="sm">
|
<Table variant="unstyled" size="sm">
|
||||||
<Thead>
|
<Thead>
|
||||||
@@ -132,10 +112,9 @@ const PledgePanel: React.FC<PledgePanelProps> = ({ pledgeData }) => {
|
|||||||
</Tbody>
|
</Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
</Box>
|
</DarkGoldCard>
|
||||||
</Box>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PledgePanel;
|
export default memo(PledgePanel);
|
||||||
|
|||||||
@@ -1,76 +1,27 @@
|
|||||||
// src/views/Company/components/MarketDataView/components/panels/UnusualPanel.tsx
|
// src/views/Company/components/MarketDataView/components/panels/UnusualPanel.tsx
|
||||||
// 龙虎榜面板 - 黑金主题
|
// 龙虎榜面板 - 黑金主题
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
Center,
|
|
||||||
VStack,
|
VStack,
|
||||||
HStack,
|
HStack,
|
||||||
Grid,
|
Grid,
|
||||||
Heading,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import { formatNumber } from '../../utils/formatUtils';
|
import { formatNumber } from '../../utils/formatUtils';
|
||||||
import { darkGoldTheme } from '../../constants';
|
import { darkGoldTheme } from '../../constants';
|
||||||
import type { Theme, UnusualData } from '../../types';
|
import { DarkGoldCard, DarkGoldBadge, EmptyState } from '../shared';
|
||||||
|
import type { UnusualData } from '../../types';
|
||||||
|
|
||||||
export interface UnusualPanelProps {
|
export interface UnusualPanelProps {
|
||||||
theme: Theme;
|
|
||||||
unusualData: UnusualData;
|
unusualData: UnusualData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 黑金卡片样式
|
|
||||||
const darkGoldCardStyle = {
|
|
||||||
bg: darkGoldTheme.bgCard,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: darkGoldTheme.border,
|
|
||||||
borderRadius: 'xl',
|
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
_hover: {
|
|
||||||
borderColor: darkGoldTheme.borderHover,
|
|
||||||
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 黑金徽章样式
|
|
||||||
const DarkGoldBadge: React.FC<{ children: React.ReactNode; variant?: 'red' | 'green' | 'gold' }> = ({
|
|
||||||
children,
|
|
||||||
variant = 'gold',
|
|
||||||
}) => {
|
|
||||||
const colors = {
|
|
||||||
red: { bg: 'rgba(255, 68, 68, 0.15)', color: darkGoldTheme.red },
|
|
||||||
green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green },
|
|
||||||
gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold },
|
|
||||||
};
|
|
||||||
const style = colors[variant];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
px={2}
|
|
||||||
py={1}
|
|
||||||
bg={style.bg}
|
|
||||||
color={style.color}
|
|
||||||
borderRadius="md"
|
|
||||||
fontSize="xs"
|
|
||||||
fontWeight="medium"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
|
const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
|
||||||
return (
|
return (
|
||||||
<Box {...darkGoldCardStyle} overflow="hidden">
|
<DarkGoldCard title="龙虎榜数据">
|
||||||
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
|
||||||
<Heading size="md" color={darkGoldTheme.gold}>
|
|
||||||
龙虎榜数据
|
|
||||||
</Heading>
|
|
||||||
</Box>
|
|
||||||
<Box p={4}>
|
|
||||||
{unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? (
|
{unusualData?.grouped_data && unusualData.grouped_data.length > 0 ? (
|
||||||
<VStack spacing={4} align="stretch">
|
<VStack spacing={4} align="stretch">
|
||||||
{unusualData.grouped_data.map((dayData, idx) => (
|
{unusualData.grouped_data.map((dayData, idx) => (
|
||||||
@@ -208,13 +159,10 @@ const UnusualPanel: React.FC<UnusualPanelProps> = ({ unusualData }) => {
|
|||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
) : (
|
) : (
|
||||||
<Center h="200px">
|
<EmptyState message="暂无龙虎榜数据" />
|
||||||
<Text color={darkGoldTheme.textMuted}>暂无龙虎榜数据</Text>
|
|
||||||
</Center>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</DarkGoldCard>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UnusualPanel;
|
export default memo(UnusualPanel);
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// src/views/Company/components/MarketDataView/components/shared/DarkGoldBadge.tsx
|
||||||
|
// 黑金主题徽章组件
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Box, BoxProps } from '@chakra-ui/react';
|
||||||
|
import { darkGoldTheme } from '../../constants';
|
||||||
|
|
||||||
|
export type DarkGoldBadgeVariant = 'gold' | 'orange' | 'green' | 'red' | 'purple';
|
||||||
|
|
||||||
|
export interface DarkGoldBadgeProps extends Omit<BoxProps, 'children'> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: DarkGoldBadgeVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 徽章颜色配置
|
||||||
|
const BADGE_COLORS: Record<DarkGoldBadgeVariant, { bg: string; color: string }> = {
|
||||||
|
gold: { bg: 'rgba(212, 175, 55, 0.15)', color: darkGoldTheme.gold },
|
||||||
|
orange: { bg: 'rgba(255, 149, 0, 0.15)', color: darkGoldTheme.orange },
|
||||||
|
green: { bg: 'rgba(0, 200, 81, 0.15)', color: darkGoldTheme.green },
|
||||||
|
red: { bg: 'rgba(255, 68, 68, 0.15)', color: darkGoldTheme.red },
|
||||||
|
purple: { bg: 'rgba(160, 120, 220, 0.15)', color: '#A078DC' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 黑金主题徽章组件
|
||||||
|
* 用于显示标签、状态等信息
|
||||||
|
*/
|
||||||
|
const DarkGoldBadge: React.FC<DarkGoldBadgeProps> = ({
|
||||||
|
children,
|
||||||
|
variant = 'gold',
|
||||||
|
...boxProps
|
||||||
|
}) => {
|
||||||
|
const colors = BADGE_COLORS[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px={2}
|
||||||
|
py={1}
|
||||||
|
bg={colors.bg}
|
||||||
|
color={colors.color}
|
||||||
|
borderRadius="md"
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight="medium"
|
||||||
|
{...boxProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(DarkGoldBadge);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// src/views/Company/components/MarketDataView/components/shared/DarkGoldCard.tsx
|
||||||
|
// 黑金主题卡片组件
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Box, Heading, BoxProps } from '@chakra-ui/react';
|
||||||
|
import { darkGoldTheme } from '../../constants';
|
||||||
|
import { darkGoldCardFullStyle } from './styles';
|
||||||
|
|
||||||
|
export interface DarkGoldCardProps extends Omit<BoxProps, 'title'> {
|
||||||
|
/** 卡片标题 */
|
||||||
|
title?: string;
|
||||||
|
/** 标题颜色 */
|
||||||
|
titleColor?: string;
|
||||||
|
/** 是否显示标题区域 */
|
||||||
|
showHeader?: boolean;
|
||||||
|
/** 内容区 padding */
|
||||||
|
contentPadding?: number | string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 黑金主题卡片组件
|
||||||
|
* 统一的卡片样式,包含标题区和内容区
|
||||||
|
*/
|
||||||
|
const DarkGoldCard: React.FC<DarkGoldCardProps> = ({
|
||||||
|
title,
|
||||||
|
titleColor = darkGoldTheme.gold,
|
||||||
|
showHeader = true,
|
||||||
|
contentPadding = 4,
|
||||||
|
children,
|
||||||
|
...boxProps
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box sx={darkGoldCardFullStyle} overflow="hidden" {...boxProps}>
|
||||||
|
{showHeader && title && (
|
||||||
|
<Box p={4} borderBottom="1px solid" borderColor={darkGoldTheme.border}>
|
||||||
|
<Heading size="md" color={titleColor}>
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box p={contentPadding}>{children}</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(DarkGoldCard);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// src/views/Company/components/MarketDataView/components/shared/EmptyState.tsx
|
||||||
|
// 空状态组件
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Center, Text, CenterProps } from '@chakra-ui/react';
|
||||||
|
import { darkGoldTheme } from '../../constants';
|
||||||
|
|
||||||
|
export interface EmptyStateProps extends Omit<CenterProps, 'children'> {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空状态组件
|
||||||
|
* 数据为空时显示的占位组件
|
||||||
|
*/
|
||||||
|
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
|
message = '暂无数据',
|
||||||
|
h = '200px',
|
||||||
|
...centerProps
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Center h={h} {...centerProps}>
|
||||||
|
<Text color={darkGoldTheme.textMuted}>{message}</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(EmptyState);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// src/views/Company/components/MarketDataView/components/shared/index.ts
|
||||||
|
// 共享组件和样式导出
|
||||||
|
|
||||||
|
export { default as DarkGoldCard } from './DarkGoldCard';
|
||||||
|
export { default as DarkGoldBadge } from './DarkGoldBadge';
|
||||||
|
export { default as EmptyState } from './EmptyState';
|
||||||
|
export { darkGoldCardStyle, darkGoldCardHoverStyle } from './styles';
|
||||||
|
export type { DarkGoldBadgeVariant } from './DarkGoldBadge';
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// src/views/Company/components/MarketDataView/components/shared/styles.ts
|
||||||
|
// 共享样式常量
|
||||||
|
|
||||||
|
import { darkGoldTheme } from '../../constants';
|
||||||
|
import type { SystemStyleObject } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 黑金卡片基础样式
|
||||||
|
*/
|
||||||
|
export const darkGoldCardStyle: SystemStyleObject = {
|
||||||
|
bg: darkGoldTheme.bgCard,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: darkGoldTheme.border,
|
||||||
|
borderRadius: 'xl',
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 黑金卡片悬停样式
|
||||||
|
*/
|
||||||
|
export const darkGoldCardHoverStyle: SystemStyleObject = {
|
||||||
|
borderColor: darkGoldTheme.borderHover,
|
||||||
|
boxShadow: '0 8px 30px rgba(212, 175, 55, 0.15)',
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 黑金卡片完整样式(包含 hover)
|
||||||
|
*/
|
||||||
|
export const darkGoldCardFullStyle: SystemStyleObject = {
|
||||||
|
...darkGoldCardStyle,
|
||||||
|
_hover: darkGoldCardHoverStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据项行样式
|
||||||
|
*/
|
||||||
|
export const dataRowStyle: SystemStyleObject = {
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 'md',
|
||||||
|
border: '1px solid',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表格行悬停样式
|
||||||
|
*/
|
||||||
|
export const tableRowHoverStyle: SystemStyleObject = {
|
||||||
|
bg: 'rgba(212, 175, 55, 0.08)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表格边框样式
|
||||||
|
*/
|
||||||
|
export const tableBorderStyle: SystemStyleObject = {
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'rgba(212, 175, 55, 0.1)',
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// MarketDataView 数据获取 Hook
|
// MarketDataView 数据获取 Hook
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { marketService } from '../services/marketService';
|
import { marketService } from '../services/marketService';
|
||||||
import { DEFAULT_PERIOD } from '../constants';
|
import { DEFAULT_PERIOD } from '../constants';
|
||||||
@@ -17,6 +18,11 @@ import type {
|
|||||||
UseMarketDataReturn,
|
UseMarketDataReturn,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
// 判断是否为取消请求的错误
|
||||||
|
const isCancelError = (error: unknown): boolean => {
|
||||||
|
return axios.isCancel(error) || (error instanceof Error && error.name === 'CanceledError');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 市场数据获取 Hook
|
* 市场数据获取 Hook
|
||||||
* @param stockCode 股票代码
|
* @param stockCode 股票代码
|
||||||
@@ -51,6 +57,11 @@ export const useMarketData = (
|
|||||||
// 记录上一次的 period,用于判断是否需要刷新交易数据
|
// 记录上一次的 period,用于判断是否需要刷新交易数据
|
||||||
const prevPeriodRef = useRef(period);
|
const prevPeriodRef = useRef(period);
|
||||||
|
|
||||||
|
// AbortController refs - 用于取消请求
|
||||||
|
const coreDataControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const tabDataControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const minuteDataControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载涨幅分析数据(懒加载)
|
* 加载涨幅分析数据(懒加载)
|
||||||
* 需要 tradeData 来建立日期索引映射
|
* 需要 tradeData 来建立日期索引映射
|
||||||
@@ -100,14 +111,20 @@ export const useMarketData = (
|
|||||||
const loadCoreData = useCallback(async () => {
|
const loadCoreData = useCallback(async () => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
|
// 取消之前的核心数据请求
|
||||||
|
coreDataControllerRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
coreDataControllerRef.current = controller;
|
||||||
|
const options = { signal: controller.signal };
|
||||||
|
|
||||||
logger.debug('useMarketData', '开始加载核心市场数据', { stockCode, period });
|
logger.debug('useMarketData', '开始加载核心市场数据', { stockCode, period });
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setAnalysisMap({}); // 清空旧的分析数据
|
setAnalysisMap({}); // 清空旧的分析数据
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [summaryRes, tradeRes] = await Promise.all([
|
const [summaryRes, tradeRes] = await Promise.all([
|
||||||
marketService.getMarketSummary(stockCode),
|
marketService.getMarketSummary(stockCode, options),
|
||||||
marketService.getTradeData(stockCode, period),
|
marketService.getTradeData(stockCode, period, options),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 设置概览数据
|
// 设置概览数据
|
||||||
@@ -131,10 +148,15 @@ export const useMarketData = (
|
|||||||
loadRiseAnalysis(loadedTradeData);
|
loadRiseAnalysis(loadedTradeData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 取消请求不作为错误处理
|
||||||
|
if (isCancelError(error)) return;
|
||||||
logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
|
logger.error('useMarketData', 'loadCoreData', error, { stockCode, period });
|
||||||
} finally {
|
} finally {
|
||||||
|
// 只有当前请求没有被取消时才设置 loading 状态
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [stockCode, period, loadRiseAnalysis]);
|
}, [stockCode, period, loadRiseAnalysis]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,12 +166,18 @@ export const useMarketData = (
|
|||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
if (loadedDataRef.current[dataType]) return; // 已加载则跳过
|
if (loadedDataRef.current[dataType]) return; // 已加载则跳过
|
||||||
|
|
||||||
|
// 取消之前的 Tab 数据请求
|
||||||
|
tabDataControllerRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
tabDataControllerRef.current = controller;
|
||||||
|
const options = { signal: controller.signal };
|
||||||
|
|
||||||
logger.debug('useMarketData', `按需加载 ${dataType} 数据`, { stockCode });
|
logger.debug('useMarketData', `按需加载 ${dataType} 数据`, { stockCode });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (dataType) {
|
switch (dataType) {
|
||||||
case 'funding': {
|
case 'funding': {
|
||||||
const res = await marketService.getFundingData(stockCode, 30);
|
const res = await marketService.getFundingData(stockCode, 30, options);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setFundingData(res.data);
|
setFundingData(res.data);
|
||||||
loadedDataRef.current.funding = true;
|
loadedDataRef.current.funding = true;
|
||||||
@@ -157,7 +185,7 @@ export const useMarketData = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'bigDeal': {
|
case 'bigDeal': {
|
||||||
const res = await marketService.getBigDealData(stockCode, 30);
|
const res = await marketService.getBigDealData(stockCode, 30, options);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setBigDealData(res);
|
setBigDealData(res);
|
||||||
loadedDataRef.current.bigDeal = true;
|
loadedDataRef.current.bigDeal = true;
|
||||||
@@ -165,7 +193,7 @@ export const useMarketData = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'unusual': {
|
case 'unusual': {
|
||||||
const res = await marketService.getUnusualData(stockCode, 30);
|
const res = await marketService.getUnusualData(stockCode, 30, options);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setUnusualData(res);
|
setUnusualData(res);
|
||||||
loadedDataRef.current.unusual = true;
|
loadedDataRef.current.unusual = true;
|
||||||
@@ -173,7 +201,7 @@ export const useMarketData = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'pledge': {
|
case 'pledge': {
|
||||||
const res = await marketService.getPledgeData(stockCode);
|
const res = await marketService.getPledgeData(stockCode, options);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setPledgeData(res.data);
|
setPledgeData(res.data);
|
||||||
loadedDataRef.current.pledge = true;
|
loadedDataRef.current.pledge = true;
|
||||||
@@ -183,6 +211,8 @@ export const useMarketData = (
|
|||||||
}
|
}
|
||||||
logger.info('useMarketData', `${dataType} 数据加载成功`, { stockCode });
|
logger.info('useMarketData', `${dataType} 数据加载成功`, { stockCode });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 取消请求不作为错误处理
|
||||||
|
if (isCancelError(error)) return;
|
||||||
logger.error('useMarketData', `loadDataByType:${dataType}`, error, { stockCode });
|
logger.error('useMarketData', `loadDataByType:${dataType}`, error, { stockCode });
|
||||||
}
|
}
|
||||||
}, [stockCode]);
|
}, [stockCode]);
|
||||||
@@ -200,11 +230,17 @@ export const useMarketData = (
|
|||||||
const loadMinuteData = useCallback(async () => {
|
const loadMinuteData = useCallback(async () => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
|
// 取消之前的分钟数据请求
|
||||||
|
minuteDataControllerRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
minuteDataControllerRef.current = controller;
|
||||||
|
const options = { signal: controller.signal };
|
||||||
|
|
||||||
logger.debug('useMarketData', '开始加载分钟频数据', { stockCode });
|
logger.debug('useMarketData', '开始加载分钟频数据', { stockCode });
|
||||||
setMinuteLoading(true);
|
setMinuteLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await marketService.getMinuteData(stockCode);
|
const data = await marketService.getMinuteData(stockCode, options);
|
||||||
setMinuteData(data);
|
setMinuteData(data);
|
||||||
|
|
||||||
if (data.data && data.data.length > 0) {
|
if (data.data && data.data.length > 0) {
|
||||||
@@ -216,6 +252,8 @@ export const useMarketData = (
|
|||||||
logger.warn('useMarketData', '分钟频数据为空', { stockCode });
|
logger.warn('useMarketData', '分钟频数据为空', { stockCode });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 取消请求不作为错误处理
|
||||||
|
if (isCancelError(error)) return;
|
||||||
logger.error('useMarketData', 'loadMinuteData', error, { stockCode });
|
logger.error('useMarketData', 'loadMinuteData', error, { stockCode });
|
||||||
setMinuteData({
|
setMinuteData({
|
||||||
data: [],
|
data: [],
|
||||||
@@ -225,8 +263,11 @@ export const useMarketData = (
|
|||||||
type: 'minute',
|
type: 'minute',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
// 只有当前请求没有被取消时才设置 loading 状态
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
setMinuteLoading(false);
|
setMinuteLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [stockCode]);
|
}, [stockCode]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,6 +344,15 @@ export const useMarketData = (
|
|||||||
}
|
}
|
||||||
}, [period, refreshTradeData, stockCode]);
|
}, [period, refreshTradeData, stockCode]);
|
||||||
|
|
||||||
|
// 组件卸载时取消所有进行中的请求
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
coreDataControllerRef.current?.abort();
|
||||||
|
tabDataControllerRef.current?.abort();
|
||||||
|
minuteDataControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
tradeLoading,
|
tradeLoading,
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
// src/views/Company/components/MarketDataView/index.tsx
|
// src/views/Company/components/MarketDataView/index.tsx
|
||||||
// MarketDataView 主组件 - 股票市场数据综合展示
|
// MarketDataView 主组件 - 股票市场数据综合展示
|
||||||
|
|
||||||
import React, { useState, useEffect, ReactNode, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, ReactNode, useMemo, useCallback, memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
VStack,
|
VStack,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import { Unlock, ArrowUp, Star, Lock } from 'lucide-react';
|
||||||
Unlock,
|
|
||||||
ArrowUp,
|
|
||||||
Star,
|
|
||||||
Lock,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
// 通用组件
|
// 通用组件
|
||||||
import SubTabContainer from '@components/SubTabContainer';
|
import SubTabContainer from '@components/SubTabContainer';
|
||||||
@@ -36,7 +31,7 @@ import {
|
|||||||
PledgePanel,
|
PledgePanel,
|
||||||
} from './components/panels';
|
} from './components/panels';
|
||||||
import LoadingState from '../LoadingState';
|
import LoadingState from '../LoadingState';
|
||||||
import type { MarketDataViewProps, RiseAnalysis } from './types';
|
import type { MarketDataViewProps } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MarketDataView 主组件
|
* MarketDataView 主组件
|
||||||
@@ -118,37 +113,16 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
|||||||
{ key: 'pledge', name: '股权质押', icon: Lock, component: PledgePanel },
|
{ key: 'pledge', name: '股权质押', icon: Lock, component: PledgePanel },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 传递给 Tab 组件的 props
|
// 传递给 Tab 组件的 props - 只传递各 Tab 需要的数据
|
||||||
const componentProps = useMemo(
|
const componentProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
theme,
|
// 各 Tab 只使用自己需要的数据
|
||||||
tradeData,
|
|
||||||
minuteData,
|
|
||||||
minuteLoading,
|
|
||||||
analysisMap,
|
|
||||||
onLoadMinuteData: loadMinuteData,
|
|
||||||
onChartClick: handleChartClick,
|
|
||||||
selectedPeriod,
|
|
||||||
onPeriodChange: setSelectedPeriod,
|
|
||||||
fundingData,
|
fundingData,
|
||||||
bigDealData,
|
bigDealData,
|
||||||
unusualData,
|
unusualData,
|
||||||
pledgeData,
|
pledgeData,
|
||||||
}),
|
}),
|
||||||
[
|
[fundingData, bigDealData, unusualData, pledgeData]
|
||||||
theme,
|
|
||||||
tradeData,
|
|
||||||
minuteData,
|
|
||||||
minuteLoading,
|
|
||||||
analysisMap,
|
|
||||||
loadMinuteData,
|
|
||||||
handleChartClick,
|
|
||||||
selectedPeriod,
|
|
||||||
fundingData,
|
|
||||||
bigDealData,
|
|
||||||
unusualData,
|
|
||||||
pledgeData,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -203,4 +177,4 @@ const MarketDataView: React.FC<MarketDataViewProps> = ({ stockCode: propStockCod
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MarketDataView;
|
export default memo(MarketDataView);
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ interface ApiResponse<T> {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求选项
|
||||||
|
*/
|
||||||
|
interface RequestOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 市场数据服务
|
* 市场数据服务
|
||||||
*/
|
*/
|
||||||
@@ -30,9 +37,13 @@ export const marketService = {
|
|||||||
/**
|
/**
|
||||||
* 获取市场概览数据
|
* 获取市场概览数据
|
||||||
* @param stockCode 股票代码
|
* @param stockCode 股票代码
|
||||||
|
* @param options 请求选项
|
||||||
*/
|
*/
|
||||||
async getMarketSummary(stockCode: string): Promise<ApiResponse<MarketSummary>> {
|
async getMarketSummary(stockCode: string, options?: RequestOptions): Promise<ApiResponse<MarketSummary>> {
|
||||||
const { data } = await axios.get<ApiResponse<MarketSummary>>(`/api/market/summary/${stockCode}`);
|
const { data } = await axios.get<ApiResponse<MarketSummary>>(
|
||||||
|
`/api/market/summary/${stockCode}`,
|
||||||
|
{ signal: options?.signal }
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -40,9 +51,13 @@ export const marketService = {
|
|||||||
* 获取交易日数据
|
* 获取交易日数据
|
||||||
* @param stockCode 股票代码
|
* @param stockCode 股票代码
|
||||||
* @param days 天数,默认 60 天
|
* @param days 天数,默认 60 天
|
||||||
|
* @param options 请求选项
|
||||||
*/
|
*/
|
||||||
async getTradeData(stockCode: string, days: number = 60): Promise<ApiResponse<TradeDayData[]>> {
|
async getTradeData(stockCode: string, days: number = 60, options?: RequestOptions): Promise<ApiResponse<TradeDayData[]>> {
|
||||||
const { data } = await axios.get<ApiResponse<TradeDayData[]>>(`/api/market/trade/${stockCode}?days=${days}`);
|
const { data } = await axios.get<ApiResponse<TradeDayData[]>>(
|
||||||
|
`/api/market/trade/${stockCode}?days=${days}`,
|
||||||
|
{ signal: options?.signal }
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -50,9 +65,13 @@ export const marketService = {
|
|||||||
* 获取融资融券数据
|
* 获取融资融券数据
|
||||||
* @param stockCode 股票代码
|
* @param stockCode 股票代码
|
||||||
* @param days 天数,默认 30 天
|
* @param days 天数,默认 30 天
|
||||||
|
* @param options 请求选项
|
||||||
*/
|
*/
|
||||||
async getFundingData(stockCode: string, days: number = 30): Promise<ApiResponse<FundingDayData[]>> {
|
async getFundingData(stockCode: string, days: number = 30, options?: RequestOptions): Promise<ApiResponse<FundingDayData[]>> {
|
||||||
const { data } = await axios.get<ApiResponse<FundingDayData[]>>(`/api/market/funding/${stockCode}?days=${days}`);
|
const { data } = await axios.get<ApiResponse<FundingDayData[]>>(
|
||||||
|
`/api/market/funding/${stockCode}?days=${days}`,
|
||||||
|
{ signal: options?.signal }
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -60,9 +79,13 @@ export const marketService = {
|
|||||||
* 获取大宗交易数据
|
* 获取大宗交易数据
|
||||||
* @param stockCode 股票代码
|
* @param stockCode 股票代码
|
||||||
* @param days 天数,默认 30 天
|
* @param days 天数,默认 30 天
|
||||||
|
* @param options 请求选项
|
||||||
*/
|
*/
|
||||||
async getBigDealData(stockCode: string, days: number = 30): Promise<BigDealData> {
|
async getBigDealData(stockCode: string, days: number = 30, options?: RequestOptions): Promise<BigDealData> {
|
||||||
const { data } = await axios.get<BigDealData>(`/api/market/bigdeal/${stockCode}?days=${days}`);
|
const { data } = await axios.get<BigDealData>(
|
||||||
|
`/api/market/bigdeal/${stockCode}?days=${days}`,
|
||||||
|
{ signal: options?.signal }
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -70,18 +93,26 @@ export const marketService = {
|
|||||||
* 获取龙虎榜数据
|
* 获取龙虎榜数据
|
||||||
* @param stockCode 股票代码
|
* @param stockCode 股票代码
|
||||||
* @param days 天数,默认 30 天
|
* @param days 天数,默认 30 天
|
||||||
|
* @param options 请求选项
|
||||||
*/
|
*/
|
||||||
async getUnusualData(stockCode: string, days: number = 30): Promise<UnusualData> {
|
async getUnusualData(stockCode: string, days: number = 30, options?: RequestOptions): Promise<UnusualData> {
|
||||||
const { data } = await axios.get<UnusualData>(`/api/market/unusual/${stockCode}?days=${days}`);
|
const { data } = await axios.get<UnusualData>(
|
||||||
|
`/api/market/unusual/${stockCode}?days=${days}`,
|
||||||
|
{ signal: options?.signal }
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取股权质押数据
|
* 获取股权质押数据
|
||||||
* @param stockCode 股票代码
|
* @param stockCode 股票代码
|
||||||
|
* @param options 请求选项
|
||||||
*/
|
*/
|
||||||
async getPledgeData(stockCode: string): Promise<ApiResponse<PledgeData[]>> {
|
async getPledgeData(stockCode: string, options?: RequestOptions): Promise<ApiResponse<PledgeData[]>> {
|
||||||
const { data } = await axios.get<ApiResponse<PledgeData[]>>(`/api/market/pledge/${stockCode}`);
|
const { data } = await axios.get<ApiResponse<PledgeData[]>>(
|
||||||
|
`/api/market/pledge/${stockCode}`,
|
||||||
|
{ signal: options?.signal }
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -90,27 +121,33 @@ export const marketService = {
|
|||||||
* @param stockCode 股票代码
|
* @param stockCode 股票代码
|
||||||
* @param startDate 开始日期(可选)
|
* @param startDate 开始日期(可选)
|
||||||
* @param endDate 结束日期(可选)
|
* @param endDate 结束日期(可选)
|
||||||
|
* @param options 请求选项
|
||||||
*/
|
*/
|
||||||
async getRiseAnalysis(
|
async getRiseAnalysis(
|
||||||
stockCode: string,
|
stockCode: string,
|
||||||
startDate?: string,
|
startDate?: string,
|
||||||
endDate?: string
|
endDate?: string,
|
||||||
|
options?: RequestOptions
|
||||||
): Promise<ApiResponse<RiseAnalysis[]>> {
|
): Promise<ApiResponse<RiseAnalysis[]>> {
|
||||||
let url = `/api/market/rise-analysis/${stockCode}`;
|
let url = `/api/market/rise-analysis/${stockCode}`;
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
url += `?start_date=${startDate}&end_date=${endDate}`;
|
url += `?start_date=${startDate}&end_date=${endDate}`;
|
||||||
}
|
}
|
||||||
const { data } = await axios.get<ApiResponse<RiseAnalysis[]>>(url);
|
const { data } = await axios.get<ApiResponse<RiseAnalysis[]>>(url, { signal: options?.signal });
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取分钟K线数据
|
* 获取分钟K线数据
|
||||||
* @param stockCode 股票代码
|
* @param stockCode 股票代码
|
||||||
|
* @param options 请求选项
|
||||||
*/
|
*/
|
||||||
async getMinuteData(stockCode: string): Promise<MinuteData> {
|
async getMinuteData(stockCode: string, options?: RequestOptions): Promise<MinuteData> {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<MinuteData>(`/api/stock/${stockCode}/latest-minute`);
|
const { data } = await axios.get<MinuteData>(
|
||||||
|
`/api/stock/${stockCode}/latest-minute`,
|
||||||
|
{ signal: options?.signal }
|
||||||
|
);
|
||||||
|
|
||||||
if (data.data && Array.isArray(data.data)) {
|
if (data.data && Array.isArray(data.data)) {
|
||||||
return data;
|
return data;
|
||||||
@@ -125,6 +162,10 @@ export const marketService = {
|
|||||||
type: 'minute',
|
type: 'minute',
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 取消请求不作为错误处理
|
||||||
|
if (axios.isCancel(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
logger.error('marketService', 'getMinuteData', error, { stockCode });
|
logger.error('marketService', 'getMinuteData', error, { stockCode });
|
||||||
// 返回空数据结构
|
// 返回空数据结构
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -927,7 +927,8 @@ export const getKLineDarkGoldOption = (
|
|||||||
|
|
||||||
// 布局配置(优化比例)
|
// 布局配置(优化比例)
|
||||||
// 主图: 55%, 成交量: 12%, 副图指标: 18%(如有)
|
// 主图: 55%, 成交量: 12%, 副图指标: 18%(如有)
|
||||||
const grids: EChartsOption['grid'] = [
|
// 注意:使用 object[] 而非 EChartsOption['grid'],因为后者可能是单个对象或数组
|
||||||
|
const grids: object[] = [
|
||||||
{
|
{
|
||||||
left: '3%',
|
left: '3%',
|
||||||
right: '3%',
|
right: '3%',
|
||||||
@@ -956,7 +957,7 @@ export const getKLineDarkGoldOption = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// X轴配置(使用 boundaryGap: true 确保柱状图对齐)
|
// X轴配置(使用 boundaryGap: true 确保柱状图对齐)
|
||||||
const xAxes: EChartsOption['xAxis'] = [
|
const xAxes: object[] = [
|
||||||
{
|
{
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: dates,
|
data: dates,
|
||||||
@@ -978,7 +979,7 @@ export const getKLineDarkGoldOption = (
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Y轴配置
|
// Y轴配置
|
||||||
const yAxes: EChartsOption['yAxis'] = [
|
const yAxes: object[] = [
|
||||||
{
|
{
|
||||||
scale: true,
|
scale: true,
|
||||||
splitLine: {
|
splitLine: {
|
||||||
@@ -1144,7 +1145,7 @@ export const getKLineDarkGoldOption = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 构建系列数据
|
// 构建系列数据
|
||||||
const series: EChartsOption['series'] = [
|
const series: object[] = [
|
||||||
{
|
{
|
||||||
name: 'K线',
|
name: 'K线',
|
||||||
type: 'candlestick',
|
type: 'candlestick',
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* CompanyInfo - 公司信息原子组件
|
|
||||||
* 显示公司基本信息(成立日期、注册资本、所在地、官网、简介)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
|
||||||
import { Box, Flex, HStack, Text, Link, Icon, Divider } from '@chakra-ui/react';
|
|
||||||
import { Calendar, Coins, MapPin, Globe } from 'lucide-react';
|
|
||||||
import { formatRegisteredCapital, formatDate } from '../../CompanyOverview/utils';
|
|
||||||
import { STOCK_CARD_THEME } from './theme';
|
|
||||||
|
|
||||||
export interface CompanyBasicInfo {
|
|
||||||
establish_date?: string;
|
|
||||||
reg_capital?: number;
|
|
||||||
province?: string;
|
|
||||||
city?: string;
|
|
||||||
website?: string;
|
|
||||||
company_intro?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompanyInfoProps {
|
|
||||||
basicInfo: CompanyBasicInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CompanyInfo: React.FC<CompanyInfoProps> = memo(({ basicInfo }) => {
|
|
||||||
const { labelColor, valueColor, borderColor } = STOCK_CARD_THEME;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Divider borderColor={borderColor} my={4} />
|
|
||||||
<Flex gap={8}>
|
|
||||||
{/* 左侧:公司关键属性 (flex=1) */}
|
|
||||||
<Box flex="1" minWidth="0">
|
|
||||||
<HStack spacing={4} flexWrap="wrap" fontSize="14px">
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Icon as={Calendar} color={labelColor} boxSize={4} />
|
|
||||||
<Text color={labelColor}>成立:</Text>
|
|
||||||
<Text color={valueColor} fontWeight="bold">
|
|
||||||
{formatDate(basicInfo.establish_date)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Icon as={Coins} color={labelColor} boxSize={4} />
|
|
||||||
<Text color={labelColor}>注册资本:</Text>
|
|
||||||
<Text color={valueColor} fontWeight="bold">
|
|
||||||
{formatRegisteredCapital(basicInfo.reg_capital)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Icon as={MapPin} color={labelColor} boxSize={4} />
|
|
||||||
<Text color={labelColor}>所在地:</Text>
|
|
||||||
<Text color={valueColor} fontWeight="bold">
|
|
||||||
{basicInfo.province} {basicInfo.city}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Icon as={Globe} color={labelColor} boxSize={4} />
|
|
||||||
{basicInfo.website ? (
|
|
||||||
<Link
|
|
||||||
href={basicInfo.website}
|
|
||||||
isExternal
|
|
||||||
color={valueColor}
|
|
||||||
fontWeight="bold"
|
|
||||||
_hover={{ color: labelColor }}
|
|
||||||
>
|
|
||||||
访问官网
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Text color={valueColor}>暂无官网</Text>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 右侧:公司简介 (flex=2) */}
|
|
||||||
<Box flex="2" minWidth="0" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
|
||||||
<Text fontSize="14px" color={labelColor} noOfLines={2}>
|
|
||||||
<Text as="span" fontWeight="bold" color={valueColor}>公司简介:</Text>
|
|
||||||
{basicInfo.company_intro || '暂无'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
CompanyInfo.displayName = 'CompanyInfo';
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* GlassSection - 玻璃态内嵌区块组件
|
||||||
|
*
|
||||||
|
* 用于包装数据区块(如估值指标、市值股本、主力动态)
|
||||||
|
* 提供统一的 FUI 风格容器样式:
|
||||||
|
* - 半透明背景
|
||||||
|
* - 金色边框高亮
|
||||||
|
* - 顶部光条装饰
|
||||||
|
* - 悬停效果
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Box, Text } from '@chakra-ui/react';
|
||||||
|
import { DEEP_SPACE_THEME as T } from './theme';
|
||||||
|
|
||||||
|
export interface GlassSectionProps {
|
||||||
|
/** 区块标题 */
|
||||||
|
title: string;
|
||||||
|
/** 区块内容 */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** flex 布局属性,默认 1 */
|
||||||
|
flex?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玻璃态内嵌区块
|
||||||
|
*
|
||||||
|
* 提供统一的数据区块容器样式
|
||||||
|
* 用于包装 KeyMetrics、MainForceInfo 等内容组件
|
||||||
|
*/
|
||||||
|
export const GlassSection: React.FC<GlassSectionProps> = memo(({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
flex = 1,
|
||||||
|
}) => (
|
||||||
|
<Box
|
||||||
|
flex={flex}
|
||||||
|
bg={T.bgInset}
|
||||||
|
borderRadius={T.radiusLG}
|
||||||
|
border={`1px solid ${T.borderGlass}`}
|
||||||
|
p={4}
|
||||||
|
position="relative"
|
||||||
|
transition={T.transitionFast}
|
||||||
|
_hover={{
|
||||||
|
borderColor: T.borderGoldHover,
|
||||||
|
bg: 'rgba(15, 18, 35, 0.6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 区块顶部金色光条装饰 */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left="20px"
|
||||||
|
right="20px"
|
||||||
|
height="1px"
|
||||||
|
background={`linear-gradient(90deg, transparent, ${T.gold}40, transparent)`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 区块标题 */}
|
||||||
|
<Text
|
||||||
|
fontSize="14px"
|
||||||
|
fontWeight="700"
|
||||||
|
color={T.gold}
|
||||||
|
mb={3}
|
||||||
|
textTransform="uppercase"
|
||||||
|
letterSpacing="0.1em"
|
||||||
|
textShadow={`0 0 12px ${T.gold}60`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 区块内容 */}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
GlassSection.displayName = 'GlassSection';
|
||||||
|
|
||||||
|
export default GlassSection;
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* KeyMetrics - 关键指标原子组件
|
|
||||||
* 显示 PE、EPS、PB、流通市值、52周波动
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
|
||||||
import { Box, VStack, HStack, Text } from '@chakra-ui/react';
|
|
||||||
import { formatPrice } from './formatters';
|
|
||||||
import { STOCK_CARD_THEME } from './theme';
|
|
||||||
|
|
||||||
export interface KeyMetricsProps {
|
|
||||||
pe: number;
|
|
||||||
marketCap: string;
|
|
||||||
totalShares?: number; // 发行总股本(亿股)
|
|
||||||
floatShares?: number; // 流通股本(亿股)
|
|
||||||
turnoverRate?: number; // 换手率(%)
|
|
||||||
week52Low: number;
|
|
||||||
week52High: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const KeyMetrics: React.FC<KeyMetricsProps> = memo(({
|
|
||||||
pe,
|
|
||||||
marketCap,
|
|
||||||
totalShares,
|
|
||||||
floatShares,
|
|
||||||
turnoverRate,
|
|
||||||
week52Low,
|
|
||||||
week52High,
|
|
||||||
}) => {
|
|
||||||
const { labelColor, valueColor, sectionTitleColor } = STOCK_CARD_THEME;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flex="1">
|
|
||||||
<Text
|
|
||||||
fontSize="14px"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={sectionTitleColor}
|
|
||||||
mb={3}
|
|
||||||
>
|
|
||||||
关键指标
|
|
||||||
</Text>
|
|
||||||
<VStack align="stretch" spacing={2} fontSize="14px">
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<Text color={labelColor}>市盈率(PE):</Text>
|
|
||||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
|
||||||
{pe ? pe.toFixed(2) : '-'}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<Text color={labelColor}>流通市值:</Text>
|
|
||||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
|
||||||
{marketCap || '-'}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<Text color={labelColor}>发行总股本:</Text>
|
|
||||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
|
||||||
{totalShares ? `${totalShares}亿股` : '-'}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<Text color={labelColor}>流通股本:</Text>
|
|
||||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
|
||||||
{floatShares ? `${floatShares}亿股` : '-'}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<Text color={labelColor}>换手率:</Text>
|
|
||||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
|
||||||
{turnoverRate !== undefined ? `${turnoverRate.toFixed(2)}%` : '-'}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack justify="space-between">
|
|
||||||
<Text color={labelColor}>52周波动:</Text>
|
|
||||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
|
||||||
{formatPrice(week52Low)}-{formatPrice(week52High)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
KeyMetrics.displayName = 'KeyMetrics';
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* LoadingSkeleton - 股票行情卡片加载骨架屏
|
||||||
|
*
|
||||||
|
* 在数据加载期间展示的骨架屏组件
|
||||||
|
* 保持与实际内容相同的布局结构,提供良好的加载体验
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Box, Flex, HStack, VStack, Skeleton } from '@chakra-ui/react';
|
||||||
|
import { CardGlow } from '@components/FUI';
|
||||||
|
import { glassCardStyle, DEEP_SPACE_THEME as T } from './theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票行情卡片加载骨架屏
|
||||||
|
*
|
||||||
|
* 布局结构对应实际卡片:
|
||||||
|
* - 头部:股票名称 + 操作按钮
|
||||||
|
* - 价格:当前价格 + 涨跌幅
|
||||||
|
* - 内容:多列数据区块
|
||||||
|
*/
|
||||||
|
export const LoadingSkeleton: React.FC = memo(() => (
|
||||||
|
<Box
|
||||||
|
{...glassCardStyle.containerGold}
|
||||||
|
p={8}
|
||||||
|
>
|
||||||
|
{/* 装饰性光效 */}
|
||||||
|
<CardGlow variant="gold" />
|
||||||
|
|
||||||
|
<VStack align="stretch" spacing={6} position="relative" zIndex={1}>
|
||||||
|
{/* 头部骨架:股票名称 + 代码 + 操作按钮 */}
|
||||||
|
<Flex justify="space-between">
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<Skeleton
|
||||||
|
height="32px"
|
||||||
|
width="120px"
|
||||||
|
startColor={T.bgInset}
|
||||||
|
endColor={T.borderGlass}
|
||||||
|
borderRadius={T.radiusSM}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="24px"
|
||||||
|
width="80px"
|
||||||
|
startColor={T.bgInset}
|
||||||
|
endColor={T.borderGlass}
|
||||||
|
borderRadius={T.radiusSM}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Skeleton
|
||||||
|
height="32px"
|
||||||
|
width="32px"
|
||||||
|
startColor={T.bgInset}
|
||||||
|
endColor={T.borderGlass}
|
||||||
|
borderRadius={T.radiusSM}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="32px"
|
||||||
|
width="32px"
|
||||||
|
startColor={T.bgInset}
|
||||||
|
endColor={T.borderGlass}
|
||||||
|
borderRadius={T.radiusSM}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 价格骨架:当前价格 + 涨跌幅 Badge */}
|
||||||
|
<HStack>
|
||||||
|
<Skeleton
|
||||||
|
height="56px"
|
||||||
|
width="160px"
|
||||||
|
startColor={T.bgInset}
|
||||||
|
endColor={T.borderGlass}
|
||||||
|
borderRadius={T.radiusMD}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
height="36px"
|
||||||
|
width="100px"
|
||||||
|
startColor={T.bgInset}
|
||||||
|
endColor={T.borderGlass}
|
||||||
|
borderRadius={T.radiusMD}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 内容骨架:三列数据区块 */}
|
||||||
|
<Flex gap={6}>
|
||||||
|
<Box flex={1}>
|
||||||
|
<Skeleton
|
||||||
|
height="120px"
|
||||||
|
startColor={T.bgInset}
|
||||||
|
endColor={T.borderGlass}
|
||||||
|
borderRadius={T.radiusLG}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box flex={1}>
|
||||||
|
<Skeleton
|
||||||
|
height="120px"
|
||||||
|
startColor={T.bgInset}
|
||||||
|
endColor={T.borderGlass}
|
||||||
|
borderRadius={T.radiusLG}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box flex={1}>
|
||||||
|
<Skeleton
|
||||||
|
height="120px"
|
||||||
|
startColor={T.bgInset}
|
||||||
|
endColor={T.borderGlass}
|
||||||
|
borderRadius={T.radiusLG}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
));
|
||||||
|
|
||||||
|
LoadingSkeleton.displayName = 'LoadingSkeleton';
|
||||||
|
|
||||||
|
export default LoadingSkeleton;
|
||||||
@@ -1,70 +1,113 @@
|
|||||||
/**
|
/**
|
||||||
* MainForceInfo - 主力动态原子组件
|
* MainForceInfo - 主力动态原子组件
|
||||||
* 显示主力净流入、机构持仓、买卖比例
|
*
|
||||||
|
* 显示主力资金和机构相关指标:
|
||||||
|
* - 主力净流入(带正负颜色)
|
||||||
|
* - 机构持仓比例
|
||||||
|
* - 买卖比例进度条
|
||||||
|
*
|
||||||
|
* 注意:标题由外层 GlassSection 提供
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { Box, VStack, HStack, Text, Progress } from '@chakra-ui/react';
|
import { Box, VStack, HStack, Text, Progress } from '@chakra-ui/react';
|
||||||
import { formatNetInflow } from './formatters';
|
import { formatNetInflow } from './formatters';
|
||||||
import { STOCK_CARD_THEME } from './theme';
|
import { DEEP_SPACE_THEME as T } from './theme';
|
||||||
|
|
||||||
export interface MainForceInfoProps {
|
export interface MainForceInfoProps {
|
||||||
|
/** 主力净流入(亿) */
|
||||||
mainNetInflow: number;
|
mainNetInflow: number;
|
||||||
|
/** 机构持仓比例(%) */
|
||||||
institutionHolding: number;
|
institutionHolding: number;
|
||||||
|
/** 买入比例(%) */
|
||||||
buyRatio: number;
|
buyRatio: number;
|
||||||
|
/** 卖出比例(%) */
|
||||||
sellRatio: number;
|
sellRatio: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指标行组件 - 内部使用
|
||||||
|
*/
|
||||||
|
interface MetricRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
valueColor?: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetricRow: React.FC<MetricRowProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueColor = T.textWhite,
|
||||||
|
highlight = false,
|
||||||
|
}) => (
|
||||||
|
<HStack justify="space-between" fontSize="13px">
|
||||||
|
<Text color={T.textMuted}>{label}</Text>
|
||||||
|
<Text
|
||||||
|
color={valueColor}
|
||||||
|
fontWeight={highlight ? '700' : '600'}
|
||||||
|
fontSize={highlight ? '15px' : '13px'}
|
||||||
|
textShadow={highlight ? `0 0 10px ${valueColor}40` : undefined}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主力动态展示组件
|
||||||
|
*
|
||||||
|
* 纯展示组件,不包含标题
|
||||||
|
* 应由 GlassSection 包装以提供标题
|
||||||
|
*/
|
||||||
export const MainForceInfo: React.FC<MainForceInfoProps> = memo(({
|
export const MainForceInfo: React.FC<MainForceInfoProps> = memo(({
|
||||||
mainNetInflow,
|
mainNetInflow,
|
||||||
institutionHolding,
|
institutionHolding,
|
||||||
buyRatio,
|
buyRatio,
|
||||||
sellRatio,
|
sellRatio,
|
||||||
}) => {
|
}) => {
|
||||||
const { labelColor, valueColor, sectionTitleColor, borderColor, upColor, downColor } = STOCK_CARD_THEME;
|
const inflowColor = mainNetInflow >= 0 ? T.upColor : T.downColor;
|
||||||
const inflowColor = mainNetInflow >= 0 ? upColor : downColor;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
|
<VStack align="stretch" spacing={2}>
|
||||||
<Text
|
<MetricRow
|
||||||
fontSize="14px"
|
label="主力净流入"
|
||||||
fontWeight="bold"
|
value={formatNetInflow(mainNetInflow)}
|
||||||
color={sectionTitleColor}
|
valueColor={inflowColor}
|
||||||
mb={3}
|
highlight
|
||||||
>
|
/>
|
||||||
主力动态
|
<MetricRow
|
||||||
</Text>
|
label="机构持仓"
|
||||||
<VStack align="stretch" spacing={2} fontSize="14px">
|
value={`${institutionHolding.toFixed(2)}%`}
|
||||||
<HStack justify="space-between">
|
valueColor={T.purple}
|
||||||
<Text color={labelColor}>主力净流入:</Text>
|
highlight
|
||||||
<Text color={inflowColor} fontWeight="bold" fontSize="16px">
|
/>
|
||||||
{formatNetInflow(mainNetInflow)}
|
|
||||||
</Text>
|
{/* 买卖比例进度条 */}
|
||||||
</HStack>
|
<Box mt={2}>
|
||||||
<HStack justify="space-between">
|
|
||||||
<Text color={labelColor}>机构持仓:</Text>
|
|
||||||
<Text color={valueColor} fontWeight="bold" fontSize="16px">
|
|
||||||
{institutionHolding.toFixed(2)}%
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
{/* 买卖比例条 */}
|
|
||||||
<Box mt={1}>
|
|
||||||
<Progress
|
<Progress
|
||||||
value={buyRatio}
|
value={buyRatio}
|
||||||
size="sm"
|
size="sm"
|
||||||
sx={{
|
sx={{
|
||||||
'& > div': { bg: upColor },
|
'& > div': {
|
||||||
|
bg: T.upColor,
|
||||||
|
boxShadow: T.upGlow,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
bg={downColor}
|
bg={T.downColor}
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
|
h="8px"
|
||||||
/>
|
/>
|
||||||
<HStack justify="space-between" mt={1} fontSize="14px">
|
<HStack justify="space-between" mt={2} fontSize="13px">
|
||||||
<Text color={upColor}>买入{buyRatio}%</Text>
|
<Text color={T.upColor} fontWeight="600">
|
||||||
<Text color={downColor}>卖出{sellRatio}%</Text>
|
买入 {buyRatio}%
|
||||||
|
</Text>
|
||||||
|
<Text color={T.downColor} fontWeight="600">
|
||||||
|
卖出 {sellRatio}%
|
||||||
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* MetricRow - 指标行原子组件
|
||||||
|
*
|
||||||
|
* 用于数据区块内的单行指标展示
|
||||||
|
* 支持普通和高亮两种模式
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { memo } from 'react';
|
||||||
|
import { HStack, Text } from '@chakra-ui/react';
|
||||||
|
import { DEEP_SPACE_THEME as T } from './theme';
|
||||||
|
|
||||||
|
export interface MetricRowProps {
|
||||||
|
/** 指标标签 */
|
||||||
|
label: string;
|
||||||
|
/** 指标值 */
|
||||||
|
value: string | number;
|
||||||
|
/** 值的颜色(默认白色) */
|
||||||
|
valueColor?: string;
|
||||||
|
/** 是否高亮显示(加粗 + 发光) */
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指标行组件
|
||||||
|
*
|
||||||
|
* 布局:左侧标签 + 右侧值
|
||||||
|
* 高亮模式:更大字号 + 发光效果
|
||||||
|
*/
|
||||||
|
export const MetricRow: React.FC<MetricRowProps> = memo(({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueColor = T.textWhite,
|
||||||
|
highlight = false,
|
||||||
|
}) => (
|
||||||
|
<HStack justify="space-between" fontSize="13px">
|
||||||
|
<Text color={T.textMuted}>{label}</Text>
|
||||||
|
<Text
|
||||||
|
color={valueColor}
|
||||||
|
fontWeight={highlight ? '700' : '600'}
|
||||||
|
fontSize={highlight ? '15px' : '13px'}
|
||||||
|
textShadow={highlight ? `0 0 10px ${valueColor}40` : undefined}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
));
|
||||||
|
|
||||||
|
MetricRow.displayName = 'MetricRow';
|
||||||
@@ -1,38 +1,67 @@
|
|||||||
/**
|
/**
|
||||||
* PriceDisplay - 价格显示原子组件
|
* PriceDisplay - 价格显示原子组件
|
||||||
* 显示当前价格和涨跌幅 Badge
|
*
|
||||||
|
* 深空 FUI 设计风格:
|
||||||
|
* - 大号价格数字,带涨跌色发光
|
||||||
|
* - Badge 使用半透明背景 + 边框发光
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
import { HStack, Text, Badge } from '@chakra-ui/react';
|
import { HStack, Text, Badge } from '@chakra-ui/react';
|
||||||
import { formatPrice, formatChangePercent } from './formatters';
|
import { formatPrice, formatChangePercent } from './formatters';
|
||||||
import { STOCK_CARD_THEME } from './theme';
|
import { DEEP_SPACE_THEME as T } from './theme';
|
||||||
|
|
||||||
export interface PriceDisplayProps {
|
export interface PriceDisplayProps {
|
||||||
|
/** 当前价格 */
|
||||||
currentPrice: number;
|
currentPrice: number;
|
||||||
|
/** 涨跌幅(百分比) */
|
||||||
changePercent: number;
|
changePercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 价格显示组件
|
||||||
|
*
|
||||||
|
* 使用发光效果突出涨跌状态:
|
||||||
|
* - 涨:红色 (#FF4757) + 红色光晕
|
||||||
|
* - 跌:绿色 (#00D984) + 绿色光晕
|
||||||
|
*/
|
||||||
export const PriceDisplay: React.FC<PriceDisplayProps> = memo(({
|
export const PriceDisplay: React.FC<PriceDisplayProps> = memo(({
|
||||||
currentPrice,
|
currentPrice,
|
||||||
changePercent,
|
changePercent,
|
||||||
}) => {
|
}) => {
|
||||||
const { upColor, downColor } = STOCK_CARD_THEME;
|
// 根据涨跌计算颜色和发光效果
|
||||||
const priceColor = changePercent >= 0 ? upColor : downColor;
|
const { priceColor, priceGlow, priceBg } = useMemo(() => {
|
||||||
|
const isUp = changePercent >= 0;
|
||||||
|
return {
|
||||||
|
priceColor: isUp ? T.upColor : T.downColor,
|
||||||
|
priceGlow: isUp ? T.upGlow : T.downGlow,
|
||||||
|
priceBg: isUp ? T.upColorMuted : T.downColorMuted,
|
||||||
|
};
|
||||||
|
}, [changePercent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack align="baseline" spacing={3} mb={3}>
|
<HStack align="baseline" spacing={3}>
|
||||||
<Text fontSize="48px" fontWeight="bold" color={priceColor}>
|
{/* 主价格 - 大号字体 + 发光 */}
|
||||||
|
<Text
|
||||||
|
fontSize="42px"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={priceColor}
|
||||||
|
textShadow={priceGlow}
|
||||||
|
lineHeight="1"
|
||||||
|
>
|
||||||
{formatPrice(currentPrice)}
|
{formatPrice(currentPrice)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{/* 涨跌幅 Badge - 半透明背景 + 边框发光 */}
|
||||||
<Badge
|
<Badge
|
||||||
bg={changePercent >= 0 ? upColor : downColor}
|
bg={priceBg}
|
||||||
color="#FFFFFF"
|
color={priceColor}
|
||||||
fontSize="20px"
|
fontSize="18px"
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
px={3}
|
px={3}
|
||||||
py={1}
|
py={1.5}
|
||||||
borderRadius="md"
|
borderRadius={T.radiusMD}
|
||||||
|
boxShadow={priceGlow}
|
||||||
>
|
>
|
||||||
{formatChangePercent(changePercent)}
|
{formatChangePercent(changePercent)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -1,59 +1,74 @@
|
|||||||
/**
|
/**
|
||||||
* SecondaryQuote - 次要行情原子组件
|
* SecondaryQuote - 次要行情原子组件
|
||||||
* 显示今开、昨收、最高、最低
|
*
|
||||||
|
* 显示今开、昨收、最高、最低等次要行情数据
|
||||||
|
* 使用水平布局,通过竖线分隔符分隔各项
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { HStack, Text } from '@chakra-ui/react';
|
import { Box, HStack, Text } from '@chakra-ui/react';
|
||||||
import { formatPrice } from './formatters';
|
import { formatPrice } from './formatters';
|
||||||
import { STOCK_CARD_THEME } from './theme';
|
import { DEEP_SPACE_THEME as T } from './theme';
|
||||||
|
|
||||||
export interface SecondaryQuoteProps {
|
export interface SecondaryQuoteProps {
|
||||||
|
/** 今日开盘价 */
|
||||||
todayOpen: number;
|
todayOpen: number;
|
||||||
|
/** 昨日收盘价 */
|
||||||
yesterdayClose: number;
|
yesterdayClose: number;
|
||||||
|
/** 今日最高价 */
|
||||||
todayHigh: number;
|
todayHigh: number;
|
||||||
|
/** 今日最低价 */
|
||||||
todayLow: number;
|
todayLow: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 竖线分隔符组件
|
||||||
|
*/
|
||||||
|
const Divider: React.FC = () => (
|
||||||
|
<Box w="1px" h="14px" bg={T.divider} />
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 次要行情展示组件
|
||||||
|
*
|
||||||
|
* 水平排列展示今开、昨收、最高、最低
|
||||||
|
* 最高使用上涨颜色,最低使用下跌颜色
|
||||||
|
*/
|
||||||
export const SecondaryQuote: React.FC<SecondaryQuoteProps> = memo(({
|
export const SecondaryQuote: React.FC<SecondaryQuoteProps> = memo(({
|
||||||
todayOpen,
|
todayOpen,
|
||||||
yesterdayClose,
|
yesterdayClose,
|
||||||
todayHigh,
|
todayHigh,
|
||||||
todayLow,
|
todayLow,
|
||||||
}) => {
|
}) => (
|
||||||
const { labelColor, valueColor, borderColor, upColor, downColor } = STOCK_CARD_THEME;
|
<HStack spacing={6} fontSize="14px" flexWrap="wrap">
|
||||||
|
<Text color={T.textMuted}>
|
||||||
return (
|
|
||||||
<HStack spacing={4} fontSize="14px" flexWrap="wrap">
|
|
||||||
<Text color={labelColor}>
|
|
||||||
今开:
|
今开:
|
||||||
<Text as="span" color={valueColor} fontWeight="bold">
|
<Text as="span" color={T.textWhite} fontWeight="600" ml={1}>
|
||||||
{formatPrice(todayOpen)}
|
{formatPrice(todayOpen)}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={borderColor}>|</Text>
|
<Divider />
|
||||||
<Text color={labelColor}>
|
<Text color={T.textMuted}>
|
||||||
昨收:
|
昨收:
|
||||||
<Text as="span" color={valueColor} fontWeight="bold">
|
<Text as="span" color={T.textWhite} fontWeight="600" ml={1}>
|
||||||
{formatPrice(yesterdayClose)}
|
{formatPrice(yesterdayClose)}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={borderColor}>|</Text>
|
<Divider />
|
||||||
<Text color={labelColor}>
|
<Text color={T.textMuted}>
|
||||||
最高:
|
最高:
|
||||||
<Text as="span" color={upColor} fontWeight="bold">
|
<Text as="span" color={T.upColor} fontWeight="600" ml={1}>
|
||||||
{formatPrice(todayHigh)}
|
{formatPrice(todayHigh)}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={borderColor}>|</Text>
|
<Divider />
|
||||||
<Text color={labelColor}>
|
<Text color={T.textMuted}>
|
||||||
最低:
|
最低:
|
||||||
<Text as="span" color={downColor} fontWeight="bold">
|
<Text as="span" color={T.downColor} fontWeight="600" ml={1}>
|
||||||
{formatPrice(todayLow)}
|
{formatPrice(todayLow)}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
));
|
||||||
});
|
|
||||||
|
|
||||||
SecondaryQuote.displayName = 'SecondaryQuote';
|
SecondaryQuote.displayName = 'SecondaryQuote';
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* StockHeader - 股票头部原子组件
|
* StockHeader - 股票头部原子组件
|
||||||
* 显示股票名称、代码、行业标签、指数标签、操作按钮
|
*
|
||||||
|
* 深空 FUI 设计风格:
|
||||||
|
* - 股票名称带金色发光
|
||||||
|
* - 行业标签使用金色边框
|
||||||
|
* - 操作按钮悬停态有玻璃效果
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
@@ -8,14 +12,20 @@ import { Flex, HStack, Text, Badge, IconButton, Tooltip } from '@chakra-ui/react
|
|||||||
import { Share2 } from 'lucide-react';
|
import { Share2 } from 'lucide-react';
|
||||||
import FavoriteButton from '@components/FavoriteButton';
|
import FavoriteButton from '@components/FavoriteButton';
|
||||||
import CompareStockInput from './CompareStockInput';
|
import CompareStockInput from './CompareStockInput';
|
||||||
import { STOCK_CARD_THEME } from './theme';
|
import { DEEP_SPACE_THEME as T } from './theme';
|
||||||
|
|
||||||
export interface StockHeaderProps {
|
export interface StockHeaderProps {
|
||||||
|
/** 股票名称 */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** 股票代码 */
|
||||||
code: string;
|
code: string;
|
||||||
|
/** 一级行业 */
|
||||||
industryL1?: string;
|
industryL1?: string;
|
||||||
|
/** 二级行业 */
|
||||||
industry?: string;
|
industry?: string;
|
||||||
|
/** 指数标签(沪深300、中证500等) */
|
||||||
indexTags?: string[];
|
indexTags?: string[];
|
||||||
|
/** 更新时间 */
|
||||||
updateTime?: string;
|
updateTime?: string;
|
||||||
// 关注相关
|
// 关注相关
|
||||||
isInWatchlist?: boolean;
|
isInWatchlist?: boolean;
|
||||||
@@ -28,6 +38,13 @@ export interface StockHeaderProps {
|
|||||||
onCompare?: (stockCode: string) => void;
|
onCompare?: (stockCode: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票头部组件
|
||||||
|
*
|
||||||
|
* 包含:
|
||||||
|
* - 左侧:股票名称、代码、行业标签、指数标签
|
||||||
|
* - 右侧:对比输入、关注按钮、分享按钮、更新时间
|
||||||
|
*/
|
||||||
export const StockHeader: React.FC<StockHeaderProps> = memo(({
|
export const StockHeader: React.FC<StockHeaderProps> = memo(({
|
||||||
name,
|
name,
|
||||||
code,
|
code,
|
||||||
@@ -42,32 +59,35 @@ export const StockHeader: React.FC<StockHeaderProps> = memo(({
|
|||||||
isCompareLoading = false,
|
isCompareLoading = false,
|
||||||
onCompare,
|
onCompare,
|
||||||
}) => {
|
}) => {
|
||||||
const { labelColor, valueColor, borderColor } = STOCK_CARD_THEME;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex justify="space-between" align="center" mb={4}>
|
<Flex justify="space-between" align="center">
|
||||||
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
|
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
|
||||||
<HStack spacing={3} align="center">
|
<HStack spacing={4} align="center">
|
||||||
{/* 股票名称 - 突出显示 */}
|
{/* 股票名称 - 金色发光效果 */}
|
||||||
<Text fontSize="26px" fontWeight="800" color={valueColor}>
|
<Text
|
||||||
|
fontSize="24px"
|
||||||
|
fontWeight="800"
|
||||||
|
color={T.textPrimary}
|
||||||
|
textShadow={`0 0 20px ${T.gold}40`}
|
||||||
|
>
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="18px" fontWeight="normal" color={labelColor}>
|
<Text fontSize="16px" color={T.textMuted} fontWeight="normal">
|
||||||
({code})
|
({code})
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* 行业标签 */}
|
{/* 行业标签 - 金色边框 */}
|
||||||
{(industryL1 || industry) && (
|
{(industryL1 || industry) && (
|
||||||
<Badge
|
<Badge
|
||||||
bg="transparent"
|
bg="transparent"
|
||||||
color={labelColor}
|
color={T.textSecondary}
|
||||||
fontSize="14px"
|
fontSize="13px"
|
||||||
fontWeight="medium"
|
fontWeight="500"
|
||||||
border="1px solid"
|
border="1px solid"
|
||||||
borderColor={borderColor}
|
borderColor={T.borderGold}
|
||||||
px={2}
|
px={3}
|
||||||
py={0.5}
|
py={1}
|
||||||
borderRadius="md"
|
borderRadius={T.radiusMD}
|
||||||
>
|
>
|
||||||
{industryL1 && industry
|
{industryL1 && industry
|
||||||
? `${industryL1} · ${industry}`
|
? `${industryL1} · ${industry}`
|
||||||
@@ -77,7 +97,7 @@ export const StockHeader: React.FC<StockHeaderProps> = memo(({
|
|||||||
|
|
||||||
{/* 指数标签 */}
|
{/* 指数标签 */}
|
||||||
{indexTags && indexTags.length > 0 && (
|
{indexTags && indexTags.length > 0 && (
|
||||||
<Text fontSize="14px" color={labelColor}>
|
<Text fontSize="13px" color={T.textMuted}>
|
||||||
{indexTags.join('、')}
|
{indexTags.join('、')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -103,13 +123,14 @@ export const StockHeader: React.FC<StockHeaderProps> = memo(({
|
|||||||
aria-label="分享"
|
aria-label="分享"
|
||||||
icon={<Share2 size={18} />}
|
icon={<Share2 size={18} />}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color={labelColor}
|
color={T.textSecondary}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
borderRadius={T.radiusSM}
|
||||||
onClick={onShare}
|
onClick={onShare}
|
||||||
_hover={{ bg: 'whiteAlpha.100' }}
|
_hover={{ bg: T.borderGlass, color: T.textPrimary }}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Text fontSize="14px" color={labelColor}>
|
<Text fontSize="13px" color={T.textMuted}>
|
||||||
{updateTime?.split(' ')[1] || '--:--'}
|
{updateTime?.split(' ')[1] || '--:--'}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -1,27 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* StockQuoteCard 组件统一导出
|
* StockQuoteCard 组件统一导出
|
||||||
|
*
|
||||||
|
* 组件分类:
|
||||||
|
* - 原子组件:基础展示组件(价格、指标、信息)
|
||||||
|
* - 容器组件:布局和装饰组件(玻璃容器、光效)
|
||||||
|
* - 复合组件:功能组件(对比、搜索)
|
||||||
|
* - 状态组件:加载、错误状态
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 原子组件
|
// ============================================
|
||||||
|
// 原子组件 - 数据展示
|
||||||
|
// ============================================
|
||||||
export { PriceDisplay } from './PriceDisplay';
|
export { PriceDisplay } from './PriceDisplay';
|
||||||
export { SecondaryQuote } from './SecondaryQuote';
|
export { SecondaryQuote } from './SecondaryQuote';
|
||||||
export { KeyMetrics } from './KeyMetrics';
|
|
||||||
export { MainForceInfo } from './MainForceInfo';
|
export { MainForceInfo } from './MainForceInfo';
|
||||||
export { CompanyInfo } from './CompanyInfo';
|
|
||||||
export { StockHeader } from './StockHeader';
|
export { StockHeader } from './StockHeader';
|
||||||
|
export { MetricRow } from './MetricRow';
|
||||||
|
|
||||||
// 复合组件
|
// ============================================
|
||||||
|
// 容器组件 - 布局
|
||||||
|
// ============================================
|
||||||
|
// 注意: 装饰光效组件已移至 @components/FUI/CardGlow
|
||||||
|
export { GlassSection } from './GlassSection';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 状态组件 - 加载/错误
|
||||||
|
// ============================================
|
||||||
|
export { LoadingSkeleton } from './LoadingSkeleton';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 复合组件 - 功能性
|
||||||
|
// ============================================
|
||||||
export { default as CompareStockInput } from './CompareStockInput';
|
export { default as CompareStockInput } from './CompareStockInput';
|
||||||
export { default as StockCompareModal } from './StockCompareModal';
|
export { default as StockCompareModal } from './StockCompareModal';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
// 工具和主题
|
// 工具和主题
|
||||||
export { STOCK_CARD_THEME } from './theme';
|
// ============================================
|
||||||
|
export { STOCK_CARD_THEME, DEEP_SPACE_THEME, glassCardStyle } from './theme';
|
||||||
export * from './formatters';
|
export * from './formatters';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
// 类型导出
|
// 类型导出
|
||||||
|
// ============================================
|
||||||
export type { PriceDisplayProps } from './PriceDisplay';
|
export type { PriceDisplayProps } from './PriceDisplay';
|
||||||
export type { SecondaryQuoteProps } from './SecondaryQuote';
|
export type { SecondaryQuoteProps } from './SecondaryQuote';
|
||||||
export type { KeyMetricsProps } from './KeyMetrics';
|
|
||||||
export type { MainForceInfoProps } from './MainForceInfo';
|
export type { MainForceInfoProps } from './MainForceInfo';
|
||||||
export type { CompanyInfoProps, CompanyBasicInfo } from './CompanyInfo';
|
|
||||||
export type { StockHeaderProps } from './StockHeader';
|
export type { StockHeaderProps } from './StockHeader';
|
||||||
|
export type { MetricRowProps } from './MetricRow';
|
||||||
|
export type { GlassSectionProps } from './GlassSection';
|
||||||
|
|||||||
@@ -74,45 +74,46 @@ export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult =
|
|||||||
const [basicLoading, setBasicLoading] = useState(false);
|
const [basicLoading, setBasicLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 用于手动刷新的 ref
|
// 用于手动刷新的 ref(并行请求)
|
||||||
const refetchRef = useCallback(async () => {
|
const refetchRef = useCallback(async () => {
|
||||||
if (!stockCode) return;
|
if (!stockCode) return;
|
||||||
|
|
||||||
// 标准化股票代码(去除后缀)
|
// 标准化股票代码(去除后缀)
|
||||||
const baseCode = stockCode.split('.')[0];
|
const baseCode = stockCode.split('.')[0];
|
||||||
|
|
||||||
// 获取行情详情数据(使用新的 quote-detail 接口)
|
// 并行获取行情详情和基本信息
|
||||||
setQuoteLoading(true);
|
setQuoteLoading(true);
|
||||||
|
setBasicLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
logger.debug('useStockQuoteData', '刷新股票数据', { stockCode, baseCode });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.debug('useStockQuoteData', '获取股票行情详情', { stockCode, baseCode });
|
const [quoteResult, basicResult] = await Promise.all([
|
||||||
const { data: result } = await axios.get(`/api/stock/${baseCode}/quote-detail`);
|
axios.get(`/api/stock/${baseCode}/quote-detail`),
|
||||||
if (result.success && result.data) {
|
axios.get(`/api/stock/${baseCode}/basic-info`),
|
||||||
const transformedData = transformQuoteData(result.data, stockCode);
|
]);
|
||||||
|
|
||||||
|
// 处理行情数据
|
||||||
|
if (quoteResult.data.success && quoteResult.data.data) {
|
||||||
|
const transformedData = transformQuoteData(quoteResult.data.data, stockCode);
|
||||||
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
||||||
setQuoteData(transformedData);
|
setQuoteData(transformedData);
|
||||||
} else {
|
} else {
|
||||||
setError('获取行情数据失败');
|
setError('获取行情数据失败');
|
||||||
setQuoteData(null);
|
setQuoteData(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理基本信息
|
||||||
|
if (basicResult.data.success) {
|
||||||
|
setBasicInfo(basicResult.data.data);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('useStockQuoteData', '获取行情失败', err);
|
logger.error('useStockQuoteData', '刷新数据失败', err);
|
||||||
setError('获取行情数据失败');
|
setError('刷新数据失败');
|
||||||
setQuoteData(null);
|
setQuoteData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setQuoteLoading(false);
|
setQuoteLoading(false);
|
||||||
}
|
|
||||||
|
|
||||||
// 获取基本信息(公司简介等)
|
|
||||||
setBasicLoading(true);
|
|
||||||
try {
|
|
||||||
const { data: result } = await axios.get(`/api/stock/${baseCode}/basic-info`);
|
|
||||||
if (result.success) {
|
|
||||||
setBasicInfo(result.data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('useStockQuoteData', '获取基本信息失败', err);
|
|
||||||
} finally {
|
|
||||||
setBasicLoading(false);
|
setBasicLoading(false);
|
||||||
}
|
}
|
||||||
}, [stockCode]);
|
}, [stockCode]);
|
||||||
@@ -132,47 +133,45 @@ export const useStockQuoteData = (stockCode?: string): UseStockQuoteDataResult =
|
|||||||
const baseCode = stockCode.split('.')[0];
|
const baseCode = stockCode.split('.')[0];
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
// 获取行情详情数据(使用新的 quote-detail 接口)
|
// 并行获取行情详情和基本信息(优化:原串行改为并行,节省 ~120ms)
|
||||||
setQuoteLoading(true);
|
setQuoteLoading(true);
|
||||||
|
setBasicLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
logger.debug('useStockQuoteData', '并行获取股票数据', { stockCode, baseCode });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.debug('useStockQuoteData', '获取股票行情详情', { stockCode, baseCode });
|
const [quoteResult, basicResult] = await Promise.all([
|
||||||
const { data: result } = await axios.get(`/api/stock/${baseCode}/quote-detail`, {
|
axios.get(`/api/stock/${baseCode}/quote-detail`, { signal: controller.signal }),
|
||||||
signal: controller.signal,
|
axios.get(`/api/stock/${baseCode}/basic-info`, { signal: controller.signal }),
|
||||||
});
|
]);
|
||||||
|
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
if (result.success && result.data) {
|
|
||||||
const transformedData = transformQuoteData(result.data, stockCode);
|
// 处理行情数据
|
||||||
|
if (quoteResult.data.success && quoteResult.data.data) {
|
||||||
|
const transformedData = transformQuoteData(quoteResult.data.data, stockCode);
|
||||||
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
logger.debug('useStockQuoteData', '行情数据转换完成', { stockCode, hasData: !!transformedData });
|
||||||
setQuoteData(transformedData);
|
setQuoteData(transformedData);
|
||||||
} else {
|
} else {
|
||||||
setError('获取行情数据失败');
|
setError('获取行情数据失败');
|
||||||
setQuoteData(null);
|
setQuoteData(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理基本信息
|
||||||
|
if (basicResult.data.success) {
|
||||||
|
setBasicInfo(basicResult.data.data);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (isCancelled || err.name === 'CanceledError') return;
|
if (isCancelled || err.name === 'CanceledError') return;
|
||||||
logger.error('useStockQuoteData', '获取行情失败', err);
|
logger.error('useStockQuoteData', '获取数据失败', err);
|
||||||
setError('获取行情数据失败');
|
setError('获取数据失败');
|
||||||
setQuoteData(null);
|
setQuoteData(null);
|
||||||
} finally {
|
} finally {
|
||||||
if (!isCancelled) setQuoteLoading(false);
|
if (!isCancelled) {
|
||||||
|
setQuoteLoading(false);
|
||||||
|
setBasicLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取基本信息(公司简介等)
|
|
||||||
setBasicLoading(true);
|
|
||||||
try {
|
|
||||||
const { data: result } = await axios.get(`/api/stock/${baseCode}/basic-info`, {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (isCancelled) return;
|
|
||||||
if (result.success) {
|
|
||||||
setBasicInfo(result.data);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (isCancelled || err.name === 'CanceledError') return;
|
|
||||||
logger.error('useStockQuoteData', '获取基本信息失败', err);
|
|
||||||
} finally {
|
|
||||||
if (!isCancelled) setBasicLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,197 +6,44 @@
|
|||||||
* - 光影深度,弥散背景光
|
* - 光影深度,弥散背景光
|
||||||
* - 极致圆角,科幻数据终端感
|
* - 极致圆角,科幻数据终端感
|
||||||
*
|
*
|
||||||
* 保留原有所有功能:
|
* 组件结构:
|
||||||
* - 股票头部(名称、代码、行业、对比、关注、分享)
|
* - StockHeader:股票名称、代码、行业标签、操作按钮
|
||||||
* - 价格显示(当前价、涨跌幅)
|
* - PriceDisplay:当前价格、涨跌幅 Badge
|
||||||
* - 次要行情(今开、昨收、最高、最低)
|
* - SecondaryQuote:今开、昨收、最高、最低
|
||||||
* - 关键指标(PE、市值、股本、换手率、52周)
|
* - GlassSection + MetricRow:估值指标、市值股本
|
||||||
* - 主力动态(净流入、机构持仓、买卖比)
|
* - MainForceInfo:主力动态
|
||||||
* - 公司信息(成立、注册资本、所在地、官网、简介)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
|
import { Box, Flex, VStack, useDisclosure } from '@chakra-ui/react';
|
||||||
|
import { CardGlow } from '@components/FUI';
|
||||||
|
|
||||||
|
// 子组件导入
|
||||||
import {
|
import {
|
||||||
Box,
|
StockCompareModal,
|
||||||
Flex,
|
LoadingSkeleton,
|
||||||
HStack,
|
GlassSection,
|
||||||
VStack,
|
StockHeader,
|
||||||
Text,
|
PriceDisplay,
|
||||||
Badge,
|
SecondaryQuote,
|
||||||
IconButton,
|
MetricRow,
|
||||||
Tooltip,
|
MainForceInfo,
|
||||||
Skeleton,
|
DEEP_SPACE_THEME as T,
|
||||||
Progress,
|
formatPrice,
|
||||||
Link,
|
} from './components';
|
||||||
Icon,
|
import { glassCardStyle } from './components/theme';
|
||||||
useDisclosure,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { Share2, Calendar, Coins, MapPin, Globe } from 'lucide-react';
|
|
||||||
import FavoriteButton from '@components/FavoriteButton';
|
|
||||||
|
|
||||||
import { StockCompareModal, CompareStockInput } from './components';
|
// Hooks
|
||||||
import { useStockQuoteData, useStockCompare } from './hooks';
|
import { useStockQuoteData, useStockCompare } from './hooks';
|
||||||
import { DEEP_SPACE_THEME, glassCardStyle, decorativeElements } from './components/theme';
|
|
||||||
import { formatPrice, formatChangePercent, formatNetInflow } from './components/formatters';
|
|
||||||
import { formatRegisteredCapital, formatDate } from '../CompanyOverview/utils';
|
|
||||||
import type { StockQuoteCardProps } from './types';
|
import type { StockQuoteCardProps } from './types';
|
||||||
|
|
||||||
const T = DEEP_SPACE_THEME;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 装饰性光效组件
|
|
||||||
*/
|
|
||||||
const GlowDecorations: React.FC = () => (
|
|
||||||
<>
|
|
||||||
{/* 顶部金色光条 */}
|
|
||||||
<Box {...decorativeElements.topGlowBar} />
|
|
||||||
|
|
||||||
{/* 左上角光晕 */}
|
|
||||||
<Box
|
|
||||||
{...decorativeElements.cornerGlow}
|
|
||||||
top="-40px"
|
|
||||||
left="-40px"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 右下角光晕 */}
|
|
||||||
<Box
|
|
||||||
{...decorativeElements.cornerGlow}
|
|
||||||
bottom="-40px"
|
|
||||||
right="-40px"
|
|
||||||
background={`radial-gradient(circle, rgba(0, 212, 255, 0.1) 0%, transparent 70%)`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 背景网格 */}
|
|
||||||
<Box {...decorativeElements.gridOverlay} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载骨架屏
|
|
||||||
*/
|
|
||||||
const LoadingSkeleton: React.FC = () => (
|
|
||||||
<Box
|
|
||||||
{...glassCardStyle.containerGold}
|
|
||||||
p={8}
|
|
||||||
>
|
|
||||||
<GlowDecorations />
|
|
||||||
|
|
||||||
<VStack align="stretch" spacing={6} position="relative" zIndex={1}>
|
|
||||||
{/* 头部骨架 */}
|
|
||||||
<Flex justify="space-between">
|
|
||||||
<HStack spacing={3}>
|
|
||||||
<Skeleton height="32px" width="120px" startColor={T.bgInset} endColor={T.borderGlass} borderRadius={T.radiusSM} />
|
|
||||||
<Skeleton height="24px" width="80px" startColor={T.bgInset} endColor={T.borderGlass} borderRadius={T.radiusSM} />
|
|
||||||
</HStack>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Skeleton height="32px" width="32px" startColor={T.bgInset} endColor={T.borderGlass} borderRadius={T.radiusSM} />
|
|
||||||
<Skeleton height="32px" width="32px" startColor={T.bgInset} endColor={T.borderGlass} borderRadius={T.radiusSM} />
|
|
||||||
</HStack>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* 价格骨架 */}
|
|
||||||
<HStack>
|
|
||||||
<Skeleton height="56px" width="160px" startColor={T.bgInset} endColor={T.borderGlass} borderRadius={T.radiusMD} />
|
|
||||||
<Skeleton height="36px" width="100px" startColor={T.bgInset} endColor={T.borderGlass} borderRadius={T.radiusMD} />
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 内容骨架 */}
|
|
||||||
<Flex gap={6}>
|
|
||||||
<Box flex={1}>
|
|
||||||
<Skeleton height="120px" startColor={T.bgInset} endColor={T.borderGlass} borderRadius={T.radiusLG} />
|
|
||||||
</Box>
|
|
||||||
<Box flex={1}>
|
|
||||||
<Skeleton height="120px" startColor={T.bgInset} endColor={T.borderGlass} borderRadius={T.radiusLG} />
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 玻璃态内嵌区块
|
|
||||||
*/
|
|
||||||
interface GlassSectionProps {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
flex?: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GlassSection: React.FC<GlassSectionProps> = ({ title, children, flex = 1 }) => (
|
|
||||||
<Box
|
|
||||||
flex={flex}
|
|
||||||
bg={T.bgInset}
|
|
||||||
borderRadius={T.radiusLG}
|
|
||||||
border={`1px solid ${T.borderGlass}`}
|
|
||||||
p={4}
|
|
||||||
position="relative"
|
|
||||||
transition={T.transitionFast}
|
|
||||||
_hover={{
|
|
||||||
borderColor: T.borderGoldHover,
|
|
||||||
bg: 'rgba(15, 18, 35, 0.6)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 区块顶部光条 */}
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
left="20px"
|
|
||||||
right="20px"
|
|
||||||
height="1px"
|
|
||||||
background={`linear-gradient(90deg, transparent, ${T.gold}40, transparent)`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
fontSize="14px"
|
|
||||||
fontWeight="700"
|
|
||||||
color={T.gold}
|
|
||||||
mb={3}
|
|
||||||
textTransform="uppercase"
|
|
||||||
letterSpacing="0.1em"
|
|
||||||
textShadow={`0 0 12px ${T.gold}60`}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 指标行组件
|
|
||||||
*/
|
|
||||||
interface MetricRowProps {
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
valueColor?: string;
|
|
||||||
highlight?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MetricRow: React.FC<MetricRowProps> = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
valueColor = T.textWhite,
|
|
||||||
highlight = false,
|
|
||||||
}) => (
|
|
||||||
<HStack justify="space-between" fontSize="13px">
|
|
||||||
<Text color={T.textMuted}>{label}</Text>
|
|
||||||
<Text
|
|
||||||
color={valueColor}
|
|
||||||
fontWeight={highlight ? '700' : '600'}
|
|
||||||
fontSize={highlight ? '15px' : '13px'}
|
|
||||||
textShadow={highlight ? `0 0 10px ${valueColor}40` : undefined}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
|
|
||||||
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
||||||
stockCode,
|
stockCode,
|
||||||
isInWatchlist = false,
|
isInWatchlist = false,
|
||||||
isWatchlistLoading = false,
|
isWatchlistLoading = false,
|
||||||
onWatchlistToggle,
|
onWatchlistToggle,
|
||||||
}) => {
|
}) => {
|
||||||
const { quoteData, basicInfo, isLoading } = useStockQuoteData(stockCode);
|
const { quoteData, isLoading } = useStockQuoteData(stockCode);
|
||||||
const {
|
const {
|
||||||
currentStockInfo,
|
currentStockInfo,
|
||||||
compareStockInfo,
|
compareStockInfo,
|
||||||
@@ -222,157 +69,45 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
|||||||
return <LoadingSkeleton />;
|
return <LoadingSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 涨跌判断
|
|
||||||
const isUp = quoteData.changePercent >= 0;
|
|
||||||
const priceColor = isUp ? T.upColor : T.downColor;
|
|
||||||
const priceGlow = isUp ? T.upGlow : T.downGlow;
|
|
||||||
const priceBg = isUp ? T.upColorMuted : T.downColorMuted;
|
|
||||||
const inflowColor = (quoteData.mainNetInflow || 0) >= 0 ? T.upColor : T.downColor;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box {...glassCardStyle.containerGold} p={6}>
|
||||||
{...glassCardStyle.containerGold}
|
<CardGlow variant="gold" />
|
||||||
p={6}
|
|
||||||
>
|
|
||||||
<GlowDecorations />
|
|
||||||
|
|
||||||
{/* 内容区域(在装饰层之上)*/}
|
{/* 内容区域(在装饰层之上)*/}
|
||||||
<VStack align="stretch" spacing={4} position="relative" zIndex={1}>
|
<VStack align="stretch" spacing={4} position="relative" zIndex={1}>
|
||||||
|
|
||||||
{/* ========== 头部区域 ========== */}
|
{/* ========== 头部区域 ========== */}
|
||||||
<Flex justify="space-between" align="center">
|
<StockHeader
|
||||||
{/* 左侧:股票名称 + 代码 + 行业 */}
|
name={quoteData.name}
|
||||||
<HStack spacing={4} align="center">
|
code={quoteData.code}
|
||||||
<Text
|
industryL1={quoteData.industryL1}
|
||||||
fontSize="24px"
|
industry={quoteData.industry}
|
||||||
fontWeight="800"
|
indexTags={quoteData.indexTags}
|
||||||
color={T.textPrimary}
|
updateTime={quoteData.updateTime}
|
||||||
textShadow={`0 0 20px ${T.gold}40`}
|
isInWatchlist={isInWatchlist}
|
||||||
>
|
isWatchlistLoading={isWatchlistLoading}
|
||||||
{quoteData.name}
|
onWatchlistToggle={onWatchlistToggle}
|
||||||
</Text>
|
isCompareLoading={isCompareLoading}
|
||||||
<Text fontSize="16px" color={T.textMuted} fontWeight="normal">
|
|
||||||
({quoteData.code})
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* 行业标签 */}
|
|
||||||
{(quoteData.industryL1 || quoteData.industry) && (
|
|
||||||
<Badge
|
|
||||||
bg="transparent"
|
|
||||||
color={T.textSecondary}
|
|
||||||
fontSize="13px"
|
|
||||||
fontWeight="500"
|
|
||||||
border="1px solid"
|
|
||||||
borderColor={T.borderGold}
|
|
||||||
px={3}
|
|
||||||
py={1}
|
|
||||||
borderRadius={T.radiusMD}
|
|
||||||
>
|
|
||||||
{quoteData.industryL1 && quoteData.industry
|
|
||||||
? `${quoteData.industryL1} · ${quoteData.industry}`
|
|
||||||
: quoteData.industry || quoteData.industryL1}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 指数标签 */}
|
|
||||||
{quoteData.indexTags && quoteData.indexTags.length > 0 && (
|
|
||||||
<Text fontSize="13px" color={T.textMuted}>
|
|
||||||
{quoteData.indexTags.join('、')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 右侧:操作按钮 */}
|
|
||||||
<HStack spacing={3}>
|
|
||||||
<CompareStockInput
|
|
||||||
onCompare={handleCompare}
|
onCompare={handleCompare}
|
||||||
isLoading={isCompareLoading}
|
|
||||||
currentStockCode={quoteData.code}
|
|
||||||
/>
|
/>
|
||||||
<FavoriteButton
|
|
||||||
isFavorite={isInWatchlist}
|
|
||||||
isLoading={isWatchlistLoading}
|
|
||||||
onClick={onWatchlistToggle || (() => {})}
|
|
||||||
colorScheme="gold"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<Tooltip label="分享" placement="top">
|
|
||||||
<IconButton
|
|
||||||
aria-label="分享"
|
|
||||||
icon={<Share2 size={18} />}
|
|
||||||
variant="ghost"
|
|
||||||
color={T.textSecondary}
|
|
||||||
size="sm"
|
|
||||||
borderRadius={T.radiusSM}
|
|
||||||
_hover={{ bg: T.borderGlass, color: T.textPrimary }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Text fontSize="13px" color={T.textMuted}>
|
|
||||||
{quoteData.updateTime?.split(' ')[1] || '--:--'}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* ========== 价格区域 ========== */}
|
{/* ========== 价格区域 ========== */}
|
||||||
<HStack align="baseline" spacing={3}>
|
<PriceDisplay
|
||||||
<Text
|
currentPrice={quoteData.currentPrice}
|
||||||
fontSize="42px"
|
changePercent={quoteData.changePercent}
|
||||||
fontWeight="bold"
|
/>
|
||||||
color={priceColor}
|
|
||||||
textShadow={priceGlow}
|
|
||||||
lineHeight="1"
|
|
||||||
>
|
|
||||||
{formatPrice(quoteData.currentPrice)}
|
|
||||||
</Text>
|
|
||||||
<Badge
|
|
||||||
bg={priceBg}
|
|
||||||
color={priceColor}
|
|
||||||
fontSize="18px"
|
|
||||||
fontWeight="bold"
|
|
||||||
px={3}
|
|
||||||
py={1.5}
|
|
||||||
borderRadius={T.radiusMD}
|
|
||||||
boxShadow={priceGlow}
|
|
||||||
>
|
|
||||||
{formatChangePercent(quoteData.changePercent)}
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* ========== 次要行情 ========== */}
|
{/* ========== 次要行情 ========== */}
|
||||||
<HStack spacing={6} fontSize="14px" flexWrap="wrap">
|
<SecondaryQuote
|
||||||
<Text color={T.textMuted}>
|
todayOpen={quoteData.todayOpen}
|
||||||
今开:
|
yesterdayClose={quoteData.yesterdayClose}
|
||||||
<Text as="span" color={T.textWhite} fontWeight="600" ml={1}>
|
todayHigh={quoteData.todayHigh}
|
||||||
{formatPrice(quoteData.todayOpen)}
|
todayLow={quoteData.todayLow}
|
||||||
</Text>
|
/>
|
||||||
</Text>
|
|
||||||
<Box w="1px" h="14px" bg={T.divider} />
|
|
||||||
<Text color={T.textMuted}>
|
|
||||||
昨收:
|
|
||||||
<Text as="span" color={T.textWhite} fontWeight="600" ml={1}>
|
|
||||||
{formatPrice(quoteData.yesterdayClose)}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
<Box w="1px" h="14px" bg={T.divider} />
|
|
||||||
<Text color={T.textMuted}>
|
|
||||||
最高:
|
|
||||||
<Text as="span" color={T.upColor} fontWeight="600" ml={1}>
|
|
||||||
{formatPrice(quoteData.todayHigh)}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
<Box w="1px" h="14px" bg={T.divider} />
|
|
||||||
<Text color={T.textMuted}>
|
|
||||||
最低:
|
|
||||||
<Text as="span" color={T.downColor} fontWeight="600" ml={1}>
|
|
||||||
{formatPrice(quoteData.todayLow)}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* ========== 数据区块(三列布局)========== */}
|
{/* ========== 数据区块(三列布局)========== */}
|
||||||
<Flex gap={4} flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
|
<Flex gap={4} flexWrap={{ base: 'wrap', lg: 'nowrap' }}>
|
||||||
{/* 第一列:估值指标 */}
|
{/* 第一列:估值指标 - PE、流通股本、换手率 */}
|
||||||
<GlassSection title="估值指标" flex={1}>
|
<GlassSection title="估值指标" flex={1}>
|
||||||
<VStack align="stretch" spacing={2}>
|
<VStack align="stretch" spacing={2}>
|
||||||
<MetricRow
|
<MetricRow
|
||||||
@@ -393,7 +128,7 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</GlassSection>
|
</GlassSection>
|
||||||
|
|
||||||
{/* 第二列:市值股本 */}
|
{/* 第二列:市值股本 - 流通市值、发行总股本、52周波动 */}
|
||||||
<GlassSection title="市值股本" flex={1}>
|
<GlassSection title="市值股本" flex={1}>
|
||||||
<VStack align="stretch" spacing={2}>
|
<VStack align="stretch" spacing={2}>
|
||||||
<MetricRow
|
<MetricRow
|
||||||
@@ -415,114 +150,16 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
|
|||||||
|
|
||||||
{/* 第三列:主力动态 */}
|
{/* 第三列:主力动态 */}
|
||||||
<GlassSection title="主力动态" flex={1}>
|
<GlassSection title="主力动态" flex={1}>
|
||||||
<VStack align="stretch" spacing={2}>
|
<MainForceInfo
|
||||||
<MetricRow
|
mainNetInflow={quoteData.mainNetInflow || 0}
|
||||||
label="主力净流入"
|
institutionHolding={quoteData.institutionHolding}
|
||||||
value={formatNetInflow(quoteData.mainNetInflow)}
|
buyRatio={quoteData.buyRatio}
|
||||||
valueColor={inflowColor}
|
sellRatio={quoteData.sellRatio}
|
||||||
highlight
|
|
||||||
/>
|
/>
|
||||||
<MetricRow
|
|
||||||
label="机构持仓"
|
|
||||||
value={`${quoteData.institutionHolding.toFixed(2)}%`}
|
|
||||||
valueColor={T.purple}
|
|
||||||
highlight
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 买卖比例条 */}
|
|
||||||
<Box mt={2}>
|
|
||||||
<Progress
|
|
||||||
value={quoteData.buyRatio}
|
|
||||||
size="sm"
|
|
||||||
sx={{
|
|
||||||
'& > div': {
|
|
||||||
bg: T.upColor,
|
|
||||||
boxShadow: T.upGlow,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
bg={T.downColor}
|
|
||||||
borderRadius="full"
|
|
||||||
h="8px"
|
|
||||||
/>
|
|
||||||
<HStack justify="space-between" mt={2} fontSize="13px">
|
|
||||||
<Text color={T.upColor} fontWeight="600">
|
|
||||||
买入 {quoteData.buyRatio}%
|
|
||||||
</Text>
|
|
||||||
<Text color={T.downColor} fontWeight="600">
|
|
||||||
卖出 {quoteData.sellRatio}%
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Box>
|
|
||||||
</VStack>
|
|
||||||
</GlassSection>
|
</GlassSection>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* ========== 公司信息(已注释)========== */}
|
{/* 公司信息区块已移至 CompanyOverview 模块 */}
|
||||||
{/* {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} />
|
|
||||||
<Text color={T.textMuted}>成立:</Text>
|
|
||||||
<Text color={T.textWhite} fontWeight="600">
|
|
||||||
{formatDate(basicInfo.establish_date)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Icon as={Coins} color={T.textMuted} boxSize={4} />
|
|
||||||
<Text color={T.textMuted}>注册资本:</Text>
|
|
||||||
<Text color={T.textWhite} fontWeight="600">
|
|
||||||
{formatRegisteredCapital(basicInfo.reg_capital)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Icon as={MapPin} color={T.textMuted} boxSize={4} />
|
|
||||||
<Text color={T.textMuted}>所在地:</Text>
|
|
||||||
<Text color={T.textWhite} fontWeight="600">
|
|
||||||
{basicInfo.province} {basicInfo.city}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Icon as={Globe} color={T.textMuted} boxSize={4} />
|
|
||||||
{basicInfo.website ? (
|
|
||||||
<Link
|
|
||||||
href={basicInfo.website}
|
|
||||||
isExternal
|
|
||||||
color={T.cyan}
|
|
||||||
fontWeight="600"
|
|
||||||
_hover={{ color: T.textPrimary, textDecoration: 'underline' }}
|
|
||||||
>
|
|
||||||
访问官网
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Text color={T.textWhiteMuted}>暂无官网</Text>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
flex={2}
|
|
||||||
borderLeftWidth="1px"
|
|
||||||
borderColor={T.divider}
|
|
||||||
pl={8}
|
|
||||||
minW="0"
|
|
||||||
>
|
|
||||||
<Text fontSize="14px" color={T.textMuted} noOfLines={2}>
|
|
||||||
<Text as="span" fontWeight="700" color={T.textSecondary}>
|
|
||||||
公司简介:
|
|
||||||
</Text>
|
|
||||||
{basicInfo.company_intro || '暂无'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
</>
|
|
||||||
)} */}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,39 @@
|
|||||||
* - 使用 axios 请求
|
* - 使用 axios 请求
|
||||||
* - 懒加载策略
|
* - 懒加载策略
|
||||||
* - 自动取消请求
|
* - 自动取消请求
|
||||||
|
* - 自选股状态与 Redux 全局状态同步
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import { useToast } from '@chakra-ui/react';
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import axios from '@utils/axiosConfig';
|
import axios from '@utils/axiosConfig';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { useAuth } from '@contexts/AuthContext';
|
import { useAuth } from '@contexts/AuthContext';
|
||||||
|
import { toggleWatchlist as reduxToggleWatchlist, loadWatchlist } from '@store/slices/stockSlice';
|
||||||
import type {
|
import type {
|
||||||
StockInfo,
|
StockInfo,
|
||||||
WatchlistItem,
|
|
||||||
UseCompanyDataReturn,
|
UseCompanyDataReturn,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
// Store 类型(因为 store 是 JS 文件,这里内联定义)
|
||||||
|
interface WatchlistItem {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockState {
|
||||||
|
watchlist: WatchlistItem[];
|
||||||
|
loading: {
|
||||||
|
watchlist: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RootState {
|
||||||
|
stock: StockState;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseCompanyDataOptions {
|
interface UseCompanyDataOptions {
|
||||||
stockCode: string;
|
stockCode: string;
|
||||||
/** 是否自动加载股票信息 */
|
/** 是否自动加载股票信息 */
|
||||||
@@ -27,17 +46,31 @@ interface UseCompanyDataOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Company 页面数据管理 Hook
|
* Company 页面数据管理 Hook
|
||||||
|
*
|
||||||
|
* 自选股状态现在从 Redux 全局状态读取,确保与导航栏等其他组件同步
|
||||||
*/
|
*/
|
||||||
export const useCompanyData = ({
|
export const useCompanyData = ({
|
||||||
stockCode,
|
stockCode,
|
||||||
autoLoadStockInfo = true,
|
autoLoadStockInfo = true,
|
||||||
autoLoadWatchlist = true,
|
autoLoadWatchlist = true,
|
||||||
}: UseCompanyDataOptions): UseCompanyDataReturn => {
|
}: UseCompanyDataOptions): UseCompanyDataReturn => {
|
||||||
// 状态
|
// 本地状态(仅股票信息)
|
||||||
const [stockInfo, setStockInfo] = useState<StockInfo | null>(null);
|
const [stockInfo, setStockInfo] = useState<StockInfo | null>(null);
|
||||||
const [stockInfoLoading, setStockInfoLoading] = useState(false);
|
const [stockInfoLoading, setStockInfoLoading] = useState(false);
|
||||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
|
||||||
const [watchlistLoading, setWatchlistLoading] = useState(false);
|
// Redux 状态(自选股)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const dispatch = useDispatch<any>();
|
||||||
|
const watchlist = useSelector((state: RootState) => state.stock.watchlist);
|
||||||
|
const watchlistLoading = useSelector((state: RootState) => state.stock.loading.watchlist);
|
||||||
|
|
||||||
|
// 从 Redux watchlist 中派生当前股票的自选状态
|
||||||
|
// 注意:当 watchlist 正在加载时,保持之前的状态(避免闪烁)
|
||||||
|
const isInWatchlist = useMemo(() => {
|
||||||
|
// 如果正在加载且 watchlist 为空,暂时返回 false
|
||||||
|
// localStorage 缓存会很快返回,所以大多数情况下不会看到错误状态
|
||||||
|
return watchlist.some((item) => item.stock_code === stockCode);
|
||||||
|
}, [watchlist, stockCode]);
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -76,47 +109,7 @@ export const useCompanyData = ({
|
|||||||
}, [stockCode]);
|
}, [stockCode]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载自选股状态(优化:只检查单个股票,避免加载整个列表)
|
* 切换自选股状态(使用 Redux action,自动同步全局状态)
|
||||||
*/
|
|
||||||
const loadWatchlistStatus = useCallback(async () => {
|
|
||||||
if (!isAuthenticated || !stockCode) {
|
|
||||||
setIsInWatchlist(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get<ApiResponse<{ is_in_watchlist: boolean }>>(
|
|
||||||
`/api/account/watchlist/check/${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]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换自选股状态
|
|
||||||
*/
|
*/
|
||||||
const toggleWatchlist = useCallback(async () => {
|
const toggleWatchlist = useCallback(async () => {
|
||||||
if (!stockCode) {
|
if (!stockCode) {
|
||||||
@@ -129,27 +122,31 @@ export const useCompanyData = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setWatchlistLoading(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isInWatchlist) {
|
// 使用 Redux action,状态会自动同步到全局
|
||||||
// 移除自选
|
// @ts-expect-error stockSlice 是 JS 文件,TypeScript 无法推断 thunk 参数类型
|
||||||
await axios.delete(`/api/account/watchlist/${stockCode}`);
|
const result = await dispatch(reduxToggleWatchlist({
|
||||||
setIsInWatchlist(false);
|
stockCode,
|
||||||
toast({ title: '已从自选移除', status: 'info', duration: 1500 });
|
stockName: stockInfo?.stock_name || '',
|
||||||
} else {
|
isInWatchlist,
|
||||||
// 添加自选
|
}));
|
||||||
await axios.post('/api/account/watchlist', { stock_code: stockCode });
|
|
||||||
setIsInWatchlist(true);
|
// 检查是否成功(rejected action 会有 error 属性)
|
||||||
toast({ title: '已加入自选', status: 'success', duration: 1500 });
|
if (result.error) {
|
||||||
|
throw new Error(result.error.message || '操作失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示提示
|
||||||
|
toast({
|
||||||
|
title: isInWatchlist ? '已从自选移除' : '已加入自选',
|
||||||
|
status: isInWatchlist ? 'info' : 'success',
|
||||||
|
duration: 1500,
|
||||||
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('useCompanyData', 'toggleWatchlist', error, { stockCode });
|
logger.error('useCompanyData', 'toggleWatchlist', error, { stockCode });
|
||||||
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
|
toast({ title: '操作失败,请稍后重试', status: 'error', duration: 2000 });
|
||||||
} finally {
|
|
||||||
setWatchlistLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [stockCode, isAuthenticated, isInWatchlist, toast]);
|
}, [stockCode, stockInfo?.stock_name, isAuthenticated, isInWatchlist, toast, dispatch]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新股票信息
|
* 刷新股票信息
|
||||||
@@ -169,12 +166,13 @@ export const useCompanyData = ({
|
|||||||
};
|
};
|
||||||
}, [autoLoadStockInfo, loadStockInfo]);
|
}, [autoLoadStockInfo, loadStockInfo]);
|
||||||
|
|
||||||
// 自动加载自选股状态
|
// 自动加载自选股列表(从 Redux)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoLoadWatchlist) {
|
if (autoLoadWatchlist && isAuthenticated && watchlist.length === 0) {
|
||||||
loadWatchlistStatus();
|
// 只有当 Redux 中没有数据时才加载
|
||||||
|
dispatch(loadWatchlist());
|
||||||
}
|
}
|
||||||
}, [autoLoadWatchlist, loadWatchlistStatus]);
|
}, [autoLoadWatchlist, isAuthenticated, watchlist.length, dispatch]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stockInfo,
|
stockInfo,
|
||||||
|
|||||||
@@ -1,100 +1,189 @@
|
|||||||
/**
|
/**
|
||||||
* 公司详情页面 - FUI 科幻风格
|
* ============================================================================
|
||||||
|
* 公司详情页面 (Company Detail Page)
|
||||||
|
* ============================================================================
|
||||||
*
|
*
|
||||||
* 特性:
|
* 📍 路由: /company?scode=000001
|
||||||
* - Ash Thorp 风格 FUI 设计
|
*
|
||||||
* - James Turrell 光影效果
|
* 📋 功能概述:
|
||||||
* - Glassmorphism 毛玻璃卡片
|
* - 展示个股详情信息,包括股票行情、公司资料、财务数据等
|
||||||
* - Linear.app 风格微交互
|
* - 支持通过 URL 参数 `scode` 指定股票代码
|
||||||
* - HeroUI 现代组件风格
|
* - 提供自选股添加/移除功能
|
||||||
|
* - 多 Tab 切换展示不同维度的公司信息
|
||||||
|
*
|
||||||
|
* 🎨 设计风格:
|
||||||
|
* - FUI (Futuristic User Interface) 科幻风格
|
||||||
|
* - Ash Thorp 风格 - 电影级 UI 设计美学
|
||||||
|
* - James Turrell 光影效果 - 环境光渲染
|
||||||
|
* - Glassmorphism 毛玻璃卡片效果
|
||||||
|
* - Linear.app 风格微交互动画
|
||||||
|
*
|
||||||
|
* 🏗️ 组件架构:
|
||||||
|
* CompanyIndex (本文件)
|
||||||
|
* ├── AmbientGlow - 全局环境光效果背景
|
||||||
|
* ├── CompanyHeader - 顶部区域 (页面标题 + 搜索栏)
|
||||||
|
* ├── StockQuoteCard - 股票实时行情卡片 (价格、涨跌幅等)
|
||||||
|
* └── SubTabContainer - Tab 切换容器
|
||||||
|
* ├── 概览 Tab
|
||||||
|
* ├── 财务 Tab
|
||||||
|
* ├── 公告 Tab
|
||||||
|
* └── ... 其他 Tab (由 TAB_CONFIG 配置)
|
||||||
|
*
|
||||||
|
* 📊 数据流:
|
||||||
|
* 1. URL 参数 scode → stockCode 状态
|
||||||
|
* 2. stockCode → useCompanyData Hook → 获取股票信息、自选股状态
|
||||||
|
* 3. stockCode → useCompanyEvents Hook → 用户行为追踪
|
||||||
|
* 4. 数据传递给子组件进行渲染
|
||||||
|
*
|
||||||
|
* 🔧 性能优化:
|
||||||
|
* - 使用 memo() 包装组件,避免父组件更新时不必要的重渲染
|
||||||
|
* - 使用 useCallback 缓存事件处理函数
|
||||||
|
* - 使用 useMemo 缓存传递给子组件的 props 对象
|
||||||
|
* - Tab 内容使用 isLazy 延迟加载,减少首屏渲染负担
|
||||||
|
* - 使用 useRef 追踪前一个股票代码,避免重复触发事件
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo, useCallback, useRef, useEffect, useMemo } from 'react';
|
import React, { memo, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
// FUI 动画样式
|
// ============================================
|
||||||
|
// 样式导入
|
||||||
|
// ============================================
|
||||||
|
// FUI 动画样式 - 包含扫描线、发光效果等科幻动画
|
||||||
import './theme/fui-animations.css';
|
import './theme/fui-animations.css';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 第三方库导入
|
||||||
|
// ============================================
|
||||||
|
// React Router - 用于读取和修改 URL 查询参数
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
// Chakra UI - 基础布局组件
|
||||||
import { Box } from '@chakra-ui/react';
|
import { Box } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 内部组件和工具导入
|
||||||
|
// ============================================
|
||||||
|
// 通用 Tab 切换容器组件 - 支持懒加载和主题配置
|
||||||
import SubTabContainer from '@components/SubTabContainer';
|
import SubTabContainer from '@components/SubTabContainer';
|
||||||
|
// FUI 风格组件 - 科幻容器和环境光效果
|
||||||
import { FuiContainer, AmbientGlow } from '@components/FUI';
|
import { FuiContainer, AmbientGlow } from '@components/FUI';
|
||||||
|
// 动态网页标题 Hook - 根据股票名称更新浏览器标签页标题
|
||||||
import { useStockDocumentTitle } from '@hooks/useDocumentTitle';
|
import { useStockDocumentTitle } from '@hooks/useDocumentTitle';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 页面级 Hooks
|
||||||
|
// ============================================
|
||||||
|
// 用户行为事件追踪 Hook - 发送分析数据到 PostHog
|
||||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||||||
|
// 公司数据获取 Hook - 封装股票信息和自选股相关 API
|
||||||
import { useCompanyData } from './hooks/useCompanyData';
|
import { useCompanyData } from './hooks/useCompanyData';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 页面子组件
|
||||||
|
// ============================================
|
||||||
|
// 顶部 Header - 包含页面标题和搜索框
|
||||||
import CompanyHeader from './components/CompanyHeader';
|
import CompanyHeader from './components/CompanyHeader';
|
||||||
|
// 股票行情卡片 - 显示实时价格、涨跌幅、成交量等
|
||||||
import StockQuoteCard from './components/StockQuoteCard';
|
import StockQuoteCard from './components/StockQuoteCard';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 配置常量
|
||||||
|
// ============================================
|
||||||
|
// THEME - 页面主题配置 (背景色、文字色等)
|
||||||
|
// TAB_CONFIG - Tab 页签配置数组 (名称、图标、对应组件)
|
||||||
import { THEME, TAB_CONFIG } from './config';
|
import { THEME, TAB_CONFIG } from './config';
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 主内容区组件 - FUI 风格
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
interface CompanyContentProps {
|
|
||||||
stockCode: string;
|
|
||||||
isInWatchlist: boolean;
|
|
||||||
watchlistLoading: boolean;
|
|
||||||
onWatchlistToggle: () => void;
|
|
||||||
onTabChange: (index: number, tabKey: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CompanyContent: React.FC<CompanyContentProps> = memo(({
|
|
||||||
stockCode,
|
|
||||||
isInWatchlist,
|
|
||||||
watchlistLoading,
|
|
||||||
onWatchlistToggle,
|
|
||||||
onTabChange,
|
|
||||||
}) => {
|
|
||||||
// 缓存 componentProps,避免每次渲染创建新对象
|
|
||||||
const memoizedComponentProps = useMemo(() => ({ stockCode }), [stockCode]);
|
|
||||||
|
|
||||||
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={memoizedComponentProps}
|
|
||||||
onTabChange={onTabChange}
|
|
||||||
themePreset="blackGold"
|
|
||||||
contentPadding={0}
|
|
||||||
isLazy={true}
|
|
||||||
/>
|
|
||||||
</FuiContainer>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
CompanyContent.displayName = 'CompanyContent';
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 主页面组件
|
// 主页面组件
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CompanyIndex - 公司详情页主组件
|
||||||
|
*
|
||||||
|
* 这是一个容器组件 (Container Component),主要负责:
|
||||||
|
* 1. 状态管理 - 管理 URL 参数、数据加载状态
|
||||||
|
* 2. 数据获取 - 通过自定义 Hook 获取股票数据
|
||||||
|
* 3. 事件处理 - 处理用户交互(搜索、Tab 切换、自选操作)
|
||||||
|
* 4. 布局编排 - 组合子组件构成完整页面
|
||||||
|
*
|
||||||
|
* 具体的 UI 渲染和业务逻辑委托给各个子组件处理
|
||||||
|
*/
|
||||||
const CompanyIndex: React.FC = () => {
|
const CompanyIndex: React.FC = () => {
|
||||||
|
// ==========================================
|
||||||
// URL 参数管理
|
// URL 参数管理
|
||||||
|
// ==========================================
|
||||||
|
/**
|
||||||
|
* useSearchParams - React Router v6 的 Hook
|
||||||
|
* 用于读取和修改 URL 中的查询参数 (query string)
|
||||||
|
*
|
||||||
|
* 示例 URL: /company?scode=600519
|
||||||
|
* searchParams.get('scode') 返回 '600519'
|
||||||
|
*/
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前股票代码
|
||||||
|
* - 从 URL 参数 `scode` 读取
|
||||||
|
* - 默认值 '000001' (平安银行) 作为兜底
|
||||||
|
*/
|
||||||
const stockCode = searchParams.get('scode') || '000001';
|
const stockCode = searchParams.get('scode') || '000001';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前一个股票代码的引用
|
||||||
|
* - 用于检测股票代码是否发生变化
|
||||||
|
* - 避免在股票未变化时重复触发追踪事件
|
||||||
|
* - 使用 useRef 而非 useState,因为不需要触发重渲染
|
||||||
|
*/
|
||||||
const prevStockCodeRef = useRef(stockCode);
|
const prevStockCodeRef = useRef(stockCode);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
// 数据加载 Hook
|
// 数据加载 Hook
|
||||||
|
// ==========================================
|
||||||
|
/**
|
||||||
|
* useCompanyData - 自定义 Hook,封装公司数据获取逻辑
|
||||||
|
*
|
||||||
|
* 返回值说明:
|
||||||
|
* @property {Object} stockInfo - 股票基础信息对象
|
||||||
|
* - stock_name: 股票名称 (如 "贵州茅台")
|
||||||
|
* - stock_code: 股票代码 (如 "600519")
|
||||||
|
* - industry: 所属行业
|
||||||
|
* - ... 其他字段
|
||||||
|
*
|
||||||
|
* @property {boolean} stockInfoLoading - 股票信息加载中状态
|
||||||
|
* - true: 正在请求数据,显示骨架屏/loading
|
||||||
|
* - false: 数据加载完成
|
||||||
|
*
|
||||||
|
* @property {boolean} isInWatchlist - 是否已添加到自选股
|
||||||
|
* - true: 已在自选列表中,显示"已自选"状态
|
||||||
|
* - false: 未添加,显示"添加自选"按钮
|
||||||
|
*
|
||||||
|
* @property {boolean} watchlistLoading - 自选股操作加载中
|
||||||
|
* - 用于禁用按钮,防止重复点击
|
||||||
|
*
|
||||||
|
* @property {Function} toggleWatchlist - 切换自选股状态
|
||||||
|
* - 异步函数,调用后台 API 添加/移除自选
|
||||||
|
*/
|
||||||
const {
|
const {
|
||||||
stockInfo,
|
stockInfo,
|
||||||
stockInfoLoading,
|
|
||||||
isInWatchlist,
|
isInWatchlist,
|
||||||
watchlistLoading,
|
watchlistLoading,
|
||||||
toggleWatchlist,
|
toggleWatchlist,
|
||||||
} = useCompanyData({ stockCode });
|
} = useCompanyData({ stockCode });
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
// 事件追踪 Hook
|
// 事件追踪 Hook
|
||||||
|
// ==========================================
|
||||||
|
/**
|
||||||
|
* useCompanyEvents - 用户行为追踪 Hook
|
||||||
|
*
|
||||||
|
* 用于记录用户在页面上的关键操作,发送到分析平台 (PostHog)
|
||||||
|
* 这些数据用于产品分析、用户行为研究、功能优化等
|
||||||
|
*
|
||||||
|
* 追踪的事件类型:
|
||||||
|
* - trackStockSearched: 用户搜索/切换股票
|
||||||
|
* - trackTabChanged: 用户切换 Tab 页签
|
||||||
|
* - trackWatchlistAdded: 用户添加自选股
|
||||||
|
* - trackWatchlistRemoved: 用户移除自选股
|
||||||
|
*/
|
||||||
const companyEvents = useCompanyEvents({ stockCode }) as {
|
const companyEvents = useCompanyEvents({ stockCode }) as {
|
||||||
trackStockSearched: (newCode: string, oldCode: string | null) => void;
|
trackStockSearched: (newCode: string, oldCode: string | null) => void;
|
||||||
trackTabChanged: (index: number, name: string, prevIndex: number) => void;
|
trackTabChanged: (index: number, name: string, prevIndex: number) => void;
|
||||||
@@ -102,81 +191,253 @@ const CompanyIndex: React.FC = () => {
|
|||||||
trackWatchlistRemoved: (code: string) => void;
|
trackWatchlistRemoved: (code: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 解构追踪函数,方便使用
|
||||||
const { trackStockSearched, trackTabChanged, trackWatchlistAdded, trackWatchlistRemoved } = companyEvents;
|
const { trackStockSearched, trackTabChanged, trackWatchlistAdded, trackWatchlistRemoved } = companyEvents;
|
||||||
|
|
||||||
// 设置网页标题
|
// ==========================================
|
||||||
|
// 副作用 Effects
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置网页标题
|
||||||
|
*
|
||||||
|
* 根据当前股票代码和名称动态更新浏览器标签页标题
|
||||||
|
* 示例: "600519 贵州茅台 - 公司详情"
|
||||||
|
*
|
||||||
|
* 这提升了用户体验,特别是当用户打开多个标签页时
|
||||||
|
* 可以通过标题快速识别每个页面展示的股票
|
||||||
|
*/
|
||||||
useStockDocumentTitle(stockCode, stockInfo?.stock_name);
|
useStockDocumentTitle(stockCode, stockInfo?.stock_name);
|
||||||
|
|
||||||
// 股票代码变化追踪
|
/**
|
||||||
|
* 股票代码变化追踪
|
||||||
|
*
|
||||||
|
* 当股票代码发生变化时(通过 URL 参数改变),
|
||||||
|
* 触发追踪事件记录用户的浏览行为
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* - 只在代码真正变化时触发,避免初始化时的重复追踪
|
||||||
|
* - 使用 useRef 存储前值,而非 usePrevious Hook,减少依赖
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 只有当股票代码真正发生变化时才触发追踪
|
||||||
if (stockCode !== prevStockCodeRef.current) {
|
if (stockCode !== prevStockCodeRef.current) {
|
||||||
|
// 记录用户从哪只股票切换到哪只股票
|
||||||
trackStockSearched(stockCode, prevStockCodeRef.current);
|
trackStockSearched(stockCode, prevStockCodeRef.current);
|
||||||
|
// 更新引用值
|
||||||
prevStockCodeRef.current = stockCode;
|
prevStockCodeRef.current = stockCode;
|
||||||
}
|
}
|
||||||
}, [stockCode, trackStockSearched]);
|
}, [stockCode, trackStockSearched]);
|
||||||
|
|
||||||
// 处理股票切换
|
// ==========================================
|
||||||
|
// 事件处理函数
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理股票切换
|
||||||
|
*
|
||||||
|
* 当用户通过搜索框选择新股票时调用
|
||||||
|
* 1. 验证新代码有效且与当前不同
|
||||||
|
* 2. 触发追踪事件
|
||||||
|
* 3. 更新 URL 参数(触发组件重新渲染,加载新数据)
|
||||||
|
*
|
||||||
|
* @param {string} newCode - 用户选择的新股票代码
|
||||||
|
*
|
||||||
|
* 使用 useCallback 缓存函数引用,避免子组件不必要的重渲染
|
||||||
|
*/
|
||||||
const handleStockChange = useCallback((newCode: string) => {
|
const handleStockChange = useCallback((newCode: string) => {
|
||||||
|
// 验证: 新代码存在 且 与当前代码不同
|
||||||
if (newCode && newCode !== stockCode) {
|
if (newCode && newCode !== stockCode) {
|
||||||
|
// 追踪: 记录股票切换行为
|
||||||
trackStockSearched(newCode, stockCode);
|
trackStockSearched(newCode, stockCode);
|
||||||
|
// 更新 URL: 这会触发组件重新渲染,进而重新获取数据
|
||||||
setSearchParams({ scode: newCode });
|
setSearchParams({ scode: newCode });
|
||||||
}
|
}
|
||||||
}, [stockCode, setSearchParams, trackStockSearched]);
|
}, [stockCode, setSearchParams, trackStockSearched]);
|
||||||
|
|
||||||
// 处理自选股切换(带追踪)
|
/**
|
||||||
|
* 处理自选股切换(带追踪)
|
||||||
|
*
|
||||||
|
* 当用户点击"添加/移除自选"按钮时调用
|
||||||
|
* 1. 记录操作前的状态(用于判断是添加还是移除)
|
||||||
|
* 2. 调用 API 执行实际操作
|
||||||
|
* 3. 根据操作类型触发对应的追踪事件
|
||||||
|
*
|
||||||
|
* 注意: 使用 async/await 确保 API 调用完成后再触发追踪
|
||||||
|
*/
|
||||||
const handleWatchlistToggle = useCallback(async () => {
|
const handleWatchlistToggle = useCallback(async () => {
|
||||||
|
// 记录操作前的状态
|
||||||
const wasInWatchlist = isInWatchlist;
|
const wasInWatchlist = isInWatchlist;
|
||||||
|
|
||||||
|
// 执行 API 调用(添加或移除自选股)
|
||||||
await toggleWatchlist();
|
await toggleWatchlist();
|
||||||
|
|
||||||
// 追踪事件(根据操作前的状态判断)
|
// 追踪事件(根据操作前的状态判断是添加还是移除)
|
||||||
if (wasInWatchlist) {
|
if (wasInWatchlist) {
|
||||||
|
// 之前在自选中 → 现在移除了
|
||||||
trackWatchlistRemoved(stockCode);
|
trackWatchlistRemoved(stockCode);
|
||||||
} else {
|
} else {
|
||||||
|
// 之前不在自选中 → 现在添加了
|
||||||
trackWatchlistAdded(stockCode);
|
trackWatchlistAdded(stockCode);
|
||||||
}
|
}
|
||||||
}, [stockCode, isInWatchlist, toggleWatchlist, trackWatchlistAdded, trackWatchlistRemoved]);
|
}, [stockCode, isInWatchlist, toggleWatchlist, trackWatchlistAdded, trackWatchlistRemoved]);
|
||||||
|
|
||||||
// 处理 Tab 切换
|
/**
|
||||||
|
* 处理 Tab 切换
|
||||||
|
*
|
||||||
|
* 当用户点击不同的 Tab 页签时调用
|
||||||
|
* 记录用户查看了哪个 Tab,用于分析用户最关注的信息类型
|
||||||
|
*
|
||||||
|
* @param {number} index - Tab 的索引位置 (0, 1, 2, ...)
|
||||||
|
* @param {string} tabKey - Tab 的唯一标识符
|
||||||
|
*/
|
||||||
const handleTabChange = useCallback((index: number, tabKey: string) => {
|
const handleTabChange = useCallback((index: number, tabKey: string) => {
|
||||||
|
// 从配置中获取 Tab 的显示名称,如果没找到则使用 tabKey
|
||||||
const tabName = TAB_CONFIG[index]?.name || tabKey;
|
const tabName = TAB_CONFIG[index]?.name || tabKey;
|
||||||
|
// 触发追踪事件
|
||||||
trackTabChanged(index, tabName, index);
|
trackTabChanged(index, tabName, index);
|
||||||
}, [trackTabChanged]);
|
}, [trackTabChanged]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 性能优化: 缓存 Props
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存传递给 SubTabContainer 的 componentProps
|
||||||
|
*
|
||||||
|
* 为什么需要 useMemo?
|
||||||
|
* - 每次组件渲染时,`{ stockCode }` 会创建一个新对象
|
||||||
|
* - 即使 stockCode 值没变,新对象的引用也不同
|
||||||
|
* - 这会导致 SubTabContainer 认为 props 变了,触发不必要的重渲染
|
||||||
|
*
|
||||||
|
* 使用 useMemo 后:
|
||||||
|
* - 只有当 stockCode 真正变化时,才创建新对象
|
||||||
|
* - 保持对象引用稳定,避免子组件重渲染
|
||||||
|
*/
|
||||||
|
const memoizedComponentProps = useMemo(() => ({ stockCode }), [stockCode]);
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 渲染 UI
|
||||||
|
// ==========================================
|
||||||
return (
|
return (
|
||||||
|
/**
|
||||||
|
* 最外层容器
|
||||||
|
* - position="relative": 为内部绝对定位元素提供定位上下文
|
||||||
|
* - bg={THEME.bg}: 使用主题配置的背景色
|
||||||
|
* - minH: 最小高度 = 视口高度 - 顶部导航栏高度 (60px)
|
||||||
|
* - overflow="hidden": 隐藏溢出内容,配合光效动画使用
|
||||||
|
*/
|
||||||
<Box
|
<Box
|
||||||
position="relative"
|
position="relative"
|
||||||
bg={THEME.bg}
|
bg={THEME.bg}
|
||||||
minH="calc(100vh - 60px)"
|
minH="calc(100vh - 60px)"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
{/* 全局环境光效果 - James Turrell 风格 */}
|
{/* ========================================
|
||||||
|
全局环境光效果
|
||||||
|
========================================
|
||||||
|
AmbientGlow 组件创建 James Turrell 风格的光影效果
|
||||||
|
- 在页面背景上渲染柔和的渐变光晕
|
||||||
|
- 增强科幻 UI 的氛围感
|
||||||
|
- variant="default" 使用默认的光效配置
|
||||||
|
*/}
|
||||||
<AmbientGlow variant="default" />
|
<AmbientGlow variant="default" />
|
||||||
|
|
||||||
{/* 顶部搜索栏 */}
|
{/* ========================================
|
||||||
|
顶部搜索栏区域
|
||||||
|
========================================
|
||||||
|
zIndex={1} 确保 Header 在环境光效果之上显示
|
||||||
|
*/}
|
||||||
<Box position="relative" zIndex={1}>
|
<Box position="relative" zIndex={1}>
|
||||||
<CompanyHeader
|
{/*
|
||||||
|
CompanyHeader 组件
|
||||||
|
负责展示:
|
||||||
|
- 左侧:页面标题和副标题
|
||||||
|
- 右侧:股票搜索框 (支持代码/名称搜索,点击图标可搜索)
|
||||||
|
|
||||||
|
Props 说明:
|
||||||
|
- onStockChange: 股票切换回调
|
||||||
|
*/}
|
||||||
|
<CompanyHeader onStockChange={handleStockChange} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========================================
|
||||||
|
主内容区
|
||||||
|
========================================
|
||||||
|
包含股票行情卡片和 Tab 内容区
|
||||||
|
*/}
|
||||||
|
<Box position="relative" zIndex={1}>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
内容容器
|
||||||
|
- maxW="container.xl": 最大宽度限制,保持内容可读性
|
||||||
|
- mx="auto": 水平居中
|
||||||
|
- px={4}: 左右内边距 16px
|
||||||
|
- py={6}: 上下内边距 24px
|
||||||
|
*/}
|
||||||
|
<Box maxW="container.xl" mx="auto" px={4} py={6}>
|
||||||
|
{/* ========================================
|
||||||
|
股票行情卡片
|
||||||
|
========================================
|
||||||
|
放在 Tab 切换器上方,始终可见
|
||||||
|
显示实时股价、涨跌幅、成交量、换手率等核心行情数据
|
||||||
|
|
||||||
|
这个卡片独立于 Tab 系统,因为行情数据是用户
|
||||||
|
无论查看哪个 Tab 都需要看到的核心信息
|
||||||
|
|
||||||
|
mb={6}: 底部外边距 24px,与下方 Tab 区域保持间距
|
||||||
|
*/}
|
||||||
|
<Box mb={6}>
|
||||||
|
<StockQuoteCard
|
||||||
stockCode={stockCode}
|
stockCode={stockCode}
|
||||||
stockInfo={stockInfo}
|
|
||||||
stockInfoLoading={stockInfoLoading}
|
|
||||||
isInWatchlist={isInWatchlist}
|
isInWatchlist={isInWatchlist}
|
||||||
watchlistLoading={watchlistLoading}
|
isWatchlistLoading={watchlistLoading}
|
||||||
onStockChange={handleStockChange}
|
|
||||||
onWatchlistToggle={handleWatchlistToggle}
|
onWatchlistToggle={handleWatchlistToggle}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 主内容区 */}
|
{/* ========================================
|
||||||
<Box position="relative" zIndex={1}>
|
Tab 内容区
|
||||||
<CompanyContent
|
========================================
|
||||||
stockCode={stockCode}
|
FuiContainer 提供 FUI 风格的容器样式:
|
||||||
isInWatchlist={isInWatchlist}
|
- 毛玻璃背景效果
|
||||||
watchlistLoading={watchlistLoading}
|
- 边框发光效果
|
||||||
onWatchlistToggle={handleWatchlistToggle}
|
- 科幻风格圆角
|
||||||
|
|
||||||
|
SubTabContainer 是通用的 Tab 切换组件:
|
||||||
|
- tabs: Tab 配置数组,定义每个 Tab 的名称、图标、组件
|
||||||
|
- componentProps: 传递给每个 Tab 组件的共享 props
|
||||||
|
- onTabChange: Tab 切换时的回调函数
|
||||||
|
- themePreset: 主题预设 ("blackGold" = 黑金配色)
|
||||||
|
- contentPadding: Tab 内容区内边距 (0 = 无内边距)
|
||||||
|
- isLazy: 懒加载,只有激活的 Tab 才渲染内容
|
||||||
|
*/}
|
||||||
|
<FuiContainer variant="default">
|
||||||
|
<SubTabContainer
|
||||||
|
tabs={TAB_CONFIG}
|
||||||
|
componentProps={memoizedComponentProps}
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
|
themePreset="blackGold"
|
||||||
|
contentPadding={0}
|
||||||
|
isLazy={true}
|
||||||
/>
|
/>
|
||||||
|
</FuiContainer>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出组件
|
||||||
|
*
|
||||||
|
* 使用 React.memo 包装组件
|
||||||
|
* memo 是一个高阶组件,用于性能优化:
|
||||||
|
* - 当组件的 props 没有变化时,跳过重新渲染
|
||||||
|
* - 对于这个页面级组件,可以避免父组件(如 MainLayout)
|
||||||
|
* 更新时导致的不必要重渲染
|
||||||
|
*
|
||||||
|
* 注意: memo 只做浅比较,对于复杂 props 需要配合 useMemo
|
||||||
|
*/
|
||||||
export default memo(CompanyIndex);
|
export default memo(CompanyIndex);
|
||||||
|
|||||||
@@ -110,13 +110,7 @@ export interface UseCompanyDataReturn {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export interface CompanyHeaderProps {
|
export interface CompanyHeaderProps {
|
||||||
stockCode: string;
|
|
||||||
stockInfo: StockInfo | null;
|
|
||||||
stockInfoLoading: boolean;
|
|
||||||
isInWatchlist: boolean;
|
|
||||||
watchlistLoading: boolean;
|
|
||||||
onStockChange: (code: string) => void;
|
onStockChange: (code: string) => void;
|
||||||
onWatchlistToggle: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyPageProps {
|
export interface CompanyPageProps {
|
||||||
|
|||||||
Reference in New Issue
Block a user