feat(workbench): refine operational interface

This commit is contained in:
2026-07-14 16:22:21 +08:00
parent ed60a13f12
commit 2b768c2c06
25 changed files with 512 additions and 153 deletions
+91 -16
View File
@@ -91,13 +91,13 @@ Recommended canvas colors:
| `canvas-map` | `#eaf1f8` | Fallback map background |
| `canvas-map-grid` | `rgba(37, 99, 235, 0.06)` | Subtle engineering grid |
| `canvas-page` | `#f8fafc` | Non-map support pages |
| `canvas-panel` | `rgba(255, 255, 255, 0.76)` | Default floating panel |
| `canvas-panel-strong` | `rgba(255, 255, 255, 0.92)` | Dense text or form surface |
| `canvas-panel-soft` | `rgba(255, 255, 255, 0.58)` | Low-density spatial overlay |
| `surface-overlay` | `rgba(255, 255, 255, 0.72-0.86)` | Floating menu or compact popover shell |
| `surface-readable` | `rgba(255, 255, 255, 0.90-0.96)` | Readable rows, menu headers, and content blocks inside translucent shells |
| `canvas-panel` | `rgba(255, 255, 255, 0.94)` | Default information-bearing floating panel |
| `canvas-panel-strong` | `rgba(255, 255, 255, 0.98)` | Dense text or form surface |
| `canvas-panel-soft` | `rgba(255, 255, 255, 0.84)` | Low-density spatial overlay without critical text |
| `surface-overlay` | `rgba(255, 255, 255, 0.90-0.94)` | Floating menu or compact panel shell |
| `surface-readable` | `rgba(255, 255, 255, 0.94)` | Readable rows, menu headers, property panels and content blocks |
| `surface-map-control` | `rgba(255, 255, 255, 0.92)` | Always-visible and persistent map instruments: toolbar, zoom, scaleline, Agent shell, scheduled-condition shell |
| `surface-tool-panel` | `rgba(255, 255, 255, 0.78)` | Toolbar function panel outer shell |
| `surface-tool-panel` | `rgba(255, 255, 255, 0.90)` | Toolbar function panel outer shell |
### Color System
@@ -203,8 +203,8 @@ Elevation should communicate interaction depth over the map.
|---|---|---|
| `map` | No shadow | Basemap and business layers |
| `control` | White 90-96%, soft border, small shadow | Zoom, basemap, tool buttons, persistent command shells |
| `panel` | White 58-82%, backdrop blur, blue-gray shadow | Toolbar function panels, temporary result panels, layer panels |
| `focus` | White 92-98%, stronger border and shadow | Popover, confirmation, detailed forms |
| `panel` | White 90-94%, backdrop blur, blue-gray shadow | Toolbar function panels, temporary result panels, layer panels |
| `focus` | White 94%, 24px blur, stronger edge and shadow | Popover, confirmation, detailed forms |
| `modal` | Scrim plus focused surface | Destructive or irreversible decisions |
Recommended shadows:
@@ -217,14 +217,89 @@ Use `backdrop-filter: blur(20px)` only where the panel remains readable. If the
#### Transparency Layering
Use consistent opacity tiers for floating UI over the map:
Transparency is a material system, not a collection of arbitrary opacity utilities. Components must use one of the named material tokens below instead of choosing independent values such as `bg-white/80`, `bg-white/90`, or `bg-white/95`.
- Outer shells such as dropdown menus, compact popovers, and temporary command surfaces should use `surface-overlay` around `rgba(255, 255, 255, 0.72-0.86)`.
- Persistent command instruments that remain visible while the operator works, such as the Agent shell and scheduled-condition shell, should use `surface-map-control` at `rgba(255, 255, 255, 0.92)` rather than a low-opacity panel shell.
- Inner readable content such as menu headers, selectable rows, status groups, and form blocks should use `surface-readable` around `rgba(255, 255, 255, 0.90-0.96)`.
- Dense text, destructive confirmations, and detailed forms may use `canvas-panel-strong` around `rgba(255, 255, 255, 0.92)` when legibility matters more than map transparency.
- Do not put long text directly on a low-opacity shell. Give it a local readable surface, or increase the shell opacity.
- Keep readable content at least as opaque as its surrounding shell. Labels, values, and commands should sit on the more readable layer, not directly on a lower-opacity shell.
#### Transparency Material Tokens
| Token | Background | Blur | Shadow | Intended use |
|---|---|---:|---|---|
| `glass-air` | `rgba(255, 255, 255, 0.84)` | `16px` | `0 10px 30px rgba(15, 23, 42, 0.10)` | Sparse map labels, scale line and low-density overlays without critical text |
| `glass-navigation` | `rgba(255, 255, 255, 0.90)` | `24px` | `0 8px 28px rgba(15, 23, 42, 0.10)` | Top bar and other broad, low-density navigation surfaces |
| `glass-panel` | `rgba(255, 255, 255, 0.90)` | `20px` | `0 24px 70px rgba(30, 64, 115, 0.18)` | Layer, base-map, measure, drawing and other temporary tool panels |
| `glass-readable` | `rgba(255, 255, 255, 0.94)` | `20px` | `0 24px 70px rgba(30, 64, 115, 0.20)` | Feature side panels, SCADA details, menus, compact results and information surfaces |
| `glass-control` | `rgba(255, 255, 255, 0.92)` | `16px` | `0 14px 34px rgba(15, 23, 42, 0.18)` | Persistent map instruments, Agent shell and scheduled-condition shell |
| `glass-menu` | `rgba(255, 255, 255, 0.94)` | `20px` | `0 18px 48px rgba(15, 23, 42, 0.16)` | Dropdown menus, notices, command palettes and transient choices |
| `glass-focus` | `rgba(255, 255, 255, 0.94)` | `24px` | `0 30px 90px rgba(15, 23, 42, 0.26)` | Centered feature popovers, SCADA popovers and focused confirmations |
| `surface-solid` | `rgba(255, 255, 255, 0.98)` | `0` | `0 20px 60px rgba(15, 23, 42, 0.18)` | Forms, reports, long text, destructive confirmations and accessibility fallback |
| `scrim-soft` | `rgba(15, 23, 42, 0.20)` | `0` | none | Non-destructive drawers and contextual focus |
| `scrim-focus` | `rgba(15, 23, 42, 0.48)` | `0` | none | Modal decisions, destructive or irreversible actions |
Supporting edge and highlight tokens:
| Token | Value | Use |
|---|---:|---|
| `glass-edge` | `rgba(255, 255, 255, 0.58)` | Outer glass edge, preferably an inset ring or layered shadow rather than a solid border |
| `glass-edge-strong` | `rgba(255, 255, 255, 0.76)` | Top-facing edge on menus and focused popovers |
| `glass-divider` | `rgba(100, 116, 139, 0.16)` | Internal section and row dividers |
| `glass-highlight` | `rgba(255, 255, 255, 0.46)` | Subtle hover highlight on a translucent surface |
| `glass-shadow-color` | `rgba(30, 64, 115, 0.18)` | Map-facing panel shadow tint |
#### Colored Glass Tokens
Colored glass communicates operational meaning. It must not be used as decoration.
| Token | Background | Edge | Use |
|---|---:|---:|---|
| `glass-info` | `rgba(239, 246, 255, 0.78)` | `rgba(147, 197, 253, 0.42)` | Selection, informational state, radar-level devices |
| `glass-normal` | `rgba(240, 253, 250, 0.78)` | `rgba(94, 234, 212, 0.40)` | Healthy state, water-quality devices |
| `glass-warning` | `rgba(255, 247, 237, 0.82)` | `rgba(253, 186, 116, 0.46)` | Warning, ultrasonic-flow devices and attention state |
| `glass-danger` | `rgba(254, 242, 242, 0.86)` | `rgba(252, 165, 165, 0.48)` | Incident, validation failure and destructive warning |
| `glass-agent` | `rgba(245, 243, 255, 0.82)` | `rgba(196, 181, 253, 0.42)` | Agent inference only |
Text on colored glass must use a darker shade of the same hue. Do not place neutral gray text on a colored surface.
#### Component Material Mapping
| Component category | Material token | Notes |
|---|---|---|
| Workbench top bar | `glass-navigation` | Preserve map context across the full width; active items use a semantic colored glass token |
| Zoom, scale line and compact toolbar | `glass-control` | Persistent controls need stable definition over every basemap and use `glass-edge` rather than an opaque border |
| Layer, base-map, measure and drawing panels | `glass-panel` | Use one translucent outer shell; repeated controls inside should be flat or lightly tinted |
| Agent command shell | `glass-control` | Conversation content may use `surface-solid` only for long generated text or form controls |
| Scheduled-condition task shell | `glass-control` | Remove stacked white layers; use dividers and state tint to create hierarchy |
| Feature and SCADA property popovers | `glass-focus` | Reuse the proven high-readability shell and distinguish focus through blur and elevation rather than a whiter background |
| Feature insight side panel | `glass-readable` | Dense values remain on the shared glass surface; avoid card-per-field nesting |
| Dropdown menus and notices | `glass-menu` | More opaque because the surface is temporary and action-dense |
| Reports, detailed forms and text-heavy results | `surface-solid` | Do not use glass for long reading or data entry |
| Status pills and device-type labels | semantic colored glass | Use `glass-normal`, `glass-info`, `glass-warning`, `glass-danger`, or `glass-agent` |
| Dialog backdrop | `scrim-soft` or `scrim-focus` | Choose by consequence, not by dialog size |
#### Composition Rules
- Use at most two translucent layers over the map at the same location. Three stacked white-alpha surfaces become visually opaque and waste GPU work.
- A glass shell should usually contain flat sections separated by `glass-divider`. Do not place an opaque white card around every row.
- Long text, editable fields, tables with many columns and destructive confirmations use `surface-solid` even when their parent shell is translucent.
- Readable content must never be less opaque than its surrounding shell.
- When map detail beneath the panel is too noisy, move one step toward `surface-solid`. Do not keep increasing blur.
- Use colored glass only for status, selection, device type or Agent inference. Neutral structure remains white glass.
- Semantic colored glass must sit inside a `glass-readable`, `glass-control`, `glass-menu`, or `glass-focus` neutral base. Do not place a 70-82% colored surface directly over the map.
- High-readability glass is the global default for information-bearing UI. Any surface containing operational text must be at least 90% opaque; properties, results, menus and multi-line content use 94%.
- Acrylic-style saturation and edge highlights are limited to broad navigation and transient outer shells. Do not add texture, tint or refractive treatment to property tables, forms, reports or long text.
- Prefer layered `box-shadow` or an inset ring for glass edges. A solid high-contrast border breaks the depth illusion.
- Hover feedback may change highlight and edge opacity. Do not animate `backdrop-filter` or background blur.
#### CSS Token Contract
`app/globals.css` is the single source of truth for material token values. Component code should consume semantic variables or shared utility classes rather than reproducing raw RGBA values. This guide documents intent and mapping only, so token values do not drift between CSS and documentation.
#### Performance And Accessibility Fallback
- Under `prefers-reduced-transparency: reduce`, replace every glass material with `surface-solid` and remove `backdrop-filter`.
- When `backdrop-filter` is unsupported, use `rgba(255, 255, 255, 0.96)` with the same shadow and radius.
- On low-power or touch-first devices, prefer `glass-readable` or `surface-solid` over a larger blur radius.
- On coarse-pointer devices, remove backdrop blur and use a 0.96-0.98 white surface while preserving semantic state colors.
- Never apply backdrop blur separately to every list row. Blur belongs to the shared outer surface.
- Maintain WCAG AA contrast for all body text and controls against the composited result, not only against the declared RGBA value.
### Motion
@@ -481,7 +556,7 @@ Layer visibility and basemap selection belong together in the Layers tool panel:
- Business layer visibility appears first.
- Basemap selection appears below business layers.
- Toolbar function panels use `surface-tool-panel` (`rgba(255, 255, 255, 0.78)`) for the outer shell.
- Toolbar function panels use `surface-tool-panel` (`rgba(255, 255, 255, 0.90)`) for the outer shell.
- Internal list rows, panel headers, swatches, result blocks, and action rows use `surface-readable`, around `0.94-0.96`, for readability.
- MVT sources should keep `bounds` where available to avoid invalid tile requests. This must not be confused with map view extent; the map view itself should remain unrestricted unless a workflow explicitly requires a view constraint.
+213 -7
View File
@@ -7,8 +7,8 @@
background: #eaf1f8;
--canvas-map: #eaf1f8;
--canvas-map-grid: rgba(37, 99, 235, 0.06);
--canvas-panel: rgba(255, 255, 255, 0.76);
--canvas-panel-strong: rgba(255, 255, 255, 0.92);
--canvas-panel: rgba(255, 255, 255, 0.94);
--canvas-panel-strong: rgba(255, 255, 255, 0.98);
--action-blue: #2563eb;
--action-blue-hover: #1d4ed8;
--action-blue-soft: #dbeafe;
@@ -20,6 +20,30 @@
--shadow-control: 0 14px 34px rgba(15, 23, 42, 0.18);
--shadow-panel: 0 24px 70px rgba(30, 64, 115, 0.2);
--shadow-focus: 0 30px 90px rgba(15, 23, 42, 0.26);
--glass-air: rgba(255, 255, 255, 0.84);
--glass-navigation: rgba(255, 255, 255, 0.9);
--glass-panel: rgba(255, 255, 255, 0.9);
--glass-readable: rgba(255, 255, 255, 0.94);
--glass-control: rgba(255, 255, 255, 0.92);
--glass-menu: rgba(255, 255, 255, 0.94);
--glass-focus: rgba(255, 255, 255, 0.94);
--surface-solid: rgba(255, 255, 255, 0.98);
--glass-edge: rgba(255, 255, 255, 0.58);
--glass-edge-strong: rgba(255, 255, 255, 0.76);
--glass-divider: rgba(100, 116, 139, 0.16);
--glass-highlight: rgba(255, 255, 255, 0.46);
--glass-info: rgba(239, 246, 255, 0.78);
--glass-normal: rgba(240, 253, 250, 0.78);
--glass-warning: rgba(255, 247, 237, 0.82);
--glass-danger: rgba(254, 242, 242, 0.86);
--glass-agent: rgba(245, 243, 255, 0.82);
--scrim-soft: rgba(15, 23, 42, 0.2);
--scrim-focus: rgba(15, 23, 42, 0.48);
--blur-air: 16px;
--blur-navigation: 24px;
--blur-control: 16px;
--blur-panel: 20px;
--blur-focus: 24px;
--background: 210 40% 98%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
@@ -84,14 +108,196 @@ input:focus-visible {
}
.command-panel {
border: 1px solid var(--border-soft);
background: var(--canvas-panel);
box-shadow: var(--shadow-panel);
backdrop-filter: blur(20px);
background: var(--glass-panel);
box-shadow:
inset 0 1px 0 var(--glass-edge),
var(--shadow-panel);
backdrop-filter: blur(var(--blur-panel)) saturate(1.08);
-webkit-backdrop-filter: blur(var(--blur-panel)) saturate(1.08);
}
.glass-navigation,
.glass-panel,
.glass-readable,
.glass-control,
.glass-menu,
.glass-focus,
.glass-info,
.glass-normal,
.glass-warning,
.glass-danger,
.glass-agent {
border-color: transparent;
background-clip: padding-box;
}
.glass-navigation {
background-color: var(--glass-navigation);
box-shadow:
inset 0 1px 0 var(--glass-edge),
0 8px 28px rgba(15, 23, 42, 0.1);
backdrop-filter: blur(var(--blur-navigation)) saturate(1.08);
-webkit-backdrop-filter: blur(var(--blur-navigation)) saturate(1.08);
}
.glass-panel {
background-color: var(--glass-panel);
box-shadow:
inset 0 1px 0 var(--glass-edge),
0 24px 70px rgba(30, 64, 115, 0.18);
backdrop-filter: blur(var(--blur-panel)) saturate(1.08);
-webkit-backdrop-filter: blur(var(--blur-panel)) saturate(1.08);
}
.glass-readable {
background-color: var(--glass-readable);
box-shadow:
inset 0 1px 0 var(--glass-edge-strong),
0 24px 70px rgba(30, 64, 115, 0.2);
backdrop-filter: blur(var(--blur-panel)) saturate(1.06);
-webkit-backdrop-filter: blur(var(--blur-panel)) saturate(1.06);
}
.glass-control {
background-color: var(--glass-control);
box-shadow:
inset 0 1px 0 var(--glass-edge-strong),
var(--shadow-control);
backdrop-filter: blur(var(--blur-control)) saturate(1.08);
-webkit-backdrop-filter: blur(var(--blur-control)) saturate(1.08);
}
.glass-menu {
background-color: var(--glass-menu);
box-shadow:
inset 0 1px 0 var(--glass-edge-strong),
0 18px 48px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(var(--blur-panel)) saturate(1.04);
-webkit-backdrop-filter: blur(var(--blur-panel)) saturate(1.04);
}
.glass-focus {
background-color: var(--glass-focus);
box-shadow:
inset 0 1px 0 var(--glass-edge-strong),
var(--shadow-focus);
backdrop-filter: blur(var(--blur-focus)) saturate(1.06);
-webkit-backdrop-filter: blur(var(--blur-focus)) saturate(1.06);
}
.glass-info {
background-color: var(--glass-info);
box-shadow: inset 0 1px 0 rgba(147, 197, 253, 0.42);
}
.glass-normal {
background-color: var(--glass-normal);
box-shadow: inset 0 1px 0 rgba(94, 234, 212, 0.4);
}
.glass-warning {
background-color: var(--glass-warning);
box-shadow: inset 0 1px 0 rgba(253, 186, 116, 0.46);
}
.glass-danger {
background-color: var(--glass-danger);
box-shadow: inset 0 1px 0 rgba(252, 165, 165, 0.48);
}
.glass-agent {
background-color: var(--glass-agent);
box-shadow: inset 0 1px 0 rgba(196, 181, 253, 0.42);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass-navigation,
.glass-panel,
.glass-readable,
.glass-control,
.glass-menu,
.glass-focus,
.command-panel {
background-color: rgba(255, 255, 255, 0.96);
}
.glass-info {
background-color: #eff6ff;
}
.glass-normal {
background-color: #f0fdfa;
}
.glass-warning {
background-color: #fff7ed;
}
.glass-danger {
background-color: #fef2f2;
}
.glass-agent {
background-color: #f5f3ff;
}
}
@media (prefers-reduced-transparency: reduce) {
.glass-navigation,
.glass-panel,
.glass-readable,
.glass-control,
.glass-menu,
.glass-focus,
.command-panel {
background-color: var(--surface-solid);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.glass-info {
background-color: #eff6ff;
}
.glass-normal {
background-color: #f0fdfa;
}
.glass-warning {
background-color: #fff7ed;
}
.glass-danger {
background-color: #fef2f2;
}
.glass-agent {
background-color: #f5f3ff;
}
}
@media (pointer: coarse) {
.glass-navigation,
.glass-panel,
.glass-readable,
.glass-control,
.glass-menu,
.glass-focus,
.command-panel {
background-color: rgba(255, 255, 255, 0.96);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.glass-control,
.glass-menu,
.glass-focus {
background-color: var(--surface-solid);
}
}
.agent-panel-band {
background: rgba(255, 255, 255, 0.96);
background: var(--glass-menu);
}
.agent-panel-conversation {
@@ -52,7 +52,7 @@ import {
agentPanelSectionVariants
} from "./agent-motion";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageDetails } from "./agent-message-details";
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
import { AgentOperationalBrief } from "./agent-operational-brief";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { StreamingTokenResponse } from "./streaming-token-response";
@@ -175,7 +175,7 @@ export function AgentCommandPanel({
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500"></p>
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200/70 bg-white/75 px-2 text-xs font-semibold text-slate-600">
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200/70 bg-[var(--glass-readable)] px-2 text-xs font-semibold text-slate-600">
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} />
{getAgentConnectionLabel(statusLabel)}
</span>
@@ -313,15 +313,17 @@ export function AgentCommandPanel({
</AgentContent>
<div className="agent-panel-band space-y-2 border-t border-white/70 p-3">
<Suggestions aria-label="推荐问题" role="group">
<Suggestions aria-label="推荐问题" role="group" className="px-0.5">
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
<Suggestion
key={suggestion}
suggestion={suggestion}
disabled={streaming}
onClick={submitPrompt}
className="border-slate-200/80 bg-white/75 text-slate-600 shadow-none hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700"
/>
className="max-w-full border-slate-200/80 bg-[var(--glass-readable)] text-slate-600 shadow-none hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700"
>
<span className="truncate">{suggestion}</span>
</Suggestion>
))}
</Suggestions>
<PromptInput
@@ -520,7 +522,7 @@ function AgentModelSelect({
key={model.id}
className={cn(
"rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
selected && "border-violet-200 bg-violet-50/80 focus:bg-violet-50"
selected && "glass-agent border-violet-200 focus:bg-violet-50"
)}
onSelect={() => onValueChange?.(model.id)}
>
@@ -612,6 +614,12 @@ function BackendMessageList({
)}
>
<div className="space-y-3">
{message.role === "assistant" ? (
<AgentMessageProgress
progress={message.progress}
running={streaming && index === messages.length - 1}
/>
) : null}
{message.content ? (
message.role === "assistant" ? (
<StreamingTokenResponse
@@ -2,10 +2,11 @@
import {
CheckCircle2,
ChevronDown,
HelpCircle,
ListChecks,
ListTree,
LockKeyhole,
Loader2,
ShieldCheck,
XCircle,
type LucideIcon
@@ -106,8 +107,7 @@ export function AgentMessageDetails({
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
const hasDetails = Boolean(
message.progress?.length ||
message.todos?.todos.length ||
message.todos?.todos.length ||
message.permissions?.length ||
message.questions?.length
);
@@ -119,11 +119,6 @@ export function AgentMessageDetails({
return (
<motion.div className="space-y-2" layout variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{message.progress?.length ? (
<motion.div key="progress" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<ProgressList progress={message.progress} />
</motion.div>
) : null}
{message.todos ? (
<motion.div key="todos" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<TodoCard todoUpdate={message.todos} />
@@ -148,6 +143,20 @@ export function AgentMessageDetails({
);
}
export function AgentMessageProgress({
progress,
running
}: {
progress?: AgentChatProgress[];
running: boolean;
}) {
if (!progress?.length) {
return null;
}
return <ProgressList progress={progress} running={running} />;
}
function AgentNestedBlock({
icon: Icon,
iconClassName,
@@ -185,44 +194,76 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
);
}
function ProgressList({ progress }: { progress: AgentChatProgress[] }) {
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
const [expanded, setExpanded] = useState(false);
const recentProgress = progress.slice(-3);
const visibleProgress = expanded ? progress : running ? recentProgress : [];
return (
<AgentNestedBlock icon={Loader2} iconClassName="text-blue-600" title="执行进度">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{progress.slice(-6).map((item) => (
<motion.li
key={item.id}
layout
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border",
progressStatusMeta[item.status].dotClassName
)}
/>
<span className="min-w-0">
<span className="block truncate font-semibold text-slate-800" title={item.title}>
{item.title}
<div className="agent-panel-nested rounded-xl p-2.5">
<button
type="button"
className="flex w-full items-center gap-2 text-left text-xs font-semibold text-slate-600"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
<ListTree size={16} className="shrink-0 text-blue-700" aria-hidden="true" />
<span></span>
<span className="flex-1" />
<span className="shrink-0 font-normal text-slate-400">
{expanded
? `全部 ${progress.length}`
: running
? `最近 ${recentProgress.length}`
: `${progress.length}`}
</span>
<ChevronDown
size={14}
className={cn("shrink-0 text-slate-400 transition-transform", expanded && "rotate-180")}
aria-hidden="true"
/>
</button>
<AnimatePresence initial={false}>
{visibleProgress.length ? (
<motion.ol
className="mt-2 space-y-1.5 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={agentLayoutTransition}
>
{visibleProgress.map((item) => (
<li key={item.id} className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs">
<span
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border",
progressStatusMeta[item.status].dotClassName
)}
/>
<span className="min-w-0">
<span className="block truncate font-semibold text-slate-800" title={item.title}>
{item.title}
</span>
{item.detail ? (
<span className="block line-clamp-2 whitespace-pre-wrap leading-5 text-slate-500">
{item.detail}
</span>
) : null}
</span>
{item.detail ? (
<span className="block line-clamp-2 whitespace-pre-wrap leading-5 text-slate-500">{item.detail}</span>
) : null}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", progressStatusMeta[item.status].className)}>
{progressStatusMeta[item.status].label}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
</AgentNestedBlock>
<span
className={cn(
"rounded-full px-2 py-0.5 font-semibold",
progressStatusMeta[item.status].className
)}
>
{progressStatusMeta[item.status].label}
</span>
</li>
))}
</motion.ol>
) : null}
</AnimatePresence>
</div>
);
}
@@ -310,7 +351,7 @@ function PermissionRequestCard({
};
return (
<div className="rounded-lg bg-white/90 px-2.5 py-2 text-xs">
<div className="rounded-lg bg-[var(--glass-menu)] px-2.5 py-2 text-xs">
<div className="grid grid-cols-[1fr_auto] gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
@@ -438,7 +479,7 @@ function QuestionRequestCard({
};
return (
<div className="rounded-lg bg-white/90 px-2.5 py-2 text-xs">
<div className="rounded-lg bg-[var(--glass-menu)] px-2.5 py-2 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-semibold text-slate-800">
{request.questions[0]?.header || "问题"}
@@ -556,7 +597,7 @@ function QuestionInput({
return (
<label
key={option.label}
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 bg-white/90 px-2 py-1.5 text-slate-600"
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 bg-[var(--surface-solid)] px-2 py-1.5 text-slate-600"
>
<input
type={question.multiple ? "checkbox" : "radio"}
@@ -579,7 +620,7 @@ function QuestionInput({
) : null}
{showCustomInput ? (
<textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200 bg-white/90 px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
className="min-h-16 w-full resize-none rounded-md border border-slate-200 bg-[var(--surface-solid)] px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
value={value.custom}
disabled={disabled}
@@ -61,7 +61,7 @@ export function AgentOperationalBrief({
</button>
<button
type="button"
className="flex h-9 items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-white/90 px-3 text-xs font-semibold text-slate-700 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
className="flex h-9 items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-[var(--glass-menu)] px-3 text-xs font-semibold text-slate-700 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={streaming}
onClick={() => onSubmitCommand(brief.secondaryCommand)}
>
@@ -303,7 +303,7 @@ function PropRows({ props, labels }: { props: unknown; labels: Record<string, st
return (
<dl className="grid grid-cols-2 gap-2">
{rows.map((row) => (
<div key={row.label} className="min-w-0 rounded-lg bg-white/90 px-2.5 py-2">
<div key={row.label} className="min-w-0 rounded-lg bg-[var(--glass-menu)] px-2.5 py-2">
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
{row.value}
@@ -194,7 +194,7 @@ export function BaseLayersControl({
) : (
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
<span className="absolute bottom-1 left-1 right-1 rounded bg-white/95 px-1 py-0.5 text-xs font-semibold leading-none text-slate-800 shadow-sm">
<span className="absolute bottom-1 left-1 right-1 rounded bg-[var(--glass-menu)] px-1 py-0.5 text-xs font-semibold leading-none text-slate-800 shadow-sm">
{activeLayer?.label ?? title}
</span>
</span>
@@ -83,7 +83,7 @@ export function MapModeButton({ icon: Icon, label, active = false, disabled = fa
className={cn(
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
active ? "bg-blue-600 text-white shadow-sm" : "bg-white/95 text-slate-600 hover:text-blue-700",
active ? "bg-blue-600 text-white shadow-sm" : "bg-[var(--glass-menu)] text-slate-600 hover:text-blue-700",
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
)}
>
@@ -164,7 +164,7 @@ export function MapActionRow({
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
: strong
? "border-blue-100 bg-blue-50/95 text-blue-800"
: "border-white/70 bg-white/95 text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
: "border-transparent bg-[var(--glass-menu)] text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
isDanger && "hover:border-red-100 hover:text-red-700",
selected && !isPrimary && "border-blue-200 bg-blue-50 text-blue-800",
muted && "opacity-70",
@@ -69,8 +69,8 @@ export function MapLayerControl({
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-100 bg-white/95 text-slate-900 shadow-md shadow-blue-950/10"
: "border-white/70 bg-white/95 text-slate-500 hover:border-slate-200 hover:bg-white",
? "border-blue-100 bg-[var(--glass-menu)] text-slate-900 shadow-md shadow-blue-950/10"
: "border-transparent bg-[var(--glass-readable)] text-slate-500 hover:border-slate-200 hover:bg-white",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)}
>
+1 -1
View File
@@ -65,7 +65,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-sm shadow-blue-950/5"
: "border-white/70 bg-white/90 text-slate-500 hover:border-slate-200 hover:bg-white",
: "border-transparent bg-[var(--glass-readable)] text-slate-500 hover:border-slate-200 hover:bg-white",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)}
>
@@ -2,23 +2,23 @@ export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
export const MAP_CONTROL_RADIUS_CLASS_NAME = "rounded-xl";
export const MAP_READABLE_RADIUS_CLASS_NAME = "rounded-xl";
export const MAP_READABLE_RADIUS_CLASS_NAME = MAP_CONTROL_RADIUS_CLASS_NAME;
export const MAP_COMPACT_RADIUS_CLASS_NAME = "rounded-xl";
export const MAP_COMPACT_RADIUS_CLASS_NAME = MAP_CONTROL_RADIUS_CLASS_NAME;
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
export const MAP_CONTROL_SURFACE_CLASS_NAME =
"border border-white/75 bg-white/95 shadow-lg shadow-slate-900/20 ring-1 ring-slate-900/5 backdrop-blur-xl";
"glass-control border";
export const MAP_TOOL_PANEL_SURFACE_CLASS_NAME =
"border border-white/70 bg-white/80 shadow-2xl shadow-blue-950/20 backdrop-blur-xl";
"glass-panel border";
export const MAP_READABLE_SURFACE_CLASS_NAME =
"border border-white/70 bg-white/95";
"border border-transparent bg-[var(--glass-readable)]";
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
"border border-white/80 bg-white/95";
"border border-transparent bg-[var(--glass-menu)]";
export const MAP_CONTROL_HOVER_CLASS_NAME =
"hover:bg-blue-100/70 hover:text-blue-700 hover:ring-1 hover:ring-blue-200/80 active:bg-blue-100";
@@ -72,7 +72,7 @@ export function MapMeasurePanel({
return (
<div className="space-y-3">
<MapPanelSection title="模式">
<div className={cn("grid grid-cols-3 gap-1.5 border border-white/60 bg-white/80 p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className={cn("grid grid-cols-3 gap-1.5 border border-transparent bg-[var(--glass-readable)] p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
{modes.map((mode) => (
<MapModeButton
key={mode.id}
@@ -121,7 +121,7 @@ export function MapMeasurePanel({
MAP_COMPACT_RADIUS_CLASS_NAME,
unit.id === activeUnitId
? "bg-blue-600 text-white"
: "bg-white/95 text-slate-600 hover:text-blue-700"
: "bg-[var(--glass-menu)] text-slate-600 hover:text-blue-700"
)}
>
{unit.label}
+6 -6
View File
@@ -45,11 +45,11 @@ const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
};
const toneClassNames: Record<MapNoticeTone, string> = {
info: "border-white/80 bg-white/95 text-slate-900",
success: "border-white/80 bg-white/95 text-slate-900",
warning: "border-white/80 bg-white/95 text-slate-900",
error: "border-white/80 bg-white/95 text-slate-900",
loading: "border-white/80 bg-white/95 text-slate-900"
info: "glass-menu text-slate-900",
success: "glass-menu text-slate-900",
warning: "glass-menu text-slate-900",
error: "glass-menu text-slate-900",
loading: "glass-menu text-slate-900"
};
const iconClassNames: Record<MapNoticeTone, string> = {
@@ -232,7 +232,7 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
<div
role={tone === "error" || tone === "warning" ? "alert" : "status"}
className={cn(
"relative flex w-[min(420px,calc(100vw-24px))] items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm shadow-lg shadow-slate-900/20 backdrop-blur-xl",
"relative flex w-[min(420px,calc(100vw-24px))] items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm",
MAP_READABLE_RADIUS_CLASS_NAME,
toneClassNames[tone]
)}
@@ -21,7 +21,7 @@ type TickerTransitionDirection = -1 | 1;
const tickerEase = [0.22, 1, 0.36, 1] as const;
const tickerSwitchAnimationMs = 380;
const tickerCardSurfaceClassName =
"border border-white/75 bg-white/[0.92] ring-1 ring-slate-900/5 backdrop-blur-xl";
"glass-control border";
const tickerCardVariants: Variants = {
initial: (direction: TickerTransitionDirection) => ({
@@ -261,7 +261,7 @@ export function AgentTaskTicker({
{isHovered && hasMultipleRunningTasks ? (
<motion.div
key="task-indicator"
className="agent-task-ticker-indicator flex flex-col gap-1 rounded-full bg-white/80 px-1 py-1 shadow-sm shadow-blue-950/10 backdrop-blur"
className="agent-task-ticker-indicator flex flex-col gap-1 rounded-full bg-[var(--glass-menu)] px-1 py-1 shadow-sm shadow-blue-950/10"
variants={tickerIndicatorVariants}
initial="initial"
animate="animate"
@@ -77,7 +77,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
}, [feature]);
return (
<aside className="pointer-events-auto flex h-full min-h-0 flex-col rounded border border-white/60 bg-white/88 shadow-glass backdrop-blur">
<aside className="glass-readable pointer-events-auto flex h-full min-h-0 flex-col rounded border">
<div className="border-b border-slate-200/80 p-4">
<div className="flex items-start justify-between gap-3">
<div>
@@ -122,11 +122,11 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("device_external_id", feature.properties.device_external_id)}</p>
</div>
</div>
<p className="mt-2 text-sm leading-6 text-slate-700">线</p>
<p className="mt-2 text-sm leading-6 text-slate-700">线</p>
</section>
) : null}
<section className="mt-4 rounded border border-orange-100 bg-orange-50/70 p-3">
<section className="glass-warning mt-4 rounded border border-orange-100 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
<TriangleAlert size={16} aria-hidden="true" />
Agent
@@ -166,8 +166,8 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
function getScadaPresentation(rawTypeId: unknown) {
const typeId = Number(rawTypeId);
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "border-teal-200 bg-teal-50/70", labelClassName: "text-teal-800" };
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "border-blue-200 bg-blue-50/70", labelClassName: "text-blue-800" };
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "border-orange-200 bg-orange-50/70", labelClassName: "text-orange-800" };
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "glass-normal border-teal-200", labelClassName: "text-teal-800" };
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "glass-info border-blue-200", labelClassName: "text-blue-800" };
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "glass-warning border-orange-200", labelClassName: "text-orange-800" };
return { iconPath: SCADA_ICON_PATHS.unknown, sectionClassName: "border-slate-200 bg-slate-50", labelClassName: "text-slate-800" };
}
@@ -46,12 +46,12 @@ const FEATURE_LAYER_META: Record<
iconClassName: string;
}
> = {
conduits: { icon: ConduitFeatureIcon, label: "管渠", headerClassName: "bg-teal-50", labelClassName: "text-teal-700", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
junctions: { icon: JunctionFeatureIcon, label: "检查井", headerClassName: "bg-sky-50", labelClassName: "text-sky-700", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
orifices: { icon: OrificeFeatureIcon, label: "孔口", headerClassName: "bg-slate-100", labelClassName: "text-slate-700", iconClassName: "bg-slate-600 text-white ring-slate-900/10" },
outfalls: { icon: OutfallFeatureIcon, label: "排放口", headerClassName: "bg-blue-50", labelClassName: "text-blue-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" },
scada: { icon: JunctionFeatureIcon, label: "SCADA 测点", headerClassName: "bg-blue-50", labelClassName: "text-blue-700", iconClassName: "bg-blue-700 text-white ring-blue-900/10" }
conduits: { icon: ConduitFeatureIcon, label: "管渠", headerClassName: "glass-normal", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
junctions: { icon: JunctionFeatureIcon, label: "检查井", headerClassName: "glass-info", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
orifices: { icon: OrificeFeatureIcon, label: "孔口", headerClassName: "bg-[var(--glass-readable)]", labelClassName: "text-slate-700", iconClassName: "bg-slate-600 text-white ring-slate-900/10" },
outfalls: { icon: OutfallFeatureIcon, label: "排放口", headerClassName: "glass-info", labelClassName: "text-blue-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "glass-normal", labelClassName: "text-teal-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" },
scada: { icon: JunctionFeatureIcon, label: "SCADA 测点", headerClassName: "glass-info", labelClassName: "text-blue-800", iconClassName: "bg-blue-700 text-white ring-blue-900/10" }
};
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
@@ -128,7 +128,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
return (
<aside
className={cn(
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden bg-white/94 shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
"glass-focus pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden border",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)}
>
@@ -155,8 +155,8 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
<div className="flex items-center gap-0.5">
{canCopy ? (
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-[120ms]", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-[120ms]", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
</button>
) : null}
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
@@ -218,7 +218,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
return (
<aside
className={cn(
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] bg-white/94 shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
"glass-focus pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] border",
"-translate-x-1/2 overflow-hidden",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)}
@@ -232,7 +232,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
{formatFeaturePropertyValue("device_type_name", properties.device_type_name)}
</span>
<span className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-white/80 px-2 py-0.5 text-xs font-semibold ring-1 ring-inset",
"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[var(--glass-menu)] px-2 py-0.5 text-xs font-semibold ring-1 ring-inset",
active ? "text-emerald-700 ring-emerald-600/20" : "text-slate-600 ring-slate-500/20"
)}>
<span className={cn("h-1.5 w-1.5 rounded-full", active ? "bg-emerald-500" : "bg-slate-400")} />
@@ -247,8 +247,8 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
<div className="flex items-center gap-0.5">
{canCopy ? (
<button type="button" aria-label={copied ? "测点编号已复制" : "复制测点编号"} title={copied ? "测点编号已复制" : "复制测点编号"} onClick={onCopy} className="relative grid h-10 w-10 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-[120ms]", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-[120ms]", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
</button>
) : null}
<button type="button" aria-label="关闭 SCADA 测点信息" title="关闭 SCADA 测点信息" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
@@ -274,7 +274,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
<div className="mt-3 grid grid-cols-[18px_1fr] gap-2 rounded-xl bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600 ring-1 ring-inset ring-slate-200/70">
<MapPin size={15} className="mt-0.5 text-slate-500" aria-hidden="true" />
<p></p>
<p></p>
</div>
<div className="mt-2 flex items-center gap-2 px-1 text-xs text-slate-400">
<Database size={13} aria-hidden="true" />
@@ -288,8 +288,8 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
function getScadaPresentation(rawTypeId: unknown) {
const typeId = Number(rawTypeId);
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "bg-teal-50", labelClassName: "text-teal-700" };
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "bg-blue-50", labelClassName: "text-blue-700" };
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "bg-orange-50", labelClassName: "text-orange-700" };
return { iconPath: SCADA_ICON_PATHS.unknown, headerClassName: "bg-slate-100", labelClassName: "text-slate-700" };
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "glass-normal", labelClassName: "text-teal-800" };
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "glass-info", labelClassName: "text-blue-800" };
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "glass-warning", labelClassName: "text-orange-800" };
return { iconPath: SCADA_ICON_PATHS.unknown, headerClassName: "bg-[var(--glass-readable)]", labelClassName: "text-slate-700" };
}
@@ -15,7 +15,7 @@ export function IconButton({ label, children, onClick, active = false }: IconBut
className={`grid h-10 w-10 place-items-center rounded border transition ${
active
? "border-slate-800 bg-slate-900 text-white"
: "border-white/60 bg-white/80 text-slate-700 shadow-sm hover:bg-white"
: "border-transparent bg-[var(--glass-readable)] text-slate-700 shadow-sm hover:bg-white"
}`}
>
{children}
@@ -49,9 +49,9 @@ import { getConditionReportRiskLevel } from "./condition-report-risk";
const RUNNING_WORKFLOW_TICK_MS = 1_000;
const STATUS_PILL_CLASS_NAME = "rounded-full border px-2 py-0.5 text-xs font-semibold leading-4";
const NEUTRAL_SURFACE_CLASS_NAME = "rounded-xl border border-slate-200/80 bg-white/95";
const INSET_SURFACE_CLASS_NAME = "rounded-xl border border-slate-200/70 bg-slate-50/80";
const REPORT_META_TILE_CLASS_NAME = "rounded-lg border border-slate-200/70 bg-slate-50/80 px-2 py-1.5";
const NEUTRAL_SURFACE_CLASS_NAME = "rounded-xl border border-transparent bg-[var(--glass-readable)]";
const INSET_SURFACE_CLASS_NAME = "rounded-xl border border-slate-200/70 bg-[var(--glass-menu)]";
const REPORT_META_TILE_CLASS_NAME = "rounded-lg border border-slate-200/70 bg-[var(--glass-menu)] px-2 py-1.5";
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
normal: "border-slate-200 bg-slate-50 text-slate-600",
@@ -365,7 +365,7 @@ function DetailHeader({
iconSpin = false
}: DetailHeaderProps) {
return (
<div className="bg-white/95 px-3 py-3">
<div className="px-3 py-3">
<div className="grid gap-3 2xl:grid-cols-[minmax(0,1fr)_220px]">
<div className="flex min-w-0 items-start gap-2.5">
<span
@@ -739,11 +739,11 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
if (riskLevel === "critical") {
return "border-red-200/80 bg-red-50/80";
return "glass-danger border-red-200/80";
}
if (riskLevel === "attention") {
return "border-orange-200/80 bg-orange-50/80";
return "glass-warning border-orange-200/80";
}
return "border-slate-200/70 bg-slate-50/80";
@@ -842,11 +842,11 @@ function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
if (status === "current") {
return "border-blue-100 bg-blue-50/70";
return "glass-info border-blue-100";
}
if (status === "confirmation") {
return "border-orange-100 bg-orange-50/70";
return "glass-warning border-orange-100";
}
return "border-slate-200/70 bg-slate-50/80";
@@ -12,7 +12,6 @@ import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice";
@@ -242,8 +241,8 @@ export function ScheduledConditionFeed({
className={cn(
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
"rounded-2xl",
MAP_CONTROL_SURFACE_CLASS_NAME
"rounded-2xl border",
"glass-control"
)}
>
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", "rounded-xl", MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
@@ -368,7 +367,9 @@ function ConditionTimelinePanel({
"flex min-h-0 flex-col overflow-hidden p-2.5",
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
"rounded-xl",
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
expanded
? "border border-white/30 bg-white/10"
: MAP_READABLE_SURFACE_STRONG_CLASS_NAME
)}
>
{expanded && !loading ? (
@@ -534,7 +535,7 @@ function ConditionFilterDropdown<TValue extends string>({
<DropdownMenuContent
align="start"
sideOffset={6}
className="scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-white/80 bg-white p-1.5 text-slate-700 shadow-xl shadow-slate-900/15 backdrop-blur-xl"
className="glass-menu scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border p-1.5 text-slate-700"
>
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
{options.map((option) => (
@@ -561,7 +562,7 @@ function ConditionFilterDropdown<TValue extends string>({
function ConditionEmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-white/65 px-4 py-5 text-center">
<div className="flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-[var(--glass-readable)] px-4 py-5 text-center">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400">
<ChevronRight size={15} aria-hidden="true" />
</span>
@@ -586,7 +587,7 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
) : null}
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
</span>
<span className="min-w-0 rounded-xl border border-white/80 bg-white/95 px-2 py-1.5">
<span className="min-w-0 rounded-xl border border-transparent bg-[var(--glass-menu)] px-2 py-1.5">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 space-y-1.5">
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" />
@@ -606,7 +607,7 @@ function ConditionDetailSkeleton() {
<DetailScroll>
<div
className={cn(
"relative min-h-[420px] overflow-hidden border border-slate-200/80 bg-white/95 px-4 py-3",
"relative min-h-[420px] overflow-hidden border border-slate-200/80 bg-[var(--glass-menu)] px-4 py-3",
"rounded-xl"
)}
role="status"
@@ -737,7 +738,7 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
"min-w-0 rounded-xl border px-2 py-1.5 transition-colors group-focus-visible:border-blue-300 group-focus-visible:bg-white",
selected
? "border-blue-300 bg-white text-blue-900 shadow-sm shadow-blue-600/10 ring-1 ring-blue-300/40"
: "border-white/80 bg-white/95 text-slate-700 group-hover:border-blue-100 group-hover:bg-white"
: "border-transparent bg-[var(--glass-menu)] text-slate-700 group-hover:border-blue-100 group-hover:bg-white"
)}
>
<span className="flex min-w-0 items-center gap-2">
@@ -46,10 +46,10 @@ const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"border border-white/80 bg-white/95 p-2 text-slate-900 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5 backdrop-blur-xl"
"glass-menu border p-2 text-slate-900"
);
const menuReadableRowClassName =
"bg-white/80 focus:bg-blue-50/95 focus:text-slate-950";
"bg-[var(--glass-readable)] focus:bg-blue-50/95 focus:text-slate-950";
const headerControlButtonClassName =
"inline-flex h-8 items-center gap-2 rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
const headerControlIconClassName =
@@ -220,7 +220,7 @@ export function AlertMenu({
</span>
</DropdownMenuItem>
)) : (
<div className={cn("my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 bg-white/70 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className={cn("my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 bg-[var(--glass-readable)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
<p className="mt-1 text-xs font-semibold text-slate-700"></p>
@@ -315,7 +315,7 @@ function AlertQueueSummary({
const hasAlerts = count > 0;
return (
<div className={cn("overflow-hidden bg-white/90 px-3 py-3 ring-1 ring-white/80", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className={cn("overflow-hidden bg-[var(--glass-menu)] px-3 py-3 ring-1 ring-white/80", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span
@@ -423,7 +423,7 @@ function MenuHeader({
icon?: LucideIcon;
}) {
return (
<div className={cn("flex items-center justify-between gap-3 border border-white/70 bg-white/90 px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className={cn("flex items-center justify-between gap-3 border border-transparent bg-[var(--glass-menu)] px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
@@ -78,7 +78,7 @@ export function WorkbenchTopBar({
};
return (
<header className="pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b border-white/65 bg-white/80 px-3 shadow-lg shadow-slate-900/5 backdrop-blur-2xl sm:px-4 md:px-5">
<header className="glass-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
<div className="flex min-w-0 items-center gap-2.5">
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Droplets size={18} aria-hidden="true" />
+16
View File
@@ -26,6 +26,22 @@ describe("SCADA map presentation", () => {
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
});
it("keeps the icon and selected ring consistent across zoom levels", () => {
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
const selectedLayer = scadaLayers.find((layer) => layer.id === "scada-selected");
expect(symbolLayer).toMatchObject({ type: "symbol", layout: { "icon-size": 0.75 } });
expect(outerLayer).toMatchObject({
type: "circle",
paint: {
"circle-radius": 12.5,
"circle-stroke-width": 3
}
});
expect(selectedLayer).toMatchObject({ type: "circle", paint: { "circle-stroke-width": 2 } });
});
it("uses MapLibre-compatible PNG assets", () => {
expect(Object.values(SCADA_MAP_ICON_PATHS)).toHaveLength(4);
Object.values(SCADA_MAP_ICON_PATHS).forEach((path) => expect(path).toMatch(/\.png$/));
+6 -3
View File
@@ -37,6 +37,9 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
"case", ["boolean", ["feature-state", state], false], 1, 0
];
const SCADA_ICON_SIZE = 0.75;
const SCADA_SELECTED_RADIUS = 12.5;
type Layer = NonNullable<StyleSpecification["layers"]>[number];
export type ScadaImageRegistrationResult = {
@@ -46,7 +49,7 @@ export type ScadaImageRegistrationResult = {
const scadaSymbolLayer: Layer = {
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12,
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": ["interpolate", ["linear"], ["zoom"], 12, 0.625, 20, 0.875], "icon-allow-overlap": true, "icon-ignore-placement": true }
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SCADA_ICON_SIZE, "icon-allow-overlap": true, "icon-ignore-placement": true }
};
const scadaFallbackLayer: Layer = {
@@ -56,8 +59,8 @@ const scadaFallbackLayer: Layer = {
const scadaInteractionLayers: Layer[] = [
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 14, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#0E7490", "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "white", "circle-stroke-width": 6, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#2563EB", "circle-stroke-width": 3, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "white", "circle-stroke-width": 3, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#2563EB", "circle-stroke-width": 2, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(0,0,0,0)", "circle-opacity": 0 } }
];
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getFeaturePanelModel, getFeaturePanelProperties, getLocalizedFeatureProperties } from "./feature-properties";
import { formatFeaturePropertyValue, getFeaturePanelModel, getFeaturePanelProperties, getLocalizedFeatureProperties } from "./feature-properties";
describe("feature panel properties", () => {
it.each([
@@ -78,4 +78,9 @@ describe("feature panel properties", () => {
expect(entries.map((entry) => entry.key)).toEqual(["point_name", "device_external_id"]);
});
it("labels uniformly sampled SCADA locations", () => {
expect(formatFeaturePropertyValue("location_source", "uniform_farthest_point_demo"))
.toBe("空间均匀采样模拟位置");
});
});
@@ -212,6 +212,10 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
return "密度聚类模拟位置";
}
if (normalizedKey === "location_source" && value === "uniform_farthest_point_demo") {
return "空间均匀采样模拟位置";
}
if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") {
const diameterInMeters = typeof value === "number" ? value : Number(value);
return Number.isFinite(diameterInMeters)