feat(frontend): AI 组件 + 创始人月报 + 健康度图表 + Copilot 浮动窗口
- 健康度图表:HealthRadar(雷达图)+ HealthGauge(仪表盘)+ HealthTrend(趋势) - 驾驶舱组件:HealthDistribution + RiskSummary - AI Copilot:浮动对话窗口(SSE 流式接收) - 创始人月报提交页:表单 + 企业选择 + 年月选择 - 投资人月报 AI 解析页:SSE 流式输出 + 健康度雷达图 + 风险检测结果 - 前端构建 14 路由成功
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
/** 投资人端布局 — B 端专业风格:左 Sidebar + 灰色内容区。 */
|
||||
/** 投资人端布局 — B 端专业风格:左 Sidebar + 灰色内容区 + AI Copilot。 */
|
||||
|
||||
import { LayoutDashboard, Building2, FileText, AlertTriangle, Settings } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CopilotWidget } from "@/components/shared/CopilotWidget";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "驾驶舱", icon: LayoutDashboard },
|
||||
@@ -39,6 +40,7 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
|
||||
</header>
|
||||
<div className="container mx-auto max-w-7xl px-4 py-6">{children}</div>
|
||||
</main>
|
||||
<CopilotWidget />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { use } from "react";
|
||||
import { Sparkles, Loader2, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { getReport, type MonthlyReport } from "@/lib/reports";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { HealthRadar } from "@/components/health/HealthRadar";
|
||||
import { HealthGauge } from "@/components/health/HealthGauge";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
|
||||
|
||||
/**
|
||||
* 投资人端 — 月报 AI 解析页。
|
||||
*/
|
||||
export default function ReportParsePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const [report, setReport] = useState<MonthlyReport | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [parseStatus, setParseStatus] = useState<"idle" | "parsing" | "done" | "error">("idle");
|
||||
const [streamText, setStreamText] = useState("");
|
||||
const [healthScores, setHealthScores] = useState<{
|
||||
total_score: number;
|
||||
financial_score: number;
|
||||
operational_score: number;
|
||||
ai_commercial_score: number;
|
||||
ai_cost_score: number;
|
||||
} | null>(null);
|
||||
const [detectedRisks, setDetectedRisks] = useState<Array<{ title: string; severity: string }>>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const resp = await getReport(id);
|
||||
if (resp.data) setReport(resp.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
async function handleParse() {
|
||||
if (!report) return;
|
||||
setParseStatus("parsing");
|
||||
setStreamText("");
|
||||
setHealthScores(null);
|
||||
setDetectedRisks([]);
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortRef.current = abortController;
|
||||
|
||||
try {
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
||||
const response = await fetch(`${API_BASE}/reports/${id}/parse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("请求失败");
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("无法读取流");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const dataStr = line.slice(6);
|
||||
try {
|
||||
const event = JSON.parse(dataStr);
|
||||
switch (event.type) {
|
||||
case "token":
|
||||
setStreamText((prev) => prev + event.content);
|
||||
break;
|
||||
case "health_score":
|
||||
setHealthScores(event.data);
|
||||
break;
|
||||
case "risks":
|
||||
setDetectedRisks(event.data || []);
|
||||
break;
|
||||
case "done":
|
||||
setParseStatus("done");
|
||||
break;
|
||||
case "error":
|
||||
setParseStatus("error");
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setParseStatus("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/reports" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft size={16} aria-hidden="true" />
|
||||
返回列表
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground">月报不存在</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/reports" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft size={16} aria-hidden="true" />
|
||||
返回列表
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{report.period_year}年{report.period_month}月月报
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">状态:{report.status}</p>
|
||||
</div>
|
||||
{report.status === "submitted" && (
|
||||
<button
|
||||
onClick={handleParse}
|
||||
disabled={parseStatus === "parsing"}
|
||||
className="flex items-center gap-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{parseStatus === "parsing" ? (
|
||||
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Sparkles size={16} aria-hidden="true" />
|
||||
)}
|
||||
AI 解析
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 原始内容 */}
|
||||
<div className="rounded-lg border bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">月报原文</h2>
|
||||
<pre className="whitespace-pre-wrap text-sm text-foreground">
|
||||
{report.raw_content || "暂无内容"}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* AI 解析流式输出 */}
|
||||
{parseStatus !== "idle" && (
|
||||
<div className="rounded-lg border bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-[var(--investor-primary)]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold">AI 解析结果</h2>
|
||||
{parseStatus === "done" && (
|
||||
<CheckCircle size={16} className="text-emerald-600" aria-hidden="true" />
|
||||
)}
|
||||
{parseStatus === "error" && (
|
||||
<AlertCircle size={16} className="text-rose-600" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap text-sm text-foreground">
|
||||
{streamText || "等待输出..."}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 健康度评分 */}
|
||||
{healthScores && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold text-muted-foreground">健康度评分</h2>
|
||||
<div className="flex items-center gap-6">
|
||||
<HealthGauge score={healthScores.total_score} size={120} />
|
||||
<HealthRadar
|
||||
scores={{
|
||||
financial: healthScores.financial_score,
|
||||
operational: healthScores.operational_score,
|
||||
ai_commercial: healthScores.ai_commercial_score,
|
||||
ai_cost: healthScores.ai_cost_score,
|
||||
}}
|
||||
size={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 检测到的风险 */}
|
||||
<div className="rounded-lg border bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">自动检测风险</h2>
|
||||
{detectedRisks.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-emerald-600">未检测到风险 ✅</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{detectedRisks.map((risk, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 rounded-md bg-amber-50 px-3 py-2">
|
||||
<AlertCircle size={14} className="text-amber-600" aria-hidden="true" />
|
||||
<span className="text-sm">{risk.title}</span>
|
||||
<span className="ml-auto text-xs text-amber-600">{risk.severity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已有 AI 摘要 */}
|
||||
{report.ai_summary && parseStatus === "idle" && (
|
||||
<div className="rounded-lg border bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">AI 摘要</h2>
|
||||
<p className="text-sm text-foreground">{report.ai_summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user