"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ErrorBoundary } from "@/components/error-boundary"; import { fetchAPI } from "@/lib/api"; import type { CatalystEvent, CatalystNewsItem, ThemeCatalystScore } from "@/lib/api"; import { useWebSocket } from "@/hooks/use-websocket"; type CatalystTone = "hot" | "warm" | "quiet"; export default function SentimentPage() { const [news, setNews] = useState([]); const [events, setEvents] = useState([]); const [themes, setThemes] = useState([]); const [loading, setLoading] = useState(true); const [statusFilter, setStatusFilter] = useState("all"); const loadData = useCallback(async () => { try { const [newsData, eventData, themeData] = await Promise.all([ fetchAPI("/api/catalysts/news?limit=80&hours=72").catch(() => []), fetchAPI("/api/catalysts/recent?limit=60&hours=72").catch(() => []), fetchAPI("/api/catalysts/theme-scores?limit=20&hours=72").catch(() => []), ]); setNews(newsData); setEvents(eventData); setThemes(themeData); } finally { setLoading(false); } }, []); useEffect(() => { loadData(); }, [loadData]); useWebSocket( useCallback((message: { type: string }) => { if (message.type === "news_catalysts_ready" || message.type === "sector_scan_ready" || message.type === "scan_complete") { loadData(); } }, [loadData]) ); const analyzedCount = news.filter((item) => item.status === "analyzed").length; const pendingCount = news.filter((item) => item.status === "pending").length; const failedCount = news.filter((item) => item.status === "failed").length; const topTheme = themes[0]; const headline = buildSentimentHeadline(themes, events); const filteredNews = useMemo(() => { if (statusFilter === "all") return news; return news.filter((item) => item.status === statusFilter); }, [news, statusFilter]); const eventsById = useMemo(() => { const map = new Map(); events.forEach((event) => map.set(event.id, event)); return map; }, [events]); return (

舆情雷达

市场情绪线索

{headline.title}

{headline.detail}

item.catalyst_score >= 70).length} tone="hot" />

主题催化排行

72小时
{themes.length ? themes.slice(0, 8).map((theme, index) => ( )) : ( )}

舆情流

后台抓取的新闻、政策和公告线索。

{[ { key: "all", label: "全部", count: news.length }, { key: "analyzed", label: "已归因", count: analyzedCount }, { key: "pending", label: "待处理", count: pendingCount }, { key: "failed", label: "异常", count: failedCount }, ].map((tab) => ( ))}
{filteredNews.length ? filteredNews.map((item) => ( )) : (
{loading ? "加载舆情流..." : "暂无符合条件的舆情。"}
)}
); } function buildSentimentHeadline(themes: ThemeCatalystScore[], events: CatalystEvent[]) { const top = themes[0]; const strongCount = themes.filter((item) => item.catalyst_score >= 70).length; const policyCount = events.filter((item) => item.catalyst_type === "policy").length; if (!top) { return { title: "舆情等待后台归因", detail: "新闻采集和催化分析会在后台任务完成后更新。", action: "等待线索", risk: "无数据不判断", }; } if (top.catalyst_score >= 75) { return { title: `${top.theme_name} 舆情升温`, detail: `过去 72 小时 ${top.catalyst_count} 条催化线索,${strongCount} 个主题达到强催化阈值。${policyCount ? `其中政策类线索 ${policyCount} 条。` : ""}`, action: "优先核对资金回流", risk: "热度兑现", }; } return { title: `${top.theme_name} 有线索但未形成强共振`, detail: `当前催化分 ${top.catalyst_score.toFixed(0)},更适合观察是否被资金和板块前排确认。`, action: "跟踪扩散", risk: "证据不足", }; } function RadarMetric({ label, value, tone }: { label: string; value: number; tone: CatalystTone }) { const toneClass = getToneClass(tone); return (
{label}
{value}
); } function RadarDecision({ title, value, detail, tone }: { title: string; value: string; detail: string; tone: CatalystTone }) { const toneClass = getToneClass(tone); return (
{title}
{value}
{detail}
); } function ThemePulseRow({ theme, rank }: { theme: ThemeCatalystScore; rank: number }) { const score = Math.max(0, Math.min(theme.catalyst_score, 100)); const tone = score >= 70 ? "hot" : score >= 45 ? "warm" : "quiet"; const toneClass = getToneClass(tone); return (
{rank}
{theme.theme_name}
{theme.top_reasons?.[0] ?? "等待更多舆情证据"}
{score.toFixed(0)}
); } function NewsRow({ item, event }: { item: CatalystNewsItem; event?: CatalystEvent }) { const status = getStatusMeta(item.status); const eventTone = event ? getEventTone(event) : "quiet"; const toneClass = getToneClass(eventTone); const title = event?.title || item.title; const href = item.url || event?.url || ""; return (
{status.label} {formatSource(item.source)} {formatDateTime(item.published_at || item.created_at)}
{href ? ( {title} ) : (

{title}

)} {event?.summary || event?.llm_reason ? (

{event.llm_reason || event.summary}

) : item.error ? (

{item.error}

) : null}
{event?.themes ? (
归因:{event.themes}
) : null} {event ? (
{formatCatalystType(event.catalyst_type)} 本地已归档
) : null}
); } function CatalystInsight({ event }: { event: CatalystEvent }) { const tone = getEventTone(event); const toneClass = getToneClass(tone); return (
{formatCatalystType(event.catalyst_type)} {formatDateTime(event.published_at || event.created_at)}
{event.title}
{event.llm_reason || event.summary || "等待更多解释"}
{Math.round(event.strength)}
); } function MiniScore({ label, value, tone }: { label: string; value?: number; tone: CatalystTone }) { const toneClass = getToneClass(tone); return (
{label}
{value == null ? "--" : Math.round(value)}
); } function BoundaryLine({ text }: { text: string }) { return (
{text}
); } function EmptyLine({ text }: { text: string }) { return (
{text}
); } function getToneClass(tone: CatalystTone) { if (tone === "hot") { return { box: "border-red-500/15 bg-red-500/[0.08]", text: "text-red-300", bar: "bg-red-400", }; } if (tone === "warm") { return { box: "border-amber-500/15 bg-amber-500/[0.08]", text: "text-amber-300", bar: "bg-amber-400", }; } return { box: "border-border-subtle bg-surface-2", text: "text-text-secondary", bar: "bg-text-muted", }; } function getEventTone(event: CatalystEvent): CatalystTone { if (event.strength >= 70 || event.confidence >= 75) return "hot"; if (event.strength >= 45 || event.freshness >= 55) return "warm"; return "quiet"; } function getStatusMeta(status: string) { if (status === "analyzed") { return { label: "已归因", className: "border-emerald-500/15 bg-emerald-500/10 text-emerald-300" }; } if (status === "pending") { return { label: "待处理", className: "border-amber-500/15 bg-amber-500/10 text-amber-300" }; } if (status === "failed") { return { label: "异常", className: "border-red-500/15 bg-red-500/10 text-red-300" }; } if (status === "skipped") { return { label: "已忽略", className: "border-border-subtle bg-surface-2 text-text-muted" }; } return { label: status || "未知", className: "border-border-subtle bg-surface-2 text-text-muted" }; } function formatCatalystType(type: string) { const labels: Record = { policy: "政策", industry: "产业", event: "事件", earnings: "业绩", announcement: "公告", news: "新闻", }; return labels[type] ?? type; } function formatSource(source: string) { if (!source) return "未知来源"; return source.replace("tushare:", "").replace("rss:", ""); } function formatDateTime(value?: string | null) { if (!value) return "暂无时间"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); }