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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* StockQuoteCard 子组件导出
|
||||
*/
|
||||
|
||||
export { default as CompareStockInput } from './CompareStockInput';
|
||||
export { default as StockCompareModal } from './StockCompareModal';
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user