feat(frontend): AI 组件 + 创始人月报 + 健康度图表 + Copilot 浮动窗口

- 健康度图表:HealthRadar(雷达图)+ HealthGauge(仪表盘)+ HealthTrend(趋势)
- 驾驶舱组件:HealthDistribution + RiskSummary
- AI Copilot:浮动对话窗口(SSE 流式接收)
- 创始人月报提交页:表单 + 企业选择 + 年月选择
- 投资人月报 AI 解析页:SSE 流式输出 + 健康度雷达图 + 风险检测结果
- 前端构建 14 路由成功
This commit is contained in:
selfrelease
2026-07-18 22:19:43 +08:00
parent 7ec4fb0747
commit 53ae2059c5
9 changed files with 981 additions and 1 deletions
@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { FileText, Send, Loader2 } from "lucide-react";
import { listCompanies, type Company } from "@/lib/companies";
import { createReport, submitReport } from "@/lib/reports";
import { useEffect } from "react";
/**
* 创始人端 — 提交月报页。
*/
export default function FounderReportSubmitPage() {
const [companies, setCompanies] = useState<Company[]>([]);
const [companyId, setCompanyId] = useState("");
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [content, setContent] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
async function load() {
try {
const resp = await listCompanies({ page: 1, page_size: 100 });
if (resp.data) {
setCompanies(resp.data.items);
if (resp.data.items.length > 0) setCompanyId(resp.data.items[0].id);
}
} catch {
// ignore
}
}
load();
}, []);
async function handleSubmit() {
if (!companyId || !content.trim()) return;
setIsLoading(true);
setMessage("");
try {
const createResp = await createReport({
company_id: companyId,
period_year: year,
period_month: month,
raw_content: content,
});
if (createResp.data) {
const submitResp = await submitReport(createResp.data.id);
if (submitResp.data) {
setMessage("月报已提交成功!等待 AI 解析...");
setContent("");
}
}
} catch (err) {
setMessage(err instanceof Error ? err.message : "提交失败");
} finally {
setIsLoading(false);
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground"></h1>
<p className="mt-1 text-sm text-muted-foreground"> AI </p>
</div>
{/* 表单 */}
<div className="max-w-2xl space-y-4 rounded-lg border bg-white p-6 shadow-sm">
{/* 企业选择 */}
<div>
<label className="mb-1 block text-sm font-medium"></label>
<select
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--founder-primary)]"
>
{companies.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{/* 年月选择 */}
<div className="flex gap-4">
<div className="flex-1">
<label className="mb-1 block text-sm font-medium"></label>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--founder-primary)]"
>
{[2024, 2025, 2026, 2027].map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<div className="flex-1">
<label className="mb-1 block text-sm font-medium"></label>
<select
value={month}
onChange={(e) => setMonth(Number(e.target.value))}
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--founder-primary)]"
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
</div>
{/* 月报内容 */}
<div>
<label className="mb-1 block text-sm font-medium"></label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={12}
placeholder="请填写本月经营情况,包括:&#10;1. 营收及变化&#10;2. 现金余额及跑道&#10;3. 团队变动&#10;4. 关键业务进展&#10;5. 面临的挑战和需求"
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--founder-primary)]"
/>
</div>
{/* 提交按钮 */}
<button
onClick={handleSubmit}
disabled={isLoading || !companyId || !content.trim()}
className="flex items-center gap-2 rounded-md bg-[var(--founder-primary)] px-6 py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
) : (
<Send size={16} aria-hidden="true" />
)}
</button>
{message && (
<p className={`text-sm ${message.includes("成功") ? "text-emerald-600" : "text-rose-600"}`}>
{message}
</p>
)}
</div>
</div>
);
}
+3 -1
View File
@@ -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>
);
}