242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
Box,
|
|
CalendarClock,
|
|
Clock3,
|
|
Droplets,
|
|
Eye,
|
|
EyeOff,
|
|
type LucideIcon
|
|
} from "lucide-react";
|
|
import {
|
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
|
} from "@/features/map/core/components/map-control-styles";
|
|
import { cn } from "@/lib/utils";
|
|
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
|
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
|
|
|
|
export type WorkbenchTopBarProps = {
|
|
dataTime: string;
|
|
modelName: string;
|
|
scenarios: WorkbenchScenario[];
|
|
activeScenarioId: string;
|
|
alerts: WorkbenchAlert[];
|
|
user: WorkbenchUser;
|
|
conditionFeedVisible: boolean;
|
|
taskTickerAvailable: boolean;
|
|
taskTickerVisible: boolean;
|
|
onSelectScenario: (scenarioId: string) => void;
|
|
onSelectAlert: (alert: WorkbenchAlert) => void;
|
|
onToggleConditionFeed: () => void;
|
|
onToggleTaskTicker: () => void;
|
|
onPreviewScenario: () => void;
|
|
onCompareScenario: () => void;
|
|
onExportScenarioReport: () => void;
|
|
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
|
onShowDataStatus: () => void;
|
|
onRefreshTiles: () => void;
|
|
onShowShortcuts: () => void;
|
|
onExportConfig: () => void;
|
|
};
|
|
|
|
const headerControlButtonClassName =
|
|
"inline-flex h-8 items-center gap-2 rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
|
const headerControlIconClassName =
|
|
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
|
|
|
|
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
|
|
|
export function WorkbenchTopBar({
|
|
dataTime,
|
|
modelName,
|
|
scenarios,
|
|
activeScenarioId,
|
|
alerts,
|
|
user,
|
|
conditionFeedVisible,
|
|
taskTickerAvailable,
|
|
taskTickerVisible,
|
|
onSelectScenario,
|
|
onSelectAlert,
|
|
onToggleConditionFeed,
|
|
onToggleTaskTicker,
|
|
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="glass-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-3 text-sm text-slate-700 lg:flex xl: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-1.5">
|
|
<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}
|
|
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
|
|
onClick={onToggle}
|
|
className={cn(
|
|
"group px-2",
|
|
headerControlButtonClassName,
|
|
visible && "bg-blue-50/80 text-blue-700"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
headerControlIconClassName,
|
|
visible && "bg-blue-600/10 text-blue-700"
|
|
)}
|
|
>
|
|
<Icon size={14} aria-hidden="true" />
|
|
</span>
|
|
<span>任务浮条</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ConditionFeedToggle({
|
|
visible,
|
|
onToggle
|
|
}: {
|
|
visible: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-pressed={visible}
|
|
onClick={onToggle}
|
|
className={cn(
|
|
"group px-2",
|
|
headerControlButtonClassName,
|
|
visible && "bg-blue-50/80 text-blue-700"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
headerControlIconClassName,
|
|
visible && "bg-blue-600/10 text-blue-700"
|
|
)}
|
|
>
|
|
<CalendarClock size={14} aria-hidden="true" />
|
|
</span>
|
|
<span>工况任务</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
|
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
|
|
<span className="text-xs font-medium text-slate-500">{label}</span>
|
|
<span className="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" />;
|
|
}
|