feat(workbench): refine operational interface

This commit is contained in:
2026-07-14 16:22:21 +08:00
parent ed60a13f12
commit 2b768c2c06
25 changed files with 512 additions and 153 deletions
@@ -52,7 +52,7 @@ import {
agentPanelSectionVariants
} from "./agent-motion";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageDetails } from "./agent-message-details";
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
import { AgentOperationalBrief } from "./agent-operational-brief";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { StreamingTokenResponse } from "./streaming-token-response";
@@ -175,7 +175,7 @@ export function AgentCommandPanel({
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500"></p>
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200/70 bg-white/75 px-2 text-xs font-semibold text-slate-600">
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200/70 bg-[var(--glass-readable)] px-2 text-xs font-semibold text-slate-600">
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} />
{getAgentConnectionLabel(statusLabel)}
</span>
@@ -313,15 +313,17 @@ export function AgentCommandPanel({
</AgentContent>
<div className="agent-panel-band space-y-2 border-t border-white/70 p-3">
<Suggestions aria-label="推荐问题" role="group">
<Suggestions aria-label="推荐问题" role="group" className="px-0.5">
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
<Suggestion
key={suggestion}
suggestion={suggestion}
disabled={streaming}
onClick={submitPrompt}
className="border-slate-200/80 bg-white/75 text-slate-600 shadow-none hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700"
/>
className="max-w-full border-slate-200/80 bg-[var(--glass-readable)] text-slate-600 shadow-none hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700"
>
<span className="truncate">{suggestion}</span>
</Suggestion>
))}
</Suggestions>
<PromptInput
@@ -520,7 +522,7 @@ function AgentModelSelect({
key={model.id}
className={cn(
"rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
selected && "border-violet-200 bg-violet-50/80 focus:bg-violet-50"
selected && "glass-agent border-violet-200 focus:bg-violet-50"
)}
onSelect={() => onValueChange?.(model.id)}
>
@@ -612,6 +614,12 @@ function BackendMessageList({
)}
>
<div className="space-y-3">
{message.role === "assistant" ? (
<AgentMessageProgress
progress={message.progress}
running={streaming && index === messages.length - 1}
/>
) : null}
{message.content ? (
message.role === "assistant" ? (
<StreamingTokenResponse
@@ -2,10 +2,11 @@
import {
CheckCircle2,
ChevronDown,
HelpCircle,
ListChecks,
ListTree,
LockKeyhole,
Loader2,
ShieldCheck,
XCircle,
type LucideIcon
@@ -106,8 +107,7 @@ export function AgentMessageDetails({
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
const hasDetails = Boolean(
message.progress?.length ||
message.todos?.todos.length ||
message.todos?.todos.length ||
message.permissions?.length ||
message.questions?.length
);
@@ -119,11 +119,6 @@ export function AgentMessageDetails({
return (
<motion.div className="space-y-2" layout variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{message.progress?.length ? (
<motion.div key="progress" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<ProgressList progress={message.progress} />
</motion.div>
) : null}
{message.todos ? (
<motion.div key="todos" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<TodoCard todoUpdate={message.todos} />
@@ -148,6 +143,20 @@ export function AgentMessageDetails({
);
}
export function AgentMessageProgress({
progress,
running
}: {
progress?: AgentChatProgress[];
running: boolean;
}) {
if (!progress?.length) {
return null;
}
return <ProgressList progress={progress} running={running} />;
}
function AgentNestedBlock({
icon: Icon,
iconClassName,
@@ -185,44 +194,76 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
);
}
function ProgressList({ progress }: { progress: AgentChatProgress[] }) {
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false);
const recentProgress = progress.slice(-3);
const visibleProgress = expanded ? progress : running ? recentProgress : [];
return (
<AgentNestedBlock icon={Loader2} iconClassName="text-blue-600" title="执行进度">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{progress.slice(-6).map((item) => (
<motion.li
key={item.id}
layout
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border",
progressStatusMeta[item.status].dotClassName
)}
/>
<span className="min-w-0">
<span className="block truncate font-semibold text-slate-800" title={item.title}>
{item.title}
<div className="agent-panel-nested rounded-xl p-2.5">
<button
type="button"
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
<span></span>
<span className="flex-1" />
<span className="shrink-0 font-normal text-slate-400">
{expanded
? `全部 ${progress.length}`
: running
? `最近 ${recentProgress.length}`
: `${progress.length}`}
</span>
<ChevronDown
size={14}
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
aria-hidden="true"
/>
</button>
<AnimatePresence initial={false}>
{visibleProgress.length ? (
<motion.ol
className="mt-2 space-y-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={agentLayoutTransition}
>
{visibleProgress.map((item) => (
<li key={item.id} className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs">
<span
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border",
progressStatusMeta[item.status].dotClassName
)}
/>
<span className="min-w-0">
<span className="block truncate font-semibold text-slate-800" title={item.title}>
{item.title}
</span>
{item.detail ? (
<span className="block line-clamp-2 whitespace-pre-wrap leading-5 text-slate-500">
{item.detail}
</span>
) : null}
</span>
{item.detail ? (
<span className="block line-clamp-2 whitespace-pre-wrap leading-5 text-slate-500">{item.detail}</span>
) : null}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", progressStatusMeta[item.status].className)}>
{progressStatusMeta[item.status].label}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
</AgentNestedBlock>
<span
className={cn(
"rounded-full px-2 py-0.5 font-semibold",
progressStatusMeta[item.status].className
)}
>
{progressStatusMeta[item.status].label}
</span>
</li>
))}
</motion.ol>
) : null}
</AnimatePresence>
</div>
);
}
@@ -310,7 +351,7 @@ function PermissionRequestCard({
};
return (
<div className="rounded-lg bg-white/90 px-2.5 py-2 text-xs">
<div className="rounded-lg bg-[var(--glass-menu)] px-2.5 py-2 text-xs">
<div className="grid grid-cols-[1fr_auto] gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
@@ -438,7 +479,7 @@ function QuestionRequestCard({
};
return (
<div className="rounded-lg bg-white/90 px-2.5 py-2 text-xs">
<div className="rounded-lg bg-[var(--glass-menu)] px-2.5 py-2 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-semibold text-slate-800">
{request.questions[0]?.header || "问题"}
@@ -556,7 +597,7 @@ function QuestionInput({
return (
<label
key={option.label}
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 bg-white/90 px-2 py-1.5 text-slate-600"
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 bg-[var(--surface-solid)] px-2 py-1.5 text-slate-600"
>
<input
type={question.multiple ? "checkbox" : "radio"}
@@ -579,7 +620,7 @@ function QuestionInput({
) : null}
{showCustomInput ? (
<textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200 bg-white/90 px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
className="min-h-16 w-full resize-none rounded-md border border-slate-200 bg-[var(--surface-solid)] px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
value={value.custom}
disabled={disabled}
@@ -61,7 +61,7 @@ export function AgentOperationalBrief({
</button>
<button
type="button"
className="flex h-9 items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-white/90 px-3 text-xs font-semibold text-slate-700 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
className="flex h-9 items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-[var(--glass-menu)] px-3 text-xs font-semibold text-slate-700 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={streaming}
onClick={() => onSubmitCommand(brief.secondaryCommand)}
>
@@ -303,7 +303,7 @@ function PropRows({ props, labels }: { props: unknown; labels: Record<string, st
return (
<dl className="grid grid-cols-2 gap-2">
{rows.map((row) => (
<div key={row.label} className="min-w-0 rounded-lg bg-white/90 px-2.5 py-2">
<div key={row.label} className="min-w-0 rounded-lg bg-[var(--glass-menu)] px-2.5 py-2">
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
{row.value}
@@ -194,7 +194,7 @@ export function BaseLayersControl({
) : (
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
<span className="absolute bottom-1 left-1 right-1 rounded bg-white/95 px-1 py-0.5 text-xs font-semibold leading-none text-slate-800 shadow-sm">
<span className="absolute bottom-1 left-1 right-1 rounded bg-[var(--glass-menu)] px-1 py-0.5 text-xs font-semibold leading-none text-slate-800 shadow-sm">
{activeLayer?.label ?? title}
</span>
</span>
@@ -83,7 +83,7 @@ export function MapModeButton({ icon: Icon, label, active = false, disabled = fa
className={cn(
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
active ? "bg-blue-600 text-white shadow-sm" : "bg-white/95 text-slate-600 hover:text-blue-700",
active ? "bg-blue-600 text-white shadow-sm" : "bg-[var(--glass-menu)] text-slate-600 hover:text-blue-700",
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
)}
>
@@ -164,7 +164,7 @@ export function MapActionRow({
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
: strong
? "border-blue-100 bg-blue-50/95 text-blue-800"
: "border-white/70 bg-white/95 text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
: "border-transparent bg-[var(--glass-menu)] text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
isDanger && "hover:border-red-100 hover:text-red-700",
selected && !isPrimary && "border-blue-200 bg-blue-50 text-blue-800",
muted && "opacity-70",
@@ -69,8 +69,8 @@ export function MapLayerControl({
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-100 bg-white/95 text-slate-900 shadow-md shadow-blue-950/10"
: "border-white/70 bg-white/95 text-slate-500 hover:border-slate-200 hover:bg-white",
? "border-blue-100 bg-[var(--glass-menu)] text-slate-900 shadow-md shadow-blue-950/10"
: "border-transparent bg-[var(--glass-readable)] text-slate-500 hover:border-slate-200 hover:bg-white",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)}
>
+1 -1
View File
@@ -65,7 +65,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-sm shadow-blue-950/5"
: "border-white/70 bg-white/90 text-slate-500 hover:border-slate-200 hover:bg-white",
: "border-transparent bg-[var(--glass-readable)] text-slate-500 hover:border-slate-200 hover:bg-white",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)}
>
@@ -2,23 +2,23 @@ export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
export const MAP_CONTROL_RADIUS_CLASS_NAME = "rounded-xl";
export const MAP_READABLE_RADIUS_CLASS_NAME = "rounded-xl";
export const MAP_READABLE_RADIUS_CLASS_NAME = MAP_CONTROL_RADIUS_CLASS_NAME;
export const MAP_COMPACT_RADIUS_CLASS_NAME = "rounded-xl";
export const MAP_COMPACT_RADIUS_CLASS_NAME = MAP_CONTROL_RADIUS_CLASS_NAME;
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
export const MAP_CONTROL_SURFACE_CLASS_NAME =
"border border-white/75 bg-white/95 shadow-lg shadow-slate-900/20 ring-1 ring-slate-900/5 backdrop-blur-xl";
"glass-control border";
export const MAP_TOOL_PANEL_SURFACE_CLASS_NAME =
"border border-white/70 bg-white/80 shadow-2xl shadow-blue-950/20 backdrop-blur-xl";
"glass-panel border";
export const MAP_READABLE_SURFACE_CLASS_NAME =
"border border-white/70 bg-white/95";
"border border-transparent bg-[var(--glass-readable)]";
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
"border border-white/80 bg-white/95";
"border border-transparent bg-[var(--glass-menu)]";
export const MAP_CONTROL_HOVER_CLASS_NAME =
"hover:bg-blue-100/70 hover:text-blue-700 hover:ring-1 hover:ring-blue-200/80 active:bg-blue-100";
@@ -72,7 +72,7 @@ export function MapMeasurePanel({
return (
<div className="space-y-3">
<MapPanelSection title="模式">
<div className={cn("grid grid-cols-3 gap-1.5 border border-white/60 bg-white/80 p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className={cn("grid grid-cols-3 gap-1.5 border border-transparent bg-[var(--glass-readable)] p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
{modes.map((mode) => (
<MapModeButton
key={mode.id}
@@ -121,7 +121,7 @@ export function MapMeasurePanel({
MAP_COMPACT_RADIUS_CLASS_NAME,
unit.id === activeUnitId
? "bg-blue-600 text-white"
: "bg-white/95 text-slate-600 hover:text-blue-700"
: "bg-[var(--glass-menu)] text-slate-600 hover:text-blue-700"
)}
>
{unit.label}
+6 -6
View File
@@ -45,11 +45,11 @@ const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
};
const toneClassNames: Record<MapNoticeTone, string> = {
info: "border-white/80 bg-white/95 text-slate-900",
success: "border-white/80 bg-white/95 text-slate-900",
warning: "border-white/80 bg-white/95 text-slate-900",
error: "border-white/80 bg-white/95 text-slate-900",
loading: "border-white/80 bg-white/95 text-slate-900"
info: "glass-menu text-slate-900",
success: "glass-menu text-slate-900",
warning: "glass-menu text-slate-900",
error: "glass-menu text-slate-900",
loading: "glass-menu text-slate-900"
};
const iconClassNames: Record<MapNoticeTone, string> = {
@@ -232,7 +232,7 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
<div
role={tone === "error" || tone === "warning" ? "alert" : "status"}
className={cn(
"relative flex w-[min(420px,calc(100vw-24px))] items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm shadow-lg shadow-slate-900/20 backdrop-blur-xl",
"relative flex w-[min(420px,calc(100vw-24px))] items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
MAP_READABLE_RADIUS_CLASS_NAME,
toneClassNames[tone]
)}
@@ -21,7 +21,7 @@ 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";
"glass-control border";
const tickerCardVariants: Variants = {
initial: (direction: TickerTransitionDirection) => ({
@@ -261,7 +261,7 @@ export function AgentTaskTicker({
{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"
className="agent-task-ticker-indicator flex flex-col gap-1 rounded-full bg-[var(--glass-menu)] px-1 py-1 shadow-sm shadow-blue-950/10"
variants={tickerIndicatorVariants}
initial="initial"
animate="animate"
@@ -77,7 +77,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
}, [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">
<aside className="glass-readable pointer-events-auto flex h-full min-h-0 flex-col rounded border">
<div className="border-b border-slate-200/80 p-4">
<div className="flex items-start justify-between gap-3">
<div>
@@ -122,11 +122,11 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("device_external_id", feature.properties.device_external_id)}</p>
</div>
</div>
<p className="mt-2 text-sm leading-6 text-slate-700">线</p>
<p className="mt-2 text-sm leading-6 text-slate-700">线</p>
</section>
) : null}
<section className="mt-4 rounded border border-orange-100 bg-orange-50/70 p-3">
<section className="glass-warning mt-4 rounded border border-orange-100 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
<TriangleAlert size={16} aria-hidden="true" />
Agent
@@ -166,8 +166,8 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
function getScadaPresentation(rawTypeId: unknown) {
const typeId = Number(rawTypeId);
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "border-teal-200 bg-teal-50/70", labelClassName: "text-teal-800" };
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "border-blue-200 bg-blue-50/70", labelClassName: "text-blue-800" };
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "border-orange-200 bg-orange-50/70", labelClassName: "text-orange-800" };
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "glass-normal border-teal-200", labelClassName: "text-teal-800" };
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "glass-info border-blue-200", labelClassName: "text-blue-800" };
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "glass-warning border-orange-200", labelClassName: "text-orange-800" };
return { iconPath: SCADA_ICON_PATHS.unknown, sectionClassName: "border-slate-200 bg-slate-50", labelClassName: "text-slate-800" };
}
@@ -46,12 +46,12 @@ const FEATURE_LAYER_META: Record<
iconClassName: string;
}
> = {
conduits: { icon: ConduitFeatureIcon, label: "管渠", headerClassName: "bg-teal-50", labelClassName: "text-teal-700", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
junctions: { icon: JunctionFeatureIcon, label: "检查井", headerClassName: "bg-sky-50", labelClassName: "text-sky-700", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
orifices: { icon: OrificeFeatureIcon, label: "孔口", headerClassName: "bg-slate-100", labelClassName: "text-slate-700", iconClassName: "bg-slate-600 text-white ring-slate-900/10" },
outfalls: { icon: OutfallFeatureIcon, label: "排放口", headerClassName: "bg-blue-50", labelClassName: "text-blue-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" },
scada: { icon: JunctionFeatureIcon, label: "SCADA 测点", headerClassName: "bg-blue-50", labelClassName: "text-blue-700", iconClassName: "bg-blue-700 text-white ring-blue-900/10" }
conduits: { icon: ConduitFeatureIcon, label: "管渠", headerClassName: "glass-normal", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
junctions: { icon: JunctionFeatureIcon, label: "检查井", headerClassName: "glass-info", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
orifices: { icon: OrificeFeatureIcon, label: "孔口", headerClassName: "bg-[var(--glass-readable)]", labelClassName: "text-slate-700", iconClassName: "bg-slate-600 text-white ring-slate-900/10" },
outfalls: { icon: OutfallFeatureIcon, label: "排放口", headerClassName: "glass-info", labelClassName: "text-blue-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "glass-normal", labelClassName: "text-teal-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" },
scada: { icon: JunctionFeatureIcon, label: "SCADA 测点", headerClassName: "glass-info", labelClassName: "text-blue-800", iconClassName: "bg-blue-700 text-white ring-blue-900/10" }
};
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
@@ -128,7 +128,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
return (
<aside
className={cn(
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden bg-white/94 shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
"glass-focus pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden border",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)}
>
@@ -155,8 +155,8 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
<div className="flex items-center gap-0.5">
{canCopy ? (
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-[120ms]", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-[120ms]", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
</button>
) : null}
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
@@ -218,7 +218,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
return (
<aside
className={cn(
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] bg-white/94 shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
"glass-focus pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] border",
"-translate-x-1/2 overflow-hidden",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)}
@@ -232,7 +232,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
{formatFeaturePropertyValue("device_type_name", properties.device_type_name)}
</span>
<span className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-white/80 px-2 py-0.5 text-xs font-semibold ring-1 ring-inset",
"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[var(--glass-menu)] px-2 py-0.5 text-xs font-semibold ring-1 ring-inset",
active ? "text-emerald-700 ring-emerald-600/20" : "text-slate-600 ring-slate-500/20"
)}>
<span className={cn("h-1.5 w-1.5 rounded-full", active ? "bg-emerald-500" : "bg-slate-400")} />
@@ -247,8 +247,8 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
<div className="flex items-center gap-0.5">
{canCopy ? (
<button type="button" aria-label={copied ? "测点编号已复制" : "复制测点编号"} title={copied ? "测点编号已复制" : "复制测点编号"} onClick={onCopy} className="relative grid h-10 w-10 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-[120ms]", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-[120ms]", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
</button>
) : null}
<button type="button" aria-label="关闭 SCADA 测点信息" title="关闭 SCADA 测点信息" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
@@ -274,7 +274,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
<div className="mt-3 grid grid-cols-[18px_1fr] gap-2 rounded-xl bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600 ring-1 ring-inset ring-slate-200/70">
<MapPin size={15} className="mt-0.5 text-slate-500" aria-hidden="true" />
<p></p>
<p></p>
</div>
<div className="mt-2 flex items-center gap-2 px-1 text-xs text-slate-400">
<Database size={13} aria-hidden="true" />
@@ -288,8 +288,8 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
function getScadaPresentation(rawTypeId: unknown) {
const typeId = Number(rawTypeId);
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "bg-teal-50", labelClassName: "text-teal-700" };
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "bg-blue-50", labelClassName: "text-blue-700" };
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "bg-orange-50", labelClassName: "text-orange-700" };
return { iconPath: SCADA_ICON_PATHS.unknown, headerClassName: "bg-slate-100", labelClassName: "text-slate-700" };
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "glass-normal", labelClassName: "text-teal-800" };
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "glass-info", labelClassName: "text-blue-800" };
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "glass-warning", labelClassName: "text-orange-800" };
return { iconPath: SCADA_ICON_PATHS.unknown, headerClassName: "bg-[var(--glass-readable)]", labelClassName: "text-slate-700" };
}
@@ -15,7 +15,7 @@ export function IconButton({ label, children, onClick, active = false }: IconBut
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"
: "border-transparent bg-[var(--glass-readable)] text-slate-700 shadow-sm hover:bg-white"
}`}
>
{children}
@@ -49,9 +49,9 @@ import { getConditionReportRiskLevel } from "./condition-report-risk";
const RUNNING_WORKFLOW_TICK_MS = 1_000;
const STATUS_PILL_CLASS_NAME = "rounded-full border px-2 py-0.5 text-xs font-semibold leading-4";
const NEUTRAL_SURFACE_CLASS_NAME = "rounded-xl border border-slate-200/80 bg-white/95";
const INSET_SURFACE_CLASS_NAME = "rounded-xl border border-slate-200/70 bg-slate-50/80";
const REPORT_META_TILE_CLASS_NAME = "rounded-lg border border-slate-200/70 bg-slate-50/80 px-2 py-1.5";
const NEUTRAL_SURFACE_CLASS_NAME = "rounded-xl border border-transparent bg-[var(--glass-readable)]";
const INSET_SURFACE_CLASS_NAME = "rounded-xl border border-slate-200/70 bg-[var(--glass-menu)]";
const REPORT_META_TILE_CLASS_NAME = "rounded-lg border border-slate-200/70 bg-[var(--glass-menu)] px-2 py-1.5";
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
normal: "border-slate-200 bg-slate-50 text-slate-600",
@@ -365,7 +365,7 @@ function DetailHeader({
iconSpin = false
}: DetailHeaderProps) {
return (
<div className="bg-white/95 px-3 py-3">
<div className="px-3 py-3">
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1fr)_220px]">
<div className="flex min-w-0 items-start gap-2.5">
<span
@@ -739,11 +739,11 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
if (riskLevel === "critical") {
return "border-red-200/80 bg-red-50/80";
return "glass-danger border-red-200/80";
}
if (riskLevel === "attention") {
return "border-orange-200/80 bg-orange-50/80";
return "glass-warning border-orange-200/80";
}
return "border-slate-200/70 bg-slate-50/80";
@@ -842,11 +842,11 @@ function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
if (status === "current") {
return "border-blue-100 bg-blue-50/70";
return "glass-info border-blue-100";
}
if (status === "confirmation") {
return "border-orange-100 bg-orange-50/70";
return "glass-warning border-orange-100";
}
return "border-slate-200/70 bg-slate-50/80";
@@ -12,7 +12,6 @@ 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";
@@ -242,8 +241,8 @@ export function ScheduledConditionFeed({
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
"rounded-2xl border",
"glass-control"
)}
>
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", "rounded-xl", MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
@@ -368,7 +367,9 @@ function ConditionTimelinePanel({
"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
? "border border-white/30 bg-white/10"
: MAP_READABLE_SURFACE_STRONG_CLASS_NAME
)}
>
{expanded && !loading ? (
@@ -534,7 +535,7 @@ function ConditionFilterDropdown<TValue extends string>({
<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"
className="glass-menu scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border p-1.5 text-slate-700"
>
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
{options.map((option) => (
@@ -561,7 +562,7 @@ function ConditionFilterDropdown<TValue extends string>({
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">
<div className="flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-[var(--glass-readable)] 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>
@@ -586,7 +587,7 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
) : 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="min-w-0 rounded-xl border border-transparent bg-[var(--glass-menu)] 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" />
@@ -606,7 +607,7 @@ function ConditionDetailSkeleton() {
<DetailScroll>
<div
className={cn(
"relative min-h-[420px] overflow-hidden border border-slate-200/80 bg-white/95 px-4 py-3",
"relative min-h-[420px] overflow-hidden border border-slate-200/80 bg-[var(--glass-menu)] px-4 py-3",
"rounded-xl"
)}
role="status"
@@ -737,7 +738,7 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
"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"
: "border-transparent bg-[var(--glass-menu)] text-slate-700 group-hover:border-blue-100 group-hover:bg-white"
)}
>
<span className="flex min-w-0 items-center gap-2">
@@ -46,10 +46,10 @@ const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
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"
"glass-menu border p-2 text-slate-900"
);
const menuReadableRowClassName =
"bg-white/80 focus:bg-blue-50/95 focus:text-slate-950";
"bg-[var(--glass-readable)] 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 =
@@ -220,7 +220,7 @@ export function AlertMenu({
</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={cn("my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 bg-[var(--glass-readable)] 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>
@@ -315,7 +315,7 @@ function AlertQueueSummary({
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={cn("overflow-hidden bg-[var(--glass-menu)] 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
@@ -423,7 +423,7 @@ function MenuHeader({
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={cn("flex items-center justify-between gap-3 border border-transparent bg-[var(--glass-menu)] 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}
@@ -78,7 +78,7 @@ export function WorkbenchTopBar({
};
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">
<header className="glass-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 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" />
+16
View File
@@ -26,6 +26,22 @@ describe("SCADA map presentation", () => {
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
});
it("keeps the icon and selected ring consistent across zoom levels", () => {
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
const selectedLayer = scadaLayers.find((layer) => layer.id === "scada-selected");
expect(symbolLayer).toMatchObject({ type: "symbol", layout: { "icon-size": 0.75 } });
expect(outerLayer).toMatchObject({
type: "circle",
paint: {
"circle-radius": 12.5,
"circle-stroke-width": 3
}
});
expect(selectedLayer).toMatchObject({ type: "circle", paint: { "circle-stroke-width": 2 } });
});
it("uses MapLibre-compatible PNG assets", () => {
expect(Object.values(SCADA_MAP_ICON_PATHS)).toHaveLength(4);
Object.values(SCADA_MAP_ICON_PATHS).forEach((path) => expect(path).toMatch(/\.png$/));
+6 -3
View File
@@ -37,6 +37,9 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
"case", ["boolean", ["feature-state", state], false], 1, 0
];
const SCADA_ICON_SIZE = 0.75;
const SCADA_SELECTED_RADIUS = 12.5;
type Layer = NonNullable<StyleSpecification["layers"]>[number];
export type ScadaImageRegistrationResult = {
@@ -46,7 +49,7 @@ export type ScadaImageRegistrationResult = {
const scadaSymbolLayer: Layer = {
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12,
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": ["interpolate", ["linear"], ["zoom"], 12, 0.625, 20, 0.875], "icon-allow-overlap": true, "icon-ignore-placement": true }
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SCADA_ICON_SIZE, "icon-allow-overlap": true, "icon-ignore-placement": true }
};
const scadaFallbackLayer: Layer = {
@@ -56,8 +59,8 @@ const scadaFallbackLayer: Layer = {
const scadaInteractionLayers: Layer[] = [
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 14, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#0E7490", "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "white", "circle-stroke-width": 6, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#2563EB", "circle-stroke-width": 3, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "white", "circle-stroke-width": 3, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#2563EB", "circle-stroke-width": 2, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(0,0,0,0)", "circle-opacity": 0 } }
];
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getFeaturePanelModel, getFeaturePanelProperties, getLocalizedFeatureProperties } from "./feature-properties";
import { formatFeaturePropertyValue, getFeaturePanelModel, getFeaturePanelProperties, getLocalizedFeatureProperties } from "./feature-properties";
describe("feature panel properties", () => {
it.each([
@@ -78,4 +78,9 @@ describe("feature panel properties", () => {
expect(entries.map((entry) => entry.key)).toEqual(["point_name", "device_external_id"]);
});
it("labels uniformly sampled SCADA locations", () => {
expect(formatFeaturePropertyValue("location_source", "uniform_farthest_point_demo"))
.toBe("空间均匀采样模拟位置");
});
});
@@ -212,6 +212,10 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
return "密度聚类模拟位置";
}
if (normalizedKey === "location_source" && value === "uniform_farthest_point_demo") {
return "空间均匀采样模拟位置";
}
if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") {
const diameterInMeters = typeof value === "number" ? value : Number(value);
return Number.isFinite(diameterInMeters)