diff --git a/app.py b/app.py index b2e2591b..91940dab 100755 --- a/app.py +++ b/app.py @@ -12284,13 +12284,20 @@ 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 diff --git a/src/mocks/handlers/market.js b/src/mocks/handlers/market.js index 28757c99..8602af79 100644 --- a/src/mocks/handlers/market.js +++ b/src/mocks/handlers/market.js @@ -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) }); } diff --git a/src/views/StockOverview/components/ConceptStocksModal.tsx b/src/views/StockOverview/components/ConceptStocksModal.tsx new file mode 100644 index 00000000..998f217e --- /dev/null +++ b/src/views/StockOverview/components/ConceptStocksModal.tsx @@ -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 = ({ + isOpen, + onClose, + concept, +}) => { + const navigate = useNavigate(); + + // 状态 + const [stockMarketData, setStockMarketData] = useState>({}); + 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 = {}; + + 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 ( + + + + + + + {concept?.concept_name} - 相关个股 + + + + + + {stocks.length === 0 ? ( + 暂无相关股票数据 + ) : ( + + {loadingStockData && ( + + + 正在获取行情数据... + + )} + + + + + + + + + + + + + {stocks.map((stock, idx) => { + const marketData = stockMarketData[stock.stock_code]; + const changePercent = marketData?.change_percent; + + return ( + handleStockClick(stock.stock_code)} + > + + + + + + ); + })} + +
股票名称股票代码现价涨跌幅
+ {stock.stock_name} + {stock.stock_code} + {loadingStockData ? ( + + ) : marketData?.close ? ( + `¥${marketData.close.toFixed(2)}` + ) : ( + '-' + )} + 0 + ? 'red.500' + : changePercent && changePercent < 0 + ? 'green.500' + : 'gray.500' + } + > + {loadingStockData ? ( + + ) : changePercent !== undefined ? ( + `${changePercent > 0 ? '+' : ''}${changePercent.toFixed(2)}%` + ) : ( + '-' + )} +
+
+
+ )} +
+ + + + +
+
+ ); +}; + +export default ConceptStocksModal; diff --git a/src/views/StockOverview/index.js b/src/views/StockOverview/index.js index 6ed74be6..5bfcd26b 100644 --- a/src/views/StockOverview/index.js +++ b/src/views/StockOverview/index.js @@ -56,6 +56,7 @@ 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'; @@ -102,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'); @@ -114,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( (() => { @@ -996,37 +1008,39 @@ const StockOverview = () => { - - - 包含 {concept.stock_count} 只个股 - + handleViewStocks(e, concept)} + _hover={{ bg: hoverBg }} + p={2} + borderRadius="md" + transition="background 0.2s" + > + + 包含 {concept.stock_count} 只个股 + - {concept.stocks && concept.stocks.length > 0 && ( - - {concept.stocks.map((stock, idx) => ( - { - e.stopPropagation(); - - // 🎯 追踪概念下的股票标签点击 - trackConceptStockClicked({ - code: stock.stock_code, - name: stock.stock_name - }, concept.concept_name); - - navigate(`/company?scode=${stock.stock_code}`); - }} - > - {stock.stock_name} - - ))} - - )} + {concept.stocks && concept.stocks.length > 0 && ( + + {concept.stocks.map((stock, idx) => ( + + {stock.stock_name} + + ))} + + )} @@ -1120,7 +1134,14 @@ const StockOverview = () => { - + + {/* 个股列表弹窗 */} + setIsStockModalOpen(false)} + concept={selectedConcept} + /> + ); };