refactor: 提取 ConceptStocksModal 为通用组件,统一概念中心和个股中心弹窗

- 将 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>
This commit is contained in:
zdl
2025-12-04 15:57:32 +08:00
parent b1d5b217d3
commit 5efd598694
3 changed files with 31 additions and 144 deletions

View File

@@ -7,8 +7,6 @@ import {
ModalHeader, ModalHeader,
ModalCloseButton, ModalCloseButton,
ModalBody, ModalBody,
ModalFooter,
Button,
Table, Table,
Thead, Thead,
Tbody, Tbody,
@@ -22,6 +20,7 @@ import {
Icon, Icon,
Spinner, Spinner,
useColorModeValue, useColorModeValue,
useBreakpointValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FaTable } from 'react-icons/fa'; import { FaTable } from 'react-icons/fa';
import marketService from '@services/marketService'; import marketService from '@services/marketService';
@@ -31,6 +30,8 @@ import { logger } from '@utils/logger';
interface StockInfo { interface StockInfo {
stock_code: string; stock_code: string;
stock_name: string; stock_name: string;
reason?: string;
change_pct?: number;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -72,6 +73,12 @@ const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
const cardBg = useColorModeValue('white', '#1a1a1a'); const cardBg = useColorModeValue('white', '#1a1a1a');
const hoverBg = useColorModeValue('gray.50', '#2a2a2a'); 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[]) => { const fetchStockMarketData = useCallback(async (stocks: StockInfo[]) => {
if (!stocks || stocks.length === 0) return; if (!stocks || stocks.length === 0) return;
@@ -131,11 +138,12 @@ const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
size="4xl" size={modalSize}
scrollBehavior="inside" scrollBehavior="inside"
isCentered
> >
<ModalOverlay /> <ModalOverlay />
<ModalContent bg={cardBg}> <ModalContent bg={cardBg} maxH={isMobile ? '70vh' : undefined}>
<ModalHeader bg="purple.500" color="white" borderTopRadius="md"> <ModalHeader bg="purple.500" color="white" borderTopRadius="md">
<HStack> <HStack>
<Icon as={FaTable} /> <Icon as={FaTable} />
@@ -156,14 +164,15 @@ const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
</HStack> </HStack>
)} )}
<TableContainer maxH="60vh" overflowY="auto"> <TableContainer maxH={tableMaxH} overflowY="auto" overflowX="auto">
<Table variant="simple" size="sm"> <Table variant="simple" size="sm" minW={isMobile ? '600px' : undefined}>
<Thead position="sticky" top={0} bg={cardBg} zIndex={1}> <Thead position="sticky" top={0} bg={cardBg} zIndex={1}>
<Tr> <Tr>
<Th></Th> <Th whiteSpace="nowrap"></Th>
<Th></Th> <Th whiteSpace="nowrap"></Th>
<Th isNumeric></Th> <Th isNumeric whiteSpace="nowrap"></Th>
<Th isNumeric></Th> <Th isNumeric whiteSpace="nowrap"></Th>
<Th whiteSpace="nowrap" minW="200px"></Th>
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
@@ -210,6 +219,9 @@ const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
'-' '-'
)} )}
</Td> </Td>
<Td fontSize="xs" color="gray.600" maxW="300px">
<Text noOfLines={2}>{stock.reason || '-'}</Text>
</Td>
</Tr> </Tr>
); );
})} })}
@@ -219,12 +231,6 @@ const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
</Box> </Box>
)} )}
</ModalBody> </ModalBody>
<ModalFooter>
<Button colorScheme="purple" onClick={onClose}>
</Button>
</ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
); );

View File

