feat: initialize drainage network frontend

This commit is contained in:
2026-07-10 16:16:17 +08:00
commit 66de96d9e4
133 changed files with 33057 additions and 0 deletions
@@ -0,0 +1,88 @@
import {
getRunningWorkflowDefinition,
getRunningWorkflowState
} from "../data/running-workflows";
import type {
ScheduledConditionRecord
} from "../types";
export type ConditionStepTickerItem = {
id: string;
title: string;
detail: string;
};
export type ConditionStepTickerView = {
title: string;
progressLabel: string;
progressValue: number;
currentStep: ConditionStepTickerItem | null;
};
export type TickerStackCard = {
condition: ScheduledConditionRecord;
view: ConditionStepTickerView;
stackIndex: number;
};
const TICKER_STACK_DEPTH = 3;
export function selectTickerCondition(
runningConditions: ScheduledConditionRecord[],
rotationIndex: number
): ScheduledConditionRecord | null {
if (runningConditions.length === 0) {
return null;
}
return runningConditions[rotationIndex % runningConditions.length] ?? runningConditions[0] ?? null;
}
export function createVisibleTickerCards(
runningConditions: ScheduledConditionRecord[],
rotationIndex: number,
nowMs: number
): TickerStackCard[] {
if (runningConditions.length === 0) {
return [];
}
const visibleCount = Math.min(runningConditions.length, TICKER_STACK_DEPTH);
return Array.from({ length: visibleCount }, (_, stackIndex) => {
const conditionIndex = (rotationIndex + stackIndex) % runningConditions.length;
const condition = runningConditions[conditionIndex] ?? runningConditions[0];
return {
condition,
view: createConditionStepView(condition, nowMs, runningConditions.length),
stackIndex
};
});
}
function createConditionStepView(
condition: ScheduledConditionRecord,
nowMs: number,
runningConditionCount: number
): ConditionStepTickerView {
const workflow = getRunningWorkflowDefinition(condition);
const workflowState = getRunningWorkflowState(condition, workflow, nowMs);
const currentStep = workflow.steps[workflowState.currentStepIndex];
const currentStepItem: ConditionStepTickerItem | null = currentStep
? {
id: currentStep.id,
title: `步骤 ${workflowState.currentStepIndex + 1}/${workflow.steps.length} ${currentStep.title}`,
detail: currentStep.detail
}
: null;
return {
title: runningConditionCount > 1
? `${workflow.title} · ${condition.title} · ${runningConditionCount} 个执行中`
: `${workflow.title} · ${condition.title}`,
progressLabel: `${workflowState.progress}%`,
progressValue: workflowState.progress,
currentStep: currentStepItem
};
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import type { ScheduledConditionRecord, ScheduledConditionTaskId } from "../types";
import { createVisibleTickerCards } from "./agent-task-ticker-utils";
function createRunningCondition(id: string, taskId: ScheduledConditionTaskId): ScheduledConditionRecord {
return {
id,
kind: "condition",
taskId,
scheduledAt: "2026-07-08T10:00:00+08:00",
title: id,
summary: "Running condition",
status: "running",
riskLevel: "normal",
updatedAt: Date.parse("2026-07-08T10:01:00+08:00"),
sessionId: id,
durationMinutes: 10
};
}
describe("createVisibleTickerCards", () => {
it("wraps the final running condition back to the first cards", () => {
const conditions = [
createRunningCondition("condition-a", "scada-diagnosis"),
createRunningCondition("condition-b", "smart-dispatch"),
createRunningCondition("condition-c", "pump-energy"),
createRunningCondition("condition-d", "network-simulation")
];
const cards = createVisibleTickerCards(
conditions,
conditions.length - 1,
Date.parse("2026-07-08T10:01:00+08:00")
);
expect(cards).toHaveLength(3);
expect(cards.map((card) => card.condition.id)).toEqual([
"condition-d",
"condition-a",
"condition-b"
]);
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1, 2]);
});
it("does not duplicate the same task to fill stack slots", () => {
const conditions = [
createRunningCondition("condition-a", "scada-diagnosis")
];
const cards = createVisibleTickerCards(
conditions,
0,
Date.parse("2026-07-08T10:01:00+08:00")
);
expect(cards).toHaveLength(1);
expect(cards.map((card) => card.condition.id)).toEqual(["condition-a"]);
});
it("uses two stack slots for two running tasks", () => {
const conditions = [
createRunningCondition("condition-a", "scada-diagnosis"),
createRunningCondition("condition-b", "smart-dispatch")
];
const cards = createVisibleTickerCards(
conditions,
1,
Date.parse("2026-07-08T10:01:00+08:00")
);
expect(cards).toHaveLength(2);
expect(cards.map((card) => card.condition.id)).toEqual([
"condition-b",
"condition-a"
]);
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1]);
});
});
@@ -0,0 +1,474 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { cn } from "@/lib/utils";
import type {
ScheduledConditionRecord
} from "@/features/workbench/types";
import {
createVisibleTickerCards,
type TickerStackCard
} from "./agent-task-ticker-utils";
type AgentTaskTickerProps = {
conditions: ScheduledConditionRecord[];
className?: string;
};
type TickerTransitionDirection = -1 | 1;
const tickerEase = [0.22, 1, 0.36, 1] as const;
const tickerSwitchAnimationMs = 380;
const tickerCardSurfaceClassName =
"border border-white/75 bg-white/[0.92] ring-1 ring-slate-900/5 backdrop-blur-xl";
const tickerCardVariants: Variants = {
initial: (direction: TickerTransitionDirection) => ({
opacity: 0.62,
x: 0,
y: direction > 0 ? 36 : -28,
rotate: 0,
filter: "brightness(0.955) saturate(0.95)"
}),
exit: (direction: TickerTransitionDirection) => ({
opacity: [1, 1, 0],
x: 0,
y: direction > 0 ? 36 : -28,
rotate: 0,
filter: "brightness(1.02) saturate(1)",
transition: {
duration: 0.3,
times: [0, 0.65, 1],
ease: [0.5, 0, 0.2, 1]
}
})
};
const tickerContentVariants: Variants = {
initial: {
clipPath: "inset(0 100% 0 0)"
},
animate: {
clipPath: "inset(0 0% 0 0)",
transition: {
duration: 0.18,
ease: tickerEase
}
},
exit: {
clipPath: "inset(0 0 0 100%)",
transition: {
duration: 0.14,
ease: [0.5, 0, 0.2, 1]
}
}
};
const tickerIndicatorVariants: Variants = {
initial: {
opacity: 0,
x: 4,
scale: 0.96
},
animate: {
opacity: 1,
x: 0,
scale: 1,
transition: {
duration: 0.16,
ease: tickerEase
}
},
exit: {
opacity: 0,
x: 4,
scale: 0.96,
transition: {
duration: 0.12,
ease: [0.5, 0, 0.2, 1]
}
}
};
export function AgentTaskTicker({
conditions,
className
}: AgentTaskTickerProps) {
const sectionRef = useRef<HTMLElement | null>(null);
const isTaskSwitchLockedRef = useRef(false);
const taskSwitchUnlockTimeoutRef = useRef<number | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const [rotationIndex, setRotationIndex] = useState(0);
const [transitionDirection, setTransitionDirection] = useState<TickerTransitionDirection>(1);
const [taskSwitchAnimation, setTaskSwitchAnimation] = useState(() => ({
id: 0,
active: false
}));
const [isHovered, setIsHovered] = useState(false);
const runningConditions = conditions;
const visibleTickerCards = useMemo(
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
[runningConditions, rotationIndex, nowMs]
);
const hasMultipleRunningTasks = runningConditions.length > 1;
const activeIndicatorIndex = runningConditions.length > 0 ? rotationIndex % runningConditions.length : 0;
const tickerLabel = hasMultipleRunningTasks
? `工况任务步骤,${runningConditions.length} 个执行中,可滚动切换`
: "工况任务步骤";
const releaseTaskSwitchLock = useCallback(() => {
if (taskSwitchUnlockTimeoutRef.current !== null) {
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
taskSwitchUnlockTimeoutRef.current = null;
}
isTaskSwitchLockedRef.current = false;
}, []);
const finishTaskSwitchAnimation = useCallback(() => {
setTaskSwitchAnimation((current) => (
current.active ? { ...current, active: false } : current
));
}, []);
const lockTaskSwitch = useCallback(() => {
isTaskSwitchLockedRef.current = true;
if (taskSwitchUnlockTimeoutRef.current !== null) {
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
}
taskSwitchUnlockTimeoutRef.current = window.setTimeout(() => {
isTaskSwitchLockedRef.current = false;
taskSwitchUnlockTimeoutRef.current = null;
finishTaskSwitchAnimation();
}, tickerSwitchAnimationMs);
}, [finishTaskSwitchAnimation]);
const shiftTask = useCallback((direction: TickerTransitionDirection) => {
if (runningConditions.length <= 1 || isTaskSwitchLockedRef.current) {
return;
}
lockTaskSwitch();
setTransitionDirection(direction);
setTaskSwitchAnimation((current) => ({
id: current.id + 1,
active: true
}));
setRotationIndex((current) => (
(current + direction + runningConditions.length) % runningConditions.length
));
}, [lockTaskSwitch, runningConditions.length]);
const showPreviousTask = useCallback(() => {
shiftTask(-1);
}, [shiftTask]);
const showNextTask = useCallback(() => {
shiftTask(1);
}, [shiftTask]);
const handleWheel = useCallback((event: WheelEvent) => {
if (!hasMultipleRunningTasks || event.deltaY === 0) {
return;
}
event.preventDefault();
if (event.deltaY > 0) {
showNextTask();
return;
}
showPreviousTask();
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
useEffect(() => {
setNowMs(Date.now());
const intervalId = window.setInterval(() => {
setNowMs(Date.now());
}, 1_000);
return () => window.clearInterval(intervalId);
}, []);
useEffect(() => {
setRotationIndex(0);
releaseTaskSwitchLock();
finishTaskSwitchAnimation();
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock, runningConditions.length]);
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
useEffect(() => {
const section = sectionRef.current;
if (!section) {
return;
}
section.addEventListener("wheel", handleWheel, { passive: false });
return () => section.removeEventListener("wheel", handleWheel);
}, [handleWheel]);
useEffect(() => {
if (!hasMultipleRunningTasks || isHovered) {
return;
}
const intervalId = window.setInterval(() => {
shiftTask(1);
}, 6_000);
return () => window.clearInterval(intervalId);
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]);
if (visibleTickerCards.length === 0) {
return null;
}
return (
<section
ref={sectionRef}
className={cn(
"agent-task-ticker pointer-events-auto relative h-[78px] overflow-visible",
className
)}
aria-label={tickerLabel}
title={tickerLabel}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="absolute inset-0">
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}>
{visibleTickerCards
.slice()
.reverse()
.map((card) => (
<TickerTaskCard
key={`ticker-stack-slot-${card.stackIndex}-${card.stackIndex === 0 ? taskSwitchAnimation.id : 0}`}
card={card}
isActive={card.stackIndex === 0}
animateContentChanges={card.stackIndex === 0 && !taskSwitchAnimation.active}
transitionDirection={transitionDirection}
/>
))}
</AnimatePresence>
<div className="pointer-events-none absolute right-3 top-8 z-40 -translate-y-1/2" aria-hidden="true">
<AnimatePresence initial={false}>
{isHovered && hasMultipleRunningTasks ? (
<motion.div
key="task-indicator"
className="agent-task-ticker-indicator flex flex-col gap-1 rounded-full bg-white/80 px-1 py-1 shadow-sm shadow-blue-950/10 backdrop-blur"
variants={tickerIndicatorVariants}
initial="initial"
animate="animate"
exit="exit"
>
{runningConditions.map((runningCondition, index) => (
<span
key={runningCondition.id}
className={cn(
"block h-1.5 w-1.5 rounded-sm transition-colors duration-150",
index === activeIndicatorIndex ? "bg-blue-600" : "bg-slate-300"
)}
/>
))}
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
</section>
);
}
function TickerTaskCard({
card,
isActive,
animateContentChanges,
transitionDirection
}: {
card: TickerStackCard;
isActive: boolean;
animateContentChanges: boolean;
transitionDirection: TickerTransitionDirection;
}) {
const { condition, view, stackIndex } = card;
const stackStyle = getTickerStackStyle(stackIndex);
const contentKey = view.currentStep
? `${condition.id}-${view.currentStep.id}`
: `${condition.id}-pending`;
return (
<motion.article
layout="position"
className={cn(
"absolute inset-x-0 top-0 h-16 overflow-hidden rounded-[22px] px-3 py-2",
tickerCardSurfaceClassName,
isActive ? "z-30" : "pointer-events-none z-10"
)}
style={{ transformOrigin: "center top" }}
custom={transitionDirection}
variants={tickerCardVariants}
initial="initial"
animate={{
opacity: stackStyle.opacity,
x: stackStyle.x,
y: stackStyle.y,
rotate: stackStyle.rotate,
filter: stackStyle.filter,
transition: {
duration: isActive ? 0.34 : 0.3,
ease: tickerEase
}
}}
exit="exit"
aria-hidden={!isActive}
>
<div
className={cn(
"h-full",
isActive ? "opacity-100" : "opacity-0"
)}
>
<AnimatePresence key={condition.id} initial={false} mode="wait">
<motion.div
key={contentKey}
className="h-full overflow-hidden"
variants={tickerContentVariants}
initial={animateContentChanges ? "initial" : false}
animate={animateContentChanges ? "animate" : undefined}
exit={animateContentChanges ? "exit" : undefined}
>
<TickerTaskCardContent view={view} />
</motion.div>
</AnimatePresence>
</div>
</motion.article>
);
}
function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
if (!view.currentStep) {
return (
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-4">
<TickerStatusDot progressValue={view.progressValue} />
<div className="min-w-0">
<span className="block truncate text-[13px] font-semibold leading-5 text-slate-500">
{view.title}
</span>
<TickerProgressBar progressLabel={view.progressLabel} muted />
</div>
</div>
);
}
return (
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-5">
<TickerStatusDot progressValue={view.progressValue} />
<div className="flex min-w-0 flex-col justify-center">
<div className="min-w-0 pr-1">
<span className="block truncate text-[13px] font-semibold leading-4 text-slate-500">
{view.title}
</span>
</div>
<div className="min-w-0" title={`${view.currentStep.title}${view.currentStep.detail}`}>
<div className="grid min-w-0 grid-cols-[auto_4px_minmax(0,1fr)] items-center gap-2">
<span className="min-w-0 truncate text-[13px] font-bold leading-5 text-slate-950">
{view.currentStep.title}
</span>
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-500">
{view.currentStep.detail}
</span>
</div>
</div>
<TickerProgressBar progressLabel={view.progressLabel} />
</div>
</div>
);
}
function TickerProgressBar({
progressLabel,
muted = false
}: {
progressLabel: string;
muted?: boolean;
}) {
return (
<div
className={cn(
"mt-0.5 h-0.5 overflow-hidden rounded-full",
muted ? "bg-slate-200/60" : "bg-slate-200/80"
)}
aria-hidden="true"
>
<motion.span
className={cn("block h-full rounded-full", muted ? "bg-blue-300" : "bg-blue-500")}
animate={{ width: progressLabel }}
initial={false}
transition={{ duration: 0.34, ease: tickerEase }}
/>
</div>
);
}
function TickerStatusDot({ progressValue }: { progressValue?: number }) {
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
const dashOffset = 44 - (44 * progress) / 100;
return (
<span className="grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-blue-100/90" />
<motion.circle
cx="8"
cy="8"
r="7"
fill="none"
strokeWidth="2"
strokeLinecap="round"
className="stroke-blue-600"
strokeDasharray="44"
animate={{ strokeDashoffset: dashOffset }}
initial={false}
transition={{ duration: 0.34, ease: tickerEase }}
/>
<circle cx="8" cy="8" r="3.5" className="fill-blue-600" />
</svg>
</span>
);
}
function getTickerStackStyle(stackIndex: number) {
if (stackIndex === 1) {
return {
opacity: 1,
x: 0,
y: 7,
rotate: 0,
filter: "brightness(0.985) saturate(0.98)"
};
}
if (stackIndex === 2) {
return {
opacity: 1,
x: 0,
y: 14,
rotate: 0,
filter: "brightness(0.965) saturate(0.96)"
};
}
return {
opacity: 1,
x: 0,
y: 0,
rotate: 0,
filter: "brightness(1) saturate(1)"
};
}
@@ -0,0 +1,120 @@
"use client";
import { Target, TriangleAlert, X } from "lucide-react";
import { useMemo } from "react";
import type { DetailFeature } from "../types";
import {
formatFeaturePropertyValue,
getLocalizedFeatureProperties
} from "../utils/feature-properties";
type FeatureInsightPanelProps = {
feature: DetailFeature | null;
onClose: () => void;
};
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
const metrics = useMemo(() => {
if (!feature) {
return [];
}
if (feature.layer === "pipes") {
return [
["管径", formatFeaturePropertyValue("diameter", feature.properties.diameter)],
["长度", formatFeaturePropertyValue("length", feature.properties.length)],
["粗糙系数", formatFeaturePropertyValue("roughness", feature.properties.roughness)],
["状态", formatFeaturePropertyValue("status", feature.properties.status)]
];
}
return [
["高程", formatFeaturePropertyValue("elevation", feature.properties.elevation)],
["需水量", formatFeaturePropertyValue("demand", feature.properties.demand)],
["对象类型", "管网节点"],
["状态", "在线"]
];
}, [feature]);
const propertyEntries = useMemo(() => {
if (!feature) {
return [];
}
return getLocalizedFeatureProperties(feature.properties);
}, [feature]);
return (
<aside className="pointer-events-auto flex h-full min-h-0 flex-col rounded border border-white/60 bg-white/88 shadow-glass backdrop-blur">
<div className="border-b border-slate-200/80 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium tracking-[0.14em] text-slate-500"></p>
<h2 className="mt-1 text-lg font-semibold text-slate-950">
{feature ? feature.title : "选择一个管网对象"}
</h2>
</div>
<button
type="button"
aria-label="关闭详情"
title="关闭详情"
onClick={onClose}
className="grid h-9 w-9 place-items-center rounded border border-slate-200 bg-white text-slate-500 hover:text-slate-900"
>
<X size={16} aria-hidden="true" />
</button>
</div>
<p className="mt-2 text-sm text-slate-600">
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性、SCADA 摘要和 Agent 解释。"}
</p>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{feature ? (
<>
<div className="grid grid-cols-2 gap-2">
{metrics.map(([label, value]) => (
<div key={label} className="rounded border border-slate-200 bg-white p-3">
<p className="text-xs text-slate-500">{label}</p>
<p className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</p>
</div>
))}
</div>
<section className="mt-4 rounded border border-orange-100 bg-orange-50/70 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
<TriangleAlert size={16} aria-hidden="true" />
Agent
</div>
<p className="mt-2 text-sm leading-6 text-slate-700">
GeoServer MVT
</p>
</section>
<section className="mt-4">
<h3 className="text-sm font-semibold text-slate-900"></h3>
<dl className="mt-2 divide-y divide-slate-100 rounded border border-slate-200 bg-white">
{propertyEntries.map((entry) => (
<div key={entry.key} className="grid grid-cols-[110px_1fr] gap-2 px-3 py-2 text-sm">
<dt className="truncate text-slate-500">{entry.label}</dt>
<dd className="truncate font-medium text-slate-800">{entry.value}</dd>
</div>
))}
</dl>
</section>
</>
) : (
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="grid h-12 w-12 place-items-center rounded border border-slate-200 bg-white text-slate-500">
<Target size={22} aria-hidden="true" />
</div>
<p className="mt-3 text-sm font-medium text-slate-800"></p>
<p className="mt-1 max-w-[260px] text-sm leading-6 text-slate-500">
Agent
</p>
</div>
)}
</div>
</aside>
);
}
@@ -0,0 +1,55 @@
"use client";
import { X } from "lucide-react";
import {
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 type { DetailFeature } from "../types";
import { getLocalizedFeatureProperties } from "../utils/feature-properties";
type FeaturePopoverProps = {
feature: DetailFeature | null;
onClose: () => void;
};
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
if (!feature) {
return null;
}
const entries = getLocalizedFeatureProperties(feature.properties).slice(0, 4);
return (
<aside className={cn("pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(360px,calc(100vw-32px))] -translate-x-1/2 border border-white/60 bg-white/70 p-4 shadow-2xl shadow-blue-950/20 backdrop-blur-2xl", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-blue-600">{feature.layer === "pipes" ? "管线要素" : "节点要素"}</p>
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
<p className="mt-1 truncate text-sm text-slate-500">{feature.subtitle}</p>
</div>
<button
type="button"
aria-label="关闭要素信息"
title="关闭要素信息"
onClick={onClose}
className="grid h-8 w-8 shrink-0 place-items-center rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-900"
>
<X size={16} aria-hidden="true" />
</button>
</div>
{entries.length > 0 ? (
<dl className="mt-3 grid grid-cols-2 gap-2">
{entries.map((entry) => (
<div key={entry.key} className={cn("bg-white/95 px-3 py-2 shadow-lg shadow-blue-950/10", MAP_READABLE_RADIUS_CLASS_NAME)}>
<dt className="truncate text-xs font-medium text-slate-500">{entry.label}</dt>
<dd className="mt-1 truncate text-sm font-semibold text-slate-800">{entry.value}</dd>
</div>
))}
</dl>
) : null}
</aside>
);
}
@@ -0,0 +1,24 @@
type IconButtonProps = {
label: string;
children: React.ReactNode;
onClick?: () => void;
active?: boolean;
};
export function IconButton({ label, children, onClick, active = false }: IconButtonProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`grid h-10 w-10 place-items-center rounded border transition ${
active
? "border-slate-800 bg-slate-900 text-white"
: "border-white/60 bg-white/80 text-slate-700 shadow-sm hover:bg-white"
}`}
>
{children}
</button>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
import {
AlertTriangle,
CheckCircle2,
Clock3,
Loader2,
XCircle
} from "lucide-react";
import type {
ScheduledConditionItem,
ScheduledConditionStatus,
ScheduledWorkOrderItem,
ScheduledWorkOrderStage
} from "@/features/workbench/types";
export const CONDITION_FILTER_ALL = "__all__";
export const statusLabels: Record<ScheduledConditionStatus, string> = {
completed: "完成",
running: "执行中",
warning: "关注",
error: "异常",
pending: "预约"
};
export const statusClassNames: Record<ScheduledConditionStatus, string> = {
completed: "border-green-100 bg-green-50 text-green-700",
running: "border-blue-100 bg-blue-50 text-blue-700",
warning: "border-orange-100 bg-orange-50 text-orange-700",
error: "border-red-100 bg-red-50 text-red-700",
pending: "border-slate-200 bg-slate-100 text-slate-600"
};
const statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
completed: CheckCircle2,
running: Loader2,
warning: AlertTriangle,
error: XCircle,
pending: Clock3
};
const statusIconClassNames: Record<ScheduledConditionStatus, string> = {
completed: "bg-green-50 text-green-700",
running: "bg-blue-50 text-blue-700",
warning: "bg-orange-50 text-orange-700",
error: "bg-red-50 text-red-700",
pending: "bg-slate-100 text-slate-600"
};
const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
export type ConditionTaskFilterOption = {
value: ConditionTaskFilterValue;
label: string;
count: number;
};
export type ConditionStatusFilterOption = {
value: ScheduledConditionStatus;
label: string;
count: number;
};
export function groupScheduledConditions(conditions: ScheduledConditionItem[]) {
const recentConditions = conditions
.filter((condition) => condition.kind === "condition")
.sort((a, b) => getConditionTime(b) - getConditionTime(a));
const futureWorkOrders = conditions
.filter((condition) => condition.kind === "work_order")
.sort((a, b) => getConditionTime(a) - getConditionTime(b));
return { recentConditions, futureWorkOrders };
}
export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): ConditionTaskFilterOption[] {
const taskCounts = new Map<string, number>();
conditions.forEach((condition) => {
taskCounts.set(condition.title, (taskCounts.get(condition.title) ?? 0) + 1);
});
return [...taskCounts.entries()]
.map(([label, count]) => ({ value: label, label, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "zh-CN"));
}
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
const statusCounts = new Map<ScheduledConditionStatus, number>();
conditions.forEach((condition) => {
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
});
return conditionStatusOrder.map((status) => ({
value: status,
label: statusLabels[status],
count: statusCounts.get(status) ?? 0
}));
}
export function getConditionTime(condition: ScheduledConditionItem) {
return Date.parse(condition.scheduledAt);
}
export function formatTime(value: string) {
const match = value.match(/T(\d{2}:\d{2})/);
return match?.[1] ?? value;
}
export function formatTimeRange(value: string, durationMinutes: number | undefined) {
const startTime = formatTime(value);
if (typeof durationMinutes !== "number") {
return startTime;
}
const endDate = new Date(Date.parse(value) + durationMinutes * 60_000);
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
return `${startTime} - ${endTime}`;
}
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
if (status === "running") {
return "执行中";
}
if (typeof durationMinutes !== "number") {
return "未记录";
}
return `${durationMinutes} 分钟`;
}
export function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
if (riskLevel === "critical") {
return "高风险";
}
if (riskLevel === "attention") {
return "关注";
}
return "正常";
}
export function getKpiStatusClassName(riskLevel: ScheduledConditionItem["riskLevel"]) {
if (riskLevel === "critical") {
return "border-red-100 bg-red-50 text-red-700";
}
if (riskLevel === "attention") {
return "border-orange-100 bg-orange-50 text-orange-700";
}
return "border-green-100 bg-green-50 text-green-700";
}
export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
return (
condition.stages.find((stage) => stage.status === "current") ??
condition.stages.find((stage) => stage.status === "pending") ??
condition.stages.at(-1) ??
null
);
}
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
if (stageId === "execution") {
return "border-blue-100 bg-blue-50 text-blue-700";
}
if (stageId === "reply") {
return "border-green-100 bg-green-50 text-green-700";
}
return "border-indigo-100 bg-indigo-50 text-indigo-700";
}
export function getDetailAccentClassName(status: ScheduledConditionStatus, isWorkOrder: boolean) {
if (isWorkOrder) {
return "bg-blue-500";
}
if (status === "completed") {
return "bg-green-500";
}
if (status === "running") {
return "bg-blue-500";
}
if (status === "warning") {
return "bg-orange-500";
}
if (status === "error") {
return "bg-red-500";
}
return "bg-slate-400";
}
export function getStatusIcon(status: ScheduledConditionStatus) {
return statusIcons[status];
}
export function getStatusIconClassName(status: ScheduledConditionStatus) {
return statusIconClassNames[status];
}
@@ -0,0 +1,753 @@
"use client";
import {
CalendarClock,
ChevronDown,
ChevronRight,
ClipboardList,
History,
XCircle
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice";
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
import type { ScheduledConditionItem } from "@/features/workbench/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
import {
CONDITION_FILTER_ALL,
createStatusFilterOptions,
createTaskFilterOptions,
formatTime,
getConditionTime,
getStatusIcon,
getStatusIconClassName,
groupScheduledConditions,
type ConditionStatusFilterOption,
type ConditionStatusFilterValue,
type ConditionTaskFilterOption,
type ConditionTaskFilterValue
} from "./scheduled-condition-feed-utils";
type ScheduledConditionFeedProps = {
conditions?: ScheduledConditionItem[];
expanded?: boolean;
focusRequest?: ScheduledConditionFeedFocusRequest | null;
loading?: boolean;
selectedConditionId?: string | null;
onExpandedChange?: (expanded: boolean) => void;
onFocusRequestHandled?: (requestId: number) => void;
onSelectedConditionChange?: (conditionId: string | null) => void;
onExpandAgent: () => void;
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
};
type ScheduledConditionFeedFocusRequest = {
conditionId: string;
requestId: number;
};
export function ScheduledConditionFeed({
conditions = [],
expanded: controlledExpanded,
focusRequest,
loading = false,
selectedConditionId: controlledSelectedConditionId,
onExpandedChange,
onFocusRequestHandled,
onSelectedConditionChange,
onExpandAgent,
onLoadHistorySession,
onSubmitPrompt
}: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(conditions),
[conditions]
);
const allTimelineConditions = useMemo(
() => [...allRecentConditions, ...allFutureWorkOrders],
[allFutureWorkOrders, allRecentConditions]
);
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
const taskFilteredConditions = useMemo(
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
[conditions, taskFilter]
);
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
const taskFilteredConditionCount = taskFilteredConditions.length;
const filteredConditions = useMemo(
() =>
taskFilteredConditions.filter(
(condition) => statusFilter === CONDITION_FILTER_ALL || condition.status === statusFilter
),
[statusFilter, taskFilteredConditions]
);
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
const timelineConditions = useMemo(
() => [...recentConditions, ...futureWorkOrders],
[futureWorkOrders, recentConditions]
);
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
const headerSummary = loading
? "正在加载工况任务"
: expanded && filterActive
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length}`
: expanded
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length}`
: `最近 ${recentConditions.length}`;
const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
recentConditions[0] ??
futureWorkOrders[0] ??
null;
const focusConditionId = focusRequest?.conditionId;
const focusRequestId = focusRequest?.requestId;
const updateExpanded = useCallback(
(nextExpanded: boolean) => {
if (controlledExpanded === undefined) {
setUncontrolledExpanded(nextExpanded);
}
onExpandedChange?.(nextExpanded);
},
[controlledExpanded, onExpandedChange]
);
const updateSelectedConditionId = useCallback(
(nextConditionId: string | null) => {
if (controlledSelectedConditionId === undefined) {
setUncontrolledSelectedConditionId(nextConditionId);
}
onSelectedConditionChange?.(nextConditionId);
},
[controlledSelectedConditionId, onSelectedConditionChange]
);
useEffect(() => {
if (taskFilter === CONDITION_FILTER_ALL) {
return;
}
if (!taskFilterOptions.some((option) => option.value === taskFilter)) {
setTaskFilter(CONDITION_FILTER_ALL);
}
}, [taskFilter, taskFilterOptions]);
useEffect(() => {
if (!expanded) {
return;
}
const nextSelectedConditionId = selectedCondition?.id ?? null;
if (selectedConditionId !== nextSelectedConditionId) {
updateSelectedConditionId(nextSelectedConditionId);
}
}, [expanded, selectedCondition?.id, selectedConditionId, updateSelectedConditionId]);
useEffect(() => {
if (!focusConditionId || focusRequestId === undefined) {
return;
}
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
if (!targetCondition) {
return;
}
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(targetCondition.id);
updateExpanded(true);
onFocusRequestHandled?.(focusRequestId);
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
function handleToggleExpanded() {
updateExpanded(!expanded);
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
}
function handleSelectTimelineCondition(conditionId: string) {
updateSelectedConditionId(conditionId);
if (!expanded) {
updateExpanded(true);
}
}
function handleContinueConversation(condition: ScheduledConditionItem) {
if (condition.kind === "work_order") {
return;
}
onExpandAgent();
if (!condition.sessionId) {
showMapNotice({
id: "scheduled-condition-missing-session",
tone: "error",
title: "无法打开历史会话",
message: "这条工况没有可加载的会话记录。"
});
return;
}
if (isGeneratedScheduledConditionSessionId(condition.sessionId)) {
void Promise.resolve(onSubmitPrompt(createConditionConversationPrompt(condition)))
.then(() => {
showMapNotice({
id: "scheduled-condition-generated-session",
tone: "success",
title: "已发起工况对话",
message: "已将当前工况上下文发送到 Agent 面板。"
});
})
.catch((submitError) => {
showMapNotice({
id: "scheduled-condition-generated-session",
tone: "error",
title: "工况对话发起失败",
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
});
});
return;
}
void Promise.resolve(onLoadHistorySession(condition.sessionId));
}
return (
<section
aria-label="工况任务"
className={cn(
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
"rounded-2xl",
MAP_CONTROL_SURFACE_CLASS_NAME
)}
>
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", "rounded-xl", MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
<CalendarClock size={17} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950"></h2>
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
</div>
<button
type="button"
aria-expanded={expanded}
onClick={handleToggleExpanded}
className={cn(
"grid h-8 w-8 shrink-0 place-items-center text-slate-500 transition hover:bg-blue-50 hover:text-blue-700",
"rounded-lg"
)}
>
<ChevronDown
size={17}
aria-hidden="true"
className={cn("transition-transform duration-150", expanded && "rotate-180")}
/>
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
</button>
</header>
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
)}
>
<div
className={cn(
"min-h-0 min-w-0 overflow-hidden",
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
)}
aria-hidden={!expanded}
>
{loading && expanded ? (
<ConditionDetailSkeleton />
) : expanded && selectedCondition ? (
<ConditionDetailPanel
condition={selectedCondition}
onContinueConversation={() => handleContinueConversation(selectedCondition)}
/>
) : null}
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<ConditionTimelinePanel
expanded={expanded}
loading={loading}
recentConditions={recentConditions}
futureWorkOrders={futureWorkOrders}
taskFilter={taskFilter}
statusFilter={statusFilter}
taskFilterOptions={taskFilterOptions}
statusFilterOptions={statusFilterOptions}
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
totalConditionCount={totalConditionCount}
taskFilteredConditionCount={taskFilteredConditionCount}
filteredConditionCount={filteredConditionCount}
onTaskFilterChange={setTaskFilter}
onStatusFilterChange={setStatusFilter}
onResetFilters={() => {
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
}}
onSelectCondition={handleSelectTimelineCondition}
/>
</div>
</div>
</section>
);
}
type ConditionTimelinePanelProps = {
expanded: boolean;
loading: boolean;
recentConditions: ScheduledConditionItem[];
futureWorkOrders: ScheduledConditionItem[];
taskFilter: ConditionTaskFilterValue;
statusFilter: ConditionStatusFilterValue;
taskFilterOptions: ConditionTaskFilterOption[];
statusFilterOptions: ConditionStatusFilterOption[];
selectedConditionId: string | null;
totalConditionCount: number;
taskFilteredConditionCount: number;
filteredConditionCount: number;
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
onResetFilters: () => void;
onSelectCondition: (conditionId: string) => void;
};
function ConditionTimelinePanel({
expanded,
loading,
recentConditions,
futureWorkOrders,
taskFilter,
statusFilter,
taskFilterOptions,
statusFilterOptions,
selectedConditionId,
totalConditionCount,
taskFilteredConditionCount,
filteredConditionCount,
onTaskFilterChange,
onStatusFilterChange,
onResetFilters,
onSelectCondition
}: ConditionTimelinePanelProps) {
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
return (
<section
aria-busy={loading}
className={cn(
"flex min-h-0 flex-col overflow-hidden p-2.5",
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
"rounded-xl",
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
)}
>
{expanded && !loading ? (
<ConditionFilterBar
taskFilter={taskFilter}
statusFilter={statusFilter}
taskFilterOptions={taskFilterOptions}
statusFilterOptions={statusFilterOptions}
filteredConditionCount={filteredConditionCount}
totalConditionCount={totalConditionCount}
taskFilteredConditionCount={taskFilteredConditionCount}
onTaskFilterChange={onTaskFilterChange}
onStatusFilterChange={onStatusFilterChange}
onResetFilters={onResetFilters}
/>
) : null}
{expanded && !loading && futureWorkOrders.length > 0 ? (
<section className="mb-2 rounded-xl border border-blue-100 bg-blue-50/55 p-2">
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
<ConditionRows
conditions={futureWorkOrders}
selectedConditionId={selectedConditionId}
onSelectCondition={onSelectCondition}
/>
</div>
</section>
) : null}
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
{loading ? (
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
) : recentConditions.length > 0 ? (
<ConditionRows
conditions={recentConditions}
selectedConditionId={selectedConditionId}
onSelectCondition={onSelectCondition}
/>
) : (
<ConditionEmptyState
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
/>
)}
</div>
</section>
);
}
type ConditionFilterBarProps = {
taskFilter: ConditionTaskFilterValue;
statusFilter: ConditionStatusFilterValue;
taskFilterOptions: ConditionTaskFilterOption[];
statusFilterOptions: ConditionStatusFilterOption[];
filteredConditionCount: number;
totalConditionCount: number;
taskFilteredConditionCount: number;
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
onResetFilters: () => void;
};
function ConditionFilterBar({
taskFilter,
statusFilter,
taskFilterOptions,
statusFilterOptions,
filteredConditionCount,
totalConditionCount,
taskFilteredConditionCount,
onTaskFilterChange,
onStatusFilterChange,
onResetFilters
}: ConditionFilterBarProps) {
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
return (
<div className="mb-2 rounded-xl border border-slate-200/70 bg-slate-50/95 p-2 ring-1 ring-white/80">
<div className="mb-2 flex items-center justify-between gap-2 px-1">
<span className="text-xs font-semibold text-slate-700"></span>
<span className="shrink-0 text-xs font-semibold text-slate-400">
{filteredConditionCount}/{totalConditionCount}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<ConditionFilterDropdown
label="任务"
value={taskFilter}
onValueChange={onTaskFilterChange}
options={[
{ value: CONDITION_FILTER_ALL, label: "全部任务", count: totalConditionCount },
...taskFilterOptions
]}
/>
<ConditionFilterDropdown
label="状态"
value={statusFilter}
onValueChange={(value) => onStatusFilterChange(value as ConditionStatusFilterValue)}
options={[
{ value: CONDITION_FILTER_ALL, label: "全部状态", count: taskFilteredConditionCount },
...statusFilterOptions
]}
/>
</div>
{filterActive ? (
<button
type="button"
onClick={onResetFilters}
className="mt-2 inline-flex h-7 w-full items-center justify-center gap-1.5 rounded-lg border border-blue-100 bg-white px-2 text-xs font-semibold text-blue-700 shadow-sm transition hover:border-blue-200 hover:bg-blue-50"
>
<XCircle size={12} aria-hidden="true" />
</button>
) : null}
</div>
);
}
type ConditionFilterDropdownProps<TValue extends string> = {
label: string;
value: TValue;
options: {
value: TValue;
label: string;
count: number;
}[];
onValueChange: (value: TValue) => void;
};
function ConditionFilterDropdown<TValue extends string>({
label,
value,
options,
onValueChange
}: ConditionFilterDropdownProps<TValue>) {
const selectedOption = options.find((option) => option.value === value) ?? options[0];
return (
<div className="min-w-0">
<span className="mb-1 block text-xs font-semibold text-slate-400">{label}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"group flex h-9 w-full min-w-0 items-center gap-2 border border-white/80 bg-white px-2 text-left text-xs shadow-sm outline-none transition hover:border-blue-100 hover:bg-blue-50/35 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50/60 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
"rounded-xl"
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
{selectedOption?.label ?? "全部"}
</span>
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} </span>
</span>
<ChevronDown
size={13}
aria-hidden="true"
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"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-white/80 bg-white p-1.5 text-slate-700 shadow-xl shadow-slate-900/15 backdrop-blur-xl"
>
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
{options.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
className={cn(
"my-0.5 min-h-9 gap-2 border border-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50/80 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/90 data-[state=checked]:text-blue-800",
"rounded-xl"
)}
>
<span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span>
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
{option.count}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function ConditionEmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-white/65 px-4 py-5 text-center">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400">
<ChevronRight size={15} aria-hidden="true" />
</span>
<p className="mt-2 text-xs font-semibold text-slate-700">{title}</p>
<p className="mt-1 text-xs leading-4 text-slate-500">{description}</p>
</div>
);
}
function ConditionTimelineSkeleton({ rows }: { rows: number }) {
return (
<div className="space-y-1.5" role="status" aria-label="工况任务加载中">
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
className="grid min-h-[60px] grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1"
>
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
<span className="relative flex min-h-12 justify-center pt-1.5">
{index < rows - 1 ? (
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
) : null}
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
</span>
<span className="min-w-0 rounded-xl border border-white/80 bg-white/95 px-2 py-1.5">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 space-y-1.5">
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" />
<span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-slate-100" />
</span>
<span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-slate-100" />
</span>
</span>
</div>
))}
</div>
);
}
function ConditionDetailSkeleton() {
return (
<DetailScroll>
<div
className={cn(
"relative min-h-[420px] overflow-hidden border border-slate-200/80 bg-white/95 px-4 py-3",
"rounded-xl"
)}
role="status"
aria-label="工况详情加载中"
>
<div className="absolute inset-y-0 left-0 w-1 bg-blue-300" aria-hidden="true" />
<div className="flex gap-2.5">
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-slate-100", "rounded-lg")} />
<div className="min-w-0 flex-1 space-y-2">
<div className="flex gap-2">
<span className="h-4 w-12 animate-pulse rounded-full bg-slate-200" />
<span className="h-5 w-14 animate-pulse rounded-full bg-blue-100" />
</div>
<span className="block h-4 w-44 animate-pulse rounded-full bg-slate-200" />
<span className="block h-3 w-full animate-pulse rounded-full bg-slate-100" />
<span className="block h-3 w-4/5 animate-pulse rounded-full bg-slate-100" />
</div>
</div>
<div className="mt-4 grid gap-2">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="rounded-xl border border-slate-200/70 bg-slate-50/80 px-3 py-3">
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
</div>
))}
</div>
</div>
</DetailScroll>
);
}
function DetailScroll({ children }: { children: React.ReactNode }) {
return (
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
{children}
</section>
);
}
type ConditionGroupHeaderProps = {
icon: typeof History;
title: string;
count: number;
compact?: boolean;
};
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
return (
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
<span className="flex items-center gap-1.5">
<Icon size={13} aria-hidden="true" />
<span>{title}</span>
</span>
<span>{count}</span>
</div>
);
}
type ConditionRowsProps = {
conditions: ScheduledConditionItem[];
selectedConditionId: string | null;
onSelectCondition: (conditionId: string) => void;
};
function ConditionRows({ conditions, selectedConditionId, onSelectCondition }: ConditionRowsProps) {
return (
<div className="space-y-1.5">
{conditions.map((condition, index) => (
<ConditionTimelineRow
key={condition.id}
condition={condition}
selected={selectedConditionId === condition.id}
isLast={index === conditions.length - 1}
onSelect={() => onSelectCondition(condition.id)}
/>
))}
</div>
);
}
type ConditionTimelineRowProps = {
condition: ScheduledConditionItem;
selected: boolean;
isLast: boolean;
onSelect: () => void;
};
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
const StatusIcon = getStatusIcon(condition.status);
const isWorkOrder = condition.kind === "work_order";
return (
<button
type="button"
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1 text-left outline-none transition-colors focus-visible:bg-blue-50/40 focus-visible:ring-2 focus-visible:ring-blue-200/80 focus-visible:ring-offset-1 focus-visible:ring-offset-white",
selected ? "bg-blue-50/70" : "hover:bg-blue-50/45"
)}
>
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
<span
className="relative flex min-h-12 justify-center pt-1.5"
>
{!isLast ? (
<span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
aria-hidden="true"
/>
) : null}
<span
className={cn(
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
selected
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
: isWorkOrder
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
: getStatusIconClassName(condition.status)
)}
>
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
</span>
</span>
<span
className={cn(
"min-w-0 rounded-xl border px-2 py-1.5 transition-colors group-focus-visible:border-blue-300 group-focus-visible:bg-white",
selected
? "border-blue-300 bg-white text-blue-900 shadow-sm shadow-blue-600/10 ring-1 ring-blue-300/40"
: "border-white/80 bg-white/95 text-slate-700 group-hover:border-blue-100 group-hover:bg-white"
)}
>
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
</span>
<StatusPill condition={condition} className="shrink-0 text-xs" />
</span>
</span>
</button>
);
}
@@ -0,0 +1,440 @@
import {
Activity,
Camera,
Circle,
Copy,
Database,
Dot,
Download,
Keyboard,
Layers3,
MapPinned,
Pentagon,
Redo2,
RefreshCw,
Route,
Ruler,
Share2,
Trash2,
Undo2,
Waypoints,
type LucideIcon
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
MapAnnotationPanel,
type MapAnnotationItem,
type MapAnnotationShareAction
} from "@/features/map/core/components/annotation-panel";
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import {
MapActionRow,
MapControlPanel,
MapMetricTile,
MapPanelSection
} from "@/features/map/core/components/control-panel";
import { MapDrawToolbar } from "@/features/map/core/components/draw-toolbar";
import { MapLayerPanel } from "@/features/map/core/components/layer-panel";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import { MapLegendList, type MapLegendItem } from "@/features/map/core/components/legend";
import { MapMeasurePanel } from "@/features/map/core/components/measure-panel";
import {
type WorkbenchDrawingAnnotation,
type WorkbenchDrawMode
} from "../hooks/use-workbench-drawing";
import {
type WorkbenchMeasurementResult,
type WorkbenchMeasureMode,
type WorkbenchMeasureUnit
} from "../hooks/use-workbench-measurement";
import { waterNetworkToolbarItems } from "../map/toolbar-config";
export type ToolbarToolId = "layers" | "measure" | "analysis" | "annotation" | "more";
export type ExportViewPreset = "current" | "4k";
type MeasureModeOption = {
id: WorkbenchMeasureMode;
label: string;
icon: LucideIcon;
};
const MEASURE_MODES: MeasureModeOption[] = [
{ id: "distance", label: "距离", icon: Route },
{ id: "area", label: "面积", icon: Pentagon },
{ id: "segment", label: "管段", icon: Waypoints }
];
const MEASURE_UNITS: Array<{ id: WorkbenchMeasureUnit; label: string }> = [
{ id: "m", label: "m" },
{ id: "km", label: "km" }
];
const ANNOTATION_SHARE_ACTIONS: MapAnnotationShareAction[] = [
{
id: "export",
icon: Share2,
label: "导出标注",
description: "GeoJSON / 截图报告"
}
];
const TOOL_PANEL_EXIT_MS = 160;
const DRAW_TOOL_OPTIONS: Array<{
id: WorkbenchDrawMode | "undo" | "redo" | "delete" | "clear";
label: string;
icon: LucideIcon;
tone?: "danger";
}> = [
{ id: "point", label: "点标注", icon: Dot },
{ id: "line", label: "线标注", icon: Route },
{ id: "polygon", label: "范围标注", icon: Pentagon },
{ id: "circle", label: "圆形标注", icon: Circle },
{ id: "undo", label: "撤销", icon: Undo2 },
{ id: "redo", label: "重做", icon: Redo2 },
{ id: "delete", label: "删除选中", icon: Trash2, tone: "danger" },
{ id: "clear", label: "清空全部", icon: Trash2, tone: "danger" }
];
export type ToolbarPanelProps = {
activeToolId: ToolbarToolId | null;
activeMeasureModeId: WorkbenchMeasureMode;
activeMeasureUnitId: WorkbenchMeasureUnit;
measuring: boolean;
measureResult: WorkbenchMeasurementResult;
measureEnabled: boolean;
hasMeasurePoints: boolean;
activeDrawToolId: WorkbenchDrawMode | null;
drawingEnabled: boolean;
drawingAnnotations: WorkbenchDrawingAnnotation[];
canUndoDrawing: boolean;
canRedoDrawing: boolean;
canDeleteSelectedDrawing: boolean;
hasDrawingFeatures: boolean;
layerItems: MapLayerControlItem[];
legendItems: MapLegendItem[];
baseLayerOptions: BaseLayerOption[];
activeBaseLayerId: string;
onSelectMeasureMode: (id: WorkbenchMeasureMode) => void;
onSelectMeasureUnit: (id: WorkbenchMeasureUnit) => void;
onStartMeasure: () => void;
onStopMeasure: () => void;
onClearMeasure: () => void;
onCopyMeasure: () => void;
onSelectDrawTool: (id: WorkbenchDrawMode | null) => void;
onUndoDrawing: () => void;
onRedoDrawing: () => void;
onDeleteSelectedDrawing: () => void;
onClearDrawing: () => void;
onExportAnnotations: () => void;
onSelectBaseLayer: (id: string) => void;
onToggleLayer: (id: string, visible: boolean) => void;
onExportView: (preset: ExportViewPreset) => void;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
panelWidthClassName?: string;
};
export function ToolbarPanel({
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
measuring,
measureResult,
measureEnabled,
hasMeasurePoints,
activeDrawToolId,
drawingEnabled,
drawingAnnotations,
canUndoDrawing,
canRedoDrawing,
canDeleteSelectedDrawing,
hasDrawingFeatures,
layerItems,
legendItems,
baseLayerOptions,
activeBaseLayerId,
onSelectMeasureMode,
onSelectMeasureUnit,
onStartMeasure,
onStopMeasure,
onClearMeasure,
onCopyMeasure,
onSelectDrawTool,
onUndoDrawing,
onRedoDrawing,
onDeleteSelectedDrawing,
onClearDrawing,
onExportAnnotations,
onSelectBaseLayer,
onToggleLayer,
onExportView,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig,
panelWidthClassName
}: ToolbarPanelProps) {
const annotationItems = useMemo<MapAnnotationItem[]>(
() =>
drawingAnnotations.map((annotation) => ({
id: annotation.id,
icon: getDrawingAnnotationIcon(annotation.kind),
label: annotation.label,
description: annotation.description,
status: annotation.hidden ? "隐藏" : "显示",
selected: annotation.selected,
muted: annotation.hidden,
onClick: annotation.onToggleVisibility
})),
[drawingAnnotations]
);
const annotationShareActions = useMemo(
() =>
ANNOTATION_SHARE_ACTIONS.map((action) => ({
...action,
onClick: action.id === "export" ? onExportAnnotations : action.onClick
})),
[onExportAnnotations]
);
const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId);
const panelVisible = Boolean(activeToolId);
useEffect(() => {
if (activeToolId) {
setRenderedToolId(activeToolId);
return;
}
if (!renderedToolId) {
return;
}
const exitTimer = window.setTimeout(() => {
setRenderedToolId(null);
}, TOOL_PANEL_EXIT_MS);
return () => window.clearTimeout(exitTimer);
}, [activeToolId, renderedToolId]);
if (!renderedToolId) {
return null;
}
const activeTool = waterNetworkToolbarItems.find((item) => item.id === renderedToolId);
const Icon = activeTool?.icon ?? Layers3;
if (renderedToolId === "layers") {
return (
<MapControlPanel
key="layers"
title="图层"
description={`${layerItems.filter((item) => item.visible).length}/${layerItems.length} 个业务图层可见`}
icon={Icon}
widthClassName={panelWidthClassName}
visible={panelVisible}
>
<MapLayerPanel
layerItems={layerItems}
baseLayerOptions={baseLayerOptions}
activeBaseLayerId={activeBaseLayerId}
onSelectBaseLayer={onSelectBaseLayer}
onToggleLayer={onToggleLayer}
/>
</MapControlPanel>
);
}
if (renderedToolId === "measure") {
return (
<MapControlPanel key="measure" title="测量" description="距离与范围测量" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
<MapMeasurePanel
modes={MEASURE_MODES}
activeModeId={activeMeasureModeId}
resultLabel={measureResult.label}
resultValue={measureResult.value}
resultUnit={measureResult.unit}
metrics={measureResult.metrics}
units={MEASURE_UNITS}
activeUnitId={activeMeasureUnitId}
actions={[
{
id: measuring ? "stop" : "start",
icon: Ruler,
label: measuring ? "停止测量" : "开始测量",
description:
activeMeasureModeId === "segment"
? measuring
? "暂停管线选择"
: "点击管线选择资产"
: measuring
? "暂停地图取点"
: "在地图上连续点击取点",
variant: measuring ? "default" : "primary",
disabled: !measureEnabled,
onClick: measuring ? onStopMeasure : onStartMeasure
},
{
id: "copy",
icon: Copy,
label: "复制结果",
description: "复制当前读数",
disabled: !hasMeasurePoints,
onClick: onCopyMeasure
},
{
id: "clear",
icon: Trash2,
label: "清除测量",
description: activeMeasureModeId === "segment" ? "清空已选管段" : "移除临时测量线",
tone: "danger",
disabled: !hasMeasurePoints,
onClick: onClearMeasure
}
]}
onSelectMode={(id) => onSelectMeasureMode(id as WorkbenchMeasureMode)}
onSelectUnit={(id) => onSelectMeasureUnit(id as WorkbenchMeasureUnit)}
/>
</MapControlPanel>
);
}
if (renderedToolId === "analysis") {
return (
<MapControlPanel key="analysis" title="分析" description="图例与当前覆盖" icon={Icon} widthClassName={panelWidthClassName ?? "w-[316px]"} visible={panelVisible}>
<AnalysisPanel legendItems={legendItems} />
</MapControlPanel>
);
}
if (renderedToolId === "annotation") {
return (
<MapControlPanel key="annotation" title="标注" description="创建与管理地图标注" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
<MapPanelSection title="绘制">
<MapDrawToolbar
variant="panel"
items={DRAW_TOOL_OPTIONS.map((item) => ({
...item,
active: activeDrawToolId === item.id,
disabled:
!drawingEnabled ||
(item.id === "undo" && !canUndoDrawing) ||
(item.id === "redo" && !canRedoDrawing) ||
(item.id === "delete" && !canDeleteSelectedDrawing) ||
(item.id === "clear" && !canUndoDrawing && !hasDrawingFeatures),
onClick: () => {
if (item.id === "undo") {
onUndoDrawing();
return;
}
if (item.id === "redo") {
onRedoDrawing();
return;
}
if (item.id === "delete") {
onDeleteSelectedDrawing();
return;
}
if (item.id === "clear") {
onClearDrawing();
return;
}
onSelectDrawTool(activeDrawToolId === item.id ? null : item.id);
}
}))}
/>
</MapPanelSection>
<MapAnnotationPanel annotations={annotationItems} shareActions={annotationShareActions} />
</MapControlPanel>
);
}
return (
<MapControlPanel key="more" title="更多" description={activeTool?.description} icon={Icon} widthClassName={panelWidthClassName ?? "w-[292px]"} visible={panelVisible}>
<MorePanel
onExportView={onExportView}
onRefreshTiles={onRefreshTiles}
onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts}
onExportConfig={onExportConfig}
/>
</MapControlPanel>
);
}
function AnalysisPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
return (
<div className="space-y-3">
<MapPanelSection title="图例">
<MapLegendList items={legendItems} showStatusDot />
</MapPanelSection>
<MapPanelSection title="当前覆盖">
<div className="grid grid-cols-2 gap-2">
<MapMetricTile label="影响范围" value="未开启" />
<MapMetricTile label="风险预览" value="待生成" />
</div>
<MapActionRow icon={Activity} label="模拟结果" description="影响面、事故点与标签" />
<MapActionRow icon={MapPinned} label="选中对象" description="点击管线或节点查看详情" />
</MapPanelSection>
</div>
);
}
type MorePanelProps = {
onExportView: (preset: ExportViewPreset) => void;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
};
function MorePanel({
onExportView,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig
}: MorePanelProps) {
return (
<div className="space-y-3">
<MapPanelSection title="导出视图">
<MapActionRow icon={Camera} label="当前分辨率" description="按当前地图画布尺寸导出" onClick={() => onExportView("current")} />
<MapActionRow icon={Camera} label="4K 分辨率" description="长边约 3840px 的 PNG" onClick={() => onExportView("4k")} />
</MapPanelSection>
<MapPanelSection title="数据">
<MapActionRow icon={RefreshCw} label="刷新瓦片" description="请求地图重新渲染" onClick={onRefreshTiles} />
<MapActionRow icon={Database} label="数据状态" description="查看当前数据源状态" status="查看" onClick={onShowDataStatus} />
</MapPanelSection>
<MapPanelSection title="系统">
<MapActionRow icon={Keyboard} label="快捷键" description="查看地图操作键位" onClick={onShowShortcuts} />
<MapActionRow icon={Download} label="导出配置" description="保存当前图层与工具状态" onClick={onExportConfig} />
</MapPanelSection>
</div>
);
}
function getDrawingAnnotationIcon(kind: WorkbenchDrawMode) {
if (kind === "line") {
return Route;
}
if (kind === "polygon") {
return Pentagon;
}
if (kind === "circle") {
return Circle;
}
return MapPinned;
}
@@ -0,0 +1,486 @@
"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,
"border border-white/80 bg-white/95 p-2 text-slate-900 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5 backdrop-blur-xl"
);
const menuReadableRowClassName =
"bg-white/80 focus:bg-blue-50/95 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("my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 bg-white/70 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("overflow-hidden bg-white/90 px-3 py-3 ring-1 ring-white/80", 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("flex items-center justify-between gap-3 border border-white/70 bg-white/90 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>
);
}
@@ -0,0 +1,241 @@
"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="pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b border-white/65 bg-white/80 px-3 shadow-lg shadow-slate-900/5 backdrop-blur-2xl 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" />;
}