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:
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user