From 5b34651e41f094689d58ce1ca22a411940e2239b Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 18 Jul 2026 22:27:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E9=83=A8=E5=BE=85=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E9=A1=B9=E6=94=B6=E5=B0=BE=20=E2=80=94=20Phase=201=20?= =?UTF-8?q?100%=20=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增组件: - 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 前端路由全部构建成功 --- docs/2-task.md | 20 +- .../src/app/(investor)/companies/page.tsx | 50 +---- frontend/src/app/(investor)/layout.tsx | 2 + .../app/(investor)/reports/view/[id]/page.tsx | 212 ++++++++++++++++++ frontend/src/app/(investor)/risks/page.tsx | 72 +----- .../src/components/company/CompanyCard.tsx | 63 ++++++ .../components/dashboard/AIWeeklyBrief.tsx | 122 ++++++++++ frontend/src/components/risk/RiskCard.tsx | 77 +++++++ frontend/src/components/risk/RiskTimeline.tsx | 67 ++++++ .../components/shared/RiskToastNotifier.tsx | 108 +++++++++ 10 files changed, 676 insertions(+), 117 deletions(-) create mode 100644 frontend/src/app/(investor)/reports/view/[id]/page.tsx create mode 100644 frontend/src/components/company/CompanyCard.tsx create mode 100644 frontend/src/components/dashboard/AIWeeklyBrief.tsx create mode 100644 frontend/src/components/risk/RiskCard.tsx create mode 100644 frontend/src/components/risk/RiskTimeline.tsx create mode 100644 frontend/src/components/shared/RiskToastNotifier.tsx 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 ( +
+ +
+ ); +} 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_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 ( +
+
+
+
+ +
+ + {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_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 ( +
+ {/* 竖线 */} +