feat(frontend): AI 组件 + 创始人月报 + 健康度图表 + Copilot 浮动窗口
- 健康度图表:HealthRadar(雷达图)+ HealthGauge(仪表盘)+ HealthTrend(趋势) - 驾驶舱组件:HealthDistribution + RiskSummary - AI Copilot:浮动对话窗口(SSE 流式接收) - 创始人月报提交页:表单 + 企业选择 + 年月选择 - 投资人月报 AI 解析页:SSE 流式输出 + 健康度雷达图 + 风险检测结果 - 前端构建 14 路由成功
This commit is contained in:
@@ -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="请填写本月经营情况,包括: 1. 营收及变化 2. 现金余额及跑道 3. 团队变动 4. 关键业务进展 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
/** 投资人端布局 — B 端专业风格:左 Sidebar + 灰色内容区。 */
|
/** 投资人端布局 — B 端专业风格:左 Sidebar + 灰色内容区 + AI Copilot。 */
|
||||||
|
|
||||||
import { LayoutDashboard, Building2, FileText, AlertTriangle, Settings } from "lucide-react";
|
import { LayoutDashboard, Building2, FileText, AlertTriangle, Settings } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { CopilotWidget } from "@/components/shared/CopilotWidget";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "驾驶舱", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "驾驶舱", icon: LayoutDashboard },
|
||||||
@@ -39,6 +40,7 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
|
|||||||
</header>
|
</header>
|
||||||
<div className="container mx-auto max-w-7xl px-4 py-6">{children}</div>
|
<div className="container mx-auto max-w-7xl px-4 py-6">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
<CopilotWidget />
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { HealthGauge } from "@/components/health/HealthGauge";
|
||||||
|
|
||||||
|
/** 健康度分布组件 — 展示各评分区间企业数量。 */
|
||||||
|
|
||||||
|
interface HealthDistributionProps {
|
||||||
|
companies: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康度分布卡片。
|
||||||
|
*/
|
||||||
|
export function HealthDistribution({ companies }: HealthDistributionProps) {
|
||||||
|
const buckets = {
|
||||||
|
excellent: companies.filter((c) => c.score >= 75),
|
||||||
|
good: companies.filter((c) => c.score >= 50 && c.score < 75),
|
||||||
|
warning: companies.filter((c) => c.score >= 25 && c.score < 50),
|
||||||
|
critical: companies.filter((c) => c.score < 25),
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: "优秀 (75+)", count: buckets.excellent.length, color: "text-emerald-600", bg: "bg-emerald-50" },
|
||||||
|
{ label: "良好 (50-74)", count: buckets.good.length, color: "text-blue-600", bg: "bg-blue-50" },
|
||||||
|
{ label: "预警 (25-49)", count: buckets.warning.length, color: "text-amber-600", bg: "bg-amber-50" },
|
||||||
|
{ label: "危险 (<25)", count: buckets.critical.length, color: "text-rose-600", bg: "bg-rose-50" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-white p-5 shadow-sm">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">健康度分布</h2>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.label} className={`rounded-lg ${item.bg} p-3 text-center`}>
|
||||||
|
<p className={`text-2xl font-bold ${item.color}`}>{item.count}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{item.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{companies.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{companies.slice(0, 5).map((c) => (
|
||||||
|
<div key={c.id} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-foreground">{c.name}</span>
|
||||||
|
<HealthGauge score={c.score} size={40} label="" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { SEVERITY_LABELS, SEVERITY_COLORS, RISK_TYPE_LABELS } from "@/lib/risks";
|
||||||
|
|
||||||
|
/** 风险汇总组件 — 按严重度分组展示。 */
|
||||||
|
|
||||||
|
interface RiskSummaryProps {
|
||||||
|
risks: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
severity: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 风险汇总卡片。
|
||||||
|
*/
|
||||||
|
export function RiskSummary({ risks }: RiskSummaryProps) {
|
||||||
|
const openRisks = risks.filter((r) => r.status === "open" || r.status === "in_progress");
|
||||||
|
const bySeverity = {
|
||||||
|
critical: openRisks.filter((r) => r.severity === "critical"),
|
||||||
|
high: openRisks.filter((r) => r.severity === "high"),
|
||||||
|
medium: openRisks.filter((r) => r.severity === "medium"),
|
||||||
|
low: openRisks.filter((r) => r.severity === "low"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">风险概览</h2>
|
||||||
|
<span className="text-sm text-muted-foreground">{openRisks.length} 条待处理</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openRisks.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">暂无待处理风险</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(["critical", "high", "medium", "low"] as const).map((severity) => {
|
||||||
|
const items = bySeverity[severity];
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={severity}>
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<AlertTriangle
|
||||||
|
size={14}
|
||||||
|
className={SEVERITY_COLORS[severity]}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className={`text-xs font-medium ${SEVERITY_COLORS[severity]}`}>
|
||||||
|
{SEVERITY_LABELS[severity]} ({items.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{items.slice(0, 3).map((risk) => (
|
||||||
|
<div
|
||||||
|
key={risk.id}
|
||||||
|
className="ml-6 truncate text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
[{RISK_TYPE_LABELS[risk.type] || risk.type}]
|
||||||
|
</span>{" "}
|
||||||
|
{risk.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/** 健康度仪表盘组件 — 圆形进度仪表盘。 */
|
||||||
|
|
||||||
|
interface HealthGaugeProps {
|
||||||
|
score: number;
|
||||||
|
size?: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 圆形仪表盘(纯 SVG)。
|
||||||
|
*/
|
||||||
|
export function HealthGauge({ score, size = 120, label = "总评分" }: HealthGaugeProps) {
|
||||||
|
const center = size / 2;
|
||||||
|
const radius = size / 2 - 10;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const progress = Math.max(0, Math.min(100, score)) / 100;
|
||||||
|
const dashOffset = circumference * (1 - progress);
|
||||||
|
|
||||||
|
const color = score >= 75 ? "var(--color-emerald)" : score >= 50 ? "var(--color-amber)" : "var(--color-rose)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<svg width={size} height={size} role="img" aria-label={`${label}: ${score}分`}>
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity={0.1}
|
||||||
|
strokeWidth={8}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={8}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform={`rotate(-90 ${center} ${center})`}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={center}
|
||||||
|
y={center}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
{score.toFixed(0)}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<span className="mt-1 text-xs text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
/** 健康度雷达图组件 — 四维评分雷达图。 */
|
||||||
|
|
||||||
|
interface HealthRadarProps {
|
||||||
|
scores: {
|
||||||
|
financial: number;
|
||||||
|
operational: number;
|
||||||
|
ai_commercial: number;
|
||||||
|
ai_cost: number;
|
||||||
|
};
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
financial: "财务",
|
||||||
|
operational: "经营",
|
||||||
|
ai_commercial: "AI 商业化",
|
||||||
|
ai_cost: "AI 成本",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康度雷达图(纯 SVG,无额外依赖)。
|
||||||
|
*/
|
||||||
|
export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||||
|
const center = size / 2;
|
||||||
|
const radius = size / 2 - 40;
|
||||||
|
const axes = Object.keys(LABELS);
|
||||||
|
const angleStep = (Math.PI * 2) / axes.length;
|
||||||
|
|
||||||
|
const { gridLevels, axisLines, dataPoints, labelPositions } = useMemo(() => {
|
||||||
|
const levels = [0.25, 0.5, 0.75, 1.0];
|
||||||
|
const grid = levels.map((level) =>
|
||||||
|
axes.map((_, i) => {
|
||||||
|
const angle = i * angleStep - Math.PI / 2;
|
||||||
|
return {
|
||||||
|
x: center + Math.cos(angle) * radius * level,
|
||||||
|
y: center + Math.sin(angle) * radius * level,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const axis = axes.map((_, i) => {
|
||||||
|
const angle = i * angleStep - Math.PI / 2;
|
||||||
|
return {
|
||||||
|
x: center + Math.cos(angle) * radius,
|
||||||
|
y: center + Math.sin(angle) * radius,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const values = [scores.financial, scores.operational, scores.ai_commercial, scores.ai_cost];
|
||||||
|
const points = values.map((val, i) => {
|
||||||
|
const angle = i * angleStep - Math.PI / 2;
|
||||||
|
const r = (val / 100) * radius;
|
||||||
|
return {
|
||||||
|
x: center + Math.cos(angle) * r,
|
||||||
|
y: center + Math.sin(angle) * r,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = axes.map((key, i) => {
|
||||||
|
const angle = i * angleStep - Math.PI / 2;
|
||||||
|
return {
|
||||||
|
x: center + Math.cos(angle) * (radius + 20),
|
||||||
|
y: center + Math.sin(angle) * (radius + 20),
|
||||||
|
label: LABELS[key],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { gridLevels: grid, axisLines: axis, dataPoints: points, labelPositions: labels };
|
||||||
|
}, [scores, center, radius, angleStep]);
|
||||||
|
|
||||||
|
const polygonPoints = dataPoints.map((p) => `${p.x},${p.y}`).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} role="img" aria-label="健康度雷达图">
|
||||||
|
{/* 网格 */}
|
||||||
|
{gridLevels.map((level, idx) => (
|
||||||
|
<polygon
|
||||||
|
key={idx}
|
||||||
|
points={level.map((p) => `${p.x},${p.y}`).join(" ")}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity={0.15}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 轴线 */}
|
||||||
|
{axisLines.map((line, idx) => (
|
||||||
|
<line
|
||||||
|
key={idx}
|
||||||
|
x1={center}
|
||||||
|
y1={center}
|
||||||
|
x2={line.x}
|
||||||
|
y2={line.y}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeOpacity={0.2}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 数据区域 */}
|
||||||
|
<polygon
|
||||||
|
points={polygonPoints}
|
||||||
|
fill="var(--investor-primary)"
|
||||||
|
fillOpacity={0.2}
|
||||||
|
stroke="var(--investor-primary)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 数据点 */}
|
||||||
|
{dataPoints.map((p, idx) => (
|
||||||
|
<circle
|
||||||
|
key={idx}
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={4}
|
||||||
|
fill="var(--investor-primary)"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
{labelPositions.map((lp, idx) => (
|
||||||
|
<text
|
||||||
|
key={idx}
|
||||||
|
x={lp.x}
|
||||||
|
y={lp.y}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
className="text-xs fill-muted-foreground"
|
||||||
|
>
|
||||||
|
{lp.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
/** 健康度趋势 Sparkline 组件。 */
|
||||||
|
|
||||||
|
interface HealthTrendProps {
|
||||||
|
scores: number[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 趋势 Sparkline(纯 SVG)。
|
||||||
|
*/
|
||||||
|
export function HealthTrend({ scores, width = 120, height = 32 }: HealthTrendProps) {
|
||||||
|
const { path, color } = useMemo(() => {
|
||||||
|
if (scores.length === 0) return { path: "", color: "currentColor" };
|
||||||
|
|
||||||
|
const max = Math.max(...scores, 100);
|
||||||
|
const min = Math.min(...scores, 0);
|
||||||
|
const range = max - min || 1;
|
||||||
|
|
||||||
|
const points = scores.map((score, i) => {
|
||||||
|
const x = (i / Math.max(scores.length - 1, 1)) * (width - 4) + 2;
|
||||||
|
const y = height - 2 - ((score - min) / range) * (height - 4);
|
||||||
|
return { x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
const d = points
|
||||||
|
.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`))
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const lastScore = scores[scores.length - 1];
|
||||||
|
const firstScore = scores[0];
|
||||||
|
const trend = lastScore - firstScore;
|
||||||
|
const color =
|
||||||
|
trend > 5 ? "var(--color-emerald)" : trend < -5 ? "var(--color-rose)" : "currentColor";
|
||||||
|
|
||||||
|
return { path: d, color };
|
||||||
|
}, [scores, width, height]);
|
||||||
|
|
||||||
|
if (scores.length === 0) {
|
||||||
|
return <div className="text-xs text-muted-foreground">暂无趋势</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} role="img" aria-label="健康度趋势">
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { MessageSquare, X, Send, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
/** AI Copilot 浮动对话窗口 — 底部抽屉式。 */
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Copilot 浮动窗口组件。
|
||||||
|
*/
|
||||||
|
export function CopilotWidget() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(async () => {
|
||||||
|
if (!input.trim() || isStreaming) return;
|
||||||
|
|
||||||
|
const userMessage = input.trim();
|
||||||
|
setInput("");
|
||||||
|
setMessages((prev) => [...prev, { role: "user", content: userMessage }]);
|
||||||
|
setIsStreaming(true);
|
||||||
|
|
||||||
|
// 添加空的 assistant 消息用于流式更新
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
||||||
|
const response = await fetch(`${API_BASE}/copilot/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: userMessage }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (event.type === "token") {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const last = updated[updated.length - 1];
|
||||||
|
if (last && last.role === "assistant") {
|
||||||
|
updated[updated.length - 1] = {
|
||||||
|
...last,
|
||||||
|
content: last.content + event.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} else if (event.type === "error") {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[updated.length - 1] = {
|
||||||
|
role: "assistant",
|
||||||
|
content: `错误: ${event.message}`,
|
||||||
|
};
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[updated.length - 1] = {
|
||||||
|
role: "assistant",
|
||||||
|
content: `请求失败: ${err instanceof Error ? err.message : "未知错误"}`,
|
||||||
|
};
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
}, [input, isStreaming]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--investor-primary)] text-white shadow-lg transition-transform hover:scale-105"
|
||||||
|
aria-label="打开 AI 副驾驶"
|
||||||
|
>
|
||||||
|
<Sparkles size={24} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-6 right-6 z-50 flex h-[480px] w-96 flex-col rounded-xl border bg-white shadow-2xl">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles size={18} className="text-[var(--investor-primary)]" aria-hidden="true" />
|
||||||
|
<span className="font-semibold text-sm">AI 副驾驶</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="rounded-md p-1 text-muted-foreground hover:bg-muted"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息列表 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
|
<MessageSquare size={32} className="text-muted-foreground" aria-hidden="true" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
向 AI 副驾驶提问关于企业分析、风险预警、报告撰写等问题
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{messages.map((msg, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
|
||||||
|
msg.role === "user"
|
||||||
|
? "bg-[var(--investor-primary)] text-white"
|
||||||
|
: "bg-muted text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.content || "..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入框 */}
|
||||||
|
<div className="border-t p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
|
||||||
|
placeholder="输入问题..."
|
||||||
|
disabled={isStreaming}
|
||||||
|
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--investor-primary)] disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={isStreaming || !input.trim()}
|
||||||
|
className="rounded-md bg-[var(--investor-primary)] p-2 text-white disabled:opacity-50"
|
||||||
|
aria-label="发送"
|
||||||
|
>
|
||||||
|
<Send size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user