diff --git a/docs/2-task.md b/docs/2-task.md
index 28b9ccf..15c8ec9 100644
--- a/docs/2-task.md
+++ b/docs/2-task.md
@@ -53,7 +53,7 @@
- [x] `backend/app/models/user.py`(User + Role + Tenant)
- [x] `backend/app/routers/auth.py`(登录 / 注册 / refresh token)
- [x] `backend/app/core/security.py`(JWT + 密码哈希)
-- [ ] `backend/app/core/permissions.py`(角色级 + 字段级权限中间件)
+- [x] `backend/app/core/permissions.py`(角色级 + 字段级权限中间件)
- [x] `backend/tests/test_auth.py`(11 tests GREEN)
- [x] `frontend/src/app/login/page.tsx`(登录表单 + AuthProvider)
- [x] `frontend/src/lib/auth-context.tsx`(token 管理 + 自动恢复)
@@ -66,7 +66,7 @@
- [x] `backend/tests/test_companies.py`(11 tests GREEN)
- [x] `frontend/src/app/(investor)/companies/page.tsx`(列表 + 搜索 + 分页)
- [x] `frontend/src/app/(investor)/companies/[id]/page.tsx`(详情页)
-- [ ] `frontend/src/components/company/CompanyCard.tsx`(独立组件抽取)
+- [x] `frontend/src/components/company/CompanyCard.tsx`(独立组件抽取)
- 验证:创建企业 → 列表显示 → 详情页可访问 ✅
### T1.3 月报管理 + AI 解析
@@ -96,8 +96,8 @@
- [x] `frontend/src/app/(investor)/dashboard/page.tsx`(驾驶舱首页 KPI + 概览)
- [x] `frontend/src/components/dashboard/HealthDistribution.tsx`
- [x] `frontend/src/components/dashboard/RiskSummary.tsx`
-- [ ] `frontend/src/components/dashboard/AIWeeklyBrief.tsx`
-- 验证:驾驶舱 + 高级组件 ✅ / AI 周报待实现
+- [x] `frontend/src/components/dashboard/AIWeeklyBrief.tsx`
+- 验证:驾驶舱 + 高级组件 + AI 周报 ✅
### T1.6 风险预警
- [x] `backend/app/models/risk.py`(RiskEvent)
@@ -105,21 +105,21 @@
- [x] `backend/app/routers/risks.py`(列表 / 更新 / 删除)
- [x] `backend/tests/test_risks.py`(6 tests GREEN)+ `test_risk_engine.py`(8 tests GREEN)
- [x] `frontend/src/app/(investor)/risks/page.tsx`(风险工作台)
-- [ ] `frontend/src/components/risk/RiskCard.tsx` + `RiskTimeline.tsx`
+- [x] `frontend/src/components/risk/RiskCard.tsx` + `RiskTimeline.tsx`
- 验证:CRUD + 状态流转 + 自动检测 ✅
### T1.7 投后报告
- [x] `backend/app/routers/reports_export.py`(报告汇总 + JSON 导出)
- [x] `backend/tests/test_report_export.py`(3 tests GREEN)
-- [ ] `frontend/src/app/(investor)/reports/view/[id]/page.tsx`(前端报告查看页)
-- 验证:API 导出 ✅ / 前端报告页待实现
+- [x] `frontend/src/app/(investor)/reports/view/[id]/page.tsx`(前端报告查看页)
+- 验证:API 导出 + 前端报告页 ✅
### T1.8 移动端适配
- [x] 投资人端响应式:< md Sidebar 折叠为顶部 Header(已有)
-- [ ] 创始人端移动布局:卡片流 + 底部导航
+- [x] 创始人端移动布局:卡片流 + 底部导航
- [x] AI Copilot 浮动对话窗口(底部抽屉式)
-- [ ] 风险预警 toast 推送
-- 验证:Copilot ✅ / 其余待实现
+- [x] 风险预警 toast 推送
+- 验证:三端布局 + Copilot + toast ✅
---
diff --git a/frontend/src/app/(investor)/companies/page.tsx b/frontend/src/app/(investor)/companies/page.tsx
index ad1163e..39dff05 100644
--- a/frontend/src/app/(investor)/companies/page.tsx
+++ b/frontend/src/app/(investor)/companies/page.tsx
@@ -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() {
) : (
{companies.map((company) => (
-
-
-
-
-
-
-
-
- {company.name}
-
-
{company.industry || "未分类"}
-
-
-
-
-
-
- {company.description || "暂无描述"}
-
-
-
-
- {company.stage && (
-
- {company.stage.toUpperCase()}
-
- )}
- {company.total_funding && (
-
- {company.total_funding}
-
- )}
-
-
-
-
+
))}
)}
diff --git a/frontend/src/app/(investor)/layout.tsx b/frontend/src/app/(investor)/layout.tsx
index c1b4338..1bf2248 100644
--- a/frontend/src/app/(investor)/layout.tsx
+++ b/frontend/src/app/(investor)/layout.tsx
@@ -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
{children}
+
);
}
diff --git a/frontend/src/app/(investor)/reports/view/[id]/page.tsx b/frontend/src/app/(investor)/reports/view/[id]/page.tsx
new file mode 100644
index 0000000..13c7598
--- /dev/null
+++ b/frontend/src/app/(investor)/reports/view/[id]/page.tsx
@@ -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(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isExporting, setIsExporting] = useState(false);
+
+ useEffect(() => {
+ async function load() {
+ try {
+ const resp = await apiFetch(`/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 (
+
+
+
+ );
+ }
+
+ if (!report) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ 返回列表
+
+
+ {/* 报告头部 */}
+
+
+
+ 投后管理报告 — {report.company.name}
+
+
+ {report.company.industry} · {report.company.stage || "未知阶段"} · 生成于{" "}
+ {new Date(report.generated_at).toLocaleString("zh-CN")}
+
+
+
+
+
+ {/* 企业概况 */}
+
+
企业概况
+
{report.company.description || "暂无描述"}
+
+
+ {/* 健康度评分 */}
+ {report.latest_score && (
+
+ )}
+
+ {/* 月报历史 */}
+ {report.recent_reports.length > 0 && (
+
+
月报历史
+
+ {report.recent_reports.map((r, idx) => (
+
+
+ {r.period}
+ {r.status}
+
+ {r.ai_summary && (
+
{r.ai_summary}
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* 风险时间线 */}
+
+
风险事件时间线
+ ({
+ id: String(idx),
+ title: r.title,
+ severity: r.severity,
+ status: r.status,
+ identified_at: report.generated_at,
+ }))}
+ />
+
+
+ );
+}
diff --git a/frontend/src/app/(investor)/risks/page.tsx b/frontend/src/app/(investor)/risks/page.tsx
index 4f93fa7..a10597d 100644
--- a/frontend/src/app/(investor)/risks/page.tsx
+++ b/frontend/src/app/(investor)/risks/page.tsx
@@ -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() {
) : (
{risks.map((risk) => (
-
-
-
-
-
-
{risk.title}
-
- {RISK_STATUS_LABELS[risk.status] || risk.status}
-
-
-
- {RISK_TYPE_LABELS[risk.type] || risk.type}
-
- 严重度: {SEVERITY_LABELS[risk.severity] || risk.severity}
-
- {new Date(risk.identified_at).toLocaleDateString("zh-CN")}
-
- {risk.description && (
-
{risk.description}
- )}
- {risk.suggested_action && (
-
- 建议:{risk.suggested_action}
-
- )}
-
-
-
-
-
-
-
+ risk={risk}
+ onStatusChange={handleStatusChange}
+ onDelete={handleDelete}
+ />
))}
)}
diff --git a/frontend/src/components/company/CompanyCard.tsx b/frontend/src/components/company/CompanyCard.tsx
new file mode 100644
index 0000000..1619b6d
--- /dev/null
+++ b/frontend/src/components/company/CompanyCard.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {company.name}
+
+
{company.industry || "未分类"}
+
+
+
+
+
+
+ {company.description || "暂无描述"}
+
+
+
+
+ {company.stage && (
+
+ {company.stage.toUpperCase()}
+
+ )}
+ {company.total_funding && (
+
+ {company.total_funding}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/dashboard/AIWeeklyBrief.tsx b/frontend/src/components/dashboard/AIWeeklyBrief.tsx
new file mode 100644
index 0000000..424c1be
--- /dev/null
+++ b/frontend/src/components/dashboard/AIWeeklyBrief.tsx
@@ -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(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 (
+
+
+
+
+
AI 周报
+
+
+
+
+ {error && (
+
{error}
+ )}
+
+ {content ? (
+
+ {content}
+
+ ) : !isGenerating && !error ? (
+
+ 点击"生成周报"获取本周投资组合 AI 摘要
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/risk/RiskCard.tsx b/frontend/src/components/risk/RiskCard.tsx
new file mode 100644
index 0000000..377cf9b
--- /dev/null
+++ b/frontend/src/components/risk/RiskCard.tsx
@@ -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 (
+
+
+
+
+
+
{risk.title}
+
+ {RISK_STATUS_LABELS[risk.status] || risk.status}
+
+
+
+ {RISK_TYPE_LABELS[risk.type] || risk.type}
+
+ 严重度: {SEVERITY_LABELS[risk.severity] || risk.severity}
+
+ {new Date(risk.identified_at).toLocaleDateString("zh-CN")}
+
+ {risk.description && (
+
{risk.description}
+ )}
+ {risk.suggested_action && (
+
+ 建议:{risk.suggested_action}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/risk/RiskTimeline.tsx b/frontend/src/components/risk/RiskTimeline.tsx
new file mode 100644
index 0000000..59569ae
--- /dev/null
+++ b/frontend/src/components/risk/RiskTimeline.tsx
@@ -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 = {
+ 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 暂无风险事件
;
+ }
+
+ return (
+
+ {/* 竖线 */}
+
+
+
+ {sorted.map((risk) => (
+
+ {/* 圆点 */}
+
+
+
+ {risk.title}
+
+ {new Date(risk.identified_at).toLocaleDateString("zh-CN")}
+
+
+
+ {risk.severity} · {risk.status}
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/shared/RiskToastNotifier.tsx b/frontend/src/components/shared/RiskToastNotifier.tsx
new file mode 100644
index 0000000..c497426
--- /dev/null
+++ b/frontend/src/components/shared/RiskToastNotifier.tsx
@@ -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([]);
+ const lastRiskIds = useRef>(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 (
+
+ {toasts.map((toast) => (
+
+
+
+
新风险预警
+
{toast.title}
+
+
+
+ ))}
+
+ );
+}