Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling. Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
260 lines
8.0 KiB
TypeScript
260 lines
8.0 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
Box,
|
|
CalendarClock,
|
|
Clock3,
|
|
Droplets,
|
|
Eye,
|
|
EyeOff,
|
|
FlaskConical,
|
|
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, WorkbenchScenario, WorkbenchUser } from "../types";
|
|
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
|
import {
|
|
compactHeaderTextClassName,
|
|
headerControlButtonClassName,
|
|
headerControlIconClassName
|
|
} from "./workbench-top-bar-styles";
|
|
|
|
export type WorkbenchTopBarProps = {
|
|
dataTime: string;
|
|
modelName: string;
|
|
scenarios: WorkbenchScenario[];
|
|
activeScenarioId: string;
|
|
alerts: WorkbenchAlert[];
|
|
user: WorkbenchUser;
|
|
conditionFeedVisible: boolean;
|
|
taskTickerAvailable: boolean;
|
|
taskTickerVisible: boolean;
|
|
devPanelEnabled: boolean;
|
|
devPanelOpen: boolean;
|
|
onSelectScenario: (scenarioId: string) => void;
|
|
onSelectAlert: (alert: WorkbenchAlert) => void;
|
|
onToggleConditionFeed: () => void;
|
|
onToggleTaskTicker: () => void;
|
|
onToggleDevPanel: () => void;
|
|
onPreviewScenario: () => void;
|
|
onCompareScenario: () => void;
|
|
onExportScenarioReport: () => void;
|
|
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
|
onShowDataStatus: () => void;
|
|
onRefreshTiles: () => void;
|
|
onShowShortcuts: () => void;
|
|
onExportConfig: () => void;
|
|
};
|
|
|
|
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
|
|
|
export function WorkbenchTopBar({
|
|
dataTime,
|
|
modelName,
|
|
scenarios,
|
|
activeScenarioId,
|
|
alerts,
|
|
user,
|
|
conditionFeedVisible,
|
|
taskTickerAvailable,
|
|
taskTickerVisible,
|
|
devPanelEnabled,
|
|
devPanelOpen,
|
|
onSelectScenario,
|
|
onSelectAlert,
|
|
onToggleConditionFeed,
|
|
onToggleTaskTicker,
|
|
onToggleDevPanel,
|
|
onPreviewScenario,
|
|
onCompareScenario,
|
|
onExportScenarioReport,
|
|
onAnalyzeAlertsWithAgent,
|
|
onShowDataStatus,
|
|
onRefreshTiles,
|
|
onShowShortcuts,
|
|
onExportConfig
|
|
}: WorkbenchTopBarProps) {
|
|
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
|
const activeScenario =
|
|
scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
|
|
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 border-b px-3 sm:px-4 md:px-5">
|
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
<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 className="truncate text-sm font-semibold leading-5 text-slate-950">
|
|
供水管网智能调度系统
|
|
</h1>
|
|
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden min-w-0 items-center gap-2 text-sm text-slate-700 lg:flex 2xl:gap-4">
|
|
<HeaderReadout icon={Clock3} label="数据时间" value={dataTime} />
|
|
<HeaderDivider />
|
|
<HeaderReadout icon={Box} label="模型版本" value={modelName} />
|
|
<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}
|
|
conditionFeedVisible={conditionFeedVisible}
|
|
onSelectAlert={onSelectAlert}
|
|
onToggleConditionFeed={onToggleConditionFeed}
|
|
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
|
/>
|
|
<HeaderDivider />
|
|
{activeScenario ? (
|
|
<ScenarioMenu
|
|
open={openMenu === "scenario"}
|
|
onOpenChange={createMenuOpenChange("scenario")}
|
|
scenarios={scenarios}
|
|
activeScenarioId={activeScenario.id}
|
|
activeScenarioName={activeScenario.name}
|
|
onSelectScenario={onSelectScenario}
|
|
onPreviewScenario={onPreviewScenario}
|
|
onCompareScenario={onCompareScenario}
|
|
onExportScenarioReport={onExportScenarioReport}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{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}
|
|
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}
|
|
/>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
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 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="hidden text-xs font-medium text-slate-500 2xl:inline">{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" />;
|
|
}
|