style: refine acrylic workbench surfaces
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
.env.local
|
||||
.next/
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
tsconfig.tsbuildinfo
|
||||
供水管网_Agent_WebGIS_设计与技术框架.md
|
||||
现代 WebGIS 与 LLM Agent 地图 UI、UX 设计研究.md
|
||||
|
||||
@@ -11,7 +11,7 @@ The visual direction is restrained technical immersion:
|
||||
- Full-screen spatial workspace by default.
|
||||
- Clear operational hierarchy over decorative spectacle.
|
||||
- Light engineering map as the default canvas.
|
||||
- Floating translucent command surfaces with strong readability.
|
||||
- Acrylic command instruments with strong reading surfaces.
|
||||
- Blue as the primary action and selection color.
|
||||
- Green/cyan for normal network flow, orange for risk, red for incident and impact, purple only for model or Agent inference.
|
||||
- Motion used to explain spatial change, simulation progress, and recommended actions.
|
||||
@@ -88,16 +88,16 @@ Recommended canvas colors:
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---:|---|
|
||||
| `canvas-map` | `#eaf1f8` | Fallback map background |
|
||||
| `canvas-map` | `oklch(94.5% 0.012 250)` | Fallback map background |
|
||||
| `canvas-map-grid` | `rgba(37, 99, 235, 0.06)` | Subtle engineering grid |
|
||||
| `canvas-page` | `#f8fafc` | Non-map support pages |
|
||||
| `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-soft` | `rgba(255, 255, 255, 0.84)` | Low-density spatial overlay without critical text |
|
||||
| `surface-overlay` | `glass-menu` | Floating menu or compact choice shell |
|
||||
| `surface-readable` | `glass-readable` | Right-side details, compact results and summaries |
|
||||
| `surface-map-control` | `glass-control` | Always-visible toolbar, zoom and compact task shells |
|
||||
| `surface-tool-panel` | `glass-panel` | Toolbar panels, expanded conditions and Agent shell |
|
||||
| `acrylic-navigation` | Adaptive 68-82% cool-blue tint | Workbench top bar |
|
||||
| `acrylic-panel` | Adaptive translucent cool-blue tint over active basemaps | Agent, conditions, menus and focused panels |
|
||||
| `acrylic-control` | Adaptive clean cool-white tint with light blue edge | Toolbar, zoom, scaleline and standalone compact controls |
|
||||
| `surface-control` | 95-98% cool-white, no blur | Headers, inputs and compact controls inside acrylic shells |
|
||||
| `surface-well` | 28-34% cool-gray overlay, no blur | Conversation wells and empty support regions |
|
||||
| `surface-reading` | 97-99% white, no blur | Dense text, forms, tables and messages |
|
||||
| `glass-transient` | Adaptive 56-72% cool-blue acrylic | Active task ticker only |
|
||||
|
||||
### Color System
|
||||
|
||||
@@ -112,8 +112,8 @@ Recommended canvas colors:
|
||||
| `ink-secondary` | `#334155` | Secondary labels and values |
|
||||
| `ink-muted` | `#64748b` | Descriptions, metadata |
|
||||
| `ink-disabled` | `#94a3b8` | Disabled controls |
|
||||
| `border-soft` | `rgba(255, 255, 255, 0.62)` | Glass panel border |
|
||||
| `border-strong` | `rgba(148, 163, 184, 0.34)` | Dense control border |
|
||||
| `border-soft` | `oklch(90.5% 0.009 250)` | Dividers and nested groups |
|
||||
| `border-strong` | `oklch(86.5% 0.012 250)` | Floating surface border |
|
||||
|
||||
#### Operational Colors
|
||||
|
||||
@@ -138,11 +138,12 @@ Color must carry meaning. Do not use orange, red, green, or purple as decoration
|
||||
Use a system font stack that performs well for Chinese and English operational UI:
|
||||
|
||||
```css
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
|
||||
"PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
```
|
||||
|
||||
Avoid negative letter spacing. This product should optimize for scanning dense operational data, not editorial display styling.
|
||||
Use tabular numbers for time, counters, coordinates and changing measurements.
|
||||
|
||||
| Token | Size | Weight | Line Height | Use |
|
||||
|---|---:|---:|---:|---|
|
||||
@@ -197,131 +198,59 @@ Use a clear radius hierarchy over the map:
|
||||
|
||||
### Elevation
|
||||
|
||||
Elevation should communicate interaction depth over the map.
|
||||
Elevation communicates operational hierarchy through adaptive acrylic outer shells, high-opacity reading surfaces and restrained shadows. The map may influence empty shell regions, but roads and satellite texture must not remain legible beneath operational text.
|
||||
|
||||
| Level | Treatment | Use |
|
||||
|---|---|---|
|
||||
| `map` | No shadow | Basemap and business layers |
|
||||
| `control` | White 90-96%, soft border, small shadow | Zoom, basemap, tool buttons, persistent command shells |
|
||||
| `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 |
|
||||
#### Surface Tokens
|
||||
|
||||
Recommended shadows:
|
||||
Tailwind owns layout and state. Global semantic classes own surface color, border and elevation. Do not reproduce their values inside components.
|
||||
|
||||
- `control`: `0 14px 34px rgba(15, 23, 42, 0.18)`
|
||||
- `panel`: `0 24px 70px rgba(30, 64, 115, 0.20)`
|
||||
- `focus`: `0 30px 90px rgba(15, 23, 42, 0.26)`
|
||||
|
||||
Use `backdrop-filter: blur(20px)` only where the panel remains readable. If the map beneath is visually busy, increase surface opacity instead of increasing blur.
|
||||
|
||||
#### Transparency Layering
|
||||
|
||||
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`.
|
||||
|
||||
#### Transparency Material Tokens
|
||||
|
||||
| Token | Background | Blur | Shadow | Intended use |
|
||||
|---|---|---:|---|---|
|
||||
| `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.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.84)` | `20px` | `0 24px 70px rgba(30, 64, 115, 0.18)` | Tool panels, expanded conditions and Agent shell |
|
||||
| `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-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-focus` | `rgba(255, 255, 255, 0.94)` | `24px` | Tailwind `shadow-2xl shadow-blue-950/20` | Normal and SCADA property popovers only |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| Class | Background | Intended 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 |
|
||||
| `acrylic-navigation` | 68% on light, 82% on satellite | Workbench top bar, blur 20-24px |
|
||||
| `acrylic-panel` | Translucent cool-blue tint on light and satellite | Agent, conditions, menus, notices and focused popovers, blur 20-24px |
|
||||
| `acrylic-control` | 78% on light, 90% on satellite | Toolbar, zoom, scaleline and standalone compact controls, blur 16-20px |
|
||||
| `surface-control` | 95-98%, no blur | Headers, inputs and controls inside acrylic shells |
|
||||
| `surface-well` | 34% on light, 28% on satellite | Conversation wells and low-density empty regions |
|
||||
| `surface-reading` | 97-99%, no blur | Messages, reports, forms, tables and properties |
|
||||
| `glass-transient` | 56% on light, 72% on satellite | Active task ticker, blur 16-20px |
|
||||
|
||||
#### Colored Glass Tokens
|
||||
Semantic classes are `material-tone-info`, `material-tone-normal`, `material-tone-warning`, `material-tone-danger`, and `material-tone-agent`. They communicate state only, never hierarchy, and never apply blur.
|
||||
|
||||
Colored glass communicates operational meaning. It must not be used as decoration.
|
||||
#### Component Mapping
|
||||
|
||||
| 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 |
|
||||
| Component category | Surface token |
|
||||
|---|---|
|
||||
| Workbench top bar | `acrylic-navigation` |
|
||||
| Agent command shell and collapsed rail | `acrylic-panel` |
|
||||
| Conversation background | `surface-well` |
|
||||
| Zoom, toolbar and scaleline | `acrylic-control` |
|
||||
| Inputs and internal headers | `surface-control` or `surface-reading` |
|
||||
| Tool panels, conditions, menus, notices and popovers | `acrylic-panel` |
|
||||
| Messages, reports, tables and property blocks | `surface-reading` |
|
||||
| Active task ticker | `glass-transient` |
|
||||
| Ticker stack cards | `surface-control` |
|
||||
|
||||
Text on colored glass must use a darker shade of the same hue. Do not place neutral gray text on a colored surface.
|
||||
The task ticker is always centered against the full viewport, independent of Agent or scheduled-condition panel widths. Hide it while the scheduled-condition detail is expanded instead of shifting it away from center.
|
||||
|
||||
#### Canonical Focus Glass
|
||||
#### Composition And Performance
|
||||
|
||||
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 category | Material token | Notes |
|
||||
|---|---|---|
|
||||
| Workbench top bar | `glass-navigation` | Preserve map context across the full width; active items use a semantic colored glass token |
|
||||
| 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 |
|
||||
| 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` 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 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 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.
|
||||
- Long text, editable fields, tables with many columns and destructive confirmations use `surface-solid` even when their parent shell is translucent.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Hover feedback may change highlight and edge opacity. Do not animate `backdrop-filter` or background blur.
|
||||
- Apply `backdrop-filter` only to the outermost acrylic shell. Descendants must use non-filtered surface classes.
|
||||
- Keep the acrylic shell as the first composited visual layer above the map. Positioning ancestors must not retain `transform`, `filter`, `opacity`, `isolation` or `will-change: transform`; presence motion belongs on the acrylic shell itself and must leave no persistent compositor hint after it settles.
|
||||
- Scheduled conditions use `acrylic-panel` only for the outer shell. Internal headers, filters, timeline rows, detail reports and KPI blocks use non-filtered content surfaces (`surface-control` or `surface-reading`) so the panel has one acrylic layer and readable upper content.
|
||||
- The acrylic recipe is blur plus controlled backdrop saturation, a cool tint, a luminosity layer, a low-opacity exclusion layer and fine monochrome noise. Blur and tint alone are only frosted glass and do not satisfy this contract.
|
||||
- Follow Microsoft Fluent acrylic principles: tint, blur, noise and layer purpose create the material. Do not use gray or muddy color casts to make glass feel deeper.
|
||||
- Acrylic regions may coexist only when they do not overlap. Tool panels replace the condition panel instead of stacking over it.
|
||||
- Floating acrylic panels use a translucent edge, an inset top highlight and one restrained shadow.
|
||||
- Light, satellite and no-basemap modes adjust acrylic opacity through `data-basemap-tone`; they do not alter component markup.
|
||||
- Separate adjacent regions with dividers or background steps instead of nested cards.
|
||||
- Use semantic color only for status, selection, device type or Agent inference.
|
||||
- Under `prefers-reduced-transparency: reduce`, all acrylic and glass surfaces become 98% opaque and remove blur.
|
||||
- Browsers without `backdrop-filter` support use the same high-opacity fallback.
|
||||
- Maintain WCAG AA contrast against the declared reading surface.
|
||||
|
||||
#### 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.
|
||||
- Low-power fallback must be activated by an explicit performance-degradation condition, not inferred from touch or coarse pointer input.
|
||||
- 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.
|
||||
- Maintain WCAG AA contrast for all body text and controls against the composited result, not only against the declared RGBA value.
|
||||
`app/globals.css` is the single source of truth for surface values. `features/workbench/layout/workbench-layout.ts` is the single source for dock, condition, toolbar and ticker geometry used by both the rendered shell and map camera padding.
|
||||
|
||||
### Motion
|
||||
|
||||
@@ -371,7 +300,7 @@ The workspace should be composed from five persistent regions:
|
||||
| Region | Role | Behavior |
|
||||
|---|---|---|
|
||||
| Map Canvas | Primary spatial context | Always visible, full viewport |
|
||||
| Command Top Bar | System state | Thin, translucent, status-oriented |
|
||||
| Command Top Bar | System state | Thin, acrylic, status-oriented |
|
||||
| Agent Command Panel | Reasoning and recommended actions | Left side, collapsible, task-focused |
|
||||
| Analysis / Layer Rail | Tools, layers, results | Right side, icon-first, panels open on demand |
|
||||
| Simulation Timeline | Scenario time and playback | Bottom dock, visible during simulation |
|
||||
@@ -415,23 +344,23 @@ Use this pattern for persistent map instruments that start as compact lists and
|
||||
|
||||
The Agent panel is the main decision companion. It should make the Agent auditable and controllable.
|
||||
|
||||
On desktop, the Agent panel may be wider than a utility side panel because it carries the auditable decision chain. Use a preferred width around `460px`, with room to expand up to about `520px` on wide command workstations when the map camera reserves matching left padding. Do not widen it on medium or small screens; there it should become narrower or overlay on demand.
|
||||
On desktop, the Agent panel is a floating left-side instrument with 12px left, 96px top and 16px bottom offsets. Use `460px` from `1280px` through `1535px` and `500px` at `1536px` and above. Below `1024px` it becomes the existing single primary mobile panel.
|
||||
|
||||
The Agent panel is a persistent command instrument, so its outer shell should use the same control surface family as the map toolbar, zoom control, and scheduled-condition panel. It is not a low-opacity temporary glass panel.
|
||||
|
||||
| Agent surface | Opacity | Use |
|
||||
|---|---:|---|
|
||||
| Outer shell | `rgba(255, 255, 255, 0.92)` | Main floating panel, collapsed rail, and mobile entry. Reuse the map control surface treatment: soft white border, compact control shadow, subtle dark ring, and backdrop blur. |
|
||||
| Header, context strip, action tray, input band | `rgba(255, 255, 255, 0.96)` | Persistent readable bands that organize the command surface. |
|
||||
| Conversation well | `rgba(255, 255, 255, 0.34)` | Low-density scroll area behind messages. Do not place text directly on this layer. |
|
||||
| Message and recommendation surfaces | `rgba(255, 255, 255, 0.94)` | Assistant messages, recommendation groups, and primary readable content. |
|
||||
| Nested evidence/tool result blocks | `rgba(248, 250, 252, 0.90)` | Evidence tiles, tool run rows, and compact structured outputs inside messages. |
|
||||
| Prompt input and secondary controls | `rgba(255, 255, 255, 0.96)` | Text entry, secondary buttons, and controls that need crisp hit targets. |
|
||||
| Active confirmation surface | `rgba(255, 247, 237, 0.96)` | Agent action review and confirmation state. |
|
||||
| Agent region | Surface | Use |
|
||||
|---|---|---|
|
||||
| Outer floating shell | `acrylic-panel` | Persistent command region with adaptive opacity and one outer blur |
|
||||
| Header and action bands | `surface-control` | Quiet organizing bands |
|
||||
| Conversation well | `surface-well` | Low-density scroll area behind messages |
|
||||
| Message and recommendation surfaces | `surface-reading` | Assistant messages and primary readable content |
|
||||
| Nested evidence/tool result blocks | `surface-control` or `surface-reading` | Choose by text density |
|
||||
| Prompt input and secondary controls | `surface-reading` | Stable text-entry contrast |
|
||||
| Active confirmation surface | warning tone | Agent action review and confirmation state |
|
||||
|
||||
Do not return the Agent shell to a separate `0.78` panel treatment while the toolbar, zoom, and scheduled-condition panel use `surface-map-control`; those surfaces should read as one instrument family. Keep the conversation well lighter than the shell, and use readable local message surfaces for operational text. Do not use `0.58` or lower for any Agent region that contains operational text.
|
||||
Keep the conversation well visibly darker than the reading surfaces. Operational text must never sit directly on the map or on a translucent layer.
|
||||
|
||||
Collapsed Agent state should be content-sized, not full height. It should keep only the expand button, Agent identity, and pending status indicators. Expanding and collapsing should use a short `150-180ms` reveal: opacity, `4-8px` horizontal translation, and very small scale change. Do not animate the map camera when the Agent panel changes state.
|
||||
Collapsed Agent state is a floating 136px compact instrument that keeps the Agent identity and expand action. Expanding and collapsing uses a short `150-180ms` reveal with opacity and a small horizontal translation. The map updates padding without animating business layers.
|
||||
|
||||
Required sections:
|
||||
|
||||
@@ -458,17 +387,18 @@ The Agent should use purple only for model identity or inference metadata. Prima
|
||||
|
||||
The scheduled-condition panel is a persistent right-side command instrument for live operational tasks. It should visually belong to the same family as the toolbar, zoom control, and Agent shell, not to temporary tool panels.
|
||||
|
||||
Use the same shell treatment as `surface-map-control`:
|
||||
Use the adaptive acrylic outer-shell contract while preserving the established collapsible-detail geometry:
|
||||
|
||||
- Outer shell: `rgba(255, 255, 255, 0.92)`, soft white border, subtle dark ring, compact control shadow, and `backdrop-blur-xl`.
|
||||
- Outer shell: `acrylic-panel` in both states.
|
||||
- Outer radius: `rounded-2xl`, because the component reads as a major floating panel even when collapsed.
|
||||
- Header, list containers, rows, and detail cards: `surface-readable` or `canvas-panel-strong`, around `0.94-0.96`.
|
||||
- Header and compact controls: `surface-control`. Detail and report content: `surface-reading`.
|
||||
- Icon cells: `rounded-lg`, matching toolbar icon cells.
|
||||
- Rows and compact controls: `rounded-xl`.
|
||||
- Detail content blocks: `rounded-xl` where the block carries multi-line text or structured details.
|
||||
|
||||
Collapsed state:
|
||||
|
||||
- Preserve the existing `432px` compact width.
|
||||
- Keep the compact width list-focused, with enough height to reveal roughly six recent rows before scrolling.
|
||||
- Show recent conditions only. Do not show scheduled work orders in the collapsed state; they are secondary to the compact scanning task.
|
||||
- The header should reflect the compact content, such as recent-condition count. Avoid advertising hidden scheduled-work content unless the expanded state is open.
|
||||
@@ -477,6 +407,7 @@ Collapsed state:
|
||||
|
||||
Expanded state:
|
||||
|
||||
- Preserve the existing `880px` width and `960px` width at `1536px` and above.
|
||||
- Add a separate detail region beside the timeline list. The detail region may use the newly available horizontal space for evidence, Agent analysis, recommended options, and operational metadata.
|
||||
- Keep the timeline list column width and row typography close to the collapsed state. The list should feel like the same instrument after expansion.
|
||||
- Show scheduled work orders as a separate compact group above recent conditions only in expanded state. If there are many work orders, keep that group scrollable and height-limited.
|
||||
@@ -535,14 +466,14 @@ Reusable controls under `features/map/core/components` should share one compact
|
||||
|
||||
Zoom, toolbar, scaleline, and other always-visible map controls should use the `control` elevation level:
|
||||
|
||||
- Surface: use `surface-map-control`, fixed at `rgba(255, 255, 255, 0.92)`.
|
||||
- Border: white at roughly 70-80% opacity plus a very subtle dark ring when extra definition is needed.
|
||||
- Shadow: soft multi-layer map shadow, not card-like depth.
|
||||
- Backdrop blur: use lightly, only when text and icons remain readable.
|
||||
- Surface: use `acrylic-control` for the outer instrument and solid internal buttons.
|
||||
- Edge: 1px cool-gray boundary.
|
||||
- Shadow: the shared restrained acrylic instrument shadow.
|
||||
- Backdrop blur: apply only to the outer control shell, never to its internal buttons.
|
||||
- Radius: `rounded-xl` for grouped controls; `rounded-lg` for internal icon buttons.
|
||||
- Do not stack a `command-panel` or second glass wrapper around toolbar, zoom, Agent shell, or scheduled-condition shell.
|
||||
- Do not stack a second shell around toolbar, zoom, Agent shell, or scheduled-condition shell.
|
||||
|
||||
Prefer Tailwind default opacity utilities for UI component surfaces, such as `bg-white/95`, `bg-blue-50/95`, and `shadow-lg shadow-slate-900/20`. Keep explicit color strings only for platform APIs such as MapLibre paint values, runtime dynamic `style` values, syntax-highlighting variables, or rare cases where an audited opacity cannot be represented clearly by Tailwind defaults.
|
||||
Use semantic surface classes for hierarchy. Tailwind opacity utilities remain appropriate for transient hover and selected-state color, not for defining persistent surfaces.
|
||||
|
||||
#### Zoom Control
|
||||
|
||||
@@ -566,7 +497,7 @@ The map toolbar should follow the same visual system as zoom:
|
||||
- Default buttons are flat; hover uses soft blue background and subtle ring.
|
||||
- Active tools use blue fill with white icon/text and no heavy shadow.
|
||||
- Badges may use red only for operational alerts or counts that require attention.
|
||||
- The rail shell uses `surface-map-control` (`rgba(255, 255, 255, 0.92)`) and should not sit inside another translucent panel.
|
||||
- The rail shell uses `acrylic-control` and should not sit inside another elevated panel.
|
||||
|
||||
Opening a tool rail panel should not move the map camera by default. Treat tool panels as floating overlays unless they become persistent major panels that materially obstruct map work.
|
||||
|
||||
@@ -578,8 +509,8 @@ 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.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.
|
||||
- Toolbar function panels use `acrylic-panel` for the outer shell.
|
||||
- Internal list rows, panel headers, swatches, result blocks, and action rows use `surface-reading` or a flat divider layout.
|
||||
- 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.
|
||||
|
||||
#### Scaleline
|
||||
@@ -590,7 +521,7 @@ The scaleline should read as a map instrument, not a dashboard footer:
|
||||
- Width should be content-driven, not fixed.
|
||||
- When positioned at the bottom-right edge, it may touch the viewport edge directly.
|
||||
- If touching the bottom-right edge, only the top-left corner should be rounded.
|
||||
- Use the same `surface-map-control` opacity as toolbar and zoom so the bottom-right instrument cluster reads as one family.
|
||||
- Use the same `acrylic-control` surface as toolbar and zoom so the bottom-right instrument cluster reads as one family.
|
||||
- Coordinate readout should match the displayed projection. If the scaleline labels `EPSG:3857`, prioritize visible projected `X/Y`; longitude and latitude may remain as secondary debug context, not the primary readout.
|
||||
- Use only a short, smooth transition for the internal scale bar width. Keep metadata widths stable; do not animate the outer auto-sized container or coordinate text.
|
||||
|
||||
@@ -732,7 +663,7 @@ Primary buttons:
|
||||
|
||||
Secondary buttons:
|
||||
|
||||
- White or translucent fill.
|
||||
- White or cool-gray solid fill.
|
||||
- Blue or slate text.
|
||||
- Border or subtle ring.
|
||||
- Used for inspect, cancel, locate, details.
|
||||
@@ -875,7 +806,7 @@ The codebase should eventually align to this document rather than letting curren
|
||||
Recommended implementation direction:
|
||||
|
||||
- Keep route files thin and move feature UI into `features/workbench`, `features/map/core`, and `features/agent`.
|
||||
- Convert repeated glass surfaces, map controls, metric cards, status pills, and action cards into reusable typed components.
|
||||
- Keep adaptive acrylic shells and non-filtered reading surfaces behind shared semantic classes instead of repeating raw material values.
|
||||
- Keep MapLibre sources, layers, camera behavior, and interactions outside presentational React components.
|
||||
- Define operational colors as shared constants for both map layers and UI legends.
|
||||
- Treat Agent actions as typed data that the workbench can preview, validate, confirm, apply, and roll back.
|
||||
|
||||
+207
-183
@@ -4,46 +4,50 @@
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
background: #eaf1f8;
|
||||
--canvas-map: #eaf1f8;
|
||||
background: oklch(94.5% 0.012 250);
|
||||
--canvas-map: oklch(94.5% 0.012 250);
|
||||
--canvas-map-grid: rgba(37, 99, 235, 0.06);
|
||||
--canvas-panel: rgba(255, 255, 255, 0.94);
|
||||
--canvas-panel-strong: rgba(255, 255, 255, 0.98);
|
||||
--acrylic-navigation: rgba(242, 248, 252, 0.74);
|
||||
--acrylic-panel: rgba(232, 241, 249, 0.58);
|
||||
--acrylic-control: rgba(249, 251, 255, 0.84);
|
||||
--acrylic-panel-blur: 22px;
|
||||
--acrylic-control-blur: 18px;
|
||||
--acrylic-backdrop-saturation: 1.2;
|
||||
--acrylic-exclusion: rgba(255, 255, 255, 0.05);
|
||||
--acrylic-luminosity: rgba(255, 255, 255, 0.22);
|
||||
--acrylic-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='.06'/%3E%3C/svg%3E");
|
||||
--acrylic-border: rgba(255, 255, 255, 0.58);
|
||||
--acrylic-outline: rgba(71, 85, 105, 0.18);
|
||||
--acrylic-highlight: rgba(255, 255, 255, 0.82);
|
||||
--acrylic-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
|
||||
--surface-dock: oklch(97% 0.006 250);
|
||||
--surface-control: rgba(252, 253, 255, 0.96);
|
||||
--surface-well: rgba(232, 240, 248, 0.2);
|
||||
--surface-reading: rgba(255, 255, 255, 0.985);
|
||||
--glass-transient: rgba(248, 252, 255, 0.88);
|
||||
--surface-border: oklch(86.5% 0.012 250);
|
||||
--surface-divider: oklch(90.5% 0.009 250);
|
||||
--surface-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||||
--dock-shadow: 8px 0 24px rgba(15, 23, 42, 0.07);
|
||||
--radius-icon: 8px;
|
||||
--radius-control: 12px;
|
||||
--radius-panel: 16px;
|
||||
--radius-pill: 9999px;
|
||||
--action-blue: #2563eb;
|
||||
--action-blue-hover: #1d4ed8;
|
||||
--action-blue-soft: #dbeafe;
|
||||
--ink: #0f172a;
|
||||
--ink-secondary: #334155;
|
||||
--ink-muted: #64748b;
|
||||
--border-soft: rgba(255, 255, 255, 0.62);
|
||||
--border-strong: rgba(148, 163, 184, 0.34);
|
||||
--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 25px 50px -12px rgba(23, 37, 84, 0.2);
|
||||
--glass-air: rgba(255, 255, 255, 0.68);
|
||||
--glass-navigation: rgba(255, 255, 255, 0.8);
|
||||
--glass-panel: rgba(255, 255, 255, 0.84);
|
||||
--glass-readable: rgba(255, 255, 255, 0.9);
|
||||
--glass-control: rgba(255, 255, 255, 0.9);
|
||||
--glass-menu: rgba(255, 255, 255, 0.92);
|
||||
--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);
|
||||
--border-soft: var(--surface-divider);
|
||||
--border-strong: var(--surface-border);
|
||||
--tone-info: oklch(95% 0.035 250);
|
||||
--tone-normal: oklch(95% 0.04 175);
|
||||
--tone-warning: oklch(95% 0.05 70);
|
||||
--tone-danger: oklch(95% 0.045 25);
|
||||
--tone-agent: oklch(95% 0.035 290);
|
||||
--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%;
|
||||
@@ -66,6 +70,38 @@
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
[data-basemap-tone="light"] {
|
||||
--acrylic-navigation: rgba(242, 248, 252, 0.68);
|
||||
--acrylic-panel: rgba(232, 241, 249, 0.7);
|
||||
--acrylic-control: rgba(249, 251, 255, 0.78);
|
||||
--acrylic-panel-blur: 24px;
|
||||
--acrylic-control-blur: 20px;
|
||||
--acrylic-backdrop-saturation: 1.22;
|
||||
--acrylic-exclusion: rgba(255, 255, 255, 0.04);
|
||||
--acrylic-luminosity: rgba(255, 255, 255, 0.18);
|
||||
--surface-control: rgba(252, 253, 255, 0.95);
|
||||
--surface-well: rgba(232, 240, 248, 0.34);
|
||||
--surface-reading: rgba(255, 255, 255, 0.97);
|
||||
--glass-transient: rgba(238, 247, 253, 0.56);
|
||||
}
|
||||
|
||||
[data-basemap-tone="satellite"] {
|
||||
--acrylic-navigation: rgba(242, 248, 252, 0.82);
|
||||
--acrylic-panel: rgba(232, 241, 249, 0.7);
|
||||
--acrylic-control: rgba(250, 252, 255, 0.9);
|
||||
--acrylic-panel-blur: 20px;
|
||||
--acrylic-control-blur: 16px;
|
||||
--acrylic-backdrop-saturation: 1.1;
|
||||
--acrylic-exclusion: rgba(255, 255, 255, 0.06);
|
||||
--acrylic-luminosity: rgba(255, 255, 255, 0.24);
|
||||
--surface-control: rgba(252, 253, 255, 0.98);
|
||||
--surface-well: rgba(232, 240, 248, 0.28);
|
||||
--surface-reading: rgba(255, 255, 255, 0.99);
|
||||
--glass-transient: rgba(238, 247, 253, 0.72);
|
||||
--acrylic-outline: rgba(71, 85, 105, 0.24);
|
||||
--acrylic-shadow: 0 14px 38px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -78,9 +114,11 @@ body {
|
||||
|
||||
body {
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
|
||||
"PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -107,208 +145,196 @@ input:focus-visible {
|
||||
auto;
|
||||
}
|
||||
|
||||
.command-panel {
|
||||
background: var(--glass-panel);
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--glass-edge),
|
||||
var(--shadow-panel);
|
||||
backdrop-filter: blur(var(--blur-panel));
|
||||
-webkit-backdrop-filter: blur(var(--blur-panel));
|
||||
}
|
||||
|
||||
.glass-air,
|
||||
.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;
|
||||
.acrylic-navigation,
|
||||
.acrylic-panel,
|
||||
.acrylic-control,
|
||||
.surface-dock,
|
||||
.surface-control,
|
||||
.surface-well,
|
||||
.surface-reading,
|
||||
.glass-transient,
|
||||
.material-tone-info,
|
||||
.material-tone-normal,
|
||||
.material-tone-warning,
|
||||
.material-tone-danger,
|
||||
.material-tone-agent {
|
||||
border-color: var(--surface-border);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.glass-air {
|
||||
background-color: var(--glass-air);
|
||||
.acrylic-navigation,
|
||||
.acrylic-panel,
|
||||
.acrylic-control,
|
||||
.glass-transient {
|
||||
border-color: var(--acrylic-border);
|
||||
background-image:
|
||||
linear-gradient(var(--acrylic-exclusion), var(--acrylic-exclusion)),
|
||||
linear-gradient(var(--acrylic-luminosity), var(--acrylic-luminosity)),
|
||||
var(--acrylic-noise);
|
||||
background-blend-mode: exclusion, luminosity, soft-light;
|
||||
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));
|
||||
inset 0 1px 0 var(--acrylic-highlight),
|
||||
0 0 0 1px var(--acrylic-outline),
|
||||
var(--acrylic-shadow);
|
||||
}
|
||||
|
||||
.glass-navigation {
|
||||
background-color: var(--glass-navigation);
|
||||
.acrylic-navigation {
|
||||
background-color: var(--acrylic-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));
|
||||
-webkit-backdrop-filter: blur(var(--blur-navigation));
|
||||
inset 0 1px 0 var(--acrylic-highlight),
|
||||
0 1px 0 var(--acrylic-outline),
|
||||
0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
}
|
||||
|
||||
.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));
|
||||
-webkit-backdrop-filter: blur(var(--blur-panel));
|
||||
.acrylic-panel {
|
||||
background-color: var(--acrylic-panel);
|
||||
backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-panel-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
}
|
||||
|
||||
.glass-readable {
|
||||
background-color: var(--glass-readable);
|
||||
.acrylic-control {
|
||||
background-color: var(--acrylic-control);
|
||||
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));
|
||||
-webkit-backdrop-filter: blur(var(--blur-panel));
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.86),
|
||||
0 0 0 1px rgba(59, 130, 246, 0.14),
|
||||
0 14px 34px rgba(15, 23, 42, 0.13);
|
||||
backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
}
|
||||
|
||||
.glass-control {
|
||||
background-color: var(--glass-control);
|
||||
.surface-dock { background-color: var(--surface-dock); }
|
||||
.surface-control { background-color: var(--surface-control); }
|
||||
.surface-well { background-color: var(--surface-well); }
|
||||
.surface-reading { background-color: var(--surface-reading); }
|
||||
|
||||
.glass-transient {
|
||||
background-color: var(--glass-transient);
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--glass-edge-strong),
|
||||
var(--shadow-control);
|
||||
backdrop-filter: blur(var(--blur-control));
|
||||
-webkit-backdrop-filter: blur(var(--blur-control));
|
||||
inset 0 1px 0 var(--acrylic-highlight),
|
||||
0 0 0 1px var(--acrylic-outline),
|
||||
0 10px 28px rgba(15, 23, 42, 0.14);
|
||||
backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
-webkit-backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
|
||||
}
|
||||
|
||||
.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));
|
||||
-webkit-backdrop-filter: blur(var(--blur-panel));
|
||||
}
|
||||
|
||||
.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));
|
||||
-webkit-backdrop-filter: blur(var(--blur-focus));
|
||||
}
|
||||
|
||||
.glass-info {
|
||||
background-color: var(--glass-info);
|
||||
.material-tone-info {
|
||||
background-color: var(--tone-info);
|
||||
box-shadow: inset 0 1px 0 rgba(147, 197, 253, 0.42);
|
||||
}
|
||||
|
||||
.glass-normal {
|
||||
background-color: var(--glass-normal);
|
||||
.material-tone-normal {
|
||||
background-color: var(--tone-normal);
|
||||
box-shadow: inset 0 1px 0 rgba(94, 234, 212, 0.4);
|
||||
}
|
||||
|
||||
.glass-warning {
|
||||
background-color: var(--glass-warning);
|
||||
.material-tone-warning {
|
||||
background-color: var(--tone-warning);
|
||||
box-shadow: inset 0 1px 0 rgba(253, 186, 116, 0.46);
|
||||
}
|
||||
|
||||
.glass-danger {
|
||||
background-color: var(--glass-danger);
|
||||
.material-tone-danger {
|
||||
background-color: var(--tone-danger);
|
||||
box-shadow: inset 0 1px 0 rgba(252, 165, 165, 0.48);
|
||||
}
|
||||
|
||||
.glass-agent {
|
||||
background-color: var(--glass-agent);
|
||||
.material-tone-agent {
|
||||
background-color: var(--tone-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-air,
|
||||
.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;
|
||||
.acrylic-navigation,
|
||||
.acrylic-panel,
|
||||
.acrylic-control,
|
||||
.glass-transient {
|
||||
background-color: rgba(250, 252, 255, 0.98);
|
||||
background-image: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.glass-air,
|
||||
.glass-navigation,
|
||||
.glass-panel,
|
||||
.glass-readable,
|
||||
.glass-control,
|
||||
.glass-menu,
|
||||
.glass-focus,
|
||||
.command-panel {
|
||||
background-color: var(--surface-solid);
|
||||
.acrylic-navigation,
|
||||
.acrylic-panel,
|
||||
.acrylic-control,
|
||||
.glass-transient {
|
||||
background-color: rgba(250, 252, 255, 0.98);
|
||||
background-image: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.glass-info {
|
||||
.surface-well,
|
||||
.surface-control,
|
||||
.surface-reading {
|
||||
background-color: rgba(255, 255, 255, 0.98);
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.material-tone-info {
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.glass-normal {
|
||||
.material-tone-normal {
|
||||
background-color: #f0fdfa;
|
||||
}
|
||||
|
||||
.glass-warning {
|
||||
.material-tone-warning {
|
||||
background-color: #fff7ed;
|
||||
}
|
||||
|
||||
.glass-danger {
|
||||
.material-tone-danger {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
.glass-agent {
|
||||
.material-tone-agent {
|
||||
background-color: #f5f3ff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.acrylic-navigation,
|
||||
.acrylic-panel,
|
||||
.acrylic-control,
|
||||
.glass-transient,
|
||||
.surface-dock,
|
||||
.surface-control,
|
||||
.surface-well,
|
||||
.surface-reading,
|
||||
.material-tone-info,
|
||||
.material-tone-normal,
|
||||
.material-tone-warning,
|
||||
.material-tone-danger,
|
||||
.material-tone-agent {
|
||||
forced-color-adjust: none;
|
||||
border-color: CanvasText;
|
||||
background-color: Canvas;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
color: CanvasText;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-panel-shell {
|
||||
border-color: rgba(148, 163, 184, 0.34);
|
||||
background: #f8fafc;
|
||||
box-shadow:
|
||||
0 24px 64px rgba(15, 23, 42, 0.18),
|
||||
0 2px 8px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border-color: var(--acrylic-border);
|
||||
}
|
||||
|
||||
.agent-panel-band {
|
||||
background: #ffffff;
|
||||
background: var(--surface-control);
|
||||
}
|
||||
|
||||
.agent-panel-conversation {
|
||||
background: #f8fafc;
|
||||
background: var(--surface-well);
|
||||
}
|
||||
|
||||
.agent-panel-message {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--surface-divider);
|
||||
background: var(--surface-reading);
|
||||
}
|
||||
|
||||
.agent-streaming-response {
|
||||
@@ -333,18 +359,18 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.agent-panel-nested {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--surface-divider);
|
||||
background: var(--surface-control);
|
||||
}
|
||||
|
||||
.agent-panel-control {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(100, 116, 139, 0.16);
|
||||
background: var(--surface-reading);
|
||||
}
|
||||
|
||||
.agent-panel-icon-button {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--surface-divider);
|
||||
background: var(--surface-control);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -447,7 +473,6 @@ input:focus-visible {
|
||||
scheduled-feed-shell-show 170ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
agent-panel-fade 140ms ease-out;
|
||||
transform-origin: top right;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.scheduled-feed-shell-exit {
|
||||
@@ -456,12 +481,11 @@ input:focus-visible {
|
||||
agent-panel-dim 140ms ease-in forwards;
|
||||
pointer-events: none;
|
||||
transform-origin: top right;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.agent-panel-confirmation {
|
||||
border: 1px solid rgba(254, 215, 170, 0.92);
|
||||
background: rgba(255, 247, 237, 0.96);
|
||||
border: 1px solid rgba(253, 186, 116, 0.46);
|
||||
background: rgba(255, 247, 237, 0.82);
|
||||
}
|
||||
|
||||
.agent-panel-enter {
|
||||
@@ -653,11 +677,11 @@ input:focus-visible {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.scheduled-feed-layout {
|
||||
.scheduled-feed-panel-shell {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.scheduled-feed-panel-shell {
|
||||
.scheduled-feed-layout {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -678,9 +702,9 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group {
|
||||
border: 1px solid rgba(255, 255, 255, 0.7) !important;
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 14px 36px rgba(30, 64, 115, 0.14) !important;
|
||||
border: 1px solid var(--surface-border) !important;
|
||||
border-radius: var(--radius-control) !important;
|
||||
box-shadow: var(--surface-shadow) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
|
||||
type AgentCollapsedRailProps = {
|
||||
@@ -16,13 +14,13 @@ type AgentCollapsedRailProps = {
|
||||
|
||||
export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) {
|
||||
return (
|
||||
<aside className={cn("agent-rail-enter glass-control pointer-events-auto w-[136px] p-1.5 shadow-[0_6px_18px_rgba(15,23,42,0.12)]", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}>
|
||||
<aside className={cn("agent-rail-enter acrylic-panel pointer-events-auto w-[136px] border p-1.5", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开排水助手面板"
|
||||
title="展开排水助手面板"
|
||||
onClick={onExpand}
|
||||
className="agent-rail-item group flex h-14 w-full items-center justify-center gap-4 rounded-xl bg-white px-3 transition hover:bg-slate-50"
|
||||
className="agent-rail-item surface-control group flex h-14 w-full items-center justify-center gap-4 rounded-xl border px-3 transition-[background-color,transform] hover:bg-white active:scale-95"
|
||||
>
|
||||
<AgentPersona
|
||||
className="h-12 w-12"
|
||||
@@ -30,7 +28,7 @@ export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpa
|
||||
state={personaState}
|
||||
statusLabel={statusLabel}
|
||||
/>
|
||||
<span className="agent-panel-primary-icon grid h-8 w-8 shrink-0 place-items-center rounded-lg !border-0 transition group-hover:translate-x-0.5">
|
||||
<span className="agent-panel-primary-icon grid h-8 w-8 shrink-0 place-items-center rounded-lg !border-0 transition-transform group-hover:translate-x-0.5">
|
||||
<ChevronRight size={17} strokeWidth={2.25} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -40,10 +40,6 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
import {
|
||||
@@ -164,23 +160,23 @@ export function AgentCommandPanel({
|
||||
className={cn(
|
||||
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
|
||||
"agent-panel-shell",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
"acrylic-panel border",
|
||||
"rounded-2xl",
|
||||
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
|
||||
)}
|
||||
>
|
||||
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
|
||||
<div className="agent-panel-band border-b border-slate-200">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 text-slate-900">
|
||||
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
|
||||
<AgentPersona
|
||||
className="h-[72px] w-[72px]"
|
||||
fallbackClassName="h-[72px] w-[72px] rounded-2xl"
|
||||
className="h-10 w-10"
|
||||
fallbackClassName="h-10 w-10 rounded-xl"
|
||||
state={personaState}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500">对话主题</p>
|
||||
@@ -297,17 +293,13 @@ export function AgentCommandPanel({
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
className="flex flex-1 items-center justify-center px-5 py-10"
|
||||
className="flex flex-1 items-start justify-start px-4 py-6"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<AgentEmptyState
|
||||
personaState={personaState}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
/>
|
||||
<AgentEmptyState />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -327,14 +319,14 @@ export function AgentCommandPanel({
|
||||
|
||||
<div className="agent-panel-band relative border-t border-slate-200 p-3">
|
||||
{messages.length === 0 && !streaming ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-full z-10 bg-gradient-to-t from-white/95 via-white/80 to-transparent px-3 pb-2 pt-5">
|
||||
<div className="surface-dock pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
|
||||
<Suggestions aria-label="推荐问题" role="group" className="pointer-events-auto px-0.5">
|
||||
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
|
||||
<Suggestion
|
||||
key={suggestion}
|
||||
suggestion={suggestion}
|
||||
onClick={submitPrompt}
|
||||
className="max-w-full border-slate-200 bg-white text-slate-600 shadow-sm hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700"
|
||||
className="surface-reading max-w-full border border-slate-200 text-slate-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
>
|
||||
<span className="truncate">{suggestion}</span>
|
||||
</Suggestion>
|
||||
@@ -604,40 +596,25 @@ function ExpertModelIcon({ size }: { size: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentEmptyState({
|
||||
personaState,
|
||||
statusLabel,
|
||||
streaming
|
||||
}: {
|
||||
personaState?: PersonaState;
|
||||
statusLabel: string;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
function AgentEmptyState() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="agent-empty-state-title"
|
||||
className="mx-auto flex w-full max-w-sm flex-col items-center rounded-2xl border border-slate-200 bg-white p-4 text-center shadow-sm sm:p-5"
|
||||
className="mx-auto flex w-full max-w-sm flex-col px-2 py-4 text-left"
|
||||
>
|
||||
<AgentPersona
|
||||
className="mb-3 h-20 w-20"
|
||||
fallbackClassName="h-20 w-20 rounded-2xl"
|
||||
state={personaState}
|
||||
statusLabel={statusLabel}
|
||||
streaming={streaming}
|
||||
/>
|
||||
<p id="agent-empty-state-title" className="text-base font-semibold text-slate-900">
|
||||
从一个问题开始
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-72 text-xs leading-5 text-slate-500">
|
||||
<p className="mt-1.5 text-xs leading-5 text-slate-500">
|
||||
描述分析目标,或选择下方推荐问题。Agent 会整理证据并给出下一步建议。
|
||||
</p>
|
||||
<ul className="mt-4 grid w-full grid-cols-2 gap-2" aria-label="Agent 分析能力">
|
||||
<ul className="mt-5 w-full divide-y divide-slate-200 border-y border-slate-200" aria-label="Agent 分析能力">
|
||||
{AGENT_CAPABILITIES.map(({ icon: Icon, label }) => (
|
||||
<li
|
||||
key={label}
|
||||
className="flex min-h-16 flex-col items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-slate-50 px-2 py-3 text-xs font-semibold text-slate-700 min-[420px]:min-h-14 min-[420px]:flex-row min-[420px]:gap-2"
|
||||
className="flex min-h-11 items-center gap-3 px-1 text-xs font-semibold text-slate-700"
|
||||
>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-slate-200 bg-white text-slate-600">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
|
||||
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
|
||||
@@ -351,7 +351,7 @@ function PermissionRequestCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-[var(--glass-menu)] px-2.5 py-2 text-xs">
|
||||
<div className="surface-reading rounded-lg px-2.5 py-2 text-xs">
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
|
||||
@@ -479,7 +479,7 @@ function QuestionRequestCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-[var(--glass-menu)] px-2.5 py-2 text-xs">
|
||||
<div className="surface-reading rounded-lg px-2.5 py-2 text-xs">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-semibold text-slate-800">
|
||||
{request.questions[0]?.header || "问题"}
|
||||
@@ -597,7 +597,7 @@ function QuestionInput({
|
||||
return (
|
||||
<label
|
||||
key={option.label}
|
||||
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 bg-[var(--surface-solid)] px-2 py-1.5 text-slate-600"
|
||||
className="surface-reading flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 px-2 py-1.5 text-slate-600"
|
||||
>
|
||||
<input
|
||||
type={question.multiple ? "checkbox" : "radio"}
|
||||
@@ -620,7 +620,7 @@ function QuestionInput({
|
||||
) : null}
|
||||
{showCustomInput ? (
|
||||
<textarea
|
||||
className="min-h-16 w-full resize-none rounded-md border border-slate-200 bg-[var(--surface-solid)] px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||
className="surface-reading min-h-16 w-full resize-none rounded-md border border-slate-200 px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
|
||||
placeholder="输入回答"
|
||||
value={value.custom}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function AgentOperationalBrief({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-[var(--glass-menu)] px-3 text-xs font-semibold text-slate-700 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="surface-control flex h-9 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold text-slate-700 transition-colors hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={streaming}
|
||||
onClick={() => onSubmitCommand(brief.secondaryCommand)}
|
||||
>
|
||||
@@ -75,7 +75,7 @@ export function AgentOperationalBrief({
|
||||
|
||||
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border border-slate-200/70 bg-slate-50/90 px-2 py-2">
|
||||
<div className="surface-reading min-w-0 rounded-xl border px-2 py-2">
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<span className="text-blue-600">{icon}</span>
|
||||
{label}
|
||||
|
||||
@@ -303,7 +303,7 @@ function PropRows({ props, labels }: { props: unknown; labels: Record<string, st
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="min-w-0 rounded-lg bg-[var(--glass-menu)] px-2.5 py-2">
|
||||
<div key={row.label} className="surface-reading min-w-0 rounded-lg px-2.5 py-2">
|
||||
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
|
||||
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
|
||||
{row.value}
|
||||
|
||||
@@ -194,7 +194,7 @@ export function BaseLayersControl({
|
||||
) : (
|
||||
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
<span className="absolute bottom-1 left-1 right-1 rounded bg-[var(--glass-menu)] px-1 py-0.5 text-xs font-semibold leading-none text-slate-800 shadow-sm">
|
||||
<span className="surface-reading absolute bottom-1 left-1 right-1 rounded px-1 py-0.5 text-xs font-semibold leading-none text-slate-800">
|
||||
{activeLayer?.label ?? title}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function MapModeButton({ icon: Icon, label, active = false, disabled = fa
|
||||
className={cn(
|
||||
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
active ? "bg-blue-600 text-white shadow-sm" : "bg-[var(--glass-menu)] text-slate-600 hover:text-blue-700",
|
||||
active ? "bg-blue-600 text-white" : "surface-control text-slate-600 hover:text-blue-700",
|
||||
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
@@ -164,7 +164,7 @@ export function MapActionRow({
|
||||
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
|
||||
: strong
|
||||
? "border-blue-100 bg-blue-50/95 text-blue-800"
|
||||
: "border-transparent bg-[var(--glass-menu)] text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
|
||||
: "surface-control border-transparent text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
|
||||
isDanger && "hover:border-red-100 hover:text-red-700",
|
||||
selected && !isPrimary && "border-blue-200 bg-blue-50 text-blue-800",
|
||||
muted && "opacity-70",
|
||||
|
||||
@@ -69,8 +69,8 @@ export function MapLayerControl({
|
||||
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
item.visible
|
||||
? "border-blue-100 bg-[var(--glass-menu)] text-slate-900 shadow-md shadow-blue-950/10"
|
||||
: "border-transparent bg-[var(--glass-readable)] text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
? "surface-control border-blue-100 text-slate-900"
|
||||
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -65,7 +65,7 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
item.visible
|
||||
? "border-blue-200 bg-blue-50/90 text-blue-950 shadow-sm shadow-blue-950/5"
|
||||
: "border-transparent bg-[var(--glass-readable)] text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -9,19 +9,19 @@ export const MAP_COMPACT_RADIUS_CLASS_NAME = MAP_CONTROL_RADIUS_CLASS_NAME;
|
||||
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
|
||||
|
||||
export const MAP_CONTROL_SURFACE_CLASS_NAME =
|
||||
"glass-control border";
|
||||
|
||||
export const MAP_AIR_SURFACE_CLASS_NAME =
|
||||
"glass-air border";
|
||||
"acrylic-control border";
|
||||
|
||||
export const MAP_TOOL_PANEL_SURFACE_CLASS_NAME =
|
||||
"glass-panel border";
|
||||
"acrylic-panel border";
|
||||
|
||||
export const MAP_FOCUS_SURFACE_CLASS_NAME =
|
||||
"acrylic-panel border";
|
||||
|
||||
export const MAP_READABLE_SURFACE_CLASS_NAME =
|
||||
"border border-transparent bg-[var(--glass-readable)]";
|
||||
"surface-well border";
|
||||
|
||||
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
|
||||
"border border-transparent bg-[var(--glass-menu)]";
|
||||
"surface-control border";
|
||||
|
||||
export const MAP_CONTROL_HOVER_CLASS_NAME =
|
||||
"hover:bg-blue-100/70 hover:text-blue-700 hover:ring-1 hover:ring-blue-200/80 active:bg-blue-100";
|
||||
|
||||
@@ -72,7 +72,7 @@ export function MapMeasurePanel({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="模式">
|
||||
<div className={cn("grid grid-cols-3 gap-1.5 border border-transparent bg-[var(--glass-readable)] p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className={cn("surface-well grid grid-cols-3 gap-1.5 border p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
{modes.map((mode) => (
|
||||
<MapModeButton
|
||||
key={mode.id}
|
||||
@@ -121,7 +121,7 @@ export function MapMeasurePanel({
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
unit.id === activeUnitId
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-[var(--glass-menu)] text-slate-600 hover:text-blue-700"
|
||||
: "surface-control text-slate-600 hover:text-blue-700"
|
||||
)}
|
||||
>
|
||||
{unit.label}
|
||||
|
||||
@@ -45,11 +45,11 @@ const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
|
||||
};
|
||||
|
||||
const toneClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "glass-menu text-slate-900",
|
||||
success: "glass-menu text-slate-900",
|
||||
warning: "glass-menu text-slate-900",
|
||||
error: "glass-menu text-slate-900",
|
||||
loading: "glass-menu text-slate-900"
|
||||
info: "acrylic-panel text-slate-900",
|
||||
success: "acrylic-panel text-slate-900",
|
||||
warning: "acrylic-panel text-slate-900",
|
||||
error: "acrylic-panel text-slate-900",
|
||||
loading: "acrylic-panel text-slate-900"
|
||||
};
|
||||
|
||||
const iconClassNames: Record<MapNoticeTone, string> = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MAP_AIR_SURFACE_CLASS_NAME } from "./map-control-styles";
|
||||
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
|
||||
|
||||
type ScaleLineState = {
|
||||
label: string;
|
||||
@@ -102,7 +102,7 @@ export function MapScaleLine({
|
||||
aria-label={`比例尺 ${scale.label},${coordinateLabel}`}
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex w-auto max-w-full select-none items-center gap-2.5 overflow-hidden rounded-none rounded-tl-xl border-b-0 border-r-0 px-2.5 py-1.5 text-xs font-semibold leading-none text-slate-800",
|
||||
MAP_AIR_SURFACE_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -20,23 +20,18 @@ type TickerTransitionDirection = -1 | 1;
|
||||
|
||||
const tickerEase = [0.22, 1, 0.36, 1] as const;
|
||||
const tickerSwitchAnimationMs = 380;
|
||||
const tickerCardSurfaceClassName =
|
||||
"glass-control border";
|
||||
|
||||
const tickerCardVariants: Variants = {
|
||||
initial: (direction: TickerTransitionDirection) => ({
|
||||
opacity: 0.62,
|
||||
x: 0,
|
||||
y: direction > 0 ? 36 : -28,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.955) saturate(0.95)"
|
||||
rotate: 0
|
||||
}),
|
||||
exit: (direction: TickerTransitionDirection) => ({
|
||||
opacity: [1, 1, 0],
|
||||
x: 0,
|
||||
y: direction > 0 ? 36 : -28,
|
||||
rotate: 0,
|
||||
filter: "brightness(1.02) saturate(1)",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
times: [0, 0.65, 1],
|
||||
@@ -261,7 +256,7 @@ export function AgentTaskTicker({
|
||||
{isHovered && hasMultipleRunningTasks ? (
|
||||
<motion.div
|
||||
key="task-indicator"
|
||||
className="agent-task-ticker-indicator flex flex-col gap-1 rounded-full bg-[var(--glass-menu)] px-1 py-1 shadow-sm shadow-blue-950/10"
|
||||
className="agent-task-ticker-indicator surface-control flex flex-col gap-1 rounded-full border px-1 py-1"
|
||||
variants={tickerIndicatorVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
@@ -307,8 +302,7 @@ function TickerTaskCard({
|
||||
layout="position"
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-0 h-16 overflow-hidden rounded-[22px] px-3 py-2",
|
||||
tickerCardSurfaceClassName,
|
||||
isActive ? "z-30" : "pointer-events-none z-10"
|
||||
isActive ? "glass-transient z-30" : "surface-control pointer-events-none z-10 border"
|
||||
)}
|
||||
style={{ transformOrigin: "center top" }}
|
||||
custom={transitionDirection}
|
||||
@@ -319,7 +313,6 @@ function TickerTaskCard({
|
||||
x: stackStyle.x,
|
||||
y: stackStyle.y,
|
||||
rotate: stackStyle.rotate,
|
||||
filter: stackStyle.filter,
|
||||
transition: {
|
||||
duration: isActive ? 0.34 : 0.3,
|
||||
ease: tickerEase
|
||||
@@ -449,8 +442,7 @@ function getTickerStackStyle(stackIndex: number) {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 7,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.985) saturate(0.98)"
|
||||
rotate: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -459,8 +451,7 @@ function getTickerStackStyle(stackIndex: number) {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 14,
|
||||
rotate: 0,
|
||||
filter: "brightness(0.965) saturate(0.96)"
|
||||
rotate: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -468,7 +459,6 @@ function getTickerStackStyle(stackIndex: number) {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotate: 0,
|
||||
filter: "brightness(1) saturate(1)"
|
||||
rotate: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
}, [feature]);
|
||||
|
||||
return (
|
||||
<aside className="glass-readable pointer-events-auto flex h-full min-h-0 flex-col rounded border">
|
||||
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
|
||||
<div className="border-b border-slate-200/80 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -91,7 +91,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
aria-label="关闭详情"
|
||||
title="关闭详情"
|
||||
onClick={onClose}
|
||||
className="grid h-9 w-9 place-items-center rounded border border-slate-200 bg-white text-slate-500 hover:text-slate-900"
|
||||
className="surface-control grid h-9 w-9 place-items-center rounded border text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -106,7 +106,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{metrics.map(([label, value]) => (
|
||||
<div key={label} className="rounded border border-slate-200 bg-white p-3">
|
||||
<div key={label} className="surface-reading rounded border p-3">
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</p>
|
||||
</div>
|
||||
@@ -126,7 +126,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="glass-warning mt-4 rounded border border-orange-100 p-3">
|
||||
<section className="material-tone-warning mt-4 rounded border border-orange-100 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
|
||||
<TriangleAlert size={16} aria-hidden="true" />
|
||||
Agent 解释
|
||||
@@ -138,7 +138,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
|
||||
<section className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
||||
<dl className="mt-2 divide-y divide-slate-100 rounded border border-slate-200 bg-white">
|
||||
<dl className="surface-reading mt-2 divide-y divide-slate-100 rounded border">
|
||||
{propertyEntries.map((entry) => (
|
||||
<div key={entry.key} className="grid grid-cols-[110px_1fr] gap-2 px-3 py-2 text-sm">
|
||||
<dt className="truncate text-slate-500">{entry.label}</dt>
|
||||
@@ -150,7 +150,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<div className="grid h-12 w-12 place-items-center rounded border border-slate-200 bg-white text-slate-500">
|
||||
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-slate-500">
|
||||
<Target size={22} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
|
||||
@@ -166,8 +166,8 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
|
||||
function getScadaPresentation(rawTypeId: unknown) {
|
||||
const typeId = Number(rawTypeId);
|
||||
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "glass-normal border-teal-200", labelClassName: "text-teal-800" };
|
||||
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "glass-info border-blue-200", labelClassName: "text-blue-800" };
|
||||
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "glass-warning border-orange-200", labelClassName: "text-orange-800" };
|
||||
return { iconPath: SCADA_ICON_PATHS.unknown, sectionClassName: "border-slate-200 bg-slate-50", labelClassName: "text-slate-800" };
|
||||
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "material-tone-normal border-teal-200", labelClassName: "text-teal-800" };
|
||||
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "material-tone-info border-blue-200", labelClassName: "text-blue-800" };
|
||||
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "material-tone-warning border-orange-200", labelClassName: "text-orange-800" };
|
||||
return { iconPath: SCADA_ICON_PATHS.unknown, sectionClassName: "surface-control border-transparent", labelClassName: "text-slate-800" };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
import Image from "next/image";
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||
import {
|
||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SCADA_ICON_PATHS } from "../map/scada";
|
||||
@@ -128,7 +131,8 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden bg-white/[0.94] shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
|
||||
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden",
|
||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
@@ -173,7 +177,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
<Database size={14} aria-hidden="true" />
|
||||
设施属性
|
||||
</div>
|
||||
<dl className="divide-y divide-slate-100 border-y border-slate-200/80">
|
||||
<dl className="surface-reading divide-y divide-slate-100 rounded-xl px-3">
|
||||
{panelModel.attributes.map((entry) => (
|
||||
<div key={entry.key} className="grid min-h-10 grid-cols-[96px_minmax(0,1fr)] items-baseline gap-3 py-2.5">
|
||||
<dt className="text-xs font-medium leading-5 text-slate-500">{entry.label}</dt>
|
||||
@@ -218,7 +222,8 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] bg-white/[0.94] shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
|
||||
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))]",
|
||||
MAP_FOCUS_SURFACE_CLASS_NAME,
|
||||
"-translate-x-1/2 overflow-hidden",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
@@ -264,7 +269,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
|
||||
<Radio size={14} aria-hidden="true" />
|
||||
设备与关联信息
|
||||
</div>
|
||||
<dl className="divide-y divide-slate-100 border-y border-slate-200/80">
|
||||
<dl className="surface-reading divide-y divide-slate-100 rounded-xl px-3">
|
||||
{attributes.map(([label, value]) => (
|
||||
<div key={label} className="grid min-h-10 grid-cols-[92px_minmax(0,1fr)] items-baseline gap-3 py-2.5">
|
||||
<dt className="text-xs font-medium text-slate-500">{label}</dt>
|
||||
@@ -273,7 +278,7 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-3 grid grid-cols-[18px_1fr] gap-2 rounded-xl bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600 ring-1 ring-inset ring-slate-200/70">
|
||||
<div className="surface-reading mt-3 grid grid-cols-[18px_1fr] gap-2 rounded-xl px-3 py-2.5 text-xs leading-5 text-slate-600">
|
||||
<MapPin size={15} className="mt-0.5 text-slate-500" aria-hidden="true" />
|
||||
<p>当前点位由管网节点空间均匀采样生成,用于空间展示,不代表设备真实安装坐标。</p>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function IconButton({ label, children, onClick, active = false }: IconBut
|
||||
className={`grid h-10 w-10 place-items-center rounded border transition ${
|
||||
active
|
||||
? "border-slate-800 bg-slate-900 text-white"
|
||||
: "border-transparent bg-[var(--glass-readable)] text-slate-700 shadow-sm hover:bg-white"
|
||||
: "surface-control border-transparent text-slate-700 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -49,9 +49,9 @@ import { getConditionReportRiskLevel } from "./condition-report-risk";
|
||||
|
||||
const RUNNING_WORKFLOW_TICK_MS = 1_000;
|
||||
const STATUS_PILL_CLASS_NAME = "rounded-full border px-2 py-0.5 text-xs font-semibold leading-4";
|
||||
const NEUTRAL_SURFACE_CLASS_NAME = "rounded-xl border border-transparent bg-[var(--glass-readable)]";
|
||||
const INSET_SURFACE_CLASS_NAME = "rounded-xl border border-slate-200/70 bg-[var(--glass-menu)]";
|
||||
const REPORT_META_TILE_CLASS_NAME = "rounded-lg border border-slate-200/70 bg-[var(--glass-menu)] px-2 py-1.5";
|
||||
const NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-slate-200/70 px-2 py-1.5";
|
||||
|
||||
const riskIndicatorClassNames: Record<ScheduledConditionRiskLevel, string> = {
|
||||
normal: "border-slate-200 bg-slate-50 text-slate-600",
|
||||
@@ -136,9 +136,9 @@ export function ConditionDetailPanel({ condition, onContinueConversation }: Cond
|
||||
<motion.div
|
||||
key={detailKey}
|
||||
className="min-h-0"
|
||||
initial={{ opacity: 0, y: 8, filter: "blur(2px)" }}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, y: -6, filter: "blur(1px)" }}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{content}
|
||||
@@ -185,9 +185,9 @@ function ConditionRecordDetailPanel({
|
||||
<motion.div
|
||||
key={bodyKey}
|
||||
className="space-y-2.5"
|
||||
initial={{ opacity: 0, y: 10, scale: 0.992, filter: "blur(2px)" }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.996, filter: "blur(1px)" }}
|
||||
initial={{ opacity: 0, y: 10, scale: 0.992 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.996 }}
|
||||
transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{condition.status === "running" ? (
|
||||
@@ -345,7 +345,7 @@ type DetailShellProps = {
|
||||
|
||||
function DetailShell({ children }: DetailShellProps) {
|
||||
return (
|
||||
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden ring-1 ring-white/80")}>
|
||||
<div className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -444,7 +444,7 @@ function SolutionOptionsList({
|
||||
{options.map((option, index) => (
|
||||
<article key={option.id} className="px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-lg bg-white text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
|
||||
<span className="surface-reading grid h-5 w-5 shrink-0 place-items-center rounded-lg border border-slate-200/70 text-xs font-semibold text-slate-600">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h4 className="min-w-0 truncate text-xs font-semibold leading-4 text-slate-900">{option.title}</h4>
|
||||
@@ -576,7 +576,7 @@ function EscalationCriteriaList({ items }: { items: string[] }) {
|
||||
<ol className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-slate-200/70 overflow-hidden")}>
|
||||
{items.map((item, index) => (
|
||||
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 px-2 py-1.5 text-xs leading-5 text-slate-700">
|
||||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-white text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
|
||||
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-slate-200/70 text-xs font-semibold text-slate-600">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{item}</span>
|
||||
@@ -711,7 +711,7 @@ function ReportSection({
|
||||
|
||||
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
return (
|
||||
<div className={cn("min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
|
||||
<div className={cn("surface-reading min-w-0 rounded-xl border px-2.5 py-2", getKpiSurfaceClassName(kpi.status))}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}>
|
||||
@@ -739,14 +739,14 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
|
||||
|
||||
function getKpiSurfaceClassName(riskLevel: ScheduledConditionRiskLevel) {
|
||||
if (riskLevel === "critical") {
|
||||
return "glass-danger border-red-200/80";
|
||||
return "border-red-200/80";
|
||||
}
|
||||
|
||||
if (riskLevel === "attention") {
|
||||
return "glass-warning border-orange-200/80";
|
||||
return "border-orange-200/80";
|
||||
}
|
||||
|
||||
return "border-slate-200/70 bg-slate-50/80";
|
||||
return "border-slate-200/70";
|
||||
}
|
||||
|
||||
function DecisionActionStrip({
|
||||
@@ -782,7 +782,7 @@ function DecisionActionStrip({
|
||||
"flex min-h-9 shrink-0 items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors min-[1500px]:w-[104px]",
|
||||
"rounded-xl",
|
||||
actionDisabled
|
||||
? "cursor-not-allowed bg-white/70 text-slate-500 opacity-80"
|
||||
? "surface-control cursor-not-allowed border border-slate-200/70 text-slate-500 opacity-80"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
@@ -842,14 +842,14 @@ function getTaskExecutionStepIconClassName(status: ExecutionStepStatus) {
|
||||
|
||||
function getTaskExecutionStepSurfaceClassName(status: ExecutionStepStatus) {
|
||||
if (status === "current") {
|
||||
return "glass-info border-blue-100";
|
||||
return "surface-reading border-blue-100";
|
||||
}
|
||||
|
||||
if (status === "confirmation") {
|
||||
return "glass-warning border-orange-100";
|
||||
return "surface-reading border-orange-100";
|
||||
}
|
||||
|
||||
return "border-slate-200/70 bg-slate-50/80";
|
||||
return "surface-reading border-slate-200/70";
|
||||
}
|
||||
|
||||
function getTaskExecutionStepStatusClassName(status: ExecutionStepStatus) {
|
||||
@@ -973,7 +973,7 @@ function DetailSectionCard({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-xl border border-slate-200/80 bg-slate-50/90 px-2.5 py-2.5 text-slate-500", className)}>
|
||||
<div className={cn("surface-reading rounded-xl border border-slate-200/70 px-2.5 py-2.5 text-slate-500", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -1131,7 +1131,7 @@ function WorkOrderReplyRequirements({ condition }: { condition: ScheduledWorkOrd
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{condition.replyRequirements.map((item, index) => (
|
||||
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 text-xs leading-5 text-slate-700">
|
||||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-white text-xs font-semibold text-blue-700 ring-1 ring-blue-100">
|
||||
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-blue-100 text-xs font-semibold text-blue-700">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{item}</span>
|
||||
@@ -1150,7 +1150,7 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<li key={item} className="grid grid-cols-[18px_1fr_auto] items-start gap-2 text-xs leading-5 text-slate-700">
|
||||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-blue-50 text-xs font-semibold text-blue-700 ring-1 ring-blue-100">
|
||||
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-blue-100 text-xs font-semibold text-blue-700">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="line-clamp-1">{item}</span>
|
||||
@@ -1173,7 +1173,7 @@ function EvidenceList({ items }: { items: string[] }) {
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 text-xs leading-5 text-slate-700">
|
||||
<span className="mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg bg-white text-xs font-semibold text-blue-700 ring-1 ring-blue-100">
|
||||
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-blue-100 text-xs font-semibold text-blue-700">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{item}</span>
|
||||
|
||||
@@ -10,14 +10,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||
} from "@/features/map/core/components/map-control-styles";
|
||||
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
|
||||
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
|
||||
import type { ScheduledConditionItem } from "@/features/workbench/types";
|
||||
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -41,11 +39,15 @@ import {
|
||||
type ConditionTaskFilterValue
|
||||
} from "./scheduled-condition-feed-utils";
|
||||
|
||||
const CONDITION_CONTENT_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70";
|
||||
const CONDITION_CONTROL_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70";
|
||||
|
||||
type ScheduledConditionFeedProps = {
|
||||
conditions?: ScheduledConditionItem[];
|
||||
expanded?: boolean;
|
||||
focusRequest?: ScheduledConditionFeedFocusRequest | null;
|
||||
loading?: boolean;
|
||||
visible?: boolean;
|
||||
selectedConditionId?: string | null;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
onFocusRequestHandled?: (requestId: number) => void;
|
||||
@@ -65,6 +67,7 @@ export function ScheduledConditionFeed({
|
||||
expanded: controlledExpanded,
|
||||
focusRequest,
|
||||
loading = false,
|
||||
visible = true,
|
||||
selectedConditionId: controlledSelectedConditionId,
|
||||
onExpandedChange,
|
||||
onFocusRequestHandled,
|
||||
@@ -115,7 +118,7 @@ export function ScheduledConditionFeed({
|
||||
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条`
|
||||
: expanded
|
||||
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`
|
||||
: `最近 ${recentConditions.length} 条`;
|
||||
: `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`;
|
||||
const selectedCondition =
|
||||
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
|
||||
recentConditions[0] ??
|
||||
@@ -238,14 +241,16 @@ export function ScheduledConditionFeed({
|
||||
return (
|
||||
<section
|
||||
aria-label="工况任务"
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
|
||||
visible ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit",
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
|
||||
"rounded-2xl border",
|
||||
expanded ? "glass-panel" : "glass-control"
|
||||
"rounded-2xl"
|
||||
)}
|
||||
>
|
||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", "rounded-xl", expanded ? "bg-white/30" : MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
||||
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", CONDITION_CONTROL_SURFACE_CLASS_NAME)}>
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
|
||||
<CalendarClock size={17} aria-hidden="true" />
|
||||
</span>
|
||||
@@ -366,10 +371,7 @@ function ConditionTimelinePanel({
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden p-2.5",
|
||||
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
|
||||
"rounded-xl",
|
||||
expanded
|
||||
? "border border-white/30 bg-white/10"
|
||||
: MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||
CONDITION_CONTENT_SURFACE_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
{expanded && !loading ? (
|
||||
@@ -387,7 +389,7 @@ function ConditionTimelinePanel({
|
||||
/>
|
||||
) : null}
|
||||
{expanded && !loading && futureWorkOrders.length > 0 ? (
|
||||
<section className="mb-2 rounded-xl border border-blue-100 bg-blue-50/55 p-2">
|
||||
<section className="mb-2">
|
||||
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
|
||||
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
|
||||
<ConditionRows
|
||||
@@ -447,7 +449,7 @@ function ConditionFilterBar({
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
|
||||
return (
|
||||
<div className="mb-2 rounded-xl border border-slate-200/70 bg-slate-50/95 p-2 ring-1 ring-white/80">
|
||||
<div className={cn(CONDITION_CONTENT_SURFACE_CLASS_NAME, "mb-2 p-2")}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<span className="text-xs font-semibold text-slate-700">筛选</span>
|
||||
<span className="shrink-0 text-xs font-semibold text-slate-400">
|
||||
@@ -478,7 +480,7 @@ function ConditionFilterBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetFilters}
|
||||
className="mt-2 inline-flex h-7 w-full items-center justify-center gap-1.5 rounded-lg border border-blue-100 bg-white px-2 text-xs font-semibold text-blue-700 shadow-sm transition hover:border-blue-200 hover:bg-blue-50"
|
||||
className="surface-control mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 transition-[background-color,transform] hover:bg-blue-50 active:scale-95"
|
||||
>
|
||||
<XCircle size={12} aria-hidden="true" />
|
||||
重置筛选
|
||||
@@ -515,7 +517,7 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group flex h-9 w-full min-w-0 items-center gap-2 border border-white/80 bg-white px-2 text-left text-xs shadow-sm outline-none transition hover:border-blue-100 hover:bg-blue-50/35 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50/60 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
|
||||
"surface-reading group flex h-9 w-full min-w-0 items-center gap-2 border border-slate-200/70 px-2 text-left text-xs outline-none transition-colors hover:bg-blue-50 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
|
||||
"rounded-xl"
|
||||
)}
|
||||
>
|
||||
@@ -535,7 +537,7 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="glass-menu scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border p-1.5 text-slate-700"
|
||||
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
|
||||
{options.map((option) => (
|
||||
@@ -543,12 +545,12 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={cn(
|
||||
"my-0.5 min-h-9 gap-2 border border-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50/80 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/90 data-[state=checked]:text-blue-800",
|
||||
"my-0.5 min-h-9 gap-2 border border-transparent bg-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50 data-[state=checked]:text-blue-800",
|
||||
"rounded-xl"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span>
|
||||
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
|
||||
<span className="surface-control shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
|
||||
{option.count}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
@@ -562,7 +564,7 @@ function ConditionFilterDropdown<TValue extends string>({
|
||||
|
||||
function ConditionEmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-[var(--glass-readable)] px-4 py-5 text-center">
|
||||
<div className="surface-reading flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200/80 px-4 py-5 text-center">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400">
|
||||
<ChevronRight size={15} aria-hidden="true" />
|
||||
</span>
|
||||
@@ -587,7 +589,7 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
|
||||
) : null}
|
||||
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
|
||||
</span>
|
||||
<span className="min-w-0 rounded-xl border border-transparent bg-[var(--glass-menu)] px-2 py-1.5">
|
||||
<span className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2 py-1.5">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 space-y-1.5">
|
||||
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" />
|
||||
@@ -607,13 +609,12 @@ function ConditionDetailSkeleton() {
|
||||
<DetailScroll>
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-[420px] overflow-hidden border border-slate-200/60 bg-white/30 px-4 py-3",
|
||||
"surface-reading relative min-h-[420px] overflow-hidden border border-slate-200/70 px-4 py-3",
|
||||
"rounded-xl"
|
||||
)}
|
||||
role="status"
|
||||
aria-label="工况详情加载中"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 w-1 bg-blue-300" aria-hidden="true" />
|
||||
<div className="flex gap-2.5">
|
||||
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-slate-100", "rounded-lg")} />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
@@ -628,7 +629,7 @@ function ConditionDetailSkeleton() {
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="rounded-xl border border-slate-200/70 bg-slate-50/80 px-3 py-3">
|
||||
<div key={index} className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3">
|
||||
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
|
||||
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
|
||||
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
|
||||
@@ -706,8 +707,8 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1 text-left outline-none transition-colors focus-visible:bg-blue-50/40 focus-visible:ring-2 focus-visible:ring-blue-200/80 focus-visible:ring-offset-1 focus-visible:ring-offset-white",
|
||||
selected ? "bg-blue-50/70" : "hover:bg-blue-50/45"
|
||||
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-none transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200",
|
||||
selected ? "surface-reading border-blue-100 text-blue-900" : "border-transparent hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
|
||||
@@ -735,10 +736,10 @@ function ConditionTimelineRow({ condition, selected, isLast, onSelect }: Conditi
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 rounded-xl border px-2 py-1.5 transition-colors group-focus-visible:border-blue-300 group-focus-visible:bg-white",
|
||||
"min-w-0 px-2 py-1.5 transition-colors",
|
||||
selected
|
||||
? "border-blue-300 bg-white text-blue-900 shadow-sm shadow-blue-600/10 ring-1 ring-blue-300/40"
|
||||
: "border-transparent bg-[var(--glass-menu)] text-slate-700 group-hover:border-blue-100 group-hover:bg-white"
|
||||
? "text-blue-900"
|
||||
: "text-slate-700"
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -46,10 +46,10 @@ const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
|
||||
|
||||
const menuSurfaceClassName = cn(
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
"glass-menu border p-2 text-slate-900"
|
||||
"acrylic-panel border p-2 text-slate-900"
|
||||
);
|
||||
const menuReadableRowClassName =
|
||||
"bg-[var(--glass-readable)] focus:bg-blue-50/95 focus:text-slate-950";
|
||||
"surface-well focus:bg-blue-50 focus:text-slate-950";
|
||||
const headerControlButtonClassName =
|
||||
"inline-flex h-8 items-center gap-2 rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
|
||||
const headerControlIconClassName =
|
||||
@@ -220,7 +220,7 @@ export function AlertMenu({
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)) : (
|
||||
<div className={cn("my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 bg-[var(--glass-readable)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
|
||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
|
||||
@@ -315,7 +315,7 @@ function AlertQueueSummary({
|
||||
const hasAlerts = count > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("overflow-hidden bg-[var(--glass-menu)] px-3 py-3 ring-1 ring-white/80", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className={cn("surface-reading overflow-hidden px-3 py-3", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
@@ -423,7 +423,7 @@ function MenuHeader({
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between gap-3 border border-transparent bg-[var(--glass-menu)] px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className={cn("surface-control flex items-center justify-between gap-3 border px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function WorkbenchTopBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="glass-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
|
||||
<header className="acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b px-3 sm:px-4 md:px-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<Droplets size={18} aria-hidden="true" />
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
simulationAnnotationLayers,
|
||||
simulationSources
|
||||
} from "../map/annotation-layers";
|
||||
import {
|
||||
fitNetworkBounds,
|
||||
getResponsiveWorkbenchPadding
|
||||
} from "../map/camera";
|
||||
import { fitNetworkBounds } from "../map/camera";
|
||||
import {
|
||||
waterNetworkBusinessLayers,
|
||||
waterNetworkHitLayers,
|
||||
@@ -98,7 +95,6 @@ export function useWorkbenchMap({
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const impactVisibleRef = useRef(impactVisible);
|
||||
const layoutOpenRef = useRef({ leftPanelOpen, rightPanelOpen });
|
||||
const appliedLayoutPaddingRef = useRef<{ leftPanelOpen: boolean; rightPanelOpen: boolean } | null>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||
@@ -223,30 +219,6 @@ export function useWorkbenchMap({
|
||||
selectedFeature
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousLayout = appliedLayoutPaddingRef.current;
|
||||
const currentLayout = { leftPanelOpen, rightPanelOpen };
|
||||
appliedLayoutPaddingRef.current = currentLayout;
|
||||
|
||||
if (
|
||||
!previousLayout ||
|
||||
(previousLayout.leftPanelOpen === currentLayout.leftPanelOpen &&
|
||||
previousLayout.rightPanelOpen === currentLayout.rightPanelOpen)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.easeTo({
|
||||
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
|
||||
duration: 350
|
||||
});
|
||||
}, [leftPanelOpen, rightPanelOpen, mapReady]);
|
||||
|
||||
useSimulationLayerVisibility({
|
||||
mapRef,
|
||||
mapReady,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getWorkbenchBasemapTone } from "./workbench-layout";
|
||||
|
||||
describe("workbench basemap surface tone", () => {
|
||||
it.each([
|
||||
["mapbox-light", "light"],
|
||||
["mapbox-satellite", "satellite"],
|
||||
["none", "none"],
|
||||
["unknown", "none"]
|
||||
])("maps %s to %s", (baseLayerId, expectedTone) => {
|
||||
expect(getWorkbenchBasemapTone(baseLayerId)).toBe(expectedTone);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
export const WORKBENCH_LAYOUT = {
|
||||
headerHeight: 56,
|
||||
desktopMinWidth: 1024,
|
||||
persistentConditionMinWidth: 1280,
|
||||
wideMinWidth: 1536,
|
||||
collapsedAgentWidth: 136,
|
||||
mapEdgeGap: 24,
|
||||
desktop: {
|
||||
agentWidth: 460,
|
||||
conditionWidth: 432,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 420
|
||||
},
|
||||
wide: {
|
||||
agentWidth: 500,
|
||||
conditionWidth: 432,
|
||||
toolbarWidth: 48,
|
||||
tickerWidth: 460
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type WorkbenchPanelState = {
|
||||
agentOpen: boolean;
|
||||
conditionOpen: boolean;
|
||||
};
|
||||
|
||||
export type WorkbenchBasemapTone = "light" | "satellite" | "none";
|
||||
|
||||
export type WorkbenchViewportLayout = {
|
||||
agentWidth: number;
|
||||
conditionWidth: number;
|
||||
toolbarWidth: number;
|
||||
tickerWidth: number;
|
||||
};
|
||||
|
||||
export function getWorkbenchViewportLayout(viewportWidth: number): WorkbenchViewportLayout {
|
||||
return viewportWidth >= WORKBENCH_LAYOUT.wideMinWidth
|
||||
? WORKBENCH_LAYOUT.wide
|
||||
: WORKBENCH_LAYOUT.desktop;
|
||||
}
|
||||
|
||||
export function getWorkbenchBasemapTone(activeBaseLayerId: string): WorkbenchBasemapTone {
|
||||
if (activeBaseLayerId === "mapbox-light") {
|
||||
return "light";
|
||||
}
|
||||
|
||||
if (activeBaseLayerId === "mapbox-satellite") {
|
||||
return "satellite";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function getWorkbenchCameraPadding(viewportWidth: number, panels: WorkbenchPanelState) {
|
||||
if (viewportWidth < WORKBENCH_LAYOUT.desktopMinWidth) {
|
||||
return { top: 72, right: 24, bottom: 72, left: 24 };
|
||||
}
|
||||
|
||||
const layout = getWorkbenchViewportLayout(viewportWidth);
|
||||
const agentWidth = panels.agentOpen ? layout.agentWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
|
||||
const conditionWidth = panels.conditionOpen ? layout.conditionWidth : 0;
|
||||
|
||||
return {
|
||||
top: WORKBENCH_LAYOUT.headerHeight + 16,
|
||||
right: WORKBENCH_LAYOUT.mapEdgeGap + layout.toolbarWidth + conditionWidth,
|
||||
bottom: 32,
|
||||
left: WORKBENCH_LAYOUT.mapEdgeGap + agentWidth
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkbenchLayoutCssVariables() {
|
||||
return {
|
||||
"--workbench-agent-width": `${WORKBENCH_LAYOUT.desktop.agentWidth}px`,
|
||||
"--workbench-agent-width-wide": `${WORKBENCH_LAYOUT.wide.agentWidth}px`,
|
||||
"--workbench-condition-width": `${WORKBENCH_LAYOUT.desktop.conditionWidth}px`,
|
||||
"--workbench-condition-width-wide": `${WORKBENCH_LAYOUT.wide.conditionWidth}px`,
|
||||
"--workbench-toolbar-width": `${WORKBENCH_LAYOUT.desktop.toolbarWidth}px`,
|
||||
"--workbench-ticker-width": `${WORKBENCH_LAYOUT.desktop.tickerWidth}px`,
|
||||
"--workbench-ticker-width-wide": `${WORKBENCH_LAYOUT.wide.tickerWidth}px`
|
||||
} as const;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps, type CSSProperties } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
@@ -42,6 +42,8 @@ import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workben
|
||||
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
||||
import { toMapFeatureReference } from "./hooks/use-map-interactions";
|
||||
import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement";
|
||||
import { getWorkbenchBasemapTone, getWorkbenchLayoutCssVariables } from "./layout/workbench-layout";
|
||||
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
||||
import { exportMapViewImage } from "./map/export-view";
|
||||
import {
|
||||
BASE_LAYER_IDS,
|
||||
@@ -58,6 +60,7 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
|
||||
|
||||
const HEADER_DATA_TIME_STEP_MINUTES = 5;
|
||||
const INITIAL_HEADER_DATA_TIME = "同步中";
|
||||
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
||||
|
||||
function formatHeaderDataTime(date: Date) {
|
||||
const snappedMinutes = Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
|
||||
@@ -88,7 +91,7 @@ export function MapWorkbenchPage() {
|
||||
const [impactVisible, setImpactVisible] = useState(false);
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(true);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||||
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||
@@ -98,6 +101,7 @@ export function MapWorkbenchPage() {
|
||||
requestId: number;
|
||||
} | null>(null);
|
||||
const [activeBaseLayerId, setActiveBaseLayerId] = useState(hasMapboxToken ? "mapbox-light" : "none");
|
||||
const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId);
|
||||
const [activeScenarioId, setActiveScenarioId] = useState(WORKBENCH_SCENARIOS[0]?.id ?? "");
|
||||
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
|
||||
const [activeMeasureUnitId, setActiveMeasureUnitId] = useState<WorkbenchMeasureUnit>("km");
|
||||
@@ -125,6 +129,7 @@ export function MapWorkbenchPage() {
|
||||
const handleChange = () => setIsLargeScreen(mediaQuery.matches);
|
||||
|
||||
handleChange();
|
||||
setConditionFeedVisible(window.innerWidth >= 1280);
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
@@ -172,8 +177,12 @@ export function MapWorkbenchPage() {
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [shouldShowConditionFeed]);
|
||||
|
||||
const leftPanelOpen = isLargeScreen;
|
||||
const rightPanelOpen = false;
|
||||
const agent = useWorkbenchAgent({
|
||||
onUiEnvelope: handleAgentUiEnvelope,
|
||||
onFrontendAction: handleFrontendAction
|
||||
});
|
||||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
||||
const rightPanelOpen = isLargeScreen && shouldShowConditionFeed;
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||
containerRef: mapContainerRef,
|
||||
@@ -184,10 +193,6 @@ export function MapWorkbenchPage() {
|
||||
selectedFeature: detailFeature
|
||||
});
|
||||
|
||||
const agent = useWorkbenchAgent({
|
||||
onUiEnvelope: handleAgentUiEnvelope,
|
||||
onFrontendAction: handleFrontendAction
|
||||
});
|
||||
const activeAgentUiResults = useMemo(
|
||||
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
|
||||
[agent.sessionId, agentUiResults]
|
||||
@@ -221,19 +226,17 @@ export function MapWorkbenchPage() {
|
||||
});
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||||
const shouldShowTaskTicker = !agent.mobileOpen && taskTickerVisible && taskTickerAvailable;
|
||||
const shouldShowTaskTicker = !agent.mobileOpen && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
|
||||
const taskTickerEnterState = prefersReducedMotion
|
||||
? {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)"
|
||||
y: 0
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
stiffness: 420,
|
||||
@@ -246,14 +249,12 @@ export function MapWorkbenchPage() {
|
||||
opacity: 0,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: { duration: 0 }
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: -8,
|
||||
filter: "blur(4px)",
|
||||
transition: {
|
||||
duration: 0.18,
|
||||
ease: [0.5, 0, 0.2, 1] as const
|
||||
@@ -526,14 +527,14 @@ export function MapWorkbenchPage() {
|
||||
const map = mapRef.current;
|
||||
if (request.name === "zoom_to_map") {
|
||||
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const x = Number(request.params.x); const y = Number(request.params.y);
|
||||
const center: [number, number] = request.params.source_crs === "EPSG:4326" ? [x, y] : [x * 180 / 20037508.34, Math.atan(Math.exp(y * Math.PI / 20037508.34)) * 360 / Math.PI - 90]; const zoom = Number(request.params.zoom ?? 18); map.easeTo({ center, zoom, duration: Number(request.params.duration_ms ?? 500) }); return { center, zoom };
|
||||
const center: [number, number] = request.params.source_crs === "EPSG:4326" ? [x, y] : [x * 180 / 20037508.34, Math.atan(Math.exp(y * Math.PI / 20037508.34)) * 360 / Math.PI - 90]; const zoom = Number(request.params.zoom ?? 18); map.easeTo({ center, zoom, padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), duration: Number(request.params.duration_ms ?? 500) }); return { center, zoom };
|
||||
}
|
||||
if (request.name === "locate_features") {
|
||||
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const ids = request.params.ids as string[]; const featureType = String(request.params.feature_type);
|
||||
const response = await fetch("/api/agent-locate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, featureType }), signal }); const geojson = await response.json(); if (!response.ok) throw new Error(geojson.code ?? "WFS_FAILED");
|
||||
const features = geojson.features as Array<{ id?: string | number; properties?: Record<string, unknown>; geometry: { coordinates: unknown } }>; if (!features.length) throw new Error("FEATURE_NOT_FOUND");
|
||||
if (map.getSource("agent-locate")) (map.getSource("agent-locate") as unknown as { setData: (data: unknown) => void }).setData(geojson); else { map.addSource("agent-locate", { type: "geojson", data: geojson }); map.addLayer({ id: "agent-locate-lines", type: "line", source: "agent-locate", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": "#f97316", "line-width": 6 } }); map.addLayer({ id: "agent-locate-points", type: "circle", source: "agent-locate", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": "#f97316", "circle-radius": 8, "circle-stroke-color": "#fff", "circle-stroke-width": 2 } }); }
|
||||
const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 80, maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds };
|
||||
const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds };
|
||||
}
|
||||
const history = request.name === "view_history"; const itemCount = history ? (request.params.feature_infos as unknown[]).length : Array.isArray(request.params.device_ids) ? request.params.device_ids.length : 1; setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope: { kind: "registered_component", schemaVersion: "agent-ui@1", component: history ? "HistoryPanel" : "ScadaPanel", surface: "side_panel", props: request.params } }]); return { component: history ? "HistoryPanel" : "ScadaPanel", rendered: true, itemCount };
|
||||
}
|
||||
@@ -559,6 +560,7 @@ export function MapWorkbenchPage() {
|
||||
map.easeTo({
|
||||
center: trustedAction.center,
|
||||
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
|
||||
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
|
||||
duration: 500
|
||||
});
|
||||
showMapNotice({
|
||||
@@ -699,7 +701,11 @@ export function MapWorkbenchPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900">
|
||||
<main
|
||||
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
|
||||
data-basemap-tone={basemapTone}
|
||||
style={WORKBENCH_LAYOUT_CSS_VARIABLES as CSSProperties}
|
||||
>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="map-grid"
|
||||
@@ -707,8 +713,6 @@ export function MapWorkbenchPage() {
|
||||
aria-label="排水管网地图"
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-white/25 via-white/5 to-slate-100/20" />
|
||||
|
||||
<WorkbenchTopBar
|
||||
dataTime={headerDataTime}
|
||||
modelName="DrainFlow 1.0.0"
|
||||
@@ -736,7 +740,7 @@ export function MapWorkbenchPage() {
|
||||
onExportConfig={handleExportConfig}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[460px] 2xl:w-[500px] lg:block">
|
||||
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] 2xl:w-[var(--workbench-agent-width-wide)] lg:block">
|
||||
{agent.panelOpen ? (
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
@@ -757,17 +761,13 @@ export function MapWorkbenchPage() {
|
||||
</div>
|
||||
|
||||
{conditionFeedMounted ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-[62px] top-24 z-20 hidden lg:block",
|
||||
shouldShowConditionFeed ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit"
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||||
<ScheduledConditionFeed
|
||||
conditions={scheduledConditions}
|
||||
expanded={conditionFeedExpanded}
|
||||
focusRequest={conditionFocusRequest}
|
||||
loading={scheduledConditionsLoading}
|
||||
visible={shouldShowConditionFeed}
|
||||
selectedConditionId={selectedConditionId}
|
||||
onExpandedChange={setConditionFeedExpanded}
|
||||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||||
@@ -779,7 +779,7 @@ export function MapWorkbenchPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute right-[62px] top-24 z-20 hidden lg:block">
|
||||
<div className="absolute right-16 top-24 z-20 hidden lg:block">
|
||||
<ToolbarPanel {...toolbarPanelProps} />
|
||||
</div>
|
||||
|
||||
@@ -821,7 +821,7 @@ export function MapWorkbenchPage() {
|
||||
{shouldShowTaskTicker ? (
|
||||
<motion.div
|
||||
key="agent-task-ticker-shell"
|
||||
className="fixed left-3 right-3 top-24 z-20 w-auto [translate:0] md:absolute md:left-1/2 md:right-auto md:w-[460px] md:[translate:-50%_0]"
|
||||
className="fixed left-1/2 right-auto top-24 z-20 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 2xl:w-[var(--workbench-ticker-width-wide)]"
|
||||
style={{ transformOrigin: "top center" }}
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
@@ -829,15 +829,14 @@ export function MapWorkbenchPage() {
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: -8,
|
||||
filter: "blur(6px)"
|
||||
y: -8
|
||||
}
|
||||
}
|
||||
animate={taskTickerEnterState}
|
||||
exit={taskTickerExitState}
|
||||
>
|
||||
<AgentTaskTicker
|
||||
className="w-full"
|
||||
className="w-full max-w-full"
|
||||
conditions={runningTickerConditions}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fitNetworkBounds } from "./camera";
|
||||
import {
|
||||
fitNetworkBounds,
|
||||
getResponsiveWorkbenchPadding,
|
||||
getWorkbenchPadding
|
||||
} from "./camera";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
function createMap(width = 1280, height = 720) {
|
||||
const fitBounds = vi.fn();
|
||||
const map = {
|
||||
fitBounds,
|
||||
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height })
|
||||
} as unknown as MapLibreMap;
|
||||
|
||||
return { fitBounds, map };
|
||||
}
|
||||
|
||||
describe("workbench camera padding", () => {
|
||||
it.each([
|
||||
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 160 } },
|
||||
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 484 } },
|
||||
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 160 } },
|
||||
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 484 } }
|
||||
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
|
||||
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("caps combined padding on constrained desktop widths", () => {
|
||||
const { map } = createMap(1024, 720);
|
||||
const padding = getResponsiveWorkbenchPadding(map, true, true);
|
||||
|
||||
expect(padding.left + padding.right).toBeLessThanOrEqual(Math.floor(1024 * 0.58) + 1);
|
||||
expect(padding.top + padding.bottom).toBeLessThanOrEqual(Math.floor(720 * 0.58) + 1);
|
||||
expect(padding.left).toBeGreaterThan(padding.top);
|
||||
expect(padding.right).toBeGreaterThan(padding.top);
|
||||
});
|
||||
|
||||
it("uses mobile-safe padding below the dock breakpoint", () => {
|
||||
const { map } = createMap(375, 667);
|
||||
|
||||
expect(getResponsiveWorkbenchPadding(map, true, true)).toEqual({
|
||||
top: 72,
|
||||
right: 24,
|
||||
bottom: 72,
|
||||
left: 24
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitNetworkBounds", () => {
|
||||
it("fits the global WebMercator bbox through MapLibre lng/lat bounds", () => {
|
||||
const fitBounds = vi.fn();
|
||||
const map = {
|
||||
fitBounds,
|
||||
getCanvas: () => ({
|
||||
clientWidth: 1280,
|
||||
clientHeight: 720,
|
||||
width: 1280,
|
||||
height: 720
|
||||
})
|
||||
} as unknown as MapLibreMap;
|
||||
it("preserves the original global extent for initial and home views", () => {
|
||||
const { fitBounds, map } = createMap();
|
||||
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import {
|
||||
getWorkbenchCameraPadding
|
||||
} from "../layout/workbench-layout";
|
||||
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
|
||||
|
||||
type LngLatBounds = [[number, number], [number, number]];
|
||||
@@ -11,14 +14,11 @@ export type NetworkViewParams = {
|
||||
const WEB_MERCATOR_RADIUS = 6378137;
|
||||
const GLOBAL_VIEW_MAX_ZOOM = 12;
|
||||
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 48, right: 48, bottom: 48, left: 48 };
|
||||
|
||||
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
|
||||
return {
|
||||
top: 88,
|
||||
left: leftPanelOpen ? 530 : 88,
|
||||
right: rightPanelOpen ? 430 : 120,
|
||||
bottom: 96
|
||||
};
|
||||
return getWorkbenchCameraPadding(1280, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponsiveWorkbenchPadding(
|
||||
@@ -26,7 +26,14 @@ export function getResponsiveWorkbenchPadding(
|
||||
leftPanelOpen: boolean,
|
||||
rightPanelOpen: boolean
|
||||
): PaddingOptions {
|
||||
return getResponsivePadding(map, getWorkbenchPadding(leftPanelOpen, rightPanelOpen));
|
||||
const width = getMapViewportSize(map).width;
|
||||
return getResponsivePadding(
|
||||
map,
|
||||
getWorkbenchCameraPadding(width, {
|
||||
agentOpen: leftPanelOpen,
|
||||
conditionOpen: rightPanelOpen
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function fitNetworkBounds(
|
||||
@@ -44,10 +51,8 @@ export function fitNetworkBounds(
|
||||
);
|
||||
}
|
||||
|
||||
function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
|
||||
const canvas = map.getCanvas();
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
|
||||
const { width, height } = getMapViewportSize(map);
|
||||
const horizontalPadding = padding.left + padding.right;
|
||||
const verticalPadding = padding.top + padding.bottom;
|
||||
const maxHorizontalPadding = width * 0.58;
|
||||
@@ -68,6 +73,14 @@ function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): Paddin
|
||||
};
|
||||
}
|
||||
|
||||
function getMapViewportSize(map: MapLibreMap) {
|
||||
const canvas = map.getCanvas();
|
||||
return {
|
||||
width: canvas.clientWidth || canvas.width,
|
||||
height: canvas.clientHeight || canvas.height
|
||||
};
|
||||
}
|
||||
|
||||
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
|
||||
const [minX, minY, maxX, maxY] = bbox3857;
|
||||
|
||||
|
||||
@@ -60,4 +60,26 @@ describe("drainage feature adapter", () => {
|
||||
"Unsupported water network source layer: other"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the promoted SCADA point id for feature-state interactions", () => {
|
||||
const detail = toDetailFeature({
|
||||
id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00",
|
||||
sourceLayer: SOURCE_LAYERS.scada,
|
||||
properties: {
|
||||
id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2",
|
||||
point_id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00",
|
||||
point_external_id: "36455",
|
||||
point_name: "DY22东市南街环城南路西段交叉口",
|
||||
device_external_id: "26825171",
|
||||
device_type_name: "2代水质仪"
|
||||
}
|
||||
} as unknown as MapGeoJSONFeature);
|
||||
|
||||
expect(detail).toMatchObject({
|
||||
id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00",
|
||||
layer: "scada",
|
||||
title: "DY22东市南街环城南路西段交叉口",
|
||||
subtitle: "2代水质仪 · 设备 26825171"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import { formatValue } from "../utils/format-value";
|
||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
||||
|
||||
export function getFeatureId(feature: MapGeoJSONFeature) {
|
||||
const raw = feature.properties?.id ?? feature.id;
|
||||
const layer = getWaterNetworkSourceId(feature);
|
||||
const raw = layer === "scada"
|
||||
? feature.id ?? feature.properties?.id ?? feature.properties?.point_id ?? feature.properties?.point_external_id
|
||||
: feature.id ?? feature.properties?.id;
|
||||
|
||||
return raw === undefined || raw === null ? "" : String(raw);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
scadaFallbackLayers,
|
||||
scadaLayers
|
||||
} from "./scada";
|
||||
import { waterNetworkLayers } from "./layers";
|
||||
|
||||
describe("SCADA map presentation", () => {
|
||||
it("maps the three authoritative device types and falls back to unknown", () => {
|
||||
@@ -26,20 +27,29 @@ describe("SCADA map presentation", () => {
|
||||
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
|
||||
});
|
||||
|
||||
it("keeps the icon and selected ring consistent across zoom levels", () => {
|
||||
it("keeps the icon and selected ring consistent with junction selection", () => {
|
||||
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
|
||||
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
|
||||
const selectedLayer = scadaLayers.find((layer) => layer.id === "scada-selected");
|
||||
const junctionOuterLayer = waterNetworkLayers.find((layer) => layer.id === "junctions-selected-outer");
|
||||
const junctionSelectedLayer = waterNetworkLayers.find((layer) => layer.id === "junctions-selected");
|
||||
const junctionOuterPaint = junctionOuterLayer?.paint as Record<string, unknown> | undefined;
|
||||
const junctionSelectedPaint = junctionSelectedLayer?.paint as Record<string, unknown> | undefined;
|
||||
|
||||
expect(symbolLayer).toMatchObject({ type: "symbol", layout: { "icon-size": 0.75 } });
|
||||
expect(outerLayer).toMatchObject({
|
||||
type: "circle",
|
||||
paint: {
|
||||
"circle-radius": 12.5,
|
||||
"circle-stroke-width": 3
|
||||
"circle-stroke-width": junctionOuterPaint?.["circle-stroke-width"]
|
||||
}
|
||||
});
|
||||
expect(selectedLayer).toMatchObject({
|
||||
type: "circle",
|
||||
paint: {
|
||||
"circle-stroke-width": junctionSelectedPaint?.["circle-stroke-width"]
|
||||
}
|
||||
});
|
||||
expect(selectedLayer).toMatchObject({ type: "circle", paint: { "circle-stroke-width": 2 } });
|
||||
});
|
||||
|
||||
it("uses MapLibre-compatible PNG assets", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
|
||||
export const SCADA_SOURCE_ID = "scada";
|
||||
export const SCADA_SOURCE_LAYER = "scada_points";
|
||||
@@ -39,6 +40,8 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
|
||||
|
||||
const SCADA_ICON_SIZE = 0.75;
|
||||
const SCADA_SELECTED_RADIUS = 12.5;
|
||||
const SCADA_SELECTED_OUTER_STROKE_WIDTH = 6;
|
||||
const SCADA_SELECTED_STROKE_WIDTH = 3;
|
||||
|
||||
type Layer = NonNullable<StyleSpecification["layers"]>[number];
|
||||
|
||||
@@ -59,8 +62,8 @@ const scadaFallbackLayer: Layer = {
|
||||
|
||||
const scadaInteractionLayers: Layer[] = [
|
||||
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 14, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#0E7490", "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
|
||||
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "white", "circle-stroke-width": 3, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
|
||||
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#2563EB", "circle-stroke-width": 2, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
|
||||
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": SCADA_SELECTED_OUTER_STROKE_WIDTH, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
|
||||
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": SCADA_SELECTED_RADIUS, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": SCADA_SELECTED_STROKE_WIDTH, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
|
||||
{ id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(0,0,0,0)", "circle-opacity": 0 } }
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { RasterLayerSpecification } from "maplibre-gl";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createBaseStyle, createWaterNetworkSources, SOURCE_LAYERS } from "./sources";
|
||||
|
||||
describe("createWaterNetworkSources", () => {
|
||||
it("loads SCADA tiles from the lingang:scada_points GeoServer layer", () => {
|
||||
const scada = createWaterNetworkSources().scada;
|
||||
|
||||
expect(SOURCE_LAYERS.scada).toBe("scada_points");
|
||||
expect(scada.tiles).toEqual([
|
||||
expect.stringContaining("/lingang:scada_points/point/WebMercatorQuad/")
|
||||
]);
|
||||
expect(scada.promoteId).toBe("point_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBaseStyle", () => {
|
||||
it("calibrates light and satellite rasters for solid workbench surfaces", () => {
|
||||
const style = createBaseStyle("test-token");
|
||||
const light = style.layers.find((layer) => layer.id === "mapbox-light") as RasterLayerSpecification;
|
||||
const satellite = style.layers.find((layer) => layer.id === "mapbox-satellite") as RasterLayerSpecification;
|
||||
|
||||
expect(light.paint).toMatchObject({
|
||||
"raster-opacity": 0.96,
|
||||
"raster-saturation": -0.1,
|
||||
"raster-contrast": 0.06
|
||||
});
|
||||
expect(satellite.paint).toMatchObject({
|
||||
"raster-opacity": 1,
|
||||
"raster-saturation": -0.24,
|
||||
"raster-contrast": -0.06
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,18 +60,24 @@ export function createWaterNetworkSources() {
|
||||
"line",
|
||||
[121.73585149761436, 30.861759757577705, 121.9498481645973, 30.983213202338085]
|
||||
),
|
||||
scada: createLingangVectorSource(SOURCE_LAYERS.scada, "point", [121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165])
|
||||
scada: createLingangVectorSource(
|
||||
SOURCE_LAYERS.scada,
|
||||
"point",
|
||||
[121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165],
|
||||
"point_id"
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function createLingangVectorSource(
|
||||
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS],
|
||||
style: "line" | "point",
|
||||
bounds: [number, number, number, number]
|
||||
bounds: [number, number, number, number],
|
||||
promoteId = "id"
|
||||
): VectorSourceSpecification {
|
||||
return {
|
||||
type: "vector",
|
||||
promoteId: "id",
|
||||
promoteId,
|
||||
tiles: [
|
||||
`${GEOSERVER_WMTS_ROOT}/lingang:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||
],
|
||||
@@ -118,9 +124,9 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
type: "raster",
|
||||
source: "mapbox-light",
|
||||
paint: {
|
||||
"raster-opacity": 0.82,
|
||||
"raster-saturation": -0.22,
|
||||
"raster-contrast": 0.04
|
||||
"raster-opacity": 0.96,
|
||||
"raster-saturation": -0.1,
|
||||
"raster-contrast": 0.06
|
||||
}
|
||||
});
|
||||
layers.push({
|
||||
@@ -131,9 +137,9 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
visibility: "none"
|
||||
},
|
||||
paint: {
|
||||
"raster-opacity": 0.86,
|
||||
"raster-saturation": -0.16,
|
||||
"raster-contrast": 0.02
|
||||
"raster-opacity": 1,
|
||||
"raster-saturation": -0.24,
|
||||
"raster-contrast": -0.06
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,6 +69,34 @@ describe("feature panel properties", () => {
|
||||
expect(model.attributes.map((entry) => entry.key)).toEqual(["invert_elevation", "max_depth"]);
|
||||
});
|
||||
|
||||
it("shows SCADA identity, device, state, and sync fields", () => {
|
||||
const model = getFeaturePanelModel("scada", {
|
||||
id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2",
|
||||
point_id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00",
|
||||
point_external_id: "36455",
|
||||
point_name: "DY22东市南街环城南路西段交叉口",
|
||||
device_external_id: "26825171",
|
||||
active: true,
|
||||
device_type_id: 72,
|
||||
device_type_name: "2代水质仪",
|
||||
junction_id: "31011502010001809",
|
||||
synced_at: "Jul 15, 2026, 7:38:45 AM"
|
||||
});
|
||||
|
||||
expect(model.id).toBe("36455");
|
||||
expect(model.badge).toEqual({ label: "在线", tone: "active" });
|
||||
expect(model.attributes.map((entry) => entry.key)).toEqual([
|
||||
"id",
|
||||
"point_id",
|
||||
"point_name",
|
||||
"device_external_id",
|
||||
"device_type_id",
|
||||
"device_type_name",
|
||||
"junction_id",
|
||||
"synced_at"
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the internal SCADA clustering size from user-facing properties", () => {
|
||||
const entries = getLocalizedFeatureProperties({
|
||||
point_name: "THC雷达液位计(MQTT)",
|
||||
|
||||
@@ -21,6 +21,7 @@ export type FeaturePanelModel = {
|
||||
};
|
||||
|
||||
const PROPERTY_LABELS: Record<string, string> = {
|
||||
fid: "GeoServer 要素 ID",
|
||||
id: "编号",
|
||||
gid: "要素编号",
|
||||
name: "名称",
|
||||
@@ -74,6 +75,7 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const PROPERTY_ORDER: Record<string, number> = {
|
||||
fid: 9,
|
||||
id: 10,
|
||||
gid: 11,
|
||||
name: 12,
|
||||
@@ -134,12 +136,13 @@ const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[
|
||||
junctions: ["id", "invert_elevation", "max_depth"],
|
||||
orifices: ["id", "discharge_coeff", "gated", "shape", "geom1", "geom2"],
|
||||
outfalls: ["id", "invert_elevation", "outfall_type"],
|
||||
scada: ["point_external_id", "device_type_name", "device_external_id", "junction_id", "synced_at"]
|
||||
scada: ["id", "point_id", "point_external_id", "point_name", "device_external_id", "active", "device_type_id", "device_type_name", "junction_id", "synced_at"]
|
||||
};
|
||||
|
||||
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
||||
pumps: "status",
|
||||
orifices: "gated"
|
||||
orifices: "gated",
|
||||
scada: "active"
|
||||
};
|
||||
|
||||
export function getLocalizedFeatureProperties(properties: Record<string, unknown>): LocalizedFeatureProperty[] {
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:browser": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^4.0.9",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/browser",
|
||||
testMatch: "**/*.e2e.ts",
|
||||
timeout: 30_000,
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:3000",
|
||||
viewport: { width: 1440, height: 900 }
|
||||
},
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
url: "http://127.0.0.1:3000"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test, type Locator } from "playwright/test";
|
||||
|
||||
test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await expect(panel).toBeVisible();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await expectAcrylicComposition(panel);
|
||||
await expect(panel).toHaveCSS("width", "432px");
|
||||
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await expectAcrylicComposition(panel);
|
||||
await expect(panel).toHaveCSS("width", "880px");
|
||||
});
|
||||
|
||||
async function expectAcrylicComposition(panel: Locator) {
|
||||
const composition = await panel.evaluate((element) => {
|
||||
const rootStyle = getComputedStyle(element);
|
||||
const blockingAncestors: string[] = [];
|
||||
const filteredDescendants: string[] = [];
|
||||
|
||||
let ancestor = element.parentElement;
|
||||
while (ancestor && ancestor.tagName !== "MAIN") {
|
||||
const style = getComputedStyle(ancestor);
|
||||
if (
|
||||
style.transform !== "none" ||
|
||||
style.filter !== "none" ||
|
||||
style.opacity !== "1" ||
|
||||
style.isolation !== "auto" ||
|
||||
style.willChange.includes("transform")
|
||||
) {
|
||||
blockingAncestors.push(ancestor.className);
|
||||
}
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
|
||||
for (const descendant of element.querySelectorAll("*")) {
|
||||
if (getComputedStyle(descendant).backdropFilter !== "none") {
|
||||
filteredDescendants.push(descendant.className);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
backdropFilter: rootStyle.backdropFilter,
|
||||
blockingAncestors,
|
||||
filteredDescendants,
|
||||
transform: rootStyle.transform,
|
||||
willChange: rootStyle.willChange
|
||||
};
|
||||
});
|
||||
|
||||
expect(composition.backdropFilter).toContain("blur(");
|
||||
expect(composition.blockingAncestors).toEqual([]);
|
||||
expect(composition.filteredDescendants).toEqual([]);
|
||||
expect(composition.transform).toBe("none");
|
||||
expect(composition.willChange).toBe("auto");
|
||||
}
|
||||
Reference in New Issue
Block a user