style: refine acrylic workbench surfaces
This commit is contained in:
@@ -3,9 +3,7 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
|
||||
type AgentCollapsedRailProps = {
|
||||
@@ -16,13 +14,13 @@ type AgentCollapsedRailProps = {
|
||||
|
||||
export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) {
|
||||
return (
|
||||
<aside className={cn("agent-rail-enter glass-control pointer-events-auto w-[136px] p-1.5 shadow-[0_6px_18px_rgba(15,23,42,0.12)]", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}>
|
||||
<aside className={cn("agent-rail-enter acrylic-panel pointer-events-auto w-[136px] border p-1.5", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开排水助手面板"
|
||||
title="展开排水助手面板"
|
||||
onClick={onExpand}
|
||||
className="agent-rail-item group flex h-14 w-full items-center justify-center gap-4 rounded-xl bg-white px-3 transition hover:bg-slate-50"
|
||||
className="agent-rail-item surface-control group flex h-14 w-full items-center justify-center gap-4 rounded-xl border px-3 transition-[background-color,transform] hover:bg-white active:scale-95"
|
||||
>
|
||||
<AgentPersona
|
||||
className="h-12 w-12"
|
||||
@@ -30,7 +28,7 @@ export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpa
|
||||
state={personaState}
|
||||
statusLabel={statusLabel}
|
||||
/>
|
||||
<span className="agent-panel-primary-icon grid h-8 w-8 shrink-0 place-items-center rounded-lg !border-0 transition group-hover:translate-x-0.5">
|
||||
<span className="agent-panel-primary-icon grid h-8 w-8 shrink-0 place-items-center rounded-lg !border-0 transition-transform group-hover:translate-x-0.5">
|
||||
<ChevronRight size={17} strokeWidth={2.25} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -40,10 +40,6 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
import {
|
||||
@@ -164,23 +160,23 @@ export function AgentCommandPanel({
|
||||
className={cn(
|
||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
|
||||
"agent-panel-shell",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
"acrylic-panel border",
|
||||
"rounded-2xl",
|
||||
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
||||
)}
|
||||
>
|
||||
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
|
||||
<div className="agent-panel-band border-b border-slate-200">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 text-slate-900">
|
||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||
<AgentPersona
|
||||
className="h-[72px] w-[72px]"
|
||||
fallbackClassName="h-[72px] w-[72px] rounded-2xl"
|
||||
className="h-10 w-10"
|
||||
fallbackClassName="h-10 w-10 rounded-xl"
|
||||
state={personaState}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
@@ -297,17 +293,13 @@ export function AgentCommandPanel({
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
className="flex flex-1 items-center justify-center px-5 py-10"
|
||||
className="flex flex-1 items-start justify-start px-4 py-6"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<AgentEmptyState
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
/>
|
||||
<AgentEmptyState />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -327,14 +319,14 @@ export function AgentCommandPanel({
|
||||
|
||||
<div className="agent-panel-band relative border-t border-slate-200 p-3">
|
||||
{messages.length === 0 && !streaming ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-full z-10 bg-gradient-to-t from-white/95 via-white/80 to-transparent px-3 pb-2 pt-5">
|
||||
<div className="surface-dock pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
|
||||
<Suggestions aria-label="推荐问题" role="group" className="pointer-events-auto px-0.5">
|
||||
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
|
||||
<Suggestion
|
||||
key={suggestion}
|
||||
suggestion={suggestion}
|
||||
onClick={submitPrompt}
|
||||
className="max-w-full border-slate-200 bg-white text-slate-600 shadow-sm hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700"
|
||||
className="surface-reading max-w-full border border-slate-200 text-slate-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
>
|
||||
<span className="truncate">{suggestion}</span>
|
||||
</Suggestion>
|
||||
@@ -604,40 +596,25 @@ function ExpertModelIcon({ size }: { size: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentEmptyState({
|
||||
personaState,
|
||||
statusLabel,
|
||||
streaming
|
||||
}: {
|
||||
personaState?: PersonaState;
|
||||
statusLabel: string;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
function AgentEmptyState() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="agent-empty-state-title"
|
||||
className="mx-auto flex w-full max-w-sm flex-col items-center rounded-2xl border border-slate-200 bg-white p-4 text-center shadow-sm sm:p-5"
|
||||
className="mx-auto flex w-full max-w-sm flex-col px-2 py-4 text-left"
|
||||
>
|
||||
<AgentPersona
|
||||
className="mb-3 h-20 w-20"
|
||||
fallbackClassName="h-20 w-20 rounded-2xl"
|
||||
state={personaState}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
/>
|
||||
<p id="agent-empty-state-title" className="text-base font-semibold text-slate-900">
|
||||
从一个问题开始
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-72 text-xs leading-5 text-slate-500">
|
||||
<p className="mt-1.5 text-xs leading-5 text-slate-500">
|
||||
描述分析目标,或选择下方推荐问题。Agent 会整理证据并给出下一步建议。
|
||||
</p>
|
||||
<ul className="mt-4 grid w-full grid-cols-2 gap-2" aria-label="Agent 分析能力">
|
||||
<ul className="mt-5 w-full divide-y divide-slate-200 border-y border-slate-200" aria-label="Agent 分析能力">
|
||||
{AGENT_CAPABILITIES.map(({ icon: Icon, label }) => (
|
||||
<li
|
||||
key={label}
|
||||
className="flex min-h-16 flex-col items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-slate-50 px-2 py-3 text-xs font-semibold text-slate-700 min-[420px]:min-h-14 min-[420px]:flex-row min-[420px]:gap-2"
|
||||
className="flex min-h-11 items-center gap-3 px-1 text-xs font-semibold text-slate-700"
|
||||
>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-600">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
|
||||
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
|
||||
@@ -351,7 +351,7 @@ function PermissionRequestCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-[var(--glass-menu)] px-2.5 py-2 text-xs">
|
||||
<div className="surface-reading rounded-lg 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}>
|
||||
@@ -479,7 +479,7 @@ function QuestionRequestCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-[var(--glass-menu)] px-2.5 py-2 text-xs">
|
||||
<div className="surface-reading rounded-lg 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 || "问题"}
|
||||
@@ -597,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-[var(--surface-solid)] px-2 py-1.5 text-slate-600"
|
||||
className="surface-reading flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 px-2 py-1.5 text-slate-600"
|
||||
>
|
||||
<input
|
||||
type={question.multiple ? "checkbox" : "radio"}
|
||||
@@ -620,7 +620,7 @@ function QuestionInput({
|
||||
) : null}
|
||||
{showCustomInput ? (
|
||||
<textarea
|
||||
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"
|
||||
className="surface-reading min-h-16 w-full resize-none rounded-md border border-slate-200 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-[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"
|
||||
className="surface-control flex h-9 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold text-slate-700 transition-colors hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
||||
>
|
||||
@@ -75,7 +75,7 @@ export function AgentOperationalBrief({
|
||||
|
||||
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border border-slate-200/70 bg-slate-50/90 px-2 py-2">
|
||||
<div className="surface-reading min-w-0 rounded-xl border px-2 py-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<span className="text-blue-600">{icon}</span>
|
||||
{label}
|
||||
|
||||
@@ -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-[var(--glass-menu)] px-2.5 py-2">
|
||||
<div key={row.label} className="surface-reading min-w-0 rounded-lg 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-[var(--glass-menu)] px-1 py-0.5 text-xs font-semibold leading-none text-slate-800 shadow-sm">
|
||||
<span className="surface-reading absolute bottom-1 left-1 right-1 rounded px-1 py-0.5 text-xs font-semibold leading-none text-slate-800">
|
||||
{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-[var(--glass-menu)] text-slate-600 hover:text-blue-700",
|
||||
active ? "bg-blue-600 text-white" : "surface-control 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-transparent bg-[var(--glass-menu)] text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
|
||||
: "surface-control border-transparent 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-[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",
|
||||
? "surface-control border-blue-100 text-slate-900"
|
||||
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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-transparent bg-[var(--glass-readable)] text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -9,19 +9,19 @@ 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 =
|
||||
"glass-control border";
|
||||
|
||||
export const MAP_AIR_SURFACE_CLASS_NAME =
|
||||
"glass-air border";
|
||||
"acrylic-control border";
|
||||
|
||||
export const MAP_TOOL_PANEL_SURFACE_CLASS_NAME =
|
||||
"glass-panel border";
|
||||
"acrylic-panel border";
|
||||
|
||||
export const MAP_FOCUS_SURFACE_CLASS_NAME =
|
||||
"acrylic-panel border";
|
||||
|
||||
export const MAP_READABLE_SURFACE_CLASS_NAME =
|
||||
"border border-transparent bg-[var(--glass-readable)]";
|
||||
"surface-well border";
|
||||
|
||||
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
|
||||
"border border-transparent bg-[var(--glass-menu)]";
|
||||
"surface-control border";
|
||||
|
||||
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-transparent bg-[var(--glass-readable)] p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className={cn("surface-well grid grid-cols-3 gap-1.5 border 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-[var(--glass-menu)] text-slate-600 hover:text-blue-700"
|
||||
: "surface-control text-slate-600 hover:text-blue-700"
|
||||
)}
|
||||
>
|
||||
{unit.label}
|
||||
|
||||
@@ -45,11 +45,11 @@ const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
|
||||
};
|
||||
|
||||
const toneClassNames: Record<MapNoticeTone, string> = {
|
||||
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"
|
||||
info: "acrylic-panel text-slate-900",
|
||||
success: "acrylic-panel text-slate-900",
|
||||
warning: "acrylic-panel text-slate-900",
|
||||
error: "acrylic-panel text-slate-900",
|
||||
loading: "acrylic-panel text-slate-900"
|
||||
};
|
||||
|
||||
const iconClassNames: Record<MapNoticeTone, string> = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MAP_AIR_SURFACE_CLASS_NAME } from "./map-control-styles";
|
||||
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
|
||||
|
||||
type ScaleLineState = {
|
||||
label: string;
|
||||
@@ -102,7 +102,7 @@ export function MapScaleLine({
|
||||
aria-label={`比例尺 ${scale.label},${coordinateLabel}`}
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex w-auto max-w-full select-none items-center gap-2.5 overflow-hidden rounded-none rounded-tl-xl border-b-0 border-r-0 px-2.5 py-1.5 text-xs font-semibold leading-none text-slate-800",
|
||||
MAP_AIR_SURFACE_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -20,23 +20,18 @@ type TickerTransitionDirection = -1 | 1;
|
||||
|
||||
const tickerEase = [0.22, 1, 0.36, 1] as const;
|
||||
const tickerSwitchAnimationMs = 380;
|
||||
const tickerCardSurfaceClassName =
|
||||
"glass-control border";
|
||||
|
||||
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)"
|
||||
rotate: 0
|
||||
}),
|
||||
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],
|
||||
@@ -261,7 +256,7 @@ export function AgentTaskTicker({
|
||||
{isHovered && hasMultipleRunningTasks ? (
|
||||
<motion.div
|
||||
key="task-indicator"
|
||||
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"
|
||||
className="agent-task-ticker-indicator surface-control flex flex-col gap-1 rounded-full border px-1 py-1"
|
||||
variants={tickerIndicatorVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
@@ -307,8 +302,7 @@ function TickerTaskCard({
|
||||
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"
|
||||
isActive ? "glass-transient z-30" : "surface-control pointer-events-none z-10 border"
|
||||
)}
|
||||
style={{ transformOrigin: "center top" }}
|
||||
custom={transitionDirection}
|
||||
@@ -319,7 +313,6 @@ function TickerTaskCard({
|
||||
x: stackStyle.x,
|
||||
y: stackStyle.y,
|
||||
rotate: stackStyle.rotate,
|
||||
filter: stackStyle.filter,
|
||||
transition: {
|
||||
duration: isActive ? 0.34 : 0.3,
|
||||
ease: tickerEase
|
||||
@@ -449,8 +442,7 @@ function getTickerStackStyle(stackIndex: number) {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 7,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.985) saturate(0.98)"
|
||||
rotate: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -459,8 +451,7 @@ function getTickerStackStyle(stackIndex: number) {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 14,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.965) saturate(0.96)"
|
||||
rotate: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -468,7 +459,6 @@ function getTickerStackStyle(stackIndex: number) {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotate: 0,
|
||||
filter: "brightness(1) saturate(1)"
|
||||
rotate: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
}, [feature]);
|
||||
|
||||
return (
|
||||
<aside className="glass-readable pointer-events-auto flex h-full min-h-0 flex-col rounded border">
|
||||
<aside className="acrylic-panel 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>
|
||||
@@ -91,7 +91,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
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"
|
||||
className="surface-control grid h-9 w-9 place-items-center rounded border text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -106,7 +106,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
<>
|
||||
<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">
|
||||
<div key={label} className="surface-reading rounded border 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>
|
||||
@@ -126,7 +126,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="glass-warning mt-4 rounded border border-orange-100 p-3">
|
||||
<section className="material-tone-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 解释
|
||||
@@ -138,7 +138,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
|
||||
<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">
|
||||
<dl className="surface-reading mt-2 divide-y divide-slate-100 rounded border">
|
||||
{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>
|
||||
@@ -150,7 +150,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
</>
|
||||
) : (
|
||||
<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">
|
||||
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-slate-500">
|
||||
<Target size={22} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
|
||||
@@ -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: "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" };
|
||||
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "material-tone-normal border-teal-200", labelClassName: "text-teal-800" };
|
||||
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "material-tone-info border-blue-200", labelClassName: "text-blue-800" };
|
||||
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "material-tone-warning border-orange-200", labelClassName: "text-orange-800" };
|
||||
return { iconPath: SCADA_ICON_PATHS.unknown, sectionClassName: "surface-control border-transparent", labelClassName: "text-slate-800" };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
import Image from "next/image";
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||
import {
|
||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SCADA_ICON_PATHS } from "../map/scada";
|
||||
@@ -128,7 +131,8 @@ 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/[0.94] shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
|
||||
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden",
|
||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
@@ -173,7 +177,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
<Database size={14} aria-hidden="true" />
|
||||
设施属性
|
||||
</div>
|
||||
<dl className="divide-y divide-slate-100 border-y border-slate-200/80">
|
||||
<dl className="surface-reading divide-y divide-slate-100 rounded-xl px-3">
|
||||
{panelModel.attributes.map((entry) => (
|
||||
<div key={entry.key} className="grid min-h-10 grid-cols-[96px_minmax(0,1fr)] items-baseline gap-3 py-2.5">
|
||||
<dt className="text-xs font-medium leading-5 text-slate-500">{entry.label}</dt>
|
||||
@@ -218,7 +222,8 @@ 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/[0.94] shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
|
||||
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))]",
|
||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||
"-translate-x-1/2 overflow-hidden",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
@@ -264,7 +269,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
|
||||
<Radio size={14} aria-hidden="true" />
|
||||
设备与关联信息
|
||||
</div>
|
||||
<dl className="divide-y divide-slate-100 border-y border-slate-200/80">
|
||||
<dl className="surface-reading divide-y divide-slate-100 rounded-xl px-3">
|
||||
{attributes.map(([label, value]) => (
|
||||
<div key={label} className="grid min-h-10 grid-cols-[92px_minmax(0,1fr)] items-baseline gap-3 py-2.5">
|
||||
<dt className="text-xs font-medium text-slate-500">{label}</dt>
|
||||
@@ -273,7 +278,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<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">
|
||||
<div className="surface-reading mt-3 grid grid-cols-[18px_1fr] gap-2 rounded-xl px-3 py-2.5 text-xs leading-5 text-slate-600">
|
||||
<MapPin size={15} className="mt-0.5 text-slate-500" aria-hidden="true" />
|
||||
<p>当前点位由管网节点空间均匀采样生成,用于空间展示,不代表设备真实安装坐标。</p>
|
||||
</div>
|
||||
|
||||
@@ -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-transparent bg-[var(--glass-readable)] text-slate-700 shadow-sm hover:bg-white"
|
||||
: "surface-control border-transparent text-slate-700 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-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 NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-slate-200/70 px-2 py-1.5";
|
||||
|
||||
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
|
||||
normal: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
@@ -136,9 +136,9 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
|
||||
<motion.div
|
||||
key={detailKey}
|
||||
className="min-h-0"
|
||||
initial={{ opacity: 0, y: 8, filter: "blur(2px)" }}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, y: -6, filter: "blur(1px)" }}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{content}
|
||||
@@ -185,9 +185,9 @@ function ConditionRecordDetailPanel({
|
||||
<motion.div
|
||||
key={bodyKey}
|
||||
className="space-y-2.5"
|
||||
initial={{ opacity: 0, y: 10, scale: 0.992, filter: "blur(2px)" }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.996, filter: "blur(1px)" }}
|
||||
initial={{ opacity: 0, y: 10, scale: 0.992 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.996 }}
|
||||
transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{condition.status === "running" ? (
|
||||
@@ -345,7 +345,7 @@ type DetailShellProps = {
|
||||
|
||||
function DetailShell({ children }: DetailShellProps) {
|
||||
return (
|
||||
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden ring-1 ring-white/80")}>
|
||||
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -444,7 +444,7 @@ function SolutionOptionsList({
|
||||
{options.map((option, index) => (
|
||||
<article key={option.id} className="px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-lg bg-white text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
|
||||
<span className="surface-reading grid h-5 w-5 shrink-0 place-items-center rounded-lg border border-slate-200/70 text-xs font-semibold text-slate-600">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h4 className="min-w-0 truncate text-xs font-semibold leading-4 text-slate-900">{option.title}</h4>
|
||||
@@ -576,7 +576,7 @@ function EscalationCriteriaList({ items }: { items: string[] }) {
|
||||
<ol className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-slate-200/70 overflow-hidden")}>
|
||||
{items.map((item, index) => (
|
||||
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 px-2 py-1.5 text-xs leading-5 text-slate-700">
|
||||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-white text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
|
||||
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-slate-200/70 text-xs font-semibold text-slate-600">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{item}</span>
|
||||
@@ -711,7 +711,7 @@ function ReportSection({
|
||||
|
||||
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
return (
|
||||
<div className={cn("min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
|
||||
<div className={cn("surface-reading min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
|
||||
@@ -739,14 +739,14 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
|
||||
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
|
||||
if (riskLevel === "critical") {
|
||||
return "glass-danger border-red-200/80";
|
||||
return "border-red-200/80";
|
||||
}
|
||||
|
||||
if (riskLevel === "attention") {
|
||||
return "glass-warning border-orange-200/80";
|
||||
return "border-orange-200/80";
|
||||
}
|
||||
|
||||
return "border-slate-200/70 bg-slate-50/80";
|
||||
return "border-slate-200/70";
|
||||
}
|
||||
|
||||
function DecisionActionStrip({
|
||||
@@ -782,7 +782,7 @@ function DecisionActionStrip({
|
||||
"flex min-h-9 shrink-0 items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors min-[1500px]:w-[104px]",
|
||||
"rounded-xl",
|
||||
actionDisabled
|
||||
? "cursor-not-allowed bg-white/70 text-slate-500 opacity-80"
|
||||
? "surface-control cursor-not-allowed border border-slate-200/70 text-slate-500 opacity-80"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
@@ -842,14 +842,14 @@ function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
|
||||
|
||||
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
|
||||
if (status === "current") {
|
||||
return "glass-info border-blue-100";
|
||||
return "surface-reading border-blue-100";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "glass-warning border-orange-100";
|
||||
return "surface-reading border-orange-100";
|
||||
}
|
||||
|
||||
return "border-slate-200/70 bg-slate-50/80";
|
||||
return "surface-reading border-slate-200/70";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus) {
|
||||
@@ -973,7 +973,7 @@ function DetailSectionCard({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-xl border border-slate-200/80 bg-slate-50/90 px-2.5 py-2.5 text-slate-500", className)}>
|
||||
<div className={cn("surface-reading rounded-xl border border-slate-200/70 px-2.5 py-2.5 text-slate-500", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -1131,7 +1131,7 @@ function WorkOrderReplyRequirements({ condition }: { condition: ScheduledWorkOrd
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{condition.replyRequirements.map((item, index) => (
|
||||
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 text-xs leading-5 text-slate-700">
|
||||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-white text-xs font-semibold text-blue-700 ring-1 ring-blue-100">
|
||||
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-blue-100 text-xs font-semibold text-blue-700">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{item}</span>
|
||||
@@ -1150,7 +1150,7 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<li key={item} className="grid grid-cols-[18px_1fr_auto] items-start gap-2 text-xs leading-5 text-slate-700">
|
||||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-blue-50 text-xs font-semibold text-blue-700 ring-1 ring-blue-100">
|
||||
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-blue-100 text-xs font-semibold text-blue-700">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="line-clamp-1">{item}</span>
|
||||
@@ -1173,7 +1173,7 @@ function EvidenceList({ items }: { items: string[] }) {
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 text-xs leading-5 text-slate-700">
|
||||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-white text-xs font-semibold text-blue-700 ring-1 ring-blue-100">
|
||||
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-blue-100 text-xs font-semibold text-blue-700">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{item}</span>
|
||||
|
||||
@@ -10,14 +10,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { MAP_TOOL_PANEL_SURFACE_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 { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -41,11 +39,15 @@ import {
|
||||
type ConditionTaskFilterValue
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
|
||||
type ScheduledConditionFeedProps = {
|
||||
conditions?: ScheduledConditionItem[];
|
||||
expanded?: boolean;
|
||||
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
||||
loading?: boolean;
|
||||
visible?: boolean;
|
||||
selectedConditionId?: string | null;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
onFocusRequestHandled?: (requestId: number) => void;
|
||||
@@ -65,6 +67,7 @@ export function ScheduledConditionFeed({
|
||||
expanded: controlledExpanded,
|
||||
focusRequest,
|
||||
loading = false,
|
||||
visible = true,
|
||||
selectedConditionId: controlledSelectedConditionId,
|
||||
onExpandedChange,
|
||||
onFocusRequestHandled,
|
||||
@@ -115,7 +118,7 @@ export function ScheduledConditionFeed({
|
||||
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条`
|
||||
: expanded
|
||||
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`
|
||||
: `最近 ${recentConditions.length} 条`;
|
||||
: `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`;
|
||||
const selectedCondition =
|
||||
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
|
||||
recentConditions[0] ??
|
||||
@@ -238,14 +241,16 @@ export function ScheduledConditionFeed({
|
||||
return (
|
||||
<section
|
||||
aria-label="工况任务"
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
|
||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
||||
"rounded-2xl border",
|
||||
expanded ? "glass-panel" : "glass-control"
|
||||
"rounded-2xl"
|
||||
)}
|
||||
>
|
||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", "rounded-xl", expanded ? "bg-white/30" : MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", CONDITION_CONTROL_SURFACE_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>
|
||||
@@ -366,10 +371,7 @@ function ConditionTimelinePanel({
|
||||
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",
|
||||
expanded
|
||||
? "border border-white/30 bg-white/10"
|
||||
: MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
{expanded && !loading ? (
|
||||
@@ -387,7 +389,7 @@ function ConditionTimelinePanel({
|
||||
/>
|
||||
) : null}
|
||||
{expanded && !loading && futureWorkOrders.length > 0 ? (
|
||||
<section className="mb-2 rounded-xl border border-blue-100 bg-blue-50/55 p-2">
|
||||
<section className="mb-2">
|
||||
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
|
||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
||||
<ConditionRows
|
||||
@@ -447,7 +449,7 @@ function ConditionFilterBar({
|
||||
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={cn(CONDITION_CONTENT_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<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">
|
||||
@@ -478,7 +480,7 @@ function ConditionFilterBar({
|
||||
<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"
|
||||
className="surface-control mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 transition-[background-color,transform] hover:bg-blue-50 active:scale-95"
|
||||
>
|
||||
<XCircle size={12} aria-hidden="true" />
|
||||
重置筛选
|
||||
@@ -515,7 +517,7 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
<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",
|
||||
"surface-reading group flex h-9 w-full min-w-0 items-center gap-2 border border-slate-200/70 px-2 text-left text-xs outline-none transition-colors hover:bg-blue-50 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 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
|
||||
"rounded-xl"
|
||||
)}
|
||||
>
|
||||
@@ -535,7 +537,7 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
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"
|
||||
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
|
||||
{options.map((option) => (
|
||||
@@ -543,12 +545,12 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
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",
|
||||
"my-0.5 min-h-9 gap-2 border border-transparent bg-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50 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">
|
||||
<span className="surface-control shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
|
||||
{option.count}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
@@ -562,7 +564,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-[var(--glass-readable)] px-4 py-5 text-center">
|
||||
<div className="surface-reading flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200/80 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>
|
||||
@@ -587,7 +589,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-transparent bg-[var(--glass-menu)] px-2 py-1.5">
|
||||
<span className="surface-reading min-w-0 rounded-xl border border-slate-200/70 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" />
|
||||
@@ -607,13 +609,12 @@ function ConditionDetailSkeleton() {
|
||||
<DetailScroll>
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-[420px] overflow-hidden border border-slate-200/60 bg-white/30 px-4 py-3",
|
||||
"surface-reading relative min-h-[420px] overflow-hidden border border-slate-200/70 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">
|
||||
@@ -628,7 +629,7 @@ function ConditionDetailSkeleton() {
|
||||
</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">
|
||||
<div key={index} className="surface-reading rounded-xl border border-slate-200/70 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" />
|
||||
@@ -706,8 +707,8 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
||||
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"
|
||||
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-none transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
|
||||
selected ? "surface-reading border-blue-100 text-blue-900" : "border-transparent hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
|
||||
@@ -735,10 +736,10 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
||||
</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",
|
||||
"min-w-0 px-2 py-1.5 transition-colors",
|
||||
selected
|
||||
? "border-blue-300 bg-white text-blue-900 shadow-sm shadow-blue-600/10 ring-1 ring-blue-300/40"
|
||||
: "border-transparent bg-[var(--glass-menu)] text-slate-700 group-hover:border-blue-100 group-hover:bg-white"
|
||||
? "text-blue-900"
|
||||
: "text-slate-700"
|
||||
)}
|
||||
>
|
||||
<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,
|
||||
"glass-menu border p-2 text-slate-900"
|
||||
"acrylic-panel border p-2 text-slate-900"
|
||||
);
|
||||
const menuReadableRowClassName =
|
||||
"bg-[var(--glass-readable)] focus:bg-blue-50/95 focus:text-slate-950";
|
||||
"surface-well focus:bg-blue-50 focus:text-slate-950";
|
||||
const headerControlButtonClassName =
|
||||
"inline-flex h-8 items-center gap-2 rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
||||
const headerControlIconClassName =
|
||||
@@ -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-[var(--glass-readable)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
|
||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
|
||||
@@ -315,7 +315,7 @@ function AlertQueueSummary({
|
||||
const hasAlerts = count > 0;
|
||||
|
||||
return (
|
||||
<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={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
@@ -423,7 +423,7 @@ function MenuHeader({
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<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={cn("surface-control flex items-center justify-between gap-3 border px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function WorkbenchTopBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<header className="acrylic-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" />
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
simulationAnnotationLayers,
|
||||
simulationSources
|
||||
} from "../map/annotation-layers";
|
||||
import {
|
||||
fitNetworkBounds,
|
||||
getResponsiveWorkbenchPadding
|
||||
} from "../map/camera";
|
||||
import { fitNetworkBounds } from "../map/camera";
|
||||
import {
|
||||
waterNetworkBusinessLayers,
|
||||
waterNetworkHitLayers,
|
||||
@@ -98,7 +95,6 @@ export function useWorkbenchMap({
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const impactVisibleRef = useRef(impactVisible);
|
||||
const layoutOpenRef = useRef({ leftPanelOpen, rightPanelOpen });
|
||||
const appliedLayoutPaddingRef = useRef<{ leftPanelOpen: boolean; rightPanelOpen: boolean } | null>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||
@@ -223,30 +219,6 @@ export function useWorkbenchMap({
|
||||
selectedFeature
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousLayout = appliedLayoutPaddingRef.current;
|
||||
const currentLayout = { leftPanelOpen, rightPanelOpen };
|
||||
appliedLayoutPaddingRef.current = currentLayout;
|
||||
|
||||
if (
|
||||
!previousLayout ||
|
||||
(previousLayout.leftPanelOpen === currentLayout.leftPanelOpen &&
|
||||
previousLayout.rightPanelOpen === currentLayout.rightPanelOpen)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.easeTo({
|
||||
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
|
||||
duration: 350
|
||||
});
|
||||
}, [leftPanelOpen, rightPanelOpen, mapReady]);
|
||||
|
||||
useSimulationLayerVisibility({
|
||||
mapRef,
|
||||
mapReady,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getWorkbenchBasemapTone } from "./workbench-layout";
|
||||
|
||||
describe("workbench basemap surface tone", () => {
|
||||
it.each([
|
||||
["mapbox-light", "light"],
|
||||
["mapbox-satellite", "satellite"],
|
||||
["none", "none"],
|
||||
["unknown", "none"]
|
||||
])("maps %s to %s", (baseLayerId, expectedTone) => {
|
||||
expect(getWorkbenchBasemapTone(baseLayerId)).toBe(expectedTone);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
export const WORKBENCH_LAYOUT = {
|
||||
headerHeight: 56,
|
||||
desktopMinWidth: 1024,
|
||||
persistentConditionMinWidth: 1280,
|
||||
wideMinWidth: 1536,
|
||||
collapsedAgentWidth: 136,
|
||||
mapEdgeGap: 24,
|
||||
desktop: {
|
||||
agentWidth: 460,
|
||||
conditionWidth: 432,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 420
|
||||
},
|
||||
wide: {
|
||||
agentWidth: 500,
|
||||
conditionWidth: 432,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 460
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type WorkbenchPanelState = {
|
||||
agentOpen: boolean;
|
||||
conditionOpen: boolean;
|
||||
};
|
||||
|
||||
export type WorkbenchBasemapTone = "light" | "satellite" | "none";
|
||||
|
||||
export type WorkbenchViewportLayout = {
|
||||
agentWidth: number;
|
||||
conditionWidth: number;
|
||||
toolbarWidth: number;
|
||||
tickerWidth: number;
|
||||
};
|
||||
|
||||
export function getWorkbenchViewportLayout(viewportWidth: number): WorkbenchViewportLayout {
|
||||
return viewportWidth >= WORKBENCH_LAYOUT.wideMinWidth
|
||||
? WORKBENCH_LAYOUT.wide
|
||||
: WORKBENCH_LAYOUT.desktop;
|
||||
}
|
||||
|
||||
export function getWorkbenchBasemapTone(activeBaseLayerId: string): WorkbenchBasemapTone {
|
||||
if (activeBaseLayerId === "mapbox-light") {
|
||||
return "light";
|
||||
}
|
||||
|
||||
if (activeBaseLayerId === "mapbox-satellite") {
|
||||
return "satellite";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function getWorkbenchCameraPadding(viewportWidth: number, panels: WorkbenchPanelState) {
|
||||
if (viewportWidth < WORKBENCH_LAYOUT.desktopMinWidth) {
|
||||
return { top: 72, right: 24, bottom: 72, left: 24 };
|
||||
}
|
||||
|
||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||
const agentWidth = panels.agentOpen ? layout.agentWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const conditionWidth = panels.conditionOpen ? layout.conditionWidth : 0;
|
||||
|
||||
return {
|
||||
top: WORKBENCH_LAYOUT.headerHeight + 16,
|
||||
right: WORKBENCH_LAYOUT.mapEdgeGap + layout.toolbarWidth + conditionWidth,
|
||||
bottom: 32,
|
||||
left: WORKBENCH_LAYOUT.mapEdgeGap + agentWidth
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkbenchLayoutCssVariables() {
|
||||
return {
|
||||
"--workbench-agent-width": `${WORKBENCH_LAYOUT.desktop.agentWidth}px`,
|
||||
"--workbench-agent-width-wide": `${WORKBENCH_LAYOUT.wide.agentWidth}px`,
|
||||
"--workbench-condition-width": `${WORKBENCH_LAYOUT.desktop.conditionWidth}px`,
|
||||
"--workbench-condition-width-wide": `${WORKBENCH_LAYOUT.wide.conditionWidth}px`,
|
||||
"--workbench-toolbar-width": `${WORKBENCH_LAYOUT.desktop.toolbarWidth}px`,
|
||||
"--workbench-ticker-width": `${WORKBENCH_LAYOUT.desktop.tickerWidth}px`,
|
||||
"--workbench-ticker-width-wide": `${WORKBENCH_LAYOUT.wide.tickerWidth}px`
|
||||
} as const;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps, type CSSProperties } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
@@ -42,6 +42,8 @@ import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workben
|
||||
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
||||
import { toMapFeatureReference } from "./hooks/use-map-interactions";
|
||||
import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement";
|
||||
import { getWorkbenchBasemapTone, getWorkbenchLayoutCssVariables } from "./layout/workbench-layout";
|
||||
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
||||
import { exportMapViewImage } from "./map/export-view";
|
||||
import {
|
||||
BASE_LAYER_IDS,
|
||||
@@ -58,6 +60,7 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
|
||||
|
||||
const HEADER_DATA_TIME_STEP_MINUTES = 5;
|
||||
const INITIAL_HEADER_DATA_TIME = "同步中";
|
||||
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
||||
|
||||
function formatHeaderDataTime(date: Date) {
|
||||
const snappedMinutes = Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
|
||||
@@ -88,7 +91,7 @@ export function MapWorkbenchPage() {
|
||||
const [impactVisible, setImpactVisible] = useState(false);
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(true);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||
@@ -98,6 +101,7 @@ export function MapWorkbenchPage() {
|
||||
requestId: number;
|
||||
} | null>(null);
|
||||
const [activeBaseLayerId, setActiveBaseLayerId] = useState(hasMapboxToken ? "mapbox-light" : "none");
|
||||
const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId);
|
||||
const [activeScenarioId, setActiveScenarioId] = useState(WORKBENCH_SCENARIOS[0]?.id ?? "");
|
||||
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
|
||||
const [activeMeasureUnitId, setActiveMeasureUnitId] = useState<WorkbenchMeasureUnit>("km");
|
||||
@@ -125,6 +129,7 @@ export function MapWorkbenchPage() {
|
||||
const handleChange = () => setIsLargeScreen(mediaQuery.matches);
|
||||
|
||||
handleChange();
|
||||
setConditionFeedVisible(window.innerWidth >= 1280);
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
@@ -172,8 +177,12 @@ export function MapWorkbenchPage() {
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [shouldShowConditionFeed]);
|
||||
|
||||
const leftPanelOpen = isLargeScreen;
|
||||
const rightPanelOpen = false;
|
||||
const agent = useWorkbenchAgent({
|
||||
onUiEnvelope: handleAgentUiEnvelope,
|
||||
onFrontendAction: handleFrontendAction
|
||||
});
|
||||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
||||
const rightPanelOpen = isLargeScreen && shouldShowConditionFeed;
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||
containerRef: mapContainerRef,
|
||||
@@ -184,10 +193,6 @@ export function MapWorkbenchPage() {
|
||||
selectedFeature: detailFeature
|
||||
});
|
||||
|
||||
const agent = useWorkbenchAgent({
|
||||
onUiEnvelope: handleAgentUiEnvelope,
|
||||
onFrontendAction: handleFrontendAction
|
||||
});
|
||||
const activeAgentUiResults = useMemo(
|
||||
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
|
||||
[agent.sessionId, agentUiResults]
|
||||
@@ -221,19 +226,17 @@ export function MapWorkbenchPage() {
|
||||
});
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||
const shouldShowTaskTicker = !agent.mobileOpen && taskTickerVisible && taskTickerAvailable;
|
||||
const shouldShowTaskTicker = !agent.mobileOpen && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
||||
const taskTickerEnterState = prefersReducedMotion
|
||||
? {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)"
|
||||
y: 0
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
stiffness: 420,
|
||||
@@ -246,14 +249,12 @@ export function MapWorkbenchPage() {
|
||||
opacity: 0,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: { duration: 0 }
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: -8,
|
||||
filter: "blur(4px)",
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: [0.5, 0, 0.2, 1] as const
|
||||
@@ -526,14 +527,14 @@ export function MapWorkbenchPage() {
|
||||
const map = mapRef.current;
|
||||
if (request.name === "zoom_to_map") {
|
||||
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const x = Number(request.params.x); const y = Number(request.params.y);
|
||||
const center: [number, number] = request.params.source_crs === "EPSG:4326" ? [x, y] : [x * 180 / 20037508.34, Math.atan(Math.exp(y * Math.PI / 20037508.34)) * 360 / Math.PI - 90]; const zoom = Number(request.params.zoom ?? 18); map.easeTo({ center, zoom, duration: Number(request.params.duration_ms ?? 500) }); return { center, zoom };
|
||||
const center: [number, number] = request.params.source_crs === "EPSG:4326" ? [x, y] : [x * 180 / 20037508.34, Math.atan(Math.exp(y * Math.PI / 20037508.34)) * 360 / Math.PI - 90]; const zoom = Number(request.params.zoom ?? 18); map.easeTo({ center, zoom, padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), duration: Number(request.params.duration_ms ?? 500) }); return { center, zoom };
|
||||
}
|
||||
if (request.name === "locate_features") {
|
||||
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const ids = request.params.ids as string[]; const featureType = String(request.params.feature_type);
|
||||
const response = await fetch("/api/agent-locate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, featureType }), signal }); const geojson = await response.json(); if (!response.ok) throw new Error(geojson.code ?? "WFS_FAILED");
|
||||
const features = geojson.features as Array<{ id?: string | number; properties?: Record<string, unknown>; geometry: { coordinates: unknown } }>; if (!features.length) throw new Error("FEATURE_NOT_FOUND");
|
||||
if (map.getSource("agent-locate")) (map.getSource("agent-locate") as unknown as { setData: (data: unknown) => void }).setData(geojson); else { map.addSource("agent-locate", { type: "geojson", data: geojson }); map.addLayer({ id: "agent-locate-lines", type: "line", source: "agent-locate", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": "#f97316", "line-width": 6 } }); map.addLayer({ id: "agent-locate-points", type: "circle", source: "agent-locate", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": "#f97316", "circle-radius": 8, "circle-stroke-color": "#fff", "circle-stroke-width": 2 } }); }
|
||||
const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 80, maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds };
|
||||
const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds };
|
||||
}
|
||||
const history = request.name === "view_history"; const itemCount = history ? (request.params.feature_infos as unknown[]).length : Array.isArray(request.params.device_ids) ? request.params.device_ids.length : 1; setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope: { kind: "registered_component", schemaVersion: "agent-ui@1", component: history ? "HistoryPanel" : "ScadaPanel", surface: "side_panel", props: request.params } }]); return { component: history ? "HistoryPanel" : "ScadaPanel", rendered: true, itemCount };
|
||||
}
|
||||
@@ -559,6 +560,7 @@ export function MapWorkbenchPage() {
|
||||
map.easeTo({
|
||||
center: trustedAction.center,
|
||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
|
||||
duration: 500
|
||||
});
|
||||
showMapNotice({
|
||||
@@ -699,7 +701,11 @@ export function MapWorkbenchPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900">
|
||||
<main
|
||||
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
|
||||
data-basemap-tone={basemapTone}
|
||||
style={WORKBENCH_LAYOUT_CSS_VARIABLES as CSSProperties}
|
||||
>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="map-grid"
|
||||
@@ -707,8 +713,6 @@ export function MapWorkbenchPage() {
|
||||
aria-label="排水管网地图"
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-white/25 via-white/5 to-slate-100/20" />
|
||||
|
||||
<WorkbenchTopBar
|
||||
dataTime={headerDataTime}
|
||||
modelName="DrainFlow 1.0.0"
|
||||
@@ -736,7 +740,7 @@ export function MapWorkbenchPage() {
|
||||
onExportConfig={handleExportConfig}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[460px] 2xl:w-[500px] lg:block">
|
||||
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] 2xl:w-[var(--workbench-agent-width-wide)] lg:block">
|
||||
{agent.panelOpen ? (
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
@@ -757,17 +761,13 @@ export function MapWorkbenchPage() {
|
||||
</div>
|
||||
|
||||
{conditionFeedMounted ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-[62px] top-24 z-20 hidden lg:block",
|
||||
shouldShowConditionFeed ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit"
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||
<ScheduledConditionFeed
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible={shouldShowConditionFeed}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={setConditionFeedExpanded}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
@@ -779,7 +779,7 @@ export function MapWorkbenchPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute right-[62px] top-24 z-20 hidden lg:block">
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
|
||||
@@ -821,7 +821,7 @@ export function MapWorkbenchPage() {
|
||||
{shouldShowTaskTicker ? (
|
||||
<motion.div
|
||||
key="agent-task-ticker-shell"
|
||||
className="fixed left-3 right-3 top-24 z-20 w-auto [translate:0] md:absolute md:left-1/2 md:right-auto md:w-[460px] md:[translate:-50%_0]"
|
||||
className="fixed left-1/2 right-auto top-24 z-20 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 2xl:w-[var(--workbench-ticker-width-wide)]"
|
||||
style={{ transformOrigin: "top center" }}
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
@@ -829,15 +829,14 @@ export function MapWorkbenchPage() {
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: -8,
|
||||
filter: "blur(6px)"
|
||||
y: -8
|
||||
}
|
||||
}
|
||||
animate={taskTickerEnterState}
|
||||
exit={taskTickerExitState}
|
||||
>
|
||||
<AgentTaskTicker
|
||||
className="w-full"
|
||||
className="w-full max-w-full"
|
||||
conditions={runningTickerConditions}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fitNetworkBounds } from "./camera";
|
||||
import {
|
||||
fitNetworkBounds,
|
||||
getResponsiveWorkbenchPadding,
|
||||
getWorkbenchPadding
|
||||
} from "./camera";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
function createMap(width = 1280, height = 720) {
|
||||
const fitBounds = vi.fn();
|
||||
const map = {
|
||||
fitBounds,
|
||||
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height })
|
||||
} as unknown as MapLibreMap;
|
||||
|
||||
return { fitBounds, map };
|
||||
}
|
||||
|
||||
describe("workbench camera padding", () => {
|
||||
it.each([
|
||||
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 160 } },
|
||||
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 484 } },
|
||||
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 160 } },
|
||||
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 484 } }
|
||||
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
|
||||
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("caps combined padding on constrained desktop widths", () => {
|
||||
const { map } = createMap(1024, 720);
|
||||
const padding = getResponsiveWorkbenchPadding(map, true, true);
|
||||
|
||||
expect(padding.left + padding.right).toBeLessThanOrEqual(Math.floor(1024 * 0.58) + 1);
|
||||
expect(padding.top + padding.bottom).toBeLessThanOrEqual(Math.floor(720 * 0.58) + 1);
|
||||
expect(padding.left).toBeGreaterThan(padding.top);
|
||||
expect(padding.right).toBeGreaterThan(padding.top);
|
||||
});
|
||||
|
||||
it("uses mobile-safe padding below the dock breakpoint", () => {
|
||||
const { map } = createMap(375, 667);
|
||||
|
||||
expect(getResponsiveWorkbenchPadding(map, true, true)).toEqual({
|
||||
top: 72,
|
||||
right: 24,
|
||||
bottom: 72,
|
||||
left: 24
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitNetworkBounds", () => {
|
||||
it("fits the global WebMercator bbox through MapLibre lng/lat bounds", () => {
|
||||
const fitBounds = vi.fn();
|
||||
const map = {
|
||||
fitBounds,
|
||||
getCanvas: () => ({
|
||||
clientWidth: 1280,
|
||||
clientHeight: 720,
|
||||
width: 1280,
|
||||
height: 720
|
||||
})
|
||||
} as unknown as MapLibreMap;
|
||||
it("preserves the original global extent for initial and home views", () => {
|
||||
const { fitBounds, map } = createMap();
|
||||
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import {
|
||||
getWorkbenchCameraPadding
|
||||
} from "../layout/workbench-layout";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
type LngLatBounds = [[number, number], [number, number]];
|
||||
@@ -11,14 +14,11 @@ export type NetworkViewParams = {
|
||||
const WEB_MERCATOR_RADIUS = 6378137;
|
||||
const GLOBAL_VIEW_MAX_ZOOM = 12;
|
||||
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 48, right: 48, bottom: 48, left: 48 };
|
||||
|
||||
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
|
||||
return {
|
||||
top: 88,
|
||||
left: leftPanelOpen ? 530 : 88,
|
||||
right: rightPanelOpen ? 430 : 120,
|
||||
bottom: 96
|
||||
};
|
||||
return getWorkbenchCameraPadding(1280, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponsiveWorkbenchPadding(
|
||||
@@ -26,7 +26,14 @@ export function getResponsiveWorkbenchPadding(
|
||||
leftPanelOpen: boolean,
|
||||
rightPanelOpen: boolean
|
||||
): PaddingOptions {
|
||||
return getResponsivePadding(map, getWorkbenchPadding(leftPanelOpen, rightPanelOpen));
|
||||
const width = getMapViewportSize(map).width;
|
||||
return getResponsivePadding(
|
||||
map,
|
||||
getWorkbenchCameraPadding(width, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function fitNetworkBounds(
|
||||
@@ -44,10 +51,8 @@ export function fitNetworkBounds(
|
||||
);
|
||||
}
|
||||
|
||||
function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
|
||||
const canvas = map.getCanvas();
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
|
||||
const { width, height } = getMapViewportSize(map);
|
||||
const horizontalPadding = padding.left + padding.right;
|
||||
const verticalPadding = padding.top + padding.bottom;
|
||||
const maxHorizontalPadding = width * 0.58;
|
||||
@@ -68,6 +73,14 @@ function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): Paddin
|
||||
};
|
||||
}
|
||||
|
||||
function getMapViewportSize(map: MapLibreMap) {
|
||||
const canvas = map.getCanvas();
|
||||
return {
|
||||
width: canvas.clientWidth || canvas.width,
|
||||
height: canvas.clientHeight || canvas.height
|
||||
};
|
||||
}
|
||||
|
||||
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
|
||||
const [minX, minY, maxX, maxY] = bbox3857;
|
||||
|
||||
|
||||
@@ -60,4 +60,26 @@ describe("drainage feature adapter", () => {
|
||||
"Unsupported water network source layer: other"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the promoted SCADA point id for feature-state interactions", () => {
|
||||
const detail = toDetailFeature({
|
||||
id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00",
|
||||
sourceLayer: SOURCE_LAYERS.scada,
|
||||
properties: {
|
||||
id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2",
|
||||
point_id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00",
|
||||
point_external_id: "36455",
|
||||
point_name: "DY22东市南街环城南路西段交叉口",
|
||||
device_external_id: "26825171",
|
||||
device_type_name: "2代水质仪"
|
||||
}
|
||||
} as unknown as MapGeoJSONFeature);
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00",
|
||||
layer: "scada",
|
||||
title: "DY22东市南街环城南路西段交叉口",
|
||||
subtitle: "2代水质仪 · 设备 26825171"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import { formatValue } from "../utils/format-value";
|
||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
||||
|
||||
export function getFeatureId(feature: MapGeoJSONFeature) {
|
||||
const raw = feature.properties?.id ?? feature.id;
|
||||
const layer = getWaterNetworkSourceId(feature);
|
||||
const raw = layer === "scada"
|
||||
? feature.id ?? feature.properties?.id ?? feature.properties?.point_id ?? feature.properties?.point_external_id
|
||||
: feature.id ?? feature.properties?.id;
|
||||
|
||||
return raw === undefined || raw === null ? "" : String(raw);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
scadaFallbackLayers,
|
||||
scadaLayers
|
||||
} from "./scada";
|
||||
import { waterNetworkLayers } from "./layers";
|
||||
|
||||
describe("SCADA map presentation", () => {
|
||||
it("maps the three authoritative device types and falls back to unknown", () => {
|
||||
@@ -26,20 +27,29 @@ 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", () => {
|
||||
it("keeps the icon and selected ring consistent with junction selection", () => {
|
||||
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");
|
||||
const junctionOuterLayer = waterNetworkLayers.find((layer) => layer.id === "junctions-selected-outer");
|
||||
const junctionSelectedLayer = waterNetworkLayers.find((layer) => layer.id === "junctions-selected");
|
||||
const junctionOuterPaint = junctionOuterLayer?.paint as Record<string, unknown> | undefined;
|
||||
const junctionSelectedPaint = junctionSelectedLayer?.paint as Record<string, unknown> | undefined;
|
||||
|
||||
expect(symbolLayer).toMatchObject({ type: "symbol", layout: { "icon-size": 0.75 } });
|
||||
expect(outerLayer).toMatchObject({
|
||||
type: "circle",
|
||||
paint: {
|
||||
"circle-radius": 12.5,
|
||||
"circle-stroke-width": 3
|
||||
"circle-stroke-width": junctionOuterPaint?.["circle-stroke-width"]
|
||||
}
|
||||
});
|
||||
expect(selectedLayer).toMatchObject({
|
||||
type: "circle",
|
||||
paint: {
|
||||
"circle-stroke-width": junctionSelectedPaint?.["circle-stroke-width"]
|
||||
}
|
||||
});
|
||||
expect(selectedLayer).toMatchObject({ type: "circle", paint: { "circle-stroke-width": 2 } });
|
||||
});
|
||||
|
||||
it("uses MapLibre-compatible PNG assets", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
|
||||
export const SCADA_SOURCE_ID = "scada";
|
||||
export const SCADA_SOURCE_LAYER = "scada_points";
|
||||
@@ -39,6 +40,8 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
|
||||
|
||||
const SCADA_ICON_SIZE = 0.75;
|
||||
const SCADA_SELECTED_RADIUS = 12.5;
|
||||
const SCADA_SELECTED_OUTER_STROKE_WIDTH = 6;
|
||||
const SCADA_SELECTED_STROKE_WIDTH = 3;
|
||||
|
||||
type Layer = NonNullable<StyleSpecification["layers"]>[number];
|
||||
|
||||
@@ -59,8 +62,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": 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-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": SCADA_SELECTED_OUTER_STROKE_WIDTH, "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": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": SCADA_SELECTED_STROKE_WIDTH, "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 } }
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { RasterLayerSpecification } from "maplibre-gl";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBaseStyle, createWaterNetworkSources, SOURCE_LAYERS } from "./sources";
|
||||
|
||||
describe("createWaterNetworkSources", () => {
|
||||
it("loads SCADA tiles from the lingang:scada_points GeoServer layer", () => {
|
||||
const scada = createWaterNetworkSources().scada;
|
||||
|
||||
expect(SOURCE_LAYERS.scada).toBe("scada_points");
|
||||
expect(scada.tiles).toEqual([
|
||||
expect.stringContaining("/lingang:scada_points/point/WebMercatorQuad/")
|
||||
]);
|
||||
expect(scada.promoteId).toBe("point_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBaseStyle", () => {
|
||||
it("calibrates light and satellite rasters for solid workbench surfaces", () => {
|
||||
const style = createBaseStyle("test-token");
|
||||
const light = style.layers.find((layer) => layer.id === "mapbox-light") as RasterLayerSpecification;
|
||||
const satellite = style.layers.find((layer) => layer.id === "mapbox-satellite") as RasterLayerSpecification;
|
||||
|
||||
expect(light.paint).toMatchObject({
|
||||
"raster-opacity": 0.96,
|
||||
"raster-saturation": -0.1,
|
||||
"raster-contrast": 0.06
|
||||
});
|
||||
expect(satellite.paint).toMatchObject({
|
||||
"raster-opacity": 1,
|
||||
"raster-saturation": -0.24,
|
||||
"raster-contrast": -0.06
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,18 +60,24 @@ export function createWaterNetworkSources() {
|
||||
"line",
|
||||
[121.73585149761436, 30.861759757577705, 121.9498481645973, 30.983213202338085]
|
||||
),
|
||||
scada: createLingangVectorSource(SOURCE_LAYERS.scada, "point", [121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165])
|
||||
scada: createLingangVectorSource(
|
||||
SOURCE_LAYERS.scada,
|
||||
"point",
|
||||
[121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165],
|
||||
"point_id"
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function createLingangVectorSource(
|
||||
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS],
|
||||
style: "line" | "point",
|
||||
bounds: [number, number, number, number]
|
||||
bounds: [number, number, number, number],
|
||||
promoteId = "id"
|
||||
): VectorSourceSpecification {
|
||||
return {
|
||||
type: "vector",
|
||||
promoteId: "id",
|
||||
promoteId,
|
||||
tiles: [
|
||||
`${GEOSERVER_WMTS_ROOT}/lingang:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||
],
|
||||
@@ -118,9 +124,9 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
type: "raster",
|
||||
source: "mapbox-light",
|
||||
paint: {
|
||||
"raster-opacity": 0.82,
|
||||
"raster-saturation": -0.22,
|
||||
"raster-contrast": 0.04
|
||||
"raster-opacity": 0.96,
|
||||
"raster-saturation": -0.1,
|
||||
"raster-contrast": 0.06
|
||||
}
|
||||
});
|
||||
layers.push({
|
||||
@@ -131,9 +137,9 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
visibility: "none"
|
||||
},
|
||||
paint: {
|
||||
"raster-opacity": 0.86,
|
||||
"raster-saturation": -0.16,
|
||||
"raster-contrast": 0.02
|
||||
"raster-opacity": 1,
|
||||
"raster-saturation": -0.24,
|
||||
"raster-contrast": -0.06
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,6 +69,34 @@ describe("feature panel properties", () => {
|
||||
expect(model.attributes.map((entry) => entry.key)).toEqual(["invert_elevation", "max_depth"]);
|
||||
});
|
||||
|
||||
it("shows SCADA identity, device, state, and sync fields", () => {
|
||||
const model = getFeaturePanelModel("scada", {
|
||||
id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2",
|
||||
point_id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00",
|
||||
point_external_id: "36455",
|
||||
point_name: "DY22东市南街环城南路西段交叉口",
|
||||
device_external_id: "26825171",
|
||||
active: true,
|
||||
device_type_id: 72,
|
||||
device_type_name: "2代水质仪",
|
||||
junction_id: "31011502010001809",
|
||||
synced_at: "Jul 15, 2026, 7:38:45 AM"
|
||||
});
|
||||
|
||||
expect(model.id).toBe("36455");
|
||||
expect(model.badge).toEqual({ label: "在线", tone: "active" });
|
||||
expect(model.attributes.map((entry) => entry.key)).toEqual([
|
||||
"id",
|
||||
"point_id",
|
||||
"point_name",
|
||||
"device_external_id",
|
||||
"device_type_id",
|
||||
"device_type_name",
|
||||
"junction_id",
|
||||
"synced_at"
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the internal SCADA clustering size from user-facing properties", () => {
|
||||
const entries = getLocalizedFeatureProperties({
|
||||
point_name: "THC雷达液位计(MQTT)",
|
||||
|
||||
@@ -21,6 +21,7 @@ export type FeaturePanelModel = {
|
||||
};
|
||||
|
||||
const PROPERTY_LABELS: Record<string, string> = {
|
||||
fid: "GeoServer 要素 ID",
|
||||
id: "编号",
|
||||
gid: "要素编号",
|
||||
name: "名称",
|
||||
@@ -74,6 +75,7 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const PROPERTY_ORDER: Record<string, number> = {
|
||||
fid: 9,
|
||||
id: 10,
|
||||
gid: 11,
|
||||
name: 12,
|
||||
@@ -134,12 +136,13 @@ const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[
|
||||
junctions: ["id", "invert_elevation", "max_depth"],
|
||||
orifices: ["id", "discharge_coeff", "gated", "shape", "geom1", "geom2"],
|
||||
outfalls: ["id", "invert_elevation", "outfall_type"],
|
||||
scada: ["point_external_id", "device_type_name", "device_external_id", "junction_id", "synced_at"]
|
||||
scada: ["id", "point_id", "point_external_id", "point_name", "device_external_id", "active", "device_type_id", "device_type_name", "junction_id", "synced_at"]
|
||||
};
|
||||
|
||||
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
||||
pumps: "status",
|
||||
orifices: "gated"
|
||||
orifices: "gated",
|
||||
scada: "active"
|
||||
};
|
||||
|
||||
export function getLocalizedFeatureProperties(properties: Record<string, unknown>): LocalizedFeatureProperty[] {
|
||||
|
||||
Reference in New Issue
Block a user