feat(StockQuoteCard): 新增股票对比功能

- 新增 CompareStockInput: 股票搜索输入组件
 - 新增 StockCompareModal: 股票对比弹窗
 - 更新类型定义支持对比功能

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

 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-16 16:15:36 +08:00
parent 804de885e1
commit b25d48e167
5 changed files with 521 additions and 1 deletions

View File

@@ -0,0 +1,220 @@
/**
* CompareStockInput - 对比股票输入组件
* 紧凑型输入框,支持模糊搜索下拉
*/
import React, { useState, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import {
Box,
HStack,
Input,
InputGroup,
InputLeftElement,
Button,
Text,
VStack,
Spinner,
} from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons';
import { BarChart2 } from 'lucide-react';
interface CompareStockInputProps {
onCompare: (stockCode: string) => void;
isLoading?: boolean;
currentStockCode?: string;
}
interface Stock {
code: string;
name: string;
}
interface RootState {
stock: {
allStocks: Stock[];
};
}
const CompareStockInput: React.FC<CompareStockInputProps> = ({
onCompare,
isLoading = false,
currentStockCode,
}) => {
const [inputValue, setInputValue] = useState('');
const [showDropdown, setShowDropdown] = useState(false);
const [filteredStocks, setFilteredStocks] = useState<Stock[]>([]);
const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 从 Redux 获取全部股票列表
const allStocks = useSelector((state: RootState) => state.stock.allStocks);
// 黑金主题颜色
const borderColor = '#C9A961';
const goldColor = '#F4D03F';
const bgColor = '#1A202C';
// 模糊搜索过滤
useEffect(() => {
if (inputValue && inputValue.trim()) {
const searchTerm = inputValue.trim().toLowerCase();
const filtered = allStocks
.filter(
(stock) =>
stock.code !== currentStockCode && // 排除当前股票
(stock.code.toLowerCase().includes(searchTerm) ||
stock.name.includes(inputValue.trim()))
)
.slice(0, 8); // 限制显示8条
setFilteredStocks(filtered);
setShowDropdown(filtered.length > 0);
} else {
setFilteredStocks([]);
setShowDropdown(false);
}
}, [inputValue, allStocks, currentStockCode]);
// 点击外部关闭下拉
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 选择股票
const handleSelectStock = (stock: Stock) => {
setSelectedStock(stock);
setInputValue(stock.name);
setShowDropdown(false);
};
// 处理对比按钮点击
const handleCompare = () => {
if (selectedStock) {
onCompare(selectedStock.code);
} else if (inputValue.trim().length === 6 && /^\d{6}$/.test(inputValue.trim())) {
// 如果直接输入了6位数字代码
onCompare(inputValue.trim());
}
};
// 处理键盘事件
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
setShowDropdown(false);
handleCompare();
}
};
const isButtonDisabled = !selectedStock && !(inputValue.trim().length === 6 && /^\d{6}$/.test(inputValue.trim()));
return (
<Box ref={containerRef} position="relative">
<HStack spacing={2}>
<InputGroup size="sm" w="160px">
<InputLeftElement pointerEvents="none">
<SearchIcon color={borderColor} boxSize={3} />
</InputLeftElement>
<Input
placeholder="对比股票"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setSelectedStock(null);
}}
onKeyDown={handleKeyDown}
onFocus={() => inputValue && filteredStocks.length > 0 && setShowDropdown(true)}
borderRadius="md"
color="white"
fontSize="sm"
borderColor={borderColor}
bg="transparent"
_placeholder={{ color: borderColor, fontSize: 'sm' }}
_focus={{
borderColor: goldColor,
boxShadow: `0 0 0 1px ${goldColor}`,
}}
_hover={{
borderColor: goldColor,
}}
/>
</InputGroup>
<Button
size="sm"
leftIcon={isLoading ? <Spinner size="xs" /> : <BarChart2 size={14} />}
onClick={handleCompare}
isDisabled={isButtonDisabled || isLoading}
bg="transparent"
color={goldColor}
border="1px solid"
borderColor={borderColor}
_hover={{
bg: 'whiteAlpha.100',
borderColor: goldColor,
}}
_disabled={{
opacity: 0.5,
cursor: 'not-allowed',
}}
fontSize="sm"
px={3}
>
</Button>
</HStack>
{/* 模糊搜索下拉列表 */}
{showDropdown && (
<Box
position="absolute"
top="100%"
left={0}
mt={1}
w="220px"
bg={bgColor}
border="1px solid"
borderColor={borderColor}
borderRadius="md"
maxH="240px"
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={3}
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={goldColor} fontWeight="bold" fontSize="xs">
{stock.code}
</Text>
<Text color={borderColor} fontSize="xs" noOfLines={1} maxW="120px">
{stock.name}
</Text>
</HStack>
</Box>
))}
</VStack>
</Box>
)}
</Box>
);
};
export default CompareStockInput;

