Add explain analysis workflow and UI
This commit is contained in:
308
frontend/src/components/explain/ExplainNewsSection.jsx
Normal file
308
frontend/src/components/explain/ExplainNewsSection.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React from 'react';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
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,
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user