Files
vf_react/src/views/Company/components/StockQuoteCard/index.tsx
zdl b25d48e167 feat(StockQuoteCard): 新增股票对比功能
- 新增 CompareStockInput: 股票搜索输入组件
 - 新增 StockCompareModal: 股票对比弹窗
 - 更新类型定义支持对比功能

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

 Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 16:15:36 +08:00

408 lines
14 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 - 股票行情卡片组件
*
* 展示股票的实时行情、关键指标和主力动态
*/
import React from 'react';
import {
Box,
Card,
CardBody,
Flex,
HStack,
VStack,
Text,
Badge,
Progress,
Skeleton,
IconButton,
Tooltip,
Divider,
Link,
Icon,
useDisclosure,
} from '@chakra-ui/react';
import { Share2, Calendar, Coins, MapPin, Globe } from 'lucide-react';
import { formatRegisteredCapital, formatDate } from '../CompanyOverview/utils';
import FavoriteButton from '@components/FavoriteButton';
import { CompareStockInput, StockCompareModal } from './components';
import type { StockQuoteCardProps } from './types';
/**
* 格式化价格显示
*/
const formatPrice = (price: number): string => {
return price.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
/**
* 格式化涨跌幅显示
*/
const formatChangePercent = (percent: number): string => {
const sign = percent >= 0 ? '+' : '';
return `${sign}${percent.toFixed(2)}%`;
};
/**
* 格式化主力净流入显示
*/
const formatNetInflow = (value: number): string => {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}亿`;
};
const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
data,
isLoading = false,
isInWatchlist = false,
isWatchlistLoading = false,
onWatchlistToggle,
onShare,
basicInfo,
// 对比相关
currentStockInfo,
compareStockInfo,
isCompareLoading = false,
onCompare,
onCloseCompare,
}) => {
// 对比弹窗控制
const { isOpen: isCompareModalOpen, onOpen: openCompareModal, onClose: closeCompareModal } = useDisclosure();
// 处理分享点击
const handleShare = () => {
onShare?.();
};
// 处理对比按钮点击
const handleCompare = (stockCode: string) => {
onCompare?.(stockCode);
openCompareModal();
};
// 处理关闭对比弹窗
const handleCloseCompare = () => {
closeCompareModal();
onCloseCompare?.();
};
// 黑金主题颜色配置
const cardBg = '#1A202C';
const borderColor = '#C9A961';
const labelColor = '#C9A961';
const valueColor = '#F4D03F';
const sectionTitleColor = '#F4D03F';
// 涨跌颜色(红涨绿跌)
const upColor = '#F44336'; // 涨 - 红色
const downColor = '#4CAF50'; // 跌 - 绿色
// 加载中或无数据时显示骨架屏
if (isLoading || !data) {
return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody>
<VStack spacing={4} align="stretch">
<Skeleton height="30px" width="200px" />
<Skeleton height="60px" />
<Skeleton height="80px" />
</VStack>
</CardBody>
</Card>
);
}
const priceColor = data.changePercent >= 0 ? upColor : downColor;
const inflowColor = data.mainNetInflow >= 0 ? upColor : downColor;
return (
<Card bg={cardBg} shadow="sm" borderWidth="1px" borderColor={borderColor}>
<CardBody>
{/* 顶部:股票名称 + 关注/分享按钮 + 更新时间 */}
<Flex justify="space-between" align="center" mb={4}>
{/* 左侧:股票名称 + 行业标签 + 指数标签 */}
<HStack spacing={3} align="center">
{/* 股票名称 - 突出显示 */}
<Text fontSize="26px" fontWeight="800" color={valueColor}>
{data.name}
</Text>
<Text fontSize="18px" fontWeight="normal" color={labelColor}>
({data.code})
</Text>
{/* 行业标签 */}
{(data.industryL1 || data.industry) && (
<Badge
bg="transparent"
color={labelColor}
fontSize="14px"
fontWeight="medium"
border="1px solid"
borderColor={borderColor}
px={2}
py={0.5}
borderRadius="md"
>
{data.industryL1 && data.industry
? `${data.industryL1} · ${data.industry}`
: data.industry || data.industryL1}
</Badge>
)}
{/* 指数标签 */}
{data.indexTags?.length > 0 && (
<Text fontSize="14px" color={labelColor}>
{data.indexTags.join('、')}
</Text>
)}
</HStack>
{/* 右侧:对比 + 关注 + 分享 + 时间 */}
<HStack spacing={3}>
{/* 股票对比输入 */}
<CompareStockInput
onCompare={handleCompare}
isLoading={isCompareLoading}
currentStockCode={data.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={labelColor}
size="sm"
onClick={handleShare}
_hover={{ bg: 'whiteAlpha.100' }}
/>
</Tooltip>
<Text fontSize="14px" color={labelColor}>
{data.updateTime?.split(' ')[1] || '--:--'}
</Text>
</HStack>
</Flex>
{/* 股票对比弹窗 */}
<StockCompareModal
isOpen={isCompareModalOpen}
onClose={handleCloseCompare}
currentStock={data.code}
currentStockInfo={currentStockInfo || null}
compareStock={compareStockInfo?.stock_code || ''}
compareStockInfo={compareStockInfo || null}
isLoading={isCompareLoading}
/>
{/* 1:2 布局 */}
<Flex gap={8}>
{/* 左栏:价格信息 (flex=1) */}
<Box flex="1" minWidth="0">
<HStack align="baseline" spacing={3} mb={3}>
<Text fontSize="48px" fontWeight="bold" color={priceColor}>
{formatPrice(data.currentPrice)}
</Text>
<Badge
bg={data.changePercent >= 0 ? upColor : downColor}
color="#FFFFFF"
fontSize="20px"
fontWeight="bold"
px={3}
py={1}
borderRadius="md"
>
{formatChangePercent(data.changePercent)}
</Badge>
</HStack>
{/* 次要行情:今开 | 昨收 | 最高 | 最低 */}
<HStack spacing={4} fontSize="14px" flexWrap="wrap">
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(data.todayOpen)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={valueColor} fontWeight="bold">
{formatPrice(data.yesterdayClose)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={upColor} fontWeight="bold">
{formatPrice(data.todayHigh)}
</Text>
</Text>
<Text color={borderColor}>|</Text>
<Text color={labelColor}>
<Text as="span" color={downColor} fontWeight="bold">
{formatPrice(data.todayLow)}
</Text>
</Text>
</HStack>
</Box>
{/* 右栏:关键指标 + 主力动态 (flex=2) */}
<Flex flex="2" minWidth="0" gap={8} borderLeftWidth="1px" borderColor={borderColor} pl={8}>
{/* 关键指标 */}
<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">
{data.pe.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>(PB)</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.pb.toFixed(2)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.marketCap}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}>52</Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{formatPrice(data.week52Low)}-{formatPrice(data.week52High)}
</Text>
</HStack>
</VStack>
</Box>
{/* 主力动态 */}
<Box flex="1" borderLeftWidth="1px" borderColor={borderColor} pl={8}>
<Text
fontSize="14px"
fontWeight="bold"
color={sectionTitleColor}
mb={3}
>
</Text>
<VStack align="stretch" spacing={2} fontSize="14px">
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={inflowColor} fontWeight="bold" fontSize="16px">
{formatNetInflow(data.mainNetInflow)}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={labelColor}></Text>
<Text color={valueColor} fontWeight="bold" fontSize="16px">
{data.institutionHolding.toFixed(2)}%
</Text>
</HStack>
{/* 买卖比例条 */}
<Box mt={1}>
<Progress
value={data.buyRatio}
size="sm"
sx={{
'& > div': { bg: upColor },
}}
bg={downColor}
borderRadius="full"
/>
<HStack justify="space-between" mt={1} fontSize="14px">
<Text color={upColor}>{data.buyRatio}%</Text>
<Text color={downColor}>{data.sellRatio}%</Text>
</HStack>
</Box>
</VStack>
</Box>
</Flex>
</Flex>
{/* 公司信息区块 - 1:2 布局 */}
{basicInfo && (
<>
<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>
</>
)}
</CardBody>
</Card>
);
};
export default StockQuoteCard;