feat(scope): add company scope switcher — two views (by-task all companies / by-company all tasks)
- CompanyScopeContext + Provider with localStorage persistence - Sidebar company selector (desktop + mobile) - apiFetch auto-injects company_id on GET requests - Dashboard summary API supports company_id filter - Milestones/Financial/DigitalTwins pages use scope instead of manual input - HealthHeatmap/HealthTrends components react to scope changes
This commit is contained in:
@@ -28,10 +28,11 @@ _DIMENSION_KEYS = [
|
||||
|
||||
@router.get("/summary", response_model=ApiResponse[DashboardSummary])
|
||||
async def get_dashboard_summary(
|
||||
company_id: str | None = Query(default=None, description="指定企业 ID,不传则汇总全租户"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取仪表盘汇总数据。"""
|
||||
"""获取仪表盘汇总数据。支持按企业过滤。"""
|
||||
tenant_id = user.tenant_id
|
||||
|
||||
# 企业总数
|
||||
@@ -40,40 +41,52 @@ async def get_dashboard_summary(
|
||||
)
|
||||
total_companies = companies_result.scalar_one()
|
||||
|
||||
# 平均健康度
|
||||
avg_result = await db.execute(
|
||||
# 平均健康度(按企业过滤时只算该企业)
|
||||
avg_query = (
|
||||
select(func.avg(HealthScore.total_score))
|
||||
.join(Company, HealthScore.company_id == Company.id)
|
||||
.where(Company.tenant_id == tenant_id)
|
||||
)
|
||||
if company_id:
|
||||
avg_query = avg_query.where(HealthScore.company_id == company_id)
|
||||
avg_result = await db.execute(avg_query)
|
||||
avg_score = avg_result.scalar_one()
|
||||
avg_health_score = float(avg_score) if avg_score else 0.0
|
||||
|
||||
# 高风险事件数
|
||||
risk_result = await db.execute(
|
||||
risk_query = (
|
||||
select(func.count())
|
||||
.select_from(RiskEvent)
|
||||
.join(Company, RiskEvent.company_id == Company.id)
|
||||
.where(Company.tenant_id == tenant_id, RiskEvent.status.in_(["open", "assigned", "in_progress"]))
|
||||
)
|
||||
if company_id:
|
||||
risk_query = risk_query.where(RiskEvent.company_id == company_id)
|
||||
risk_result = await db.execute(risk_query)
|
||||
high_risk_count = risk_result.scalar_one()
|
||||
|
||||
# 待审阅月报数
|
||||
pending_result = await db.execute(
|
||||
pending_query = (
|
||||
select(func.count())
|
||||
.select_from(MonthlyReport)
|
||||
.join(Company, MonthlyReport.company_id == Company.id)
|
||||
.where(Company.tenant_id == tenant_id, MonthlyReport.status.in_(["submitted", "ai_parsed"]))
|
||||
)
|
||||
if company_id:
|
||||
pending_query = pending_query.where(MonthlyReport.company_id == company_id)
|
||||
pending_result = await db.execute(pending_query)
|
||||
pending_reports = pending_result.scalar_one()
|
||||
|
||||
# 最近评分(最多 10 条)
|
||||
recent_result = await db.execute(
|
||||
recent_query = (
|
||||
select(HealthScore, Company.name.label("company_name"))
|
||||
.join(Company, HealthScore.company_id == Company.id)
|
||||
.where(Company.tenant_id == tenant_id)
|
||||
.order_by(HealthScore.calculated_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
if company_id:
|
||||
recent_query = recent_query.where(HealthScore.company_id == company_id)
|
||||
recent_result = await db.execute(
|
||||
recent_query.order_by(HealthScore.calculated_at.desc()).limit(10)
|
||||
)
|
||||
recent_rows = recent_result.all()
|
||||
recent_scores = []
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { useCompanyScope } from "@/lib/company-scope";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
|
||||
@@ -13,6 +14,7 @@ import { HealthHeatmap, HealthTrends } from "@/components/dashboard/HealthHeatma
|
||||
* 投资人端 — 驾驶舱首页。
|
||||
*/
|
||||
export default function InvestorDashboardPage() {
|
||||
const { companyName } = useCompanyScope();
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -79,8 +81,8 @@ export default function InvestorDashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">投资机构驾驶舱</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Portfolio 全局健康度与风险概览</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">{companyName ? `${companyName} — 企业驾驶舱` : "投资机构驾驶舱"}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{companyName ? "单企业健康度与风险概览" : "Portfolio 全局健康度与风险概览"}</p>
|
||||
</div>
|
||||
|
||||
{/* KPI 卡片 */}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {Sparkles } from "lucide-react";
|
||||
import { listDigitalTwins, buildDigitalTwin, simulateTwin } from "@/lib/api-v2";
|
||||
import { useCompanyScope } from "@/lib/company-scope";
|
||||
import { PageContainer, Card, Badge } from "@/components/shared/PageContainer";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
@@ -20,6 +21,7 @@ interface SimulationResult {
|
||||
}
|
||||
|
||||
export default function DigitalTwinsPage() {
|
||||
const { companyId } = useCompanyScope();
|
||||
const [items, setItems] = useState<DigitalTwinItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [companyData, setCompanyData] = useState("");
|
||||
@@ -27,11 +29,17 @@ export default function DigitalTwinsPage() {
|
||||
const [simResult, setSimResult] = useState<SimulationResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listDigitalTwins("")
|
||||
.then(() => setItems([]))
|
||||
if (!companyId) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setItems([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
listDigitalTwins(companyId)
|
||||
.then((resp) => setItems((resp.data as DigitalTwinItem[]) ?? []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
}, [companyId]);
|
||||
|
||||
const handleBuild = async () => {
|
||||
if (!companyData.trim()) {
|
||||
@@ -62,6 +70,10 @@ export default function DigitalTwinsPage() {
|
||||
|
||||
return (
|
||||
<PageContainer title="数字孪生" description="企业模型 + 场景模拟 + 精度追踪">
|
||||
{!companyId ? (
|
||||
<EmptyState title="请先在侧边栏选择企业" description="数字孪生需要指定具体企业,请在左上角企业选择器中选择" />
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<h3 className="font-medium text-gray-900">构建数字孪生模型</h3>
|
||||
<textarea
|
||||
@@ -124,6 +136,8 @@ export default function DigitalTwinsPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { listFinancialData} from "@/lib/api-v2";
|
||||
import { PageContainer,Badge, TableEmpty } from "@/components/shared/PageContainer";
|
||||
import { listFinancialData } from "@/lib/api-v2";
|
||||
import { useCompanyScope } from "@/lib/company-scope";
|
||||
import { PageContainer, Badge, TableEmpty } from "@/components/shared/PageContainer";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
|
||||
@@ -19,13 +20,14 @@ interface FinancialData {
|
||||
* 投资人端 — 财务数据校验页面。
|
||||
*/
|
||||
export default function FinancialPage() {
|
||||
const { companyId, companyName } = useCompanyScope();
|
||||
const [items, setItems] = useState<FinancialData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [companyId, setCompanyId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setItems([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -37,20 +39,10 @@ export default function FinancialPage() {
|
||||
|
||||
return (
|
||||
<PageContainer title="财务数据校验" description="AI 交叉验证不同来源数据一致性,检测报表内部逻辑矛盾">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入企业 ID"
|
||||
value={companyId}
|
||||
onChange={(e) => setCompanyId(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : !companyId ? (
|
||||
<EmptyState title="请输入企业 ID" description="输入企业 ID 后查看财务数据" />
|
||||
<EmptyState title="请先在侧边栏选择企业" description="财务数据需要指定具体企业,请在左上角企业选择器中选择" />
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
|
||||
@@ -5,19 +5,30 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { Menu, X, Building2, ChevronDown } from "lucide-react";
|
||||
import { CopilotWidget } from "@/components/shared/CopilotWidget";
|
||||
import { RiskToastNotifier } from "@/components/shared/RiskToastNotifier";
|
||||
import { AuthGuard } from "@/components/shared/AuthGuard";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { getNavDomains, NAV_FOOTER } from "@/lib/navConfig";
|
||||
import { CompanyScopeProvider, useCompanyScope } from "@/lib/company-scope";
|
||||
|
||||
/**
|
||||
* 投资人端布局组件。
|
||||
*
|
||||
* 左侧 Sidebar 按 6 业务域分组,域顺序根据用户角色动态排序。
|
||||
* 顶部企业选择器可切换全局/单企业视角。
|
||||
*/
|
||||
export default function InvestorLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<CompanyScopeProvider>
|
||||
<InvestorLayoutInner>{children}</InvestorLayoutInner>
|
||||
</CompanyScopeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 投资人端布局内部组件 — 包裹在 CompanyScopeProvider 内。 */
|
||||
function InvestorLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
@@ -29,7 +40,11 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
|
||||
{/* 左侧 Sidebar */}
|
||||
<aside className="sticky top-0 hidden h-screen w-52 shrink-0 bg-[var(--investor-sidebar-bg)] text-white md:block">
|
||||
<div className="flex h-14 items-center px-4 font-bold">AIPortPilot</div>
|
||||
<nav className="flex flex-col gap-1 overflow-y-auto px-3 py-2" style={{ maxHeight: "calc(100vh - 3.5rem)" }}>
|
||||
|
||||
{/* 企业视角选择器 */}
|
||||
<CompanyScopeSelector />
|
||||
|
||||
<nav className="flex flex-col gap-1 overflow-y-auto px-3 py-2" style={{ maxHeight: "calc(100vh - 8.5rem)" }}>
|
||||
{domains.map((domain) => (
|
||||
<div key={domain.key} className="mb-2">
|
||||
<div className="px-3 py-1 text-xs font-medium text-white/40">{domain.title}</div>
|
||||
@@ -91,6 +106,8 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
|
||||
</button>
|
||||
<span className="font-bold">AIPortPilot</span>
|
||||
</div>
|
||||
{/* 移动端企业选择器 */}
|
||||
<MobileCompanyScopeSelector />
|
||||
</header>
|
||||
|
||||
{/* 移动端抽屉导航 */}
|
||||
@@ -109,6 +126,10 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
|
||||
<X size={20} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/* 移动端抽屉内企业选择器 */}
|
||||
<div className="px-3 py-2">
|
||||
<CompanyScopeSelector />
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1 px-3 py-2">
|
||||
{domains.map((domain) => (
|
||||
<div key={domain.key} className="mb-2">
|
||||
@@ -160,3 +181,107 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 桌面端企业视角选择器 — 嵌入侧边栏顶部。 */
|
||||
function CompanyScopeSelector() {
|
||||
const { companyId, companyName, companies, setCompanyId, isLoading } = useCompanyScope();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between rounded-md bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/15"
|
||||
aria-expanded={open}
|
||||
aria-label="选择企业视角"
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<Building2 size={14} className="shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{companyName ?? "全部企业"}</span>
|
||||
</span>
|
||||
<ChevronDown size={14} className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`} aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute left-3 right-3 top-full z-20 max-h-64 overflow-y-auto rounded-md border border-white/10 bg-[var(--investor-sidebar-bg)] py-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCompanyId(null); setOpen(false); }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors ${
|
||||
!companyId ? "bg-white/15 text-white" : "text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<Building2 size={14} aria-hidden="true" />
|
||||
全部企业
|
||||
</button>
|
||||
{!isLoading && companies.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => { setCompanyId(c.id); setOpen(false); }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors ${
|
||||
companyId === c.id ? "bg-white/15 text-white" : "text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<Building2 size={14} aria-hidden="true" />
|
||||
<span className="truncate">{c.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 移动端企业视角选择器 — 嵌入顶部 Header。 */
|
||||
function MobileCompanyScopeSelector() {
|
||||
const { companyId, companyName, companies, setCompanyId, isLoading } = useCompanyScope();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded-md bg-white/10 px-2 py-1 text-sm text-white"
|
||||
aria-expanded={open}
|
||||
aria-label="选择企业视角"
|
||||
>
|
||||
<Building2 size={14} aria-hidden="true" />
|
||||
<span className="max-w-24 truncate">{companyName ?? "全部"}</span>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-40 max-h-64 w-48 overflow-y-auto rounded-md bg-[var(--investor-sidebar-bg)] py-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCompanyId(null); setOpen(false); }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm ${
|
||||
!companyId ? "bg-white/15 text-white" : "text-white/70"
|
||||
}`}
|
||||
>
|
||||
全部企业
|
||||
</button>
|
||||
{!isLoading && companies.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => { setCompanyId(c.id); setOpen(false); }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm ${
|
||||
companyId === c.id ? "bg-white/15 text-white" : "text-white/70"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{c.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { listMilestones } from "@/lib/api-v2";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useCompanyScope } from "@/lib/company-scope";
|
||||
import { PageContainer, Badge } from "@/components/shared/PageContainer";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
@@ -16,17 +17,19 @@ interface Milestone {
|
||||
}
|
||||
|
||||
export default function MilestonesPage() {
|
||||
const { companyId, companyName } = useCompanyScope();
|
||||
const [items, setItems] = useState<Milestone[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [companyId, setCompanyId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setItems([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
listMilestones(companyId)
|
||||
// company_id 已由 apiFetch 自动注入,此处显式传给必填接口
|
||||
apiFetch<Milestone[]>(`/milestones?company_id=${companyId}`)
|
||||
.then((resp) => setItems((resp.data as Milestone[]) ?? []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setIsLoading(false));
|
||||
@@ -34,22 +37,12 @@ export default function MilestonesPage() {
|
||||
|
||||
return (
|
||||
<PageContainer title="里程碑树" description="分支路径管理 + 环境变化时 AI 建议路径切换">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入企业 ID"
|
||||
value={companyId}
|
||||
onChange={(e) => setCompanyId(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : !companyId ? (
|
||||
<EmptyState title="请输入企业 ID" description="输入企业 ID 后查看里程碑树" />
|
||||
<EmptyState title="请先在侧边栏选择企业" description="里程碑树需要指定具体企业,请在左上角企业选择器中选择" />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState description="暂无里程碑" />
|
||||
<EmptyState description={`${companyName ?? "该企业"}暂无里程碑`} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useCompanyScope } from "@/lib/company-scope";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { DIMENSIONS_14 } from "@/components/health/HealthRadar";
|
||||
@@ -29,18 +30,20 @@ const LINE_COLORS = [
|
||||
|
||||
/** 健康度趋势对比图 — 按企业分组,每家企业一条折线。 */
|
||||
export function HealthTrends({ companyId }: { companyId?: string }) {
|
||||
const { companyId: scopeCompanyId } = useCompanyScope();
|
||||
const effectiveCompanyId = companyId ?? scopeCompanyId;
|
||||
const [data, setData] = useState<CompanyTrend[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const path = companyId
|
||||
? `/dashboard/trends?company_id=${companyId}&months=6`
|
||||
const path = effectiveCompanyId
|
||||
? `/dashboard/trends?company_id=${effectiveCompanyId}&months=6`
|
||||
: "/dashboard/trends?months=6";
|
||||
apiFetch<CompanyTrend[]>(path)
|
||||
.then((res) => setData((res.data as CompanyTrend[]) || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [companyId]);
|
||||
}, [effectiveCompanyId]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (!data.length) return <EmptyState title="暂无趋势数据" />;
|
||||
@@ -156,6 +159,8 @@ export function HealthTrends({ companyId }: { companyId?: string }) {
|
||||
);
|
||||
}
|
||||
export function HealthHeatmap({ companyId }: { companyId?: string }) {
|
||||
const { companyId: scopeCompanyId } = useCompanyScope();
|
||||
const effectiveCompanyId = companyId ?? scopeCompanyId;
|
||||
const [data, setData] = useState<HeatmapRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -163,11 +168,11 @@ export function HealthHeatmap({ companyId }: { companyId?: string }) {
|
||||
apiFetch<HeatmapRow[]>("/dashboard/heatmap")
|
||||
.then((res) => {
|
||||
const rows = (res.data as HeatmapRow[]) || [];
|
||||
setData(companyId ? rows.filter((r) => r.company_id === companyId) : rows);
|
||||
setData(effectiveCompanyId ? rows.filter((r) => r.company_id === effectiveCompanyId) : rows);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [companyId]);
|
||||
}, [effectiveCompanyId]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (!data.length) return <EmptyState title="暂无热力图数据" />;
|
||||
|
||||
+24
-3
@@ -13,19 +13,40 @@ export interface ApiResponse<T = unknown> {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** apiFetch 扩展选项。 */
|
||||
interface ApiFetchOptions extends RequestInit {
|
||||
/** 跳过企业视角自动注入(用于企业列表等自身管理 scope 的请求) */
|
||||
skipCompanyScope?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 API 请求。
|
||||
*
|
||||
* GET 请求会自动注入企业视角筛选:当 localStorage 中有 company_scope_id 且非 "all" 时,
|
||||
* 自动在 URL 上追加 `company_id` 查询参数。后端未声明该参数的接口会自动忽略。
|
||||
*
|
||||
* @param path - API 路径(不含 base URL)
|
||||
* @param options - fetch 选项
|
||||
* @param options - fetch 选项 + skipCompanyScope 标志
|
||||
* @returns 解析后的响应数据
|
||||
*/
|
||||
export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
options: ApiFetchOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
// 企业视角自动注入:GET 请求且未跳过时,追加 company_id 查询参数
|
||||
let finalPath = path;
|
||||
const isGet = !options.method || options.method === "GET";
|
||||
if (isGet && !options.skipCompanyScope && typeof window !== "undefined") {
|
||||
const scopeId = localStorage.getItem("company_scope_id");
|
||||
if (scopeId && scopeId !== "all" && !path.includes("company_id=")) {
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
finalPath = `${path}${separator}company_id=${scopeId}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${finalPath}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function listCompanies(params?: {
|
||||
if (params?.keyword) query.set("keyword", params.keyword);
|
||||
if (params?.industry) query.set("industry", params.industry);
|
||||
if (params?.stage) query.set("stage", params.stage);
|
||||
return apiFetch<CompanyListResponse>(`/companies?${query.toString()}`);
|
||||
return apiFetch<CompanyListResponse>(`/companies?${query.toString()}`, { skipCompanyScope: true });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/** 企业视角切换 Context — 全局企业筛选器。 */
|
||||
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { listCompanies, type Company } from "@/lib/companies";
|
||||
|
||||
interface CompanyScopeValue {
|
||||
/** 选中的企业 ID,null 表示"全部企业" */
|
||||
companyId: string | null;
|
||||
/** 选中的企业名称,null 表示"全部企业" */
|
||||
companyName: string | null;
|
||||
/** 企业列表 */
|
||||
companies: Company[];
|
||||
/** 切换企业 */
|
||||
setCompanyId: (id: string | null) => void;
|
||||
/** 是否加载中 */
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const CompanyScopeContext = createContext<CompanyScopeValue | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* 企业视角 Provider。
|
||||
*
|
||||
* 在侧边栏顶部提供企业选择器,选中后所有页面自动按该企业过滤数据。
|
||||
* 选择"全部企业"时恢复全局视图。
|
||||
*/
|
||||
export function CompanyScopeProvider({ children }: { children: ReactNode }) {
|
||||
const [companyId, setCompanyIdState] = useState<string | null>(null);
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 加载企业列表
|
||||
useEffect(() => {
|
||||
listCompanies({ page_size: 100 })
|
||||
.then((resp) => {
|
||||
if (resp.data?.items) setCompanies(resp.data.items);
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
// 从 localStorage 恢复选择
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("company_scope_id");
|
||||
if (saved && saved !== "all") {
|
||||
setCompanyIdState(saved);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 持久化选择
|
||||
function setCompanyId(id: string | null) {
|
||||
setCompanyIdState(id);
|
||||
if (id) {
|
||||
localStorage.setItem("company_scope_id", id);
|
||||
} else {
|
||||
localStorage.setItem("company_scope_id", "all");
|
||||
}
|
||||
}
|
||||
|
||||
const companyName = companyId
|
||||
? companies.find((c) => c.id === companyId)?.name ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<CompanyScopeContext.Provider
|
||||
value={{ companyId, companyName, companies, setCompanyId, isLoading }}
|
||||
>
|
||||
{children}
|
||||
</CompanyScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用企业视角 Context。
|
||||
*
|
||||
* 返回当前选中的企业 ID,用于各页面过滤数据。
|
||||
*/
|
||||
export function useCompanyScope() {
|
||||
const ctx = useContext(CompanyScopeContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useCompanyScope 必须在 CompanyScopeProvider 内使用");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user