View File

@@ -0,0 +1,244 @@
/**
* StockCompareModal - 股票对比弹窗组件
* 展示对比明细、盈利能力对比、成长力对比
*/
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
VStack,
HStack,
Grid,
GridItem,
Card,
CardHeader,
CardBody,
Heading,
Text,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Spinner,
Center,
} from '@chakra-ui/react';
import { ArrowUpIcon, ArrowDownIcon } from '@chakra-ui/icons';
import ReactECharts from 'echarts-for-react';
import { COMPARE_METRICS } from '../../FinancialPanorama/constants';
import { getValueByPath, getCompareBarChartOption } from '../../FinancialPanorama/utils';
import { formatUtils } from '@services/financialService';
import type { StockInfo } from '../../FinancialPanorama/types';
interface StockCompareModalProps {
isOpen: boolean;
onClose: () => void;
currentStock: string;
currentStockInfo: StockInfo | null;
compareStock: string;
compareStockInfo: StockInfo | null;
isLoading?: boolean;
}
const StockCompareModal: React.FC<StockCompareModalProps> = ({
isOpen,
onClose,
currentStock,
currentStockInfo,
compareStock,
compareStockInfo,
isLoading = false,
}) => {
// 黑金主题颜色
const bgColor = '#1A202C';
const borderColor = '#C9A961';
const goldColor = '#F4D03F';
const positiveColor = '#EF4444'; // 红涨
const negativeColor = '#10B981'; // 绿跌
// 加载中或无数据时的显示
if (isLoading || !currentStockInfo || !compareStockInfo) {
return (
<Modal isOpen={isOpen} onClose={onClose} size="5xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent bg={bgColor} borderColor={borderColor} borderWidth="1px">
<ModalHeader color={goldColor}></ModalHeader>
<ModalCloseButton color={borderColor} />
<ModalBody pb={6}>
<Center py={20}>
{isLoading ? (
<VStack spacing={4}>
<Spinner size="xl" color={goldColor} />
<Text color={borderColor}>...</Text>
</VStack>
) : (
<Text color={borderColor}></Text>
)}
</Center>
</ModalBody>
</ModalContent>
</Modal>
);
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="5xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent bg={bgColor} borderColor={borderColor} borderWidth="1px">
<ModalHeader color={goldColor}>
{currentStockInfo?.stock_name} ({currentStock}) vs {compareStockInfo?.stock_name} ({compareStock})
</ModalHeader>
<ModalCloseButton color={borderColor} />
<ModalBody pb={6}>
<VStack spacing={6} align="stretch">
{/* 对比明细表格 */}
<Card bg={bgColor} borderColor={borderColor} borderWidth="1px">
<CardHeader pb={2}>
<Heading size="sm" color={goldColor}></Heading>
</CardHeader>
<CardBody pt={0}>
<TableContainer>
<Table size="sm" variant="unstyled">
<Thead>
<Tr borderBottom="1px solid" borderColor={borderColor}>
<Th color={borderColor} fontSize="xs"></Th>
<Th isNumeric color={borderColor} fontSize="xs">{currentStockInfo?.stock_name}</Th>
<Th isNumeric color={borderColor} fontSize="xs">{compareStockInfo?.stock_name}</Th>
<Th isNumeric color={borderColor} fontSize="xs"></Th>
</Tr>
</Thead>
<Tbody>
{COMPARE_METRICS.map((metric) => {
const value1 = getValueByPath<number>(currentStockInfo, metric.path);
const value2 = getValueByPath<number>(compareStockInfo, metric.path);
let diff: number | null = null;
let diffColor = borderColor;
if (value1 !== undefined && value2 !== undefined && value1 !== null && value2 !== null) {
if (metric.format === 'percent') {
diff = value1 - value2;
diffColor = diff > 0 ? positiveColor : negativeColor;
} else if (value2 !== 0) {
diff = ((value1 - value2) / Math.abs(value2)) * 100;
diffColor = diff > 0 ? positiveColor : negativeColor;
}
}
return (
<Tr key={metric.key} borderBottom="1px solid" borderColor="whiteAlpha.100">
<Td color={borderColor} fontSize="sm">{metric.label}</Td>
<Td isNumeric color={goldColor} fontSize="sm">
{metric.format === 'percent'
? formatUtils.formatPercent(value1)
: formatUtils.formatLargeNumber(value1)}
</Td>
<Td isNumeric color={goldColor} fontSize="sm">
{metric.format === 'percent'
? formatUtils.formatPercent(value2)
: formatUtils.formatLargeNumber(value2)}
</Td>
<Td isNumeric color={diffColor} fontSize="sm">
{diff !== null ? (
<HStack spacing={1} justify="flex-end">
{diff > 0 && <ArrowUpIcon boxSize={3} />}
{diff < 0 && <ArrowDownIcon boxSize={3} />}
<Text>
{metric.format === 'percent'
? `${Math.abs(diff).toFixed(2)}pp`
: `${Math.abs(diff).toFixed(2)}%`}
</Text>
</HStack>
) : (
'-'
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
</CardBody>
</Card>
{/* 对比图表 */}
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
<GridItem>
<Card bg={bgColor} borderColor={borderColor} borderWidth="1px">
<CardHeader pb={2}>
<Heading size="sm" color={goldColor}></Heading>
</CardHeader>
<CardBody pt={0}>
<ReactECharts
option={getCompareBarChartOption(
'盈利能力对比',
currentStockInfo?.stock_name || '',
compareStockInfo?.stock_name || '',
['ROE', 'ROA', '毛利率', '净利率'],
[
currentStockInfo?.key_metrics?.roe,
currentStockInfo?.key_metrics?.roa,
currentStockInfo?.key_metrics?.gross_margin,
currentStockInfo?.key_metrics?.net_margin,
],
[
compareStockInfo?.key_metrics?.roe,
compareStockInfo?.key_metrics?.roa,
compareStockInfo?.key_metrics?.gross_margin,
compareStockInfo?.key_metrics?.net_margin,
]
)}
style={{ height: '280px' }}
/>
</CardBody>
</Card>
</GridItem>
<GridItem>
<Card bg={bgColor} borderColor={borderColor} borderWidth="1px">
<CardHeader pb={2}>
<Heading size="sm" color={goldColor}></Heading>
</CardHeader>
<CardBody pt={0}>
<ReactECharts
option={getCompareBarChartOption(
'成长能力对比',
currentStockInfo?.stock_name || '',
compareStockInfo?.stock_name || '',
['营收增长', '利润增长', '资产增长', '股东权益增长'],
[
currentStockInfo?.growth_rates?.revenue_growth,
currentStockInfo?.growth_rates?.profit_growth,
currentStockInfo?.growth_rates?.asset_growth,
currentStockInfo?.growth_rates?.equity_growth,
],
[
compareStockInfo?.growth_rates?.revenue_growth,
compareStockInfo?.growth_rates?.profit_growth,
compareStockInfo?.growth_rates?.asset_growth,
compareStockInfo?.growth_rates?.equity_growth,
]
)}
style={{ height: '280px' }}
/>
</CardBody>
</Card>
</GridItem>
</Grid>
</VStack>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default StockCompareModal;

View File

@@ -0,0 +1,6 @@
/**
* StockQuoteCard 子组件导出
*/
export { default as CompareStockInput } from './CompareStockInput';
export { default as StockCompareModal } from './StockCompareModal';

View File

@@ -21,11 +21,13 @@ import {
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';
/**
@@ -62,12 +64,33 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
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';
@@ -139,8 +162,14 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
)}
</HStack>
{/* 右侧:关注 + 分享 + 时间 */}
{/* 右侧:对比 + 关注 + 分享 + 时间 */}
<HStack spacing={3}>
{/* 股票对比输入 */}
<CompareStockInput
onCompare={handleCompare}
isLoading={isCompareLoading}
currentStockCode={data.code}
/>
<FavoriteButton
isFavorite={isInWatchlist}
isLoading={isWatchlistLoading}
@@ -165,6 +194,17 @@ const StockQuoteCard: React.FC<StockQuoteCardProps> = ({
</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) */}

View File

@@ -3,6 +3,7 @@
*/
import type { BasicInfo } from '../CompanyOverview/types';
import type { StockInfo } from '../FinancialPanorama/types';
/**
* 股票行情卡片数据
@@ -57,4 +58,13 @@ export interface StockQuoteCardProps {
onShare?: () => void; // 分享回调
// 公司基本信息
basicInfo?: BasicInfo;
// 股票对比相关
currentStockInfo?: StockInfo; // 当前股票财务信息(用于对比)
compareStockInfo?: StockInfo; // 对比股票财务信息
isCompareLoading?: boolean; // 对比数据加载中
onCompare?: (stockCode: string) => void; // 触发对比回调
onCloseCompare?: () => void; // 关闭对比弹窗回调
}
// 重新导出 StockInfo 类型以便外部使用
export type { StockInfo };