430 lines
18 KiB
TypeScript
430 lines
18 KiB
TypeScript
"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<CatalystNewsItem[]>([]);
|
||
const [events, setEvents] = useState<CatalystEvent[]>([]);
|
||
const [themes, setThemes] = useState<ThemeCatalystScore[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [statusFilter, setStatusFilter] = useState("all");
|
||
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
const [newsData, eventData, themeData] = await Promise.all([
|
||
fetchAPI<CatalystNewsItem[]>("/api/catalysts/news?limit=80&hours=72").catch(() => []),
|
||
fetchAPI<CatalystEvent[]>("/api/catalysts/recent?limit=60&hours=72").catch(() => []),
|
||
fetchAPI<ThemeCatalystScore[]>("/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<number, CatalystEvent>();
|
||
events.forEach((event) => map.set(event.id, event));
|
||
return map;
|
||
}, [events]);
|
||
|
||
return (
|
||
<ErrorBoundary>
|
||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||
<div className="animate-fade-in-up">
|
||
<h1 className="text-lg font-bold tracking-tight">舆情雷达</h1>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-4 animate-fade-in-up">
|
||
<section className="glass-card-static p-4 md:p-5">
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">市场情绪线索</div>
|
||
<h2 className="mt-2 text-xl font-bold tracking-tight text-text-primary">{headline.title}</h2>
|
||
<p className="mt-2 max-w-3xl text-sm leading-6 text-text-secondary">{headline.detail}</p>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 md:w-[280px] shrink-0">
|
||
<RadarMetric label="已分析" value={analyzedCount} tone="hot" />
|
||
<RadarMetric label="待处理" value={pendingCount} tone="warm" />
|
||
<RadarMetric label="强主题" value={themes.filter((item) => item.catalyst_score >= 70).length} tone="hot" />
|
||
<RadarMetric label="异常" value={failedCount} tone={failedCount ? "warm" : "quiet"} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||
<RadarDecision title="当前重点" value={topTheme?.theme_name ?? "暂无"} detail={topTheme ? `催化分 ${topTheme.catalyst_score.toFixed(0)},${topTheme.catalyst_count} 条线索` : "等待后台新闻归因。"} tone="hot" />
|
||
<RadarDecision title="观察方式" value={headline.action} detail="只作为主题热度和催化背景,不直接生成买卖结论。" tone="warm" />
|
||
<RadarDecision title="失效信号" value={headline.risk} detail="当新闻无法映射到资金主线时,降低权重。" tone="quiet" />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="glass-card-static p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h2 className="text-sm font-semibold text-text-primary">主题催化排行</h2>
|
||
<span className="text-[10px] text-text-muted">72小时</span>
|
||
</div>
|
||
<div className="mt-3 space-y-2.5">
|
||
{themes.length ? themes.slice(0, 8).map((theme, index) => (
|
||
<ThemePulseRow key={theme.theme_id} theme={theme} rank={index + 1} />
|
||
)) : (
|
||
<EmptyLine text={loading ? "加载舆情主题..." : "暂无主题催化"} />
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_380px] gap-4 animate-fade-in-up">
|
||
<section className="glass-card-static p-4 md:p-5">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="text-sm font-semibold text-text-primary">舆情流</h2>
|
||
<p className="mt-1 text-xs text-text-muted">后台抓取的新闻、政策和公告线索。</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{[
|
||
{ key: "all", label: "全部", count: news.length },
|
||
{ key: "analyzed", label: "已归因", count: analyzedCount },
|
||
{ key: "pending", label: "待处理", count: pendingCount },
|
||
{ key: "failed", label: "异常", count: failedCount },
|
||
].map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setStatusFilter(tab.key)}
|
||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-all ${
|
||
statusFilter === tab.key
|
||
? "border-amber-500/20 bg-amber-500/10 text-amber-300"
|
||
: "border-border-subtle bg-surface-1 text-text-muted hover:text-text-primary"
|
||
}`}
|
||
>
|
||
{tab.label} {tab.count}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-3">
|
||
{filteredNews.length ? filteredNews.map((item) => (
|
||
<NewsRow key={item.id} item={item} event={item.catalyst_id ? eventsById.get(item.catalyst_id) : undefined} />
|
||
)) : (
|
||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-8 text-center text-sm text-text-muted">
|
||
{loading ? "加载舆情流..." : "暂无符合条件的舆情。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<aside className="space-y-4">
|
||
<section className="glass-card-static p-4">
|
||
<h2 className="text-sm font-semibold text-text-primary">AI 归因摘要</h2>
|
||
<div className="mt-3 space-y-3">
|
||
{events.length ? events.slice(0, 6).map((event) => (
|
||
<CatalystInsight key={event.id} event={event} />
|
||
)) : (
|
||
<EmptyLine text={loading ? "加载归因结果..." : "暂无 AI 归因结果"} />
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="glass-card-static p-4">
|
||
<h2 className="text-sm font-semibold text-text-primary">使用边界</h2>
|
||
<div className="mt-3 space-y-2 text-xs leading-6 text-text-secondary">
|
||
<BoundaryLine text="舆情只解释催化方向,不直接给出买卖。" />
|
||
<BoundaryLine text="主题催化需要和资金流、前排强度共同确认。" />
|
||
<BoundaryLine text="页面只读取本地数据库,不触发外部抓取。" />
|
||
</div>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
</ErrorBoundary>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className={`rounded-xl border px-3 py-2 ${toneClass.box}`}>
|
||
<div className="text-[10px] text-text-muted">{label}</div>
|
||
<div className={`mt-1 font-mono text-xl font-bold tabular-nums ${toneClass.text}`}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RadarDecision({ title, value, detail, tone }: { title: string; value: string; detail: string; tone: CatalystTone }) {
|
||
const toneClass = getToneClass(tone);
|
||
return (
|
||
<div className={`rounded-2xl border p-3 ${toneClass.box}`}>
|
||
<div className="text-[11px] font-semibold text-text-secondary">{title}</div>
|
||
<div className={`mt-1 text-sm font-bold ${toneClass.text}`}>{value}</div>
|
||
<div className="mt-1 text-xs leading-5 text-text-muted">{detail}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 flex gap-3">
|
||
<div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border font-mono text-xs ${toneClass.box} ${toneClass.text}`}>
|
||
{rank}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-text-primary">{theme.theme_name}</div>
|
||
<div className="mt-1 text-[11px] text-text-muted line-clamp-1">
|
||
{theme.top_reasons?.[0] ?? "等待更多舆情证据"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={`font-mono text-sm font-bold tabular-nums ${toneClass.text}`}>{score.toFixed(0)}</div>
|
||
</div>
|
||
<div className="mt-3 h-1.5 rounded-full bg-surface-2 overflow-hidden">
|
||
<div className={`h-full rounded-full ${toneClass.bar}`} style={{ width: `${score}%` }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<article className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4 transition-colors hover:border-amber-500/15">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={`rounded-md border px-2 py-0.5 text-[10px] ${status.className}`}>{status.label}</span>
|
||
<span className="text-[10px] text-text-muted">{formatSource(item.source)}</span>
|
||
<span className="text-[10px] text-text-muted">{formatDateTime(item.published_at || item.created_at)}</span>
|
||
</div>
|
||
{href ? (
|
||
<a href={href} target="_blank" rel="noreferrer" className="mt-2 block text-sm font-semibold leading-6 text-text-primary hover:text-amber-300">
|
||
{title}
|
||
</a>
|
||
) : (
|
||
<h3 className="mt-2 text-sm font-semibold leading-6 text-text-primary">{title}</h3>
|
||
)}
|
||
{event?.summary || event?.llm_reason ? (
|
||
<p className="mt-2 text-xs leading-6 text-text-secondary line-clamp-2">
|
||
{event.llm_reason || event.summary}
|
||
</p>
|
||
) : item.error ? (
|
||
<p className="mt-2 text-xs leading-6 text-amber-300/80 line-clamp-2">{item.error}</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-2 md:w-[240px] shrink-0">
|
||
<MiniScore label="强度" value={event?.strength} tone={eventTone} />
|
||
<MiniScore label="新鲜" value={event?.freshness} tone={eventTone} />
|
||
<MiniScore label="可信" value={event?.confidence} tone={eventTone} />
|
||
</div>
|
||
</div>
|
||
{event?.themes ? (
|
||
<div className="mt-3 rounded-xl border border-border-subtle bg-surface-2/70 px-3 py-2 text-[11px] leading-5 text-text-secondary">
|
||
归因:{event.themes}
|
||
</div>
|
||
) : null}
|
||
{event ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<span className={`rounded-md border px-2 py-1 text-[10px] ${toneClass.box} ${toneClass.text}`}>{formatCatalystType(event.catalyst_type)}</span>
|
||
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">本地已归档</span>
|
||
</div>
|
||
) : null}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function CatalystInsight({ event }: { event: CatalystEvent }) {
|
||
const tone = getEventTone(event);
|
||
const toneClass = getToneClass(tone);
|
||
return (
|
||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={`rounded-md border px-2 py-0.5 text-[10px] ${toneClass.box} ${toneClass.text}`}>{formatCatalystType(event.catalyst_type)}</span>
|
||
<span className="text-[10px] text-text-muted">{formatDateTime(event.published_at || event.created_at)}</span>
|
||
</div>
|
||
<div className="mt-2 text-sm font-semibold leading-6 text-text-primary line-clamp-2">{event.title}</div>
|
||
<div className="mt-1 text-xs leading-5 text-text-secondary line-clamp-3">
|
||
{event.llm_reason || event.summary || "等待更多解释"}
|
||
</div>
|
||
</div>
|
||
<div className={`font-mono text-sm font-bold tabular-nums ${toneClass.text}`}>{Math.round(event.strength)}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MiniScore({ label, value, tone }: { label: string; value?: number; tone: CatalystTone }) {
|
||
const toneClass = getToneClass(tone);
|
||
return (
|
||
<div className="rounded-lg bg-surface-2 px-2 py-1.5">
|
||
<div className="text-[10px] text-text-muted">{label}</div>
|
||
<div className={`mt-0.5 font-mono text-xs font-semibold tabular-nums ${value == null ? "text-text-muted" : toneClass.text}`}>
|
||
{value == null ? "--" : Math.round(value)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BoundaryLine({ text }: { text: string }) {
|
||
return (
|
||
<div className="flex items-start gap-2">
|
||
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
|
||
<span>{text}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyLine({ text }: { text: string }) {
|
||
return (
|
||
<div className="rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-4 text-center text-xs text-text-muted">
|
||
{text}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<string, string> = {
|
||
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",
|
||
});
|
||
}
|