feat(workbench): complete company view — health trend, major events, milestones, team, synergy + dashboard click-through

This commit is contained in:
selfrelease
2026-07-19 22:01:49 +08:00
parent 0197f89018
commit 3d22bd8a3d
5 changed files with 444 additions and 63 deletions
+97
View File
@@ -18,16 +18,25 @@ from app.schemas.company import (
AgreementBrief, AgreementBrief,
BoardMeetingBrief, BoardMeetingBrief,
HealthScoreBrief, HealthScoreBrief,
HealthScoreHistoryPoint,
MajorEventBrief,
MilestoneBrief,
ReportBrief, ReportBrief,
RiskBrief, RiskBrief,
SynergyBrief,
TeamMemberBrief,
WeakSignalBrief, WeakSignalBrief,
) )
from app.models.agreement import InvestmentAgreement from app.models.agreement import InvestmentAgreement
from app.models.board import BoardMeeting from app.models.board import BoardMeeting
from app.models.financial_data import FinancialData from app.models.financial_data import FinancialData
from app.models.health_score import HealthScore 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.report import MonthlyReport
from app.models.risk import RiskEvent 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 from app.models.weak_signal import WeakSignal
router = APIRouter(prefix="/companies", tags=["companies"]) 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_fin = latest_fin_result.scalar_one_or_none()
latest_financial = latest_fin.data_json if latest_fin else 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( return success(data=CompanyDetailResponse(
company=CompanyResponse.model_validate(company, from_attributes=True), company=CompanyResponse.model_validate(company, from_attributes=True),
health_score=health_brief, health_score=health_brief,
health_score_history=health_score_history,
recent_reports=report_briefs, recent_reports=report_briefs,
open_risks=risk_briefs, open_risks=risk_briefs,
recent_weak_signals=signal_briefs, recent_weak_signals=signal_briefs,
active_agreements=agreement_briefs, active_agreements=agreement_briefs,
recent_board_meetings=meeting_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, financial_data_count=fin_count,
latest_financial=latest_financial, latest_financial=latest_financial,
)) ))
+59
View File
@@ -130,15 +130,74 @@ class BoardMeetingBrief(BaseModel):
meeting_at: datetime | None = None 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): class CompanyDetailResponse(BaseModel):
"""企业详情聚合响应 — 工作台使用。""" """企业详情聚合响应 — 工作台使用。"""
company: CompanyResponse company: CompanyResponse
health_score: HealthScoreBrief | None = None health_score: HealthScoreBrief | None = None
health_score_history: list[HealthScoreHistoryPoint] = []
recent_reports: list[ReportBrief] = [] recent_reports: list[ReportBrief] = []
open_risks: list[RiskBrief] = [] open_risks: list[RiskBrief] = []
recent_weak_signals: list[WeakSignalBrief] = [] recent_weak_signals: list[WeakSignalBrief] = []
active_agreements: list[AgreementBrief] = [] active_agreements: list[AgreementBrief] = []
recent_board_meetings: list[BoardMeetingBrief] = [] 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 financial_data_count: int = 0
latest_financial: dict | None = None latest_financial: dict | None = None
@@ -10,21 +10,75 @@ import { HighlightsPanel } from "@/components/workbench/HighlightsPanel";
import { WorkModeSwitcher, type WorkMode } from "@/components/shared/WorkModeSwitcher"; import { WorkModeSwitcher, type WorkMode } from "@/components/shared/WorkModeSwitcher";
import { InsightRail } from "@/components/shared/InsightRail"; import { InsightRail } from "@/components/shared/InsightRail";
import { apiFetch } from "@/lib/api"; 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 { interface CompanyDetail {
company: { id: string; name: string; industry?: string }; company: { id: string; name: string; industry?: string };
health_score: HealthScore | null; health_score: HealthScore | null;
health_score_history: HealthScoreHistoryPoint[];
recent_reports: ReportItem[]; recent_reports: ReportItem[];
open_risks: RiskItem[]; open_risks: RiskItem[];
recent_weak_signals: WeakSignalItem[]; recent_weak_signals: WeakSignalItem[];
active_agreements: AgreementItem[]; active_agreements: AgreementItem[];
recent_board_meetings: BoardMeetingItem[]; recent_board_meetings: BoardMeetingItem[];
major_events: MajorEventItem[];
milestones: MilestoneItem[];
team_members: TeamMemberItem[];
synergy_opportunities: SynergyItem[];
latest_financial?: Record<string, unknown>; latest_financial?: Record<string, unknown>;
financial_data_count?: number; 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 = { type HealthScore = {
total_score: number; total_score: number;
@@ -105,7 +159,7 @@ export default function WorkbenchPage() {
return <EmptyState title="未找到企业信息" />; return <EmptyState title="未找到企业信息" />;
} }
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 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; const pendingTasksCount = (recent_reports || []).filter((r) => r.status === "pending" || r.status === "draft").length;
@@ -137,13 +191,14 @@ export default function WorkbenchPage() {
{activeTab === "overview" && <OverviewTab detail={detail} />} {activeTab === "overview" && <OverviewTab detail={detail} />}
{activeTab === "financial" && <FinancialTab detail={detail} />} {activeTab === "financial" && <FinancialTab detail={detail} />}
{activeTab === "operational" && <OperationalTab detail={detail} />} {activeTab === "operational" && <OperationalTab detail={detail} />}
{activeTab === "org" && <OrgTab />} {activeTab === "org" && <OrgTab teamMembers={team_members} />}
{activeTab === "ai" && <AITab detail={detail} />} {activeTab === "ai" && <AITab detail={detail} />}
{activeTab === "risk" && <RiskTab risks={open_risks} signals={recent_weak_signals} />} {activeTab === "risk" && <RiskTab risks={open_risks} signals={recent_weak_signals} />}
{activeTab === "events" && <EventsTab events={major_events} milestones={milestones} />}
{activeTab === "reports" && <ReportsTab reports={recent_reports} />} {activeTab === "reports" && <ReportsTab reports={recent_reports} />}
{activeTab === "board" && <BoardTab meetings={recent_board_meetings} />} {activeTab === "board" && <BoardTab meetings={recent_board_meetings} />}
{activeTab === "agreements" && <AgreementsTab agreements={active_agreements} />} {activeTab === "agreements" && <AgreementsTab agreements={active_agreements} />}
{activeTab === "synergy" && <SynergyTab />} {activeTab === "synergy" && <SynergyTab synergies={synergy_opportunities} />}
</TabPanel> </TabPanel>
</div> </div>
@@ -156,24 +211,79 @@ export default function WorkbenchPage() {
/** 概览 Tab — 健康度雷达图 + 维度详情。 */ /** 概览 Tab — 健康度雷达图 + 维度详情。 */
function OverviewTab({ detail }: { detail: CompanyDetail }) { function OverviewTab({ detail }: { detail: CompanyDetail }) {
const { health_score } = detail; const { health_score, health_score_history } = detail;
if (!health_score) { if (!health_score) {
return <EmptyState title="暂无健康度评分数据" />; return <EmptyState title="暂无健康度评分数据" />;
} }
return ( return (
<div className="grid gap-4 md:grid-cols-2"> <div className="space-y-4">
<div className="rounded-lg border border-[var(--border)] bg-white p-4"> <div className="grid gap-4 md:grid-cols-2">
<h3 className="mb-2 text-sm font-medium"></h3> <div className="rounded-lg border border-[var(--border)] bg-white p-4">
<HealthRadar scores={health_score} dimensions={DIMENSIONS_14} size={320} /> <h3 className="mb-2 text-sm font-medium"></h3>
<HealthRadar scores={health_score} dimensions={DIMENSIONS_14} size={320} />
</div>
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<h3 className="mb-2 text-sm font-medium"></h3>
<HealthDimensionDetail scores={health_score} dimensions={DIMENSIONS_14_DETAIL} />
</div>
</div> </div>
{/* 健康度趋势图 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4"> <div className="rounded-lg border border-[var(--border)] bg-white p-4">
<h3 className="mb-2 text-sm font-medium"></h3> <div className="flex items-center gap-2">
<HealthDimensionDetail scores={health_score} dimensions={DIMENSIONS_14_DETAIL} /> <TrendingUp size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3>
</div>
{health_score_history && health_score_history.length > 0 ? (
<HealthTrendMini data={health_score_history} />
) : (
<p className="mt-2 text-sm text-muted-foreground"></p>
)}
</div> </div>
</div> </div>
); );
} }
/** 健康度趋势迷你折线图。 */
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 (
<svg width={width} height={height} role="img" aria-label="健康度趋势图" className="mt-2">
{[0, 25, 50, 75, 100].map((v) => (
<g key={v}>
<line
x1={padding.left} y1={padding.top + yScale(v)}
x2={width - padding.right} y2={padding.top + yScale(v)}
stroke="var(--border)" strokeWidth={1} strokeDasharray={v === 0 ? "none" : "2,2"}
/>
<text x={padding.left - 6} y={padding.top + yScale(v) + 4} textAnchor="end" className="text-[10px] fill-muted-foreground">{v}</text>
</g>
))}
<path d={linePath} fill="none" stroke="var(--investor-primary)" strokeWidth={2} />
{scores.map((s, i) => (
<g key={i}>
<circle cx={padding.left + i * xStep} cy={padding.top + yScale(s)} r={3} fill="var(--investor-primary)" />
<text x={padding.left + i * xStep} y={padding.top + yScale(s) - 8} textAnchor="middle" className="text-[9px] font-medium fill-foreground">{s.toFixed(0)}</text>
<text x={padding.left + i * xStep} y={height - padding.bottom + 14} textAnchor="middle" className="text-[9px] fill-muted-foreground">{periods[i]}</text>
</g>
))}
</svg>
);
}
/** 财务 Tab。 */ /** 财务 Tab。 */
function FinancialTab({ detail }: { detail: CompanyDetail }) { function FinancialTab({ detail }: { detail: CompanyDetail }) {
const { latest_financial, financial_data_count } = detail; const { latest_financial, financial_data_count } = detail;
@@ -223,14 +333,40 @@ function OperationalTab({ detail }: { detail: CompanyDetail }) {
} }
/** 组织 Tab。 */ /** 组织 Tab。 */
function OrgTab() { function OrgTab({ teamMembers }: { teamMembers: TeamMemberItem[] }) {
return ( return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="rounded-lg border border-[var(--border)] bg-white p-4">
<Users size={18} className="text-[var(--investor-primary)]" /> <div className="flex items-center gap-2">
<h3 className="text-sm font-medium"></h3> <Users size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"> ({teamMembers?.length || 0})</h3>
</div>
{teamMembers && teamMembers.length > 0 ? (
<div className="mt-3 space-y-2">
{teamMembers.map((m) => (
<div key={m.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{m.name}</span>
{m.is_key_person && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700"></span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{m.role && <span>{m.role}</span>}
{m.stability_score != null && (
<span>: {(m.stability_score * 100).toFixed(0)}%</span>
)}
{m.joined_at && (
<span>: {new Date(m.joined_at).toLocaleDateString("zh-CN")}</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState title="暂无团队成员数据" />
)}
</div> </div>
<EmptyState title="组织数据待月报结构化后展示" />
</div> </div>
); );
} }
@@ -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 (
<div className="space-y-4">
{/* 重大事项 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Flag size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"> ({events?.length || 0})</h3>
</div>
{events && events.length > 0 ? (
<div className="mt-3 space-y-2">
{events.map((e) => (
<div key={e.id} className="rounded-md border border-[var(--border)] px-3 py-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{e.title}</span>
<div className="flex items-center gap-2">
<span className={`rounded px-1.5 py-0.5 text-xs ${severityColor(e.severity)}`}>{e.severity}</span>
<span className="text-xs text-muted-foreground">{statusLabel(e.status)}</span>
</div>
</div>
{e.description && <p className="mt-1 text-xs text-muted-foreground">{e.description}</p>}
{e.occurred_at && (
<p className="mt-1 text-xs text-muted-foreground">: {new Date(e.occurred_at).toLocaleDateString("zh-CN")}</p>
)}
</div>
))}
</div>
) : (
<EmptyState title="暂无重大事项" />
)}
</div>
{/* 里程碑 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2">
<Calendar size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"> ({milestones?.length || 0})</h3>
</div>
{milestones && milestones.length > 0 ? (
<div className="mt-3 space-y-2">
{milestones.map((m) => (
<div key={m.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
<div className="flex items-center gap-2">
{m.is_current && <span className="h-2 w-2 rounded-full bg-emerald-500" />}
<span className={`text-sm font-medium ${m.status === "abandoned" ? "line-through text-muted-foreground" : ""}`}>{m.name}</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{m.target_date && <span>: {new Date(m.target_date).toLocaleDateString("zh-CN")}</span>}
{m.actual_date && <span className="text-emerald-600">: {new Date(m.actual_date).toLocaleDateString("zh-CN")}</span>}
<span className={`rounded px-1.5 py-0.5 ${milestoneStatusColor(m.status)}`}>{m.status}</span>
</div>
</div>
))}
</div>
) : (
<EmptyState title="暂无里程碑数据" />
)}
</div>
</div>
);
}
/** 协同 Tab。 */ /** 协同 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 ( return (
<div className="rounded-lg border border-[var(--border)] bg-white p-4"> <div className="rounded-lg border border-[var(--border)] bg-white p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Network size={18} className="text-[var(--investor-primary)]" /> <Network size={18} className="text-[var(--investor-primary)]" />
<h3 className="text-sm font-medium"></h3> <h3 className="text-sm font-medium"> ({synergies?.length || 0})</h3>
</div> </div>
<EmptyState title="请前往协同中心查看" /> {synergies && synergies.length > 0 ? (
<div className="mt-3 space-y-2">
{synergies.map((s) => (
<div key={s.id} className="rounded-md border border-[var(--border)] px-3 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{s.title}</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">{typeLabel(s.type)}</span>
</div>
<span className={`rounded px-1.5 py-0.5 text-xs ${statusColor(s.status)}`}>{s.status}</span>
</div>
{s.description && <p className="mt-1 text-xs text-muted-foreground">{s.description}</p>}
{s.match_reason && (
<p className="mt-1 text-xs text-muted-foreground">: {s.match_reason}</p>
)}
</div>
))}
</div>
) : (
<EmptyState title="暂无协同机会" />
)}
</div> </div>
); );
} }
+15 -42
View File
@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
import { Building2, Heart, AlertTriangle, FileText, TrendingUp, TrendingDown, Minus } from "lucide-react"; import { Building2, Heart, AlertTriangle, FileText, TrendingUp, TrendingDown, Minus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getDashboardSummary, type DashboardSummary } from "@/lib/dashboard"; import { getDashboardSummary, type DashboardSummary } from "@/lib/dashboard";
import { listCompanies, type Company } from "@/lib/companies";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner"; import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState"; import { EmptyState } from "@/components/shared/EmptyState";
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge"; import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
@@ -15,19 +14,13 @@ import { HealthHeatmap, HealthTrends } from "@/components/dashboard/HealthHeatma
*/ */
export default function InvestorDashboardPage() { export default function InvestorDashboardPage() {
const [summary, setSummary] = useState<DashboardSummary | null>(null); const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [companies, setCompanies] = useState<Company[]>([]);
const [selectedCompanyId, setSelectedCompanyId] = useState<string>("");
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { try {
const [summaryResp, companiesResp] = await Promise.all([ const resp = await getDashboardSummary();
getDashboardSummary(), if (resp.data) setSummary(resp.data);
listCompanies({ page_size: 100 }),
]);
if (summaryResp.data) setSummary(summaryResp.data);
if (companiesResp.data?.items) setCompanies(companiesResp.data.items);
} catch { } catch {
// ignore // ignore
} finally { } 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div>
<div> <h1 className="text-2xl font-bold text-foreground"></h1>
<h1 className="text-2xl font-bold text-foreground"></h1> <p className="mt-1 text-sm text-muted-foreground">Portfolio </p>
<p className="mt-1 text-sm text-muted-foreground">
{selectedCompany ? `${selectedCompany.name} — 企业健康度概览` : "Portfolio 全局健康度与风险概览"}
</p>
</div>
{/* 企业筛选器 */}
<select
value={selectedCompanyId}
onChange={(e) => setSelectedCompanyId(e.target.value)}
className="rounded-md border bg-white px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--investor-primary)]"
>
<option value=""></option>
{companies.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div> </div>
{/* KPI 卡片 */} {/* KPI 卡片 */}
@@ -144,10 +113,14 @@ export default function InvestorDashboardPage() {
</Link> </Link>
</div> </div>
{filteredScores.length > 0 ? ( {summary && summary.recent_scores.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{filteredScores.map((score) => ( {summary.recent_scores.map((score) => (
<div key={score.id} className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0"> <Link
key={score.id}
href={`/companies/${score.company_id}/workbench`}
className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0 transition-colors hover:bg-muted/30 rounded-md px-2 -mx-2"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<HealthScoreBadge score={Math.round(score.total_score)} /> <HealthScoreBadge score={Math.round(score.total_score)} />
<div> <div>
@@ -175,7 +148,7 @@ export default function InvestorDashboardPage() {
</span> </span>
)} )}
</div> </div>
</div> </Link>
))} ))}
</div> </div>
) : ( ) : (
@@ -187,10 +160,10 @@ export default function InvestorDashboardPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border bg-white p-5 shadow-sm"> <div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-lg font-semibold"></h2> <h2 className="mb-4 text-lg font-semibold"></h2>
<HealthHeatmap companyId={selectedCompanyId || undefined} /> <HealthHeatmap />
</div> </div>
<div className="rounded-lg border bg-white p-5 shadow-sm"> <div className="rounded-lg border bg-white p-5 shadow-sm">
<HealthTrends companyId={selectedCompanyId || undefined} /> <HealthTrends />
</div> </div>
</div> </div>
</div> </div>
+2 -1
View File
@@ -3,7 +3,7 @@
import {type ReactNode } from "react"; import {type ReactNode } from "react";
import { import {
LayoutDashboard, DollarSign, Activity, Users, Bot, LayoutDashboard, DollarSign, Activity, Users, Bot,
AlertTriangle, FileText, ClipboardList, ScrollText, Network, AlertTriangle, FileText, ClipboardList, ScrollText, Network, Flag,
} from "lucide-react"; } from "lucide-react";
/** 工作台 Tab 定义。 */ /** 工作台 Tab 定义。 */
@@ -14,6 +14,7 @@ const TABS = [
{ id: "org", label: "组织", icon: Users }, { id: "org", label: "组织", icon: Users },
{ id: "ai", label: "AI+专项", icon: Bot }, { id: "ai", label: "AI+专项", icon: Bot },
{ id: "risk", label: "风险", icon: AlertTriangle }, { id: "risk", label: "风险", icon: AlertTriangle },
{ id: "events", label: "重大事项", icon: Flag },
{ id: "reports", label: "月报", icon: FileText }, { id: "reports", label: "月报", icon: FileText },
{ id: "board", label: "董事会", icon: ClipboardList }, { id: "board", label: "董事会", icon: ClipboardList },
{ id: "agreements", label: "协议", icon: ScrollText }, { id: "agreements", label: "协议", icon: ScrollText },