feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
@@ -5,14 +5,15 @@ import {
CheckCircle2,
ChevronDown,
ChevronRight,
Columns2,
Download,
FileText,
Keyboard,
LogOut,
PlayCircle,
Map,
RefreshCw,
ShieldCheck,
SlidersHorizontal,
X,
UserRound,
type LucideIcon
} from "lucide-react";
@@ -35,12 +36,10 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import type { WorkbenchAlert, WorkbenchUser } from "../types";
import {
compactHeaderControlButtonClassName,
compactHeaderTextClassName,
@@ -48,44 +47,36 @@ import {
headerControlIconClassName
} from "./workbench-top-bar-styles";
const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
active: "运行中",
draft: "草案",
review: "待复核"
};
const scenarioStatusTones = {
active: "info",
draft: "neutral",
review: "warning"
} satisfies Record<WorkbenchScenario["status"], StatusTone>;
const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"acrylic-panel border p-2 text-slate-900"
);
const menuReadableRowClassName =
"surface-well focus:bg-blue-50 focus:text-slate-950";
export function ScenarioMenu({
export function AnalysisViewMenu({
open,
onOpenChange,
scenarios,
activeScenarioId,
activeScenarioName,
onSelectScenario,
onPreviewScenario,
onCompareScenario,
onExportScenarioReport
title,
subtitle,
sourceLabel,
splitAvailable,
hasSpatialView,
onShowResult,
onShowMapSplit,
onShowMapOnly,
onClose
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
scenarios: WorkbenchScenario[];
activeScenarioId: string;
activeScenarioName: string;
onSelectScenario: (scenarioId: string) => void;
onPreviewScenario: () => void;
onCompareScenario: () => void;
onExportScenarioReport: () => void;
title: string;
subtitle: string;
sourceLabel: string;
splitAvailable: boolean;
hasSpatialView: boolean;
onShowResult: () => void;
onShowMapSplit: () => void;
onShowMapOnly: () => void;
onClose: () => void;
}) {
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
@@ -93,52 +84,29 @@ export function ScenarioMenu({
<button
type="button"
className={cn("group max-w-[220px] px-2", headerControlButtonClassName)}
aria-label="切换模拟方案"
aria-label={`当前分析视图:${title}`}
>
<span className={headerControlIconClassName}>
<SlidersHorizontal size={14} aria-hidden="true" />
<Activity size={14} aria-hidden="true" />
</span>
<span className="hidden max-w-[160px] truncate text-sm font-semibold xl:inline 2xl:max-w-[200px]">
分析视图 · {title}
</span>
<span className={cn(compactHeaderTextClassName, "truncate")}>场景 · {activeScenarioName}</span>
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
<MenuHeader title="场景控制" meta={`当前:${activeScenarioName}`} />
<MenuSectionLabel>场景列表</MenuSectionLabel>
<DropdownMenuRadioGroup
value={activeScenarioId}
onValueChange={(nextScenarioId) => {
onSelectScenario(nextScenarioId);
onOpenChange(false);
}}
>
{scenarios.map((scenario) => (
<DropdownMenuRadioItem
key={scenario.id}
value={scenario.id}
className={cn(
"my-0.5 items-start border border-transparent py-2 pr-2 transition data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/80",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
<StatusBadge tone={scenarioStatusTones[scenario.status]}>
{scenarioStatusLabels[scenario.status]}
</StatusBadge>
</span>
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<MenuHeader title={title} meta={`${subtitle} · ${sourceLabel}`} />
<MenuSectionLabel>查看方式</MenuSectionLabel>
<MenuAction icon={FileText} label="查看完整成果" description="打开指标、图表和结果表" tone="primary" onSelect={onShowResult} />
{splitAvailable ? (
<MenuAction icon={Columns2} label="半屏显示" description="分析报告与地图各占半屏" onSelect={onShowMapSplit} />
) : null}
{hasSpatialView ? (
<MenuAction icon={Map} label="仅看地图" description="保留当前分析图层并展开地图" onSelect={onShowMapOnly} />
) : null}
<MenuSeparator />
<MenuSectionLabel>场景命令</MenuSectionLabel>
<MenuAction icon={PlayCircle} label="预览影响范围" description="打开模拟图层与影响区" tone="primary" onSelect={onPreviewScenario} />
<MenuAction icon={SlidersHorizontal} label="比较候选方案" description="影响、阀门与保供风险差异" onSelect={onCompareScenario} />
<MenuAction icon={FileText} label="导出场景报告" description="生成调度复核材料" onSelect={onExportScenarioReport} />
<MenuAction icon={X} label="关闭分析视图" description="移除当前成果及其地图图层" onSelect={onClose} />
</DropdownMenuContent>
</DropdownMenu>
);
@@ -148,6 +116,7 @@ export function AlertMenu({
open,
onOpenChange,
alerts,
sourceLabel,
compact = false,
conditionFeedVisible,
onSelectAlert,
@@ -157,6 +126,7 @@ export function AlertMenu({
open: boolean;
onOpenChange: (open: boolean) => void;
alerts: WorkbenchAlert[];
sourceLabel: string;
compact?: boolean;
conditionFeedVisible: boolean;
onSelectAlert: (alert: WorkbenchAlert) => void;
@@ -164,6 +134,8 @@ export function AlertMenu({
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) {
const hasAlerts = alerts.length > 0;
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
const latestAlertTime = alerts[0]?.time;
return (
@@ -175,7 +147,9 @@ export function AlertMenu({
"group relative px-2",
headerControlButtonClassName,
hasAlerts && !compact &&
"status-tone-danger border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
(hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"),
hasAlerts && !compact &&
"border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
compact && compactHeaderControlButtonClassName
)}
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
@@ -183,7 +157,9 @@ export function AlertMenu({
<span
className={cn(
headerControlIconClassName,
hasAlerts ? "status-icon status-tone-danger" : "bg-transparent!"
hasAlerts
? cn("status-icon", hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning")
: "bg-transparent!"
)}
>
<Bell size={14} aria-hidden="true" />
@@ -191,7 +167,7 @@ export function AlertMenu({
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
{compact ? (
<StatusBadge
tone={hasAlerts ? "danger" : "neutral"}
tone={alertSummaryTone}
className={cn(
"absolute right-0 top-0 min-h-4 min-w-4 justify-center px-1 py-0 leading-none",
!hasAlerts && "border-transparent! bg-transparent!"
@@ -201,7 +177,7 @@ export function AlertMenu({
</StatusBadge>
) : (
<StatusBadge
tone={hasAlerts ? "danger" : "neutral"}
tone={alertSummaryTone}
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
>
{alerts.length}
@@ -214,32 +190,39 @@ export function AlertMenu({
count={alerts.length}
latestAlertTime={latestAlertTime}
conditionFeedVisible={conditionFeedVisible}
sourceLabel={sourceLabel}
tone={hasAlerts ? alertSummaryTone : "success"}
/>
<MenuSectionLabel>待复核工况</MenuSectionLabel>
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
{hasAlerts ? alerts.map((alert) => (
<DropdownMenuItem
key={alert.id}
className={cn(
"status-tone-danger group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
onSelect={() => onSelectAlert(alert)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<StatusDot tone="danger" size="md" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
{hasAlerts ? alerts.map((alert) => {
const tone = alert.severity === "warning" ? "warning" : "danger";
const toneClassName = tone === "warning" ? "status-tone-warning" : "status-tone-danger";
return (
<DropdownMenuItem
key={alert.id}
className={cn(
toneClassName,
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
onSelect={() => onSelectAlert(alert)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<StatusDot tone={tone} size="md" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
</span>
<span className={cn("status-tone-danger grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem>
)) : (
<span className={cn(toneClassName, "grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem>
);
}) : (
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
@@ -342,11 +325,15 @@ export function UserMenu({
function AlertQueueSummary({
count,
latestAlertTime,
conditionFeedVisible
conditionFeedVisible,
sourceLabel,
tone
}: {
count: number;
latestAlertTime?: string;
conditionFeedVisible: boolean;
sourceLabel: string;
tone: StatusTone;
}) {
const hasAlerts = count > 0;
@@ -355,7 +342,7 @@ function AlertQueueSummary({
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<SemanticStatusIcon
tone={hasAlerts ? "danger" : "success"}
tone={tone}
size="md"
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
>
@@ -366,9 +353,10 @@ function AlertQueueSummary({
<p className="mt-0.5 text-xs leading-5 text-slate-500">
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
</p>
<p className="mt-0.5 text-xs leading-4 text-slate-400">来源:{sourceLabel}</p>
</div>
</div>
<StatusBadge tone={hasAlerts ? "danger" : "success"}>
<StatusBadge tone={tone}>
{hasAlerts ? "需复核" : "正常"}
</StatusBadge>
</div>