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:
selfrelease
2026-07-18 22:27:27 +08:00
parent 374981609b
commit 5b34651e41
10 changed files with 676 additions and 117 deletions
+3 -47
View File
@@ -1,12 +1,11 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Building2, Search, Plus, ArrowRight } from "lucide-react";
import Link from "next/link";
import { Search, Plus } from "lucide-react";
import { listCompanies, type Company } from "@/lib/companies";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
import { CompanyCard } from "@/components/company/CompanyCard";
/**
* 投资人端 — 企业列表页。
@@ -94,50 +93,7 @@ export default function CompaniesPage() {
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{companies.map((company) => (
<Link
key={company.id}
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>
<CompanyCard key={company.id} company={company} />
))}
</div>
)}
+2
View File
@@ -3,6 +3,7 @@
import { LayoutDashboard, Building2, FileText, AlertTriangle, Settings } from "lucide-react";
import Link from "next/link";
import { CopilotWidget } from "@/components/shared/CopilotWidget";
import { RiskToastNotifier } from "@/components/shared/RiskToastNotifier";
const navItems = [
{ href: "/dashboard", label: "驾驶舱", icon: LayoutDashboard },
@@ -41,6 +42,7 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
<div className="container mx-auto max-w-7xl px-4 py-6">{children}</div>
</main>
<CopilotWidget />
<RiskToastNotifier />
</div>
);
}
@@ -0,0 +1,212 @@
"use client";
import { use } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, Download, Loader2 } from "lucide-react";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { HealthGauge } from "@/components/health/HealthGauge";
import { HealthRadar } from "@/components/health/HealthRadar";
import { RiskTimeline } from "@/components/risk/RiskTimeline";
import { apiFetch } from "@/lib/api";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
/** 投后报告数据结构。 */
interface ReportSummary {
company: {
name: string;
industry: string;
stage: string;
description: string;
website: string;
};
latest_score: {
total_score: number;
financial_score: number;
operational_score: number;
ai_commercial_score: number;
ai_cost_score: number;
trend: string;
} | null;
score_history: Array<{
total_score: number;
calculated_at: string;
trend: string;
}>;
recent_reports: Array<{
period: string;
status: string;
ai_summary: string;
}>;
risks: Array<{
title: string;
severity: string;
status: string;
type: string;
description: string;
}>;
generated_at: string;
}
/**
* 投资人端 — 投后报告查看页。
*/
export default function ReportViewPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const [report, setReport] = useState<ReportSummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isExporting, setIsExporting] = useState(false);
useEffect(() => {
async function load() {
try {
const resp = await apiFetch<ReportSummary>(`/reports-export/${id}/summary`);
if (resp.data) setReport(resp.data);
} catch {
// ignore
} finally {
setIsLoading(false);
}
}
load();
}, [id]);
async function handleExport() {
setIsExporting(true);
try {
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
const response = await fetch(`${API_BASE}/reports-export/${id}/pdf`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) throw new Error("导出失败");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `report_${id}_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
alert(err instanceof Error ? err.message : "导出失败");
} finally {
setIsExporting(false);
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
);
}
if (!report) {
return (
<div className="space-y-4">
<Link href="/companies" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft size={16} aria-hidden="true" />
</Link>
<EmptyState title="报告不存在" description="该企业可能已被删除" />
</div>
);
}
return (
<div className="space-y-6">
<Link href="/companies" 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 rounded-lg border bg-white p-5 shadow-sm no-print">
<div>
<h1 className="text-2xl font-bold text-foreground">
{report.company.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{report.company.industry} · {report.company.stage || "未知阶段"} · {" "}
{new Date(report.generated_at).toLocaleString("zh-CN")}
</p>
</div>
<button
onClick={handleExport}
disabled={isExporting}
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"
>
{isExporting ? (
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
) : (
<Download size={16} aria-hidden="true" />
)}
</button>
</div>
{/* 企业概况 */}
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-3 text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">{report.company.description || "暂无描述"}</p>
</div>
{/* 健康度评分 */}
{report.latest_score && (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-lg font-semibold"></h2>
<div className="flex flex-wrap items-center gap-6">
<HealthGauge score={report.latest_score.total_score} size={120} />
<HealthRadar
scores={{
financial: report.latest_score.financial_score,
operational: report.latest_score.operational_score,
ai_commercial: report.latest_score.ai_commercial_score,
ai_cost: report.latest_score.ai_cost_score,
}}
size={200}
/>
</div>
</div>
)}
{/* 月报历史 */}
{report.recent_reports.length > 0 && (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-3 text-lg font-semibold"></h2>
<div className="space-y-3">
{report.recent_reports.map((r, idx) => (
<div key={idx} className="border-b pb-3 last:border-0">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{r.period}</span>
<span className="text-xs text-muted-foreground">{r.status}</span>
</div>
{r.ai_summary && (
<p className="mt-1 text-sm text-muted-foreground">{r.ai_summary}</p>
)}
</div>
))}
</div>
</div>
)}
{/* 风险时间线 */}
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-lg font-semibold">线</h2>
<RiskTimeline
risks={report.risks.map((r, idx) => ({
id: String(idx),
title: r.title,
severity: r.severity,
status: r.status,
identified_at: report.generated_at,
}))}
/>
</div>
</div>
);
}
+12 -60
View File
@@ -1,20 +1,18 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { AlertTriangle, Trash2, Filter } from "lucide-react";
import { Filter } from "lucide-react";
import { toast } from "sonner";
import {
listRisks,
updateRisk,
deleteRisk,
RISK_STATUS_LABELS,
RISK_STATUS_COLORS,
SEVERITY_LABELS,
SEVERITY_COLORS,
RISK_TYPE_LABELS,
type RiskEvent,
} from "@/lib/risks";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { RiskCard } from "@/components/risk/RiskCard";
/**
* 投资人端 — 风险工作台。
@@ -53,19 +51,20 @@ export default function RisksPage() {
async function handleStatusChange(id: string, newStatus: string) {
try {
await updateRisk(id, { status: newStatus });
toast.success("状态已更新");
loadRisks();
} catch (err) {
alert(err instanceof Error ? err.message : "更新失败");
toast.error(err instanceof Error ? err.message : "更新失败");
}
}
async function handleDelete(id: string) {
if (!confirm("确认删除该风险事件?")) return;
try {
await deleteRisk(id);
toast.success("风险事件已删除");
loadRisks();
} catch (err) {
alert(err instanceof Error ? err.message : "删除失败");
toast.error(err instanceof Error ? err.message : "删除失败");
}
}
@@ -103,59 +102,12 @@ export default function RisksPage() {
) : (
<div className="space-y-3">
{risks.map((risk) => (
<div
<RiskCard
key={risk.id}
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) => handleStatusChange(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={() => handleDelete(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>
risk={risk}
onStatusChange={handleStatusChange}
onDelete={handleDelete}
/>
))}
</div>
)}