487 lines
19 KiB
TypeScript
487 lines
19 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";
|
|
|
|
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";
|
|
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";
|
|
|
|
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="truncate">场景 · {activeScenarioName}</span>
|
|
<ChevronDown size={14} className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600" 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" : undefined}>异常处置</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="hidden text-sm font-semibold md:inline">{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 sm: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>
|
|
);
|
|
}
|