From 0bb47e17101e3dd4d617355447fd523937dd2ede Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 18 Dec 2025 21:10:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0Company=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=9A=84UI=E4=B8=BAFUI=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 48 ++- .../DynamicNewsDetailPanel.js | 10 +- .../RelatedConceptsSection/index.js | 390 ++++++++---------- 3 files changed, 190 insertions(+), 258 deletions(-) diff --git a/app.py b/app.py index 7d829af2..7480b2cc 100755 --- a/app.py +++ b/app.py @@ -43,6 +43,7 @@ else: import base64 import csv import io +import threading import time import urllib import uuid @@ -7517,20 +7518,16 @@ def get_stock_quotes(): prev_close_map[norm_code] = base_close_map[base_code] # 批量查询当前价格数据(从 ClickHouse) + # 使用 argMax 函数获取最新价格,比窗口函数效率高很多 batch_price_query = """ - WITH last_prices AS ( - SELECT - code, - close as last_price, - ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp DESC) as rn - FROM stock_minute - WHERE code IN %(codes)s - AND timestamp >= %(start)s - AND timestamp <= %(end)s - ) - SELECT code, last_price - FROM last_prices - WHERE rn = 1 + SELECT + code, + argMax(close, timestamp) as last_price + FROM stock_minute + WHERE code IN %(codes)s + AND timestamp >= %(start)s + AND timestamp <= %(end)s + GROUP BY code """ batch_data = client.execute(batch_price_query, { @@ -7626,14 +7623,25 @@ def get_stock_quotes(): return jsonify({'success': False, 'error': str(e)}), 500 +# ==================== ClickHouse 连接池(单例模式) ==================== +_clickhouse_client = None +_clickhouse_client_lock = threading.Lock() + def get_clickhouse_client(): - return Cclient( - host='127.0.0.1', - port=9000, - user='default', - password='Zzl33818!', - database='stock' - ) + """获取 ClickHouse 客户端(单例模式,避免重复创建连接)""" + global _clickhouse_client + if _clickhouse_client is None: + with _clickhouse_client_lock: + if _clickhouse_client is None: + _clickhouse_client = Cclient( + host='127.0.0.1', + port=9000, + user='default', + password='Zzl33818!', + database='stock' + ) + print("[ClickHouse] 创建新连接(单例)") + return _clickhouse_client @app.route('/api/account/calendar/events', methods=['GET', 'POST']) diff --git a/src/components/EventDetailPanel/DynamicNewsDetailPanel.js b/src/components/EventDetailPanel/DynamicNewsDetailPanel.js index 508ce68e..a2d16d1b 100644 --- a/src/components/EventDetailPanel/DynamicNewsDetailPanel.js +++ b/src/components/EventDetailPanel/DynamicNewsDetailPanel.js @@ -198,10 +198,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => { } }, [sectionState.stocks, stocks.length, refreshQuotes]); - // 相关概念 - 展开/收起(无需加载) - const handleConceptsToggle = useCallback(() => { - dispatchSection({ type: 'TOGGLE', section: 'concepts' }); - }, []); // 历史事件对比 - 数据已预加载,只需切换展开状态 const handleHistoricalToggle = useCallback(() => { @@ -350,14 +346,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => { )} - {/* 相关概念(可折叠) - 需要 PRO 权限 */} + {/* 相关概念(手风琴样式) - 需要 PRO 权限 */} : null} isLocked={!canAccessConcepts} onLockedClick={() => handleLockedClick('相关概念', 'pro')} diff --git a/src/components/EventDetailPanel/RelatedConceptsSection/index.js b/src/components/EventDetailPanel/RelatedConceptsSection/index.js index a687e67a..23a4cab0 100644 --- a/src/components/EventDetailPanel/RelatedConceptsSection/index.js +++ b/src/components/EventDetailPanel/RelatedConceptsSection/index.js @@ -1,145 +1,140 @@ // src/components/EventDetailPanel/RelatedConceptsSection/index.js -// 相关概念区组件(主组件) +// 相关概念区组件 - 折叠手风琴样式 import React, { useState, useEffect } from 'react'; import { Box, - SimpleGrid, Flex, - Button, - Collapse, Heading, Center, Spinner, Text, + Badge, + VStack, + HStack, + Icon, + Collapse, useColorModeValue, } from '@chakra-ui/react'; -import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; +import { ChevronRightIcon, ChevronDownIcon } from '@chakra-ui/icons'; import { useNavigate } from 'react-router-dom'; -import dayjs from 'dayjs'; -import SimpleConceptCard from './SimpleConceptCard'; -import DetailedConceptCard from './DetailedConceptCard'; -import TradingDateInfo from './TradingDateInfo'; import { logger } from '@utils/logger'; import { getApiBase } from '@utils/apiConfig'; +/** + * 单个概念项组件(手风琴项) + */ +const ConceptItem = ({ concept, isExpanded, onToggle, onNavigate }) => { + const itemBg = useColorModeValue('white', 'gray.700'); + const itemHoverBg = useColorModeValue('gray.50', 'gray.650'); + const borderColor = useColorModeValue('gray.200', 'gray.600'); + const conceptColor = useColorModeValue('blue.600', 'blue.300'); + const reasonBg = useColorModeValue('blue.50', 'gray.800'); + const reasonColor = useColorModeValue('gray.700', 'gray.200'); + const iconColor = useColorModeValue('gray.500', 'gray.400'); + + return ( + + {/* 概念标题行 - 可点击展开 */} + + + + { + e.stopPropagation(); + onNavigate(concept); + }} + > + {concept.concept} + + + AI 分析 + + + + + {/* 关联原因 - 可折叠 */} + + + + {concept.reason || '暂无关联原因说明'} + + + + + ); +}; + /** * 相关概念区组件 * @param {Object} props * @param {number} props.eventId - 事件ID(用于获取 related_concepts 表数据) - * @param {string} props.eventTitle - 事件标题(备用,当 eventId 不存在时使用搜索) - * @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期) - * @param {string|Object} props.eventTime - 事件发生时间 + * @param {string} props.eventTitle - 事件标题(备用) * @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选) - * @param {boolean} props.isLocked - 是否锁定详细模式(需要付费) - * @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗) + * @param {boolean} props.isLocked - 是否锁定(需要付费) + * @param {Function} props.onLockedClick - 锁定时的点击回调 */ const RelatedConceptsSection = ({ eventId, eventTitle, - effectiveTradingDate, - eventTime, subscriptionBadge = null, isLocked = false, onLockedClick = null, - isOpen = undefined, // 新增:受控模式(外部控制展开状态) - onToggle = undefined // 新增:受控模式(外部控制展开回调) }) => { - // 使用外部 isOpen,如果没有则使用内部 useState - const [internalExpanded, setInternalExpanded] = useState(false); - const isExpanded = onToggle !== undefined ? isOpen : internalExpanded; const [concepts, setConcepts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // 记录每个概念的展开状态 + const [expandedItems, setExpandedItems] = useState({}); const navigate = useNavigate(); // 颜色配置 const sectionBg = useColorModeValue('gray.50', 'gray.750'); const headingColor = useColorModeValue('gray.700', 'gray.200'); const textColor = useColorModeValue('gray.600', 'gray.400'); + const countBadgeBg = useColorModeValue('blue.100', 'blue.800'); + const countBadgeColor = useColorModeValue('blue.700', 'blue.200'); - console.log('[RelatedConceptsSection] 组件渲染', { - eventId, - eventTitle, - effectiveTradingDate, - eventTime, - loading, - conceptsCount: concepts?.length || 0, - error - }); - - // 获取相关概念 - 优先使用 eventId 从数据库获取 + // 获取相关概念 useEffect(() => { const fetchConcepts = async () => { - console.log('[RelatedConceptsSection] useEffect 触发', { - eventId, - eventTitle, - effectiveTradingDate - }); - - // 优先使用 eventId 获取数据库中的相关概念 - if (eventId) { - try { - setLoading(true); - setError(null); - - const apiUrl = `${getApiBase()}/api/events/${eventId}/concepts`; - console.log('[RelatedConceptsSection] 从数据库获取相关概念', { url: apiUrl }); - - const response = await fetch(apiUrl, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include' - }); - - if (!response.ok) { - // 如果是 403,说明需要订阅,不是错误 - if (response.status === 403) { - console.log('[RelatedConceptsSection] 需要订阅才能查看'); - setConcepts([]); - setLoading(false); - return; - } - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - console.log('[RelatedConceptsSection] 数据库响应', data); - - if (data.success && Array.isArray(data.data)) { - // 转换数据格式,使其与原有展示逻辑兼容 - const formattedConcepts = data.data.map(item => ({ - concept: item.concept, - reason: item.reason, - // 保留原有字段以兼容 DetailedConceptCard - score: 1, // 数据库中的都是高相关度 - description: item.reason, // reason 作为描述 - stocks: [], // 暂无股票数据 - stock_count: 0 - })); - console.log('[RelatedConceptsSection] 设置概念数据', formattedConcepts); - setConcepts(formattedConcepts); - } else { - setConcepts([]); - } - } catch (err) { - console.error('[RelatedConceptsSection] 获取概念失败', err); - logger.error('RelatedConceptsSection', 'fetchConcepts', err); - setError('加载概念数据失败'); - setConcepts([]); - } finally { - setLoading(false); - } - return; - } - - // 降级方案:使用 eventTitle 搜索概念(兼容旧逻辑) - if (!eventTitle || !effectiveTradingDate) { - console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', { - hasEventId: !!eventId, - hasEventTitle: !!eventTitle, - hasEffectiveTradingDate: !!effectiveTradingDate - }); + if (!eventId) { setLoading(false); return; } @@ -148,48 +143,35 @@ const RelatedConceptsSection = ({ setLoading(true); setError(null); - // 格式化交易日期 - let formattedTradeDate; - try { - formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD'); - if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) { - formattedTradeDate = dayjs().format('YYYY-MM-DD'); - } - } catch (error) { - formattedTradeDate = dayjs().format('YYYY-MM-DD'); - } - - const requestBody = { - query: eventTitle, - size: 5, - page: 1, - sort_by: "_score", - trade_date: formattedTradeDate - }; - - const apiUrl = `${getApiBase()}/concept-api/search`; - console.log('[RelatedConceptsSection] 降级:使用搜索接口', { url: apiUrl, requestBody }); - + const apiUrl = `${getApiBase()}/api/events/${eventId}/concepts`; const response = await fetch(apiUrl, { - method: 'POST', + method: 'GET', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) + credentials: 'include' }); if (!response.ok) { + if (response.status === 403) { + setConcepts([]); + setLoading(false); + return; + } throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); - if (data.results && Array.isArray(data.results)) { - setConcepts(data.results); - } else if (data.data && data.data.concepts) { - setConcepts(data.data.concepts); + if (data.success && Array.isArray(data.data)) { + setConcepts(data.data); + // 默认展开第一个 + if (data.data.length > 0) { + setExpandedItems({ 0: true }); + } } else { setConcepts([]); } } catch (err) { - console.error('[RelatedConceptsSection] 搜索概念失败', err); + console.error('[RelatedConceptsSection] 获取概念失败', err); + logger.error('RelatedConceptsSection', 'fetchConcepts', err); setError('加载概念数据失败'); setConcepts([]); } finally { @@ -198,93 +180,66 @@ const RelatedConceptsSection = ({ }; fetchConcepts(); - }, [eventId, eventTitle, effectiveTradingDate]); + }, [eventId]); + + // 切换某个概念的展开状态 + const toggleItem = (index) => { + if (isLocked && onLockedClick) { + onLockedClick(); + return; + } + setExpandedItems(prev => ({ + ...prev, + [index]: !prev[index] + })); + }; + + // 跳转到概念中心 + const handleNavigate = (concept) => { + navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`); + }; // 加载中状态 if (loading) { return (
- + 加载相关概念中...
); } - // 判断是否有数据 const hasNoConcepts = !concepts || concepts.length === 0; - /** - * 根据相关度获取颜色(浅色背景 + 深色文字) - * @param {number} relevance - 相关度(0-100) - * @returns {Object} 包含背景色和文字色 - */ - const getRelevanceColor = (relevance) => { - if (relevance >= 90) { - return { bg: 'purple.50', color: 'purple.800' }; // 极高相关 - } else if (relevance >= 80) { - return { bg: 'pink.50', color: 'pink.800' }; // 高相关 - } else if (relevance >= 70) { - return { bg: 'orange.50', color: 'orange.800' }; // 中等相关 - } else { - return { bg: 'gray.100', color: 'gray.700' }; // 低相关 - } - }; - - /** - * 处理概念点击 - * @param {Object} concept - 概念对象 - */ - const handleConceptClick = (concept) => { - // 跳转到概念中心,并搜索该概念 - navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`); - }; - return ( - {/* 标题栏 - 两行布局 */} - - {/* 第一行:标题 + Badge + 按钮 */} - - - - 相关概念 - - {/* 订阅徽章 */} - {subscriptionBadge} - - - - {/* 第二行:交易日期信息 */} - - + {/* 标题栏 */} + + + + 相关概念 + + {!hasNoConcepts && ( + + {concepts.length} + + )} + {subscriptionBadge} + + - {/* 简单模式:横向卡片列表(总是显示) */} + {/* 概念列表 - 手风琴样式 */} {hasNoConcepts ? ( - + {error ? ( {error} ) : ( @@ -292,41 +247,18 @@ const RelatedConceptsSection = ({ )} ) : ( - + {concepts.map((concept, index) => ( - toggleItem(index)} + onNavigate={handleNavigate} /> ))} - + )} - - {/* 详细模式:卡片网格(可折叠) */} - - {hasNoConcepts ? ( - - {error ? ( - {error} - ) : ( - 暂无详细数据 - )} - - ) : ( - /* 详细概念卡片网格 */ - - {concepts.map((concept, index) => ( - - ))} - - )} - ); };