@@ -86,6 +86,7 @@ import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import { keyframes } from '@emotion/react'; import { keyframes } from '@emotion/react';
import ConceptTimelineModal from './ConceptTimelineModal'; import ConceptTimelineModal from './ConceptTimelineModal';
import ConceptStatsPanel from './components/ConceptStatsPanel'; import ConceptStatsPanel from './components/ConceptStatsPanel';
import ConceptStocksModal from '@components/ConceptStocksModal';
// 导航栏已由 MainLayout 提供,无需在此导入 // 导航栏已由 MainLayout 提供,无需在此导入
// 导入订阅权限管理 // 导入订阅权限管理
import { useSubscription } from '../../hooks/useSubscription'; import { useSubscription } from '../../hooks/useSubscription';
@@ -528,109 +529,6 @@ const ConceptCenter = () => {
return `https://valuefrontier.cn/company?scode=${seccode}`; return `https://valuefrontier.cn/company?scode=${seccode}`;
}; };
// 渲染动态表格列
const renderStockTable = () => {
if (!selectedConceptStocks || selectedConceptStocks.length === 0) {
return <Text>暂无相关股票数据</Text>;
}
const allFields = new Set();
selectedConceptStocks.forEach(stock => {
Object.keys(stock).forEach(key => allFields.add(key));
});
// 定义固定的列顺序,包含新增的现价和涨跌幅列
const orderedFields = ['stock_name', 'stock_code', 'current_price', 'change_percent'];
allFields.forEach(field => {
if (!orderedFields.includes(field)) {
orderedFields.push(field);
}
});
return (
<Box>
{loadingStockData && (
<Box mb={4} textAlign="center">
<HStack justify="center" spacing={2}>
<Spinner size="sm" color="purple.500" />
<Text fontSize="sm" color="gray.600">正在获取行情数据...</Text>
</HStack>
</Box>
)}
<TableContainer maxH="60vh" overflowY="auto">
<Table variant="simple" size="sm">
<Thead position="sticky" top={0} bg="white" zIndex={1}>
<Tr>
{orderedFields.map(field => (
<Th key={field}>
{field === 'stock_name' ? '股票名称' :
field === 'stock_code' ? '股票代码' :
field === 'current_price' ? '现价' :
field === 'change_percent' ? '当日涨跌幅' : field}
</Th>
))}
</Tr>
</Thead>
<Tbody>
{selectedConceptStocks.map((stock, idx) => {
const marketData = stockMarketData[stock.stock_code];
const companyLink = generateCompanyLink(stock.stock_code);
return (
<Tr key={idx} _hover={{ bg: 'gray.50' }}>
{orderedFields.map(field => {
let cellContent = stock[field] || '-';
let cellProps = {};
// 处理特殊字段
if (field === 'current_price') {
cellContent = marketData ? formatPrice(marketData.close) : (loadingStockData ? <Spinner size="xs" /> : '-');
} else if (field === 'change_percent') {
if (marketData) {
cellContent = formatStockChangePercent(marketData.change_percent);
cellProps.color = `${getStockChangeColor(marketData.change_percent)}.500`;
cellProps.fontWeight = 'bold';
} else {
cellContent = loadingStockData ? <Spinner size="xs" /> : '-';
}
} else if (field === 'stock_name' || field === 'stock_code') {
// 添加超链接
cellContent = (
<Text
as="a"
href={companyLink}
target="_blank"
rel="noopener noreferrer"
color="blue.600"
textDecoration="underline"
_hover={{
color: 'blue.800',
textDecoration: 'underline'
}}
cursor="pointer"
>
{stock[field] || '-'}
</Text>
);
}
return (
<Td key={field} {...cellProps}>
{cellContent}
</Td>
);
})}
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
</Box>
);
};
// 格式化添加日期显示 // 格式化添加日期显示
const formatAddedDate = (concept) => { const formatAddedDate = (concept) => {
// 优先使用 created_at 或 added_date 字段 // 优先使用 created_at 或 added_date 字段
@@ -1763,32 +1661,15 @@ const ConceptCenter = () => {
</Flex> </Flex>
</Container> </Container>
{/* 股票详情Modal */} {/* 股票详情Modal - 复用通用组件 */}
<Modal <ConceptStocksModal
isOpen={isStockModalOpen} isOpen={isStockModalOpen}
onClose={() => setIsStockModalOpen(false)} onClose={() => setIsStockModalOpen(false)}
size="6xl" concept={{
scrollBehavior="inside" concept_name: selectedConceptName,
> stocks: selectedConceptStocks
<ModalOverlay /> }}
<ModalContent> />
<ModalHeader bg="purple.500" color="white">
<HStack>
<Icon as={FaTable} />
<Text>{selectedConceptName} - 相关个股</Text>
</HStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6}>
{renderStockTable()}
</ModalBody>
<ModalFooter>
<Button colorScheme="purple" onClick={() => setIsStockModalOpen(false)}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 时间轴Modal */} {/* 时间轴Modal */}
<ConceptTimelineModal <ConceptTimelineModal
isOpen={isTimelineModalOpen} isOpen={isTimelineModalOpen}

View File

@@ -56,7 +56,7 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons'; import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons';
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa'; import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
import ConceptStocksModal from './components/ConceptStocksModal'; import ConceptStocksModal from '@components/ConceptStocksModal';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs'; import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';