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:
selfrelease
2026-07-20 07:47:53 +08:00
parent 8b8926bde3
commit e81aa6828e
10 changed files with 306 additions and 53 deletions
+21 -8
View File
@@ -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>
);
}
+6 -14
View File
@@ -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">
+127 -2
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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 });
}
/**
+88
View File
@@ -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 {
/** 选中的企业 IDnull 表示"全部企业" */
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;
}