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}
-
- : }
- onClick={() => {
- // 如果被锁定且有回调函数,触发付费弹窗
- if (isLocked && onLockedClick) {
- onLockedClick();
- } else if (onToggle !== undefined) {
- // 受控模式:调用外部回调
- onToggle();
- } else {
- // 非受控模式:使用内部状态
- setInternalExpanded(!internalExpanded);
- }
- }}
- >
- {isExpanded ? '收起' : '查看详细'}
-
-
- {/* 第二行:交易日期信息 */}
-
-
+ {/* 标题栏 */}
+
+
+
+ 相关概念
+
+ {!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) => (
-
- ))}
-
- )}
-
);
};