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:
zdl
2025-12-04 11:53:37 +08:00
7 changed files with 413 additions and 46 deletions

30
app.py
View File

@@ -12232,12 +12232,19 @@ def get_market_statistics():
available_dates = [str(row.TRADEDATE) for row in available_dates_result]
# 格式化日期为 YYYY-MM-DD
formatted_trade_date = trade_date.strftime('%Y-%m-%d') if hasattr(trade_date, 'strftime') else str(trade_date).split(' ')[0][:10]
formatted_available_dates = [
d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d).split(' ')[0][:10]
for d in [row.TRADEDATE for row in available_dates_result]
]
return jsonify({
'success': True,
'trade_date': str(trade_date),
'trade_date': formatted_trade_date,
'summary': summary,
'details': list(statistics.values()),
'available_dates': available_dates
'available_dates': formatted_available_dates
})
except Exception as e:
@@ -12277,19 +12284,30 @@ def get_daily_top_concepts():
top_concepts = []
for concept in data.get('results', []):
# 保持与 /concept-api/search 相同的字段结构
top_concepts.append({
'concept_id': concept.get('concept_id'),
'concept_name': concept.get('concept'),
'concept': concept.get('concept'), # 原始字段名
'concept_name': concept.get('concept'), # 兼容旧字段名
'description': concept.get('description'),
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0),
'stock_count': concept.get('stock_count', 0),
'stocks': concept.get('stocks', [])[:5] # 只返回前5只股票
'score': concept.get('score'),
'match_type': concept.get('match_type'),
'price_info': concept.get('price_info', {}), # 完整的价格信息
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), # 兼容旧字段
'happened_times': concept.get('happened_times', []), # 历史触发时间
'stocks': concept.get('stocks', []), # 返回完整股票列表
'hot_score': concept.get('hot_score')
})
# 格式化日期为 YYYY-MM-DD
price_date = data.get('price_date', '')
formatted_date = str(price_date).split(' ')[0][:10] if price_date else ''
return jsonify({
'success': True,
'data': top_concepts,
'trade_date': data.get('price_date'),
'trade_date': formatted_date,
'count': len(top_concepts)
})
else:

View File

