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