更新Company页面的UI为FUI风格

This commit is contained in:
2025-12-18 20:06:51 +08:00
parent 64fdb6e580
commit 4ac9b30bfb
5 changed files with 165 additions and 78 deletions

7
app.py
View File

@@ -18875,5 +18875,12 @@ if __name__ == '__main__':
# 初始化事件轮询机制WebSocket 推送) # 初始化事件轮询机制WebSocket 推送)
initialize_event_polling() initialize_event_polling()
# 启动时预热股票缓存(股票名称 + 前收盘价)
print("[启动] 正在预热股票缓存...")
try:
preload_stock_cache()
except Exception as e:
print(f"[启动] 预热缓存失败(不影响服务启动): {e}")
# 使用 socketio.run 替代 app.run 以支持 WebSocket # 使用 socketio.run 替代 app.run 以支持 WebSocket
socketio.run(app, host='0.0.0.0', port=5001, debug=False, allow_unsafe_werkzeug=True) socketio.run(app, host='0.0.0.0', port=5001, debug=False, allow_unsafe_werkzeug=True)

View File

@@ -352,6 +352,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 相关概念(可折叠) - 需要 PRO 权限 */} {/* 相关概念(可折叠) - 需要 PRO 权限 */}
<RelatedConceptsSection <RelatedConceptsSection
eventId={event.id}
eventTitle={event.title} eventTitle={event.title}
effectiveTradingDate={event.trading_date || event.created_at} effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at} eventTime={event.created_at}

View File

