diff --git a/concept_api_v2.py b/concept_api_v2.py index 896f5d5e..66ef32db 100644 --- a/concept_api_v2.py +++ b/concept_api_v2.py @@ -749,6 +749,130 @@ async def get_hierarchy(): raise HTTPException(status_code=500, detail=str(e)) +# 注意:/hierarchy/price 必须在 /hierarchy/{lv1_id} 之前定义,否则会被错误匹配 +@app.get("/hierarchy/price", tags=["Hierarchy"]) +async def get_hierarchy_price_early( + trade_date: Optional[date] = Query(None, description="交易日期,默认最新"), + lv1_filter: Optional[str] = Query(None, description="筛选特定一级分类") +): + """获取层级概念(lv1/lv2/lv3)的涨跌幅数据""" + logger.info(f"[hierarchy/price] 请求参数: trade_date={trade_date}, lv1_filter={lv1_filter}") + + if not mysql_pool: + logger.error("[hierarchy/price] MySQL连接池不可用") + raise HTTPException(status_code=503, detail="MySQL连接不可用") + + try: + async with mysql_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 获取交易日期 + query_date = trade_date + if query_date is None: + # 优先从母概念查最新日期 + await cursor.execute( + "SELECT MAX(trade_date) as max_date FROM concept_daily_stats WHERE concept_type IN ('lv1', 'lv2', 'lv3')" + ) + result = await cursor.fetchone() + logger.info(f"[hierarchy/price] 查询母概念最新日期: {result}") + if result and result['max_date']: + query_date = result['max_date'] + else: + # 尝试从全部数据获取 + await cursor.execute("SELECT MAX(trade_date) as max_date FROM concept_daily_stats") + result = await cursor.fetchone() + logger.info(f"[hierarchy/price] 查询全部最新日期: {result}") + if not result or not result['max_date']: + raise HTTPException(status_code=404, detail="无涨跌幅数据") + query_date = result['max_date'] + + logger.info(f"[hierarchy/price] 使用查询日期: {query_date}") + + # 构建查询 + base_query = """ + SELECT concept_id, concept_name, concept_type, trade_date, avg_change_pct, stock_count + FROM concept_daily_stats + WHERE trade_date = %s AND concept_type = %s + """ + + if lv1_filter: + base_query += " AND concept_name LIKE %s" + + base_query += " ORDER BY avg_change_pct DESC" + + lv1_concepts = [] + lv2_concepts = [] + lv3_concepts = [] + + # 查询 lv1 + if lv1_filter: + await cursor.execute(base_query, (query_date, 'lv1', f"%{lv1_filter}%")) + else: + await cursor.execute(base_query, (query_date, 'lv1')) + lv1_rows = await cursor.fetchall() + logger.info(f"[hierarchy/price] lv1查询结果数量: {len(lv1_rows)}") + for row in lv1_rows: + lv1_concepts.append({ + "concept_id": row['concept_id'], + "concept_name": row['concept_name'], + "concept_type": 'lv1', + "trade_date": str(row['trade_date']), + "avg_change_pct": float(row['avg_change_pct']) if row['avg_change_pct'] else None, + "stock_count": row['stock_count'] + }) + + # 查询 lv2 + if lv1_filter: + await cursor.execute(base_query, (query_date, 'lv2', f"%{lv1_filter}%")) + else: + await cursor.execute(base_query, (query_date, 'lv2')) + lv2_rows = await cursor.fetchall() + logger.info(f"[hierarchy/price] lv2查询结果数量: {len(lv2_rows)}") + for row in lv2_rows: + lv2_concepts.append({ + "concept_id": row['concept_id'], + "concept_name": row['concept_name'], + "concept_type": 'lv2', + "trade_date": str(row['trade_date']), + "avg_change_pct": float(row['avg_change_pct']) if row['avg_change_pct'] else None, + "stock_count": row['stock_count'] + }) + + # 查询 lv3 + if lv1_filter: + await cursor.execute(base_query, (query_date, 'lv3', f"%{lv1_filter}%")) + else: + await cursor.execute(base_query, (query_date, 'lv3')) + lv3_rows = await cursor.fetchall() + logger.info(f"[hierarchy/price] lv3查询结果数量: {len(lv3_rows)}") + for row in lv3_rows: + lv3_concepts.append({ + "concept_id": row['concept_id'], + "concept_name": row['concept_name'], + "concept_type": 'lv3', + "trade_date": str(row['trade_date']), + "avg_change_pct": float(row['avg_change_pct']) if row['avg_change_pct'] else None, + "stock_count": row['stock_count'] + }) + + logger.info(f"[hierarchy/price] 返回结果: lv1={len(lv1_concepts)}, lv2={len(lv2_concepts)}, lv3={len(lv3_concepts)}") + return { + "trade_date": str(query_date), + "lv1_concepts": lv1_concepts, + "lv2_concepts": lv2_concepts, + "lv3_concepts": lv3_concepts, + "total_count": len(lv1_concepts) + len(lv2_concepts) + len(lv3_concepts) + } + + except HTTPException as he: + logger.error(f"[hierarchy/price] HTTPException: {he.status_code} - {he.detail}") + raise + except Exception as e: + logger.error(f"[hierarchy/price] 获取层级涨跌幅失败: {e}") + import traceback + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/hierarchy/{lv1_id}", tags=["Hierarchy"]) async def get_hierarchy_level( lv1_id: str, @@ -1494,12 +1618,33 @@ class HierarchyPriceResponse(BaseModel): total_count: int -@app.get("/hierarchy/price", response_model=HierarchyPriceResponse, tags=["Hierarchy"]) -async def get_hierarchy_price( +class ConceptPriceItem(BaseModel): + """概念价格项""" + concept_id: str + concept_name: str + concept_type: str # leaf/lv1/lv2/lv3 + trade_date: date + avg_change_pct: Optional[float] = None + stock_count: Optional[int] = None + hierarchy: Optional[HierarchyInfo] = None + + +class ConceptPriceListResponse(BaseModel): + """概念价格列表响应""" + trade_date: date + total: int + concepts: List[ConceptPriceItem] + + +@app.get("/price/list", response_model=ConceptPriceListResponse, tags=["Price"]) +async def get_concept_price_list( trade_date: Optional[date] = Query(None, description="交易日期,默认最新"), - lv1_filter: Optional[str] = Query(None, description="筛选特定一级分类") + concept_type: Optional[str] = Query(None, description="概念类型: leaf/lv1/lv2/lv3,默认全部"), + sort_by: str = Query("change_desc", description="排序: change_desc(涨幅降序), change_asc(涨幅升序), stock_count(股票数)"), + limit: int = Query(100, ge=1, le=1000, description="返回数量"), + offset: int = Query(0, ge=0, description="偏移量") ): - """获取层级概念(lv1/lv2/lv3)的涨跌幅数据""" + """批量获取概念涨跌幅列表""" if not mysql_pool: raise HTTPException(status_code=503, detail="MySQL连接不可用") @@ -1508,87 +1653,74 @@ async def get_hierarchy_price( async with conn.cursor(aiomysql.DictCursor) as cursor: # 获取交易日期 if trade_date is None: - await cursor.execute( - "SELECT MAX(trade_date) as max_date FROM concept_daily_stats WHERE concept_type != 'leaf'" - ) + await cursor.execute("SELECT MAX(trade_date) as max_date FROM concept_daily_stats") result = await cursor.fetchone() if not result or not result['max_date']: - raise HTTPException(status_code=404, detail="无母概念涨跌幅数据") + raise HTTPException(status_code=404, detail="无涨跌幅数据") trade_date = result['max_date'] # 构建查询 - base_query = """ + where_conditions = ["trade_date = %s"] + params = [trade_date] + + if concept_type and concept_type in ['leaf', 'lv1', 'lv2', 'lv3']: + where_conditions.append("concept_type = %s") + params.append(concept_type) + + # 排序 + order_clause = "avg_change_pct DESC" + if sort_by == "change_asc": + order_clause = "avg_change_pct ASC" + elif sort_by == "stock_count": + order_clause = "stock_count DESC" + + # 获取总数 + count_query = f"SELECT COUNT(*) as cnt FROM concept_daily_stats WHERE {' AND '.join(where_conditions)}" + await cursor.execute(count_query, params) + total = (await cursor.fetchone())['cnt'] + + # 获取数据 + query = f""" SELECT concept_id, concept_name, concept_type, trade_date, avg_change_pct, stock_count FROM concept_daily_stats - WHERE trade_date = %s AND concept_type = %s + WHERE {' AND '.join(where_conditions)} + ORDER BY {order_clause} + LIMIT %s OFFSET %s """ + params.extend([limit, offset]) + await cursor.execute(query, params) + rows = await cursor.fetchall() - if lv1_filter: - base_query += " AND concept_name LIKE %s" + concepts = [] + for row in rows: + concept_name = row['concept_name'] + # 获取层级信息(仅对叶子概念) + hierarchy = None + if row['concept_type'] == 'leaf': + hierarchy_info = get_concept_hierarchy(concept_name) + if hierarchy_info: + hierarchy = HierarchyInfo(**hierarchy_info) - base_query += " ORDER BY avg_change_pct DESC" - - lv1_concepts = [] - lv2_concepts = [] - lv3_concepts = [] - - # 查询 lv1 - if lv1_filter: - await cursor.execute(base_query, (trade_date, 'lv1', f"%{lv1_filter}%")) - else: - await cursor.execute(base_query, (trade_date, 'lv1')) - for row in await cursor.fetchall(): - lv1_concepts.append(HierarchyPriceItem( + concepts.append(ConceptPriceItem( concept_id=row['concept_id'], - concept_name=row['concept_name'], - concept_type='lv1', + concept_name=concept_name, + concept_type=row['concept_type'], trade_date=row['trade_date'], avg_change_pct=float(row['avg_change_pct']) if row['avg_change_pct'] else None, - stock_count=row['stock_count'] + stock_count=row['stock_count'], + hierarchy=hierarchy )) - # 查询 lv2 - if lv1_filter: - await cursor.execute(base_query, (trade_date, 'lv2', f"%{lv1_filter}%")) - else: - await cursor.execute(base_query, (trade_date, 'lv2')) - for row in await cursor.fetchall(): - lv2_concepts.append(HierarchyPriceItem( - concept_id=row['concept_id'], - concept_name=row['concept_name'], - concept_type='lv2', - trade_date=row['trade_date'], - avg_change_pct=float(row['avg_change_pct']) if row['avg_change_pct'] else None, - stock_count=row['stock_count'] - )) - - # 查询 lv3 - if lv1_filter: - await cursor.execute(base_query, (trade_date, 'lv3', f"%{lv1_filter}%")) - else: - await cursor.execute(base_query, (trade_date, 'lv3')) - for row in await cursor.fetchall(): - lv3_concepts.append(HierarchyPriceItem( - concept_id=row['concept_id'], - concept_name=row['concept_name'], - concept_type='lv3', - trade_date=row['trade_date'], - avg_change_pct=float(row['avg_change_pct']) if row['avg_change_pct'] else None, - stock_count=row['stock_count'] - )) - - return HierarchyPriceResponse( + return ConceptPriceListResponse( trade_date=trade_date, - lv1_concepts=lv1_concepts, - lv2_concepts=lv2_concepts, - lv3_concepts=lv3_concepts, - total_count=len(lv1_concepts) + len(lv2_concepts) + len(lv3_concepts) + total=total, + concepts=concepts ) except HTTPException: raise except Exception as e: - logger.error(f"获取层级涨跌幅失败: {e}") + logger.error(f"获取概念涨跌幅列表失败: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/views/Concept/components/HierarchyView.js b/src/views/Concept/components/HierarchyView.js index a2055a27..1342bae1 100644 --- a/src/views/Concept/components/HierarchyView.js +++ b/src/views/Concept/components/HierarchyView.js @@ -4,7 +4,7 @@ * 使用 CSS Grid + Chakra UI 实现热力图效果 * 特性: * 1. 炫酷的矩形热力图展示,涨红跌绿背景色 - * 2. 点击 lv1 进入 lv2,点击 lv2 进入 lv3,层层钻取 + * 2. 点击 lv1 进入 lv2,点击 lv2 进入 lv3,点击 lv3 进入具体概念 * 3. 集成 /hierarchy/price 接口获取实时涨跌幅 * 4. 每个分类有独特图标 * 5. 支持面包屑导航返回上级 @@ -60,7 +60,7 @@ import { FaSatellite, FaBatteryFull, FaSolarPanel, - FaWind, + FaTags, } from 'react-icons/fa'; import { logger } from '../../../utils/logger'; @@ -111,47 +111,27 @@ const LV2_ICONS = { '军贸出海': FaGlobe, }; -// 一级分类基础颜色(用于渐变) -const LV1_BASE_COLORS = { - '人工智能': { from: '#8B5CF6', to: '#A78BFA' }, - '半导体': { from: '#3B82F6', to: '#60A5FA' }, - '机器人': { from: '#10B981', to: '#34D399' }, - '消费电子': { from: '#EC4899', to: '#F472B6' }, - '智能驾驶与汽车': { from: '#F97316', to: '#FB923C' }, - '新能源与电力': { from: '#22C55E', to: '#4ADE80' }, - '空天经济': { from: '#06B6D4', to: '#22D3EE' }, - '国防军工': { from: '#EF4444', to: '#F87171' }, - '政策与主题': { from: '#F59E0B', to: '#FBBF24' }, - '周期与材料': { from: '#6B7280', to: '#9CA3AF' }, - '大消费': { from: '#F472B6', to: '#F9A8D4' }, - '数字经济与金融科技': { from: '#6366F1', to: '#818CF8' }, - '全球宏观与贸易': { from: '#14B8A6', to: '#2DD4BF' }, - '医药健康': { from: '#84CC16', to: '#A3E635' }, - '前沿科技': { from: '#A855F7', to: '#C084FC' }, -}; - -// 根据涨跌幅获取背景色(涨红跌绿渐变) -const getChangeBgColor = (value, baseColor = null) => { +// 根据涨跌幅获取背景色(统一涨红跌绿) +const getChangeBgColor = (value) => { if (value === null || value === undefined) { - // 无数据时使用基础色 - if (baseColor) { - return `linear-gradient(135deg, ${baseColor.from} 0%, ${baseColor.to} 100%)`; - } - return 'linear-gradient(135deg, #6B7280 0%, #9CA3AF 100%)'; + // 无数据时使用灰色 + return 'linear-gradient(135deg, #4B5563 0%, #6B7280 100%)'; } - // 涨跌幅越大,颜色越深 - if (value > 5) return 'linear-gradient(135deg, #991B1B 0%, #DC2626 100%)'; - if (value > 3) return 'linear-gradient(135deg, #B91C1C 0%, #EF4444 100%)'; - if (value > 1) return 'linear-gradient(135deg, #DC2626 0%, #F87171 100%)'; - if (value > 0) return 'linear-gradient(135deg, #EF4444 0%, #FCA5A5 100%)'; - if (value < -5) return 'linear-gradient(135deg, #14532D 0%, #16A34A 100%)'; - if (value < -3) return 'linear-gradient(135deg, #166534 0%, #22C55E 100%)'; - if (value < -1) return 'linear-gradient(135deg, #16A34A 0%, #4ADE80 100%)'; - if (value < 0) return 'linear-gradient(135deg, #22C55E 0%, #86EFAC 100%)'; + // 涨跌幅越大,颜色越深 - 统一涨红跌绿 + if (value > 7) return 'linear-gradient(135deg, #7F1D1D 0%, #991B1B 100%)'; + if (value > 5) return 'linear-gradient(135deg, #991B1B 0%, #B91C1C 100%)'; + if (value > 3) return 'linear-gradient(135deg, #B91C1C 0%, #DC2626 100%)'; + if (value > 1) return 'linear-gradient(135deg, #DC2626 0%, #EF4444 100%)'; + if (value > 0) return 'linear-gradient(135deg, #EF4444 0%, #F87171 100%)'; + if (value < -7) return 'linear-gradient(135deg, #14532D 0%, #166534 100%)'; + if (value < -5) return 'linear-gradient(135deg, #166534 0%, #15803D 100%)'; + if (value < -3) return 'linear-gradient(135deg, #15803D 0%, #16A34A 100%)'; + if (value < -1) return 'linear-gradient(135deg, #16A34A 0%, #22C55E 100%)'; + if (value < 0) return 'linear-gradient(135deg, #22C55E 0%, #4ADE80 100%)'; // 平盘 - return 'linear-gradient(135deg, #6B7280 0%, #9CA3AF 100%)'; + return 'linear-gradient(135deg, #4B5563 0%, #6B7280 100%)'; }; // 格式化涨跌幅 @@ -169,7 +149,16 @@ const getIcon = (name, level) => { if (level === 'lv2') { return LV2_ICONS[name] || FaCubes; } - return FaLightbulb; + if (level === 'lv3') { + return FaCubes; + } + return FaTags; // 具体概念用标签图标 +}; + +// 从 API 返回的名称中提取纯名称(去掉 [一级] [二级] [三级] 前缀) +const extractPureName = (apiName) => { + if (!apiName) return ''; + return apiName.replace(/^\[(一级|二级|三级)\]\s*/, ''); }; // 脉冲动画 @@ -182,27 +171,26 @@ const pulseKeyframes = keyframes` * 单个热力图块组件 */ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => { - const isMobile = useBreakpointValue({ base: true, md: false }); const hasChange = item.avg_change_pct !== null && item.avg_change_pct !== undefined; const isPositive = hasChange && item.avg_change_pct > 0; const isNegative = hasChange && item.avg_change_pct < 0; const isLargeChange = hasChange && Math.abs(item.avg_change_pct) > 3; const IconComponent = getIcon(item.name, item.level); - const baseColor = item.parentLv1 - ? LV1_BASE_COLORS[item.parentLv1] - : LV1_BASE_COLORS[item.name]; // 根据 size 调整高度 const heightMap = { - large: { base: '140px', md: '180px' }, - normal: { base: '120px', md: '150px' }, - small: { base: '100px', md: '120px' }, + large: { base: '140px', md: '160px' }, + normal: { base: '110px', md: '130px' }, + small: { base: '90px', md: '100px' }, }; + // 是否可点击进入下一层 + const canDrillDown = item.level !== 'concept' && (item.children?.length > 0 || item.concepts?.length > 0); + return ( { transform: 'translateY(-4px) scale(1.02)', boxShadow: '0 12px 30px rgba(0, 0, 0, 0.3)', }} - animation={isLargeChange ? `${pulseKeyframes} 2s infinite` : 'none'} + css={isLargeChange ? { animation: `${pulseKeyframes} 2s infinite` } : {}} > {/* 背景装饰 */} - {/* 内容 */} @@ -249,14 +228,14 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => { {/* 顶部:图标和名称 */} @@ -264,17 +243,15 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => { {item.name} - {item.concept_count && ( - + {item.concept_count > 0 && ( + {item.concept_count} 个概念 )} @@ -284,7 +261,7 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => { {/* 底部:涨跌幅 */} - {item.stock_count && ( + {item.stock_count > 0 && ( {item.stock_count} 只股票 @@ -292,9 +269,9 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => { - {hasChange && ( + {hasChange && (isPositive || isNegative) && ( @@ -302,7 +279,7 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => { {formatChangePercent(item.avg_change_pct)} @@ -312,7 +289,7 @@ const HeatmapBlock = ({ item, onClick, size = 'normal' }) => { {/* 可点击提示 */} - {(item.children?.length > 0 || item.concepts?.length > 0) && ( + {canDrillDown && ( lv2 -> lv3 -> concept const [currentLevel, setCurrentLevel] = useState('lv1'); const [currentLv1, setCurrentLv1] = useState(null); const [currentLv2, setCurrentLv2] = useState(null); + const [currentLv3, setCurrentLv3] = useState(null); const [breadcrumbs, setBreadcrumbs] = useState([{ label: '全部分类', level: 'root' }]); const isMobile = useBreakpointValue({ base: true, md: false }); @@ -397,18 +375,22 @@ const HierarchyView = ({ const data = await response.json(); + // 构建映射表 - 使用纯名称作为 key(去掉 [一级] 等前缀) const lv1Map = {}; const lv2Map = {}; const lv3Map = {}; (data.lv1_concepts || []).forEach(item => { - lv1Map[item.concept_name] = item; + const pureName = extractPureName(item.concept_name); + lv1Map[pureName] = item; }); (data.lv2_concepts || []).forEach(item => { - lv2Map[item.concept_name] = item; + const pureName = extractPureName(item.concept_name); + lv2Map[pureName] = item; }); (data.lv3_concepts || []).forEach(item => { - lv3Map[item.concept_name] = item; + const pureName = extractPureName(item.concept_name); + lv3Map[pureName] = item; }); setPriceData({ lv1Map, lv2Map, lv3Map }); @@ -418,7 +400,8 @@ const HierarchyView = ({ lv1Count: Object.keys(lv1Map).length, lv2Count: Object.keys(lv2Map).length, lv3Count: Object.keys(lv3Map).length, - tradeDate: data.trade_date + tradeDate: data.trade_date, + sampleLv1Keys: Object.keys(lv1Map).slice(0, 3), }); } catch (err) { logger.warn('HierarchyView', '获取层级涨跌幅失败', { error: err.message }); @@ -441,6 +424,7 @@ const HierarchyView = ({ const currentData = useMemo(() => { const { lv1Map, lv2Map, lv3Map } = priceData; + // 第一层:显示所有 lv1 if (currentLevel === 'lv1') { return hierarchy.map((lv1) => { const price = lv1Map[lv1.name] || {}; @@ -456,6 +440,7 @@ const HierarchyView = ({ }); } + // 第二层:显示选中 lv1 下的 lv2 if (currentLevel === 'lv2' && currentLv1) { const lv1Data = hierarchy.find(h => h.name === currentLv1.name); if (!lv1Data || !lv1Data.children) return []; @@ -476,6 +461,7 @@ const HierarchyView = ({ }); } + // 第三层:显示选中 lv2 下的 lv3 if (currentLevel === 'lv3' && currentLv1 && currentLv2) { const lv1Data = hierarchy.find(h => h.name === currentLv1.name); if (!lv1Data || !lv1Data.children) return []; @@ -483,6 +469,7 @@ const HierarchyView = ({ const lv2Data = lv1Data.children.find(h => h.name === currentLv2.name); if (!lv2Data) return []; + // 如果有 lv3 子级 if (lv2Data.children && lv2Data.children.length > 0) { return lv2Data.children.map((lv3) => { const price = lv3Map[lv3.name] || {}; @@ -500,6 +487,7 @@ const HierarchyView = ({ }); } + // 如果 lv2 直接包含概念(没有 lv3) if (lv2Data.concepts && lv2Data.concepts.length > 0) { return lv2Data.concepts.map((concept) => ({ name: concept, @@ -512,14 +500,35 @@ const HierarchyView = ({ return []; } + // 第四层:显示选中 lv3 下的具体概念 + if (currentLevel === 'concept' && currentLv1 && currentLv2 && currentLv3) { + const lv1Data = hierarchy.find(h => h.name === currentLv1.name); + if (!lv1Data || !lv1Data.children) return []; + + const lv2Data = lv1Data.children.find(h => h.name === currentLv2.name); + if (!lv2Data || !lv2Data.children) return []; + + const lv3Data = lv2Data.children.find(h => h.name === currentLv3.name); + if (!lv3Data || !lv3Data.concepts) return []; + + return lv3Data.concepts.map((concept) => ({ + name: concept, + level: 'concept', + parentLv1: currentLv1.name, + parentLv2: currentLv2.name, + parentLv3: currentLv3.name, + })); + } + return []; - }, [hierarchy, priceData, currentLevel, currentLv1, currentLv2]); + }, [hierarchy, priceData, currentLevel, currentLv1, currentLv2, currentLv3]); // 处理点击事件 - 钻取 const handleBlockClick = useCallback((item) => { logger.info('HierarchyView', '热力图点击', { level: item.level, name: item.name }); if (item.level === 'lv1' && item.children && item.children.length > 0) { + // 进入 lv2 setCurrentLevel('lv2'); setCurrentLv1(item); setBreadcrumbs([ @@ -527,7 +536,9 @@ const HierarchyView = ({ { label: item.name, level: 'lv1', data: item }, ]); } else if (item.level === 'lv2') { - if ((item.children && item.children.length > 0) || (item.concepts && item.concepts.length > 0)) { + // 检查是否有 lv3 或直接是概念 + if (item.children && item.children.length > 0) { + // 进入 lv3 setCurrentLevel('lv3'); setCurrentLv2(item); setBreadcrumbs([ @@ -535,9 +546,32 @@ const HierarchyView = ({ { label: currentLv1.name, level: 'lv1', data: currentLv1 }, { label: item.name, level: 'lv2', data: item }, ]); + } else if (item.concepts && item.concepts.length > 0) { + // lv2 直接包含概念,进入概念层 + setCurrentLevel('lv3'); // 实际显示概念 + setCurrentLv2(item); + setBreadcrumbs([ + { label: '全部分类', level: 'root' }, + { label: currentLv1.name, level: 'lv1', data: currentLv1 }, + { label: item.name, level: 'lv2', data: item }, + ]); } + } else if (item.level === 'lv3' && item.concepts && item.concepts.length > 0) { + // 进入具体概念层 + setCurrentLevel('concept'); + setCurrentLv3(item); + setBreadcrumbs([ + { label: '全部分类', level: 'root' }, + { label: currentLv1.name, level: 'lv1', data: currentLv1 }, + { label: currentLv2.name, level: 'lv2', data: currentLv2 }, + { label: item.name, level: 'lv3', data: item }, + ]); + } else if (item.level === 'concept') { + // 点击具体概念,跳转到概念详情页 + const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(item.name)}.html`; + window.open(htmlPath, '_blank'); } - }, [currentLv1]); + }, [currentLv1, currentLv2]); // 面包屑导航 const handleBreadcrumbClick = useCallback((crumb, index) => { @@ -545,11 +579,18 @@ const HierarchyView = ({ setCurrentLevel('lv1'); setCurrentLv1(null); setCurrentLv2(null); + setCurrentLv3(null); setBreadcrumbs([{ label: '全部分类', level: 'root' }]); } else if (crumb.level === 'lv1') { setCurrentLevel('lv2'); setCurrentLv1(crumb.data); setCurrentLv2(null); + setCurrentLv3(null); + setBreadcrumbs(breadcrumbs.slice(0, index + 1)); + } else if (crumb.level === 'lv2') { + setCurrentLevel('lv3'); + setCurrentLv2(crumb.data); + setCurrentLv3(null); setBreadcrumbs(breadcrumbs.slice(0, index + 1)); } }, [breadcrumbs]); @@ -567,18 +608,27 @@ const HierarchyView = ({ // 获取当前层级标题 const getCurrentTitle = () => { if (currentLevel === 'lv1') return '概念分类热力图'; - if (currentLevel === 'lv2' && currentLv1) return `${currentLv1.name}`; - if (currentLevel === 'lv3' && currentLv2) return `${currentLv2.name}`; + if (currentLevel === 'lv2' && currentLv1) return currentLv1.name; + if (currentLevel === 'lv3' && currentLv2) return currentLv2.name; + if (currentLevel === 'concept' && currentLv3) return currentLv3.name; return '概念分类'; }; + // 获取当前层级描述 + const getLevelDesc = () => { + const levelNames = { + lv1: '一级分类', + lv2: '二级分类', + lv3: '三级分类', + concept: '具体概念', + }; + return levelNames[currentLevel] || ''; + }; + // 计算列数 const getGridColumns = () => { - if (currentLevel === 'lv1') { - return { base: 2, md: 3, lg: 4 }; - } - if (currentLevel === 'lv2') { - return { base: 2, md: 3, lg: 4 }; + if (currentLevel === 'concept') { + return { base: 2, md: 4, lg: 5 }; // 概念层更多列 } return { base: 2, md: 3, lg: 4 }; }; @@ -641,11 +691,16 @@ const HierarchyView = ({ > - - {getCurrentTitle()} - + + + {getCurrentTitle()} + + + {getLevelDesc()} · {currentData.length} 项 + + {tradeDate && ( - + {tradeDate} )} @@ -721,29 +776,39 @@ const HierarchyView = ({ fontSize="sm" > - + - + - - + + 平/无数据 - | - 点击色块查看下级分类 + {currentLevel !== 'concept' && ( + <> + | + 点击色块查看下级 + + )} + {currentLevel === 'concept' && ( + <> + | + 点击查看概念详情 + + )} {/* 热力图网格 */} - {currentData.map((item, index) => ( + {currentData.map((item) => ( ))} @@ -762,8 +827,7 @@ const HierarchyView = ({ borderRadius="full" fontSize="sm" > - 当前显示 {currentData.length} 个 - {currentLevel === 'lv1' ? '一级' : currentLevel === 'lv2' ? '二级' : '三级'}分类 + 当前显示 {currentData.length} 个{getLevelDesc()} {currentLevel === 'lv1' && (