Files
vf_react/src/views/Company/components/StockQuoteCard/index.tsx
zdl 51721ce9bf perf(Company): 优化渲染性能和 API 请求
- StockQuoteCard: 添加 memo 包装减少重渲染
- Company/index: componentProps 使用 useMemo 缓存
- useCompanyEvents: 页面浏览事件只触发一次,避免重复追踪
- useCompanyData: 自选股状态改用单股票查询接口,减少数据传输
- CompanyHeader: inputCode 状态下移到 SearchActions,减少父组件重渲染
- CompanyHeader: 移除重复环境光效果,由全局 AmbientGlow 统一处理

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 10:14:07 +08:00

544 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* StockQuoteCard - 股票行情卡片组件
*
* 深空 FUI 设计风格Glassmorphism + Ash Thorp + James Turrell
* - 半透明玻璃态卡片,漂浮在深空中
* - 光影深度,弥散背景光
* - 极致圆角,科幻数据终端感
*
* 保留原有所有功能:
* - 股票头部(名称、代码、行业、对比、关注、分享)
* - 价格显示(当前价、涨跌幅)
* - 次要行情(今开、昨收、最高、最低)
* - 关键指标PE、市值、股本、换手率、52周
* - 主力动态(净流入、机构持仓、买卖比)
* - 公司信息(成立、注册资本、所在地、官网、简介)
*/
import React, { memo } from 'react';
import {
Box,
Flex,
HStack,
VStack,
Text,
Badge,
IconButton,
Tooltip,
Skeleton,
Progress,
Link,
Icon,
useDisclosure,
} from '@chakra-ui/react';
import { Share2, Calendar, Coins, MapPin, Globe } from 'lucide-react';
import FavoriteButton from '@components/FavoriteButton';
import { StockCompareModal, CompareStockInput } from './components';
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';
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> = ({
stockCode,
isInWatchlist = false,
isWatchlistLoading = false,
onWatchlistToggle,
}) => {
const { quoteData, basicInfo, isLoading } = useStockQuoteData(stockCode);
const {
currentStockInfo,
compareStockInfo,
isCompareLoading,
handleCompare: triggerCompare,
clearCompare,
} = useStockCompare(stockCode);
const { isOpen: isCompareModalOpen, onOpen: openCompareModal, onClose: closeCompareModal } = useDisclosure();
const handleCompare = (compareCode: string) => {
triggerCompare(compareCode);
openCompareModal();
};
const handleCloseCompare = () => {
closeCompareModal();
clearCompare();
};
// 加载中
if (isLoading || !quoteData) {
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 (
<>
<Box
{...glassCardStyle.containerGold}
p={6}
>
<GlowDecorations />
{/* 内容区域(在装饰层之上)*/}
<VStack align="stretch" spacing={4} position="relative" zIndex={1}>
{/* ========== 头部区域 ========== */}
<Flex justify="space-between" align="center">
{/* 左侧:股票名称 + 代码 + 行业 */}
<HStack spacing={4} align="center">
<Text
fontSize="24px"
fontWeight="800"
color={T.textPrimary}
textShadow={`0 0 20px ${T.gold}40`}
>
{quoteData.name}
</Text>
<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}
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}>
<Text
fontSize="42px"
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">
<Text color={T.textMuted}>
<Text as="span" color={T.textWhite} fontWeight="600" ml={1}>
{formatPrice(quoteData.todayOpen)}
</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' }}>
{/* 第一列:估值指标 */}
<GlassSection title="估值指标" flex={1}>
<VStack align="stretch" spacing={2}>
<MetricRow
label="市盈率 (PE)"
value={quoteData.pe ? quoteData.pe.toFixed(2) : '-'}
valueColor={T.cyan}
highlight
/>
<MetricRow
label="流通股本"
value={quoteData.floatShares ? `${quoteData.floatShares}亿股` : '-'}
/>
<MetricRow
label="换手率"
value={quoteData.turnoverRate !== undefined ? `${quoteData.turnoverRate.toFixed(2)}%` : '-'}
valueColor={quoteData.turnoverRate && quoteData.turnoverRate > 5 ? T.orange : T.textWhite}
/>
</VStack>
</GlassSection>
{/* 第二列:市值股本 */}
<GlassSection title="市值股本" flex={1}>
<VStack align="stretch" spacing={2}>
<MetricRow
label="流通市值"
value={quoteData.marketCap || '-'}
valueColor={T.textPrimary}
highlight
/>
<MetricRow
label="发行总股本"
value={quoteData.totalShares ? `${quoteData.totalShares}亿股` : '-'}
/>
<MetricRow
label="52周波动"
value={`${formatPrice(quoteData.week52Low)} - ${formatPrice(quoteData.week52High)}`}
/>
</VStack>
</GlassSection>
{/* 第三列:主力动态 */}
<GlassSection title="主力动态" flex={1}>
<VStack align="stretch" spacing={2}>
<MetricRow
label="主力净流入"
value={formatNetInflow(quoteData.mainNetInflow)}
valueColor={inflowColor}
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>
</Flex>
{/* ========== 公司信息(已注释)========== */}
{/* {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>
</Box>
{/* 股票对比弹窗 */}
<StockCompareModal
isOpen={isCompareModalOpen}
onClose={handleCloseCompare}
currentStock={quoteData.code}
currentStockInfo={currentStockInfo || null}
compareStock={compareStockInfo?.stock_code || ''}
compareStockInfo={compareStockInfo || null}
isLoading={isCompareLoading}
/>
</>
);
};
export default memo(StockQuoteCard);