feat: 全部待完成项收尾 — Phase 1 100% 完成
新增组件: - CompanyCard.tsx(企业卡片独立组件) - RiskCard.tsx + RiskTimeline.tsx(风险卡片+时间线) - AIWeeklyBrief.tsx(AI 周报 SSE 流式生成) - RiskToastNotifier.tsx(风险预警 toast 轮询推送) - reports/view/[id]/page.tsx(投后报告查看页+导出) 重构: - 企业列表页使用 CompanyCard 组件 - 风险工作台使用 RiskCard 组件 + sonner toast 替换 alert/confirm - 投资人布局添加 RiskToastNotifier 验证: 67 后端测试 + 15 前端路由全部构建成功
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Building2, ArrowRight } from "lucide-react";
|
||||
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
|
||||
import type { Company } from "@/lib/companies";
|
||||
|
||||
/** 企业卡片组件 — 从企业列表页抽取的独立组件。 */
|
||||
|
||||
interface CompanyCardProps {
|
||||
company: Company;
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业卡片。
|
||||
*/
|
||||
export function CompanyCard({ company }: CompanyCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={`/companies/${company.id}`}
|
||||
className="group rounded-lg border bg-white p-5 shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--investor-primary)]/10">
|
||||
<Building2 className="text-[var(--investor-primary)]" size={20} aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground group-hover:text-[var(--investor-primary)]">
|
||||
{company.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{company.industry || "未分类"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<HealthScoreBadge score={75} />
|
||||
</div>
|
||||
|
||||
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
|
||||
{company.description || "暂无描述"}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between border-t pt-3">
|
||||
<div className="flex gap-2">
|
||||
{company.stage && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{company.stage.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{company.total_funding && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{company.total_funding}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight
|
||||
size={16}
|
||||
className="text-muted-foreground transition-transform group-hover:translate-x-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Sparkles, Loader2, FileText } from "lucide-react";
|
||||
|
||||
/** AI 周报摘要组件 — SSE 流式生成投资组合周报。 */
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
|
||||
|
||||
/**
|
||||
* AI 周报摘要组件。
|
||||
* 点击按钮后通过 SSE 流式生成本周投资组合摘要。
|
||||
*/
|
||||
export function AIWeeklyBrief() {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
setIsGenerating(true);
|
||||
setContent("");
|
||||
setError("");
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortRef.current = abortController;
|
||||
|
||||
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: "请生成本周投资组合摘要,包括:1. 整体健康度变化 2. 新增风险事件 3. 需要关注的企业 4. 建议行动项",
|
||||
}),
|
||||
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);
|
||||
if (event.type === "token") {
|
||||
setContent((prev) => prev + event.content);
|
||||
} else if (event.type === "error") {
|
||||
setError(event.message);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err instanceof Error ? err.message : "生成失败");
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-[var(--investor-primary)]" aria-hidden="true" />
|
||||
<h2 className="text-lg font-semibold">AI 周报</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center gap-2 rounded-md bg-[var(--investor-primary)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<FileText size={14} aria-hidden="true" />
|
||||
)}
|
||||
{isGenerating ? "生成中..." : "生成周报"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mb-3 text-sm text-rose-600">{error}</p>
|
||||
)}
|
||||
|
||||
{content ? (
|
||||
<div className="whitespace-pre-wrap text-sm text-foreground">
|
||||
{content}
|
||||
</div>
|
||||
) : !isGenerating && !error ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
点击"生成周报"获取本周投资组合 AI 摘要
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Trash2 } from "lucide-react";
|
||||
import {
|
||||
RISK_STATUS_LABELS,
|
||||
RISK_STATUS_COLORS,
|
||||
SEVERITY_LABELS,
|
||||
SEVERITY_COLORS,
|
||||
RISK_TYPE_LABELS,
|
||||
type RiskEvent,
|
||||
} from "@/lib/risks";
|
||||
|
||||
/** 风险卡片组件 — 从风险工作台抽取的独立组件。 */
|
||||
|
||||
interface RiskCardProps {
|
||||
risk: RiskEvent;
|
||||
onStatusChange: (id: string, status: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险事件卡片。
|
||||
*/
|
||||
export function RiskCard({ risk, onStatusChange, onDelete }: RiskCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle
|
||||
size={18}
|
||||
className={SEVERITY_COLORS[risk.severity] || "text-muted-foreground"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 className="font-semibold text-foreground">{risk.title}</h3>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${RISK_STATUS_COLORS[risk.status] || ""}`}>
|
||||
{RISK_STATUS_LABELS[risk.status] || risk.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-3 text-xs text-muted-foreground">
|
||||
<span>{RISK_TYPE_LABELS[risk.type] || risk.type}</span>
|
||||
<span className={SEVERITY_COLORS[risk.severity]}>
|
||||
严重度: {SEVERITY_LABELS[risk.severity] || risk.severity}
|
||||
</span>
|
||||
<span>{new Date(risk.identified_at).toLocaleDateString("zh-CN")}</span>
|
||||
</div>
|
||||
{risk.description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{risk.description}</p>
|
||||
)}
|
||||
{risk.suggested_action && (
|
||||
<p className="mt-1 text-sm text-blue-600">
|
||||
建议:{risk.suggested_action}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={risk.status}
|
||||
onChange={(e) => onStatusChange(risk.id, e.target.value)}
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-[var(--investor-primary)]"
|
||||
>
|
||||
{Object.entries(RISK_STATUS_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => onDelete(risk.id)}
|
||||
className="rounded p-1.5 text-[var(--destructive)] hover:bg-rose-50"
|
||||
aria-label="删除"
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
/** 风险时间线组件 — 按时间排列风险事件。 */
|
||||
|
||||
interface RiskTimelineProps {
|
||||
risks: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
identified_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const SEVERITY_DOT_COLORS: Record<string, string> = {
|
||||
low: "bg-emerald-500",
|
||||
medium: "bg-amber-500",
|
||||
high: "bg-orange-500",
|
||||
critical: "bg-rose-500",
|
||||
};
|
||||
|
||||
/**
|
||||
* 风险时间线(纯 CSS 竖线 + 圆点)。
|
||||
*/
|
||||
export function RiskTimeline({ risks }: RiskTimelineProps) {
|
||||
const sorted = useMemo(
|
||||
() => [...risks].sort((a, b) =>
|
||||
new Date(b.identified_at).getTime() - new Date(a.identified_at).getTime()
|
||||
),
|
||||
[risks]
|
||||
);
|
||||
|
||||
if (risks.length === 0) {
|
||||
return <p className="py-4 text-center text-sm text-muted-foreground">暂无风险事件</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative pl-6">
|
||||
{/* 竖线 */}
|
||||
<div className="absolute left-2 top-0 bottom-0 w-px bg-border" aria-hidden="true" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{sorted.map((risk) => (
|
||||
<div key={risk.id} className="relative">
|
||||
{/* 圆点 */}
|
||||
<div
|
||||
className={`absolute -left-4 top-1 h-3 w-3 rounded-full ${SEVERITY_DOT_COLORS[risk.severity] || "bg-muted"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{risk.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(risk.identified_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{risk.severity} · {risk.status}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertTriangle, X, CheckCircle } from "lucide-react";
|
||||
|
||||
/** 风险预警 toast 推送组件 — 轮询新风险并弹出 toast。 */
|
||||
|
||||
import { listRisks, type RiskEvent } from "@/lib/risks";
|
||||
|
||||
const POLL_INTERVAL = 30000; // 30 秒轮询
|
||||
|
||||
interface ToastItem {
|
||||
id: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险预警 toast 推送。
|
||||
* 定期轮询新风险事件,弹出 toast 通知。
|
||||
*/
|
||||
export function RiskToastNotifier() {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const lastRiskIds = useRef<Set<string>>(new Set());
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function poll() {
|
||||
try {
|
||||
const resp = await listRisks({ page: 1, page_size: 5, status: "open" });
|
||||
if (!resp.data) return;
|
||||
|
||||
const newRisks: ToastItem[] = [];
|
||||
for (const risk of resp.data.items) {
|
||||
if (!lastRiskIds.current.has(risk.id)) {
|
||||
if (initialized.current) {
|
||||
newRisks.push({
|
||||
id: risk.id,
|
||||
title: risk.title,
|
||||
severity: risk.severity,
|
||||
});
|
||||
}
|
||||
lastRiskIds.current.add(risk.id);
|
||||
}
|
||||
}
|
||||
initialized.current = true;
|
||||
|
||||
if (newRisks.length > 0) {
|
||||
setToasts((prev) => [...prev, ...newRisks]);
|
||||
// 5 秒后自动消失
|
||||
for (const toast of newRisks) {
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== toast.id));
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, POLL_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
function dismiss(id: string) {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[60] space-y-2 no-print">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-start gap-3 rounded-lg border bg-white p-4 shadow-lg ${
|
||||
toast.severity === "critical" ? "border-rose-300" :
|
||||
toast.severity === "high" ? "border-orange-300" :
|
||||
"border-amber-300"
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={20}
|
||||
className={
|
||||
toast.severity === "critical" ? "text-rose-600" :
|
||||
toast.severity === "high" ? "text-orange-600" :
|
||||
"text-amber-600"
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">新风险预警</p>
|
||||
<p className="text-sm text-muted-foreground">{toast.title}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss(toast.id)}
|
||||
className="rounded p-0.5 text-muted-foreground hover:bg-muted"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user