更新Company页面的UI为FUI风格

This commit is contained in:
2025-12-18 21:10:11 +08:00
parent 1fa85639f4
commit 0bb47e1710
3 changed files with 190 additions and 258 deletions

24
app.py
View File

@@ -43,6 +43,7 @@ else:
import base64 import base64
import csv import csv
import io import io
import threading
import time import time
import urllib import urllib
import uuid import uuid
@@ -7517,20 +7518,16 @@ def get_stock_quotes():
prev_close_map[norm_code] = base_close_map[base_code] prev_close_map[norm_code] = base_close_map[base_code]
# 批量查询当前价格数据(从 ClickHouse # 批量查询当前价格数据(从 ClickHouse
# 使用 argMax 函数获取最新价格,比窗口函数效率高很多
batch_price_query = """ batch_price_query = """
WITH last_prices AS (
SELECT SELECT
code, code,
close as last_price, argMax(close, timestamp) as last_price
ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp DESC) as rn
FROM stock_minute FROM stock_minute
WHERE code IN %(codes)s WHERE code IN %(codes)s
AND timestamp >= %(start)s AND timestamp >= %(start)s
AND timestamp <= %(end)s AND timestamp <= %(end)s
) GROUP BY code
SELECT code, last_price
FROM last_prices
WHERE rn = 1
""" """
batch_data = client.execute(batch_price_query, { batch_data = client.execute(batch_price_query, {
@@ -7626,14 +7623,25 @@ def get_stock_quotes():
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
# ==================== ClickHouse 连接池(单例模式) ====================
_clickhouse_client = None
_clickhouse_client_lock = threading.Lock()
def get_clickhouse_client(): def get_clickhouse_client():
return Cclient( """获取 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', host='127.0.0.1',
port=9000, port=9000,
user='default', user='default',
password='Zzl33818!', password='Zzl33818!',
database='stock' database='stock'
) )
print("[ClickHouse] 创建新连接(单例)")
return _clickhouse_client
@app.route('/api/account/calendar/events', methods=['GET', 'POST']) @app.route('/api/account/calendar/events', methods=['GET', 'POST'])

View File

@@ -198,10 +198,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
} }
}, [sectionState.stocks, stocks.length, refreshQuotes]); }, [sectionState.stocks, stocks.length, refreshQuotes]);
// 相关概念 - 展开/收起(无需加载)
const handleConceptsToggle = useCallback(() => {
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
}, []);
// 历史事件对比 - 数据已预加载,只需切换展开状态 // 历史事件对比 - 数据已预加载,只需切换展开状态
const handleHistoricalToggle = useCallback(() => { const handleHistoricalToggle = useCallback(() => {
@@ -350,14 +346,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
)} )}
</CollapsibleSection> </CollapsibleSection>
{/* 相关概念(可折叠 - 需要 PRO 权限 */} {/* 相关概念(手风琴样式 - 需要 PRO 权限 */}
<RelatedConceptsSection <RelatedConceptsSection
eventId={event.id} eventId={event.id}
eventTitle={event.title} eventTitle={event.title}
effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at}
isOpen={sectionState.concepts.isOpen}
onToggle={handleConceptsToggle}
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null} subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessConcepts} isLocked={!canAccessConcepts}
onLockedClick={() => handleLockedClick('相关概念', 'pro')} onLockedClick={() => handleLockedClick('相关概念', 'pro')}

View File

@@ -1,91 +1,149 @@
// src/components/EventDetailPanel/RelatedConceptsSection/index.js // src/components/EventDetailPanel/RelatedConceptsSection/index.js
// 相关概念区组件(主组件) // 相关概念区组件 - 折叠手风琴样式
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Box, Box,
SimpleGrid,
Flex, Flex,
Button,
Collapse,
Heading, Heading,
Center, Center,
Spinner, Spinner,
Text, Text,
Badge,
VStack,
HStack,
Icon,
Collapse,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } 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 { 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 { logger } from '@utils/logger';
import { getApiBase } from '@utils/apiConfig'; 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 (
<Box
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
bg={itemBg}
>
{/* 概念标题行 - 可点击展开 */}
<Flex
px={3}
py={2.5}
cursor="pointer"
align="center"
justify="space-between"
_hover={{ bg: itemHoverBg }}
onClick={onToggle}
transition="background 0.2s"
>
<HStack spacing={2} flex={1}>
<Icon
as={isExpanded ? ChevronDownIcon : ChevronRightIcon}
color={iconColor}
boxSize={4}
transition="transform 0.2s"
/>
<Text
fontSize="sm"
fontWeight="medium"
color={conceptColor}
cursor="pointer"
_hover={{ textDecoration: 'underline' }}
onClick={(e) => {
e.stopPropagation();
onNavigate(concept);
}}
>
{concept.concept}
</Text>
<Badge colorScheme="green" fontSize="xs" flexShrink={0}>
AI 分析
</Badge>
</HStack>
</Flex>
{/* 关联原因 - 可折叠 */}
<Collapse in={isExpanded} animateOpacity>
<Box
px={4}
py={3}
bg={reasonBg}
borderTop="1px solid"
borderTopColor={borderColor}
>
<Text
fontSize="sm"
color={reasonColor}
lineHeight="1.8"
whiteSpace="pre-wrap"
>
{concept.reason || '暂无关联原因说明'}
</Text>
</Box>
</Collapse>
</Box>
);
};
/** /**
* 相关概念区组件 * 相关概念区组件
* @param {Object} props * @param {Object} props
* @param {number} props.eventId - 事件ID用于获取 related_concepts 表数据) * @param {number} props.eventId - 事件ID用于获取 related_concepts 表数据)
* @param {string} props.eventTitle - 事件标题(备用,当 eventId 不存在时使用搜索 * @param {string} props.eventTitle - 事件标题(备用)
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
* @param {string|Object} props.eventTime - 事件发生时间
* @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选) * @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选)
* @param {boolean} props.isLocked - 是否锁定详细模式(需要付费) * @param {boolean} props.isLocked - 是否锁定(需要付费)
* @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗) * @param {Function} props.onLockedClick - 锁定时的点击回调
*/ */
const RelatedConceptsSection = ({ const RelatedConceptsSection = ({
eventId, eventId,
eventTitle, eventTitle,
effectiveTradingDate,
eventTime,
subscriptionBadge = null, subscriptionBadge = null,
isLocked = false, isLocked = false,
onLockedClick = null, onLockedClick = null,
isOpen = undefined, // 新增:受控模式(外部控制展开状态)
onToggle = undefined // 新增:受控模式(外部控制展开回调)
}) => { }) => {
// 使用外部 isOpen如果没有则使用内部 useState
const [internalExpanded, setInternalExpanded] = useState(false);
const isExpanded = onToggle !== undefined ? isOpen : internalExpanded;
const [concepts, setConcepts] = useState([]); const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// 记录每个概念的展开状态
const [expandedItems, setExpandedItems] = useState({});
const navigate = useNavigate(); const navigate = useNavigate();
// 颜色配置 // 颜色配置
const sectionBg = useColorModeValue('gray.50', 'gray.750'); const sectionBg = useColorModeValue('gray.50', 'gray.750');
const headingColor = useColorModeValue('gray.700', 'gray.200'); const headingColor = useColorModeValue('gray.700', 'gray.200');
const textColor = useColorModeValue('gray.600', 'gray.400'); 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(() => { useEffect(() => {
const fetchConcepts = async () => { const fetchConcepts = async () => {
console.log('[RelatedConceptsSection] useEffect 触发', { if (!eventId) {
eventId, setLoading(false);
eventTitle, return;
effectiveTradingDate }
});
// 优先使用 eventId 获取数据库中的相关概念
if (eventId) {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const apiUrl = `${getApiBase()}/api/events/${eventId}/concepts`; const apiUrl = `${getApiBase()}/api/events/${eventId}/concepts`;
console.log('[RelatedConceptsSection] 从数据库获取相关概念', { url: apiUrl });
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: 'GET', method: 'GET',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -93,9 +151,7 @@ const RelatedConceptsSection = ({
}); });
if (!response.ok) { if (!response.ok) {
// 如果是 403说明需要订阅不是错误
if (response.status === 403) { if (response.status === 403) {
console.log('[RelatedConceptsSection] 需要订阅才能查看');
setConcepts([]); setConcepts([]);
setLoading(false); setLoading(false);
return; return;
@@ -104,21 +160,12 @@ const RelatedConceptsSection = ({
} }
const data = await response.json(); const data = await response.json();
console.log('[RelatedConceptsSection] 数据库响应', data);
if (data.success && Array.isArray(data.data)) { if (data.success && Array.isArray(data.data)) {
// 转换数据格式,使其与原有展示逻辑兼容 setConcepts(data.data);
const formattedConcepts = data.data.map(item => ({ // 默认展开第一个
concept: item.concept, if (data.data.length > 0) {
reason: item.reason, setExpandedItems({ 0: true });
// 保留原有字段以兼容 DetailedConceptCard }
score: 1, // 数据库中的都是高相关度
description: item.reason, // reason 作为描述
stocks: [], // 暂无股票数据
stock_count: 0
}));
console.log('[RelatedConceptsSection] 设置概念数据', formattedConcepts);
setConcepts(formattedConcepts);
} else { } else {
setConcepts([]); setConcepts([]);
} }
@@ -130,161 +177,69 @@ const RelatedConceptsSection = ({
} finally { } finally {
setLoading(false); setLoading(false);
} }
return;
}
// 降级方案:使用 eventTitle 搜索概念(兼容旧逻辑)
if (!eventTitle || !effectiveTradingDate) {
console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', {
hasEventId: !!eventId,
hasEventTitle: !!eventTitle,
hasEffectiveTradingDate: !!effectiveTradingDate
});
setLoading(false);
return;
}
try {
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 response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
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);
} else {
setConcepts([]);
}
} catch (err) {
console.error('[RelatedConceptsSection] 搜索概念失败', err);
setError('加载概念数据失败');
setConcepts([]);
} finally {
setLoading(false);
}
}; };
fetchConcepts(); 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) { if (loading) {
return ( return (
<Box bg={sectionBg} p={3} borderRadius="md"> <Box bg={sectionBg} p={3} borderRadius="md">
<Center py={4}> <Center py={4}>
<Spinner size="md" color="blue.500" mr={2} /> <Spinner size="sm" color="blue.500" mr={2} />
<Text color={textColor} fontSize="sm">加载相关概念中...</Text> <Text color={textColor} fontSize="sm">加载相关概念中...</Text>
</Center> </Center>
</Box> </Box>
); );
} }
// 判断是否有数据
const hasNoConcepts = !concepts || concepts.length === 0; 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 ( return (
<Box bg={sectionBg} p={3} borderRadius="md"> <Box bg={sectionBg} p={3} borderRadius="md">
{/* 标题栏 - 两行布局 */} {/* 标题栏 */}
<Box mb={3}> <Flex justify="space-between" align="center" mb={3}>
{/* 第一行:标题 + Badge + 按钮 */} <HStack spacing={2}>
<Flex justify="space-between" align="center" mb={2}>
<Flex align="center" gap={2}>
<Heading size="sm" color={headingColor}> <Heading size="sm" color={headingColor}>
相关概念 相关概念
</Heading> </Heading>
{/* 订阅徽章 */} {!hasNoConcepts && (
{subscriptionBadge} <Badge
</Flex> bg={countBadgeBg}
<Button color={countBadgeColor}
size="sm" fontSize="xs"
variant="ghost" px={2}
colorScheme="blue" py={0.5}
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />} borderRadius="full"
onClick={() => {
// 如果被锁定且有回调函数,触发付费弹窗
if (isLocked && onLockedClick) {
onLockedClick();
} else if (onToggle !== undefined) {
// 受控模式:调用外部回调
onToggle();
} else {
// 非受控模式:使用内部状态
setInternalExpanded(!internalExpanded);
}
}}
> >
{isExpanded ? '收起' : '查看详细'} {concepts.length}
</Button> </Badge>
)}
{subscriptionBadge}
</HStack>
</Flex> </Flex>
{/* 第二行:交易日期信息 */}
<TradingDateInfo
effectiveTradingDate={effectiveTradingDate}
eventTime={eventTime}
/>
</Box>
{/* 简单模式:横向卡片列表(总是显示) */} {/* 概念列表 - 手风琴样式 */}
{hasNoConcepts ? ( {hasNoConcepts ? (
<Box mb={isExpanded ? 3 : 0}> <Box py={2}>
{error ? ( {error ? (
<Text color="red.500" fontSize="sm">{error}</Text> <Text color="red.500" fontSize="sm">{error}</Text>
) : ( ) : (
@@ -292,41 +247,18 @@ const RelatedConceptsSection = ({
)} )}
</Box> </Box>
) : ( ) : (
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}> <VStack spacing={2} align="stretch">
{concepts.map((concept, index) => ( {concepts.map((concept, index) => (
<SimpleConceptCard <ConceptItem
key={index} key={concept.id || index}
concept={concept} concept={concept}
onClick={handleConceptClick} isExpanded={!!expandedItems[index]}
getRelevanceColor={getRelevanceColor} onToggle={() => toggleItem(index)}
onNavigate={handleNavigate}
/> />
))} ))}
</Flex> </VStack>
)} )}
{/* 详细模式:卡片网格(可折叠) */}
<Collapse in={isExpanded} animateOpacity>
{hasNoConcepts ? (
<Box py={4}>
{error ? (
<Text color="red.500" fontSize="sm" textAlign="center">{error}</Text>
) : (
<Text color={textColor} fontSize="sm" textAlign="center">暂无详细数据</Text>
)}
</Box>
) : (
/* 详细概念卡片网格 */
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{concepts.map((concept, index) => (
<DetailedConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
/>
))}
</SimpleGrid>
)}
</Collapse>
</Box> </Box>
); );
}; };