astock-agent/frontend/src/app/(auth)/sentiment/page.tsx
2026-05-14 17:02:13 +08:00

430 lines
18 KiB
TypeScript
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.

"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",
});
}