The not_configured branch previously promoted mock task failures into critical alerts. Return an explicit empty alert source so the exception badge reflects only actionable backend state.
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
Box,
|
|
CalendarClock,
|
|
BarChart3,
|
|
Clock3,
|
|
Droplets,
|
|
Eye,
|
|
EyeOff,
|
|
FlaskConical,
|
|
RadioTower,
|
|
Sparkles,
|
|
type LucideIcon
|
|
} from "lucide-react";
|
|
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import type {
|
|
WorkbenchAlert,
|
|
WorkbenchAlertDataSource,
|
|
WorkbenchUser
|
|
} from "../types";
|
|
import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model";
|
|
import { AlertMenu, AnalysisViewMenu, UserMenu } from "./workbench-top-bar-menus";
|
|
import {
|
|
compactHeaderTextClassName,
|
|
headerControlButtonClassName,
|
|
headerControlIconClassName
|
|
} from "./workbench-top-bar-styles";
|
|
|
|
export type WorkbenchTopBarProps = {
|
|
dataTime: string;
|
|
modelName: string;
|
|
activeAnalysisArtifact: AnalysisArtifact | null;
|
|
alerts: WorkbenchAlert[];
|
|
alertDataSource: WorkbenchAlertDataSource;
|
|
user: WorkbenchUser;
|
|
conditionFeedVisible: boolean;
|
|
timelineVisible: boolean;
|
|
taskTickerAvailable: boolean;
|
|
taskTickerVisible: boolean;
|
|
analysisResultsPanelOpen: boolean;
|
|
peripheralSamplePanelOpen: boolean;
|
|
devPanelEnabled: boolean;
|
|
devPanelOpen: boolean;
|
|
onSelectAlert: (alert: WorkbenchAlert) => void;
|
|
onToggleConditionFeed: () => void;
|
|
onToggleTimeline: () => void;
|
|
onToggleTaskTicker: () => void;
|
|
onToggleAnalysisResultsPanel: () => void;
|
|
onTogglePeripheralSamplePanel: () => void;
|
|
onToggleDevPanel: () => void;
|
|
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
|
latestAnalysisAvailable: boolean;
|
|
analysisSplitAvailable: boolean;
|
|
onOpenLatestAnalysis: () => void;
|
|
onSetAnalysisView: (view: ArtifactRequestedView) => void;
|
|
onCloseAnalysisView: () => void;
|
|
onShowDataStatus: () => void;
|
|
onRefreshTiles: () => void;
|
|
onShowShortcuts: () => void;
|
|
onExportConfig: () => void;
|
|
onLogout?: () => Promise<void>;
|
|
};
|
|
|
|
type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user";
|
|
|
|
export function WorkbenchTopBar({
|
|
dataTime,
|
|
modelName,
|
|
activeAnalysisArtifact,
|
|
alerts,
|
|
alertDataSource,
|
|
user,
|
|
conditionFeedVisible,
|
|
timelineVisible,
|
|
taskTickerAvailable,
|
|
taskTickerVisible,
|
|
analysisResultsPanelOpen,
|
|
peripheralSamplePanelOpen,
|
|
devPanelEnabled,
|
|
devPanelOpen,
|
|
onSelectAlert,
|
|
onToggleConditionFeed,
|
|
onToggleTimeline,
|
|
onToggleTaskTicker,
|
|
onToggleAnalysisResultsPanel,
|
|
onTogglePeripheralSamplePanel,
|
|
onToggleDevPanel,
|
|
onAnalyzeAlertsWithAgent,
|
|
latestAnalysisAvailable,
|
|
analysisSplitAvailable,
|
|
onOpenLatestAnalysis,
|
|
onSetAnalysisView,
|
|
onCloseAnalysisView,
|
|
onShowDataStatus,
|
|
onRefreshTiles,
|
|
onShowShortcuts,
|
|
onExportConfig,
|
|
onLogout
|
|
}: WorkbenchTopBarProps) {
|
|
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
|
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
|
|
setOpenMenu(open ? menuId : null);
|
|
};
|
|
|
|
return (
|
|
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5">
|
|
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0">
|
|
<div
|
|
className={cn(
|
|
"grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20",
|
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
|
)}
|
|
>
|
|
<Droplets size={18} aria-hidden="true" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h1 aria-label="供水管网智能调度系统" className="truncate text-sm font-semibold leading-5 text-slate-950">
|
|
<span className="sm:hidden">供水</span>
|
|
<span className="hidden sm:inline">供水管网智能调度系统</span>
|
|
</h1>
|
|
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden shrink-0 items-center gap-2 text-sm text-slate-700 lg:flex">
|
|
<HeaderReadout icon={Clock3} label="数据时间" value={dataTime} />
|
|
<div className="hidden items-center gap-2 xl:flex">
|
|
<HeaderDivider />
|
|
<HeaderReadout icon={Box} label="模型版本" value={modelName} />
|
|
</div>
|
|
<HeaderDivider />
|
|
<TaskTickerToggle
|
|
available={taskTickerAvailable}
|
|
visible={taskTickerVisible}
|
|
onToggle={onToggleTaskTicker}
|
|
/>
|
|
{taskTickerAvailable ? <HeaderDivider /> : null}
|
|
<ConditionFeedToggle visible={conditionFeedVisible} onToggle={onToggleConditionFeed} />
|
|
<HeaderDivider />
|
|
<AlertMenu
|
|
open={openMenu === "alerts"}
|
|
onOpenChange={createMenuOpenChange("alerts")}
|
|
alerts={alerts}
|
|
sourceLabel={getAlertSourceLabel(alertDataSource)}
|
|
conditionFeedVisible={conditionFeedVisible}
|
|
onSelectAlert={onSelectAlert}
|
|
onToggleConditionFeed={onToggleConditionFeed}
|
|
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
|
/>
|
|
{activeAnalysisArtifact ? (
|
|
<>
|
|
<HeaderDivider />
|
|
<AnalysisViewMenu
|
|
open={openMenu === "analysis-view"}
|
|
onOpenChange={createMenuOpenChange("analysis-view")}
|
|
title={activeAnalysisArtifact.title}
|
|
subtitle={activeAnalysisArtifact.subtitle}
|
|
sourceLabel={getAnalysisOriginLabel(activeAnalysisArtifact.origin)}
|
|
splitAvailable={analysisSplitAvailable}
|
|
hasSpatialView={activeAnalysisArtifact.mapRelation !== "none"}
|
|
onShowResult={() => onSetAnalysisView("focus")}
|
|
onShowMapSplit={() => onSetAnalysisView("map_split")}
|
|
onShowMapOnly={() => onSetAnalysisView("map_only")}
|
|
onClose={onCloseAnalysisView}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<button
|
|
type="button"
|
|
aria-label="项目分析成果"
|
|
aria-pressed={analysisResultsPanelOpen}
|
|
title="项目分析成果"
|
|
onClick={onToggleAnalysisResultsPanel}
|
|
className={cn("group px-2", headerControlButtonClassName)}
|
|
>
|
|
<span className={headerControlIconClassName}>
|
|
<BarChart3 size={14} aria-hidden="true" />
|
|
</span>
|
|
<span className={cn(compactHeaderTextClassName, "hidden xl:inline")}>分析成果</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="外围样板管理"
|
|
aria-pressed={peripheralSamplePanelOpen}
|
|
title="外围样板管理"
|
|
onClick={onTogglePeripheralSamplePanel}
|
|
className={cn("group px-2", headerControlButtonClassName)}
|
|
>
|
|
<span className={headerControlIconClassName}><RadioTower size={14} aria-hidden="true" /></span>
|
|
<span className={cn(compactHeaderTextClassName, "hidden xl:inline")}>外围样板</span>
|
|
</button>
|
|
<div className="lg:hidden">
|
|
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
|
|
</div>
|
|
<div className="hidden lg:block">
|
|
<TimelineToggle visible={timelineVisible} onToggle={onToggleTimeline} />
|
|
</div>
|
|
{latestAnalysisAvailable && !activeAnalysisArtifact ? (
|
|
<div className="hidden lg:block">
|
|
<button
|
|
type="button"
|
|
aria-label="打开最新后端分析成果"
|
|
title="打开最新后端分析成果"
|
|
data-selection-tone="soft"
|
|
onClick={onOpenLatestAnalysis}
|
|
className={cn("group px-2", headerControlButtonClassName)}
|
|
>
|
|
<span className={headerControlIconClassName}>
|
|
<Sparkles size={14} aria-hidden="true" />
|
|
</span>
|
|
<span className="hidden text-sm font-semibold xl:inline">最新分析</span>
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{devPanelEnabled ? (
|
|
<div className="hidden lg:block">
|
|
<button
|
|
type="button"
|
|
aria-label="切换地图 Dev Panel"
|
|
aria-pressed={devPanelOpen}
|
|
data-selection-tone="soft"
|
|
onClick={onToggleDevPanel}
|
|
className={cn("group px-2", headerControlButtonClassName)}
|
|
>
|
|
<span className={headerControlIconClassName}>
|
|
<FlaskConical size={14} aria-hidden="true" />
|
|
</span>
|
|
<span className={compactHeaderTextClassName}>Dev</span>
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
<div className="lg:hidden">
|
|
<AlertMenu
|
|
compact
|
|
open={openMenu === "compact-alerts"}
|
|
onOpenChange={createMenuOpenChange("compact-alerts")}
|
|
alerts={alerts}
|
|
sourceLabel={getAlertSourceLabel(alertDataSource)}
|
|
conditionFeedVisible={conditionFeedVisible}
|
|
onSelectAlert={onSelectAlert}
|
|
onToggleConditionFeed={onToggleConditionFeed}
|
|
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
|
/>
|
|
</div>
|
|
<UserMenu
|
|
open={openMenu === "user"}
|
|
onOpenChange={createMenuOpenChange("user")}
|
|
user={user}
|
|
onRefreshTiles={onRefreshTiles}
|
|
onShowDataStatus={onShowDataStatus}
|
|
onShowShortcuts={onShowShortcuts}
|
|
onExportConfig={onExportConfig}
|
|
onLogout={onLogout}
|
|
/>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function getAlertSourceLabel(source: WorkbenchAlertDataSource) {
|
|
if (source === "backend") return "后端运行态";
|
|
if (source === "mock") return "工况演示回退";
|
|
return "未配置告警源";
|
|
}
|
|
|
|
function getAnalysisOriginLabel(origin: AnalysisArtifact["origin"]) {
|
|
if (origin === "backend") return "后端分析运行";
|
|
if (origin === "demo") return "演示成果";
|
|
return "Agent 分析";
|
|
}
|
|
|
|
function TaskTickerToggle({
|
|
available,
|
|
visible,
|
|
onToggle
|
|
}: {
|
|
available: boolean;
|
|
visible: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const Icon = visible ? EyeOff : Eye;
|
|
|
|
if (!available) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
|
aria-pressed={visible}
|
|
data-selection-tone="soft"
|
|
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
|
onClick={onToggle}
|
|
className={cn("group px-2", headerControlButtonClassName)}
|
|
>
|
|
<span className={headerControlIconClassName}>
|
|
<Icon size={14} aria-hidden="true" />
|
|
</span>
|
|
<span className={compactHeaderTextClassName}>任务浮条</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ConditionFeedToggle({ visible, onToggle }: { visible: boolean; onToggle: () => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label="工况任务"
|
|
aria-pressed={visible}
|
|
data-selection-tone="soft"
|
|
title="工况任务"
|
|
onClick={onToggle}
|
|
className={cn("group px-2", headerControlButtonClassName)}
|
|
>
|
|
<span className={headerControlIconClassName}>
|
|
<CalendarClock size={14} aria-hidden="true" />
|
|
</span>
|
|
<span className={compactHeaderTextClassName}>工况任务</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function TimelineToggle({
|
|
visible,
|
|
compact = false,
|
|
onToggle
|
|
}: {
|
|
visible: boolean;
|
|
compact?: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
|
|
aria-pressed={visible}
|
|
data-selection-tone="soft"
|
|
title={visible ? "隐藏运行时间轴" : "显示运行时间轴"}
|
|
onClick={onToggle}
|
|
className={cn("group", compact ? "px-1.5" : "px-2", headerControlButtonClassName)}
|
|
>
|
|
<span className={headerControlIconClassName}>
|
|
<Clock3 size={14} aria-hidden="true" />
|
|
</span>
|
|
{!compact ? <span className={compactHeaderTextClassName}>时间轴</span> : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function HeaderReadout({
|
|
icon: Icon,
|
|
label,
|
|
value
|
|
}: {
|
|
icon?: LucideIcon;
|
|
label: string;
|
|
value: string;
|
|
}) {
|
|
return (
|
|
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
|
|
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
|
<span className="sr-only">{label}</span>
|
|
<span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800">
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HeaderDivider() {
|
|
return <div className="h-5 w-px shrink-0 bg-slate-200/80" />;
|
|
}
|