From 3d22bd8a3d9b27ef229b9bf24ca0665681ed16e4 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sun, 19 Jul 2026 22:01:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(workbench):=20complete=20company=20view=20?= =?UTF-8?q?=E2=80=94=20health=20trend,=20major=20events,=20milestones,=20t?= =?UTF-8?q?eam,=20synergy=20+=20dashboard=20click-through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/companies.py | 97 ++++++ backend/app/schemas/company.py | 59 ++++ .../companies/[id]/workbench/page.tsx | 291 ++++++++++++++++-- .../src/app/(investor)/dashboard/page.tsx | 57 +--- frontend/src/components/workbench/TabNav.tsx | 3 +- 5 files changed, 444 insertions(+), 63 deletions(-) diff --git a/backend/app/routers/companies.py b/backend/app/routers/companies.py index 8c4f33b..47f2ca0 100644 --- a/backend/app/routers/companies.py +++ b/backend/app/routers/companies.py @@ -18,16 +18,25 @@ from app.schemas.company import ( AgreementBrief, BoardMeetingBrief, HealthScoreBrief, + HealthScoreHistoryPoint, + MajorEventBrief, + MilestoneBrief, ReportBrief, RiskBrief, + SynergyBrief, + TeamMemberBrief, WeakSignalBrief, ) from app.models.agreement import InvestmentAgreement from app.models.board import BoardMeeting from app.models.financial_data import FinancialData from app.models.health_score import HealthScore +from app.models.major_event import MajorEvent +from app.models.milestone import MilestoneTree from app.models.report import MonthlyReport from app.models.risk import RiskEvent +from app.models.synergy import SynergyOpportunity +from app.models.talent import TeamMember from app.models.weak_signal import WeakSignal router = APIRouter(prefix="/companies", tags=["companies"]) @@ -224,14 +233,102 @@ async def get_company_detail( latest_fin = latest_fin_result.scalar_one_or_none() latest_financial = latest_fin.data_json if latest_fin else None + # 健康度历史趋势(最近 12 条) + history_result = await db.execute( + select(HealthScore) + .where(HealthScore.company_id == company_id) + .order_by(HealthScore.calculated_at.asc()) + .limit(12) + ) + history_scores = history_result.scalars().all() + health_score_history = [ + HealthScoreHistoryPoint( + period=s.calculated_at.strftime("%Y-%m"), + total_score=s.total_score, + calculated_at=s.calculated_at, + ) + for s in history_scores + ] + + # 重大事项(最近 10 条) + events_result = await db.execute( + select(MajorEvent) + .where(MajorEvent.company_id == company_id) + .order_by(MajorEvent.created_at.desc()) + .limit(10) + ) + events = events_result.scalars().all() + major_events = [ + MajorEventBrief( + id=e.id, event_type=e.event_type, title=e.title, + description=e.description, severity=e.severity, + status=e.status, occurred_at=e.occurred_at, + ) for e in events + ] + + # 里程碑 + milestone_result = await db.execute( + select(MilestoneTree) + .where(MilestoneTree.company_id == company_id) + .order_by(MilestoneTree.target_date.desc().nulls_last()) + .limit(20) + ) + milestones = milestone_result.scalars().all() + milestone_briefs = [ + MilestoneBrief( + id=m.id, name=m.name, status=m.status, is_current=m.is_current, + target_date=m.target_date, actual_date=m.actual_date, + description=m.description, + ) for m in milestones + ] + + # 团队成员 + team_result = await db.execute( + select(TeamMember) + .where(TeamMember.company_id == company_id) + .order_by(TeamMember.is_key_person.desc(), TeamMember.joined_at.desc()) + .limit(20) + ) + team_members_data = team_result.scalars().all() + team_members = [ + TeamMemberBrief( + id=t.id, name=t.name, role=t.role, is_key_person=t.is_key_person, + stability_score=t.stability_score, joined_at=t.joined_at, + ) for t in team_members_data + ] + + # 协同机会(涉及该企业的) + synergy_result = await db.execute( + select(SynergyOpportunity) + .where( + (SynergyOpportunity.company_a_id == company_id) | + (SynergyOpportunity.company_b_id == company_id) + ) + .order_by(SynergyOpportunity.created_at.desc()) + .limit(10) + ) + synergies = synergy_result.scalars().all() + synergy_briefs = [ + SynergyBrief( + id=s.id, type=s.type, title=s.title, + description=s.description, status=s.status, + match_reason=s.match_reason, + ) for s in synergies + ] + return success(data=CompanyDetailResponse( company=CompanyResponse.model_validate(company, from_attributes=True), health_score=health_brief, + health_score_history=health_score_history, recent_reports=report_briefs, open_risks=risk_briefs, recent_weak_signals=signal_briefs, active_agreements=agreement_briefs, recent_board_meetings=meeting_briefs, + major_events=major_events, + milestones=milestone_briefs, + team_members=team_members, + synergy_opportunities=synergy_briefs, financial_data_count=fin_count, latest_financial=latest_financial, )) diff --git a/backend/app/schemas/company.py b/backend/app/schemas/company.py index c027bfd..2d266a3 100644 --- a/backend/app/schemas/company.py +++ b/backend/app/schemas/company.py @@ -130,15 +130,74 @@ class BoardMeetingBrief(BaseModel): meeting_at: datetime | None = None +class MajorEventBrief(BaseModel): + """重大事项摘要。""" + + id: str + event_type: str + title: str + description: str | None = None + severity: str = "medium" + status: str = "identified" + occurred_at: datetime | None = None + + +class MilestoneBrief(BaseModel): + """里程碑摘要。""" + + id: str + name: str + status: str = "planned" + is_current: bool = False + target_date: datetime | None = None + actual_date: datetime | None = None + description: str | None = None + + +class TeamMemberBrief(BaseModel): + """团队成员摘要。""" + + id: str + name: str + role: str | None = None + is_key_person: bool = False + stability_score: float | None = None + joined_at: datetime | None = None + + +class SynergyBrief(BaseModel): + """协同机会摘要。""" + + id: str + type: str + title: str + description: str | None = None + status: str = "discovered" + match_reason: str | None = None + + +class HealthScoreHistoryPoint(BaseModel): + """健康度历史数据点。""" + + period: str + total_score: float + calculated_at: datetime + + class CompanyDetailResponse(BaseModel): """企业详情聚合响应 — 工作台使用。""" company: CompanyResponse health_score: HealthScoreBrief | None = None + health_score_history: list[HealthScoreHistoryPoint] = [] recent_reports: list[ReportBrief] = [] open_risks: list[RiskBrief] = [] recent_weak_signals: list[WeakSignalBrief] = [] active_agreements: list[AgreementBrief] = [] recent_board_meetings: list[BoardMeetingBrief] = [] + major_events: list[MajorEventBrief] = [] + milestones: list[MilestoneBrief] = [] + team_members: list[TeamMemberBrief] = [] + synergy_opportunities: list[SynergyBrief] = [] financial_data_count: int = 0 latest_financial: dict | None = None diff --git a/frontend/src/app/(investor)/companies/[id]/workbench/page.tsx b/frontend/src/app/(investor)/companies/[id]/workbench/page.tsx index c202fc3..677b34e 100644 --- a/frontend/src/app/(investor)/companies/[id]/workbench/page.tsx +++ b/frontend/src/app/(investor)/companies/[id]/workbench/page.tsx @@ -10,21 +10,75 @@ import { HighlightsPanel } from "@/components/workbench/HighlightsPanel"; import { WorkModeSwitcher, type WorkMode } from "@/components/shared/WorkModeSwitcher"; import { InsightRail } from "@/components/shared/InsightRail"; import { apiFetch } from "@/lib/api"; -import { AlertTriangle, FileText, ClipboardList, ScrollText, Network, DollarSign, Activity, Users, Bot } from "lucide-react"; +import { AlertTriangle, FileText, ClipboardList, ScrollText, Network, DollarSign, Activity, Users, Bot, Flag, Calendar, TrendingUp } from "lucide-react"; /** 企业详情数据结构。 */ interface CompanyDetail { company: { id: string; name: string; industry?: string }; health_score: HealthScore | null; + health_score_history: HealthScoreHistoryPoint[]; recent_reports: ReportItem[]; open_risks: RiskItem[]; recent_weak_signals: WeakSignalItem[]; active_agreements: AgreementItem[]; recent_board_meetings: BoardMeetingItem[]; + major_events: MajorEventItem[]; + milestones: MilestoneItem[]; + team_members: TeamMemberItem[]; + synergy_opportunities: SynergyItem[]; latest_financial?: Record; financial_data_count?: number; } +/** 健康度历史数据点。 */ +interface HealthScoreHistoryPoint { + period: string; + total_score: number; + calculated_at: string; +} + +/** 重大事项项。 */ +interface MajorEventItem { + id: string; + event_type: string; + title: string; + description?: string; + severity: string; + status: string; + occurred_at?: string; +} + +/** 里程碑项。 */ +interface MilestoneItem { + id: string; + name: string; + status: string; + is_current: boolean; + target_date?: string; + actual_date?: string; + description?: string; +} + +/** 团队成员项。 */ +interface TeamMemberItem { + id: string; + name: string; + role?: string; + is_key_person: boolean; + stability_score?: number; + joined_at?: string; +} + +/** 协同机会项。 */ +interface SynergyItem { + id: string; + type: string; + title: string; + description?: string; + status: string; + match_reason?: string; +} + /** 健康度评分结构。 */ type HealthScore = { total_score: number; @@ -105,7 +159,7 @@ export default function WorkbenchPage() { return ; } - const { company, health_score, recent_reports, open_risks, recent_weak_signals, active_agreements, recent_board_meetings } = detail; + const { company, health_score, health_score_history, recent_reports, open_risks, recent_weak_signals, active_agreements, recent_board_meetings, major_events, milestones, team_members, synergy_opportunities } = detail; const highRisksCount = (open_risks || []).filter((r) => r.severity === "high" || r.severity === "critical").length; const pendingTasksCount = (recent_reports || []).filter((r) => r.status === "pending" || r.status === "draft").length; @@ -137,13 +191,14 @@ export default function WorkbenchPage() { {activeTab === "overview" && } {activeTab === "financial" && } {activeTab === "operational" && } - {activeTab === "org" && } + {activeTab === "org" && } {activeTab === "ai" && } {activeTab === "risk" && } + {activeTab === "events" && } {activeTab === "reports" && } {activeTab === "board" && } {activeTab === "agreements" && } - {activeTab === "synergy" && } + {activeTab === "synergy" && } @@ -156,24 +211,79 @@ export default function WorkbenchPage() { /** 概览 Tab — 健康度雷达图 + 维度详情。 */ function OverviewTab({ detail }: { detail: CompanyDetail }) { - const { health_score } = detail; + const { health_score, health_score_history } = detail; if (!health_score) { return ; } return ( -
-
-

健康度雷达图

- +
+
+
+

健康度雷达图

+ +
+
+

维度详情

+ +
+ {/* 健康度趋势图 */}
-

维度详情

- +
+ +

健康度趋势

+
+ {health_score_history && health_score_history.length > 0 ? ( + + ) : ( +

暂无历史趋势数据

+ )}
); } +/** 健康度趋势迷你折线图。 */ +function HealthTrendMini({ data }: { data: HealthScoreHistoryPoint[] }) { + const width = 500; + const height = 180; + const padding = { top: 15, right: 15, bottom: 30, left: 35 }; + const chartW = width - padding.left - padding.right; + const chartH = height - padding.top - padding.bottom; + + const periods = data.map((d) => d.period); + const scores = data.map((d) => d.total_score); + const xStep = periods.length > 1 ? chartW / (periods.length - 1) : 0; + const yScale = (val: number) => chartH - (val / 100) * chartH; + + const linePath = scores + .map((s, i) => `${i === 0 ? "M" : "L"} ${padding.left + i * xStep} ${padding.top + yScale(s)}`) + .join(" "); + + return ( + + {[0, 25, 50, 75, 100].map((v) => ( + + + {v} + + ))} + + {scores.map((s, i) => ( + + + {s.toFixed(0)} + {periods[i]} + + ))} + + ); +} + /** 财务 Tab。 */ function FinancialTab({ detail }: { detail: CompanyDetail }) { const { latest_financial, financial_data_count } = detail; @@ -223,14 +333,40 @@ function OperationalTab({ detail }: { detail: CompanyDetail }) { } /** 组织 Tab。 */ -function OrgTab() { +function OrgTab({ teamMembers }: { teamMembers: TeamMemberItem[] }) { return ( -
-
- -

组织面板

+
+
+
+ +

核心团队 ({teamMembers?.length || 0})

+
+ {teamMembers && teamMembers.length > 0 ? ( +
+ {teamMembers.map((m) => ( +
+
+ {m.name} + {m.is_key_person && ( + 核心 + )} +
+
+ {m.role && {m.role}} + {m.stability_score != null && ( + 稳定性: {(m.stability_score * 100).toFixed(0)}% + )} + {m.joined_at && ( + 入职: {new Date(m.joined_at).toLocaleDateString("zh-CN")} + )} +
+
+ ))} +
+ ) : ( + + )}
-
); } @@ -383,15 +519,130 @@ function AgreementsTab({ agreements }: { agreements: AgreementItem[] }) { ); } +/** 重大事项 Tab。 */ +function EventsTab({ events, milestones }: { events: MajorEventItem[]; milestones: MilestoneItem[] }) { + const severityColor = (s: string) => + s === "critical" ? "bg-rose-100 text-rose-700" : + s === "high" ? "bg-amber-100 text-amber-700" : + s === "medium" ? "bg-blue-100 text-blue-700" : + "bg-muted text-muted-foreground"; + + const statusLabel = (s: string) => + s === "identified" ? "已识别" : + s === "confirmed" ? "已确认" : + s === "addressed" ? "已处理" : s; + + const milestoneStatusColor = (s: string) => + s === "completed" ? "bg-emerald-100 text-emerald-700" : + s === "in_progress" ? "bg-blue-100 text-blue-700" : + s === "abandoned" ? "bg-muted text-muted-foreground line-through" : + "bg-amber-100 text-amber-700"; + + return ( +
+ {/* 重大事项 */} +
+
+ +

重大事项 ({events?.length || 0})

+
+ {events && events.length > 0 ? ( +
+ {events.map((e) => ( +
+
+ {e.title} +
+ {e.severity} + {statusLabel(e.status)} +
+
+ {e.description &&

{e.description}

} + {e.occurred_at && ( +

发生时间: {new Date(e.occurred_at).toLocaleDateString("zh-CN")}

+ )} +
+ ))} +
+ ) : ( + + )} +
+ + {/* 里程碑 */} +
+
+ +

里程碑 ({milestones?.length || 0})

+
+ {milestones && milestones.length > 0 ? ( +
+ {milestones.map((m) => ( +
+
+ {m.is_current && } + {m.name} +
+
+ {m.target_date && 目标: {new Date(m.target_date).toLocaleDateString("zh-CN")}} + {m.actual_date && 完成: {new Date(m.actual_date).toLocaleDateString("zh-CN")}} + {m.status} +
+
+ ))} +
+ ) : ( + + )} +
+
+ ); +} + /** 协同 Tab。 */ -function SynergyTab() { +function SynergyTab({ synergies }: { synergies: SynergyItem[] }) { + const typeLabel = (t: string) => + t === "customer" ? "客户" : + t === "talent" ? "人才" : + t === "funding" ? "融资" : + t === "supply_chain" ? "供应链" : + t === "tech" ? "技术" : t; + + const statusColor = (s: string) => + s === "completed" ? "bg-emerald-100 text-emerald-700" : + s === "executing" ? "bg-blue-100 text-blue-700" : + s === "authorized" ? "bg-indigo-100 text-indigo-700" : + s === "confirmed" ? "bg-amber-100 text-amber-700" : + s === "declined" ? "bg-rose-100 text-rose-700" : + "bg-muted text-muted-foreground"; + return (
-

协同机会

+

协同机会 ({synergies?.length || 0})

- + {synergies && synergies.length > 0 ? ( +
+ {synergies.map((s) => ( +
+
+
+ {s.title} + {typeLabel(s.type)} +
+ {s.status} +
+ {s.description &&

{s.description}

} + {s.match_reason && ( +

匹配理由: {s.match_reason}

+ )} +
+ ))} +
+ ) : ( + + )}
); } diff --git a/frontend/src/app/(investor)/dashboard/page.tsx b/frontend/src/app/(investor)/dashboard/page.tsx index afcf255..43b7811 100644 --- a/frontend/src/app/(investor)/dashboard/page.tsx +++ b/frontend/src/app/(investor)/dashboard/page.tsx @@ -4,7 +4,6 @@ import { useEffect, useState } from "react"; import { Building2, Heart, AlertTriangle, FileText, TrendingUp, TrendingDown, Minus } from "lucide-react"; import Link from "next/link"; import { getDashboardSummary, type DashboardSummary } from "@/lib/dashboard"; -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"; @@ -15,19 +14,13 @@ import { HealthHeatmap, HealthTrends } from "@/components/dashboard/HealthHeatma */ export default function InvestorDashboardPage() { const [summary, setSummary] = useState(null); - const [companies, setCompanies] = useState([]); - const [selectedCompanyId, setSelectedCompanyId] = useState(""); const [isLoading, setIsLoading] = useState(true); useEffect(() => { async function load() { try { - const [summaryResp, companiesResp] = await Promise.all([ - getDashboardSummary(), - listCompanies({ page_size: 100 }), - ]); - if (summaryResp.data) setSummary(summaryResp.data); - if (companiesResp.data?.items) setCompanies(companiesResp.data.items); + const resp = await getDashboardSummary(); + if (resp.data) setSummary(resp.data); } catch { // ignore } finally { @@ -83,35 +76,11 @@ export default function InvestorDashboardPage() { }, ]; - // 筛选后的评分列表 - const filteredScores = selectedCompanyId - ? summary?.recent_scores.filter((s) => s.company_id === selectedCompanyId) ?? [] - : summary?.recent_scores ?? []; - - // 选中的企业名称 - const selectedCompany = companies.find((c) => c.id === selectedCompanyId); - return (
-
-
-

投资机构驾驶舱

-

- {selectedCompany ? `${selectedCompany.name} — 企业健康度概览` : "Portfolio 全局健康度与风险概览"} -

-
- - {/* 企业筛选器 */} - +
+

投资机构驾驶舱

+

Portfolio 全局健康度与风险概览

{/* KPI 卡片 */} @@ -144,10 +113,14 @@ export default function InvestorDashboardPage() {
- {filteredScores.length > 0 ? ( + {summary && summary.recent_scores.length > 0 ? (
- {filteredScores.map((score) => ( -
+ {summary.recent_scores.map((score) => ( +
@@ -175,7 +148,7 @@ export default function InvestorDashboardPage() { )}
-
+ ))}
) : ( @@ -187,10 +160,10 @@ export default function InvestorDashboardPage() {

健康度热力图

- +
- +
diff --git a/frontend/src/components/workbench/TabNav.tsx b/frontend/src/components/workbench/TabNav.tsx index 148a417..3a6a36a 100644 --- a/frontend/src/components/workbench/TabNav.tsx +++ b/frontend/src/components/workbench/TabNav.tsx @@ -3,7 +3,7 @@ import {type ReactNode } from "react"; import { LayoutDashboard, DollarSign, Activity, Users, Bot, - AlertTriangle, FileText, ClipboardList, ScrollText, Network, + AlertTriangle, FileText, ClipboardList, ScrollText, Network, Flag, } from "lucide-react"; /** 工作台 Tab 定义。 */ @@ -14,6 +14,7 @@ const TABS = [ { id: "org", label: "组织", icon: Users }, { id: "ai", label: "AI+专项", icon: Bot }, { id: "risk", label: "风险", icon: AlertTriangle }, + { id: "events", label: "重大事项", icon: Flag }, { id: "reports", label: "月报", icon: FileText }, { id: "board", label: "董事会", icon: ClipboardList }, { id: "agreements", label: "协议", icon: ScrollText },