From bebcf2a06c5fe4c9a5261eca7c5d20a6aa984f92 Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 22 Sep 2026 10:45:46 +0800 Subject: [PATCH] feat(workbench): integrate runtime data and map tooling --- .env.example | 6 + README.md | 10 + docker-entrypoint.sh | 12 + docs/component-design-system.md | 57 + docs/non-map-ui.md | 115 + public/map/gis-customer-meter.png | Bin 0 -> 3014 bytes public/map/gis-customer-meter.svg | 6 + public/map/gis-drain-valve.png | Bin 0 -> 2508 bytes public/map/gis-drain-valve.svg | 5 + public/map/gis-flow-meter.png | Bin 0 -> 3105 bytes public/map/gis-flow-meter.svg | 7 + public/map/gis-hydrant.png | Bin 0 -> 2618 bytes public/map/gis-hydrant.svg | 4 + public/map/gis-non-residential-meter.png | Bin 0 -> 2135 bytes public/map/gis-non-residential-meter.svg | 6 + public/map/gis-service-connection.png | Bin 0 -> 2458 bytes public/map/gis-service-connection.svg | 8 + public/map/gis-valve.png | Bin 0 -> 2577 bytes public/map/gis-valve.svg | 5 + public/map/water-quality-monitoring.png | Bin 0 -> 7018 bytes public/map/water-quality-monitoring.svg | 1 + public/runtime-config.js | 3 +- src/app/app.e2e.ts | 37 +- src/features/agent/api/client.test.ts | 14 + src/features/agent/api/client.ts | 25 +- .../agent/components/agent-command-panel.tsx | 113 +- .../map/core/components/control-panel.tsx | 4 +- .../map/core/components/layer-control.tsx | 17 + .../map/core/components/layer-panel.tsx | 279 +- src/features/map/core/components/legend.tsx | 168 +- .../map/core/components/measure-panel.tsx | 274 +- src/features/map/core/index.ts | 6 +- .../api/gis-properties-client.test.ts | 40 + .../workbench/api/gis-properties-client.ts | 114 + .../workbench/api/project-client.test.ts | 105 + src/features/workbench/api/project-client.ts | 196 ++ .../api/realtime-flow-client.test.ts | 55 + .../workbench/api/realtime-flow-client.ts | 65 + .../workbench/api/scada-client.test.ts | 57 +- src/features/workbench/api/scada-client.ts | 158 +- .../workbench-analysis-history-client.test.ts | 71 + .../api/workbench-analysis-history-client.ts | 97 + .../api/workbench-runtime-client.test.ts | 405 +++ .../workbench/api/workbench-runtime-client.ts | 573 +++ .../components/agent-task-ticker-utils.ts | 33 +- .../components/agent-task-ticker.test.ts | 40 + .../components/agent-task-ticker.tsx | 23 +- .../components/analysis-history-section.tsx | 202 ++ .../components/analysis-results-panel.tsx | 42 + .../components/feature-popover.test.tsx | 90 + .../workbench/components/feature-popover.tsx | 129 +- .../workbench/components/map-dev-panel.tsx | 148 +- .../components/mobile-workbench-sheet.tsx | 2 +- .../scheduled-condition-detail-panel.test.tsx | 83 + .../scheduled-condition-detail-panel.tsx | 386 +- .../scheduled-condition-feed-utils.test.ts | 78 +- .../scheduled-condition-feed-utils.ts | 51 +- .../components/scheduled-condition-feed.tsx | 44 +- .../workbench/components/toolbar-panel.tsx | 129 +- .../components/workbench-panel-ui.tsx | 135 + .../components/workbench-top-bar-menus.tsx | 186 +- .../components/workbench-top-bar-styles.ts | 2 +- .../components/workbench-top-bar.tsx | 142 +- .../workbench/data/running-workflows.ts | 6 +- .../data/scheduled-conditions.test.ts | 18 +- .../workbench/data/scheduled-conditions.ts | 129 +- .../workbench/data/workbench-simulation.ts | 10 +- .../hooks/use-element-gis-properties.ts | 68 + .../hooks/use-map-interactions.test.ts | 13 +- .../workbench/hooks/use-workbench-agent.ts | 18 +- .../workbench/hooks/use-workbench-map.ts | 172 +- ...use-workbench-project-presentation.test.ts | 48 + .../use-workbench-project-presentation.ts | 65 + .../hooks/use-workbench-responsive-layout.ts | 14 +- .../hooks/use-workbench-runtime-data.test.ts | 362 +- .../hooks/use-workbench-runtime-data.ts | 232 +- .../use-workbench-scenario-analysis.test.tsx | 66 + .../hooks/use-workbench-scenario-analysis.ts | 71 + .../workbench/layout/workbench-layout.ts | 2 + src/features/workbench/map-workbench-page.tsx | 241 +- .../workbench/map/annotation-layers.ts | 65 +- .../workbench/map/feature-adapter.test.ts | 35 + src/features/workbench/map/feature-adapter.ts | 26 +- .../map/geoserver-layer-availability.test.ts | 31 +- .../map/geoserver-layer-availability.ts | 11 +- src/features/workbench/map/layers.test.ts | 52 +- src/features/workbench/map/layers.ts | 37 +- src/features/workbench/map/map-colors.ts | 14 +- .../workbench/map/map-control-config.test.ts | 141 +- .../workbench/map/map-control-config.ts | 395 ++- .../workbench/map/map-feature-query.ts | 11 +- .../workbench/map/published-gis-layers.ts | 268 ++ src/features/workbench/map/scada.test.ts | 31 +- src/features/workbench/map/scada.ts | 230 +- src/features/workbench/map/sources.test.ts | 33 +- src/features/workbench/map/sources.ts | 178 +- .../workbench/map/supply-icons.test.ts | 19 +- src/features/workbench/map/supply-icons.ts | 47 +- src/features/workbench/map/toolbar-config.ts | 8 +- .../operational-timeline-model.test.ts | 26 +- .../operational-timeline-model.ts | 42 +- .../peripheral-sample/client.test.ts | 169 + .../workbench/peripheral-sample/client.ts | 415 +++ .../peripheral-sample/generated/schema.d.ts | 3132 +++++++++++++++++ .../peripheral-sample/history-window.test.ts | 34 + .../peripheral-sample/history-window.ts | 29 + .../managed-test-resources.tsx | 386 ++ .../peripheral-sample-panel.tsx | 199 ++ .../synthetic-demo-section.tsx | 84 + src/features/workbench/types.ts | 67 +- .../utils/feature-properties.test.ts | 8 +- .../workbench/utils/feature-properties.ts | 107 +- .../utils/gis-attribute-presentation.test.ts | 51 + .../utils/gis-attribute-presentation.ts | 211 ++ .../utils/scheduled-condition-alerts.ts | 1 + .../utils/scheduled-condition-prompts.test.ts | 93 + .../utils/scheduled-condition-prompts.ts | 45 +- .../workspace/analysis-document-fixtures.ts | 14 +- .../workspace/analysis-document-view.tsx | 15 +- .../workbench/workspace/artifact-peek-bar.tsx | 19 +- .../workspace/use-artifact-workspace.ts | 11 + .../workspace/workbench-main-frame.tsx | 58 +- .../workspace/workspace-model.test.ts | 27 +- .../workbench/workspace/workspace-model.ts | 19 +- src/shared/config/env.test.ts | 41 +- src/shared/config/env.ts | 30 + src/shared/ui/checkbox.test.tsx | 27 + src/shared/ui/checkbox.tsx | 31 + src/shared/ui/date-time-field.test.tsx | 70 + src/shared/ui/date-time-field.tsx | 399 +++ src/shared/ui/disclosure.tsx | 36 + src/shared/ui/field-select.test.tsx | 84 + src/shared/ui/field-select.tsx | 72 + src/styles.css | 18 +- src/test/radix-select-test-setup.ts | 6 + tests/browser/agent-acrylic.e2e.ts | 188 +- tests/browser/agent-panel-resize.e2e.ts | 41 +- tests/browser/agent-question.e2e.ts | 18 + .../browser/agent-streaming-stability.e2e.ts | 73 +- tests/browser/agent-voice-hydration.e2e.ts | 27 +- tests/browser/feature-popover-layout.e2e.ts | 147 + tests/browser/map-dev-panel.e2e.ts | 12 +- tests/browser/map-notice-position.e2e.ts | 4 +- tests/browser/mobile-workbench-sheet.e2e.ts | 9 +- tests/browser/non-map-panels-layout.e2e.ts | 367 ++ tests/browser/peripheral-sample-panel.e2e.ts | 547 +++ .../project-presentation-backend.e2e.ts | 57 + tests/browser/scada-api-source.e2e.ts | 50 +- .../scheduled-condition-acrylic.e2e.ts | 51 +- .../browser/support/mock-analysis-runtime.ts | 56 + .../support/mock-hierarchical-work-order.ts | 92 + tests/browser/support/mock-scada-api.ts | 87 +- .../browser/support/mock-workbench-context.ts | 38 + .../workbench-panel-map-stability.e2e.ts | 46 +- .../browser/workbench-runtime-backend.e2e.ts | 513 +++ tests/browser/workbench-visual.e2e.ts | 59 +- ...workbench-desktop-light-chromium-linux.png | Bin 203846 -> 246448 bytes ...bench-desktop-satellite-chromium-linux.png | Bin 224374 -> 263323 bytes ...-desktop-wide-max-agent-chromium-linux.png | Bin 177773 -> 224838 bytes ...ench-mobile-agent-light-chromium-linux.png | Bin 118055 -> 123096 bytes ...h-mobile-launcher-light-chromium-linux.png | Bin 72461 -> 93807 bytes tests/browser/workspace-main-frame.e2e.ts | 155 +- vite.config.ts | 11 + 163 files changed, 15642 insertions(+), 1312 deletions(-) create mode 100644 docs/component-design-system.md create mode 100644 docs/non-map-ui.md create mode 100644 public/map/gis-customer-meter.png create mode 100644 public/map/gis-customer-meter.svg create mode 100644 public/map/gis-drain-valve.png create mode 100644 public/map/gis-drain-valve.svg create mode 100644 public/map/gis-flow-meter.png create mode 100644 public/map/gis-flow-meter.svg create mode 100644 public/map/gis-hydrant.png create mode 100644 public/map/gis-hydrant.svg create mode 100644 public/map/gis-non-residential-meter.png create mode 100644 public/map/gis-non-residential-meter.svg create mode 100644 public/map/gis-service-connection.png create mode 100644 public/map/gis-service-connection.svg create mode 100644 public/map/gis-valve.png create mode 100644 public/map/gis-valve.svg create mode 100644 public/map/water-quality-monitoring.png create mode 100644 public/map/water-quality-monitoring.svg create mode 100644 src/features/workbench/api/gis-properties-client.test.ts create mode 100644 src/features/workbench/api/gis-properties-client.ts create mode 100644 src/features/workbench/api/project-client.test.ts create mode 100644 src/features/workbench/api/project-client.ts create mode 100644 src/features/workbench/api/realtime-flow-client.test.ts create mode 100644 src/features/workbench/api/realtime-flow-client.ts create mode 100644 src/features/workbench/api/workbench-analysis-history-client.test.ts create mode 100644 src/features/workbench/api/workbench-analysis-history-client.ts create mode 100644 src/features/workbench/api/workbench-runtime-client.test.ts create mode 100644 src/features/workbench/api/workbench-runtime-client.ts create mode 100644 src/features/workbench/components/analysis-history-section.tsx create mode 100644 src/features/workbench/components/analysis-results-panel.tsx create mode 100644 src/features/workbench/components/feature-popover.test.tsx create mode 100644 src/features/workbench/components/scheduled-condition-detail-panel.test.tsx create mode 100644 src/features/workbench/components/workbench-panel-ui.tsx create mode 100644 src/features/workbench/hooks/use-element-gis-properties.ts create mode 100644 src/features/workbench/hooks/use-workbench-project-presentation.test.ts create mode 100644 src/features/workbench/hooks/use-workbench-project-presentation.ts create mode 100644 src/features/workbench/hooks/use-workbench-scenario-analysis.test.tsx create mode 100644 src/features/workbench/hooks/use-workbench-scenario-analysis.ts create mode 100644 src/features/workbench/map/feature-adapter.test.ts create mode 100644 src/features/workbench/map/published-gis-layers.ts create mode 100644 src/features/workbench/peripheral-sample/client.test.ts create mode 100644 src/features/workbench/peripheral-sample/client.ts create mode 100644 src/features/workbench/peripheral-sample/generated/schema.d.ts create mode 100644 src/features/workbench/peripheral-sample/history-window.test.ts create mode 100644 src/features/workbench/peripheral-sample/history-window.ts create mode 100644 src/features/workbench/peripheral-sample/managed-test-resources.tsx create mode 100644 src/features/workbench/peripheral-sample/peripheral-sample-panel.tsx create mode 100644 src/features/workbench/peripheral-sample/synthetic-demo-section.tsx create mode 100644 src/features/workbench/utils/gis-attribute-presentation.test.ts create mode 100644 src/features/workbench/utils/gis-attribute-presentation.ts create mode 100644 src/features/workbench/utils/scheduled-condition-prompts.test.ts create mode 100644 src/shared/ui/checkbox.test.tsx create mode 100644 src/shared/ui/checkbox.tsx create mode 100644 src/shared/ui/date-time-field.test.tsx create mode 100644 src/shared/ui/date-time-field.tsx create mode 100644 src/shared/ui/disclosure.tsx create mode 100644 src/shared/ui/field-select.test.tsx create mode 100644 src/shared/ui/field-select.tsx create mode 100644 src/test/radix-select-test-setup.ts create mode 100644 tests/browser/feature-popover-layout.e2e.ts create mode 100644 tests/browser/non-map-panels-layout.e2e.ts create mode 100644 tests/browser/peripheral-sample-panel.e2e.ts create mode 100644 tests/browser/project-presentation-backend.e2e.ts create mode 100644 tests/browser/support/mock-analysis-runtime.ts create mode 100644 tests/browser/support/mock-hierarchical-work-order.ts create mode 100644 tests/browser/support/mock-workbench-context.ts create mode 100644 tests/browser/workbench-runtime-backend.e2e.ts diff --git a/.env.example b/.env.example index 3f0cd78..0b0b65f 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,12 @@ TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver TJWATER_GEOSERVER_WORKSPACE=tjwater_next TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000 TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 +TJWATER_WORKBENCH_RUNTIME_MODE=auto +TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL= +TJWATER_PERIPHERAL_SAMPLE_TIME_MODE=demo +TJWATER_PERIPHERAL_SAMPLE_DEMO_START=2026-09-17T00:00:00Z +TJWATER_PERIPHERAL_SAMPLE_DEMO_END=2026-09-17T12:00:00Z +TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS=8 TJWATER_ENABLE_DEV_PANEL=false TJWATER_ENABLE_MSW=false diff --git a/README.md b/README.md index cd0c050..4241adb 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力 | `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 | | `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 | | `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 | +| `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 | +| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 | +| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 | +| `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) | +| `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) | +| `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` | `8` | 实时时间模式回看小时数,范围 1—168 | | `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | | `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | @@ -106,4 +112,8 @@ src/ ## 构建说明 +调查任务支持样板反馈的人工复核草稿。在“外围样板”显式导入反馈证据后,可读取同一来源的完整反馈集合、确认并保存新修订;已保存草稿也可从“调查任务”的历史草稿中再次打开,不依赖外围服务在线。页面区分待重分析、冲突证据及前后修订,不代表已经重新计算。接口与存储边界见 [复核修订说明](../next-tjwater-server/docs/feedback-review-revisions.md)。 + +“分析成果”面板只读取当前项目 BizDB 中已持久化的分析历史,不启动分析任务。外围样板面板可对 synthetic 测试工单和测试数据执行 CRUD,历史监测回放仍为只读。部署所需表和边界见 Server 的[外围样板说明](../next-tjwater-server/docs/peripheral-sample.md)。 + 生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c0817e1..af72d16 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -10,6 +10,12 @@ runtime_config=$(jq -cn \ --arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \ --arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \ --arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \ + --arg workbenchRuntimeMode "${TJWATER_WORKBENCH_RUNTIME_MODE:-auto}" \ + --arg peripheralSampleApiBaseUrl "${TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL:-}" \ + --arg peripheralSampleTimeMode "${TJWATER_PERIPHERAL_SAMPLE_TIME_MODE:-demo}" \ + --arg peripheralSampleDemoStart "${TJWATER_PERIPHERAL_SAMPLE_DEMO_START:-2026-09-17T00:00:00Z}" \ + --arg peripheralSampleDemoEnd "${TJWATER_PERIPHERAL_SAMPLE_DEMO_END:-2026-09-17T12:00:00Z}" \ + --arg peripheralSampleRecentHours "${TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS:-8}" \ --arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \ --arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \ '{ @@ -21,6 +27,12 @@ runtime_config=$(jq -cn \ TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace, TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl, TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl, + TJWATER_WORKBENCH_RUNTIME_MODE: $workbenchRuntimeMode, + TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: $peripheralSampleApiBaseUrl, + TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: $peripheralSampleTimeMode, + TJWATER_PERIPHERAL_SAMPLE_DEMO_START: $peripheralSampleDemoStart, + TJWATER_PERIPHERAL_SAMPLE_DEMO_END: $peripheralSampleDemoEnd, + TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: $peripheralSampleRecentHours, TJWATER_ENABLE_DEV_PANEL: $enableDevPanel, TJWATER_ENABLE_MSW: $enableMsw }') diff --git a/docs/component-design-system.md b/docs/component-design-system.md new file mode 100644 index 0000000..069f40c --- /dev/null +++ b/docs/component-design-system.md @@ -0,0 +1,57 @@ +# 新增业务组件设计规范 + +本规范覆盖调查任务、运行控制、人工复核、外围样板、日期时间与共享表单组件。地图仍是主工作区,侧栏负责定位当前任务、显示状态、给出下一步操作和按需展开证据。 + +## 1. 视觉主题 + +方向为浅色专业调度台。界面保持高信息密度,但使用稳定的标题、状态、操作、证据顺序,减少连续嵌套的彩色卡片。视觉参考取 Linear 的状态聚焦、Grafana 的数据对齐,以及 ArcGIS Experience Builder 的地图优先侧栏。 + +## 2. 色彩角色 + +- `--surface-reading`:主要阅读面。 +- `slate-50`:同一业务区内的次级数据面。 +- `--action-blue`:主操作、焦点和调查任务状态。 +- `cyan-700`:外围样板入口及其来源标识。 +- `emerald`:已完成、已通过。 +- `amber`:需要确认、暂停、结果未知。 +- `rose`:失败、冲突或破坏性操作。 + +彩色区域只表达语义。普通分组使用白色阅读面、灰色边界和轻微背景层级。 + +## 3. 排版规则 + +沿用 Noto Sans SC。侧栏标题使用 14px,业务区标题使用 13px,正文和表单标签使用 12px,辅助信息使用 11px。编号、修订号、时间和数量使用等宽数字。标签左对齐,数值列右对齐。 + +## 4. 组件规则 + +- `WorkbenchSection` 统一业务区标题、说明、状态和操作,不在内部再次堆叠同级标题卡。 +- `WorkbenchNotice` 使用左侧状态线传达信息、警告与错误,避免大面积色块。 +- `WorkbenchEmptyState` 只说明当前为空和下一步,不占用过高空间。 +- 表单控件高度为 40px,按钮使用现有 8px 或 12px 圆角体系。 +- 主操作每个业务区最多一个。其余操作使用 outline 或 ghost。 + +## 5. 布局原则 + +每个业务区按“定位、状态、下一步、证据”排列。侧栏内容使用 12px 至 14px 的紧凑间距。复杂原始对象、请求参数、历史和测试资源按需展开。移动端保持单列,不用横向滚动承载业务字段。 + +## 6. 层级与深度 + +侧栏使用既有 acrylic 容器。一级业务区使用不透明阅读面和 1px 边界。区内次级数据使用背景色差,不叠加投影。弹层可使用较强投影,普通卡片只保留极轻的 1px 投影。 + +## 7. 使用约束 + +- 必须优先显示当前状态和可执行的下一步。 +- 必须明确 synthetic、只读、结果未知和未配置状态。 +- 必须保留完整请求号、对象修订和证据来源的可读入口。 +- 不用颜色代替文字状态。 +- 不在一个业务区内放置多个同等醒目的主按钮。 +- 不用大块空卡显示一句空状态。 +- 不引入第二套字体、主题或 CSS 方案。 + +## 8. 响应式与无障碍 + +支持 1280px 桌面和 320px、375px 窄屏。所有交互控件保留可见焦点,触控目标至少 40px。弹层受视口高度和宽度限制。hover 仅在支持 hover 的设备启用,按压使用轻微缩放,并尊重 reduced motion。 + +## 9. 交互提示 + +任务面板先显示任务状态和下一步,再提供计划、目标、运行与证据。运行暂停时突出检查点校验或恢复中的一个主操作。外围面板先显示连接状态、历史回放和待提交来源,测试资源与原始请求回执默认按需展开。 diff --git a/docs/non-map-ui.md b/docs/non-map-ui.md new file mode 100644 index 0000000..d2f6bbe --- /dev/null +++ b/docs/non-map-ui.md @@ -0,0 +1,115 @@ +# 非地图面板整理 + +2026-09-17,范围限供水前端,不改变地图业务、OpenCode、数据库表或环境配置。 + +## 视觉与交互 + +沿用现有浅色阅读面、Noto Sans SC 中文字体、蓝色操作强调和状态徽标。优先修复可读性、对齐和窄屏裁切,不引入第二套主题、字体或组件库。 + +- `FieldSelect` 复用现有 Radix Select:统一标签、40px 最小高度、长选项换行、禁用态、键盘选择和弹层层级。清空受控值后回到占位提示,不触发额外业务动作。 +- `Checkbox` 保留原生输入与标签语义,统一勾选、焦点和禁用样式。反馈确认仍须用户主动勾选。 +- `Disclosure` 保留原生 details/summary 语义,统一展开箭头、焦点和点击区域。 +- 调查目标和外围历史回放的日期字段单列展示,避免窄面板内日期与时分被裁切。两处均已使用共享自定义日期时间弹窗,保留各自本地时间/UTC 的业务语义。 +- 手机端 Agent 欢迎区改为紧凑横排,避免被底部输入区遮挡。 +- 桌面成果专注视图为 Agent 折叠栏预留空间,标题不再被覆盖,仍可重新展开助手。 +- 成果图表显式使用页面的中文字体栈。此前 ECharts 画布仍以 `11px sans-serif` 绘制中文图例,在测试环境显示方框;浏览器画布探针已验证修复前失败、修复后在 1280/375px 通过,截图可见完整“调度下限”。源码只有这一处 ReactECharts 入口。 + +## 检查覆盖 + +| 面板 | 本次检查 | +| --- | --- | +| 调查任务、生命周期、运行与历史结果 | 表单、确认区、折叠内容、长标识、320/375/1280px | +| 已保存比较结论、Evidence 查看 | 四分支、加载/空/错误态、只读重试、键盘下拉、320/375/1280px | +| 外围样板、模拟数据、历史回放 | 下拉、日期、状态徽标、任务历史、375px | +| 反馈关联、人工复核 | 确认勾选、展开内容、禁用与只读边界、窄屏 | +| Agent 对话与历史 | 桌面抽屉、320/375px 欢迎区与滚动容器边界 | +| 工况列表与详情 | 375px 抽屉布局 | +| 成果预览与文档 | 1280/375px,桌面标题与折叠栏几何边界 | +| 图层、测量、标注、更多、开发面板 | 非地图浮层边界,开发面板表单控件统一 | + +`FeatureInsightPanel` 当前只有定义,没有运行入口,未伪造页面声称已完成运行检查。实际要素详情使用地图上的 `FeaturePopover`,不在本次非地图面板范围。 + +## 验证与限制 + +最终代码检查:`pnpm typecheck`、`pnpm lint`、全量 68 文件 / 350 项 Vitest、`pnpm build` 通过。生产构建仍提示部分 chunk 超过 500kB,本次未扩展打包优化范围。调查/反馈/外围/比较/生命周期的 29 项与非地图布局的 5 项专项浏览器用例通过,比较区新增的弹层不透明度和背景色检查另行重跑 6 项通过。非地图布局用例同时覆盖开发面板键盘选项和成果图表画布字体。 + +浏览器用例使用显式模拟接口,不代表真实账号、Agent 和 PG 的全链路验收。主要回归在 `tests/browser/non-map-panels-layout.e2e.ts`、`run-comparison.e2e.ts` 及既有调查、反馈、外围与生命周期测试中;截图保存在忽略的 `test-results/non-map-panels/`、`test-results/run-comparison/` 等目录,不作为发布资源。 + +本次也运行了完整浏览器套件,当次为 66 通过、33 失败,不能宣称全量通过。已确认的失败包括旧用例依赖 Agent 初始展开或已不存在的“工作台导航”、旧截图基线,以及新移动用例当时选择器不唯一。后者已改为语义化 `role=log` 并在专项回归中通过;其他旧用例与真实地图依赖仍需单独梳理,不通过修改产品默认行为或批量更新截图掩盖失败。 + +运行内容来自现有接口,无需手动替换界面占位数据。人工验收时请在桌面全宽与 375px 下检查长任务名称、日期输入、下拉键盘操作和成果标题。 + +## 后续交互回归 + +继续核查旧失败用例时发现并修复了两处真实缺陷: + +- 窄桌面展开工况详情会自动收起 Agent;此前聊天草稿存在被卸载的面板内部,重新展开时丢失。现在由稳定的工作台 hook 持有草稿,桌面和手机面板共享受控值,新建会话仍清空。此状态只在当前页面内保留,不是刷新后的持久化草稿,也没有新增存储层。面板仍可卸载,避免为了保留文字让隐藏的语音控件继续活动。 +- 新增调查/外围入口后,1536px 顶栏的异常处置与调查按钮发生重叠。现在保留按钮宽度、收紧组间距,仅在 1800px 以上展开次要按钮文字;较窄桌面省略模型只读提示,完整操作名称仍通过无障碍标签提供。手机标题显示短称“供水”,而不是省略号。 + +`workspace-main-frame.e2e.ts` 现在验证默认收起、主动展开后的相邻布局、收起恢复、成果 split/focus 切换,并检查 320/375/1024/1280/1440/1536/1600/1800/1920px 顶栏按钮不重叠、标题不截断。顶栏几何断言修复前失败,修复后通过。截图位于 `test-results/workspace-regression/`。 + +`agent-voice-hydration.e2e.ts` 与 `agent-panel-resize.e2e.ts` 改为先通过真实按钮打开助手,再检查语音、几何和草稿状态;不再假定 Agent 初始展开或折叠后保留旧版 72px 导航宽度。工况自动收起场景使用其实际适用的 1280px 宽度,保留了未发送草稿不丢失的断言。布局测试按实时几何等待入场动画完成,不固定动画中途的坐标,也没有加入固定 sleep。 + +该轮完整浏览器套件为 79 通过、21 失败,共 100 项。仍有旧导航/默认展开假设、截图基线与动画时机相关失败,不能宣称全量通过,也未批量更新基线。 + +## 调查任务日期时间补充 + +根因是原先只统一了 `datetime-local` 输入框外观,其日历和时间弹窗仍由浏览器绘制。调查任务的开始、结束时窗已替换为共享 `DateTimeField`:沿用浅色面板、蓝色选中态和现有 Radix 弹层/下拉,不引入新依赖。 + +- 日历、月份、年份、小时和分钟均在应用内绘制,窄屏弹窗限制宽高并允许纵向滚动。 +- 点击确认才更新值,取消或 Esc 放弃草稿并将焦点返回字段;日历支持方向键和 PageUp/PageDown。 +- 年份编辑保持输入焦点,支持闰年和真实月份天数;无效源值不会被静默截断后保存。 +- 已关闭任务不可编辑。组件仍输出本地 `YYYY-MM-DDTHH:mm`,原有目标保存逻辑负责转换 UTC,不改变业务时区语义。 +- `date-time-field.test.tsx` 覆盖组件边界。 + +调查任务两个日期字段均已替换;同类的外围历史回放已在后续批次统一,见下节。 + +补充后的验证:`pnpm typecheck`、`pnpm lint`、69 文件 / 357 项单元测试和 `pnpm build` 全部通过;日期时间、调查面板、非地图布局、运行控制及任务生命周期的 20 项浏览器回归通过。生产构建仍有既有大 chunk 提示。上述浏览器检查使用模拟接口,不替代真实系统验收。 + +日期回归用例做过红绿验证:临时将调查时窗重新接回原生 `datetime-local`,确认/提交用例因缺少自定义字段入口失败;恢复 `DateTimeField` 后重新运行全部 3 项日期用例通过。代码未提交。 + +按用户要求,每轮 Playwright 结束后检查其浏览器和临时 Vite 服务是否退出,不关闭用户已有的 5173 开发服务。 + +## 外围历史回放日期补充 + +外围面板的 `HistoryTime` 同样仍由浏览器绘制日期时间弹窗,已删除该包装并复用 `DateTimeField`。保留“开始(UTC)/结束(UTC)”标签和原来的 UTC 查询拼接,不套用调查任务的本地时间转换。扫描 `src/` 已无原生 date/time/datetime-local 输入入口。 + +`peripheral-sample-panel.e2e.ts` 新增实际交互断言:修改起止日期时间后只有点击“读取历史读数”才发送 GET;浏览器为 Asia/Shanghai 时,08:15 仍按 08:15Z 查询;取消不改值、不发查询,也没有任何外围写请求。320/375/1280px 弹窗边界与无横向溢出通过,截图位于忽略的 `test-results/peripheral-datetime/`。 + +红绿验证:替换前该用例因缺少自定义日期入口失败,替换后外围面板、调查日期时间、反馈导入共 14 项浏览器测试通过。类型检查、Lint、69 文件 / 357 项单元测试与生产构建通过,构建仍提示既有大 chunk。仍使用模拟接口,不代表真实外围服务验收;完整浏览器套件的失败不因本轮专项通过而视为消除。 + +## 问答与流式测试入口修订 + +重新运行确认旧问答/流式用例默认寻找尚未展开的 Agent 输入框;显式展开后还暴露了授权项目模拟缺失。请求失败探针记录 `/api/v1/projects` 连接拒绝,Agent 客户端因此未进入回复与模型查询。现在使用明确的测试运行配置和已有项目/SCADA mock,不改产品授权流程;临时日志已移除。 + +流式用例另外仍断言旧浮动简报和浮动输入区。当前 `desktop-dock` 分支将简报和输入区放入正常布局,已改为验证简报在对话区上方、输入框在滚动区域下方、分析中操作禁用及区域不重叠;进度窗口几何采样、计划卡片、结束滚动、保持展开、选中文本朗读等原断言仍保留。问答回复的精确请求号与 payload 断言未放宽。两个文件共 3 项重跑通过,其中问答桌面和流式用例此前失败。 + +本轮最终完整浏览器套件:103 项中 85 通过、18 失败,耗时 2.7 分钟。失败分布为 `app.e2e.ts` 4 项、`agent-acrylic.e2e.ts` 4 项、开发面板/地图通知/移动转桌面/窄桌面工况/地图相机稳定性各 1 项、视觉基线 5 项。不得将全部失败归因于旧断言,仍需逐项核查实际行为。此前工况动画时机失败在本轮未复现,未作修复,仍作为时序风险保留;问答与流式是本轮明确修订后通过的两项。完整结果与失败上下文位于忽略的 `test-results/non-map-followup-full/`。 + +## 历史菜单、Esc 分层与全量回归收尾 + +继续核查上述 18 个失败后,确认并修复了以下真实行为问题: + +- 手机 Agent 内的 Radix 模型菜单和权限模式菜单已处理 Esc,并将事件标记为 `defaultPrevented`,但外层 `MobileWorkbenchSheet` 的 window 监听仍无条件关闭抽屉。外层现在只处理未被内层消费的 Esc。模型和权限菜单各自关闭后,焦点返回原触发按钮;没有内层菜单时,Esc 仍关闭抽屉。 +- Dev Panel 有相同的事件分层缺陷。`FieldSelect` 打开时第一次 Esc 只关闭选择菜单并返回焦点,第二次 Esc 才关闭开发面板。 +- 手机端已打开 Agent 时跨越 1024px 进入桌面,原逻辑先清空移动抽屉状态,导致用户打开 Agent 的意图丢失。现在先将移动 Agent 状态迁移为桌面展开状态,再清空移动抽屉。 +- Dev Panel 打开时,地图工具栏仍在面板下面渲染。现在开发面板显示期间不挂载桌面地图工具栏与其工具面板。 +- 1024px 下展开工况任务会自动折叠 Agent,但 880px 工况面板与 72px 折叠栏重叠 4px。布局变量现显式记录折叠栏宽度,工况面板在该宽度下收敛到 852px,二者保留 24px 间距。 + +同时修正了三类失真的回归断言。地图容器变宽或变窄时,MapLibre 的 `resize()` 会发出 `movestart`,但经纬度、缩放、方位角和俯仰并未改变;相机稳定性用例现在直接比较这五项参数。地图通知位置测试改用确定会生成通知的“操作参考”入口。工况材质测试等待入场动画结束后再读取 transform 和 will-change,不再采样动画中间帧。焦点、几何和业务请求断言均保留。 + +320px 与 375px 的模型菜单截图位于 `test-results/agent-history-ui/320-model-menu.png` 和 `375-model-menu.png`,长标题重命名截图位于同一目录。模型名称、说明和菜单边界完整,权限模式、模型、语音和发送控件没有重叠。五张过期的工作台视觉基线经逐张检查后更新,覆盖桌面浅色/卫星底图、1920px Agent 收起状态、手机启动器和手机 Agent 半屏;手机工况卫星图无需更新。视觉用例更新后再次无更新参数运行,6/6 通过。 + +最终验证结果:`pnpm typecheck`、`pnpm lint`、69 文件 / 357 项 Vitest、`pnpm build` 全部通过。构建继续报告既有的 500kB 以上 chunk 提示,CSS 校验和 TTS bundle 通过。完整 Playwright 套件为 104/104 通过,耗时 2.4 分钟,结果位于忽略的 `test-results/non-map-followup-final-green/`。这些浏览器用例仍使用模拟接口,不替代真实账号、Agent、PG 和外围服务的全链路验收。 + +## 新增业务组件统一设计 + +2026-09-18 对调查任务与外围样板的新组件完成统一重构。完整规则见 `docs/component-design-system.md`,视觉方向是浅色专业调度台,保留地图为主工作区,并将侧栏内容稳定为“定位、状态、下一步、证据”的阅读顺序。 + +- 新增 `WorkbenchSection`、`WorkbenchNotice` 和 `WorkbenchEmptyState`,统一标题、状态、提示和空态,减少同级彩色卡片与大面积空白。 +- 生命周期确认、定时计划、目标编译、运行控制、运行对比、人工复核和 Evidence 导入统一状态语言与操作层级。 +- 外围样板将连接与场景、历史回放、待提交来源、已有任务放在前面;synthetic 数据、测试工单、测试数据和 request_id 查询移到辅助区并默认折叠。 +- `FieldSelect`、`Checkbox`、`Disclosure` 和 `DateTimeField` 统一焦点、hover、按压和禁用反馈。日期时间弹层继续支持 Esc 取消并返回焦点。 +- 数据表使用等宽数字并将数值右对齐;320、375 和 1280px 下均无横向溢出。 + +本轮最终检查为 `pnpm typecheck`、`pnpm lint`、76 文件 / 395 项 Vitest 和 `pnpm build` 通过。新增组件相关的 17 文件 / 118 项单测通过,核心业务面板 Playwright 为 31/31 通过。完整浏览器套件共 110 项,并发运行 108 项通过;2 项在等待 Toast 退出时超过 15 秒,随后单进程精确复跑 2/2 通过。截图位于忽略的 `test-results/task-lifecycle/`、`run-control/`、`run-comparison/`、`peripheral-datetime/` 和 `/tmp/feedback-*.png`。 diff --git a/public/map/gis-customer-meter.png b/public/map/gis-customer-meter.png new file mode 100644 index 0000000000000000000000000000000000000000..543ace1f99b83daa4588d6629ffe5cf5b2713b27 GIT binary patch literal 3014 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 + + + + + diff --git a/public/map/gis-drain-valve.png b/public/map/gis-drain-valve.png new file mode 100644 index 0000000000000000000000000000000000000000..9f61305cd8ae9080ad63cfdf373ef1b8b264973e GIT binary patch literal 2508 zcmV;-2{ZPIP)9ewL56CM$M6yhj@C%~b@N7b^m8`Zea zW~)BK#;LA-2CFVHh0pRGzDpU04j)r-pDInlLxOL8IpYSQUWn89{Vj*p%mqtTk3pkj zD?6_A95hDCUZxJT9EoL!X+ozTmhKVu31JQG=;%;uHf~k#)qUbz={Dh7Yd39^Yo84@ zLbGtU;9HM1@rKYQ*c#W~-l3K^Y*f96ew3Tyb?H5HoLbSiDd+??Xv6D*Z+*t*Qh^0j zSkKzEcfWdf)I@KJ*QNi63F?QYy`gtQop6cZTfd=PF3b>YjW~7scQvv8OYaJ|$tN@B zs?)#!VKu^Zfj0Wqvk@w`Sg=)o_r8N(hnpnB(w_Z?tVUQQ6wU|?J7GP4$?Em0Tfd>+ zl^a)H?LSm4U9%zdPAJrHG(s>{l)3lph0DDwv{tE~zeL&GQatB;>pg(kj;Vr7@fcHm zD>1E1nmNxpU42dPs{FfzGlHq-Pnk8}yYgHK9m#YjFh#vr@S=QHiF*Z8&zSYqGVe-r zEz{?IZM+Ni3pWb68^GpX;DPH~Sy{=lwdM!&R3TU8OqIt2nY8y55$bAd2WuG?lsb4(b8%(k&o@;% zYK2?mp07h)!`d=w#ysQqdJ35;|61W^LBH^h-OZ8X=Y?bdWapl0Coh{i*9n;!fE^?K zqIePqj{Lah2Urma8-gk_V8n#LqpQs_u*i(k#{|qbeLAjF9^;J&?x8^Vkax_Mi8%p$<`iqr-midZk0kiXIZ=gaud=Tz0l$#nF%pcm{rx}k^$U{R)n?ouhA z2e4i!QOaHW4k-MSQ=`o>Z190+CFA(2{>x%!^QyRx3nbZJD%db@$zx}?dPR|H68=#Az7szzWb;V74 z1>f)5qh1?eR#FlJPzGh`WHFWIq>j$ZLd^zXb3rd~;mQW*6}RmX*o+X@05;`AoL#S^ zvS?-XS;M!5PHO<05#ksiO!=M006vGHFe5}6AWHerDTbiD9>Q@H_Fp4}86as{E@hqT zhj5(Pw1B*`>o+LR~vLSBtadMD)B^O0UdGTf90GEM1?%sVV4?wyc#&qqvQDH{bR z$_LVX=@;-3m=$FNAIe7m6!5DLB;;n)+1Ar;78!2D% zZZMM921^`iGz8~J{&tG5dQYL;h(fVw(#`l1?L9f z&;beV^o!wwP0~oh_@>YMIyly3sywgp9-rZ}St~tFeO9giw(9dogewK-27q2dbp<=r zz){ghl-~ZZE=aIv@|W14@vAp%$;1$8>qEXHlfm8+GEqL*4(T=RJz!0$$Lmbf&e)S@ zMZ$L}BT3>u>oDV}KF5w%sUbL|hmJBc0GJNyfhjQuz08F_r^3$N`+{*fO|N%ZADvX& zk8$Ck_6|Y6xPDN(`v;@ciC?1AgZ_Hzbg-sC_&bV|znxNblj{>JpYQNp*Y&BeOUNye z(}RMk63qu&|CtWjl3M`ATCjYLaUH>Hycf1nk7tCb=bP`3j8r_I;j>}ssMFR59u{&@ zeg*PsnkqtLZ3ZZs;*!?KmUDEykgM{66XAOVQ-w%Z!w_`*=YNNwlfM>g=tw%0)z4dE zET>&?zUqxjSi+`HXDc!&2i$0Rj1 zHIg|p>Aq@Jx-Pz^vy*4dx27y31H4z|R~RmsDoCf9vuK4gBOrM$=y18)o4h$P)$SPFYN?;C5Qd3){NrswLG=)p29%~sgf7$p- zxJ>Y^ZzC8X>`q`x$OA14BcwK>$YgnDg-u)O`Gr+}g^PtDg0&UuobTwpy5c17Cil+zzJZzn9zT-L(1aVdxWjsVl_!pwG(!jB)=a*O_Jh_gY& zHCadUQasy;>|{m#fCCH7I|bi*Oa%#E89PH;`Zr`9VHKT)|DVti)&QmrbQ7NmndX1X z8yt9e4C6GOsW-V7I1IuG^x#K)63lA&EU}t|3lgzr3YxNkr}qKDx4trgSrVxoO`AGV zu<)YyZrR=xoJKtFXRPE|!fU)oh`1@4UKITFA^!Zr{Wd@rRDQ^@jzC$mk49fD7V*WznCV}%-DDzo? + + + + diff --git a/public/map/gis-flow-meter.png b/public/map/gis-flow-meter.png new file mode 100644 index 0000000000000000000000000000000000000000..c2ae471fd2184788c325fffacdf984dd703f96d0 GIT binary patch literal 3105 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 literal 0 HcmV?d00001 diff --git a/public/map/gis-flow-meter.svg b/public/map/gis-flow-meter.svg new file mode 100644 index 0000000..89664d1 --- /dev/null +++ b/public/map/gis-flow-meter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/map/gis-hydrant.png b/public/map/gis-hydrant.png new file mode 100644 index 0000000000000000000000000000000000000000..2d3ad1a4bef22c05b3d6c9260d67d19303233a3c GIT binary patch literal 2618 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 cDJh=(2QC + + + diff --git a/public/map/gis-non-residential-meter.png b/public/map/gis-non-residential-meter.png new file mode 100644 index 0000000000000000000000000000000000000000..52be79daf0d87ca3718b4e0a75d4fbce2e780cb0 GIT binary patch literal 2135 zcmV-d2&ngoP)MAO`ym|A!v)w(fXI@X4qEILl3WY+UPy~}li06r!#79JCk^R#h6oYQ5Pru~iU*1Ji3Y;&06I+_F^9l8`WQ;BxM??AatEAbZL zcf9ZOII*AbJ1|~TMm$FN8vIv?ZbHW4%Y6f^y1tPes65GbA3Bkgt>WlOcCw*S^Hqk6 z#LI*)!GD9eNyKyTY;yJ{Fp*s?d2`$6}gku8C zB}7UGdBBxzd7F0%e+9D%#{{S+_yb_7>G#PeOZz+0tP=paBS(V&93fITNb~$e+b8)O z*}Z%E7(GQKEC5o#)~+7I!1G+}>C?pW z+RvW;t2$pWhT0YY>!Z9uq-(u8p^tNlSk0Lx>A>^gZZ(4jNU!`A`8u7jEdUm&d4tI3 zyL}?_>F0c;x{hWRf~iFe1kdfc@Wm_dVR z$-zo?XVfs$eIH!ii*kzDf>rC-iGL!(X<#g4G?bOo#WQPJzH9#P`t7*xxLJg40ag;c zK?KNY$ET&Q)&|eP>%*pjM<#N`+V6EXG7C1FE}noJBR92K)O#7(j-Ll&!-E(mwE)>0 zwzErD%$ESK3=FZg+sbG*s9@d^Z z@MG3uWmW|r;cK7-n4J}ldj}f@D{JX_8ypKf-(VZZFay5A=fDWCVdufPC*0oM%f2l7 z+0@Rv>Des&-7a?Q^cmLO+ppz-(D3i0ry8`+@EtQdI2Mi>5BMAy0Yr`gE4iEeEv3R+ zeV)sGSG7eFGka#aC8y3aX$7dNjYzE}L!7eT|1=Fe&)&mTT9D1`!w2}1astTO*>~hG z%fXMb=TMdF{sme9bUQqwWB*N0aUX%J7j4;^En1Fh{H}h6g@>t>05(n#gDKzV_YYoU zX}Rll-<_7Wn(hCiT6YY9^tsuvgfST7vzeKB6F$Qe6iNV#c6413 zO*>;hN8TSIpcVeGXToP$Yqn1KzerUm0W8`u@(!un|LDNc~yRF!15$k$dmv!>>!Q6gR2{hCg|reFk^O8e})1zrF&WHs%A$3)!rFYp2&rHrhmRq(K`OgdT#AlX8jc1XR!jy~V& zn>$JX$%ercGaBS}kY2#e8X6wP#Z(iYnGLu~00}$Mn=Ge*E!b27NZN5S_)%a}2_R|5 zEtCL~cHBYT50GNbrGC*-Dw$e}djbuTcR<%T^i{Zbi{jKgyRSOGBBmYs31&trrMK z3%3Gn+aXdK#V%5@bTOXDYwo-xy!u@$Wshw;LjSP8 zux$r+j_^A$ZqrP7zHgW;xckF~m)N|(??5>XayzpK$M106;%Op>Ku&zJZf6VMu?K)7 z8!`9^O1PtnIM$dK5ZF99lw`ms_=YuHq~H@OJFieE6bgkxp-?0@{{ow7?v!9Ph`#^; N002ovPDHLkV1lrK`$qr( literal 0 HcmV?d00001 diff --git a/public/map/gis-non-residential-meter.svg b/public/map/gis-non-residential-meter.svg new file mode 100644 index 0000000..a064bf4 --- /dev/null +++ b/public/map/gis-non-residential-meter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map/gis-service-connection.png b/public/map/gis-service-connection.png new file mode 100644 index 0000000000000000000000000000000000000000..b05fe71c54540800e9611085bfd3febe4e7f9bdd GIT binary patch literal 2458 zcmV;L31#+)P)nQHt=mFjzPs{3FCwcriHVeqoUCnBcm;so*Z*LxEOqTH(du z?)X5sQ*f;Z>)b7TD%1a>Czy#&_p0GhFps~)C z8&!HrS=Vpb6?!M6syiAd=nBf*d+C}Q=L#)V7Oq_91-Ydd=UnSO2eTbr0h!{_r@B^R zxiVwX%E0MrcfqOhpA=dIUCaM*@ha!aGbJ=6-JQS`^%=p5@_9-;CFokl;?>p8m1bJz zFaJe*7n~6u5Ud*@$i2XT>snb!$@;CkwMLjDSXDmGDqTTp&(-H<5Uv%KlrWQHI<4as z!HV*k10NC80$jP;oby1sR#MW!v&?19!x~;xE3Rz(`MMU6<|ka!GJWAn?OpJNkf`$S z5&jU=3m-Xg+PP9}%h8i(d=&JM;5>Y-2 z=oba`g1D4jE2A8lI%k=8y!Ncu(%PC6JhfA1FO78U#vOZo251x#S?DKvLD%{_gM%$d zSWgaO?S^edk7v@MdrE#^Fm8Ztf_foVvRo*x=iqTUXBjiEB$+aMNzt+p44|?}Fsgil zrgR0gxr|sSK@UWxIduyVwKB_g)vuHh?a|fk7~h28eqerkX*lt|>kZ zEtDF09#SEaqHF^YMXa8fkiV{A0EDR~vz_La)|}Ao#A*3?shYdYR~{qN(My7QV%HB} zCJkUp29B-rl?UCGrF;fp^`t~8cON(=Gk_xKL>HA^)20B81AHQ=C#*?< z`#G%!V5g4CM?%R4U~xe`VH7v*|J49%H*EKnsgIBuptBkv>w@Bm@h&JEgyYE$(}Qp} z4UH$pr=i({SUk}^h?Nz*t8+#0xb4yv!7^wNu9ip2eTSzN-9krlC>mv1#W%-}eo|9fENKz?Xu0K{ja& zE!d)DD$uD=bbj_?l)G+GtRCb$(zX8HLL$nCpiO#ALE|LViz{(m5l1YzA~22$oAl68 zVg>-+Mmrc;Z zPpWIUc%ysZZ$2xv&yOQP8?}!L))G0rAn01aX;Kw9a0T0vp1D*7`fOS+309P!hrF7) zg3uCOD=I0e)`XzLEWxVsfgRx|1zmxbbis5r3s>mV)sPpJ$tn@CRUMEQA0YN~4)%#lg=Ln| zH2<4zaNyxFj8b`K-sE1uwagkYT}7OLV17%iCaY#dtm%TLg22=Jyx>}2iNGw0)Q-AE zonjDqOzYi$=y+~7V&QA77)$t!?+6jsCDSVguRg?|pSpJgBtiZ~ZgoOzO&KQtBDXHp z-9&|jeiGafMQl^)#O-iRq8AcdK<{jjV~v(brYYhoO)H&qa&E3+JZP$`K~O7^#XS6M z7IgVai@`Cpa?ZiIoTQlVN<1jA-wI{EBe4B5|3YW}=CjuXev>IXBO@atBO@atBO{c5 Y0jN4q-Px6@iU0rr07*qoM6N<$g2vK`LjV8( literal 0 HcmV?d00001 diff --git a/public/map/gis-service-connection.svg b/public/map/gis-service-connection.svg new file mode 100644 index 0000000..f47d96a --- /dev/null +++ b/public/map/gis-service-connection.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/map/gis-valve.png b/public/map/gis-valve.png new file mode 100644 index 0000000000000000000000000000000000000000..7028d00d94c9128e04b4babb0b30edb002a286a9 GIT binary patch literal 2577 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 literal 0 HcmV?d00001 diff --git a/public/map/gis-valve.svg b/public/map/gis-valve.svg new file mode 100644 index 0000000..9a2a6ed --- /dev/null +++ b/public/map/gis-valve.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/map/water-quality-monitoring.png b/public/map/water-quality-monitoring.png new file mode 100644 index 0000000000000000000000000000000000000000..9c35a413917fc2c46b7ea29ef6e79cf9955934ee GIT binary patch literal 7018 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 IM6N<$f`2AqyZ`_I literal 0 HcmV?d00001 diff --git a/public/map/water-quality-monitoring.svg b/public/map/water-quality-monitoring.svg new file mode 100644 index 0000000..093b92f --- /dev/null +++ b/public/map/water-quality-monitoring.svg @@ -0,0 +1 @@ + diff --git a/public/runtime-config.js b/public/runtime-config.js index d4a46a2..dada733 100644 --- a/public/runtime-config.js +++ b/public/runtime-config.js @@ -2,5 +2,6 @@ globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "required", TJWATER_KEYCLOAK_ISSUER: "", TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater", - TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000" + TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000", + TJWATER_WORKBENCH_RUNTIME_MODE: "auto" }; diff --git a/src/app/app.e2e.ts b/src/app/app.e2e.ts index 8448e6d..88640dc 100644 --- a/src/app/app.e2e.ts +++ b/src/app/app.e2e.ts @@ -1,4 +1,11 @@ import { expect, test } from "@playwright/test"; +import { mockAgentApi } from "../../tests/browser/support/mock-agent-api"; +import { mockWorkbenchContext } from "../../tests/browser/support/mock-workbench-context"; + +test.beforeEach(async ({ page }) => { + await mockWorkbenchContext(page); + await mockAgentApi(page); +}); test("renders the migrated WebGIS workbench shell", async ({ page }) => { const riveRequests: string[] = []; @@ -11,6 +18,9 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => { await page.goto("/"); await expect(page.getByText("供水管网智能调度系统")).toBeVisible(); await expect(page.getByText("调度工作台")).toBeVisible(); + await expect(page.getByLabel("Agent 折叠栏")).toBeVisible(); + await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0); + await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await expect(page.getByLabel("推荐问题")).toBeVisible(); const runtimeConfig = await page.evaluate(() => globalThis.__TJWATER_CONFIG__); @@ -72,8 +82,11 @@ test("renders map notices through a single toaster", async ({ page }) => { test("preserves compact typography in header and agent controls", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/"); + await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); - const headerControl = page.locator("header.acrylic-navigation button.font-semibold:visible").first(); + const headerControl = page + .locator("header.acrylic-navigation button.font-semibold:visible") + .first(); const approvalControl = page.getByRole("button", { name: "权限批准模式" }); const suggestion = page.locator("[aria-label=推荐问题] button").first(); @@ -86,6 +99,7 @@ test("preserves compact typography in header and agent controls", async ({ page test("lets interactive utilities override surface materials", async ({ page }) => { await page.goto("/"); + await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await page.addStyleTag({ content: "*,*::before,*::after{transition:none!important}" }); const suggestion = page.locator("[aria-label=推荐问题] button").first(); @@ -113,18 +127,17 @@ test("keeps menu origin and the solid Main Frame styling through Tailwind v4", a expect(menuTransformOrigin).not.toBe("50% 50%"); await page.keyboard.press("Escape"); - const navigation = page.getByRole("complementary", { name: "工作台导航" }); - const navigationMaterial = await navigation.evaluate( - (element) => { - const style = window.getComputedStyle(element); - return { - backdropFilter: style.backdropFilter, - backgroundColor: style.backgroundColor - }; - } - ); + await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); + const navigation = page.getByRole("complementary", { name: "Agent 命令面板" }); + const navigationMaterial = await navigation.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + backdropFilter: style.backdropFilter, + backgroundColor: style.backgroundColor + }; + }); expect(navigationMaterial.backdropFilter).toBe("none"); - expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)"); + expect(navigationMaterial.backgroundColor).toBe("oklch(0.965 0.012 248)"); await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default"); await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0); }); diff --git a/src/features/agent/api/client.test.ts b/src/features/agent/api/client.test.ts index 71d85c0..79b2eb0 100644 --- a/src/features/agent/api/client.test.ts +++ b/src/features/agent/api/client.test.ts @@ -122,6 +122,20 @@ describe("Agent API client sessions", () => { expect(headers.get("Content-Type")).toBe("application/json"); }); + it("sends the authorized project context on Agent requests", async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(JSON.stringify({ sessions: [] }), { status: 200 }) + ); + vi.stubGlobal("fetch", fetchMock); + const getProjectId = vi.fn().mockResolvedValue("project-117"); + + await createAgentApiClient("http://agent.local", { getProjectId }).listSessions(); + + expect(getProjectId).toHaveBeenCalledOnce(); + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get("X-Project-Id")).toBe("project-117"); + }); + it("streams session events from the backend SSE endpoint", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/agent/api/client.ts b/src/features/agent/api/client.ts index dad9d88..f2674e0 100644 --- a/src/features/agent/api/client.ts +++ b/src/features/agent/api/client.ts @@ -115,6 +115,7 @@ export type AgentApiClient = { export type AgentApiClientOptions = { getAccessToken?: AccessTokenProvider; + getProjectId?: () => Promise; }; const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")]; @@ -137,7 +138,8 @@ export function createAgentApiClient( setActiveBaseUrl, path, init, - options.getAccessToken + options.getAccessToken, + options.getProjectId ); const requestJson = async (path: string, init?: RequestInit) => { const response = await request(path, init); @@ -298,18 +300,19 @@ async function fetchWithFallback( setActiveBaseUrl: (baseUrl: string) => void, path: string, init?: RequestInit, - getAccessToken?: AccessTokenProvider + getAccessToken?: AccessTokenProvider, + getProjectId?: () => Promise ) { const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)]; let lastError: unknown; let lastResponse: Response | null = null; const accessToken = await getAccessToken?.(); - const requestInit = accessToken - ? { - ...init, - headers: withBearerToken(init?.headers, accessToken) - } - : init; + const projectId = await getProjectId?.(); + const hasContextHeaders = Boolean(accessToken || projectId); + const headers = hasContextHeaders ? new Headers(init?.headers) : undefined; + if (accessToken) headers?.set("Authorization", `Bearer ${accessToken}`); + if (projectId) headers?.set("X-Project-Id", projectId); + const requestInit = hasContextHeaders ? { ...init, headers } : init; for (const baseUrl of orderedBaseUrls) { try { @@ -337,12 +340,6 @@ async function fetchWithFallback( throw lastError instanceof Error ? lastError : new Error("Agent API unavailable"); } -function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) { - const headers = new Headers(headersInit); - headers.set("Authorization", `Bearer ${accessToken}`); - return headers; -} - function shouldFallbackOnHttpStatus(status: number) { return status === 404 || status === 405 || status === 502 || status === 503 || status === 504; } diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx index a6ed782..d5427c5 100644 --- a/src/features/agent/components/agent-command-panel.tsx +++ b/src/features/agent/components/agent-command-panel.tsx @@ -20,7 +20,17 @@ import { X } from "lucide-react"; import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react"; -import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction +} from "react"; import { createPortal } from "react-dom"; import { useStickToBottomContext } from "use-stick-to-bottom"; import { showMapNotice } from "@/features/map/core"; @@ -108,7 +118,9 @@ type AgentCommandPanelProps = { approvalMode?: AgentApprovalMode; uiResults?: AgentUiResult[]; workspaceContext?: AgentWorkspaceContextItem[]; + prompt: string; onCollapse: () => void; + onPromptChange: Dispatch>; onRefreshHistory?: () => Promise | void; onStartNewSession?: () => Promise | void; onLoadHistorySession?: (sessionId: string) => Promise | void; @@ -160,7 +172,9 @@ export function AgentCommandPanel({ approvalMode = "request", uiResults = [], workspaceContext = [], + prompt, onCollapse, + onPromptChange, onRefreshHistory, onStartNewSession, onLoadHistorySession, @@ -174,13 +188,14 @@ export function AgentCommandPanel({ onReplyQuestion, onRejectQuestion }: AgentCommandPanelProps) { - const [prompt, setPrompt] = useState(""); const [historyOpen, setHistoryOpen] = useState(false); const [loadingSessionId, setLoadingSessionId] = useState(null); const [scrollRequestId, setScrollRequestId] = useState(0); const historyPanelId = useId(); const historyTriggerRef = useRef(null); const historyPanelRef = useRef(null); + const promptRestoreGenerationRef = useRef(0); + const mountedRef = useRef(true); const desktopFloating = presentation === "desktop-floating"; const floatingPresentation = desktopFloating || presentation === "mobile-sheet"; const hasConversation = messages.length > 0 || streaming; @@ -195,8 +210,9 @@ export function AgentCommandPanel({ isSupported: isSpeechSupported } = useAgentSpeechSynthesis(); const appendRecognitionResult = useCallback((text: string) => { - setPrompt((current) => `${current}${current.trim() ? " " : ""}${text}`); - }, []); + promptRestoreGenerationRef.current += 1; + onPromptChange((current) => `${current}${current.trim() ? " " : ""}${text}`); + }, [onPromptChange]); const showRecognitionError = useCallback((message: string) => { showMapNotice({ id: "agent-speech-recognition-error", @@ -219,6 +235,14 @@ export function AgentCommandPanel({ } }, [historyOpen, onRefreshHistory]); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + promptRestoreGenerationRef.current += 1; + }; + }, []); + useEffect(() => { if (!historyOpen) { return; @@ -271,11 +295,16 @@ export function AgentCommandPanel({ if (!nextPrompt || streaming) { return; } - setPrompt(""); + const restoreGeneration = promptRestoreGenerationRef.current + 1; + promptRestoreGenerationRef.current = restoreGeneration; + onPromptChange(""); stopListening(); setScrollRequestId((requestId) => requestId + 1); void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => { - setPrompt(nextPrompt); + if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) { + return; + } + onPromptChange((current) => current || nextPrompt); }); }; const historyPanel = ( @@ -286,6 +315,7 @@ export function AgentCommandPanel({ sessions={sessionHistory} onRefresh={onRefreshHistory} onSelectSession={(nextSessionId) => { + promptRestoreGenerationRef.current += 1; setLoadingSessionId(nextSessionId); void Promise.resolve(onLoadHistorySession?.(nextSessionId)) .then(() => setHistoryOpen(false)) @@ -369,8 +399,9 @@ export function AgentCommandPanel({ aria-label="新建 Agent 对话" title="新建 Agent 对话" onClick={() => { + promptRestoreGenerationRef.current += 1; setHistoryOpen(false); - setPrompt(""); + onPromptChange(""); void Promise.resolve(onStartNewSession?.()); }} className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600" @@ -436,7 +467,9 @@ export function AgentCommandPanel({ {workspaceContext.length > 0 ? (
); } function TickerProgressBar({ - progressLabel, + progressValue, muted = false }: { - progressLabel: string; + progressValue: number; muted?: boolean; }) { return ( @@ -443,7 +450,7 @@ function TickerProgressBar({ "status-tone-info block h-full rounded-full", muted ? "bg-[var(--status-border)]" : "bg-[var(--status-mark)]" )} - animate={{ width: progressLabel }} + animate={{ width: `${Math.min(100, Math.max(0, progressValue))}%` }} initial={false} transition={{ duration: 0.34, ease: tickerEase }} /> diff --git a/src/features/workbench/components/analysis-history-section.tsx b/src/features/workbench/components/analysis-history-section.tsx new file mode 100644 index 0000000..3ba36b6 --- /dev/null +++ b/src/features/workbench/components/analysis-history-section.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react"; +import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; +import { Button } from "@/shared/ui/button"; +import { Disclosure } from "@/shared/ui/disclosure"; +import { StatusBadge, type StatusTone } from "@/shared/ui/status"; +import { fetchWorkbenchAnalysisRun } from "../api/workbench-runtime-client"; +import { + fetchWorkbenchAnalysisHistory, + type WorkbenchAnalysisHistoryItem +} from "../api/workbench-analysis-history-client"; +import type { AnalysisArtifact } from "../workspace/workspace-model"; +import { + WorkbenchEmptyState, + WorkbenchNotice, + WorkbenchSection +} from "./workbench-panel-ui"; + +const HISTORY_PAGE_SIZE = 10; + +export function AnalysisHistorySection({ + getAccessToken, + onOpenArtifact +}: { + getAccessToken?: AccessTokenProvider; + onOpenArtifact: (artifact: AnalysisArtifact) => void; +}) { + const [items, setItems] = useState([]); + const [nextOffset, setNextOffset] = useState(null); + const [loading, setLoading] = useState(false); + const [openingRunId, setOpeningRunId] = useState(null); + const [error, setError] = useState(null); + const loadedRef = useRef(false); + const listControllerRef = useRef(null); + const artifactControllerRef = useRef(null); + + useEffect(() => () => { + listControllerRef.current?.abort(); + artifactControllerRef.current?.abort(); + }, []); + + const load = useCallback(async (more = false) => { + if (loading) return; + const offset = more ? nextOffset : 0; + if (more && offset === null) return; + listControllerRef.current?.abort(); + const controller = new AbortController(); + listControllerRef.current = controller; + setLoading(true); + setError(null); + try { + const page = await fetchWorkbenchAnalysisHistory({ + limit: HISTORY_PAGE_SIZE, + offset: offset ?? 0, + getAccessToken, + signal: controller.signal + }); + if (controller.signal.aborted) return; + setItems((current) => more + ? [...current, ...page.items.filter( + (item) => !current.some((existing) => existing.runId === item.runId) + )] + : page.items); + setNextOffset(page.nextOffset); + loadedRef.current = true; + } catch (nextError) { + if (!controller.signal.aborted) { + setError(errorMessage(nextError)); + } + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, [getAccessToken, loading, nextOffset]); + + const openArtifact = async (item: WorkbenchAnalysisHistoryItem) => { + if (openingRunId) return; + artifactControllerRef.current?.abort(); + const controller = new AbortController(); + artifactControllerRef.current = controller; + setOpeningRunId(item.runId); + setError(null); + try { + const projection = await fetchWorkbenchAnalysisRun( + item.runId, + getAccessToken, + controller.signal + ); + if (!controller.signal.aborted) onOpenArtifact(projection.artifact); + } catch (nextError) { + if (!controller.signal.aborted) setError(errorMessage(nextError)); + } finally { + if (!controller.signal.aborted) setOpeningRunId(null); + } + }; + + return ( + } + tone="info" + meta={loadedRef.current ? ( + + 已加载 {items.length} 项 + + ) : null} + > + { + if (event.currentTarget.open && !loadedRef.current && !loading) void load(); + }} + > +
+ {error ? {error} : null} + {loading && !items.length ? ( +
+ 正在读取分析成果历史 +
+ ) : null} + {!loading && loadedRef.current && !items.length ? ( + } + title="暂无持久化分析成果" + description="新的分析运行会在保存到当前项目 BizDB 后出现在这里。" + /> + ) : null} + {items.length ? ( +
    + {items.map((item) => ( +
  1. + +
  2. + ))} +
+ ) : null} +
+ {error && !items.length ? ( + + ) : null} + {nextOffset !== null ? ( + + ) : null} +
+
+
+
+ ); +} + +function statusTone(status: string): StatusTone { + if (["completed", "succeeded"].includes(status)) return "success"; + if (["failed", "error"].includes(status)) return "danger"; + if (["running"].includes(status)) return "info"; + if (["queued", "pending"].includes(status)) return "warning"; + return "neutral"; +} + +function formatHistoryTime(value: string) { + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false + }).format(new Date(value)); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "分析成果历史请求失败"; +} diff --git a/src/features/workbench/components/analysis-results-panel.tsx b/src/features/workbench/components/analysis-results-panel.tsx new file mode 100644 index 0000000..10de15a --- /dev/null +++ b/src/features/workbench/components/analysis-results-panel.tsx @@ -0,0 +1,42 @@ +import { BarChart3, X } from "lucide-react"; +import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; +import { Button } from "@/shared/ui/button"; +import type { AnalysisArtifact } from "../workspace/workspace-model"; +import { AnalysisHistorySection } from "./analysis-history-section"; + +export function AnalysisResultsPanel({ + open, + onClose, + getAccessToken, + onOpenArtifact +}: { + open: boolean; + onClose: () => void; + getAccessToken?: AccessTokenProvider; + onOpenArtifact: (artifact: AnalysisArtifact) => void; +}) { + if (!open) return null; + + return ( + + ); +} diff --git a/src/features/workbench/components/feature-popover.test.tsx b/src/features/workbench/components/feature-popover.test.tsx new file mode 100644 index 0000000..561212f --- /dev/null +++ b/src/features/workbench/components/feature-popover.test.tsx @@ -0,0 +1,90 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FeaturePopover } from "./feature-popover"; + +const mocks = vi.hoisted(() => ({ + fetchElementGisProperties: vi.fn() +})); + +vi.mock("../api/gis-properties-client", async (importOriginal) => ({ + ...await importOriginal(), + fetchElementGisProperties: mocks.fetchElementGisProperties +})); + +describe("feature GIS properties", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("keeps imported GIS fields folded by dataset", async () => { + mocks.fetchElementGisProperties.mockResolvedValue([{ + dataset_key: "fx_pipe_inventory_20200811", + dataset_title: "奉贤 DN100 及以上管网台账", + source_date: "2020-08-11", + layer_key: "fx_20200811_dn100_pipes", + layer_name: "DN100 及以上管线台账", + source_fid: 117, + match_method: "geometry", + match_distance_m: 0.12, + attributes: { PIPE_D: 300, MATERIAL: "DI" } + }]); + + render( + "read-token"} + onClose={vi.fn()} + /> + ); + + expect(await screen.findByText("1 份")).toBeVisible(); + expect(screen.getByText("PIPE_D")).not.toBeVisible(); + + fireEvent.click(screen.getByText("GIS 台账属性")); + expect(screen.getByText("DN100 及以上管线台账")).toBeVisible(); + expect(screen.getByText("PIPE_D")).not.toBeVisible(); + + fireEvent.click(screen.getByText("DN100 及以上管线台账")); + expect(screen.getByTitle("300 mm")).toBeVisible(); + expect(screen.getByText("材质")).toBeVisible(); + expect(screen.getByTitle("球墨铸铁")).toBeVisible(); + expect(screen.getByText("PIPE_D")).not.toBeVisible(); + + fireEvent.click(screen.getByText("全部原始字段 · 2 项")); + expect(screen.getByText("PIPE_D")).toBeVisible(); + }); + + it("hides the GIS inventory section when the element has no linked records", async () => { + mocks.fetchElementGisProperties.mockResolvedValue([]); + + render( + "read-token"} + onClose={vi.fn()} + /> + ); + + await waitFor(() => expect(mocks.fetchElementGisProperties).toHaveBeenCalledOnce()); + expect(screen.queryByText("GIS 台账属性")).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/workbench/components/feature-popover.tsx b/src/features/workbench/components/feature-popover.tsx index 2440531..9fe4dc3 100644 --- a/src/features/workbench/components/feature-popover.tsx +++ b/src/features/workbench/components/feature-popover.tsx @@ -1,12 +1,14 @@ import { Check, Copy, Database, X } from "lucide-react"; import type { ComponentType } from "react"; import { useEffect, useRef, useState } from "react"; +import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import { MAP_FOCUS_SURFACE_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, showMapNotice } from "@/features/map/core"; import { cn } from "@/shared/ui/cn"; +import { Disclosure } from "@/shared/ui/disclosure"; import { StatusBadge, StatusDot, @@ -14,10 +16,12 @@ import { } from "@/shared/ui/status"; import type { WaterNetworkSourceId } from "../map/sources"; import type { DetailFeature } from "../types"; +import { useElementGisProperties } from "../hooks/use-element-gis-properties"; import { getFeaturePanelModel, type FeaturePanelBadgeTone } from "../utils/feature-properties"; +import { presentGisAttributeGroup } from "../utils/gis-attribute-presentation"; import { PipeFeatureIcon, JunctionFeatureIcon, @@ -31,10 +35,12 @@ import { FeaturePropertyList } from "./feature-property-list"; type FeaturePopoverProps = { feature: DetailFeature | null; + projectId: string | null; + getAccessToken?: AccessTokenProvider; onClose: () => void; }; -const FEATURE_LAYER_META: Record< +const FEATURE_LAYER_META: Partial; @@ -43,14 +49,23 @@ const FEATURE_LAYER_META: Record< labelClassName: string; iconClassName: string; } -> = { +>> = { pipes: { icon: PipeFeatureIcon, label: "管线", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }, junctions: { icon: JunctionFeatureIcon, label: "节点", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" }, valves: { icon: ValveFeatureIcon, label: "阀门", headerClassName: "bg-violet-50/70", labelClassName: "text-violet-800", iconClassName: "bg-violet-600 text-white ring-violet-900/10" }, reservoirs: { icon: ReservoirFeatureIcon, label: "水库", headerClassName: "bg-sky-50/70", labelClassName: "text-sky-900", iconClassName: "bg-sky-800 text-white ring-sky-900/10" }, scada: { icon: ScadaFeatureIcon, label: "SCADA", headerClassName: "bg-slate-50/80", labelClassName: "text-slate-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" }, pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50/70", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-900/10" }, - tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" } + tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }, + water_quality_monitoring_points: { icon: ScadaFeatureIcon, label: "水质监测点", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" } +}; + +const SOURCE_GIS_LAYER_META = { + icon: JunctionFeatureIcon, + label: "GIS 交付要素", + headerClassName: "bg-slate-50/80", + labelClassName: "text-slate-700", + iconClassName: "bg-slate-600 text-white ring-slate-900/10" }; const badgeTones = { @@ -61,9 +76,18 @@ const badgeTones = { info: "info" } satisfies Record; -export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) { +export function FeaturePopover({ + feature, + projectId, + getAccessToken, + onClose +}: FeaturePopoverProps) { const [copiedId, setCopiedId] = useState(null); + const [expandedGisFeatureKey, setExpandedGisFeatureKey] = useState(null); const copyResetTimeoutRef = useRef | null>(null); + const gisProperties = useElementGisProperties(feature, projectId, getAccessToken); + const featureKey = feature ? `${feature.layer}:${feature.id}` : null; + const gisExpanded = featureKey !== null && expandedGisFeatureKey === featureKey; useEffect(() => () => { if (copyResetTimeoutRef.current) { @@ -76,7 +100,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) { } const panelModel = getFeaturePanelModel(feature.layer, feature.properties, feature.id); - const layerMeta = FEATURE_LAYER_META[feature.layer]; + const layerMeta = FEATURE_LAYER_META[feature.layer] ?? SOURCE_GIS_LAYER_META; const LayerIcon = layerMeta.icon; const copied = copiedId === panelModel.id; const canCopy = panelModel.id !== "暂无"; @@ -106,8 +130,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) { return (