Files
evotraders/frontend/src/components/explain/ExplainNewsSection.jsx
cillin 3448667b79 feat: 微服务架构拆分和前后端优化
后端:
- 拆分出 agent_service, runtime_service, trading_service, news_service
- Gateway 模块化拆分 (gateway_*.py)
- 添加 domains/ 领域层
- 新增 control_client, runtime_client
- 更新 start-dev.sh 支持 split 服务模式

前端:
- 完善 API 服务层 (newsApi, tradingApi)
- 更新 vite.config.js
- Explain 组件优化

测试:
- 添加多个服务 app 测试

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:45:39 +08:00

321 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { formatDateTime } from '../../utils/formatters';
function renderFreshness(freshness) {
if (!freshness || typeof freshness !== 'object') return null;
const lastFetch = freshness.last_news_fetch || '-';
return `新闻更新到 ${lastFetch}${freshness.refreshed ? ' · 本次已刷新' : ''}`;
}
function categoryLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
const labels = {
market: '市场交易',
policy: '政策监管',
earnings: '业绩财报',
product_tech: '产品技术',
competition: '竞争格局',
management: '管理层动态',
};
return labels[normalized] || value || '';
}
function relevanceLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
const labels = {
high: '高相关',
medium: '中相关',
low: '低相关',
relevant: '高相关',
};
return labels[normalized] || value || '';
}
function analysisSourceLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'llm') return 'LLM分析';
if (normalized === 'local') return '规则分析';
return value || '';
}
function sentimentStyle(sentiment) {
const normalized = String(sentiment || '').trim().toLowerCase();
if (normalized === 'positive') {
return { border: '#16a34a', background: '#f0fdf4', color: '#166534', label: '利多' };
}
if (normalized === 'negative') {
return { border: '#dc2626', background: '#fef2f2', color: '#991b1b', label: '利空' };
}
return { border: '#6b7280', background: '#f9fafb', color: '#4b5563', label: '中性' };
}
export default function ExplainNewsSection({
newsSnapshot,
visibleNewsByCategory,
visibleNews,
selectedNewsFreshness,
activeNewsCategory,
onSelectNewsCategory,
activeNewsSentiment,
onSelectNewsSentiment,
newsCategories,
tickerNews,
isOpen,
onToggle,
}) {
return (
<div className="section">
<div className="section-header">
<h2 className="section-title">新闻面板</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 11, color: '#666666' }}>
{newsSnapshot?.source ? `最近 ${visibleNewsByCategory.length} 条 · ${newsSnapshot.source}` : `最近 ${visibleNewsByCategory.length} 条真实新闻`}
</div>
{renderFreshness(selectedNewsFreshness) ? (
<div style={{ fontSize: 11, color: '#666666' }}>
{renderFreshness(selectedNewsFreshness)}
</div>
) : null}
<button
onClick={onToggle}
style={{
border: '1px solid #111111',
background: isOpen ? '#111111' : '#ffffff',
color: isOpen ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{isOpen ? '收起新闻面板' : '展开新闻面板'}
</button>
</div>
</div>
{!isOpen ? (
<div className="empty-state">新闻面板已收起需要时再展开查看分类情绪和新闻卡片</div>
) : (
<>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
<button
onClick={() => onSelectNewsCategory('all')}
style={{
border: '1px solid #111111',
background: activeNewsCategory === 'all' ? '#111111' : '#ffffff',
color: activeNewsCategory === 'all' ? '#ffffff' : '#111111',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
全部 {visibleNews.length}
</button>
{Object.entries(newsCategories)
.filter(([, meta]) => Number(meta?.count || 0) > 0)
.map(([key, meta]) => {
const isActive = activeNewsCategory === key;
const pos = Number(meta?.positive_ids?.length || 0);
const neg = Number(meta?.negative_ids?.length || 0);
return (
<button
key={key}
onClick={() => onSelectNewsCategory(key)}
style={{
border: '1px solid #2563eb',
background: isActive ? '#2563eb' : '#ffffff',
color: isActive ? '#ffffff' : '#2563eb',
padding: '7px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{categoryLabel(meta.label || key)} {meta.count}{pos || neg ? ` · +${pos}/-${neg}` : ''}
</button>
);
})}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
{[
{ key: 'all', label: '全部情绪' },
{ key: 'positive', label: '利多' },
{ key: 'negative', label: '利空' },
{ key: 'neutral', label: '中性' }
].map((item) => {
const isActive = activeNewsSentiment === item.key;
return (
<button
key={item.key}
onClick={() => onSelectNewsSentiment(item.key)}
style={{
border: '1px solid #111111',
background: isActive ? '#111111' : '#ffffff',
color: isActive ? '#ffffff' : '#111111',
padding: '6px 10px',
fontFamily: 'inherit',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer'
}}
>
{item.label}
</button>
);
})}
</div>
{tickerNews.length === 0 ? (
<div className="empty-state">当前数据源没有返回相关新闻</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
{visibleNewsByCategory.map((item) => (
<div
key={item.id}
style={{
border: '1px solid #000000',
background: '#ffffff',
padding: 14,
minHeight: 180
}}
>
{(() => {
const sentimentMeta = sentimentStyle(item.sentiment);
return (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: `1px solid ${sentimentMeta.border}`,
background: sentimentMeta.background,
color: sentimentMeta.color,
fontSize: 10,
fontWeight: 700
}}>
{sentimentMeta.label}
</span>
{item.relevance ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #111111',
color: '#111111',
fontSize: 10,
fontWeight: 700
}}>
{relevanceLabel(item.relevance)}
</span>
) : null}
{item.analysisSource ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #6b7280',
color: '#4b5563',
fontSize: 10,
fontWeight: 700
}}>
{analysisSourceLabel(item.analysisSource)}
</span>
) : null}
{item.analysisModelLabel ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #9ca3af',
color: '#374151',
fontSize: 10,
fontWeight: 700
}}>
{item.analysisModelLabel}
</span>
) : null}
{typeof item.retT0 === 'number' ? (
<span style={{ fontSize: 10, color: item.retT0 >= 0 ? '#15803d' : '#b91c1c', fontWeight: 700 }}>
T0 {item.retT0 >= 0 ? '+' : ''}{(item.retT0 * 100).toFixed(2)}%
</span>
) : null}
</div>
);
})()}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{item.category ? (
<span style={{
display: 'inline-flex',
padding: '2px 6px',
border: '1px solid #111111',
color: '#111111',
fontSize: 10,
fontWeight: 700
}}>
{categoryLabel(item.category)}
</span>
) : null}
<strong style={{ fontSize: 13 }}>{item.title}</strong>
</div>
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
{formatDateTime(item.date)}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
{item.source}
</span>
{item.related ? (
<span style={{ fontSize: 10, color: '#666666' }}>
关联: {item.related}
</span>
) : null}
</div>
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{item.summary || '该新闻没有可用摘要。'}
</div>
{item.keyDiscussion ? (
<div style={{ marginTop: 10, fontSize: 11, lineHeight: 1.7, color: '#374151' }}>
<strong>核心讨论:</strong> {item.keyDiscussion}
</div>
) : null}
{item.reasonGrowth ? (
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#166534' }}>
<strong>利多逻辑:</strong> {item.reasonGrowth}
</div>
) : null}
{item.reasonDecrease ? (
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#991b1b' }}>
<strong>利空逻辑:</strong> {item.reasonDecrease}
</div>
) : null}
{item.url ? (
<div style={{ marginTop: 12 }}>
<a
href={item.url}
target="_blank"
rel="noreferrer"
style={{ fontSize: 11, fontWeight: 700, color: '#111111', textDecoration: 'underline' }}
>
查看原文
</a>
</div>
) : null}
</div>
))}
</div>
)}
</>
)}
</div>
);
}