diff --git a/concept_api_v2.py b/concept_api_v2.py new file mode 100644 index 00000000..896f5d5e --- /dev/null +++ b/concept_api_v2.py @@ -0,0 +1,1667 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +概念搜索API V2 - 适配 concept_library_v3 索引 +新特性: +1. 支持层级结构(lv1/lv2/lv3) +2. 返回 tags, outbreak_dates, insight 等新字段 +3. 新增层级浏览接口 +""" +import json +import openai +from typing import List, Dict, Optional, Union, Any +from fastapi import FastAPI, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from elasticsearch import Elasticsearch +from datetime import datetime, date +import logging +import re +from contextlib import asynccontextmanager +import aiomysql +import os + +# 配置日志 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# ==================== 配置 ==================== + +# ES配置 +ES_HOST = 'http://127.0.0.1:9200' +INDEX_NAME = 'concept_library_v3' + +# Embedding配置 +OPENAI_BASE_URL = "http://127.0.0.1:8000/v1" +OPENAI_API_KEY = "dummy" +EMBEDDING_MODEL = "qwen3-embedding-8b" + +# 层级结构文件 +HIERARCHY_FILE = 'concept_hierarchy_v3.json' + +# MySQL配置 +MYSQL_CONFIG = { + 'host': '192.168.1.8', + 'port': 3306, + 'user': 'root', + 'password': 'Zzl5588161!', + 'db': 'stock', + 'charset': 'utf8mb4', + 'autocommit': True, + 'minsize': 1, + 'maxsize': 10 +} + +# ==================== 全局变量 ==================== + +es_client = None +openai_client = None +mysql_pool = None + +# 层级结构相关 +hierarchy_data = {} # 原始层级数据 +concept_to_hierarchy = {} # 概念名称 -> 层级信息的映射 + + +def load_hierarchy(): + """加载层级结构并建立概念到层级的映射""" + global hierarchy_data, concept_to_hierarchy + + hierarchy_path = os.path.join(os.path.dirname(__file__), HIERARCHY_FILE) + if not os.path.exists(hierarchy_path): + logger.warning(f"层级文件不存在: {hierarchy_path}") + return + + try: + with open(hierarchy_path, 'r', encoding='utf-8') as f: + hierarchy_data = json.load(f) + + # 遍历层级结构,建立概念到层级的映射 + for lv1 in hierarchy_data.get('hierarchy', []): + lv1_name = lv1.get('lv1', '') + lv1_id = lv1.get('lv1_id', '') + + for child in lv1.get('children', []): + lv2_name = child.get('lv2', '') + lv2_id = child.get('lv2_id', '') + + # 检查是否有 lv3 子级 + if 'children' in child: + for lv3_child in child.get('children', []): + lv3_name = lv3_child.get('lv3', '') + lv3_id = lv3_child.get('lv3_id', '') + + # lv3 级别的概念 + for concept in lv3_child.get('concepts', []): + concept_to_hierarchy[concept] = { + 'lv1': lv1_name, + 'lv1_id': lv1_id, + 'lv2': lv2_name, + 'lv2_id': lv2_id, + 'lv3': lv3_name, + 'lv3_id': lv3_id + } + else: + # lv2 级别直接有概念(没有 lv3) + for concept in child.get('concepts', []): + concept_to_hierarchy[concept] = { + 'lv1': lv1_name, + 'lv1_id': lv1_id, + 'lv2': lv2_name, + 'lv2_id': lv2_id, + 'lv3': None, + 'lv3_id': None + } + + logger.info(f"加载层级结构完成,共 {len(concept_to_hierarchy)} 个概念有层级信息") + + except Exception as e: + logger.error(f"加载层级结构失败: {e}") + + +def get_concept_hierarchy(concept_name: str) -> Optional[Dict]: + """获取概念的层级信息""" + return concept_to_hierarchy.get(concept_name) + + +# ==================== 生命周期管理 ==================== + +@asynccontextmanager +async def lifespan(app: FastAPI): + global es_client, openai_client, mysql_pool + + # 加载层级结构 + load_hierarchy() + + # 初始化ES客户端 + es_client = Elasticsearch( + [ES_HOST], + timeout=30, + max_retries=3, + retry_on_timeout=True + ) + logger.info(f"Connected to Elasticsearch at {ES_HOST}, index: {INDEX_NAME}") + + # 初始化OpenAI客户端 + openai_client = openai.OpenAI( + api_key=OPENAI_API_KEY, + base_url=OPENAI_BASE_URL, + timeout=60, + ) + logger.info(f"Initialized OpenAI client") + + # 初始化MySQL连接池 + try: + mysql_pool = await aiomysql.create_pool(**MYSQL_CONFIG) + logger.info(f"Connected to MySQL at {MYSQL_CONFIG['host']}") + except Exception as e: + logger.error(f"Failed to connect to MySQL: {e}") + + yield + + # 清理资源 + if es_client: + es_client.close() + if mysql_pool: + mysql_pool.close() + await mysql_pool.wait_closed() + logger.info("Cleanup completed") + + +# ==================== FastAPI 应用 ==================== + +app = FastAPI( + title="概念搜索API V2", + description="支持层级结构的概念库搜索API,适配 concept_library_v3 索引", + version="2.0.0", + lifespan=lifespan +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ==================== 数据模型 ==================== + +class HierarchyInfo(BaseModel): + """层级信息""" + lv1: Optional[str] = Field(None, description="一级分类") + lv1_id: Optional[str] = None + lv2: Optional[str] = Field(None, description="二级分类") + lv2_id: Optional[str] = None + lv3: Optional[str] = Field(None, description="三级分类") + lv3_id: Optional[str] = None + + +class StockInfo(BaseModel): + """股票信息""" + name: str = Field(..., description="股票名称") + code: Optional[str] = Field(None, description="股票代码") + + +class ConceptPriceInfo(BaseModel): + """概念涨跌幅信息""" + trade_date: date + avg_change_pct: Optional[float] = Field(None, description="平均涨跌幅(%)") + + +class SearchRequest(BaseModel): + """搜索请求""" + query: str = Field(..., description="搜索查询文本") + size: int = Field(10, ge=1, le=100, description="每页返回结果数量") + page: int = Field(1, ge=1, description="页码") + search_size: int = Field(100, ge=10, le=1000, description="搜索数量") + semantic_weight: Optional[float] = Field(None, ge=0.0, le=1.0, description="语义搜索权重") + filter_stocks: Optional[List[str]] = Field(None, description="过滤特定股票") + filter_lv1: Optional[str] = Field(None, description="过滤一级分类") + filter_lv2: Optional[str] = Field(None, description="过滤二级分类") + trade_date: Optional[date] = Field(None, description="交易日期") + sort_by: str = Field("_score", description="排序: _score, change_pct, stock_count, outbreak_date") + use_knn: bool = Field(True, description="是否使用KNN搜索") + + +class ConceptResult(BaseModel): + """概念搜索结果""" + concept_id: str + concept: str + description: Optional[str] = None + tags: Optional[List[str]] = Field(None, description="标签") + outbreak_dates: Optional[List[str]] = Field(None, description="爆发日期") + stocks: List[StockInfo] = Field(default_factory=list) + stock_count: int = 0 + hierarchy: Optional[HierarchyInfo] = Field(None, description="层级信息") + score: float = 0.0 + match_type: str = "keyword" + highlights: Optional[Dict[str, List[str]]] = None + price_info: Optional[ConceptPriceInfo] = None + + +class SearchResponse(BaseModel): + """搜索响应""" + total: int + took_ms: int + results: List[ConceptResult] + search_info: Dict[str, Any] + price_date: Optional[date] = None + page: int = 1 + total_pages: int = 1 + + +class HierarchyLevel(BaseModel): + """层级节点""" + id: str + name: str + concept_count: int = 0 + children: Optional[List['HierarchyLevel']] = None + concepts: Optional[List[str]] = None + + +class HierarchyResponse(BaseModel): + """层级结构响应""" + hierarchy: List[HierarchyLevel] + total_concepts: int + + +class ConceptDetailResponse(BaseModel): + """概念详情响应""" + concept_id: str + concept: str + description: Optional[str] = None + insight: Optional[str] = None + tags: Optional[List[str]] = None + outbreak_dates: Optional[List[str]] = None + stocks: List[Dict[str, Any]] = Field(default_factory=list) + stock_count: int = 0 + hierarchy: Optional[HierarchyInfo] = None + folders: Optional[List[str]] = None + created_at: Optional[str] = None + price_info: Optional[ConceptPriceInfo] = None + + +# ==================== 辅助函数 ==================== + +def generate_embedding(text: str) -> List[float]: + """生成文本向量""" + try: + if not text or len(text.strip()) == 0: + return [] + + text = text[:8000] if len(text) > 8000 else text + + if not openai_client: + return [] + + response = openai_client.embeddings.create( + model=EMBEDDING_MODEL, + input=[text] + ) + return response.data[0].embedding + + except Exception as e: + logger.warning(f"Embedding生成失败: {e}") + return [] + + +def calculate_semantic_weight(query: str) -> float: + """根据查询长度动态计算语义权重""" + query_length = len(query) + + if query_length < 10: + return 0.3 + elif query_length < 50: + return 0.5 + elif query_length < 200: + return 0.6 + else: + return 0.7 + + +async def get_concept_price_data(concept_ids: List[str], trade_date: Optional[date] = None) -> Dict[str, ConceptPriceInfo]: + """获取概念的涨跌幅数据""" + if not mysql_pool or not concept_ids: + return {} + + try: + async with mysql_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + placeholders = ','.join(['%s'] * len(concept_ids)) + + if trade_date: + query = f""" + SELECT concept_id, trade_date, avg_change_pct + FROM concept_daily_stats + WHERE concept_id IN ({placeholders}) AND trade_date = %s + """ + await cursor.execute(query, (*concept_ids, trade_date)) + else: + query = f""" + SELECT cds.concept_id, cds.trade_date, cds.avg_change_pct + FROM concept_daily_stats cds + INNER JOIN ( + SELECT concept_id, MAX(trade_date) as max_date + FROM concept_daily_stats + WHERE concept_id IN ({placeholders}) + GROUP BY concept_id + ) latest ON cds.concept_id = latest.concept_id + AND cds.trade_date = latest.max_date + """ + await cursor.execute(query, concept_ids) + + rows = await cursor.fetchall() + + result = {} + for row in rows: + result[row['concept_id']] = ConceptPriceInfo( + trade_date=row['trade_date'], + avg_change_pct=float(row['avg_change_pct']) if row['avg_change_pct'] is not None else None + ) + return result + + except Exception as e: + logger.error(f"获取涨跌幅数据失败: {e}") + return {} + + +def build_keyword_query(query: str, stock_filters: List[str] = None) -> Dict: + """构建关键词查询 - 适配 v3 索引""" + must_queries = [] + + if query.strip(): + must_queries.append({ + "multi_match": { + "query": query, + "fields": [ + "concept^3", + "description^2", + "tags.text^2", + "tags^1.5" + ], + "type": "best_fields", + "analyzer": "ik_smart" + } + }) + + if stock_filters: + stock_query = { + "nested": { + "path": "stocks", + "query": { + "bool": { + "should": [ + {"terms": {"stocks.name": stock_filters}}, + {"terms": {"stocks.code": stock_filters}} + ] + } + } + } + } + must_queries.append(stock_query) + + return { + "bool": { + "must": must_queries if must_queries else [{"match_all": {}}] + } + } + + +def parse_stocks_from_es(source: Dict) -> tuple: + """从ES文档解析股票信息""" + # nested stocks 字段(只有 name, code) + stocks_nested = source.get('stocks', []) + stocks_list = [] + + for s in stocks_nested[:30]: # 限制返回数量 + stocks_list.append(StockInfo( + name=s.get('name', ''), + code=s.get('code') + )) + + # 完整股票信息从 stocks_json 解析 + stocks_full = [] + stocks_json = source.get('stocks_json', '') + if stocks_json: + try: + stocks_full = json.loads(stocks_json) + except: + pass + + return stocks_list, stocks_full + + +# ==================== API 端点 ==================== + +@app.get("/", tags=["Health"]) +async def root(): + """健康检查""" + return { + "status": "healthy", + "service": "概念搜索API V2", + "version": "2.0.0", + "index": INDEX_NAME, + "hierarchy_concepts": len(concept_to_hierarchy) + } + + +@app.post("/search", response_model=SearchResponse, tags=["Search"]) +async def search_concepts(request: SearchRequest): + """ + 搜索概念 - 支持语义搜索和层级过滤 + """ + start_time = datetime.now() + + try: + # 计算语义权重 + if request.semantic_weight is not None: + semantic_weight = request.semantic_weight + else: + semantic_weight = calculate_semantic_weight(request.query) + + # 生成embedding + embedding = [] + if semantic_weight > 0: + embedding = generate_embedding(request.query) + if not embedding: + semantic_weight = 0 + + # 确定搜索数量 + effective_search_size = request.search_size + if request.sort_by in ["change_pct", "outbreak_date"]: + effective_search_size = min(500, request.search_size * 5) + + # 构建查询 + search_body = {} + match_type = "keyword" + + if semantic_weight == 0: + # 纯关键词搜索 + search_body = { + "query": build_keyword_query(request.query, request.filter_stocks), + "size": effective_search_size + } + match_type = "keyword" + + elif request.use_knn and embedding: + # KNN + 关键词混合搜索 + keyword_weight = 1.0 - semantic_weight + + search_body = { + "knn": { + "field": "description_embedding", + "query_vector": embedding, + "k": effective_search_size, + "num_candidates": min(effective_search_size * 2, 10000), + "boost": semantic_weight + }, + "query": { + "bool": { + "must": [build_keyword_query(request.query, request.filter_stocks)], + "boost": keyword_weight + } + }, + "size": effective_search_size + } + match_type = "hybrid_knn" + + else: + # 传统混合搜索 + search_body = { + "query": build_keyword_query(request.query, request.filter_stocks), + "size": effective_search_size + } + match_type = "keyword" + + # 添加高亮和源过滤 + search_body.update({ + "highlight": { + "fields": { + "concept": {}, + "description": {"fragment_size": 150}, + "tags": {} + } + }, + "_source": { + "excludes": ["description_embedding", "insight"] + }, + "track_total_hits": True + }) + + # 执行搜索 + es_response = es_client.search( + index=INDEX_NAME, + body=search_body, + timeout="30s" + ) + + # 处理结果 + all_results = [] + concept_ids = [] + + for hit in es_response['hits']['hits']: + source = hit['_source'] + concept_name = source.get('concept', '') + concept_id = source.get('concept_id', '') + concept_ids.append(concept_id) + + # 解析股票 + stocks_list, _ = parse_stocks_from_es(source) + + # 获取层级信息 + hierarchy_info = get_concept_hierarchy(concept_name) + hierarchy = None + if hierarchy_info: + hierarchy = HierarchyInfo(**hierarchy_info) + + # 层级过滤 + if request.filter_lv1 and (not hierarchy_info or hierarchy_info.get('lv1') != request.filter_lv1): + continue + if request.filter_lv2 and (not hierarchy_info or hierarchy_info.get('lv2') != request.filter_lv2): + continue + + # 高亮信息 + highlights = hit.get('highlight', {}) + + result = ConceptResult( + concept_id=concept_id, + concept=concept_name, + description=source.get('description'), + tags=source.get('tags', []), + outbreak_dates=source.get('outbreak_dates', []), + stocks=stocks_list, + stock_count=len(source.get('stocks', [])), + hierarchy=hierarchy, + score=hit['_score'] or 0, + match_type=match_type, + highlights=highlights, + price_info=None + ) + all_results.append(result) + + # 获取涨跌幅数据 + price_data = {} + actual_price_date = None + + if concept_ids: + price_data = await get_concept_price_data(concept_ids, request.trade_date) + if price_data: + actual_price_date = next(iter(price_data.values())).trade_date + + for result in all_results: + result.price_info = price_data.get(result.concept_id) + + # 排序 + if request.sort_by == "change_pct": + all_results.sort( + key=lambda x: ( + x.price_info.avg_change_pct + if x.price_info and x.price_info.avg_change_pct is not None + else -999 + ), + reverse=True + ) + elif request.sort_by == "stock_count": + all_results.sort(key=lambda x: x.stock_count, reverse=True) + elif request.sort_by == "outbreak_date": + all_results.sort( + key=lambda x: ( + x.outbreak_dates[0] if x.outbreak_dates else '1900-01-01' + ), + reverse=True + ) + + # 分页 + total_results = len(all_results) + total_pages = max(1, (total_results + request.size - 1) // request.size) + current_page = min(request.page, total_pages) + + start_idx = (current_page - 1) * request.size + end_idx = start_idx + request.size + page_results = all_results[start_idx:end_idx] + + took_ms = int((datetime.now() - start_time).total_seconds() * 1000) + + return SearchResponse( + total=es_response['hits']['total']['value'], + took_ms=took_ms, + results=page_results, + search_info={ + "query": request.query, + "semantic_weight": semantic_weight, + "match_type": match_type, + "filter_lv1": request.filter_lv1, + "filter_lv2": request.filter_lv2, + "sort_by": request.sort_by, + "has_embedding": bool(embedding) + }, + price_date=actual_price_date, + page=current_page, + total_pages=total_pages + ) + + except Exception as e: + logger.error(f"搜索失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/concept/{concept_id}", response_model=ConceptDetailResponse, tags=["Concepts"]) +async def get_concept_detail( + concept_id: str, + trade_date: Optional[date] = Query(None, description="交易日期") +): + """获取概念详情""" + try: + result = es_client.get(index=INDEX_NAME, id=concept_id) + source = result['_source'] + + concept_name = source.get('concept', '') + + # 解析完整股票信息 + _, stocks_full = parse_stocks_from_es(source) + + # 获取层级信息 + hierarchy_info = get_concept_hierarchy(concept_name) + hierarchy = HierarchyInfo(**hierarchy_info) if hierarchy_info else None + + # 获取涨跌幅 + price_data = await get_concept_price_data([concept_id], trade_date) + price_info = price_data.get(concept_id) + + return ConceptDetailResponse( + concept_id=concept_id, + concept=concept_name, + description=source.get('description'), + insight=source.get('insight'), + tags=source.get('tags', []), + outbreak_dates=source.get('outbreak_dates', []), + stocks=stocks_full, + stock_count=len(stocks_full), + hierarchy=hierarchy, + folders=source.get('folders', []), + created_at=source.get('created_at'), + price_info=price_info + ) + + except Exception as e: + if "NotFoundError" in str(type(e)): + raise HTTPException(status_code=404, detail="概念不存在") + logger.error(f"获取概念详情失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/hierarchy", response_model=HierarchyResponse, tags=["Hierarchy"]) +async def get_hierarchy(): + """获取完整层级结构""" + try: + result = [] + + for lv1 in hierarchy_data.get('hierarchy', []): + lv1_node = HierarchyLevel( + id=lv1.get('lv1_id', ''), + name=lv1.get('lv1', ''), + concept_count=0, + children=[] + ) + + for child in lv1.get('children', []): + lv2_node = HierarchyLevel( + id=child.get('lv2_id', ''), + name=child.get('lv2', ''), + concept_count=0, + children=[] + ) + + if 'children' in child: + # 有 lv3 + for lv3_child in child.get('children', []): + concepts = lv3_child.get('concepts', []) + lv3_node = HierarchyLevel( + id=lv3_child.get('lv3_id', ''), + name=lv3_child.get('lv3', ''), + concept_count=len(concepts), + concepts=concepts + ) + lv2_node.children.append(lv3_node) + lv2_node.concept_count += len(concepts) + else: + # lv2 直接有概念 + concepts = child.get('concepts', []) + lv2_node.concept_count = len(concepts) + lv2_node.concepts = concepts + + lv1_node.children.append(lv2_node) + lv1_node.concept_count += lv2_node.concept_count + + result.append(lv1_node) + + total_concepts = sum(lv1.concept_count for lv1 in result) + + return HierarchyResponse( + hierarchy=result, + total_concepts=total_concepts + ) + + except Exception as e: + logger.error(f"获取层级结构失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/hierarchy/{lv1_id}", tags=["Hierarchy"]) +async def get_hierarchy_level( + lv1_id: str, + lv2_id: Optional[str] = Query(None, description="二级分类ID") +): + """获取指定层级的概念列表""" + try: + for lv1 in hierarchy_data.get('hierarchy', []): + if lv1.get('lv1_id') == lv1_id: + if lv2_id: + # 返回指定 lv2 下的概念 + for child in lv1.get('children', []): + if child.get('lv2_id') == lv2_id: + concepts = [] + if 'children' in child: + for lv3_child in child.get('children', []): + concepts.extend(lv3_child.get('concepts', [])) + else: + concepts = child.get('concepts', []) + + return { + "lv1": lv1.get('lv1'), + "lv1_id": lv1_id, + "lv2": child.get('lv2'), + "lv2_id": lv2_id, + "concepts": concepts, + "concept_count": len(concepts) + } + raise HTTPException(status_code=404, detail=f"未找到二级分类: {lv2_id}") + else: + # 返回 lv1 下所有概念 + concepts = [] + for child in lv1.get('children', []): + if 'children' in child: + for lv3_child in child.get('children', []): + concepts.extend(lv3_child.get('concepts', [])) + else: + concepts.extend(child.get('concepts', [])) + + return { + "lv1": lv1.get('lv1'), + "lv1_id": lv1_id, + "concepts": concepts, + "concept_count": len(concepts), + "children": lv1.get('children', []) + } + + raise HTTPException(status_code=404, detail=f"未找到一级分类: {lv1_id}") + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取层级详情失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/stock/{stock_code}/concepts", tags=["Stocks"]) +async def get_stock_concepts( + stock_code: str, + size: int = Query(50, ge=1, le=200), + trade_date: Optional[date] = Query(None) +): + """根据股票代码查询相关概念""" + try: + query = { + "nested": { + "path": "stocks", + "query": { + "bool": { + "should": [ + {"term": {"stocks.code": stock_code}}, + {"term": {"stocks.name": stock_code}} + ] + } + } + } + } + + search_body = { + "query": query, + "size": size, + "_source": { + "excludes": ["description_embedding", "insight", "stocks_json"] + } + } + + es_response = es_client.search(index=INDEX_NAME, body=search_body) + + concept_ids = [] + concepts = [] + + for hit in es_response['hits']['hits']: + source = hit['_source'] + concept_name = source.get('concept', '') + concept_id = source.get('concept_id', '') + concept_ids.append(concept_id) + + # 获取层级 + hierarchy_info = get_concept_hierarchy(concept_name) + + concepts.append({ + "concept_id": concept_id, + "concept": concept_name, + "description": source.get('description'), + "tags": source.get('tags', []), + "stock_count": len(source.get('stocks', [])), + "hierarchy": hierarchy_info, + "outbreak_dates": source.get('outbreak_dates', []) + }) + + # 获取涨跌幅 + price_data = await get_concept_price_data(concept_ids, trade_date) + for concept in concepts: + concept['price_info'] = price_data.get(concept['concept_id']) + + return { + "stock_code": stock_code, + "total": es_response['hits']['total']['value'], + "concepts": concepts + } + + except Exception as e: + logger.error(f"查询股票概念失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/statistics/hierarchy", tags=["Statistics"]) +async def get_hierarchy_statistics(): + """获取层级统计信息""" + try: + stats = [] + + for lv1 in hierarchy_data.get('hierarchy', []): + lv1_stats = { + "lv1": lv1.get('lv1'), + "lv1_id": lv1.get('lv1_id'), + "lv2_count": len(lv1.get('children', [])), + "concept_count": 0, + "children": [] + } + + for child in lv1.get('children', []): + lv2_concept_count = 0 + if 'children' in child: + for lv3_child in child.get('children', []): + lv2_concept_count += len(lv3_child.get('concepts', [])) + else: + lv2_concept_count = len(child.get('concepts', [])) + + lv1_stats['children'].append({ + "lv2": child.get('lv2'), + "lv2_id": child.get('lv2_id'), + "concept_count": lv2_concept_count + }) + lv1_stats['concept_count'] += lv2_concept_count + + stats.append(lv1_stats) + + # 按概念数量排序 + stats.sort(key=lambda x: x['concept_count'], reverse=True) + + return { + "total_lv1": len(stats), + "total_concepts": sum(s['concept_count'] for s in stats), + "statistics": stats + } + + except Exception as e: + logger.error(f"获取层级统计失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ==================== 股票搜索相关 ==================== + +class StockSearchResult(BaseModel): + """股票搜索结果""" + stock_code: str + stock_name: str + concept_count: int + + +class StockSearchResponse(BaseModel): + """股票搜索响应""" + total: int + stocks: List[StockSearchResult] + + +@app.get("/stock/search", response_model=StockSearchResponse, tags=["Stocks"]) +async def search_stocks( + keyword: str = Query(..., description="股票关键词(代码或名称)"), + size: int = Query(20, ge=1, le=100, description="返回数量") +): + """搜索股票名称或代码""" + try: + # v3 索引中股票字段是 stocks.name 和 stocks.code + search_body = { + "query": { + "nested": { + "path": "stocks", + "query": { + "bool": { + "should": [ + {"wildcard": {"stocks.code": f"*{keyword}*"}}, + {"match": {"stocks.name": keyword}} + ] + } + } + } + }, + "size": 0, + "aggs": { + "unique_stocks": { + "nested": { + "path": "stocks" + }, + "aggs": { + "stock_codes": { + "terms": { + "field": "stocks.code", + "size": size * 2 + }, + "aggs": { + "stock_names": { + "terms": { + "field": "stocks.name", + "size": 1 + } + } + } + } + } + } + } + } + + es_response = es_client.search(index=INDEX_NAME, body=search_body) + + stocks = [] + buckets = es_response['aggregations']['unique_stocks']['stock_codes']['buckets'] + + for bucket in buckets: + stock_code = bucket['key'] + concept_count = bucket['doc_count'] + + name_buckets = bucket['stock_names']['buckets'] + stock_name = name_buckets[0]['key'] if name_buckets else stock_code + + if keyword in stock_code or keyword in stock_name: + stocks.append(StockSearchResult( + stock_code=stock_code, + stock_name=stock_name, + concept_count=concept_count + )) + + stocks.sort(key=lambda x: x.concept_count, reverse=True) + + return StockSearchResponse( + total=len(stocks), + stocks=stocks[:size] + ) + + except Exception as e: + logger.error(f"搜索股票失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ==================== 价格相关接口 ==================== + +class PriceTimeSeriesItem(BaseModel): + """价格时间序列数据点""" + trade_date: date + avg_change_pct: Optional[float] = Field(None, description="平均涨跌幅(%)") + stock_count: Optional[int] = Field(None, description="当日股票数量") + + +class PriceTimeSeriesResponse(BaseModel): + """价格时间序列响应""" + concept_id: str + concept_name: str + start_date: date + end_date: date + data_points: int + timeseries: List[PriceTimeSeriesItem] + + +async def get_latest_trade_date() -> Optional[date]: + """获取最新的交易日期""" + if not mysql_pool: + return None + + try: + async with mysql_pool.acquire() as conn: + async with conn.cursor() as cursor: + query = "SELECT MAX(trade_date) as latest_date FROM concept_daily_stats" + await cursor.execute(query) + result = await cursor.fetchone() + return result[0] if result and result[0] else None + except Exception as e: + logger.error(f"获取最新交易日期失败: {e}") + return None + + +@app.get("/price/latest", tags=["Price"]) +async def get_latest_price_date(): + """获取最新的涨跌幅数据日期""" + try: + latest_date = await get_latest_trade_date() + return { + "latest_trade_date": latest_date, + "has_data": latest_date is not None + } + except Exception as e: + logger.error(f"获取最新价格日期失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/concept/{concept_id}/price-timeseries", response_model=PriceTimeSeriesResponse, tags=["Price"]) +async def get_concept_price_timeseries( + concept_id: str, + start_date: date = Query(..., description="开始日期,格式:YYYY-MM-DD"), + end_date: date = Query(..., description="结束日期,格式:YYYY-MM-DD") +): + """获取概念在指定日期范围内的涨跌幅时间序列数据""" + if not mysql_pool: + logger.warning(f"[PriceTimeseries] MySQL 连接不可用") + return PriceTimeSeriesResponse( + concept_id=concept_id, + concept_name=concept_id, + start_date=start_date, + end_date=end_date, + data_points=0, + timeseries=[] + ) + + if start_date > end_date: + raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期") + + try: + async with mysql_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + query = """ + SELECT trade_date, concept_name, avg_change_pct, stock_count + FROM concept_daily_stats + WHERE concept_id = %s + AND trade_date >= %s + AND trade_date <= %s + ORDER BY trade_date ASC + """ + + await cursor.execute(query, (concept_id, start_date, end_date)) + rows = await cursor.fetchall() + + if not rows: + raise HTTPException( + status_code=404, + detail=f"未找到概念ID {concept_id} 在 {start_date} 至 {end_date} 期间的数据" + ) + + timeseries = [] + concept_name = "" + + for row in rows: + if not concept_name and row.get('concept_name'): + concept_name = row['concept_name'] + + item = PriceTimeSeriesItem( + trade_date=row['trade_date'], + avg_change_pct=float(row['avg_change_pct']) if row['avg_change_pct'] is not None else None, + stock_count=row.get('stock_count') + ) + timeseries.append(item) + + return PriceTimeSeriesResponse( + concept_id=concept_id, + concept_name=concept_name or concept_id, + start_date=start_date, + end_date=end_date, + data_points=len(timeseries), + timeseries=timeseries + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取价格时间序列失败: {e}") + raise HTTPException(status_code=500, detail=f"获取时间序列数据失败: {str(e)}") + + +# ==================== 统计接口 ==================== + +class ConceptStatItem(BaseModel): + """概念统计项""" + name: str + concept_id: Optional[str] = None + change_pct: Optional[float] = None + stock_count: Optional[int] = None + news_count: Optional[int] = None + report_count: Optional[int] = None + total_mentions: Optional[int] = None + volatility: Optional[float] = None + avg_change: Optional[float] = None + max_change: Optional[float] = None + consecutive_days: Optional[int] = None + total_change: Optional[float] = None + avg_daily: Optional[float] = None + hierarchy: Optional[HierarchyInfo] = None + + +class ConceptStatistics(BaseModel): + """概念统计数据""" + hot_concepts: List[ConceptStatItem] + cold_concepts: List[ConceptStatItem] + active_concepts: List[ConceptStatItem] + volatile_concepts: List[ConceptStatItem] + momentum_concepts: List[ConceptStatItem] + summary: Dict[str, Any] + + +class ConceptStatisticsResponse(BaseModel): + """概念统计响应""" + success: bool + data: ConceptStatistics + params: Dict[str, Any] + note: Optional[str] = None + + +def get_fallback_statistics(start_date, end_date) -> ConceptStatistics: + """返回示例统计数据""" + return ConceptStatistics( + hot_concepts=[ + ConceptStatItem(name="人工智能", change_pct=8.76, stock_count=45, news_count=12), + ConceptStatItem(name="机器人", change_pct=7.54, stock_count=38, news_count=10), + ConceptStatItem(name="半导体", change_pct=6.43, stock_count=52, news_count=15), + ConceptStatItem(name="低空经济", change_pct=5.21, stock_count=28, news_count=6), + ConceptStatItem(name="量子科技", change_pct=4.98, stock_count=15, news_count=9), + ], + cold_concepts=[ + ConceptStatItem(name="房地产", change_pct=-5.76, stock_count=33, news_count=5), + ConceptStatItem(name="煤炭", change_pct=-4.32, stock_count=25, news_count=3), + ConceptStatItem(name="钢铁", change_pct=-3.21, stock_count=28, news_count=4), + ConceptStatItem(name="传统零售", change_pct=-2.98, stock_count=19, news_count=2), + ConceptStatItem(name="纺织服装", change_pct=-2.45, stock_count=15, news_count=2), + ], + active_concepts=[ + ConceptStatItem(name="人工智能", news_count=45, report_count=15, total_mentions=60), + ConceptStatItem(name="半导体", news_count=42, report_count=12, total_mentions=54), + ConceptStatItem(name="机器人", news_count=38, report_count=8, total_mentions=46), + ConceptStatItem(name="新能源", news_count=28, report_count=6, total_mentions=34), + ConceptStatItem(name="量子科技", news_count=25, report_count=5, total_mentions=30), + ], + volatile_concepts=[ + ConceptStatItem(name="数字货币", volatility=25.6, avg_change=2.1, max_change=15.2), + ConceptStatItem(name="元宇宙", volatility=23.8, avg_change=1.8, max_change=13.9), + ConceptStatItem(name="虚拟现实", volatility=21.2, avg_change=-0.5, max_change=10.1), + ConceptStatItem(name="游戏", volatility=19.7, avg_change=3.2, max_change=12.8), + ConceptStatItem(name="短剧", volatility=18.3, avg_change=-1.1, max_change=8.1), + ], + momentum_concepts=[ + ConceptStatItem(name="AI应用", consecutive_days=6, total_change=19.2, avg_daily=3.2), + ConceptStatItem(name="算力", consecutive_days=5, total_change=16.8, avg_daily=3.36), + ConceptStatItem(name="光通信", consecutive_days=4, total_change=13.1, avg_daily=3.28), + ConceptStatItem(name="存储", consecutive_days=4, total_change=12.4, avg_daily=3.1), + ConceptStatItem(name="PCB", consecutive_days=3, total_change=9.6, avg_daily=3.2), + ], + summary={ + 'total_concepts': 500, + 'positive_count': 320, + 'negative_count': 180, + 'avg_change': 1.8, + 'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'date_range': f"{start_date} 至 {end_date}", + 'days': (end_date - start_date).days + 1 if start_date and end_date else 7, + 'start_date': str(start_date) if start_date else '', + 'end_date': str(end_date) if end_date else '' + } + ) + + +@app.get("/statistics", response_model=ConceptStatisticsResponse, tags=["Statistics"]) +async def get_concept_statistics( + days: Optional[int] = Query(None, ge=1, le=90, description="统计天数范围"), + start_date: Optional[date] = Query(None, description="开始日期"), + end_date: Optional[date] = Query(None, description="结束日期"), + min_stock_count: int = Query(3, ge=1, description="最少股票数量过滤"), + concept_type: Optional[str] = Query(None, description="概念类型过滤: leaf/lv1/lv2/lv3,默认为leaf") +): + """获取概念板块统计数据 - 涨幅榜、跌幅榜、活跃榜、波动榜、连涨榜""" + from datetime import timedelta + + # 计算日期范围 + if days is not None and (start_date is not None or end_date is not None): + raise HTTPException(status_code=400, detail="days参数与start_date/end_date参数不能同时使用") + + if start_date is not None and end_date is not None: + if start_date > end_date: + raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期") + if (end_date - start_date).days > 90: + raise HTTPException(status_code=400, detail="日期范围不能超过90天") + elif days is not None: + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + elif start_date is not None: + end_date = datetime.now().date() + elif end_date is not None: + start_date = end_date - timedelta(days=7) + else: + end_date = datetime.now().date() + start_date = end_date - timedelta(days=7) + + if not mysql_pool: + logger.warning("[Statistics] MySQL 连接不可用,使用示例数据") + return ConceptStatisticsResponse( + success=True, + data=get_fallback_statistics(start_date, end_date), + params={ + 'days': (end_date - start_date).days + 1, + 'min_stock_count': min_stock_count, + 'start_date': str(start_date), + 'end_date': str(end_date) + }, + note="MySQL 连接不可用,使用示例数据" + ) + + # 默认只查询叶子概念 + type_filter = concept_type if concept_type in ['leaf', 'lv1', 'lv2', 'lv3'] else 'leaf' + + try: + async with mysql_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + + # 1. 涨幅榜 + hot_query = """ + SELECT concept_id, concept_name, + AVG(avg_change_pct) as avg_change_pct, + AVG(stock_count) as avg_stock_count, + COUNT(*) as trading_days + FROM concept_daily_stats + WHERE trade_date >= %s AND trade_date <= %s + AND avg_change_pct IS NOT NULL AND stock_count >= %s + AND concept_type = %s + GROUP BY concept_id, concept_name + HAVING COUNT(*) >= 2 + ORDER BY AVG(avg_change_pct) DESC + LIMIT 10 + """ + await cursor.execute(hot_query, (start_date, end_date, min_stock_count, type_filter)) + hot_rows = await cursor.fetchall() + + # 2. 跌幅榜 + cold_query = """ + SELECT concept_id, concept_name, + AVG(avg_change_pct) as avg_change_pct, + AVG(stock_count) as avg_stock_count, + COUNT(*) as trading_days + FROM concept_daily_stats + WHERE trade_date >= %s AND trade_date <= %s + AND avg_change_pct IS NOT NULL AND stock_count >= %s + AND concept_type = %s + GROUP BY concept_id, concept_name + HAVING COUNT(*) >= 2 + ORDER BY AVG(avg_change_pct) ASC + LIMIT 10 + """ + await cursor.execute(cold_query, (start_date, end_date, min_stock_count, type_filter)) + cold_rows = await cursor.fetchall() + + # 3. 活跃榜 + active_query = """ + SELECT concept_id, concept_name, + COUNT(*) as trading_days, + AVG(stock_count) as avg_stock_count + FROM concept_daily_stats + WHERE trade_date >= %s AND trade_date <= %s AND stock_count >= %s + AND concept_type = %s + GROUP BY concept_id, concept_name + ORDER BY COUNT(*) DESC, AVG(stock_count) DESC + LIMIT 10 + """ + await cursor.execute(active_query, (start_date, end_date, min_stock_count, type_filter)) + active_rows = await cursor.fetchall() + + # 4. 波动榜 + volatile_query = """ + SELECT concept_id, concept_name, + STDDEV(avg_change_pct) as volatility, + AVG(avg_change_pct) as avg_change_pct, + MAX(avg_change_pct) as max_change_pct + FROM concept_daily_stats + WHERE trade_date >= %s AND trade_date <= %s + AND avg_change_pct IS NOT NULL AND stock_count >= %s + AND concept_type = %s + GROUP BY concept_id, concept_name + HAVING COUNT(*) >= 3 AND STDDEV(avg_change_pct) IS NOT NULL + ORDER BY STDDEV(avg_change_pct) DESC + LIMIT 10 + """ + await cursor.execute(volatile_query, (start_date, end_date, min_stock_count, type_filter)) + volatile_rows = await cursor.fetchall() + + # 5. 连涨榜 + momentum_query = """ + SELECT concept_id, concept_name, + COUNT(*) as positive_days, + SUM(avg_change_pct) as total_change, + AVG(avg_change_pct) as avg_change_pct + FROM concept_daily_stats + WHERE trade_date >= %s AND trade_date <= %s + AND avg_change_pct > 0 AND stock_count >= %s + AND concept_type = %s + GROUP BY concept_id, concept_name + HAVING COUNT(*) >= 2 + ORDER BY COUNT(*) DESC, AVG(avg_change_pct) DESC + LIMIT 10 + """ + await cursor.execute(momentum_query, (start_date, end_date, min_stock_count, type_filter)) + momentum_rows = await cursor.fetchall() + + # 6. 总体统计 + total_query = """ + SELECT COUNT(DISTINCT concept_id) as total_concepts, + COUNT(DISTINCT CASE WHEN avg_change_pct > 0 THEN concept_id END) as positive_concepts, + COUNT(DISTINCT CASE WHEN avg_change_pct < 0 THEN concept_id END) as negative_concepts, + AVG(avg_change_pct) as overall_avg_change + FROM concept_daily_stats + WHERE trade_date >= %s AND trade_date <= %s AND avg_change_pct IS NOT NULL + AND concept_type = %s + """ + await cursor.execute(total_query, (start_date, end_date, type_filter)) + total_row = await cursor.fetchone() + + # 构建响应 + def build_items(rows, item_type): + items = [] + for row in rows: + concept_name = row['concept_name'] + hierarchy_info = get_concept_hierarchy(concept_name) + + item = ConceptStatItem( + name=concept_name, + concept_id=row.get('concept_id'), + hierarchy=HierarchyInfo(**hierarchy_info) if hierarchy_info else None + ) + + if item_type in ['hot', 'cold']: + item.change_pct = round(float(row['avg_change_pct']), 2) if row['avg_change_pct'] else 0.0 + item.stock_count = int(row['avg_stock_count']) if row['avg_stock_count'] else 0 + item.news_count = int(row['trading_days']) if row['trading_days'] else 0 + + elif item_type == 'active': + item.news_count = int(row['trading_days']) if row['trading_days'] else 0 + item.stock_count = int(row['avg_stock_count']) if row['avg_stock_count'] else 0 + item.report_count = max(1, int(row['trading_days'] * 0.3)) if row['trading_days'] else 1 + item.total_mentions = item.news_count + item.report_count + + elif item_type == 'volatile': + item.volatility = round(float(row['volatility']), 2) if row['volatility'] else 0.0 + item.avg_change = round(float(row['avg_change_pct']), 2) if row['avg_change_pct'] else 0.0 + item.max_change = round(float(row['max_change_pct']), 2) if row['max_change_pct'] else 0.0 + + elif item_type == 'momentum': + item.consecutive_days = int(row['positive_days']) if row['positive_days'] else 0 + item.total_change = round(float(row['total_change']), 2) if row['total_change'] else 0.0 + item.avg_daily = round(float(row['avg_change_pct']), 2) if row['avg_change_pct'] else 0.0 + + items.append(item) + return items + + statistics = ConceptStatistics( + hot_concepts=build_items(hot_rows, 'hot'), + cold_concepts=build_items(cold_rows, 'cold'), + active_concepts=build_items(active_rows, 'active'), + volatile_concepts=build_items(volatile_rows, 'volatile'), + momentum_concepts=build_items(momentum_rows, 'momentum'), + summary={ + 'total_concepts': int(total_row['total_concepts']) if total_row['total_concepts'] else 0, + 'positive_count': int(total_row['positive_concepts']) if total_row['positive_concepts'] else 0, + 'negative_count': int(total_row['negative_concepts']) if total_row['negative_concepts'] else 0, + 'avg_change': round(float(total_row['overall_avg_change']), 2) if total_row['overall_avg_change'] else 0.0, + 'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'date_range': f"{start_date} 至 {end_date}", + 'days': (end_date - start_date).days + 1, + 'start_date': str(start_date), + 'end_date': str(end_date) + } + ) + + # 补充空数据 + if not statistics.hot_concepts or not statistics.cold_concepts: + fallback = get_fallback_statistics(start_date, end_date) + if not statistics.hot_concepts: + statistics.hot_concepts = fallback.hot_concepts + if not statistics.cold_concepts: + statistics.cold_concepts = fallback.cold_concepts + + return ConceptStatisticsResponse( + success=True, + data=statistics, + params={ + 'days': (end_date - start_date).days + 1, + 'min_stock_count': min_stock_count, + 'start_date': str(start_date), + 'end_date': str(end_date), + 'concept_type': type_filter + } + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取概念统计失败: {e}") + return ConceptStatisticsResponse( + success=True, + data=get_fallback_statistics(start_date, end_date), + params={ + 'days': (end_date - start_date).days + 1, + 'min_stock_count': min_stock_count, + 'start_date': str(start_date), + 'end_date': str(end_date), + 'concept_type': type_filter + }, + note=f"使用示例数据,原因: {str(e)}" + ) + + +# ==================== 层级概念涨跌幅接口 ==================== + +class HierarchyPriceItem(BaseModel): + """层级概念价格项""" + concept_id: str + concept_name: str + concept_type: str # lv1/lv2/lv3 + trade_date: date + avg_change_pct: Optional[float] = None + stock_count: Optional[int] = None + + +class HierarchyPriceResponse(BaseModel): + """层级概念价格响应""" + trade_date: date + lv1_concepts: List[HierarchyPriceItem] + lv2_concepts: List[HierarchyPriceItem] + lv3_concepts: List[HierarchyPriceItem] + total_count: int + + +@app.get("/hierarchy/price", response_model=HierarchyPriceResponse, tags=["Hierarchy"]) +async def get_hierarchy_price( + trade_date: Optional[date] = Query(None, description="交易日期,默认最新"), + lv1_filter: Optional[str] = Query(None, description="筛选特定一级分类") +): + """获取层级概念(lv1/lv2/lv3)的涨跌幅数据""" + if not mysql_pool: + raise HTTPException(status_code=503, detail="MySQL连接不可用") + + try: + async with mysql_pool.acquire() as conn: + 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'" + ) + result = await cursor.fetchone() + if not result or not result['max_date']: + raise HTTPException(status_code=404, detail="无母概念涨跌幅数据") + trade_date = result['max_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, (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( + concept_id=row['concept_id'], + concept_name=row['concept_name'], + concept_type='lv1', + 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'] + )) + + # 查询 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( + 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) + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取层级涨跌幅失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/hierarchy/{hierarchy_id}/price-timeseries", tags=["Hierarchy"]) +async def get_hierarchy_price_timeseries( + hierarchy_id: str, + start_date: date = Query(..., description="开始日期"), + end_date: date = Query(..., description="结束日期") +): + """获取层级概念(lv1/lv2/lv3)的价格时间序列""" + if not mysql_pool: + raise HTTPException(status_code=503, detail="MySQL连接不可用") + + if start_date > end_date: + raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期") + + try: + async with mysql_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + query = """ + SELECT concept_id, concept_name, concept_type, trade_date, avg_change_pct, stock_count + FROM concept_daily_stats + WHERE concept_id = %s + AND trade_date >= %s AND trade_date <= %s + ORDER BY trade_date ASC + """ + await cursor.execute(query, (hierarchy_id, start_date, end_date)) + rows = await cursor.fetchall() + + if not rows: + raise HTTPException(status_code=404, detail=f"未找到层级概念 {hierarchy_id} 的数据") + + timeseries = [] + concept_name = "" + concept_type = "" + + for row in rows: + if not concept_name: + concept_name = row['concept_name'] + concept_type = row['concept_type'] + + timeseries.append({ + '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 { + 'concept_id': hierarchy_id, + 'concept_name': concept_name, + 'concept_type': concept_type, + 'start_date': start_date, + 'end_date': end_date, + 'data_points': len(timeseries), + 'timeseries': timeseries + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取层级价格时间序列失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ==================== 主函数 ==================== + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "concept_api_v2:app", + host="0.0.0.0", + port=6801, + reload=True, + log_level="info" + ) diff --git a/concept_hierarchy_v3.json b/concept_hierarchy_v3.json new file mode 100644 index 00000000..a7eb2200 --- /dev/null +++ b/concept_hierarchy_v3.json @@ -0,0 +1,1275 @@ +{ + "hierarchy": [ + { + "lv1": "人工智能", + "lv1_id": "lv1_1", + "children": [ + { + "lv2": "AI基础设施", + "lv2_id": "lv2_1_1", + "children": [ + { + "lv3": "AI算力硬件", + "lv3_id": "lv3_1_1_1", + "concepts": [ + "AI一体机", + "AI算力芯片", + "AI芯片", + "DeepSeek智算一体机", + "GPU概念股", + "TPU芯片", + "一体机核心标的弹性测算", + "中昊芯英概念股", + "国产算力芯片", + "国产GPU", + "服务器", + "昇腾推理一体机", + "摩尔线程", + "摩尔线程IPO", + "沐曦集成", + "阿里AI芯片" + ] + }, + { + "lv3": "AI关键组件", + "lv3_id": "lv3_1_1_2", + "concepts": [ + "AI PCB", + "AI PCB英伟达M9", + "AI服务器钽电容", + "HBM", + "OCS光电路交换机", + "PCB", + "PCB设备及耗材", + "SRAM存储", + "光纤", + "光通信CPO", + "光通信", + "光芯片", + "光纤列阵单元FAU", + "博通交换机", + "改良型半加成工艺mSAP", + "服务器零部件", + "硅光技术", + "空芯光纤", + "薄膜铌酸锂", + "存储", + "存储芯片", + "存储芯片产业", + "铜互连", + "铜连接", + "钽电容", + "忆阻器", + "利基型存储DDR4" + ] + }, + { + "lv3": "AI配套设施", + "lv3_id": "lv3_1_1_3", + "concepts": [ + "AIDC供配电设备弹性", + "数据中心", + "数据中心液冷", + "数据中心电力设备", + "微泵液冷", + "微通道水冷板", + "液冷", + "液冷数据中心", + "液态金属散热", + "电池备份单元", + "电磁屏蔽", + "超级电容器", + "柴油发电机", + "钻石散热", + "英伟达电源方案" + ] + }, + { + "lv3": "算力网络与服务", + "lv3_id": "lv3_1_1_4", + "concepts": [ + "中国星际之门芜湖", + "上海算力", + "四川算力", + "大厂算力订单", + "字节算力", + "星际之门概念", + "杭州算力大会", + "核心城市智算算力", + "毫秒用算", + "算力" + ] + } + ] + }, + { + "lv2": "AI模型与软件", + "lv2_id": "lv2_1_2", + "concepts": [ + "DeepSeek FP8", + "DeepSeek", + "DeepSeek、国产算力", + "KIMI", + "MOE模型", + "Minimax", + "Nano Banana", + "SORA概念", + "国产大模型", + "文生视频", + "秘塔AI", + "阶跃星辰", + "马斯克Grok3大模型" + ] + }, + { + "lv2": "AI应用", + "lv2_id": "lv2_1_3", + "children": [ + { + "lv3": "智能体与陪伴", + "lv3_id": "lv3_1_3_1", + "concepts": [ + "AI伴侣", + "AI成人陪伴", + "AI应用陪伴智能体", + "AI智能体", + "AI应用智能体", + "AI智能体AI应用", + "AI语音助手", + "AI陪伴", + "Manus", + "字节AI陪伴", + "开发智能体" + ] + }, + { + "lv3": "行业应用", + "lv3_id": "lv3_1_3_2", + "concepts": [ + "AI4S", + "AI应用AI语料", + "AI编程", + "低代码", + "内容审核概念", + "物理AI" + ] + } + ] + }, + { + "lv2": "AI生态系统", + "lv2_id": "lv2_1_4", + "children": [ + { + "lv3": "通用生态", + "lv3_id": "lv3_1_4_1", + "concepts": [ + "云计算各厂商云", + "微软Azure云平台", + "甲骨文概念股", + "英伟达代理", + "英伟达概念", + "英伟达H20", + "超威半导体AMD", + "谷歌", + "谷歌概念" + ] + }, + { + "lv3": "阿里生态", + "lv3_id": "lv3_1_4_2", + "concepts": [ + "通义千问阿里云", + "阿里云", + "阿里云通义千问", + "阿里AI千问、灵光", + "阿里“千问”项目", + "阿里AI来听" + ] + }, + { + "lv3": "腾讯生态", + "lv3_id": "lv3_1_4_3", + "concepts": [ + "腾讯元宝", + "腾讯大模型", + "腾讯混元大模型", + "腾讯云及大模型合作公司" + ] + }, + { + "lv3": "字节跳动生态", + "lv3_id": "lv3_1_4_4", + "concepts": [ + "努比亚手机", + "华为抖音支付", + "字节概念豆包AI手机", + "字节豆包概念股", + "抖音概念", + "豆包大模型" + ] + } + ] + }, + { + "lv2": "AI综合与趋势", + "lv2_id": "lv2_1_5", + "concepts": [ + "AI-细分延伸更新", + "AI合集" + ] + } + ] + }, + { + "lv1": "半导体", + "lv1_id": "lv1_2", + "children": [ + { + "lv2": "半导体设备", + "lv2_id": "lv2_2_1", + "concepts": [ + "EDA", + "上海微电子", + "光刻机", + "光刻机宇量昇", + "半导体设备", + "大湾区芯片展览会-新凯莱", + "新凯来概念股", + "新凯来示波器", + "电子束光刻机“羲之”" + ] + }, + { + "lv2": "半导体材料", + "lv2_id": "lv2_2_2", + "concepts": [ + "PSPI", + "先进陶瓷", + "光刻胶", + "半导体抛光液", + "半导体材料", + "国产光刻胶", + "石英砂", + "磷化铟" + ] + }, + { + "lv2": "芯片设计与制造", + "lv2_id": "lv2_2_3", + "concepts": [ + "ASIC", + "GAA晶体管", + "ISP视觉", + "RISC-V", + "功率半导体", + "半导体设计", + "第三代半导体", + "端侧AI芯片", + "碳化硅", + "英诺赛科概念股" + ] + }, + { + "lv2": "先进封装", + "lv2_id": "lv2_2_4", + "concepts": [ + "半导体封测", + "半导体混合键合技术", + "玻璃基板", + "盛合晶微", + "盛合晶微概念股" + ] + }, + { + "lv2": "重点企业与IPO", + "lv2_id": "lv2_2_5", + "concepts": [ + "地平线", + "地平线概念", + "紫光展锐IPO", + "长鑫存储", + "长鑫、长江产业链", + "高通概念" + ] + }, + { + "lv2": "综合与政策", + "lv2_id": "lv2_2_6", + "concepts": [ + "半导体产业链", + "国产半导体", + "国产芯片参股公司", + "科特估半导体", + "芯片替代", + "英特尔概念股" + ] + } + ] + }, + { + "lv1": "机器人", + "lv1_id": "lv1_3", + "children": [ + { + "lv2": "人形机器人整机", + "lv2_id": "lv2_3_1", + "concepts": [ + "Optimus特斯拉机器人", + "乐聚机器人", + "人形机器人", + "人形机器人Figure", + "云深处", + "优必选", + "优必选机器人", + "华为人形机器人", + "各厂商机器人", + "奇瑞机器人潜在产业链", + "天太机器人", + "天工机器人", + "宇树人形机器人", + "宇树机器人", + "小鹏机器人", + "小米机器人", + "小米智元机器人产业链机构版", + "开普勒机器人", + "松延动力机器人", + "特斯拉人形机器人", + "智元机器人", + "荣耀华为人形机器人", + "赛力斯机器人" + ] + }, + { + "lv2": "机器人核心零部件", + "lv2_id": "lv2_3_2", + "concepts": [ + "PCB轴向磁通电机", + "人形机器人-滚柱丝杆丝杠", + "人形机器人万向节", + "人形机器人腱绳", + "机电", + "机器人皮肤仿生皮肤", + "摆线减速器", + "电子皮肤", + "轴向磁通电机" + ] + }, + { + "lv2": "机器人产业链", + "lv2_id": "lv2_3_3", + "children": [ + { + "lv3": "综合与价值链", + "lv3_id": "lv3_3_3_1", + "concepts": [ + "人形机器产业链", + "人形机器人核心标的估值弹性测算", + "人形机器人核心标的概览", + "特斯拉人形机器人价值量", + "特斯拉人形机器人弹性测算", + "特斯拉产业链" + ] + }, + { + "lv3": "轻量化材料", + "lv3_id": "lv3_3_3_2", + "concepts": [ + "人形机器人轻量化-PEEK", + "人形机器人轻量化-PEEK材料", + "机器人轻量化-镁铝合金", + "机器人轻量化—碳纤维", + "超高分子量聚乙烯纤维" + ] + }, + { + "lv3": "制造工艺与设备", + "lv3_id": "lv3_3_3_3", + "concepts": [ + "MIM概念", + "冷锻产业链", + "机器人零部件加工设备", + "金属粉末注射成形MIM" + ] + } + ] + }, + { + "lv2": "机器人软件与AI", + "lv2_id": "lv2_3_4", + "concepts": [ + "机器人-神经网络", + "机器人动作捕捉" + ] + }, + { + "lv2": "其他类型机器人", + "lv2_id": "lv2_3_5", + "concepts": [ + "外骨骼机器人", + "工业机器人", + "机器狗四足机器人", + "美的库卡机器人" + ] + } + ] + }, + { + "lv1": "消费电子", + "lv1_id": "lv1_4", + "children": [ + { + "lv2": "智能终端(端侧AI)", + "lv2_id": "lv2_4_1", + "concepts": [ + "2025CES参展公司", + "AI PC", + "AIPC", + "AI手机" + ] + }, + { + "lv2": "XR与空间计算", + "lv2_id": "lv2_4_2", + "concepts": [ + "AI手势识别", + "AI眼镜", + "AI隔空投送", + "AR镀膜", + "AR眼镜", + "META智能眼镜", + "MR", + "Rokid AR", + "消费电子-玄玑感知系统", + "智能穿戴", + "智能眼镜", + "小米眼镜", + "阿里夸克AI眼镜", + "谷歌AI眼镜-合作XREAL" + ] + }, + { + "lv2": "华为产业链", + "lv2_id": "lv2_4_3", + "children": [ + { + "lv3": "终端产品", + "lv3_id": "lv3_4_3_1", + "concepts": [ + "华为P70", + "华为Mate80", + "华为Pura70", + "华为Mate70手表", + "华为MATE70" + ] + }, + { + "lv3": "技术与生态", + "lv3_id": "lv3_4_3_2", + "concepts": [ + "华为", + "华为5G", + "华为AI容器", + "华为云", + "华为昇腾", + "华为昇腾超节点", + "华为通信大模型", + "华为海思星闪", + "华为鸿蒙", + "华为鸿蒙甄选与支付", + "华字辈", + "昇腾异构计算架构-CANN", + "鸿蒙PC" + ] + }, + { + "lv3": "芯片与算力", + "lv3_id": "lv3_4_3_3", + "concepts": [ + "华为910C", + "华为AI存储", + "华为存储OceanStor", + "华为麒麟芯片", + "磁电存储" + ] + } + ] + }, + { + "lv2": "苹果产业链", + "lv2_id": "lv2_4_4", + "concepts": [ + "果链OPEN AI复用", + "苹果MR产业链", + "苹果供应商核心公司", + "苹果机器人", + "苹果手机产业链" + ] + }, + { + "lv2": "新型显示技术", + "lv2_id": "lv2_4_5", + "concepts": [ + "华为三折叠屏", + "折叠屏", + "显影液及硅基OLED", + "苹果OLED潜在受益", + "苹果折叠屏", + "面板" + ] + } + ] + }, + { + "lv1": "智能驾驶与汽车", + "lv1_id": "lv1_5", + "children": [ + { + "lv2": "自动驾驶解决方案", + "lv2_id": "lv2_5_1", + "concepts": [ + "Robotaxi", + "京东物流Robovan", + "小马智行", + "文远知行", + "无人物流", + "无人物流车九识智能", + "无人驾驶", + "特斯拉FSD", + "特斯拉RoboTaxi概念", + "特斯拉无人驾驶出租车Robotaxi", + "自动驾驶", + "菜鸟无人物流车" + ] + }, + { + "lv2": "智能汽车产业链", + "lv2_id": "lv2_5_2", + "concepts": [ + "比亚迪产业链", + "比亚迪智驾", + "孙潇雅团队概念股", + "小米YU7供应链弹性测算", + "小米大模型", + "小米概念", + "小米算力AI互联", + "小米汽车产业链", + "小米汽车产业链弹性", + "小米汽车产业链弹性测算", + "小鹏产业链", + "理想汽车" + ] + }, + { + "lv2": "车路协同与特定场景", + "lv2_id": "lv2_5_3", + "concepts": [ + "无人驾驶-线控转向", + "无人驾驶公交", + "无人环卫车", + "矿山智驾", + "车路云-车路协同运营建设", + "车路云一体化", + "车路协同" + ] + }, + { + "lv2": "产业链综合", + "lv2_id": "lv2_5_4", + "concepts": [ + "mobileye替代概念", + "智能驾驶产业链", + "汽车安全" + ] + }, + { + "lv2": "出行服务", + "lv2_id": "lv2_5_5", + "concepts": [ + "网约车" + ] + }, + { + "lv2": "智能座舱", + "lv2_id": "lv2_5_6", + "concepts": [ + "空中成像" + ] + } + ] + }, + { + "lv1": "新能源与电力", + "lv1_id": "lv1_6", + "children": [ + { + "lv2": "新型电池技术", + "lv2_id": "lv2_6_1", + "children": [ + { + "lv3": "固态电池", + "lv3_id": "lv3_6_1_1", + "concepts": [ + "固态电池", + "固态电池-硅基负极", + "固态电池-硫化物", + "固态电池产业链", + "固态电池设备", + "固态电池负极集流体材料-铜箔", + "陶瓷隔膜骨架膜" + ] + }, + { + "lv3": "其他材料与技术", + "lv3_id": "lv3_6_1_2", + "concepts": [ + "复合集流体", + "富锂锰基材料", + "硅基负极材料", + "钠离子电池", + "隔膜" + ] + } + ] + }, + { + "lv2": "电力设备与电网", + "lv2_id": "lv2_6_2", + "concepts": [ + "北美缺电AI电力", + "变压器出海", + "固体氧化物燃料电池-SOFC", + "固态变压器SST", + "燃料电池", + "燃气设备", + "燃气轮机HRSG", + "电力", + "电力产业链", + "电力电网", + "电力设备" + ] + }, + { + "lv2": "清洁能源", + "lv2_id": "lv2_6_3", + "children": [ + { + "lv3": "光伏", + "lv3_id": "lv3_6_3_1", + "concepts": [ + "N型产业链", + "光伏", + "光伏行业兼并重组", + "光伏产业链", + "反内卷光伏", + "叠层钙钛矿", + "钙钛矿电池" + ] + }, + { + "lv3": "核能", + "lv3_id": "lv3_6_3_2", + "concepts": [ + "可控核聚变", + "微型核电", + "核电产业链", + "核电钍基熔盐堆", + "核聚变超导", + "超聚变", + "高温概念" + ] + } + ] + }, + { + "lv2": "充电桩与补能", + "lv2_id": "lv2_6_4", + "concepts": [ + "充电桩", + "华为智能充电", + "华为智能充电网络", + "换电", + "换电重卡", + "机器人充电", + "汽车无线充电", + "比亚迪兆瓦闪充" + ] + } + ] + }, + { + "lv1": "空天经济", + "lv1_id": "lv1_7", + "children": [ + { + "lv2": "低空经济", + "lv2_id": "lv2_7_1", + "concepts": [ + "eVTOL材料", + "九天无人机", + "亿航智能订单量", + "低空经济", + "低空经济&飞行汽车", + "低空经济产业链汇集", + "低空经济亿航智能", + "低空管控", + "低空物流", + "低空设计", + "小鹏汇天", + "小鹏汇天供应商", + "飞行汽车eVTOL", + "长安飞行汽车机器人概念" + ] + }, + { + "lv2": "商业航天", + "lv2_id": "lv2_7_2", + "concepts": [ + "凌空天行", + "北斗信使", + "北斗导航", + "商业航天", + "商业航天卫星通信", + "卫星出海", + "卫星互联网", + "太空行走", + "太空旅行", + "太空算力", + "手机直连卫星", + "星河动力", + "星网", + "蓝箭航天朱雀三号" + ] + }, + { + "lv2": "通信技术", + "lv2_id": "lv2_7_3", + "concepts": [ + "5.5G", + "5G-A", + "5G毫米波", + "6G概念", + "eSIM概念", + "通感一体" + ] + }, + { + "lv2": "民用航空", + "lv2_id": "lv2_7_4", + "concepts": [ + "大飞机", + "空客合作" + ] + }, + { + "lv2": "综合与主题", + "lv2_id": "lv2_7_5", + "concepts": [ + "珠海航展" + ] + } + ] + }, + { + "lv1": "国防军工", + "lv1_id": "lv1_8", + "children": [ + { + "lv2": "无人作战与信息化", + "lv2_id": "lv2_8_1", + "concepts": [ + "AI军工", + "AI无人机军工信息化", + "信息支援概念整理", + "军工信息化", + "军用无人机反无人机", + "无人机蜂群" + ] + }, + { + "lv2": "海军装备", + "lv2_id": "lv2_8_2", + "concepts": [ + "军工水面水下作战", + "国产航母", + "水下军工", + "海军", + "电磁弹射概念股", + "电磁发射设备", + "航母福建舰240430" + ] + }, + { + "lv2": "空军装备", + "lv2_id": "lv2_8_3", + "concepts": [ + "军机" + ] + }, + { + "lv2": "陆军装备", + "lv2_id": "lv2_8_4", + "concepts": [ + "远程火力" + ] + }, + { + "lv2": "军贸出海", + "lv2_id": "lv2_8_5", + "concepts": [ + "军贸", + "巴印军贸", + "巴黎航展" + ] + }, + { + "lv2": "综合与主题", + "lv2_id": "lv2_8_6", + "concepts": [ + "军工", + "军工-阅兵", + "国防军工" + ] + } + ] + }, + { + "lv1": "政策与主题", + "lv1_id": "lv1_9", + "children": [ + { + "lv2": "国企改革与市值管理", + "lv2_id": "lv2_9_1", + "concepts": [ + "中兵集团并购重组", + "中字头", + "中字头央企", + "中船合并", + "央企市值管理", + "央国企", + "央国企地产", + "央国企重组", + "安徽国资", + "地面兵装", + "整车央企重组", + "河南国资能源集团重组", + "市值管理16条-破净股", + "珠海国资", + "破净央国企", + "破净股合集", + "福建国资", + "湖北三资改革", + "国资高息股", + "高送转概念股", + "央国企AI一张图" + ] + }, + { + "lv2": "并购重组", + "lv2_id": "lv2_9_2", + "concepts": [ + "IPO终止相关企业重组预期", + "上海并购重组", + "券商合并预期", + "宝德计算机", + "并购重组", + "并购重组预期", + "消费医疗重组预期", + "湘财合并大智慧", + "科创板并购重组", + "秦淮数据", + "科技重组", + "超聚变借壳预期", + "证券", + "荣耀股改", + "重组-中科院系&海光系" + ] + }, + { + "lv2": "信创与自主可控", + "lv2_id": "lv2_9_3", + "concepts": [ + "信创概念", + "关键软件", + "国产信创概览", + "工业软件", + "自主可控", + "软件自主可控", + "信息安全", + "安全概念股", + "网络安全", + "通信设备", + "通信安全" + ] + }, + { + "lv2": "重大基建", + "lv2_id": "lv2_9_4", + "concepts": [ + "三峡水运新通道", + "新藏铁路", + "新疆概念", + "水利", + "水利工程", + "混凝土减水剂、砂石设备", + "节水产业240423", + "西部大开发", + "西部大开发240424", + "西南水电", + "西南水电站", + "西南水电站-机构测算", + "隧洞设备盾构机", + "雅下水电对电力设备增量测算-机构", + "雅下水电站", + "雅下水电站大件物流" + ] + }, + { + "lv2": "供给侧改革", + "lv2_id": "lv2_9_5", + "concepts": [ + "反内卷", + "反内卷造纸", + "反内卷食用盐", + "反内卷快递", + "反内卷合集", + "生猪", + "物流", + "牛肉", + "统一大市场", + "钢铁" + ] + }, + { + "lv2": "产业升级与制造", + "lv2_id": "lv2_9_6", + "concepts": [ + "工业设备更新", + "工业母机", + "设备更新", + "灯塔工厂" + ] + }, + { + "lv2": "国家战略", + "lv2_id": "lv2_9_7", + "concepts": [ + "2025年政府工作报告利好行业及个股", + "新型经济", + "新质生产力", + "深海数智化", + "深海经济", + "深地经济", + "首发经济" + ] + }, + { + "lv2": "区域发展与自贸区", + "lv2_id": "lv2_9_8", + "concepts": [ + "上海自贸区", + "免税离境退税", + "新型离岸贸易", + "海南", + "海南自贸区", + "海南自贸港", + "零售消费免税" + ] + }, + { + "lv2": "行业监管与规范", + "lv2_id": "lv2_9_9", + "concepts": [ + "充电宝", + "农药证件厂家", + "食品安全", + "食品安全全链条", + "预制菜" + ] + } + ] + }, + { + "lv1": "周期与材料", + "lv1_id": "lv1_10", + "children": [ + { + "lv2": "化工", + "lv2_id": "lv2_10_1", + "children": [ + { + "lv3": "行业趋势", + "lv3_id": "lv3_10_1_1", + "concepts": [ + "化工", + "化工概念", + "化工品涨价", + "涨价概念" + ] + }, + { + "lv3": "具体品种", + "lv3_id": "lv3_10_1_2", + "concepts": [ + "TMA偏苯三酸酐", + "乙烷", + "光引发剂", + "六氟磷酸锂", + "农药杀虫剂-氯虫苯甲酰胺", + "双季戊四醇", + "己内酰胺", + "有机硅", + "正丙醇", + "烧碱", + "涤纶长丝", + "电解液添加剂", + "甲苯二异氰酸酯-TDI", + "环氧丙烷", + "纯碱", + "磷化工", + "磷化工六氟磷酸锂", + "聚酯产业", + "维生素", + "苯酚丙酮", + "超硬材料", + "除草剂-烯草酮" + ] + }, + { + "lv3": "产业链", + "lv3_id": "lv3_10_1_3", + "concepts": [ + "有机硅产业链", + "电解液产业链" + ] + } + ] + }, + { + "lv2": "有色金属", + "lv2_id": "lv2_10_2", + "concepts": [ + "化工有色元素周期表", + "有色金属", + "电解铝", + "稀土", + "白银", + "铅酸电池", + "铜", + "铜产业", + "钨金属", + "钴", + "钴金属", + "钼金属", + "锡矿" + ] + } + ] + }, + { + "lv1": "大消费", + "lv1_id": "lv1_11", + "children": [ + { + "lv2": "文化传媒", + "lv2_id": "lv2_11_1", + "concepts": [ + "AI游戏", + "乙游", + "传媒出海", + "出版传媒", + "国产游戏黑神话", + "周杰伦概念股", + "幻兽帕鲁", + "影视", + "影视IP", + "影视传媒", + "影视院线", + "春节档重点影片(哪吒2)", + "智象未来", + "漫剧", + "游戏", + "游戏出海", + "疯狂动物城2", + "短剧", + "诡秘之主", + "腾讯短剧重点名单" + ] + }, + { + "lv2": "新消费", + "lv2_id": "lv2_11_2", + "concepts": [ + "上市潮玩盲盒公司", + "卡游文创玩具", + "布鲁可IP衍生品", + "布鲁可谷子经济", + "泡泡玛特概念", + "潮玩产业", + "积木玩具10大", + "谷子商城", + "谷子经济" + ] + }, + { + "lv2": "人口与社会", + "lv2_id": "lv2_11_3", + "concepts": [ + "三胎", + "多胎", + "多胎辅助生殖概念240926", + "学前教育", + "育儿补贴", + "辅助生殖" + ] + }, + { + "lv2": "体育产业", + "lv2_id": "lv2_11_4", + "concepts": [ + "体育", + "体育产业", + "冰雪经济", + "川超联赛", + "第十五届全运会", + "足球", + "足球-苏超联赛、体彩" + ] + } + ] + }, + { + "lv1": "数字经济与金融科技", + "lv1_id": "lv1_12", + "children": [ + { + "lv2": "数据要素", + "lv2_id": "lv2_12_1", + "concepts": [ + "RDA概念股", + "RWA上链— IoT设备数据采集", + "版权", + "地理信息", + "数据可信", + "数据交易所", + "数据要素", + "政务云政务IT", + "跨境数据数据要素" + ] + }, + { + "lv2": "数字金融", + "lv2_id": "lv2_12_2", + "concepts": [ + "上海浦江数链", + "互联网金融", + "复星稳定币", + "数字货币", + "树图链概念", + "稳定币-蚂蚁国际", + "稳定币RWA概念股", + "稳定币一体机", + "蚂蚁金服", + "香港金融牌照" + ] + } + ] + }, + { + "lv1": "全球宏观与贸易", + "lv1_id": "lv1_13", + "children": [ + { + "lv2": "地缘政治与冲突", + "lv2_id": "lv2_13_1", + "concepts": [ + "以伊冲突-天然气", + "以伊冲突-油运仓储", + "以伊冲突-航运", + "以伊冲突-资源化工", + "乌克兰战后重建概念", + "乌克兰重建", + "俄乌重建", + "油气", + "海外港口", + "海事反制", + "石油", + "航运", + "远洋航运", + "柬泰战争", + "黄岩岛概念股" + ] + }, + { + "lv2": "贸易政策与关系", + "lv2_id": "lv2_13_2", + "concepts": [ + "中美关系", + "中俄贸易", + "中欧贸易", + "出口管制", + "反制关税涨价预期", + "后关税战受益", + "关税减免出口链", + "关税豁免", + "绒毛浆", + "芬太尼管制", + "转口贸易出口转内销" + ] + }, + { + "lv2": "供应链重构", + "lv2_id": "lv2_13_3", + "concepts": [ + "二轮车全地形车", + "墨西哥汽车零部件", + "越南工厂" + ] + } + ] + }, + { + "lv1": "医药健康", + "lv1_id": "lv1_14", + "children": [ + { + "lv2": "创新药", + "lv2_id": "lv2_14_1", + "concepts": [ + "AI制药", + "创新药", + "创新药双抗", + "创新药相关", + "医药" + ] + }, + { + "lv2": "细胞治疗", + "lv2_id": "lv2_14_2", + "concepts": [ + "干细胞", + "干细胞概念股" + ] + } + ] + }, + { + "lv1": "前沿科技", + "lv1_id": "lv1_15", + "children": [ + { + "lv2": "量子科技", + "lv2_id": "lv2_15_1", + "concepts": [ + "量子材料钛酸锶", + "量子科技", + "量子科技产业链", + "量子科技参股公司", + "量子计算" + ] + } + ] + } + ], + "uncategorized": [] +} \ No newline at end of file diff --git a/max.docx b/max.docx new file mode 100644 index 00000000..6a9d0710 Binary files /dev/null and b/max.docx differ diff --git a/pro.docx b/pro.docx new file mode 100644 index 00000000..35100838 Binary files /dev/null and b/pro.docx differ diff --git a/src/mocks/handlers/concept.js b/src/mocks/handlers/concept.js index 8fda2180..26345eda 100644 --- a/src/mocks/handlers/concept.js +++ b/src/mocks/handlers/concept.js @@ -521,5 +521,184 @@ export const conceptHandlers = [ query: query, mode: mode }); + }), + + // ============ 层级结构 API ============ + + // 获取完整层级结构 + http.get('/concept-api/hierarchy', async () => { + await delay(300); + + console.log('[Mock Concept] 获取层级结构'); + + // 模拟层级结构数据 + const hierarchy = [ + { + id: 'lv1_1', + name: '人工智能', + concept_count: 98, + children: [ + { + id: 'lv2_1_1', + name: 'AI基础设施', + concept_count: 52, + children: [ + { id: 'lv3_1_1_1', name: 'AI算力硬件', concept_count: 16, concepts: ['AI芯片', 'GPU概念股', '服务器', 'AI一体机'] }, + { id: 'lv3_1_1_2', name: 'AI关键组件', concept_count: 24, concepts: ['HBM', 'PCB', '光通信', '存储芯片'] }, + { id: 'lv3_1_1_3', name: 'AI配套设施', concept_count: 12, concepts: ['数据中心', '液冷', '电力设备'] } + ] + }, + { + id: 'lv2_1_2', + name: 'AI模型与软件', + concept_count: 13, + concepts: ['DeepSeek', 'KIMI', 'SORA概念', '国产大模型'] + }, + { + id: 'lv2_1_3', + name: 'AI应用', + concept_count: 17, + children: [ + { id: 'lv3_1_3_1', name: '智能体与陪伴', concept_count: 11, concepts: ['AI伴侣', 'AI智能体', 'AI陪伴'] }, + { id: 'lv3_1_3_2', name: '行业应用', concept_count: 6, concepts: ['AI编程', '低代码'] } + ] + } + ] + }, + { + id: 'lv1_2', + name: '半导体', + concept_count: 45, + children: [ + { id: 'lv2_2_1', name: '半导体设备', concept_count: 10, concepts: ['光刻机', 'EDA', '半导体设备'] }, + { id: 'lv2_2_2', name: '半导体材料', concept_count: 8, concepts: ['光刻胶', '半导体材料', '石英砂'] }, + { id: 'lv2_2_3', name: '芯片设计与制造', concept_count: 10, concepts: ['第三代半导体', '碳化硅', '功率半导体'] }, + { id: 'lv2_2_4', name: '先进封装', concept_count: 5, concepts: ['玻璃基板', '半导体封测'] } + ] + }, + { + id: 'lv1_3', + name: '机器人', + concept_count: 42, + children: [ + { id: 'lv2_3_1', name: '人形机器人整机', concept_count: 20, concepts: ['特斯拉机器人', '人形机器人', '智元机器人'] }, + { id: 'lv2_3_2', name: '机器人核心零部件', concept_count: 12, concepts: ['滚柱丝杆', '电子皮肤', '轴向磁通电机'] }, + { id: 'lv2_3_3', name: '其他类型机器人', concept_count: 10, concepts: ['工业机器人', '机器狗', '外骨骼机器人'] } + ] + }, + { + id: 'lv1_4', + name: '消费电子', + concept_count: 38, + children: [ + { id: 'lv2_4_1', name: '智能终端', concept_count: 8, concepts: ['AI PC', 'AI手机'] }, + { id: 'lv2_4_2', name: 'XR与空间计算', concept_count: 14, concepts: ['AR眼镜', 'MR', '智能眼镜'] }, + { id: 'lv2_4_3', name: '华为产业链', concept_count: 16, concepts: ['华为Mate70', '鸿蒙', '华为昇腾'] } + ] + }, + { + id: 'lv1_5', + name: '智能驾驶与汽车', + concept_count: 35, + children: [ + { id: 'lv2_5_1', name: '自动驾驶解决方案', concept_count: 12, concepts: ['Robotaxi', '无人驾驶', '特斯拉FSD'] }, + { id: 'lv2_5_2', name: '智能汽车产业链', concept_count: 15, concepts: ['比亚迪产业链', '小米汽车产业链'] }, + { id: 'lv2_5_3', name: '车路协同', concept_count: 8, concepts: ['车路云一体化', '车路协同'] } + ] + }, + { + id: 'lv1_6', + name: '新能源与电力', + concept_count: 52, + children: [ + { id: 'lv2_6_1', name: '新型电池技术', concept_count: 18, concepts: ['固态电池', '钠离子电池', '硅基负极'] }, + { id: 'lv2_6_2', name: '电力设备与电网', concept_count: 20, concepts: ['电力', '变压器出海', '燃料电池'] }, + { id: 'lv2_6_3', name: '清洁能源', concept_count: 14, concepts: ['光伏', '核电', '可控核聚变'] } + ] + }, + { + id: 'lv1_7', + name: '空天经济', + concept_count: 28, + children: [ + { id: 'lv2_7_1', name: '低空经济', concept_count: 14, concepts: ['低空经济', 'eVTOL', '飞行汽车'] }, + { id: 'lv2_7_2', name: '商业航天', concept_count: 14, concepts: ['卫星互联网', '商业航天', '北斗导航'] } + ] + }, + { + id: 'lv1_8', + name: '国防军工', + concept_count: 25, + children: [ + { id: 'lv2_8_1', name: '无人作战与信息化', concept_count: 10, concepts: ['AI军工', '无人机蜂群', '军工信息化'] }, + { id: 'lv2_8_2', name: '海军装备', concept_count: 8, concepts: ['国产航母', '电磁弹射'] }, + { id: 'lv2_8_3', name: '军贸出海', concept_count: 7, concepts: ['军贸', '巴黎航展'] } + ] + } + ]; + + return HttpResponse.json({ + hierarchy, + total_lv1: hierarchy.length, + total_concepts: hierarchy.reduce((acc, h) => acc + h.concept_count, 0) + }); + }), + + // 获取层级统计数据(包含涨跌幅) + http.get('/concept-api/statistics/hierarchy', async () => { + await delay(300); + + console.log('[Mock Concept] 获取层级统计数据'); + + const statistics = [ + { lv1: '人工智能', concept_count: 98, avg_change_pct: 3.56, top_gainer: 'DeepSeek', top_gainer_change: 15.23 }, + { lv1: '半导体', concept_count: 45, avg_change_pct: 2.12, top_gainer: '光刻机', top_gainer_change: 8.76 }, + { lv1: '机器人', concept_count: 42, avg_change_pct: 4.28, top_gainer: '人形机器人', top_gainer_change: 12.45 }, + { lv1: '消费电子', concept_count: 38, avg_change_pct: 1.45, top_gainer: 'AR眼镜', top_gainer_change: 6.78 }, + { lv1: '智能驾驶与汽车', concept_count: 35, avg_change_pct: 2.89, top_gainer: 'Robotaxi', top_gainer_change: 9.32 }, + { lv1: '新能源与电力', concept_count: 52, avg_change_pct: -0.56, top_gainer: '固态电池', top_gainer_change: 5.67 }, + { lv1: '空天经济', concept_count: 28, avg_change_pct: 3.12, top_gainer: '低空经济', top_gainer_change: 11.23 }, + { lv1: '国防军工', concept_count: 25, avg_change_pct: 1.78, top_gainer: 'AI军工', top_gainer_change: 7.89 } + ]; + + return HttpResponse.json({ + statistics, + total_lv1: statistics.length, + total_concepts: statistics.reduce((acc, s) => acc + s.concept_count, 0), + market_avg_change: 2.34, + update_time: new Date().toISOString() + }); + }), + + // 获取指定层级的概念列表 + http.get('/concept-api/hierarchy/:lv1Id', async ({ params, request }) => { + await delay(300); + + const { lv1Id } = params; + const url = new URL(request.url); + const lv2Id = url.searchParams.get('lv2_id'); + + console.log('[Mock Concept] 获取层级概念列表:', { lv1Id, lv2Id }); + + // 返回该层级下的概念列表 + let concepts = generatePopularConcepts(20); + + // 添加层级信息 + concepts = concepts.map(c => ({ + ...c, + hierarchy: { + lv1: '人工智能', + lv1_id: lv1Id, + lv2: lv2Id ? 'AI基础设施' : null, + lv2_id: lv2Id + } + })); + + return HttpResponse.json({ + concepts, + total: concepts.length, + lv1_id: lv1Id, + lv2_id: lv2Id + }); }) ]; diff --git a/src/views/Concept/components/BreadcrumbNav.js b/src/views/Concept/components/BreadcrumbNav.js new file mode 100644 index 00000000..a8e1acf2 --- /dev/null +++ b/src/views/Concept/components/BreadcrumbNav.js @@ -0,0 +1,170 @@ +/** + * BreadcrumbNav - 层级筛选面包屑导航 + * + * 功能: + * 1. 显示当前选中的层级路径 + * 2. 支持点击返回上级或清除筛选 + */ +import React from 'react'; +import { + Box, + HStack, + Text, + Icon, + Badge, + IconButton, + Flex, + Tooltip, +} from '@chakra-ui/react'; +import { ChevronRightIcon, CloseIcon } from '@chakra-ui/icons'; +import { FaLayerGroup, FaFilter, FaTimes, FaHome } from 'react-icons/fa'; + +const BreadcrumbNav = ({ + filter, + onClearFilter, + onNavigate, +}) => { + // 如果没有筛选条件,不显示 + if (!filter || (!filter.lv1 && !filter.lv2 && !filter.lv3)) { + return null; + } + + const breadcrumbs = []; + + // 构建面包屑路径 + if (filter.lv1) { + breadcrumbs.push({ + label: filter.lv1, + level: 'lv1', + onClick: () => onNavigate({ lv1: filter.lv1, lv2: null, lv3: null }), + }); + } + + if (filter.lv2) { + breadcrumbs.push({ + label: filter.lv2, + level: 'lv2', + onClick: () => onNavigate({ lv1: filter.lv1, lv2: filter.lv2, lv3: null }), + }); + } + + if (filter.lv3) { + breadcrumbs.push({ + label: filter.lv3, + level: 'lv3', + onClick: null, // 最后一级不可点击 + }); + } + + return ( + + + + {/* 筛选图标 */} + + + + 层级筛选 + + + + {/* 首页入口 */} + + + + 全部 + + + + {/* 面包屑路径 */} + {breadcrumbs.map((crumb, index) => ( + + + + {crumb.label} + + + ))} + + + {/* 清除筛选按钮 */} + + } + colorScheme="red" + variant="ghost" + borderRadius="full" + onClick={onClearFilter} + aria-label="清除筛选" + /> + + + + {/* 筛选提示 */} + + 当前显示「{breadcrumbs.map(b => b.label).join(' > ')}」分类下的概念, + + 点击清除筛选 + + + + ); +}; + +export default BreadcrumbNav; diff --git a/src/views/Concept/components/HierarchyView.js b/src/views/Concept/components/HierarchyView.js new file mode 100644 index 00000000..511be394 --- /dev/null +++ b/src/views/Concept/components/HierarchyView.js @@ -0,0 +1,709 @@ +/** + * HierarchyView - 概念层级思维导图视图 + * + * 功能: + * 1. 思维导图式展示概念层级结构(lv1 → lv2 → lv3 → concepts) + * 2. 显示各层级的涨跌幅数据和概念数量 + * 3. 点击分类后切换到列表视图显示该分类下的概念 + */ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Box, + VStack, + HStack, + Text, + Badge, + Icon, + Spinner, + Center, + Flex, + Collapse, + useBreakpointValue, + Tooltip, + Tag, + TagLabel, + Wrap, + WrapItem, +} from '@chakra-ui/react'; +import { + ChevronRightIcon, + ChevronDownIcon, +} from '@chakra-ui/icons'; +import { + FaLayerGroup, + FaArrowUp, + FaArrowDown, + FaTags, + FaChartLine, + FaBrain, + FaMicrochip, + FaRobot, + FaMobileAlt, + FaCar, + FaBolt, + FaPlane, + FaShieldAlt, + FaLandmark, + FaFlask, + FaShoppingCart, + FaCoins, + FaGlobe, + FaHeartbeat, + FaAtom, +} from 'react-icons/fa'; +import { keyframes } from '@emotion/react'; +import { logger } from '../../../utils/logger'; + +// 脉冲动画 +const pulseAnimation = keyframes` + 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.4); } + 70% { transform: scale(1.02); box-shadow: 0 0 0 10px rgba(139, 92, 246, 0); } + 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(139, 92, 246, 0); } +`; + +// 连接线动画 +const flowAnimation = keyframes` + 0% { stroke-dashoffset: 20; } + 100% { stroke-dashoffset: 0; } +`; + +// 一级分类图标映射 +const LV1_ICONS = { + '人工智能': FaBrain, + '半导体': FaMicrochip, + '机器人': FaRobot, + '消费电子': FaMobileAlt, + '智能驾驶与汽车': FaCar, + '新能源与电力': FaBolt, + '空天经济': FaPlane, + '国防军工': FaShieldAlt, + '政策与主题': FaLandmark, + '周期与材料': FaFlask, + '大消费': FaShoppingCart, + '数字经济与金融科技': FaCoins, + '全球宏观与贸易': FaGlobe, + '医药健康': FaHeartbeat, + '前沿科技': FaAtom, +}; + +// 一级分类颜色映射 +const LV1_COLORS = { + '人工智能': { bg: 'purple', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' }, + '半导体': { bg: 'blue', gradient: 'linear(135deg, #4facfe 0%, #00f2fe 100%)' }, + '机器人': { bg: 'cyan', gradient: 'linear(135deg, #43e97b 0%, #38f9d7 100%)' }, + '消费电子': { bg: 'pink', gradient: 'linear(135deg, #fa709a 0%, #fee140 100%)' }, + '智能驾驶与汽车': { bg: 'orange', gradient: 'linear(135deg, #f093fb 0%, #f5576c 100%)' }, + '新能源与电力': { bg: 'green', gradient: 'linear(135deg, #11998e 0%, #38ef7d 100%)' }, + '空天经济': { bg: 'teal', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' }, + '国防军工': { bg: 'red', gradient: 'linear(135deg, #eb3349 0%, #f45c43 100%)' }, + '政策与主题': { bg: 'yellow', gradient: 'linear(135deg, #f6d365 0%, #fda085 100%)' }, + '周期与材料': { bg: 'gray', gradient: 'linear(135deg, #bdc3c7 0%, #2c3e50 100%)' }, + '大消费': { bg: 'pink', gradient: 'linear(135deg, #ff758c 0%, #ff7eb3 100%)' }, + '数字经济与金融科技': { bg: 'blue', gradient: 'linear(135deg, #4776e6 0%, #8e54e9 100%)' }, + '全球宏观与贸易': { bg: 'teal', gradient: 'linear(135deg, #00cdac 0%, #8ddad5 100%)' }, + '医药健康': { bg: 'green', gradient: 'linear(135deg, #56ab2f 0%, #a8e063 100%)' }, + '前沿科技': { bg: 'purple', gradient: 'linear(135deg, #a18cd1 0%, #fbc2eb 100%)' }, +}; + +// 获取涨跌幅颜色 +const getChangeColor = (value) => { + if (value === null || value === undefined) return 'gray'; + return value > 0 ? 'red' : value < 0 ? 'green' : 'gray'; +}; + +// 格式化涨跌幅 +const formatChangePercent = (value) => { + if (value === null || value === undefined) return null; + const formatted = value.toFixed(2); + return value > 0 ? `+${formatted}%` : `${formatted}%`; +}; + +/** + * 一级分类卡片组件 + */ +const Lv1Card = ({ + item, + isExpanded, + onToggle, + onSelectCategory, + stats +}) => { + const IconComponent = LV1_ICONS[item.name] || FaLayerGroup; + const colorConfig = LV1_COLORS[item.name] || { bg: 'purple', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' }; + const isMobile = useBreakpointValue({ base: true, md: false }); + + // 从统计数据中获取涨跌幅 + const avgChange = stats?.avg_change_pct; + const changeColor = getChangeColor(avgChange); + + return ( + + + {/* 背景装饰 */} + + + + + + {item.name} + + + + + {item.concept_count} 概念 + + + {avgChange !== null && avgChange !== undefined && ( + + 0 ? FaArrowUp : FaArrowDown} + boxSize={2} + /> + {formatChangePercent(avgChange)} + + )} + + + + + + + ); +}; + +/** + * 二级分类卡片组件 + */ +const Lv2Card = ({ + item, + parentName, + isExpanded, + onToggle, + onSelectCategory, + stats, + colorConfig +}) => { + const hasChildren = item.children && item.children.length > 0; + const avgChange = stats?.avg_change_pct; + const changeColor = getChangeColor(avgChange); + + return ( + + hasChildren ? onToggle() : onSelectCategory(parentName, item.name, null)} + transition="all 0.2s" + _hover={{ + borderColor: `${colorConfig.bg}.400`, + transform: 'translateX(4px)', + boxShadow: '0 6px 25px rgba(0, 0, 0, 0.12)', + }} + > + + + + + + {item.name} + + + + {item.concept_count} 概念 + + {avgChange !== null && avgChange !== undefined && ( + + {formatChangePercent(avgChange)} + + )} + + + + + {hasChildren ? ( + + ) : ( + + )} + + + + {/* 三级分类展开 */} + {hasChildren && ( + + + {item.children.map((lv3Item) => ( + + ))} + + + )} + + ); +}; + +/** + * 三级分类卡片组件 + */ +const Lv3Card = ({ + item, + parentLv1, + parentLv2, + onSelectCategory, + colorConfig +}) => { + return ( + onSelectCategory(parentLv1, parentLv2, item.name)} + transition="all 0.2s" + _hover={{ + bg: `${colorConfig.bg}.100`, + transform: 'translateX(4px)', + }} + > + + + + + {item.name} + + + + {item.concept_count} + + + + {/* 概念标签预览 */} + {item.concepts && item.concepts.length > 0 && ( + + {item.concepts.slice(0, 5).map((concept, idx) => ( + + + {concept} + + + ))} + {item.concepts.length > 5 && ( + + + +{item.concepts.length - 5} + + + )} + + )} + + ); +}; + +/** + * 思维导图连接线 SVG + */ +const ConnectionLine = ({ from, to, isActive }) => { + return ( + + + + ); +}; + +/** + * 主组件:层级视图 + */ +const HierarchyView = ({ + apiBaseUrl, + onSelectCategory, + selectedDate, +}) => { + const [hierarchy, setHierarchy] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedLv1, setExpandedLv1] = useState(null); + const [expandedLv2, setExpandedLv2] = useState({}); + const [hierarchyStats, setHierarchyStats] = useState(null); + + const isMobile = useBreakpointValue({ base: true, md: false }); + + // 获取层级结构数据 + const fetchHierarchy = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch(`${apiBaseUrl}/hierarchy`); + if (!response.ok) throw new Error('获取层级结构失败'); + + const data = await response.json(); + setHierarchy(data.hierarchy || []); + + logger.info('HierarchyView', '层级结构加载完成', { + totalLv1: data.hierarchy?.length, + totalConcepts: data.total_concepts + }); + } catch (err) { + logger.error('HierarchyView', 'fetchHierarchy', err); + setError(err.message); + } finally { + setLoading(false); + } + }, [apiBaseUrl]); + + // 获取层级统计数据(包含涨跌幅) + const fetchHierarchyStats = useCallback(async () => { + try { + const response = await fetch(`${apiBaseUrl}/statistics/hierarchy`); + if (!response.ok) return; + + const data = await response.json(); + setHierarchyStats(data); + + logger.info('HierarchyView', '层级统计加载完成', { + totalLv1: data.total_lv1, + totalConcepts: data.total_concepts + }); + } catch (err) { + logger.warn('HierarchyView', '获取层级统计失败', { error: err.message }); + } + }, [apiBaseUrl]); + + useEffect(() => { + fetchHierarchy(); + fetchHierarchyStats(); + }, [fetchHierarchy, fetchHierarchyStats]); + + // 获取某个 lv1 的统计数据 + const getLv1Stats = useCallback((lv1Name) => { + if (!hierarchyStats?.statistics) return null; + return hierarchyStats.statistics.find(s => s.lv1 === lv1Name); + }, [hierarchyStats]); + + // 切换一级分类展开状态 + const toggleLv1 = useCallback((lv1Id) => { + setExpandedLv1(prev => prev === lv1Id ? null : lv1Id); + setExpandedLv2({}); + }, []); + + // 切换二级分类展开状态 + const toggleLv2 = useCallback((lv2Id) => { + setExpandedLv2(prev => ({ + ...prev, + [lv2Id]: !prev[lv2Id] + })); + }, []); + + // 处理分类选择 + const handleSelectCategory = useCallback((lv1, lv2, lv3) => { + logger.info('HierarchyView', '选择分类', { lv1, lv2, lv3 }); + onSelectCategory && onSelectCategory({ lv1, lv2, lv3 }); + }, [onSelectCategory]); + + if (loading) { + return ( +
+ + + 正在加载概念层级... + +
+ ); + } + + if (error) { + return ( +
+ + + 加载失败:{error} + +
+ ); + } + + return ( + + {/* 标题 */} + + + + + 概念层级导航 + + + + 点击分类展开查看,点击具体类目筛选概念列表 + + + + {/* 思维导图布局 */} + + {/* 一级分类网格 - 居中展示 */} + + {hierarchy.map((lv1Item) => ( + toggleLv1(lv1Item.id)} + onSelectCategory={handleSelectCategory} + stats={getLv1Stats(lv1Item.name)} + /> + ))} + + + {/* 展开的二级分类 */} + {expandedLv1 && ( + + {(() => { + const lv1Item = hierarchy.find(h => h.id === expandedLv1); + if (!lv1Item) return null; + + const colorConfig = LV1_COLORS[lv1Item.name] || { bg: 'purple', gradient: 'linear(135deg, #667eea 0%, #764ba2 100%)' }; + + return ( + + {/* 展开分类的标题 */} + + + + {lv1Item.name} + + + {lv1Item.children?.length || 0} 个子分类 + + + {/* 点击筛选该一级分类 */} + handleSelectCategory(lv1Item.name, null, null)} + _hover={{ opacity: 0.8 }} + > + 筛选全部 → + + + + {/* 二级分类列表 */} + + {lv1Item.children?.map((lv2Item) => ( + + toggleLv2(lv2Item.id)} + onSelectCategory={handleSelectCategory} + colorConfig={colorConfig} + /> + + ))} + + + ); + })()} + + )} + + + {/* 统计信息 */} + + + 共 {hierarchy.length} 个一级分类 + + + {hierarchy.reduce((acc, h) => acc + (h.children?.length || 0), 0)} 个二级分类 + + + {hierarchy.reduce((acc, h) => acc + h.concept_count, 0)} 个概念 + + + + ); +}; + +export default HierarchyView; diff --git a/src/views/Concept/index.js b/src/views/Concept/index.js index ad159e46..eb8d2c2f 100644 --- a/src/views/Concept/index.js +++ b/src/views/Concept/index.js @@ -81,11 +81,13 @@ import { useBreakpointValue, } from '@chakra-ui/react'; import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons'; -import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock } from 'react-icons/fa'; +import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock, FaSitemap, FaLayerGroup } from 'react-icons/fa'; import { BsGraphUp, BsLightningFill } from 'react-icons/bs'; import { keyframes } from '@emotion/react'; import ConceptTimelineModal from './ConceptTimelineModal'; import ConceptStatsPanel from './components/ConceptStatsPanel'; +import HierarchyView from './components/HierarchyView'; +import BreadcrumbNav from './components/BreadcrumbNav'; import ConceptStocksModal from '@components/ConceptStocksModal'; import TradeDatePicker from '@components/TradeDatePicker'; // 导航栏已由 MainLayout 提供,无需在此导入 @@ -161,7 +163,10 @@ const ConceptCenter = () => { const [currentPage, setCurrentPage] = useState(1); const [totalConcepts, setTotalConcepts] = useState(0); const [totalPages, setTotalPages] = useState(1); - const [viewMode, setViewMode] = useState('grid'); + const [viewMode, setViewMode] = useState('list'); // 默认列表视图 + + // 层级筛选状态 + const [hierarchyFilter, setHierarchyFilter] = useState({ lv1: null, lv2: null, lv3: null }); // 日期相关状态 const [selectedDate, setSelectedDate] = useState(null); @@ -253,7 +258,11 @@ const ConceptCenter = () => { sort: searchParams.get('sort') || defaultSort, page: parseInt(searchParams.get('page') || '1', 10), date: searchParams.get('date') || null, - size: 12 + size: 12, + // 层级筛选参数 + lv1: searchParams.get('lv1') || null, + lv2: searchParams.get('lv2') || null, + lv3: searchParams.get('lv3') || null, }; }, [searchParams]); @@ -271,7 +280,7 @@ const ConceptCenter = () => { }, [searchParams, setSearchParams]); // 获取概念数据 - const fetchConcepts = useCallback(async (query = '', page = 1, date = selectedDate, customSortBy = null) => { + const fetchConcepts = useCallback(async (query = '', page = 1, date = selectedDate, customSortBy = null, filter = hierarchyFilter) => { setLoading(true); try { const sortToUse = customSortBy !== null ? customSortBy : sortBy; @@ -287,6 +296,14 @@ const ConceptCenter = () => { requestBody.trade_date = date.toISOString().split('T')[0]; } + // 添加层级筛选参数 + if (filter?.lv1) { + requestBody.filter_lv1 = filter.lv1; + } + if (filter?.lv2) { + requestBody.filter_lv2 = filter.lv2; + } + const response = await fetch(`${API_BASE_URL}/search`, { method: 'POST', headers: { @@ -308,24 +325,75 @@ const ConceptCenter = () => { setSelectedDate(new Date(data.price_date)); } } catch (error) { - logger.error('ConceptCenter', 'fetchConcepts', error, { query, page, date: date?.toISOString(), sortToUse }); + logger.error('ConceptCenter', 'fetchConcepts', error, { query, page, date: date?.toISOString(), sortToUse, filter }); // ❌ 移除获取数据失败toast // toast({ title: '获取数据失败', description: error.message, status: 'error', duration: 3000, isClosable: true }); } finally { setLoading(false); } - }, [pageSize, sortBy]); + }, [pageSize, sortBy, hierarchyFilter]); // 清除搜索 const handleClearSearch = () => { setSearchQuery(''); setSortBy('change_pct'); setCurrentPage(1); - updateUrlParams({ q: '', page: 1, sort: 'change_pct' }); - fetchConcepts('', 1, selectedDate, 'change_pct'); + setHierarchyFilter({ lv1: null, lv2: null, lv3: null }); + updateUrlParams({ q: '', page: 1, sort: 'change_pct', lv1: '', lv2: '', lv3: '' }); + fetchConcepts('', 1, selectedDate, 'change_pct', { lv1: null, lv2: null, lv3: null }); }; + // 处理层级筛选选择(从 HierarchyView 点击分类) + const handleHierarchySelect = useCallback((filter) => { + logger.info('ConceptCenter', '层级筛选选择', filter); + + setHierarchyFilter(filter); + setCurrentPage(1); + setViewMode('list'); // 切换到列表视图 + + // 更新 URL 参数 + updateUrlParams({ + lv1: filter.lv1 || '', + lv2: filter.lv2 || '', + lv3: filter.lv3 || '', + page: 1 + }); + + // 重新获取数据 + fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter); + + // 显示提示 + toast({ + title: '已应用筛选', + description: `正在显示「${[filter.lv1, filter.lv2, filter.lv3].filter(Boolean).join(' > ')}」分类下的概念`, + status: 'info', + duration: 2000, + isClosable: true, + }); + }, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts, toast]); + + // 清除层级筛选 + const handleClearHierarchyFilter = useCallback(() => { + setHierarchyFilter({ lv1: null, lv2: null, lv3: null }); + setCurrentPage(1); + updateUrlParams({ lv1: '', lv2: '', lv3: '', page: 1 }); + fetchConcepts(searchQuery, 1, selectedDate, sortBy, { lv1: null, lv2: null, lv3: null }); + }, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts]); + + // 导航到特定层级 + const handleNavigateHierarchy = useCallback((filter) => { + setHierarchyFilter(filter); + setCurrentPage(1); + updateUrlParams({ + lv1: filter.lv1 || '', + lv2: filter.lv2 || '', + lv3: filter.lv3 || '', + page: 1 + }); + fetchConcepts(searchQuery, 1, selectedDate, sortBy, filter); + }, [searchQuery, selectedDate, sortBy, updateUrlParams, fetchConcepts]); + // 处理搜索 const handleSearch = () => { setCurrentPage(1); @@ -556,12 +624,20 @@ const ConceptCenter = () => { setSortBy(filters.sort); setCurrentPage(filters.page); + // 恢复层级筛选状态 + const hierarchyFilterFromUrl = { + lv1: filters.lv1, + lv2: filters.lv2, + lv3: filters.lv3, + }; + setHierarchyFilter(hierarchyFilterFromUrl); + const dateToUse = filters.date ? new Date(filters.date) : latestDate; if (dateToUse) { setSelectedDate(dateToUse); - fetchConcepts(filters.q, filters.page, dateToUse, filters.sort); + fetchConcepts(filters.q, filters.page, dateToUse, filters.sort, hierarchyFilterFromUrl); } else { - fetchConcepts(filters.q, filters.page, null, filters.sort); + fetchConcepts(filters.q, filters.page, null, filters.sort, hierarchyFilterFromUrl); } }; init(); @@ -1431,46 +1507,76 @@ const ConceptCenter = () => { - } - onClick={() => { - if (viewMode !== 'grid') { - trackViewModeChanged('grid', viewMode); - setViewMode('grid'); - } - }} - bg={viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'} - color={viewMode === 'grid' ? 'white' : 'purple.500'} - borderColor="purple.500" - _hover={{ - bg: viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50', - boxShadow: viewMode === 'grid' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none', - }} - aria-label="网格视图" - /> - } - onClick={() => { - if (viewMode !== 'list') { - trackViewModeChanged('list', viewMode); - setViewMode('list'); - } - }} - bg={viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'} - color={viewMode === 'list' ? 'white' : 'purple.500'} - borderColor="purple.500" - _hover={{ - bg: viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50', - boxShadow: viewMode === 'list' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none', - }} - aria-label="列表视图" - /> + + } + onClick={() => { + if (viewMode !== 'hierarchy') { + trackViewModeChanged('hierarchy', viewMode); + setViewMode('hierarchy'); + } + }} + bg={viewMode === 'hierarchy' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'} + color={viewMode === 'hierarchy' ? 'white' : 'purple.500'} + borderColor="purple.500" + _hover={{ + bg: viewMode === 'hierarchy' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50', + boxShadow: viewMode === 'hierarchy' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none', + }} + aria-label="层级图" + /> + + + } + onClick={() => { + if (viewMode !== 'grid') { + trackViewModeChanged('grid', viewMode); + setViewMode('grid'); + } + }} + bg={viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'} + color={viewMode === 'grid' ? 'white' : 'purple.500'} + borderColor="purple.500" + _hover={{ + bg: viewMode === 'grid' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50', + boxShadow: viewMode === 'grid' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none', + }} + aria-label="网格视图" + /> + + + } + onClick={() => { + if (viewMode !== 'list') { + trackViewModeChanged('list', viewMode); + setViewMode('list'); + } + }} + bg={viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'transparent'} + color={viewMode === 'list' ? 'white' : 'purple.500'} + borderColor="purple.500" + _hover={{ + bg: viewMode === 'list' ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'purple.50', + boxShadow: viewMode === 'list' ? '0 0 10px rgba(139, 92, 246, 0.3)' : 'none', + }} + aria-label="列表视图" + /> + - {selectedDate && ( + {/* 面包屑导航 - 显示当前层级筛选 */} + + + {selectedDate && viewMode !== 'hierarchy' && ( @@ -1482,7 +1588,14 @@ const ConceptCenter = () => { )} - {loading ? ( + {/* 层级图视图 */} + {viewMode === 'hierarchy' ? ( + + ) : loading ? ( {[...Array(12)].map((_, i) => ( @@ -1590,17 +1703,32 @@ const ConceptCenter = () => { - ) : ( + ) : viewMode !== 'hierarchy' ? (
暂无概念数据 - 请尝试其他搜索关键词或选择其他日期 + + {hierarchyFilter?.lv1 + ? `「${[hierarchyFilter.lv1, hierarchyFilter.lv2, hierarchyFilter.lv3].filter(Boolean).join(' > ')}」分类下暂无数据` + : '请尝试其他搜索关键词或选择其他日期' + } + + {hierarchyFilter?.lv1 && ( + + )}
- )} + ) : null}
{/* 右侧统计面板 */}