后端: - 拆分出 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>
321 lines
12 KiB
JavaScript
321 lines
12 KiB
JavaScript
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>
|
||
);
|
||
}
|