Files
vf_react/src/views/Company/components/StockQuoteCard/index.tsx

540 lines
17 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 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="13px"
fontWeight="600"
color={T.textSecondary}
mb={3}
textTransform="uppercase"
letterSpacing="0.1em"
>
{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>
{/* ========== 数据区块Bento Grid========== */}
<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.marketCap || '-'}
valueColor={T.textPrimary}
highlight
/>
<MetricRow
label="发行总股本"
value={quoteData.totalShares ? `${quoteData.totalShares}亿股` : '-'}
/>
<MetricRow
label="流通股本"
value={quoteData.floatShares ? `${quoteData.floatShares}亿股` : '-'}
/>
<MetricRow
label="换手率"
value={quoteData.turnoverRate !== undefined ? `${quoteData.turnoverRate.toFixed(2)}%` : '-'}
valueColor={quoteData.turnoverRate && quoteData.turnoverRate > 5 ? T.orange : T.textWhite}
/>
<MetricRow
label="52周波动"
value={`${formatPrice(quoteData.week52Low)} - ${formatPrice(quoteData.week52High)}`}
/>
</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 StockQuoteCard;