feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -5,14 +5,15 @@ import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Columns2,
|
||||
Download,
|
||||
FileText,
|
||||
Keyboard,
|
||||
LogOut,
|
||||
PlayCircle,
|
||||
Map,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
UserRound,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
@@ -35,12 +36,10 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
|
||||
import type { WorkbenchAlert, WorkbenchUser } from "../types";
|
||||
import {
|
||||
compactHeaderControlButtonClassName,
|
||||
compactHeaderTextClassName,
|
||||
@@ -48,44 +47,36 @@ import {
|
||||
headerControlIconClassName
|
||||
} from "./workbench-top-bar-styles";
|
||||
|
||||
const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
||||
active: "运行中",
|
||||
draft: "草案",
|
||||
review: "待复核"
|
||||
};
|
||||
|
||||
const scenarioStatusTones = {
|
||||
active: "info",
|
||||
draft: "neutral",
|
||||
review: "warning"
|
||||
} satisfies Record<WorkbenchScenario["status"], StatusTone>;
|
||||
|
||||
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({
|
||||
export function AnalysisViewMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
scenarios,
|
||||
activeScenarioId,
|
||||
activeScenarioName,
|
||||
onSelectScenario,
|
||||
onPreviewScenario,
|
||||
onCompareScenario,
|
||||
onExportScenarioReport
|
||||
title,
|
||||
subtitle,
|
||||
sourceLabel,
|
||||
splitAvailable,
|
||||
hasSpatialView,
|
||||
onShowResult,
|
||||
onShowMapSplit,
|
||||
onShowMapOnly,
|
||||
onClose
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
scenarios: WorkbenchScenario[];
|
||||
activeScenarioId: string;
|
||||
activeScenarioName: string;
|
||||
onSelectScenario: (scenarioId: string) => void;
|
||||
onPreviewScenario: () => void;
|
||||
onCompareScenario: () => void;
|
||||
onExportScenarioReport: () => 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}>
|
||||
@@ -93,52 +84,29 @@ export function ScenarioMenu({
|
||||
<button
|
||||
type="button"
|
||||
className={cn("group max-w-[220px] px-2", headerControlButtonClassName)}
|
||||
aria-label="切换模拟方案"
|
||||
aria-label={`当前分析视图:${title}`}
|
||||
>
|
||||
<span className={headerControlIconClassName}>
|
||||
<SlidersHorizontal size={14} aria-hidden="true" />
|
||||
<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>
|
||||
<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>
|
||||
<StatusBadge tone={scenarioStatusTones[scenario.status]}>
|
||||
{scenarioStatusLabels[scenario.status]}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<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 />
|
||||
<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} />
|
||||
<MenuAction icon={X} label="关闭分析视图" description="移除当前成果及其地图图层" onSelect={onClose} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -148,6 +116,7 @@ export function AlertMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
alerts,
|
||||
sourceLabel,
|
||||
compact = false,
|
||||
conditionFeedVisible,
|
||||
onSelectAlert,
|
||||
@@ -157,6 +126,7 @@ export function AlertMenu({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
alerts: WorkbenchAlert[];
|
||||
sourceLabel: string;
|
||||
compact?: boolean;
|
||||
conditionFeedVisible: boolean;
|
||||
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||
@@ -164,6 +134,8 @@ export function AlertMenu({
|
||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||
}) {
|
||||
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 (
|
||||
@@ -175,7 +147,9 @@ export function AlertMenu({
|
||||
"group relative px-2",
|
||||
headerControlButtonClassName,
|
||||
hasAlerts && !compact &&
|
||||
"status-tone-danger 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)]",
|
||||
(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} 条待复核工况`}
|
||||
@@ -183,7 +157,9 @@ export function AlertMenu({
|
||||
<span
|
||||
className={cn(
|
||||
headerControlIconClassName,
|
||||
hasAlerts ? "status-icon status-tone-danger" : "bg-transparent!"
|
||||
hasAlerts
|
||||
? cn("status-icon", hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning")
|
||||
: "bg-transparent!"
|
||||
)}
|
||||
>
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
@@ -191,7 +167,7 @@ export function AlertMenu({
|
||||
<span className={compact ? "sr-only" : compactHeaderTextClassName}>异常处置</span>
|
||||
{compact ? (
|
||||
<StatusBadge
|
||||
tone={hasAlerts ? "danger" : "neutral"}
|
||||
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!"
|
||||
@@ -201,7 +177,7 @@ export function AlertMenu({
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
tone={hasAlerts ? "danger" : "neutral"}
|
||||
tone={alertSummaryTone}
|
||||
className={cn("leading-none", !hasAlerts && "border-transparent! bg-transparent!")}
|
||||
>
|
||||
{alerts.length}
|
||||
@@ -214,32 +190,39 @@ export function AlertMenu({
|
||||
count={alerts.length}
|
||||
latestAlertTime={latestAlertTime}
|
||||
conditionFeedVisible={conditionFeedVisible}
|
||||
sourceLabel={sourceLabel}
|
||||
tone={hasAlerts ? alertSummaryTone : "success"}
|
||||
/>
|
||||
<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(
|
||||
"status-tone-danger 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-[var(--status-border)]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
onSelect={() => onSelectAlert(alert)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<StatusDot tone="danger" size="md" />
|
||||
<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>
|
||||
{hasAlerts ? alerts.map((alert) => {
|
||||
const tone = alert.severity === "warning" ? "warning" : "danger";
|
||||
const toneClassName = tone === "warning" ? "status-tone-warning" : "status-tone-danger";
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={alert.id}
|
||||
className={cn(
|
||||
toneClassName,
|
||||
"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-[var(--status-border)]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
onSelect={() => onSelectAlert(alert)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<StatusDot tone={tone} size="md" />
|
||||
<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="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
|
||||
</span>
|
||||
<span className={cn("status-tone-danger grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)) : (
|
||||
<span className={cn(toneClassName, "grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", 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">
|
||||
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
|
||||
@@ -342,11 +325,15 @@ export function UserMenu({
|
||||
function AlertQueueSummary({
|
||||
count,
|
||||
latestAlertTime,
|
||||
conditionFeedVisible
|
||||
conditionFeedVisible,
|
||||
sourceLabel,
|
||||
tone
|
||||
}: {
|
||||
count: number;
|
||||
latestAlertTime?: string;
|
||||
conditionFeedVisible: boolean;
|
||||
sourceLabel: string;
|
||||
tone: StatusTone;
|
||||
}) {
|
||||
const hasAlerts = count > 0;
|
||||
|
||||
@@ -355,7 +342,7 @@ function AlertQueueSummary({
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<SemanticStatusIcon
|
||||
tone={hasAlerts ? "danger" : "success"}
|
||||
tone={tone}
|
||||
size="md"
|
||||
className={cn("h-8 w-8", MAP_ICON_CELL_RADIUS_CLASS_NAME)}
|
||||
>
|
||||
@@ -366,9 +353,10 @@ function AlertQueueSummary({
|
||||
<p className="mt-0.5 text-xs leading-5 text-slate-500">
|
||||
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-4 text-slate-400">来源:{sourceLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge tone={hasAlerts ? "danger" : "success"}>
|
||||
<StatusBadge tone={tone}>
|
||||
{hasAlerts ? "需复核" : "正常"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user