- 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>
544 lines
18 KiB
TypeScript
544 lines
18 KiB
TypeScript
/**
|
||
* 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);
|