refactor(ui): refine glass surfaces

This commit is contained in:
2026-07-15 11:41:51 +08:00
parent 2b768c2c06
commit 8978f2d167
11 changed files with 271 additions and 208 deletions
+44 -22
View File
@@ -91,13 +91,13 @@ Recommended canvas colors:
| `canvas-map` | `#eaf1f8` | Fallback map background | | `canvas-map` | `#eaf1f8` | Fallback map background |
| `canvas-map-grid` | `rgba(37, 99, 235, 0.06)` | Subtle engineering grid | | `canvas-map-grid` | `rgba(37, 99, 235, 0.06)` | Subtle engineering grid |
| `canvas-page` | `#f8fafc` | Non-map support pages | | `canvas-page` | `#f8fafc` | Non-map support pages |
| `canvas-panel` | `rgba(255, 255, 255, 0.94)` | Default information-bearing floating panel | | `canvas-panel` | Named glass material | Use the material level for the component category |
| `canvas-panel-strong` | `rgba(255, 255, 255, 0.98)` | Dense text or form surface | | `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 | | `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-overlay` | `glass-menu` | Floating menu or compact choice shell |
| `surface-readable` | `rgba(255, 255, 255, 0.94)` | Readable rows, menu headers, property panels and content blocks | | `surface-readable` | `glass-readable` | Right-side details, compact results and summaries |
| `surface-map-control` | `rgba(255, 255, 255, 0.92)` | Always-visible and persistent map instruments: toolbar, zoom, scaleline, Agent shell, scheduled-condition shell | | `surface-map-control` | `glass-control` | Always-visible toolbar, zoom and compact task shells |
| `surface-tool-panel` | `rgba(255, 255, 255, 0.90)` | Toolbar function panel outer shell | | `surface-tool-panel` | `glass-panel` | Toolbar panels, expanded conditions and Agent shell |
### Color System ### Color System
@@ -223,13 +223,13 @@ Transparency is a material system, not a collection of arbitrary opacity utiliti
| Token | Background | Blur | Shadow | Intended use | | 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-air` | `rgba(255, 255, 255, 0.68)` | `16px` | `0 10px 30px rgba(15, 23, 42, 0.10)` | Scale line, coordinates and lightweight map hints |
| `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-navigation` | `rgba(255, 255, 255, 0.80)` | `24px` | `0 8px 28px rgba(15, 23, 42, 0.10)` | Top bar and 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-panel` | `rgba(255, 255, 255, 0.84)` | `20px` | `0 24px 70px rgba(30, 64, 115, 0.18)` | Tool panels, expanded conditions and Agent shell |
| `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.90)` | `16px` | `0 14px 34px rgba(15, 23, 42, 0.18)` | Zoom, persistent toolbars and compact task shells |
| `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-readable` | `rgba(255, 255, 255, 0.90)` | `20px` | `0 24px 70px rgba(30, 64, 115, 0.20)` | Right-side details, compact results and summaries |
| `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` | Tailwind `shadow-2xl shadow-blue-950/20` | Normal and SCADA property popovers only |
| `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 | | `glass-menu` | `rgba(255, 255, 255, 0.92)` | `20px` | `0 18px 48px rgba(15, 23, 42, 0.16)` | Dropdown menus and notices |
| `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 | | `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-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 | | `scrim-focus` | `rgba(15, 23, 42, 0.48)` | `0` | none | Modal decisions, destructive or irreversible actions |
@@ -258,15 +258,38 @@ Colored glass communicates operational meaning. It must not be used as decoratio
Text on colored glass must use a darker shade of the same hue. Do not place neutral gray text on a colored surface. Text on colored glass must use a darker shade of the same hue. Do not place neutral gray text on a colored surface.
#### Canonical Focus Glass
Normal and SCADA property popovers are the readability benchmark. Their outer shell must use this exact Tailwind combination:
```text
bg-white/[0.94] backdrop-blur-xl
shadow-2xl shadow-blue-950/20
ring-1 ring-white/80
```
Do not add `saturate()`, gradients, acrylic effects, heavier custom focus shadows, or full-width white surfaces beneath the header and body. Attribute rows use low-opacity dividers. Device-type headers use only a small `bg-*-50/70` tint.
#### Layering And Performance
- Use at most one `backdrop-filter` in the same visual region. Inner cards never blur again.
- Do not place full-height `white/90+` child panels inside a glass shell.
- Large surfaces gain hierarchy by lowering white coverage, not by increasing blur.
- Reports, forms, long text, multi-column tables and dangerous confirmations use `surface-solid`.
- Browsers without backdrop-filter fall back to 96% white.
- `prefers-reduced-transparency` disables blur and uses `surface-solid`.
- Coarse pointers retain transparency. Use a solid surface only behind an explicit performance-degradation condition.
#### Component Material Mapping #### Component Material Mapping
| Component category | Material token | Notes | | 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 | | 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 | | Scale line, coordinates and lightweight hints | `glass-air` | Sparse overlays should preserve the strongest perception of the scattered map |
| Zoom 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 | | 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 | | Agent command shell | `glass-panel` | 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 | | Scheduled-condition task shell | `glass-control` collapsed, `glass-panel` expanded | Remove stacked full-height 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 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 | | 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 | | Dropdown menus and notices | `glass-menu` | More opaque because the surface is temporary and action-dense |
@@ -276,15 +299,14 @@ Text on colored glass must use a darker shade of the same hue. Do not place neut
#### Composition Rules #### 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. - Use at most one backdrop-filter layer over the map at the same location. Inner translucent tints must not apply blur.
- A glass shell should usually contain flat sections separated by `glass-divider`. Do not place an opaque white card around every row. - 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. - 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 the outer shell one material level toward `surface-solid`. Do not keep increasing blur or add an opaque full-height child.
- 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. - 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. - 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%. - Opacity follows component area and information density. Operational text may live on an 84% panel shell when local long-form content uses `surface-solid`; only property focus popovers use 94% glass.
- 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. - Do not apply acrylic-style saturation, texture or refractive treatment to any material level.
- Prefer layered `box-shadow` or an inset ring for glass edges. A solid high-contrast border breaks the depth illusion. - 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. - Hover feedback may change highlight and edge opacity. Do not animate `backdrop-filter` or background blur.
@@ -296,8 +318,8 @@ Text on colored glass must use a darker shade of the same hue. Do not place neut
- Under `prefers-reduced-transparency: reduce`, replace every glass material with `surface-solid` and remove `backdrop-filter`. - 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. - 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. - Low-power fallback must be activated by an explicit performance-degradation condition, not inferred from touch or coarse pointer input.
- On coarse-pointer devices, remove backdrop blur and use a 0.96-0.98 white surface while preserving semantic state colors. - Coarse-pointer devices keep the same transparency hierarchy by default.
- Never apply backdrop blur separately to every list row. Blur belongs to the shared outer surface. - 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. - Maintain WCAG AA contrast for all body text and controls against the composited result, not only against the declared RGBA value.
+51 -49
View File
@@ -19,13 +19,13 @@
--border-strong: rgba(148, 163, 184, 0.34); --border-strong: rgba(148, 163, 184, 0.34);
--shadow-control: 0 14px 34px rgba(15, 23, 42, 0.18); --shadow-control: 0 14px 34px rgba(15, 23, 42, 0.18);
--shadow-panel: 0 24px 70px rgba(30, 64, 115, 0.2); --shadow-panel: 0 24px 70px rgba(30, 64, 115, 0.2);
--shadow-focus: 0 30px 90px rgba(15, 23, 42, 0.26); --shadow-focus: 0 25px 50px -12px rgba(23, 37, 84, 0.2);
--glass-air: rgba(255, 255, 255, 0.84); --glass-air: rgba(255, 255, 255, 0.68);
--glass-navigation: rgba(255, 255, 255, 0.9); --glass-navigation: rgba(255, 255, 255, 0.8);
--glass-panel: rgba(255, 255, 255, 0.9); --glass-panel: rgba(255, 255, 255, 0.84);
--glass-readable: rgba(255, 255, 255, 0.94); --glass-readable: rgba(255, 255, 255, 0.9);
--glass-control: rgba(255, 255, 255, 0.92); --glass-control: rgba(255, 255, 255, 0.9);
--glass-menu: rgba(255, 255, 255, 0.94); --glass-menu: rgba(255, 255, 255, 0.92);
--glass-focus: rgba(255, 255, 255, 0.94); --glass-focus: rgba(255, 255, 255, 0.94);
--surface-solid: rgba(255, 255, 255, 0.98); --surface-solid: rgba(255, 255, 255, 0.98);
--glass-edge: rgba(255, 255, 255, 0.58); --glass-edge: rgba(255, 255, 255, 0.58);
@@ -112,10 +112,11 @@ input:focus-visible {
box-shadow: box-shadow:
inset 0 1px 0 var(--glass-edge), inset 0 1px 0 var(--glass-edge),
var(--shadow-panel); var(--shadow-panel);
backdrop-filter: blur(var(--blur-panel)) saturate(1.08); backdrop-filter: blur(var(--blur-panel));
-webkit-backdrop-filter: blur(var(--blur-panel)) saturate(1.08); -webkit-backdrop-filter: blur(var(--blur-panel));
} }
.glass-air,
.glass-navigation, .glass-navigation,
.glass-panel, .glass-panel,
.glass-readable, .glass-readable,
@@ -131,13 +132,22 @@ input:focus-visible {
background-clip: padding-box; background-clip: padding-box;
} }
.glass-air {
background-color: var(--glass-air);
box-shadow:
inset 0 1px 0 var(--glass-edge),
0 10px 30px rgba(15, 23, 42, 0.1);
backdrop-filter: blur(var(--blur-air));
-webkit-backdrop-filter: blur(var(--blur-air));
}
.glass-navigation { .glass-navigation {
background-color: var(--glass-navigation); background-color: var(--glass-navigation);
box-shadow: box-shadow:
inset 0 1px 0 var(--glass-edge), inset 0 1px 0 var(--glass-edge),
0 8px 28px rgba(15, 23, 42, 0.1); 0 8px 28px rgba(15, 23, 42, 0.1);
backdrop-filter: blur(var(--blur-navigation)) saturate(1.08); backdrop-filter: blur(var(--blur-navigation));
-webkit-backdrop-filter: blur(var(--blur-navigation)) saturate(1.08); -webkit-backdrop-filter: blur(var(--blur-navigation));
} }
.glass-panel { .glass-panel {
@@ -145,8 +155,8 @@ input:focus-visible {
box-shadow: box-shadow:
inset 0 1px 0 var(--glass-edge), inset 0 1px 0 var(--glass-edge),
0 24px 70px rgba(30, 64, 115, 0.18); 0 24px 70px rgba(30, 64, 115, 0.18);
backdrop-filter: blur(var(--blur-panel)) saturate(1.08); backdrop-filter: blur(var(--blur-panel));
-webkit-backdrop-filter: blur(var(--blur-panel)) saturate(1.08); -webkit-backdrop-filter: blur(var(--blur-panel));
} }
.glass-readable { .glass-readable {
@@ -154,8 +164,8 @@ input:focus-visible {
box-shadow: box-shadow:
inset 0 1px 0 var(--glass-edge-strong), inset 0 1px 0 var(--glass-edge-strong),
0 24px 70px rgba(30, 64, 115, 0.2); 0 24px 70px rgba(30, 64, 115, 0.2);
backdrop-filter: blur(var(--blur-panel)) saturate(1.06); backdrop-filter: blur(var(--blur-panel));
-webkit-backdrop-filter: blur(var(--blur-panel)) saturate(1.06); -webkit-backdrop-filter: blur(var(--blur-panel));
} }
.glass-control { .glass-control {
@@ -163,8 +173,8 @@ input:focus-visible {
box-shadow: box-shadow:
inset 0 1px 0 var(--glass-edge-strong), inset 0 1px 0 var(--glass-edge-strong),
var(--shadow-control); var(--shadow-control);
backdrop-filter: blur(var(--blur-control)) saturate(1.08); backdrop-filter: blur(var(--blur-control));
-webkit-backdrop-filter: blur(var(--blur-control)) saturate(1.08); -webkit-backdrop-filter: blur(var(--blur-control));
} }
.glass-menu { .glass-menu {
@@ -172,8 +182,8 @@ input:focus-visible {
box-shadow: box-shadow:
inset 0 1px 0 var(--glass-edge-strong), inset 0 1px 0 var(--glass-edge-strong),
0 18px 48px rgba(15, 23, 42, 0.16); 0 18px 48px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(var(--blur-panel)) saturate(1.04); backdrop-filter: blur(var(--blur-panel));
-webkit-backdrop-filter: blur(var(--blur-panel)) saturate(1.04); -webkit-backdrop-filter: blur(var(--blur-panel));
} }
.glass-focus { .glass-focus {
@@ -181,8 +191,8 @@ input:focus-visible {
box-shadow: box-shadow:
inset 0 1px 0 var(--glass-edge-strong), inset 0 1px 0 var(--glass-edge-strong),
var(--shadow-focus); var(--shadow-focus);
backdrop-filter: blur(var(--blur-focus)) saturate(1.06); backdrop-filter: blur(var(--blur-focus));
-webkit-backdrop-filter: blur(var(--blur-focus)) saturate(1.06); -webkit-backdrop-filter: blur(var(--blur-focus));
} }
.glass-info { .glass-info {
@@ -211,6 +221,7 @@ input:focus-visible {
} }
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass-air,
.glass-navigation, .glass-navigation,
.glass-panel, .glass-panel,
.glass-readable, .glass-readable,
@@ -243,6 +254,7 @@ input:focus-visible {
} }
@media (prefers-reduced-transparency: reduce) { @media (prefers-reduced-transparency: reduce) {
.glass-air,
.glass-navigation, .glass-navigation,
.glass-panel, .glass-panel,
.glass-readable, .glass-readable,
@@ -276,37 +288,27 @@ input:focus-visible {
} }
} }
@media (pointer: coarse) { .agent-panel-shell {
.glass-navigation, border-color: rgba(148, 163, 184, 0.34);
.glass-panel, background: #f8fafc;
.glass-readable, box-shadow:
.glass-control, 0 24px 64px rgba(15, 23, 42, 0.18),
.glass-menu, 0 2px 8px rgba(15, 23, 42, 0.08);
.glass-focus, backdrop-filter: none;
.command-panel { -webkit-backdrop-filter: none;
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 { .agent-panel-band {
background: var(--glass-menu); background: #ffffff;
} }
.agent-panel-conversation { .agent-panel-conversation {
background: rgba(255, 255, 255, 0.34); background: #f8fafc;
} }
.agent-panel-message { .agent-panel-message {
border: 1px solid rgba(226, 232, 240, 0.72); border: 1px solid #e2e8f0;
background: rgba(255, 255, 255, 0.94); background: #ffffff;
} }
.agent-streaming-response { .agent-streaming-response {
@@ -331,18 +333,18 @@ input:focus-visible {
} }
.agent-panel-nested { .agent-panel-nested {
border: 1px solid rgba(203, 213, 225, 0.72); border: 1px solid #e2e8f0;
background: rgba(248, 250, 252, 0.9); background: #f8fafc;
} }
.agent-panel-control { .agent-panel-control {
border: 1px solid rgba(255, 255, 255, 0.8); border: 1px solid #e2e8f0;
background: rgba(255, 255, 255, 0.96); background: #ffffff;
} }
.agent-panel-icon-button { .agent-panel-icon-button {
border: 1px solid rgba(203, 213, 225, 0.72); border: 1px solid #e2e8f0;
background: rgba(255, 255, 255, 0.92); background: #f8fafc;
box-shadow: none; box-shadow: none;
} }
@@ -4,7 +4,6 @@ import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona"; import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles"; } from "@/features/map/core/components/map-control-styles";
import { AgentPersona } from "./agent-persona"; import { AgentPersona } from "./agent-persona";
@@ -16,64 +15,25 @@ type AgentCollapsedRailProps = {
}; };
export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) { export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) {
const state = getCollapsedAgentState(statusLabel, personaState);
return ( return (
<aside className={cn("agent-rail-enter pointer-events-auto w-[188px] p-2", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}> <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)}>
<div className="agent-panel-control agent-rail-item rounded-2xl p-2"> <button
<div className="flex items-center gap-2"> type="button"
<AgentPersona aria-label="展开排水助手面板"
className="h-10 w-10" title="展开排水助手面板"
fallbackClassName="h-10 w-10" onClick={onExpand}
state={personaState} 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"
statusLabel={statusLabel} >
/> <AgentPersona
<div className="min-w-0 flex-1"> className="h-12 w-12"
<p className="truncate text-xs font-semibold text-slate-950">HydroAgent</p> fallbackClassName="h-12 w-12"
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs font-semibold text-slate-500"> state={personaState}
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", state.dotClassName)} /> statusLabel={statusLabel}
<span className="truncate" title={statusLabel}>{state.label}</span> />
</p> <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">
</div> <ChevronRight size={17} strokeWidth={2.25} aria-hidden="true" />
<button </span>
type="button" </button>
aria-label="展开 Agent 面板"
title="展开 Agent 面板"
onClick={onExpand}
className="agent-panel-primary-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</div>
</aside> </aside>
); );
} }
function getCollapsedAgentState(statusLabel: string, personaState?: PersonaState) {
if (personaState === "asleep" || /失败|不可用|错误|降级/.test(statusLabel)) {
return {
label: "离线",
dotClassName: "bg-red-500"
};
}
if (personaState === "listening") {
return {
label: "待确认",
dotClassName: "bg-orange-500"
};
}
if (personaState === "thinking") {
return {
label: "处理中",
dotClassName: "bg-blue-500"
};
}
return {
label: "在线",
dotClassName: "bg-emerald-500"
};
}
+129 -55
View File
@@ -1,15 +1,16 @@
"use client"; "use client";
import { import {
Bolt, Activity,
CheckCircle2, CheckCircle2,
ChevronsUpDown, ChevronsUpDown,
ChevronLeft, ChevronLeft,
Gauge, DatabaseZap,
GitMerge,
History, History,
MapPinned,
Plus, Plus,
SendHorizontal, SendHorizontal
Sparkles
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react"; import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
@@ -40,7 +41,7 @@ import {
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
MAP_CONTROL_SURFACE_CLASS_NAME, MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles"; } from "@/features/map/core/components/map-control-styles";
import type { AgentChatSessionSummary } from "../api/client"; import type { AgentChatSessionSummary } from "../api/client";
@@ -100,6 +101,13 @@ const AGENT_PROMPT_SUGGESTIONS = [
"当前管网哪些区域可能存在雨污混接,判断依据是什么?" "当前管网哪些区域可能存在雨污混接,判断依据是什么?"
] as const; ] as const;
const AGENT_CAPABILITIES = [
{ icon: DatabaseZap, label: "监测质量诊断" },
{ icon: Activity, label: "异常状态研判" },
{ icon: GitMerge, label: "雨污混接识别" },
{ icon: MapPinned, label: "GIS 地图联动" }
] as const;
export function AgentCommandPanel({ export function AgentCommandPanel({
collapsing = false, collapsing = false,
personaState, personaState,
@@ -155,13 +163,14 @@ export function AgentCommandPanel({
<aside <aside
className={cn( className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden", "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_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME, MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
collapsing ? "agent-panel-collapse" : "agent-panel-enter" collapsing ? "agent-panel-collapse" : "agent-panel-enter"
)} )}
> >
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent"> <Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<div className="agent-panel-band border-b border-white/70"> <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 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-w-0 flex-1 items-center gap-3 text-slate-900">
<AgentPersona <AgentPersona
@@ -175,7 +184,7 @@ export function AgentCommandPanel({
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500"></p> <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-[var(--glass-readable)] 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 bg-slate-50 px-2 text-xs font-semibold text-slate-600">
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} /> <span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} />
{getAgentConnectionLabel(statusLabel)} {getAgentConnectionLabel(statusLabel)}
</span> </span>
@@ -273,7 +282,7 @@ export function AgentCommandPanel({
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0"> <AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0"> <Conversation className="agent-panel-conversation min-h-0">
<ConversationContent className="gap-4 p-3"> <ConversationContent className={cn("gap-4 p-3", messages.length === 0 && "min-h-full")}>
<AnimatePresence mode="popLayout" initial={false}> <AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? ( {messages.length > 0 ? (
<BackendMessageList <BackendMessageList
@@ -288,13 +297,17 @@ export function AgentCommandPanel({
) : ( ) : (
<motion.div <motion.div
key="empty" key="empty"
className="w-full" className="flex flex-1 items-center justify-center px-5 py-10"
initial="initial" initial="initial"
animate="animate" animate="animate"
exit="exit" exit="exit"
variants={agentPanelItemVariants} variants={agentPanelItemVariants}
> >
<AgentEmptyState statusLabel={statusLabel} /> <AgentEmptyState
personaState={personaState}
statusLabel={statusLabel}
streaming={streaming}
/>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -312,22 +325,25 @@ export function AgentCommandPanel({
</Conversation> </Conversation>
</AgentContent> </AgentContent>
<div className="agent-panel-band space-y-2 border-t border-white/70 p-3"> <div className="agent-panel-band relative border-t border-slate-200 p-3">
<Suggestions aria-label="推荐问题" role="group" className="px-0.5"> {messages.length === 0 && !streaming ? (
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => ( <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">
<Suggestion <Suggestions aria-label="推荐问题" role="group" className="pointer-events-auto px-0.5">
key={suggestion} {AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
suggestion={suggestion} <Suggestion
disabled={streaming} key={suggestion}
onClick={submitPrompt} suggestion={suggestion}
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" 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"
<span className="truncate">{suggestion}</span> >
</Suggestion> <span className="truncate">{suggestion}</span>
))} </Suggestion>
</Suggestions> ))}
</Suggestions>
</div>
) : null}
<PromptInput <PromptInput
className="agent-panel-control rounded-2xl shadow-sm" className="agent-panel-control overflow-hidden rounded-2xl shadow-sm transition-[border-color,box-shadow] has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:!ring-0"
onSubmit={(message) => { onSubmit={(message) => {
const nextPrompt = message.text.trim(); const nextPrompt = message.text.trim();
if (nextPrompt) { if (nextPrompt) {
@@ -343,10 +359,7 @@ export function AgentCommandPanel({
placeholder="输入调度问题,Agent 将通过后端会话流式响应" placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/> />
</PromptInputBody> </PromptInputBody>
<PromptInputFooter> <PromptInputFooter className="justify-end">
<span className="min-w-0 flex-1 truncate text-xs text-slate-500" title={statusLabel}>
{statusLabel}
</span>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
<AgentModelSelect <AgentModelSelect
models={modelOptions} models={modelOptions}
@@ -494,25 +507,25 @@ function AgentModelSelect({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="h-8 w-[126px] shrink-0 justify-between rounded-md border-transparent bg-transparent px-2 text-xs text-slate-600 shadow-none hover:bg-white/80" className="h-8 w-24 shrink-0 justify-between gap-1.5 rounded-md border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50"
disabled={!onValueChange} disabled={!onValueChange}
aria-label="选择 Agent 模型" aria-label="选择 Agent 模型"
> >
<span className="flex min-w-0 items-center gap-1.5"> <span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} size={13} /> <ModelIcon model={selectedModel} size={selectedModel?.icon === "bolt" ? 15 : 14} />
<span className="truncate">{selectedModel?.label ?? "模型"}</span> <span className="shrink-0">{selectedModel?.label ?? "模型"}</span>
</span> </span>
<ChevronsUpDown size={13} className="shrink-0 opacity-50" aria-hidden="true" /> <ChevronsUpDown size={12} className="shrink-0 opacity-50" aria-hidden="true" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"
align="end" align="end"
sideOffset={8} sideOffset={8}
className="agent-panel-control w-72 rounded-xl p-2 shadow-lg" className="agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
> >
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500"> <DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
Agent
</DropdownMenuLabel> </DropdownMenuLabel>
<div className="space-y-1"> <div className="space-y-1">
{models.map((model) => { {models.map((model) => {
@@ -522,12 +535,12 @@ function AgentModelSelect({
key={model.id} key={model.id}
className={cn( className={cn(
"rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50", "rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
selected && "glass-agent border-violet-200 focus:bg-violet-50" selected && "border-violet-200 bg-violet-50 focus:bg-violet-50"
)} )}
onSelect={() => onValueChange?.(model.id)} onSelect={() => onValueChange?.(model.id)}
> >
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-white text-slate-600 shadow-sm"> <span className="ml-0.5 shrink-0 pt-0.5">
<ModelIcon model={model} size={15} /> <ModelIcon model={model} size={model.icon === "bolt" ? 18 : 16} />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">{model.label}</span> <span className="block truncate text-sm font-semibold text-slate-800">{model.label}</span>
@@ -552,25 +565,86 @@ function getAgentConnectionClassName(statusLabel: string) {
} }
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) { function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
return model?.icon === "bolt" ? ( return model?.icon === "bolt" ? <FastModelIcon size={size} /> : <ExpertModelIcon size={size} />;
<Bolt size={size} className="text-amber-500" aria-hidden="true" /> }
) : (
<Sparkles size={size} className="text-violet-500" aria-hidden="true" /> function FastModelIcon({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className="text-sky-600"
aria-hidden="true"
>
<path
d="M10.67 21c-.35 0-.62-.31-.57-.66L11 14H7.5c-.88 0-.33-.75-.31-.78 1.26-2.23 3.15-5.53 5.65-9.93.1-.18.3-.29.5-.29.35 0 .62.31.57.66l-.9 6.34h3.51c.4 0 .62.19.4.66-3.29 5.74-5.2 9.09-5.75 10.05-.1.18-.29.29-.5.29"
fill="currentColor"
/>
</svg>
); );
} }
function AgentEmptyState({ statusLabel }: { statusLabel: string }) { function ExpertModelIcon({ size }: { size: number }) {
return ( return (
<Message from="assistant" className="max-w-full"> <svg
<MessageContent className="agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-600 shadow-sm"> width={size}
<div className="flex items-center gap-2"> height={size}
<Gauge size={15} className="text-blue-600" aria-hidden="true" /> viewBox="0 0 24 24"
<p className="font-semibold text-slate-900"></p> fill="none"
</div> className="text-violet-600"
<p className="mt-2"></p> aria-hidden="true"
<p className="mt-1 text-xs text-slate-500">{statusLabel}</p> >
</MessageContent> <path
</Message> d="m19.46 8 .79-1.75L22 5.46c.39-.18.39-.73 0-.91l-1.75-.79L19.46 2c-.18-.39-.73-.39-.91 0l-.79 1.75-1.76.79c-.39.18-.39.73 0 .91l1.75.79.79 1.76c.18.39.74.39.92 0M11.5 9.5 9.91 6c-.35-.78-1.47-.78-1.82 0L6.5 9.5 3 11.09c-.78.36-.78 1.47 0 1.82l3.5 1.59L8.09 18c.36.78 1.47.78 1.82 0l1.59-3.5 3.5-1.59c.78-.36.78-1.47 0-1.82zm7.04 6.5-.79 1.75-1.75.79c-.39.18-.39.73 0 .91l1.75.79.79 1.76c.18.39.73.39.91 0l.79-1.75 1.76-.79c.39-.18.39-.73 0-.91l-1.75-.79-.79-1.76c-.18-.39-.74-.39-.92 0"
fill="currentColor"
/>
</svg>
);
}
function AgentEmptyState({
personaState,
statusLabel,
streaming
}: {
personaState?: PersonaState;
statusLabel: string;
streaming: boolean;
}) {
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"
>
<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">
Agent
</p>
<ul className="mt-4 grid w-full grid-cols-2 gap-2" 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"
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-600">
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="whitespace-nowrap">{label}</span>
</li>
))}
</ul>
</section>
); );
} }
@@ -11,6 +11,9 @@ export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
export const MAP_CONTROL_SURFACE_CLASS_NAME = export const MAP_CONTROL_SURFACE_CLASS_NAME =
"glass-control border"; "glass-control border";
export const MAP_AIR_SURFACE_CLASS_NAME =
"glass-air border";
export const MAP_TOOL_PANEL_SURFACE_CLASS_NAME = export const MAP_TOOL_PANEL_SURFACE_CLASS_NAME =
"glass-panel border"; "glass-panel border";
+2 -2
View File
@@ -3,7 +3,7 @@
import type { Map as MapLibreMap } from "maplibre-gl"; import type { Map as MapLibreMap } from "maplibre-gl";
import { type RefObject, useEffect, useState } from "react"; import { type RefObject, useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles"; import { MAP_AIR_SURFACE_CLASS_NAME } from "./map-control-styles";
type ScaleLineState = { type ScaleLineState = {
label: string; label: string;
@@ -102,7 +102,7 @@ export function MapScaleLine({
aria-label={`比例尺 ${scale.label}${coordinateLabel}`} aria-label={`比例尺 ${scale.label}${coordinateLabel}`}
className={cn( 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", "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_CONTROL_SURFACE_CLASS_NAME, MAP_AIR_SURFACE_CLASS_NAME,
className className
)} )}
> >
@@ -46,12 +46,12 @@ const FEATURE_LAYER_META: Record<
iconClassName: string; iconClassName: string;
} }
> = { > = {
conduits: { icon: ConduitFeatureIcon, label: "管渠", headerClassName: "glass-normal", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }, conduits: { icon: ConduitFeatureIcon, label: "管渠", headerClassName: "bg-teal-50/70", 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" }, junctions: { icon: JunctionFeatureIcon, label: "检查井", headerClassName: "bg-blue-50/70", 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" }, orifices: { icon: OrificeFeatureIcon, label: "孔口", headerClassName: "bg-slate-50/70", 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" }, outfalls: { icon: OutfallFeatureIcon, label: "排放口", headerClassName: "bg-blue-50/70", 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" }, pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-teal-50/70", 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" } scada: { icon: JunctionFeatureIcon, label: "SCADA 测点", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-blue-700 text-white ring-blue-900/10" }
}; };
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = { const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
@@ -128,7 +128,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
return ( return (
<aside <aside
className={cn( className={cn(
"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", "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",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)} )}
> >
@@ -218,7 +218,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
return ( return (
<aside <aside
className={cn( className={cn(
"glass-focus pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] border", "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",
"-translate-x-1/2 overflow-hidden", "-translate-x-1/2 overflow-hidden",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)} )}
@@ -232,7 +232,8 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
{formatFeaturePropertyValue("device_type_name", properties.device_type_name)} {formatFeaturePropertyValue("device_type_name", properties.device_type_name)}
</span> </span>
<span className={cn( <span className={cn(
"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", "inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold ring-1 ring-inset",
active ? "bg-emerald-500/10" : "bg-slate-500/10",
active ? "text-emerald-700 ring-emerald-600/20" : "text-slate-600 ring-slate-500/20" 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")} /> <span className={cn("h-1.5 w-1.5 rounded-full", active ? "bg-emerald-500" : "bg-slate-400")} />
@@ -288,8 +289,8 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
function getScadaPresentation(rawTypeId: unknown) { function getScadaPresentation(rawTypeId: unknown) {
const typeId = Number(rawTypeId); const typeId = Number(rawTypeId);
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "glass-normal", labelClassName: "text-teal-800" }; if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800" };
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "glass-info", labelClassName: "text-blue-800" }; if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800" };
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "glass-warning", labelClassName: "text-orange-800" }; if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "bg-orange-50/70", labelClassName: "text-orange-800" };
return { iconPath: SCADA_ICON_PATHS.unknown, headerClassName: "bg-[var(--glass-readable)]", labelClassName: "text-slate-700" }; return { iconPath: SCADA_ICON_PATHS.unknown, headerClassName: "bg-slate-50/70", labelClassName: "text-slate-700" };
} }
@@ -242,10 +242,10 @@ export function ScheduledConditionFeed({
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none", "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]", expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
"rounded-2xl border", "rounded-2xl border",
"glass-control" expanded ? "glass-panel" : "glass-control"
)} )}
> >
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", "rounded-xl", MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}> <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)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}> <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" /> <CalendarClock size={17} aria-hidden="true" />
</span> </span>
@@ -607,7 +607,7 @@ function ConditionDetailSkeleton() {
<DetailScroll> <DetailScroll>
<div <div
className={cn( className={cn(
"relative min-h-[420px] overflow-hidden border border-slate-200/80 bg-[var(--glass-menu)] px-4 py-3", "relative min-h-[420px] overflow-hidden border border-slate-200/60 bg-white/30 px-4 py-3",
"rounded-xl" "rounded-xl"
)} )}
role="status" role="status"
@@ -307,7 +307,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || ""); setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
if (chatRef.current.status === "ready") { if (chatRef.current.status === "ready") {
setRuntimeAvailability("connected"); setRuntimeAvailability("connected");
setStatusLabel("Agent 后端已连接"); setStatusLabel("准备就绪");
} }
}, [backendConnection]); }, [backendConnection]);
+3 -4
View File
@@ -57,6 +57,7 @@ import type { DetailFeature, ScheduledConditionItem, ScheduledConditionRecord, W
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts"; import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
const HEADER_DATA_TIME_STEP_MINUTES = 5; const HEADER_DATA_TIME_STEP_MINUTES = 5;
const INITIAL_HEADER_DATA_TIME = "同步中";
function formatHeaderDataTime(date: Date) { function formatHeaderDataTime(date: Date) {
const snappedMinutes = Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES; const snappedMinutes = Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
@@ -85,9 +86,7 @@ export function MapWorkbenchPage() {
const mapContainerRef = useRef<HTMLDivElement | null>(null); const mapContainerRef = useRef<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null); const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [impactVisible, setImpactVisible] = useState(false); const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState( const [isLargeScreen, setIsLargeScreen] = useState(false);
() => typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches
);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null); const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const [conditionFeedVisible, setConditionFeedVisible] = useState(true); const [conditionFeedVisible, setConditionFeedVisible] = useState(true);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true); const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
@@ -107,7 +106,7 @@ export function MapWorkbenchPage() {
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]); const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]); const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true); const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
const [headerDataTime, setHeaderDataTime] = useState(() => formatHeaderDataTime(new Date())); const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const baseLayerOptions = useMemo( const baseLayerOptions = useMemo(
() => () =>
BASE_LAYER_OPTIONS.map((option) => ({ BASE_LAYER_OPTIONS.map((option) => ({
+3 -1
View File
@@ -101,13 +101,15 @@ const getCurrentTheme = (): "light" | "dark" => {
}; };
const useTheme = (enabled: boolean) => { const useTheme = (enabled: boolean) => {
const [theme, setTheme] = useState<"light" | "dark">(getCurrentTheme); const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
return; return;
} }
setTheme(getCurrentTheme());
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
setTheme(getCurrentTheme()); setTheme(getCurrentTheme());
}); });