@@ -19,8 +19,9 @@ import ConceptStockItem from './ConceptStockItem';
/** /**
* 详细概念卡片组件 * 详细概念卡片组件
* @param {Object} props * @param {Object} props
* @param {Object} props.concept - 概念对象(兼容 v1/v2 API * @param {Object} props.concept - 概念对象(兼容 v1/v2 API 和 related_concepts 表数据
* - concept: 概念名称 * - concept: 概念名称
* - reason: 关联原因(来自 related_concepts 表)
* - stock_count: 相关股票数量 * - stock_count: 相关股票数量
* - score: 相关度0-1 * - score: 相关度0-1
* - price_info.avg_change_pct: 平均涨跌幅 * - price_info.avg_change_pct: 平均涨跌幅
@@ -34,6 +35,8 @@ const DetailedConceptCard = ({ concept, onClick }) => {
const borderColor = useColorModeValue('gray.200', 'gray.600'); const borderColor = useColorModeValue('gray.200', 'gray.600');
const headingColor = useColorModeValue('gray.700', 'gray.200'); const headingColor = useColorModeValue('gray.700', 'gray.200');
const stockCountColor = useColorModeValue('gray.500', 'gray.400'); const stockCountColor = useColorModeValue('gray.500', 'gray.400');
const reasonBg = useColorModeValue('blue.50', 'blue.900');
const reasonColor = useColorModeValue('gray.700', 'gray.200');
// 计算相关度百分比 // 计算相关度百分比
const relevanceScore = Math.round((concept.score || 0) * 100); const relevanceScore = Math.round((concept.score || 0) * 100);
@@ -43,6 +46,9 @@ const DetailedConceptCard = ({ concept, onClick }) => {
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray'; const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
const changeSymbol = changePct > 0 ? '+' : ''; const changeSymbol = changePct > 0 ? '+' : '';
// 判断是否来自数据库(有 reason 字段)
const isFromDatabase = !!concept.reason;
return ( return (
<Card <Card
bg={cardBg} bg={cardBg}
@@ -67,17 +73,27 @@ const DetailedConceptCard = ({ concept, onClick }) => {
{concept.concept} {concept.concept}
</Text> </Text>
<HStack spacing={2} flexWrap="wrap"> <HStack spacing={2} flexWrap="wrap">
<Badge colorScheme="purple" fontSize="xs"> {/* 数据库数据显示"AI分析"标签,搜索数据显示相关度 */}
相关度: {relevanceScore}% {isFromDatabase ? (
</Badge> <Badge colorScheme="green" fontSize="xs">
<Badge colorScheme="orange" fontSize="xs"> AI 分析
{concept.stock_count} 只股票 </Badge>
</Badge> ) : (
<Badge colorScheme="purple" fontSize="xs">
相关度: {relevanceScore}%
</Badge>
)}
{/* 只有搜索数据才显示股票数量 */}
{!isFromDatabase && concept.stock_count > 0 && (
<Badge colorScheme="orange" fontSize="xs">
{concept.stock_count} 只股票
</Badge>
)}
</HStack> </HStack>
</VStack> </VStack>
{/* 右侧:涨跌幅 */} {/* 右侧:涨跌幅(仅搜索数据有) */}
{concept.price_info?.avg_change_pct && ( {!isFromDatabase && concept.price_info?.avg_change_pct && (
<Box textAlign="right"> <Box textAlign="right">
<Text fontSize="xs" color={stockCountColor} mb={1}> <Text fontSize="xs" color={stockCountColor} mb={1}>
平均涨跌幅 平均涨跌幅
@@ -97,8 +113,30 @@ const DetailedConceptCard = ({ concept, onClick }) => {
<Divider /> <Divider />
{/* 概念描述 */} {/* 关联原因(来自数据库,突出显示) */}
{concept.description && ( {concept.reason && (
<Box
bg={reasonBg}
p={3}
borderRadius="md"
borderLeft="4px solid"
borderLeftColor="blue.400"
>
<Text fontSize="xs" fontWeight="bold" color="blue.500" mb={1}>
关联原因
</Text>
<Text
fontSize="sm"
color={reasonColor}
lineHeight="1.8"
>
{concept.reason}
</Text>
</Box>
)}
{/* 概念描述(仅搜索数据有,且没有 reason 时显示) */}
{!concept.reason && concept.description && (
<Text <Text
fontSize="sm" fontSize="sm"
color={stockCountColor} color={stockCountColor}

View File

@@ -14,10 +14,11 @@ import {
/** /**
* 简单概念卡片组件 * 简单概念卡片组件
* @param {Object} props * @param {Object} props
* @param {Object} props.concept - 概念对象 * @param {Object} props.concept - 概念对象(兼容搜索数据和数据库数据)
* - name: 概念名称 * - concept: 概念名称
* - reason: 关联原因(来自数据库)
* - stock_count: 相关股票数量 * - stock_count: 相关股票数量
* - relevance: 相关度0-100 * - score: 相关度0-1
* @param {Function} props.onClick - 点击回调 * @param {Function} props.onClick - 点击回调
* @param {Function} props.getRelevanceColor - 获取相关度颜色的函数 * @param {Function} props.getRelevanceColor - 获取相关度颜色的函数
*/ */
@@ -34,13 +35,16 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
const changeColor = changePct !== null ? (changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray') : null; const changeColor = changePct !== null ? (changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray') : null;
const changeSymbol = changePct !== null && changePct > 0 ? '+' : ''; const changeSymbol = changePct !== null && changePct > 0 ? '+' : '';
// 判断是否来自数据库(有 reason 字段)
const isFromDatabase = !!concept.reason;
return ( return (
<VStack <VStack
align="stretch" align="stretch"
spacing={1} spacing={1}
bg={cardBg} bg={cardBg}
borderWidth="1px" borderWidth="1px"
borderColor={borderColor} borderColor={isFromDatabase ? 'green.300' : borderColor}
borderRadius="md" borderRadius="md"
px={2} px={2}
py={1} py={1}
@@ -61,30 +65,39 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
wordBreak="break-word" wordBreak="break-word"
lineHeight="1.4" lineHeight="1.4"
> >
{concept.concept}{' '} {concept.concept}
<Text as="span" color="gray.500"> {/* 只有搜索数据才显示股票数量 */}
({concept.stock_count}) {!isFromDatabase && concept.stock_count > 0 && (
</Text> <Text as="span" color="gray.500">
{' '}({concept.stock_count})
</Text>
)}
</Text> </Text>
{/* 第二行:相关度 + 涨跌幅 */} {/* 第二行:标签 */}
<Flex justify="space-between" align="center" gap={1} flexWrap="wrap"> <Flex justify="space-between" align="center" gap={1} flexWrap="wrap">
{/* 相关度标签 */} {/* 数据库数据显示"AI分析",搜索数据显示相关度 */}
<Box {isFromDatabase ? (
bg={relevanceColors.bg} <Badge colorScheme="green" fontSize="10px" px={1.5} py={0.5}>
color={relevanceColors.color} AI 分析
px={1.5} </Badge>
py={0.5} ) : (
borderRadius="sm" <Box
flexShrink={0} bg={relevanceColors.bg}
> color={relevanceColors.color}
<Text fontSize="10px" fontWeight="medium" whiteSpace="nowrap"> px={1.5}
相关度: {relevanceScore}% py={0.5}
</Text> borderRadius="sm"
</Box> flexShrink={0}
>
<Text fontSize="10px" fontWeight="medium" whiteSpace="nowrap">
相关度: {relevanceScore}%
</Text>
</Box>
)}
{/* 涨跌幅数据 */} {/* 涨跌幅数据(仅搜索数据有) */}
{changePct !== null && ( {!isFromDatabase && changePct !== null && (
<Badge <Badge
colorScheme={changeColor} colorScheme={changeColor}
fontSize="10px" fontSize="10px"

View File

@@ -26,7 +26,8 @@ import { getApiBase } from '@utils/apiConfig';
/** /**
* 相关概念区组件 * 相关概念区组件
* @param {Object} props * @param {Object} props
* @param {string} props.eventTitle - 事件标题(用于搜索概念 * @param {number} props.eventId - 事件ID(用于获取 related_concepts 表数据
* @param {string} props.eventTitle - 事件标题(备用,当 eventId 不存在时使用搜索)
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期) * @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
* @param {string|Object} props.eventTime - 事件发生时间 * @param {string|Object} props.eventTime - 事件发生时间
* @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选) * @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选)
@@ -34,6 +35,7 @@ import { getApiBase } from '@utils/apiConfig';
* @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗) * @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗)
*/ */
const RelatedConceptsSection = ({ const RelatedConceptsSection = ({
eventId,
eventTitle, eventTitle,
effectiveTradingDate, effectiveTradingDate,
eventTime, eventTime,
@@ -57,6 +59,7 @@ const RelatedConceptsSection = ({
const textColor = useColorModeValue('gray.600', 'gray.400'); const textColor = useColorModeValue('gray.600', 'gray.400');
console.log('[RelatedConceptsSection] 组件渲染', { console.log('[RelatedConceptsSection] 组件渲染', {
eventId,
eventTitle, eventTitle,
effectiveTradingDate, effectiveTradingDate,
eventTime, eventTime,
@@ -65,16 +68,76 @@ const RelatedConceptsSection = ({
error error
}); });
// 搜索相关概念 // 获取相关概念 - 优先使用 eventId 从数据库获取
useEffect(() => { useEffect(() => {
const searchConcepts = async () => { const fetchConcepts = async () => {
console.log('[RelatedConceptsSection] useEffect 触发', { console.log('[RelatedConceptsSection] useEffect 触发', {
eventId,
eventTitle, eventTitle,
effectiveTradingDate 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,
concept_code: item.concept_code,
// 保留原有字段以兼容 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) { if (!eventTitle || !effectiveTradingDate) {
console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', { console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', {
hasEventId: !!eventId,
hasEventTitle: !!eventTitle, hasEventTitle: !!eventTitle,
hasEffectiveTradingDate: !!effectiveTradingDate hasEffectiveTradingDate: !!effectiveTradingDate
}); });
@@ -86,19 +149,14 @@ const RelatedConceptsSection = ({
setLoading(true); setLoading(true);
setError(null); setError(null);
// 格式化交易日期 - 统一使用 moment 处理 // 格式化交易日期
let formattedTradeDate; let formattedTradeDate;
try { try {
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD'); formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
// 验证日期是否有效
if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) { if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
formattedTradeDate = dayjs().format('YYYY-MM-DD'); formattedTradeDate = dayjs().format('YYYY-MM-DD');
} }
} catch (error) { } catch (error) {
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
formattedTradeDate = dayjs().format('YYYY-MM-DD'); formattedTradeDate = dayjs().format('YYYY-MM-DD');
} }
@@ -111,67 +169,37 @@ const RelatedConceptsSection = ({
}; };
const apiUrl = `${getApiBase()}/concept-api/search`; const apiUrl = `${getApiBase()}/concept-api/search`;
console.log('[RelatedConceptsSection] 发送请求', { console.log('[RelatedConceptsSection] 降级:使用搜索接口', { url: apiUrl, requestBody });
url: apiUrl,
requestBody
});
logger.debug('RelatedConceptsSection', '搜索概念', requestBody);
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody) body: JSON.stringify(requestBody)
}); });
console.log('[RelatedConceptsSection] 响应状态', {
ok: response.ok,
status: response.status,
statusText: response.statusText
});
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const data = await response.json(); const data = await response.json();
console.log('[RelatedConceptsSection] 响应数据', {
hasResults: !!data.results,
resultsCount: data.results?.length || 0,
hasDataConcepts: !!(data.data && data.data.concepts),
data: data
});
logger.debug('RelatedConceptsSection', '概念搜索响应', {
hasResults: !!data.results,
resultsCount: data.results?.length || 0
});
// 设置概念数据
if (data.results && Array.isArray(data.results)) { if (data.results && Array.isArray(data.results)) {
console.log('[RelatedConceptsSection] 设置概念数据 (results)', data.results);
setConcepts(data.results); setConcepts(data.results);
} else if (data.data && data.data.concepts) { } else if (data.data && data.data.concepts) {
// 向后兼容
console.log('[RelatedConceptsSection] 设置概念数据 (data.concepts)', data.data.concepts);
setConcepts(data.data.concepts); setConcepts(data.data.concepts);
} else { } else {
console.log('[RelatedConceptsSection] 没有找到概念数据,设置为空数组');
setConcepts([]); setConcepts([]);
} }
} catch (err) { } catch (err) {
console.error('[RelatedConceptsSection] 搜索概念失败', err); console.error('[RelatedConceptsSection] 搜索概念失败', err);
logger.error('RelatedConceptsSection', 'searchConcepts', err);
setError('加载概念数据失败'); setError('加载概念数据失败');
setConcepts([]); setConcepts([]);
} finally { } finally {
console.log('[RelatedConceptsSection] 加载完成');
setLoading(false); setLoading(false);
} }
}; };
searchConcepts(); fetchConcepts();
}, [eventTitle, effectiveTradingDate]); }, [eventId, eventTitle, effectiveTradingDate]);
// 加载中状态 // 加载中状态
if (loading) { if (loading) {