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:
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user