Files
next-tjwater-frontend/src/features/workbench/components/workbench-top-bar.tsx
T
jiang a5e5acb03b fix(workbench): suppress unconfigured demo alerts
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.
2026-09-24 15:13:14 +08:00

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" />;
}