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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user