Files
next-tjwater-frontend/src/features/workbench/components/workbench-top-bar.tsx
T

320 lines
9.9 KiB
TypeScript

import { useState } from "react";
import {
Box,
CalendarClock,
Clock3,
Droplets,
Eye,
EyeOff,
FlaskConical,
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, 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;
timelineVisible: boolean;
taskTickerAvailable: boolean;
taskTickerVisible: boolean;
devPanelEnabled: boolean;
devPanelOpen: boolean;
onSelectScenario: (scenarioId: string) => void;
onSelectAlert: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onToggleTimeline: () => void;
onToggleTaskTicker: () => void;
onToggleDevPanel: () => void;
onPreviewScenario: () => void;
onCompareScenario: () => void;
onExportScenarioReport: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
onCreateTestAnalysis?: () => void;
onShowDataStatus: () => void;
onRefreshTiles: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
onLogout?: () => Promise<void>;
};
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
export function WorkbenchTopBar({
dataTime,
modelName,
scenarios,
activeScenarioId,
alerts,
user,
conditionFeedVisible,
timelineVisible,
taskTickerAvailable,
taskTickerVisible,
devPanelEnabled,
devPanelOpen,
onSelectScenario,
onSelectAlert,
onToggleConditionFeed,
onToggleTimeline,
onToggleTaskTicker,
onToggleDevPanel,
onPreviewScenario,
onCompareScenario,
onExportScenarioReport,
onAnalyzeAlertsWithAgent,
onCreateTestAnalysis,
onShowDataStatus,
onRefreshTiles,
onShowShortcuts,
onExportConfig,
onLogout
}: 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">
<div className="lg:hidden">
<TimelineToggle compact visible={timelineVisible} onToggle={onToggleTimeline} />
</div>
<div className="hidden lg:block">
<TimelineToggle visible={timelineVisible} onToggle={onToggleTimeline} />
</div>
{onCreateTestAnalysis ? (
<div className="hidden lg:block">
<button
type="button"
aria-label="生成 Agent 模拟分析成果"
title="生成 Agent 模拟分析成果"
data-selection-tone="soft"
onClick={onCreateTestAnalysis}
className={cn("group px-2", headerControlButtonClassName)}
>
<span className={headerControlIconClassName}>
<Sparkles size={14} aria-hidden="true" />
</span>
<span className={compactHeaderTextClassName}>测试分析</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}
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 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="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" />;
}