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

487 lines
18 KiB
TypeScript

"use client";
import {
Activity,
Bell,
Bot,
CheckCircle2,
ChevronDown,
ChevronRight,
Download,
FileText,
Keyboard,
LogOut,
PlayCircle,
RefreshCw,
ShieldCheck,
SlidersHorizontal,
UserRound,
type LucideIcon
} from "lucide-react";
import type { ReactNode } from "react";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import {
compactHeaderTextClassName,
headerControlButtonClassName,
headerControlIconClassName
} from "./workbench-top-bar-styles";
const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
active: "运行中",
draft: "草案",
review: "待复核"
};
const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"acrylic-panel border p-2 text-slate-900"
);
const menuReadableRowClassName =
"surface-well focus:bg-blue-50 focus:text-slate-950";
export function ScenarioMenu({
open,
onOpenChange,
scenarios,
activeScenarioId,
activeScenarioName,
onSelectScenario,
onPreviewScenario,
onCompareScenario,
onExportScenarioReport
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
scenarios: WorkbenchScenario[];
activeScenarioId: string;
activeScenarioName: string;
onSelectScenario: (scenarioId: string) => void;
onPreviewScenario: () => void;
onCompareScenario: () => void;
onExportScenarioReport: () => void;
}) {
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("group max-w-[220px] px-2", headerControlButtonClassName)}
aria-label="切换模拟方案"
>
<span className={headerControlIconClassName}>
<SlidersHorizontal size={14} aria-hidden="true" />
</span>
<span className={cn(compactHeaderTextClassName, "truncate")}>场景 · {activeScenarioName}</span>
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
<MenuHeader title="场景控制" meta={`当前:${activeScenarioName}`} />
<MenuSectionLabel>场景列表</MenuSectionLabel>
<DropdownMenuRadioGroup
value={activeScenarioId}
onValueChange={(nextScenarioId) => {
onSelectScenario(nextScenarioId);
onOpenChange(false);
}}
>
{scenarios.map((scenario) => (
<DropdownMenuRadioItem
key={scenario.id}
value={scenario.id}
className={cn(
"my-0.5 items-start border border-transparent py-2 pr-2 transition data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/80",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
<span className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-500">
{scenarioStatusLabels[scenario.status]}
</span>
</span>
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<MenuSeparator />
<MenuSectionLabel>场景命令</MenuSectionLabel>
<MenuAction icon={PlayCircle} label="预览影响范围" description="打开模拟图层与影响区" tone="primary" onSelect={onPreviewScenario} />
<MenuAction icon={SlidersHorizontal} label="比较候选方案" description="影响、阀门与保供风险差异" onSelect={onCompareScenario} />
<MenuAction icon={FileText} label="导出场景报告" description="生成调度复核材料" onSelect={onExportScenarioReport} />
</DropdownMenuContent>
</DropdownMenu>
);
}
export function AlertMenu({
open,
onOpenChange,
alerts,
compact = false,
conditionFeedVisible,
onSelectAlert,
onToggleConditionFeed,
onAnalyzeAlertsWithAgent
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
alerts: WorkbenchAlert[];
compact?: boolean;
conditionFeedVisible: boolean;
onSelectAlert: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) {
const hasAlerts = alerts.length > 0;
const latestAlertTime = alerts[0]?.time;
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"group relative px-2",
headerControlButtonClassName,
hasAlerts && "hover:bg-red-50/60 hover:text-red-800 data-[state=open]:bg-red-50/70 data-[state=open]:text-red-800",
compact && "h-10 w-10 justify-center px-0"
)}
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
>
<span
className={cn(
headerControlIconClassName,
hasAlerts &&
"bg-red-50 text-red-700 group-hover:bg-red-50 group-hover:text-red-800 group-data-[state=open]:bg-red-50 group-data-[state=open]:text-red-800"
)}
>
<Bell size={14} aria-hidden="true" />
</span>
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
{compact ? (
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
{alerts.length}
</span>
) : (
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
{alerts.length}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
<AlertQueueSummary
count={alerts.length}
latestAlertTime={latestAlertTime}
conditionFeedVisible={conditionFeedVisible}
/>
<MenuSectionLabel>待复核工况</MenuSectionLabel>
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
{hasAlerts ? alerts.map((alert) => (
<DropdownMenuItem
key={alert.id}
className={cn(
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-red-100",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
onSelect={() => onSelectAlert(alert)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-red-300 ring-2 ring-red-50" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
</span>
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-red-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem>
)) : (
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p>
</div>
</div>
)}
</div>
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-slate-200/70 pt-2">
<MenuPlainButton
label={conditionFeedVisible ? "工况面板已打开" : "打开工况面板"}
description="查看时间线与处置详情"
disabled={conditionFeedVisible}
onClick={() => {
onToggleConditionFeed();
onOpenChange(false);
}}
/>
<MenuPrimaryButton
disabled={!hasAlerts}
icon={Bot}
label={hasAlerts ? "工况汇总" : "等待提醒"}
onClick={() => {
void onAnalyzeAlertsWithAgent();
onOpenChange(false);
}}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function UserMenu({
open,
onOpenChange,
user,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
user: WorkbenchUser;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
}) {
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("group px-2", headerControlButtonClassName)}
aria-label="打开用户菜单"
>
<span className={headerControlIconClassName}>
<UserRound size={14} aria-hidden="true" />
</span>
<span className={cn(compactHeaderTextClassName, "text-sm font-semibold")}>{user.name}</span>
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
<MenuHeader title={user.name} meta={user.role} icon={ShieldCheck} />
<MenuSectionLabel>会话控制</MenuSectionLabel>
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
<MenuSeparator />
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<LogOut size={15} aria-hidden="true" />
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function AlertQueueSummary({
count,
latestAlertTime,
conditionFeedVisible
}: {
count: number;
latestAlertTime?: string;
conditionFeedVisible: boolean;
}) {
const hasAlerts = count > 0;
return (
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
hasAlerts ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-700"
)}
>
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
<p className="mt-0.5 text-xs leading-5 text-slate-500">
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
</p>
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold leading-5",
hasAlerts ? "bg-red-50 text-red-800 ring-1 ring-red-100" : "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"
)}
>
{hasAlerts ? "需复核" : "正常"}
</span>
</div>
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
<span className={cn("h-1.5 w-1.5 rounded-full", conditionFeedVisible ? "bg-blue-500" : "bg-slate-300")} aria-hidden="true" />
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
</div>
</div>
);
}
function MenuPlainButton({
label,
description,
disabled = false,
onClick
}: {
label: string;
description: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"flex h-11 min-w-0 flex-col justify-center border px-3 text-left transition-colors disabled:cursor-default disabled:opacity-70",
MAP_COMPACT_RADIUS_CLASS_NAME,
disabled
? "border-slate-200 bg-slate-50 text-slate-500"
: "border-slate-200 bg-white text-slate-800 hover:border-blue-100 hover:bg-blue-50/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-100"
)}
>
<span className="truncate text-xs font-semibold leading-4">{label}</span>
<span className="truncate text-xs font-medium leading-4 text-slate-500">{description}</span>
</button>
);
}
function MenuPrimaryButton({
icon: Icon,
label,
disabled = false,
onClick
}: {
icon: LucideIcon;
label: string;
disabled?: boolean;
onClick: () => void | Promise<void>;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"inline-flex h-11 shrink-0 items-center justify-center gap-2 border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed",
MAP_COMPACT_RADIUS_CLASS_NAME,
disabled
? "border-slate-200 bg-slate-50 text-slate-400"
: "border-blue-200 bg-blue-600 text-white shadow-lg shadow-blue-600/20 hover:bg-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
)}
>
<Icon size={14} aria-hidden="true" />
<span className="max-w-[92px] truncate">{label}</span>
</button>
);
}
function MenuHeader({
title,
meta,
tone = "default",
icon: Icon
}: {
title: string;
meta: string;
tone?: "default" | "warning";
icon?: LucideIcon;
}) {
return (
<div className={cn("surface-control flex items-center justify-between gap-3 border px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
</div>
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "text-red-600")}>{meta}</p>
</div>
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
</div>
);
}
function MenuSectionLabel({ children }: { children: ReactNode }) {
return <DropdownMenuLabel className="px-2 pb-1 pt-2.5 text-xs font-semibold uppercase text-slate-500">{children}</DropdownMenuLabel>;
}
function MenuSeparator() {
return <DropdownMenuSeparator className="my-2 bg-slate-200/70" />;
}
function MenuAction({
icon: Icon,
label,
description,
tone = "default",
onSelect
}: {
icon: LucideIcon;
label: string;
description: string;
tone?: "default" | "primary" | "warning";
onSelect: () => void;
}) {
return (
<DropdownMenuItem
className={cn(
"my-0.5 items-center border border-transparent px-2 py-2 transition",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
onSelect={onSelect}
>
<span
className={cn(
"grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
tone === "warning" && "border-orange-100 bg-orange-50 text-orange-700",
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
)}
>
<Icon size={15} aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">{description}</span>
</span>
</DropdownMenuItem>
);
}