@@ -61,8 +61,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
borderColor={isActive(['/community', '/concepts']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={highFreqMenu.handleMouseEnter}
@@ -128,8 +128,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
borderColor={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={marketReviewMenu.handleMouseEnter}
@@ -204,8 +204,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/agent-chat', '/value-forum']) ? '2px solid' : 'none'}
borderColor={isActive(['/agent-chat', '/value-forum']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={agentCommunityMenu.handleMouseEnter}

File diff suppressed because one or more lines are too long

View File

@@ -94,7 +94,7 @@ export const marketHandlers = [
{ name: '机器人', desc: '人形机器人产业化加速,特斯拉、小米等巨头入局,产业链迎来发展机遇。' },
];
// 股票池
// 股票池(扩展到足够多的股票)
const stockPool = [
{ stock_code: '600519', stock_name: '贵州茅台' },
{ stock_code: '300750', stock_name: '宁德时代' },
@@ -104,30 +104,102 @@ export const marketHandlers = [
{ stock_code: '300274', stock_name: '阳光电源' },
{ stock_code: '688981', stock_name: '中芯国际' },
{ stock_code: '000725', stock_name: '京东方A' },
{ stock_code: '600036', stock_name: '招商银行' },
{ stock_code: '000858', stock_name: '五粮液' },
{ stock_code: '601166', stock_name: '兴业银行' },
{ stock_code: '600276', stock_name: '恒瑞医药' },
{ stock_code: '000333', stock_name: '美的集团' },
{ stock_code: '600887', stock_name: '伊利股份' },
{ stock_code: '002415', stock_name: '海康威视' },
{ stock_code: '601888', stock_name: '中国中免' },
{ stock_code: '300059', stock_name: '东方财富' },
{ stock_code: '002475', stock_name: '立讯精密' },
{ stock_code: '600900', stock_name: '长江电力' },
{ stock_code: '601398', stock_name: '工商银行' },
{ stock_code: '600030', stock_name: '中信证券' },
{ stock_code: '000568', stock_name: '泸州老窖' },
{ stock_code: '002352', stock_name: '顺丰控股' },
{ stock_code: '600809', stock_name: '山西汾酒' },
{ stock_code: '300015', stock_name: '爱尔眼科' },
{ stock_code: '002142', stock_name: '宁波银行' },
{ stock_code: '601899', stock_name: '紫金矿业' },
{ stock_code: '600309', stock_name: '万华化学' },
{ stock_code: '002304', stock_name: '洋河股份' },
{ stock_code: '600585', stock_name: '海螺水泥' },
{ stock_code: '601288', stock_name: '农业银行' },
{ stock_code: '600050', stock_name: '中国联通' },
{ stock_code: '000001', stock_name: '平安银行' },
{ stock_code: '601668', stock_name: '中国建筑' },
{ stock_code: '600028', stock_name: '中国石化' },
{ stock_code: '601857', stock_name: '中国石油' },
{ stock_code: '600000', stock_name: '浦发银行' },
{ stock_code: '601328', stock_name: '交通银行' },
{ stock_code: '000002', stock_name: '万科A' },
{ stock_code: '600104', stock_name: '上汽集团' },
{ stock_code: '601601', stock_name: '中国太保' },
{ stock_code: '600016', stock_name: '民生银行' },
{ stock_code: '601628', stock_name: '中国人寿' },
{ stock_code: '600031', stock_name: '三一重工' },
{ stock_code: '002230', stock_name: '科大讯飞' },
{ stock_code: '300124', stock_name: '汇川技术' },
{ stock_code: '002049', stock_name: '紫光国微' },
{ stock_code: '688012', stock_name: '中微公司' },
{ stock_code: '688008', stock_name: '澜起科技' },
{ stock_code: '603501', stock_name: '韦尔股份' },
];
// 生成历史触发时间
const generateHappenedTimes = (seed) => {
const times = [];
const count = 3 + (seed % 3); // 3-5个时间点
for (let k = 0; k < count; k++) {
const daysAgo = 30 + (seed * 7 + k * 11) % 330;
const d = new Date();
d.setDate(d.getDate() - daysAgo);
times.push(d.toISOString().split('T')[0]);
}
return times.sort().reverse();
};
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
// 生成概念数据
const concepts = [];
for (let i = 0; i < Math.min(limit, conceptPool.length); i++) {
const concept = conceptPool[i];
const changePercent = parseFloat((Math.random() * 8 - 1).toFixed(2)); // -1% ~ 7%
const stockCount = Math.floor(Math.random() * 40) + 20; // 20-60只股票
const stockCount = Math.floor(Math.random() * 20) + 15; // 15-35只股票
// 随机选取3-4只相关股票
// 生成与 stockCount 一致的股票列表(包含完整字段)
const relatedStocks = [];
const stockIndices = new Set();
while (stockIndices.size < Math.min(4, stockPool.length)) {
stockIndices.add(Math.floor(Math.random() * stockPool.length));
for (let j = 0; j < stockCount; j++) {
const idx = (i * 7 + j) % stockPool.length;
const stock = stockPool[idx];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.stock_name,
reason: `作为行业龙头企业,${stock.stock_name}在该领域具有核心竞争优势,市场份额领先。`,
change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2)) // -5% ~ +10%
});
}
stockIndices.forEach(idx => relatedStocks.push(stockPool[idx]));
concepts.push({
concept_id: `CONCEPT_${1001 + i}`,
concept_name: concept.name,
change_percent: changePercent,
stock_count: stockCount,
concept: concept.name, // 原始字段名
concept_name: concept.name, // 兼容字段名
description: concept.desc,
stocks: relatedStocks
stock_count: stockCount,
score: parseFloat((Math.random() * 5 + 3).toFixed(2)), // 3-8 分数
match_type: matchTypes[i % 3],
price_info: {
avg_change_pct: changePercent,
avg_price: parseFloat((Math.random() * 100 + 10).toFixed(2)),
total_market_cap: parseFloat((Math.random() * 1000 + 100).toFixed(2))
},
change_percent: changePercent, // 兼容字段
happened_times: generateHappenedTimes(i),
stocks: relatedStocks,
hot_score: Math.floor(Math.random() * 100)
});
}

View File

@@ -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)"
>
搜索

View 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;

View File

@@ -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>
);
};