Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui: fix: 导航效果UI修复 feat: 个股添加个股列表弹窗 fix: 概念中心UI fix: 个股中心页面日期数据源统一 fix: 修改的后端代码 /api/market/statistics 接口 添加日期格式化逻辑 //api/concepts/daily-top 添加日期格式化逻辑 /api/market/heatmap 接口 已经有正确的格式化
This commit is contained in:
@@ -1463,7 +1463,7 @@ const ConceptCenter = () => {
|
||||
fontSize="md"
|
||||
transition="all 0.2s"
|
||||
border="none"
|
||||
height="100%"
|
||||
alignSelf="stretch"
|
||||
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
|
||||
>
|
||||
搜索
|
||||
|
||||
233
src/views/StockOverview/components/ConceptStocksModal.tsx
Normal file
233
src/views/StockOverview/components/ConceptStocksModal.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Button,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
} 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;
|
||||
[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');
|
||||
|
||||
// 批量获取股票行情数据
|
||||
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="4xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={cardBg}>
|
||||
<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="60vh" overflowY="auto">
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead position="sticky" top={0} bg={cardBg} zIndex={1}>
|
||||
<Tr>
|
||||
<Th>股票名称</Th>
|
||||
<Th>股票代码</Th>
|
||||
<Th isNumeric>现价</Th>
|
||||
<Th isNumeric>涨跌幅</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>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button colorScheme="purple" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptStocksModal;
|
||||
@@ -56,10 +56,15 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
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 ConceptStocksModal from './components/ConceptStocksModal';
|
||||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import * as echarts from 'echarts';
|
||||
import { logger } from '../../utils/logger';
|
||||
import tradingDays from '../../data/tradingDays.json';
|
||||
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
|
||||
|
||||
// 交易日 Set,用于快速查找
|
||||
const tradingDaysSet = new Set(tradingDays);
|
||||
// Navigation bar now provided by MainLayout
|
||||
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
|
||||
@@ -98,6 +103,10 @@ const StockOverview = () => {
|
||||
const [availableDates, setAvailableDates] = useState([]);
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
|
||||
// 个股列表弹窗状态
|
||||
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
|
||||
const [selectedConcept, setSelectedConcept] = useState(null);
|
||||
|
||||
// 专业的颜色主题
|
||||
const bgColor = useColorModeValue('white', '#0a0a0a');
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
@@ -110,6 +119,13 @@ const StockOverview = () => {
|
||||
const accentColor = useColorModeValue('purple.600', goldColor);
|
||||
const heroBg = useColorModeValue('linear(to-br, purple.600, pink.500)', 'linear(to-br, #0a0a0a, #1a1a1a)');
|
||||
|
||||
// 打开个股列表弹窗
|
||||
const handleViewStocks = useCallback((e, concept) => {
|
||||
e.stopPropagation();
|
||||
setSelectedConcept(concept);
|
||||
setIsStockModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// 防抖搜索
|
||||
const debounceSearch = useCallback(
|
||||
(() => {
|
||||
@@ -173,7 +189,27 @@ const StockOverview = () => {
|
||||
|
||||
if (data.success) {
|
||||
setTopConcepts(data.data);
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
// 使用概念接口的日期作为统一数据源(数据最新)
|
||||
setSelectedDate(data.trade_date);
|
||||
// 基于交易日历生成可选日期列表
|
||||
if (data.trade_date && tradingDays.length > 0) {
|
||||
// 找到当前日期或最近的交易日
|
||||
let targetDate = data.trade_date;
|
||||
if (!tradingDaysSet.has(data.trade_date)) {
|
||||
for (let i = tradingDays.length - 1; i >= 0; i--) {
|
||||
if (tradingDays[i] <= data.trade_date) {
|
||||
targetDate = tradingDays[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const idx = tradingDays.indexOf(targetDate);
|
||||
if (idx !== -1) {
|
||||
const startIdx = Math.max(0, idx - 19);
|
||||
const dates = tradingDays.slice(startIdx, idx + 1).reverse();
|
||||
setAvailableDates(dates);
|
||||
}
|
||||
}
|
||||
logger.debug('StockOverview', '热门概念加载成功', {
|
||||
count: data.data?.length || 0,
|
||||
date: data.trade_date
|
||||
@@ -204,7 +240,7 @@ const StockOverview = () => {
|
||||
falling_count: data.statistics.falling_count
|
||||
}));
|
||||
}
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
// 日期由 fetchTopConcepts 统一设置,这里不再设置
|
||||
logger.debug('StockOverview', '热力图数据加载成功', {
|
||||
count: data.data?.length || 0,
|
||||
date: data.trade_date
|
||||
@@ -235,11 +271,9 @@ const StockOverview = () => {
|
||||
date: data.trade_date
|
||||
};
|
||||
setMarketStats(newStats);
|
||||
setAvailableDates(data.available_dates || []);
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
// 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置
|
||||
logger.debug('StockOverview', '市场统计数据加载成功', {
|
||||
date: data.trade_date,
|
||||
availableDatesCount: data.available_dates?.length || 0
|
||||
date: data.trade_date
|
||||
});
|
||||
|
||||
// 🎯 追踪市场统计数据查看
|
||||
@@ -974,31 +1008,33 @@ const StockOverview = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box w="100%">
|
||||
<Box
|
||||
w="100%"
|
||||
cursor="pointer"
|
||||
onClick={(e) => handleViewStocks(e, concept)}
|
||||
_hover={{ bg: hoverBg }}
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||
包含 {concept.stock_count} 只个股
|
||||
</Text>
|
||||
|
||||
|
||||
{concept.stocks && concept.stocks.length > 0 && (
|
||||
<Flex flexWrap="wrap" gap={2}>
|
||||
<Flex
|
||||
flexWrap="nowrap"
|
||||
gap={2}
|
||||
overflow="hidden"
|
||||
maxH="24px"
|
||||
>
|
||||
{concept.stocks.map((stock, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
variant="subtle"
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 🎯 追踪概念下的股票标签点击
|
||||
trackConceptStockClicked({
|
||||
code: stock.stock_code,
|
||||
name: stock.stock_name
|
||||
}, concept.concept_name);
|
||||
|
||||
navigate(`/company?scode=${stock.stock_code}`);
|
||||
}}
|
||||
flexShrink={0}
|
||||
>
|
||||
<TagLabel>{stock.stock_name}</TagLabel>
|
||||
</Tag>
|
||||
@@ -1098,7 +1134,14 @@ const StockOverview = () => {
|
||||
</Card>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
|
||||
{/* 个股列表弹窗 */}
|
||||
<ConceptStocksModal
|
||||
isOpen={isStockModalOpen}
|
||||
onClose={() => setIsStockModalOpen(false)}
|
||||
concept={selectedConcept}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user