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

581 lines
23 KiB
TypeScript

import {
Activity,
ArrowRightLeft,
Bell,
Bot,
CheckCircle2,
ChevronDown,
Columns2,
FileText,
Keyboard,
LogOut,
Map,
RefreshCw,
ShieldCheck,
X,
UserRound,
type LucideIcon
} from "lucide-react";
import { useRef, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ProjectSwitchDialog } from "./project-switch-dialog";
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";
import { cn } from "@/shared/ui/cn";
import {
StatusBadge,
StatusDot,
StatusIcon as SemanticStatusIcon,
type StatusTone
} from "@/shared/ui/status";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { WorkbenchProject } from "../api/project-client";
import type { WorkbenchAlert, WorkbenchUser } from "../types";
import {
compactHeaderControlButtonClassName,
compactHeaderTextClassName,
headerControlButtonClassName,
headerControlIconClassName
} from "./workbench-top-bar-styles";
const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"acrylic-panel border p-2 text-slate-900"
);
const menuReadableRowClassName =
"surface-well focus:bg-[var(--action-soft)] focus:text-slate-950";
const alertActionClassName = cn(
MAP_COMPACT_RADIUS_CLASS_NAME,
"h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] transition-colors hover:border-[var(--action-blue)] hover:bg-[var(--action-blue)] hover:text-white focus:border-[var(--action-blue)] focus:bg-[var(--action-blue)] focus:text-white data-[highlighted]:border-[var(--action-blue)] data-[highlighted]:bg-[var(--action-blue)] data-[highlighted]:text-white active:scale-95"
);
export function AnalysisViewMenu({
open,
onOpenChange,
title,
subtitle,
sourceLabel,
splitAvailable,
hasSpatialView,
onShowResult,
onShowMapSplit,
onShowMapOnly,
onClose
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
subtitle: string;
sourceLabel: string;
splitAvailable: boolean;
hasSpatialView: boolean;
onShowResult: () => void;
onShowMapSplit: () => void;
onShowMapOnly: () => void;
onClose: () => 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={`当前分析视图:${title}`}
>
<span className={headerControlIconClassName}>
<Activity size={14} aria-hidden="true" />
</span>
<span className="hidden max-w-[160px] truncate text-sm font-semibold xl:inline 2xl:max-w-[200px]">
分析视图 · {title}
</span>
<ChevronDown size={14} className="hidden shrink-0 text-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)] 2xl:block" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
<MenuHeader title={title} meta={`${subtitle} · ${sourceLabel}`} />
<MenuSectionLabel>查看方式</MenuSectionLabel>
<MenuAction icon={FileText} label="查看完整成果" description="打开指标、图表和结果表" tone="primary" onSelect={onShowResult} />
{splitAvailable ? (
<MenuAction icon={Columns2} label="半屏显示" description="分析报告与地图各占半屏" onSelect={onShowMapSplit} />
) : null}
{hasSpatialView ? (
<MenuAction icon={Map} label="仅看地图" description="保留当前分析图层并展开地图" onSelect={onShowMapOnly} />
) : null}
<MenuSeparator />
<MenuAction icon={X} label="关闭分析视图" description="移除当前成果及其地图图层" onSelect={onClose} />
</DropdownMenuContent>
</DropdownMenu>
);
}
export function AlertMenu({
open,
onOpenChange,
alerts,
sourceLabel,
compact = false,
conditionFeedVisible,
onSelectAlert,
onRemoveAlert,
onToggleConditionFeed,
onAnalyzeAlertsWithAgent
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
alerts: WorkbenchAlert[];
sourceLabel: string;
compact?: boolean;
conditionFeedVisible: boolean;
onSelectAlert: (alert: WorkbenchAlert) => void;
onRemoveAlert?: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) {
const reduceMotion = useReducedMotion();
const keyboardInteraction = useRef(false);
const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current;
const hasAlerts = alerts.length > 0;
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
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 && !compact &&
(hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"),
hasAlerts && !compact &&
"border-transparent bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_45%)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] [@media(hover:hover)]:hover:text-[var(--status-foreground)] data-[state=open]:border-transparent data-[state=open]:bg-[color-mix(in_oklch,var(--status-soft),var(--status-border)_75%)] data-[state=open]:text-[var(--status-foreground)]",
compact && compactHeaderControlButtonClassName
)}
aria-label={`查看待办与告警,当前 ${alerts.length} 条待处理事项`}
>
<span
className={cn(
headerControlIconClassName,
hasAlerts
? cn("status-icon", hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning")
: "bg-transparent!"
)}
>
<Bell size={14} aria-hidden="true" />
</span>
<span className={compact ? "sr-only" : compactHeaderTextClassName}>待办与告警</span>
{compact ? (
<StatusBadge
tone={alertSummaryTone}
className={cn(
"absolute right-0 top-0 min-h-4 min-w-4 justify-center px-1 py-0 leading-none",
!hasAlerts && "border-transparent! bg-transparent!"
)}
>
{alerts.length}
</StatusBadge>
) : (
<StatusBadge
tone={alertSummaryTone}
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
>
{alerts.length}
</StatusBadge>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}
onKeyDownCapture={() => { keyboardInteraction.current = true; }}
onPointerDownCapture={() => { keyboardInteraction.current = false; }}
>
<AlertQueueSummary
count={alerts.length}
latestAlertTime={latestAlertTime}
conditionFeedVisible={conditionFeedVisible}
sourceLabel={sourceLabel}
tone={hasAlerts ? alertSummaryTone : "success"}
/>
<MenuSectionLabel>待处理事项</MenuSectionLabel>
<div className="scheduled-feed-scroll relative max-h-[min(52dvh,420px)] overflow-x-hidden overflow-y-auto pr-1">
<AnimatePresence initial={false} mode="popLayout" custom={skipRemovalMotion}>
{hasAlerts ? alerts.map((alert) => {
const tone = alert.severity === "warning" ? "warning" : "danger";
return (
<motion.div
key={alert.id}
data-alert-id={alert.id}
layout="position"
initial={false}
animate={{ opacity: 1, x: 0 }}
exit="removed"
variants={{ removed: (skip: boolean) => ({
opacity: 0, x: skip ? 0 : 12, pointerEvents: "none",
transition: { duration: skip ? 0 : 0.18, ease: [0.4, 0, 1, 1] }
}) }}
transition={{ duration: skipRemovalMotion ? 0 : 0.18, ease: [0.4, 0, 1, 1] }}
className={cn(
tone === "warning" ? "status-tone-warning" : "status-tone-danger",
"relative my-1 min-h-[76px] border border-transparent px-3 py-2 transition-colors hover:border-[var(--action-border-hover)] hover:bg-[var(--action-selection-soft)] focus-within:border-[var(--action-border-hover)] focus-within:bg-[var(--action-selection-soft)]",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
>
<DropdownMenuItem
asChild
className="absolute inset-0 z-0 h-full w-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 hover:bg-transparent focus:bg-transparent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]"
onSelect={() => onSelectAlert(alert)}
>
<button
type="button"
aria-label={`打开通知:${alert.title}`}
/>
</DropdownMenuItem>
<div className="pointer-events-none relative">
<span className="flex min-w-0 items-center gap-2 pr-10 text-xs text-[var(--ink-muted)]">
<StatusDot tone={tone} size="md" />
<span className="font-mono tabular-nums">{alert.time}</span>
</span>
<p className="mt-1 break-words pr-10 text-sm font-semibold leading-5 text-slate-950">{alert.title}</p>
<p className="mt-0.5 break-words text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</p>
</div>
{onRemoveAlert ? (
<DropdownMenuItem
aria-label={`移除通知:${alert.title}`}
title="移除通知"
className={cn(alertActionClassName, "absolute right-1 top-1 z-10")}
onSelect={(event) => {
event.preventDefault();
onRemoveAlert(alert);
}}
>
<X size={14} aria-hidden="true" />
</DropdownMenuItem>
) : null}
</motion.div>
);
}) : (
<div key="empty-alert-queue" className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
<CheckCircle2 size={16} />
</SemanticStatusIcon>
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待处理事项</p>
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的待办和告警会进入这里。</p>
</div>
</div>
)}
</AnimatePresence>
</div>
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-[var(--surface-divider)]/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 type WorkbenchProjectSelection = {
projects: WorkbenchProject[];
selectedProjectId: string;
pendingProjectId: string | null;
error: string | null;
onSelectProject: (projectId: string) => void;
onOpen?: () => void;
onRefresh?: () => void;
refreshing?: boolean;
};
export function UserMenu({
open,
onOpenChange,
user,
projectSelection,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onLogout
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
user: WorkbenchUser;
projectSelection?: WorkbenchProjectSelection;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onLogout?: () => Promise<void>;
}) {
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [projectDialogKeyboard, setProjectDialogKeyboard] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<>
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={triggerRef}
type="button"
className={cn(
"group lg:h-8 lg:w-auto lg:justify-start lg:px-2",
headerControlButtonClassName,
compactHeaderControlButtonClassName
)}
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-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)] 2xl:block" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("z-[90] w-72", menuSurfaceClassName)}
onKeyDownCapture={() => setProjectDialogKeyboard(true)}
onPointerDownCapture={() => setProjectDialogKeyboard(false)}
onCloseAutoFocus={(event) => { if (projectDialogOpen) event.preventDefault(); }}>
<MenuHeader title={user.name} meta={user.role} icon={ShieldCheck} />
{projectSelection ? (
<>
<MenuAction icon={ArrowRightLeft} label="切换项目" description="选择要进入的供水项目"
onSelect={() => { onOpenChange(false); setProjectDialogOpen(true); projectSelection.onOpen?.(); }} />
<MenuSeparator />
</>
) : null}
<MenuSectionLabel>会话控制</MenuSectionLabel>
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
<MenuSeparator />
<DropdownMenuItem
disabled={!onLogout}
onSelect={onLogout ? () => void onLogout() : undefined}
className={cn(
"px-2 py-2",
!onLogout && "text-[var(--ink-muted)]",
MAP_COMPACT_RADIUS_CLASS_NAME
)}
>
<LogOut size={15} aria-hidden="true" />
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AnimatePresence>
{projectDialogOpen && projectSelection ? <ProjectSwitchDialog selection={projectSelection} keyboardOpened={projectDialogKeyboard}
onClose={() => setProjectDialogOpen(false)}
onRestoreFocus={() => triggerRef.current?.focus()} /> : null}
</AnimatePresence>
</>
);
}
function AlertQueueSummary({
count,
latestAlertTime,
conditionFeedVisible,
sourceLabel,
tone
}: {
count: number;
latestAlertTime?: string;
conditionFeedVisible: boolean;
sourceLabel: string;
tone: StatusTone;
}) {
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">
<SemanticStatusIcon
tone={tone}
size="md"
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
>
{hasAlerts ? <Bell size={16} /> : <CheckCircle2 size={16} />}
</SemanticStatusIcon>
<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-[var(--ink-muted)]">
{hasAlerts ? `${count} 条待处理${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待处理事项"}
</p>
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">业务告警来源:{sourceLabel}</p>
</div>
</div>
<StatusBadge tone={tone}>
{hasAlerts ? "待处理" : "暂无待办"}
</StatusBadge>
</div>
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-[var(--ink-muted)]">
<StatusDot tone={conditionFeedVisible ? "info" : "neutral"} />
<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-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
: "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-800 hover:border-[var(--surface-border)] hover:bg-[var(--action-soft)]/55 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
)}
>
<span className="truncate text-xs font-semibold leading-4">{label}</span>
<span className="truncate text-xs font-medium leading-4 text-[var(--ink-muted)]">{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-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
: "border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20 hover:bg-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
)}
>
<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-[var(--ink-muted)]" 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-[var(--ink-muted)]", tone === "warning" && "status-tone-danger text-[var(--status-foreground)]")}>{meta}</p>
</div>
{tone === "warning" ? <StatusDot tone="danger" size="md" /> : null}
</div>
);
}
function MenuSectionLabel({ children }: { children: ReactNode }) {
return <DropdownMenuLabel className="px-2 pb-1 pt-2.5 text-xs font-semibold uppercase text-[var(--ink-muted)]">{children}</DropdownMenuLabel>;
}
function MenuSeparator() {
return <DropdownMenuSeparator className="my-2 bg-[var(--surface-divider)]/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-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
tone === "warning" && "status-icon status-tone-warning",
tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)] 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-[var(--ink-muted)]">{description}</span>
</span>
</DropdownMenuItem>
);
}