- 将 ConceptStocksModal 从 StockOverview/components 移到 components 目录 - 概念中心复用 ConceptStocksModal,删除冗余的 renderStockTable 函数(约100行) - 统一 H5 端弹窗体验:响应式尺寸、高度限制(70vh)、左右滑动、垂直居中 - 移除重复的底部关闭按钮,只保留右上角关闭按钮 - 添加"板块原因"列,表头改为中文 - 使用 @components 路径别名 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
240 lines
7.7 KiB
TypeScript
240 lines
7.7 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import {
|
||
Modal,
|
||
ModalOverlay,
|
||
ModalContent,
|
||
ModalHeader,
|
||
ModalCloseButton,
|
||
ModalBody,
|
||
Table,
|
||
Thead,
|
||
Tbody,
|
||
Tr,
|
||
Th,
|
||
Td,
|
||
TableContainer,
|
||
Box,
|
||
HStack,
|
||
Text,
|
||
Icon,
|
||
Spinner,
|
||
useColorModeValue,
|
||
useBreakpointValue,
|
||
} from '@chakra-ui/react';
|
||
import { FaTable } from 'react-icons/fa';
|
||
import marketService from '@services/marketService';
|
||
import { logger } from '@utils/logger';
|
||
|
||
// 股票信息类型
|
||
interface StockInfo {
|
||
stock_code: string;
|
||
stock_name: string;
|
||
reason?: string;
|
||
change_pct?: number;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
// 概念信息类型
|
||
export interface ConceptInfo {
|
||
concept_id?: string;
|
||
concept_name: string;
|
||
stock_count?: number;
|
||
stocks?: StockInfo[];
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
// 行情数据类型
|
||
interface MarketData {
|
||
stock_code: string;
|
||
close?: number;
|
||
change_percent?: number;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface ConceptStocksModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
concept: ConceptInfo | null;
|
||
}
|
||
|
||
const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
|
||
isOpen,
|
||
onClose,
|
||
concept,
|
||
}) => {
|
||
const navigate = useNavigate();
|
||
|
||
// 状态
|
||
const [stockMarketData, setStockMarketData] = useState<Record<string, MarketData>>({});
|
||
const [loadingStockData, setLoadingStockData] = useState(false);
|
||
|
||
// 颜色主题
|
||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||
const hoverBg = useColorModeValue('gray.50', '#2a2a2a');
|
||
|
||
// 响应式配置 - 添加 fallback 避免首次渲染时返回 undefined 导致弹窗异常
|
||
const isMobile = useBreakpointValue({ base: true, md: false }, { fallback: 'md' });
|
||
// H5 使用 xl 而非 full,配合 maxH 限制高度
|
||
const modalSize = useBreakpointValue({ base: 'xl', md: '4xl' }, { fallback: 'md' });
|
||
const tableMaxH = useBreakpointValue({ base: '45vh', md: '60vh' }, { fallback: 'md' });
|
||
|
||
// 批量获取股票行情数据
|
||
const fetchStockMarketData = useCallback(async (stocks: StockInfo[]) => {
|
||
if (!stocks || stocks.length === 0) return;
|
||
|
||
setLoadingStockData(true);
|
||
const newMarketData: Record<string, MarketData> = {};
|
||
|
||
try {
|
||
const batchSize = 5;
|
||
for (let i = 0; i < stocks.length; i += batchSize) {
|
||
const batch = stocks.slice(i, i + batchSize);
|
||
const promises = batch.map(async (stock) => {
|
||
if (!stock.stock_code) return null;
|
||
const seccode = stock.stock_code.substring(0, 6);
|
||
try {
|
||
const response = await marketService.getTradeData(seccode, 1);
|
||
if (response.success && response.data?.length > 0) {
|
||
const latestData = response.data[response.data.length - 1];
|
||
return { stock_code: stock.stock_code, ...latestData };
|
||
}
|
||
} catch (error) {
|
||
logger.warn('ConceptStocksModal', '获取股票行情失败', { stockCode: seccode });
|
||
}
|
||
return null;
|
||
});
|
||
|
||
const results = await Promise.all(promises);
|
||
results.forEach((result) => {
|
||
if (result) newMarketData[result.stock_code] = result;
|
||
});
|
||
}
|
||
setStockMarketData(newMarketData);
|
||
} catch (error) {
|
||
logger.error('ConceptStocksModal', 'fetchStockMarketData', error);
|
||
} finally {
|
||
setLoadingStockData(false);
|
||
}
|
||
}, []);
|
||
|
||
// 弹窗打开时加载数据
|
||
React.useEffect(() => {
|
||
if (isOpen && concept?.stocks) {
|
||
setStockMarketData({});
|
||
fetchStockMarketData(concept.stocks);
|
||
}
|
||
}, [isOpen, concept, fetchStockMarketData]);
|
||
|
||
// 点击股票行
|
||
const handleStockClick = (stockCode: string) => {
|
||
navigate(`/company?scode=${stockCode}`);
|
||
onClose();
|
||
};
|
||
|
||
const stocks = concept?.stocks || [];
|
||
|
||
return (
|
||
<Modal
|
||
isOpen={isOpen}
|
||
onClose={onClose}
|
||
size={modalSize}
|
||
scrollBehavior="inside"
|
||
isCentered
|
||
>
|
||
<ModalOverlay />
|
||
<ModalContent bg={cardBg} maxH={isMobile ? '70vh' : undefined}>
|
||
<ModalHeader bg="purple.500" color="white" borderTopRadius="md">
|
||
<HStack>
|
||
<Icon as={FaTable} />
|
||
<Text>{concept?.concept_name} - 相关个股</Text>
|
||
</HStack>
|
||
</ModalHeader>
|
||
<ModalCloseButton color="white" />
|
||
|
||
<ModalBody py={6}>
|
||
{stocks.length === 0 ? (
|
||
<Text color="gray.500" textAlign="center">暂无相关股票数据</Text>
|
||
) : (
|
||
<Box>
|
||
{loadingStockData && (
|
||
<HStack justify="center" mb={4}>
|
||
<Spinner size="sm" color="purple.500" />
|
||
<Text fontSize="sm" color="gray.500">正在获取行情数据...</Text>
|
||
</HStack>
|
||
)}
|
||
|
||
<TableContainer maxH={tableMaxH} overflowY="auto" overflowX="auto">
|
||
<Table variant="simple" size="sm" minW={isMobile ? '600px' : undefined}>
|
||
<Thead position="sticky" top={0} bg={cardBg} zIndex={1}>
|
||
<Tr>
|
||
<Th whiteSpace="nowrap">股票名称</Th>
|
||
<Th whiteSpace="nowrap">股票代码</Th>
|
||
<Th isNumeric whiteSpace="nowrap">现价</Th>
|
||
<Th isNumeric whiteSpace="nowrap">当日涨跌幅</Th>
|
||
<Th whiteSpace="nowrap" minW="200px">板块原因</Th>
|
||
</Tr>
|
||
</Thead>
|
||
<Tbody>
|
||
{stocks.map((stock, idx) => {
|
||
const marketData = stockMarketData[stock.stock_code];
|
||
const changePercent = marketData?.change_percent;
|
||
|
||
return (
|
||
<Tr
|
||
key={idx}
|
||
_hover={{ bg: hoverBg }}
|
||
cursor="pointer"
|
||
onClick={() => handleStockClick(stock.stock_code)}
|
||
>
|
||
<Td color="blue.500" fontWeight="medium">
|
||
{stock.stock_name}
|
||
</Td>
|
||
<Td>{stock.stock_code}</Td>
|
||
<Td isNumeric>
|
||
{loadingStockData ? (
|
||
<Spinner size="xs" />
|
||
) : marketData?.close ? (
|
||
`¥${marketData.close.toFixed(2)}`
|
||
) : (
|
||
'-'
|
||
)}
|
||
</Td>
|
||
<Td
|
||
isNumeric
|
||
fontWeight="bold"
|
||
color={
|
||
changePercent && changePercent > 0
|
||
? 'red.500'
|
||
: changePercent && changePercent < 0
|
||
? 'green.500'
|
||
: 'gray.500'
|
||
}
|
||
>
|
||
{loadingStockData ? (
|
||
<Spinner size="xs" />
|
||
) : changePercent !== undefined ? (
|
||
`${changePercent > 0 ? '+' : ''}${changePercent.toFixed(2)}%`
|
||
) : (
|
||
'-'
|
||
)}
|
||
</Td>
|
||
<Td fontSize="xs" color="gray.600" maxW="300px">
|
||
<Text noOfLines={2}>{stock.reason || '-'}</Text>
|
||
</Td>
|
||
</Tr>
|
||
);
|
||
})}
|
||
</Tbody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Box>
|
||
)}
|
||
</ModalBody>
|
||
</ModalContent>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default ConceptStocksModal;
|