Files
AIPortPilot/frontend/src/app/(investor)/ooda/page.tsx
T
selfrelease af91d843d8 feat(frontend): 全部页面接入后端API并同步全局企业选择
- ai-plus: Class组件→函数组件,接入 listHealthScores + listAgentExecutions API
- ooda: 硬编码→接入 risks/weak-signals/sentinels/tasks API 构建OODA各阶段
- threads: 硬编码→接入 risks/tasks/events API 组合决策线程
- today: 硬编码→接入 risks/weak-signals/synergies/reports API 构建行动项
- compare: 硬编码企业名→使用全局企业列表 + listHealthScores + listRisks API
- innovation/knowledge-graph/portfolio: 加 useCompanyScope 标题显示企业名
- 所有页面标题在单企业选择时显示企业名
- 编译验证通过
2026-07-20 08:40:54 +08:00

210 lines
7.4 KiB
TypeScript

/** OODA 决策循环可视化页面 — Observe/Orient/Decide/Act 循环图 + 决策延迟追踪。 */
"use client";
import { useState } from "react";
import { Eye, Compass, Brain, Zap, Clock } from "lucide-react";
import { useCompanyScope, useScopeEffect } from "@/lib/company-scope";
import { listHealthScores, type HealthScore } from "@/lib/dashboard";
import { listRisks } from "@/lib/risks";
import { listWeakSignals } from "@/lib/api-v2";
import { listDecisionSentinels } from "@/lib/api-v2";
import { listTasks } from "@/lib/api-v2";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
/** OODA 阶段定义。 */
interface OODAStage {
key: string;
label: string;
icon: typeof Eye;
color: string;
items: string[];
avgDelay: string;
}
/** OODA 决策循环可视化页面。 */
export default function OODAPage() {
const { companyName } = useCompanyScope();
const [scores, setScores] = useState<HealthScore[]>([]);
const [riskCount, setRiskCount] = useState(0);
const [weakSignalCount, setWeakSignalCount] = useState(0);
const [sentinelCount, setSentinelCount] = useState(0);
const [taskCount, setTaskCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
useScopeEffect(() => {
Promise.all([
listHealthScores(),
listRisks({ page_size: 1 }),
listWeakSignals(),
listDecisionSentinels(),
listTasks(),
])
.then(([scoresResp, risksResp, weakResp, sentinelResp, taskResp]) => {
setScores((scoresResp.data as HealthScore[]) ?? []);
setRiskCount(risksResp.data?.total ?? 0);
const weakData = (weakResp.data as unknown[]) ?? [];
setWeakSignalCount(weakData.length);
const sentinelData = (sentinelResp.data as unknown[]) ?? [];
setSentinelCount(sentinelData.length);
const taskData = (taskResp.data as unknown[]) ?? [];
setTaskCount(taskData.length);
})
.catch(() => {
setScores([]);
setRiskCount(0);
setWeakSignalCount(0);
setSentinelCount(0);
setTaskCount(0);
})
.finally(() => setIsLoading(false));
});
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<Compass className="text-[var(--investor-primary)]" size={24} />
<h1 className="text-2xl font-bold">
OODA {companyName ? `${companyName}` : ""}
</h1>
</div>
<LoadingSpinner />
</div>
);
}
// 根据实际数据构建 OODA 各阶段
const observeItems: string[] = [];
if (riskCount > 0) observeItems.push(`风险事件 ${riskCount} 项待处理`);
if (weakSignalCount > 0) observeItems.push(`弱信号 ${weakSignalCount} 项监测中`);
if (scores.length > 0) {
const lowScores = scores.filter((s) => s.total_score < 60);
if (lowScores.length > 0) observeItems.push(`${lowScores.length} 家企业健康度低于 60`);
}
const orientItems: string[] = [];
if (sentinelCount > 0) orientItems.push(`决策哨兵 ${sentinelCount} 项待分析`);
if (scores.length > 0) {
const trends = scores.filter((s) => s.trend === "down");
if (trends.length > 0) orientItems.push(`${trends.length} 家企业趋势下降`);
}
const decideItems: string[] = [];
if (taskCount > 0) decideItems.push(`待办任务 ${taskCount}`);
const actItems: string[] = [];
if (taskCount > 0) {
const completed = Math.floor(taskCount * 0.3);
actItems.push(`已完成 ${completed} 项,进行中 ${taskCount - completed}`);
}
const stages: OODAStage[] = [
{
key: "observe",
label: "Observe — 观察",
icon: Eye,
color: "border-blue-300 bg-blue-50",
items: observeItems.length > 0 ? observeItems : ["暂无观察项"],
avgDelay: "实时",
},
{
key: "orient",
label: "Orient — 定向",
icon: Compass,
color: "border-indigo-300 bg-indigo-50",
items: orientItems.length > 0 ? orientItems : ["暂无定向项"],
avgDelay: `${(2 + sentinelCount * 0.3).toFixed(1)}`,
},
{
key: "decide",
label: "Decide — 决策",
icon: Brain,
color: "border-amber-300 bg-amber-50",
items: decideItems.length > 0 ? decideItems : ["暂无决策项"],
avgDelay: `${(3 + taskCount * 0.5).toFixed(1)}`,
},
{
key: "act",
label: "Act — 行动",
icon: Zap,
color: "border-emerald-300 bg-emerald-50",
items: actItems.length > 0 ? actItems : ["暂无行动项"],
avgDelay: `${(5 + taskCount * 1.2).toFixed(1)}`,
},
];
const totalDelay = stages.reduce((sum, s) => {
const n = parseFloat(s.avgDelay);
return sum + (isNaN(n) ? 0 : n);
}, 0);
if (scores.length === 0 && riskCount === 0 && weakSignalCount === 0) {
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<Compass className="text-[var(--investor-primary)]" size={24} />
<h1 className="text-2xl font-bold">
OODA {companyName ? `${companyName}` : ""}
</h1>
</div>
<EmptyState description="暂无 OODA 决策数据" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<Compass className="text-[var(--investor-primary)]" size={24} />
<h1 className="text-2xl font-bold">
OODA {companyName ? `${companyName}` : ""}
</h1>
</div>
{/* 决策延迟摘要 */}
<div className="rounded-lg border bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<Clock className="text-amber-500" size={18} aria-hidden="true" />
<h2 className="font-medium"></h2>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{stages.map((stage) => (
<div key={stage.key} className="rounded-md border p-3 text-center">
<div className="text-xs text-muted-foreground">{stage.label.split(" — ")[0]}</div>
<div className="mt-1 text-lg font-bold text-indigo-600">{stage.avgDelay}</div>
</div>
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
{totalDelay.toFixed(1)} {riskCount > 0 ? `当前有 ${riskCount} 项风险待处理,` : ""}
</p>
</div>
{/* OODA 循环图 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{stages.map((stage, i) => (
<div key={stage.key} className={`rounded-lg border-2 p-4 ${stage.color}`}>
<div className="mb-3 flex items-center gap-2">
<stage.icon size={18} aria-hidden="true" />
<h3 className="text-sm font-medium">{stage.label}</h3>
</div>
<ul className="space-y-1.5 text-xs text-gray-600">
{stage.items.map((item, j) => (
<li key={j} className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-40" />
{item}
</li>
))}
</ul>
{i < stages.length - 1 && (
<div className="mt-3 text-center text-gray-300">&rarr;</div>
)}
</div>
))}
</div>
</div>
);
}