From ba71e2d529f3991270ebd8d7a6bac711a3bc737f Mon Sep 17 00:00:00 2001 From: Huarch Date: Sat, 7 Nov 2026 19:50:14 +0800 Subject: [PATCH] feat(workbench): expand SCADA monitoring and investigation presentation --- docs/agent-chat-ui-design.md | 2 + docs/investigation-dev-tools.md | 8 +- docs/project-switching.md | 4 +- docs/style-design-guide.md | 16 +- package.json | 1 + pnpm-lock.yaml | 74 +++ .../answer-ui/answer-display-time.test.ts | 39 ++ .../agent/answer-ui/answer-display-time.ts | 37 ++ .../answer-ui-interaction-context.ts | 1 + .../agent/answer-ui/answer-ui-view.tsx | 6 +- .../answer-ui/answer-view-state-provider.tsx | 4 + .../agent/answer-ui/answer-view-state.test.ts | 11 +- .../agent/answer-ui/answer-view-state.ts | 8 +- src/features/agent/answer-ui/catalog.ts | 7 +- .../answer-ui/chart-presentation.test.ts | 31 + .../agent/answer-ui/chart-presentation.ts | 28 + src/features/agent/answer-ui/contract.test.ts | 13 + src/features/agent/answer-ui/contract.ts | 6 +- .../agent/answer-ui/interactive-chart.tsx | 296 ++++++---- .../answer-ui/interactive-data-table.tsx | 7 +- .../agent/answer-ui/responsive-chart.tsx | 37 ++ src/features/agent/chart-data.test.ts | 10 + src/features/agent/chart-data.ts | 23 +- .../agent/components/agent-command-panel.tsx | 1 - .../components/agent-ui-envelope-renderer.tsx | 223 +------- .../agent/frontend-action/chart.test.ts | 10 + .../presentation/chart-sample-time.test.ts | 42 ++ .../agent/presentation/command-schema.ts | 17 +- .../core/components/notice-actions.test.tsx | 52 ++ .../map/core/components/notice-actions.tsx | 55 +- .../core/components/notice-presentation.tsx | 43 +- .../map/core/components/notice-types.ts | 1 + src/features/map/core/components/notice.tsx | 7 +- .../workbench/api/operational-task-client.ts | 1 + .../workbench/api/project-client.test.ts | 71 +++ src/features/workbench/api/project-client.ts | 79 +-- src/features/workbench/api/project-request.ts | 69 +++ .../workbench/api/scada-replay-client.ts | 43 +- .../components/agent-execution-plan-panel.tsx | 3 +- .../feature-location-panel.test.tsx | 42 ++ .../components/feature-location-panel.tsx | 102 ++++ .../workbench/components/geocoding-panel.tsx | 2 +- .../investigation-dev-tools.test.tsx | 22 +- .../components/investigation-dev-tools.tsx | 100 +--- .../investigation-presentation-panel.tsx | 62 +- .../investigation-report-blocks.tsx | 3 +- .../workbench/components/map-dev-panel.tsx | 174 ++---- .../components/notice-dev-tools.test.tsx | 28 + .../workbench/components/notice-dev-tools.tsx | 87 +++ .../presentation-business-results.tsx | 67 ++- .../presentation-history-switcher.test.tsx | 21 + .../presentation-history-switcher.tsx | 168 +++--- .../components/project-startup-screen.tsx | 2 +- .../scada-alert-atmosphere.test.tsx | 40 ++ .../components/scada-alert-atmosphere.tsx | 41 ++ .../workbench/components/toolbar-panel.tsx | 25 +- .../components/workbench-top-bar-menus.tsx | 53 +- .../components/workbench-top-bar.tsx | 4 + ...nvestigation-presentation-follower.test.ts | 50 +- .../investigation-presentation-follower.ts | 15 +- .../use-dismissed-workbench-alerts.test.ts | 43 ++ .../hooks/use-dismissed-workbench-alerts.ts | 51 ++ .../use-investigation-presentation.test.tsx | 97 ++++ .../hooks/use-investigation-presentation.ts | 77 ++- .../hooks/use-scada-alert-notices.test.tsx | 97 ++++ .../hooks/use-scada-alert-notices.ts | 64 +++ .../hooks/use-scada-event-atmosphere.test.tsx | 63 +++ .../hooks/use-scada-event-atmosphere.ts | 67 +++ src/features/workbench/map-workbench-page.tsx | 65 ++- .../map/investigation-map-presentation.ts | 4 +- .../workbench/map/map-feature-feedback.ts | 9 + src/features/workbench/map/toolbar-config.ts | 4 +- .../event-location-coordinate.ts | 12 + .../event-location-marker.ts | 56 ++ .../event-location-picker.test.ts | 16 + .../event-location-picker.tsx | 242 ++++++++ .../peripheral-sample/event-location-pin.tsx | 63 +++ .../peripheral-sample/generated/schema.d.ts | 2 + .../managed-test-resources.tsx | 58 +- .../peripheral-sample-panel.tsx | 22 +- .../scada-monitoring-notice.test.tsx | 82 +++ .../scada-monitoring-notice.tsx | 88 +++ .../scada-monitoring-rules.tsx | 74 +++ .../scada-monitoring-status.tsx | 37 ++ .../scada-replay-section.test.tsx | 240 +++++++- .../scada-replay-section.tsx | 532 +++++++++--------- .../scada-system-section.tsx | 5 +- .../peripheral-sample/scada-time-tuner.tsx | 138 ++++- .../use-event-location-address.test.ts | 85 +++ .../use-event-location-address.ts | 66 +++ .../use-scada-playback.test.tsx | 144 +++++ .../peripheral-sample/use-scada-playback.ts | 125 ++++ .../peripheral-sample/use-scada-replay.ts | 10 +- src/features/workbench/types.ts | 1 + .../utils/investigation-dev-cases.test.ts | 67 ++- .../utils/investigation-dev-cases.ts | 68 +-- .../utils/investigation-dev-complex-cases.ts | 78 +++ .../utils/investigation-dev-network.ts | 130 +++++ .../utils/investigation-dev-preview.test.ts | 9 +- .../utils/investigation-dev-preview.ts | 31 +- ...investigation-presentation-archive.test.ts | 47 ++ .../investigation-presentation-archive.ts | 34 +- .../utils/investigation-presentation.ts | 5 + .../utils/investigation-text.test.ts | 6 + .../workbench/utils/investigation-text.ts | 38 +- .../utils/presentation-view-history.test.ts | 52 ++ .../utils/presentation-view-history.ts | 66 ++- src/shared/ui/floating-window-layer.ts | 32 ++ src/shared/ui/floating-window.test.tsx | 37 ++ src/shared/ui/floating-window.tsx | 142 +++++ src/shared/utils/date-time-text.ts | 43 ++ src/shared/utils/date-time.ts | 13 + src/styles.css | 129 ++++- tests/browser/alert-removal.e2e.ts | 75 +++ .../browser/dynamic-investigation-flow.e2e.ts | 16 +- tests/browser/floating-panel-pointer.e2e.ts | 2 +- tests/browser/geocoding.e2e.ts | 22 +- .../investigation-chart-windows.e2e.ts | 118 ++++ tests/browser/investigation-dev-tools.e2e.ts | 341 ++++++++++- .../browser/investigation-report-cards.e2e.ts | 6 +- .../investigation-retention-playback.e2e.ts | 124 ++++ .../browser/map-multi-feature-location.e2e.ts | 26 +- tests/browser/notice-dev-tools.e2e.ts | 89 +++ tests/browser/peripheral-sample-panel.e2e.ts | 526 +++++++++++++++-- tests/browser/project-switching.e2e.ts | 18 +- tests/browser/scada-alert-atmosphere.e2e.ts | 98 ++++ tests/browser/scada-comparison-chart.e2e.ts | 123 ++++ tests/browser/scada-monitoring-notice.e2e.ts | 91 +++ tests/browser/scada-notifications.e2e.ts | 114 ++++ tests/browser/startup-screens.e2e.ts | 39 ++ .../mock-investigation-presentation.ts | 10 +- .../workbench-overlay-boundaries.e2e.ts | 93 +++ tsconfig.json | 1 + 133 files changed, 6754 insertions(+), 1438 deletions(-) create mode 100644 src/features/agent/answer-ui/answer-display-time.test.ts create mode 100644 src/features/agent/answer-ui/answer-display-time.ts create mode 100644 src/features/agent/answer-ui/chart-presentation.test.ts create mode 100644 src/features/agent/answer-ui/chart-presentation.ts create mode 100644 src/features/agent/answer-ui/responsive-chart.tsx create mode 100644 src/features/agent/presentation/chart-sample-time.test.ts create mode 100644 src/features/map/core/components/notice-actions.test.tsx create mode 100644 src/features/workbench/api/project-request.ts create mode 100644 src/features/workbench/components/feature-location-panel.test.tsx create mode 100644 src/features/workbench/components/feature-location-panel.tsx create mode 100644 src/features/workbench/components/notice-dev-tools.test.tsx create mode 100644 src/features/workbench/components/notice-dev-tools.tsx create mode 100644 src/features/workbench/components/scada-alert-atmosphere.test.tsx create mode 100644 src/features/workbench/components/scada-alert-atmosphere.tsx create mode 100644 src/features/workbench/hooks/use-dismissed-workbench-alerts.test.ts create mode 100644 src/features/workbench/hooks/use-dismissed-workbench-alerts.ts create mode 100644 src/features/workbench/hooks/use-investigation-presentation.test.tsx create mode 100644 src/features/workbench/hooks/use-scada-alert-notices.test.tsx create mode 100644 src/features/workbench/hooks/use-scada-alert-notices.ts create mode 100644 src/features/workbench/hooks/use-scada-event-atmosphere.test.tsx create mode 100644 src/features/workbench/hooks/use-scada-event-atmosphere.ts create mode 100644 src/features/workbench/map/map-feature-feedback.ts create mode 100644 src/features/workbench/peripheral-sample/event-location-coordinate.ts create mode 100644 src/features/workbench/peripheral-sample/event-location-marker.ts create mode 100644 src/features/workbench/peripheral-sample/event-location-picker.test.ts create mode 100644 src/features/workbench/peripheral-sample/event-location-picker.tsx create mode 100644 src/features/workbench/peripheral-sample/event-location-pin.tsx create mode 100644 src/features/workbench/peripheral-sample/scada-monitoring-notice.test.tsx create mode 100644 src/features/workbench/peripheral-sample/scada-monitoring-notice.tsx create mode 100644 src/features/workbench/peripheral-sample/scada-monitoring-rules.tsx create mode 100644 src/features/workbench/peripheral-sample/scada-monitoring-status.tsx create mode 100644 src/features/workbench/peripheral-sample/use-event-location-address.test.ts create mode 100644 src/features/workbench/peripheral-sample/use-event-location-address.ts create mode 100644 src/features/workbench/peripheral-sample/use-scada-playback.test.tsx create mode 100644 src/features/workbench/peripheral-sample/use-scada-playback.ts create mode 100644 src/features/workbench/utils/investigation-dev-complex-cases.ts create mode 100644 src/features/workbench/utils/investigation-dev-network.ts create mode 100644 src/shared/ui/floating-window-layer.ts create mode 100644 src/shared/ui/floating-window.test.tsx create mode 100644 src/shared/ui/floating-window.tsx create mode 100644 src/shared/utils/date-time-text.ts create mode 100644 tests/browser/alert-removal.e2e.ts create mode 100644 tests/browser/investigation-chart-windows.e2e.ts create mode 100644 tests/browser/investigation-retention-playback.e2e.ts create mode 100644 tests/browser/notice-dev-tools.e2e.ts create mode 100644 tests/browser/scada-alert-atmosphere.e2e.ts create mode 100644 tests/browser/scada-comparison-chart.e2e.ts create mode 100644 tests/browser/scada-monitoring-notice.e2e.ts create mode 100644 tests/browser/scada-notifications.e2e.ts create mode 100644 tests/browser/workbench-overlay-boundaries.e2e.ts diff --git a/docs/agent-chat-ui-design.md b/docs/agent-chat-ui-design.md index 864e8cc..956e90f 100644 --- a/docs/agent-chat-ui-design.md +++ b/docs/agent-chat-ui-design.md @@ -118,6 +118,8 @@ Agent 面板 图表首先显示标题、视图切换、序列和趋势。范围选择默认收起,展开后可选择起止点及重置;收起只改变展示,不取消已选范围。序列按钮使用低强调色,图例颜色保留数据对应关系;至少一条序列可见。数据点的各序列值分行对齐,保留原始精度。 +图表通过“放大查看”打开独立浮窗,标题栏的“最大化窗口”按钮铺满可用视口,保留还原与关闭入口。内容宽度达到640px时,主图占据剩余空间,序列与摘要放在右侧;较窄窗口将资料放在主图下方独立滚动。展开、最大化与还原共享视图、序列显隐及表格筛选状态,调整窗口尺寸沿用同一图表实例。时间轴集中标注UTC+8,采样详情保留完整日期与偏移。工作台在独立层叠上下文内排列业务面板,浮窗与后续打开的菜单、对话框通过Portal显示。 + 正式报告展开后采用连续文档排版,章节之间使用细分隔线。目录跟随阅读区滚动,包含实际存在的结论、分析、调查进展、待办、处置、缺口、脉络、依据、时间线和版本。目录使用导航语义,点击或键盘激活后立即滚动并将焦点交给目标章节;手机目录可横向滚动。接近文档末尾、目标无法滚至顶部时,保留主动选择的章节标记,直到用户再次滚动。该导航只改变阅读位置,不控制任务执行或审批。 空会话采用“简短引导、首次输入、推荐问题”的顺序,整组在 Agent 面板内横向居中,放在会话栏下方可用区域的中部。居中容器最多 600px,桌面内边距 24px,手机左右内边距 16px;短屏内容超过可用高度时从顶部排列并允许滚动。判断依据是当前会话有无消息,不取决于帐号是否保存其他历史会话。加载历史、引用任务或已有工具权限审批、结构化结果时保留正常阅读区,不覆盖为欢迎页。 diff --git a/docs/investigation-dev-tools.md b/docs/investigation-dev-tools.md index 17031dd..10ae8ef 100644 --- a/docs/investigation-dev-tools.md +++ b/docs/investigation-dev-tools.md @@ -1,8 +1,12 @@ # 调查任务展示与 Dev 测试 +“调查测试”顶部的“全屏爆管视觉警告”区域提供持续预览、三档强度和“模拟事件处理结束”。默认使用标准档;关闭按钮位于屏幕底部状态提示右上角。真实警告随监测事件持续至处理结束,查看调查后继续显示;手动关闭按项目与事件在当前标签页记忆。预览操作只改变本地视觉状态。 + 可直接打开顶部 Dev 入口载入、编辑、校验并执行本地测试案例,无需先选择调查或开启实时跟随。纯数据和界面块可独立预览,包含地图或图表展示指令时需等待地图就绪。验证实际调查联动时,在“工况任务 → 调查任务”选择一项任务,再打开 Dev。Dev 默认打开“调查测试”页签,保留当前选择,显示调查代次、实时图表数、已保存报告中的图表/表格/地图入口数,以及最近的展示动作。上一轮报告会明确标注,不当成本轮新成果。 -Dev 分为“调查测试”和“地图调试”两个页签:前者按“准备数据 → 校验与执行 → 检查结果”组织 JSONL 流程,后者保留要素定位、水流、图层和标注工具。切换页签保留输入与展示状态,不清除当前预览。地图重置操作仅显示在地图调试页。 +Dev 分为“调查测试”“通知测试”和“地图调试”三个页签:调查测试按“准备数据 → 校验与执行 → 检查结果”组织 JSONL 流程;通知测试提供五种状态、报告与任务入口、长文本和连续六条通知;地图调试保留要素定位、水流、图层和标注工具。切换页签保留输入与展示状态,不清除当前预览。地图重置操作仅显示在地图调试页。 + +通知测试中的信息、成功提示默认 3.2 秒后关闭;警告和错误可手动关闭,加载状态通过“模拟加载完成”更新为成功提示。带操作入口的通知持续保留,点击查看仍保留,正文显示“保留至手动关闭”;多条通知沿用原有堆叠,关闭当前通知后可继续查看其余通知。报告和任务按钮仅记录本地点击反馈。“清除测试通知”只移除本页测试样例,不影响真实通知。 ## 当前识别链路 @@ -16,6 +20,8 @@ Dev 分为“调查测试”和“地图调试”两个页签:前者按“准 Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。 +调查与本地预览在展示分析图表时自动打开可拖动、缩放和最大化的独立窗口,覆盖 `open_chart` 指令和结构化界面中的 `Chart`。自动打开不抢走键盘焦点;关闭窗口仅收起放大视图,卡片中的图表和放大入口继续保留。同一图表内容未变化时,其他地图动作不会使已关闭的窗口再次弹出;图表内容更新或进入、退出历史回看时可再次自动打开。回放窗口随所选快照更新,保留已有窗口的位置和尺寸,期间收到的实时数据不替换历史图表。关闭图表指令或切换任务时移除对应窗口;普通对话图表仍由用户手动放大。 + ## 本地测试步骤 1. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。 diff --git a/docs/project-switching.md b/docs/project-switching.md index 72218cf..1cd7f51 100644 --- a/docs/project-switching.md +++ b/docs/project-switching.md @@ -32,7 +32,9 @@ 凤阳使用已有的 `fengyang_v2` 工作空间及元数据地图范围。固定奉贤演示工况、场景与空间覆盖不会在凤阳兜底显示。奉贤原始 GIS 图层不复制,图层入口根据当前工作空间中的实际数据探测。 -地址定位支持文字搜索及地图取点反编码,两者复用当前项目的配置与服务端 Key。取点结果保留点击坐标,关闭工具或切换项目取消未完成查询。地名搜索读取 Metadata DB 的 `projects.map_config.place_search`,按当前项目的 `enabled`、`provider`、`admin_code` 和 `region` 启用查询并显示区域文案。奉贤、凤阳和其他项目使用同一实现;未配置、关闭或配置无效时显示“当前项目暂未配置地名搜索”。区域由后端从授权项目读取,浏览器不能在搜索请求中覆盖行政区。外围样板服务仍有独立项目配置;本次复制其空表结构,不复制奉贤工单,也不扩大外部服务的访问范围。 +定位工具按“地址地名”和“管网要素”分为两个页签。地址地名支持文字搜索及地图取点反编码,两者复用当前项目的配置与服务端 Key。管网要素按业务源与要素 ID 批量定位,支持使用地图选中、仅高亮、缩放到要素和清除高亮,不依赖地名搜索配置;与 Dev 面板复用同一定位组件。切换页签保留输入,进入管网要素时结束地图取点。 + +取点结果保留点击坐标,关闭工具或切换项目取消未完成查询。地名搜索读取 Metadata DB 的 `projects.map_config.place_search`,按当前项目的 `enabled`、`provider`、`admin_code` 和 `region` 启用查询并显示区域文案。奉贤、凤阳和其他项目使用同一实现;未配置、关闭或配置无效时显示“当前项目暂未配置地名搜索”。区域由后端从授权项目读取,浏览器不能在搜索请求中覆盖行政区。外围样板服务仍有独立项目配置;本次复制其空表结构,不复制奉贤工单,也不扩大外部服务的访问范围。 ## 验证 diff --git a/docs/style-design-guide.md b/docs/style-design-guide.md index b009788..4e880bb 100644 --- a/docs/style-design-guide.md +++ b/docs/style-design-guide.md @@ -32,9 +32,9 @@ Agent 输入框使用 `--shadow-agent-composer` 的柔和双层阴影区分白 助手回复底部使用单行操作栏:“查看处理过程”靠左,语音与复制组成右侧操作组,复制位于最右。展开区域使用白色背景、细边框和完整圆角,与回答正文区分;外边缘与过程入口左侧对齐,普通进度与工具调用的状态图标统一左对齐;工具调用平铺显示,不额外包裹卡片、边框或背景,参数内容按文字列对齐。过程入口保留标准 hover 反馈,使用对称的水平留白。普通步骤和工具调用复用同一状态图标组件:24px 圆形容器、14px 线性图标,完成为圆圈勾选、失败为圆圈叉号、进行中为加载图标。操作图标统一为 16px、2px 描边,按钮高 32px;窄容器下语音与复制收起可见标签,保留中文可访问名称和提示。复制反馈不改变操作栏布局,过程展开、语音播放与复制各自独立。 -Dev 测试、外围系统、调查实时展示与本地预览展示面板也遵循本节:使用实体表面、语义颜色、细边框和标准控件,复用 `workbench-shadcn-surface` 主题;日期与选择菜单通过 Portal 渲染时继续传递这一主题。外围面板内的工单、SCADA 和 Dev 内的 JSONL 测试区采用同一规则。Dev 按调查测试与地图调试两个页签组织;工单用分类标题和摘要列表组织,完整报告按需展开,避免多层边框挤压表单;SCADA 只保留演示区间、调频时间轴、主动监测操作和本周期结论,测点目录与原始读数表不在演示面板重复展示。调查与本地展示面板使用固定标题和状态、独立滚动区、紧凑的地图操作及图表 Card;图表标题与关闭按钮同行,嵌入式渲染不再叠加旧灰色图表容器,长标题与图例可换行。面板与图表进入退出、依据展开使用 Motion 的短时透明度和位移过渡,布局变化采用 transform 投影;键盘操作及减少动态效果模式即时完成。地图定位采用可中断的 450ms 平滑 fit,覆盖层以 180ms 淡入淡出,减少动态效果时关闭这些过渡。两个浮层保持非模态,仅自身表面接收指针输入,外部地图保留点击、拖拽和滚轮操作。 展示面板的动作切换放在固定标题区,使用紧凑的 Select、前后按钮和“回到当前”;历史状态与后续动作数可见。回看切换保留短时淡入淡出,退出画面立即设为 inert 并从无障碍树隐藏,避免旧操作仍可点击。Dev 只显示测试输入、校验与原始数据,业务表格、卡片与图表统一进入展示面板。 +Dev 测试、外围系统、调查实时展示与本地预览展示面板也遵循本节:使用实体表面、语义颜色、细边框和标准控件,复用 `workbench-shadcn-surface` 主题;日期与选择菜单通过 Portal 渲染时继续传递这一主题。外围面板内的工单、SCADA 和 Dev 内的 JSONL 测试区采用同一规则。Dev 按调查测试、通知测试与地图调试三个页签组织;工单用分类标题和摘要列表组织,完整报告按需展开,避免多层边框挤压表单;SCADA 只保留演示区间、调频时间轴、主动监测操作和本周期结论,测点目录与原始读数表不在演示面板重复展示。调查与本地展示面板使用固定标题和状态、独立滚动区、紧凑的地图操作及图表 Card;图表标题与关闭按钮同行,嵌入式渲染不再叠加旧灰色图表容器,长标题与图例可换行。面板与图表进入退出、依据展开使用 Motion 的短时透明度和位移过渡,布局变化采用 transform 投影;键盘操作及减少动态效果模式即时完成。地图定位采用可中断的 450ms 平滑 fit,覆盖层以 180ms 淡入淡出,减少动态效果时关闭这些过渡。两个浮层保持非模态,仅自身表面接收指针输入,外部地图保留点击、拖拽和滚轮操作。 展示面板的动作切换放在固定标题区,使用紧凑的 Select、前后按钮和“回到当前”;历史状态与后续动作数可见。回看切换保留短时淡入淡出,退出画面立即设为 inert 并从无障碍树隐藏,避免旧操作仍可点击。Dev 只显示测试输入、校验与原始数据,业务表格、卡片与图表统一进入展示面板。 -SCADA 演示时间轴按用户指定采用 FM 调频盘样式:在选定时间区间内,玻璃刻度窗中的时间刻度带横向移动,红色细指示线固定居中。半透明、背景柔化、边缘高光和轻微内阴影仅用于这块刻度窗,数字、刻度和指示线保持清晰,其他外围区域继续采用实体表面。时间定位红色使用独立语义变量,不表示危险告警;背景模糊不可用时回退到实体底色。时间轴按 5 分钟时间戳组织刻度,不读取项目监测周期;鼠标和触摸拨动过程保持连续无缝,选定值及起止时间仍量化到 5 分钟。时间轴只用于回看且不能触发数据处理,方向键也按 5 分钟移动,监测按钮另按项目周期推进后台处理。减少动态效果时关闭连续平滑位移。详细区间与回放语义见 [SCADA 外围系统时段回放与异常检测设计](../../next-tjwater-server/docs/scada-peripheral-replay-design.md)。 +SCADA 演示时间轴按用户指定采用 FM 调频盘样式:在选定时间区间内,玻璃刻度窗中的时间刻度带横向移动,红色细指示线固定居中,上下三角箭头与指示线严格同轴并指向刻度。半透明、背景柔化、边缘高光和轻微内阴影仅用于这块刻度窗,数字、刻度和指示线保持清晰,其他外围区域继续采用实体表面。时间定位红色使用独立语义变量,不表示危险告警;背景模糊不可用时回退到实体底色。时间轴按 5 分钟时间戳组织刻度,不读取项目监测周期;鼠标和触摸拨动过程保持连续无缝,松手后以约 220ms 的减速动画吸附到最近的 5 分钟刻度,选定值及起止时间仍量化到 5 分钟。时间轴只用于回看且不能触发数据处理,方向键按 5 分钟移动并沿用同一段减速动画,动画期间保留当前位置到目标位置所需的完整刻度;Home、End 与 Page 键直接定位,监测按钮另按项目周期推进后台处理。时间轴获得键盘焦点时不额外绘制焦点外圈,减少动态效果时取消吸附及键盘动画并直接对齐。自动播放时,以连续演示时钟平滑移动刻度并标明“播放时间”,手动浏览继续按 5 分钟对齐。自动监测按项目周期等待后端回执后推进;播放控制与实际处理状态分开。播放区固定显示“播放”“暂停”“重新开始”三个独立按钮,按状态启用;暂停后播放从当前位置继续。“重新开始”先停止旧轮次并准备一轮新的待播放回放,时间回到区间起点,用户再次点击播放或单步监测后才推进。单步监测、停止本轮和失败重试放在下一行;正在处理时间步时禁止重新开始,区间完成后通过重新开始准备下一轮。监测状态区分别显示项目周期、演示倍速与每步播放耗时,后台处理耗时另计。最新监测通知置于时间轴上方,待监测、处理中和各类结果统一固定为 160px,统一按监测时间、状态说明和底部操作三行排列,使用小尺寸方形状态图标;待机为中性灰、处理中为信息色、正常和恢复为轻量绿色,异常使用警示色。各状态的时间行、图标、主副标题和底部说明共用同一排版,未显示调查按钮时也保留操作槽位,状态切换不推动下方控件;首次新异常使用两轮圆环扩散和面板边框透明度强调,总计 2.6 秒,随后保留静态提示,同一异常不重复,减少动态效果时关闭。后台持久化的 SCADA 新告警进入系统通知中心,并通过系统通知在工作台顶部居中显示弹窗;带“查看调查”等操作入口的通知持续保留至手动关闭,忽略调用方的自动关闭时长。点击“查看调查”关闭外围面板并定位对应工况任务,保留通知;关闭外围面板仍能接收通知,首次加载项目不重放历史告警。通知与调查任务关联,恢复后从待办与告警中移除活动事项,已弹出的关联通知保留至手动关闭;切换项目或离开工作台时清理原项目弹窗。详细区间与回放语义见 [SCADA 外围系统时段回放与异常检测设计](../../next-tjwater-server/docs/scada-peripheral-replay-design.md)。 Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、边框、圆角、阴影、排版、间距及交互状态。该规则覆盖主对话面板、手机 Agent 面板、历史对话、主题新建与管理、模型菜单、权限与问题确认、Agent 回答的展开阅读面板,以及这些界面的子弹窗和提示。 @@ -52,8 +52,18 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、 - 危险操作使用明确的确认步骤和 `destructive` 按钮变体;失败保留输入与原内容。审批、提问与历史操作的业务规则由对应功能文档维护。 - Agent 打开的既有地图或业务工具仍遵循其所属模块规范。`AgentPersona` 动画资源按专用设计文档维护,周边面板与控件遵循本节。 +### 系统弹窗通知 + +通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。 + +通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。 + ## 2. Tailwind 定制方式 +SCADA 回放控制使用一个有边框的连续区域:顶部集中显示演示区间和事件时间;中部将模式、日期与时间读数放在同一行,下接 FM 刻度窗,播放按钮组居中并限制最大宽度;底部集中放置单步监测、停止和重试入口,已处理与下次监测时间并排呈现,窄屏自然换行。保留播放、暂停和重新开始三个独立控制,“监测选定时刻”与“下一步并监测”并排,前者检测指针指向的精确时间,后者按项目周期推进;“回到开始时间”只定位到区间起点。监测已处理进度之前的历史时刻会建立新一轮回放,操作前说明原记录保留,避免使用后续状态影响历史判断。 + +SCADA 监测事件采用持续视觉警告,跟随关联事件至完成或取消;指标恢复后,仍在处理的事件继续显示。刷新页面时恢复进行中的事件,手动关闭以当前标签页的项目与事件编号记忆;新事件重新提示。采用1px细边框、轻量边缘光和4.8秒四角呼吸光。四角通过横向渐隐与纵向遮罩相乘,形成沿两条相邻边衰减的柔和光晕;宽度随视口限制在72至180px,高度在52至100px。默认“标准”强度,中央保持清晰与可操作。底部状态提示复用系统弹出通知的 `glass-transient` 亚克力透明材质、背景模糊、细边框与浮层阴影,避开时间轴;关闭按钮固定在提示右上角;手机将“查看调查”放在下一行,查看操作保留警告。减少动态效果时使用静态光,强制颜色模式使用系统边框。Dev面板的“调查测试”提供“柔和/标准/醒目”强度比较、“测试爆管告警效果”和“模拟事件处理结束”,均为本地视觉操作。 + 样式统一进入 [src/styles.css](../src/styles.css)。Tailwind 工具类负责布局、间距、尺寸、排版、响应式及交互状态;共享变量负责颜色和材质参数,共享变体负责重复的组件外观。 1. 先选组件,再选语义表面,最后调整布局与状态。常用差异通过组件的 `variant`、`size` 表达;局部差异通过 `className` 与 [cn](../src/shared/ui/cn.ts) 合并。 @@ -178,6 +188,8 @@ Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续 `WorkbenchSection` 统一标题、说明、状态和操作;`WorkbenchNotice` 使用局部状态线;`WorkbenchEmptyState` 只说明为空原因与下一步。以分隔线、留白和浅色差组织内容,避免重复嵌套同级卡片。 +带查看入口的系统弹出通知采用上下两区:上方排列状态图标、标题和正文,右上角预留关闭按钮;下方用细分隔线划出操作行,查看入口靠右并带跳转箭头,使用轻量按钮。无操作的通知不显示底部操作行,长正文自然换行,关闭与查看入口的点击区域互不重叠。 + 每个业务区最多一个醒目的主操作。按钮覆盖默认、hover、按压、焦点、禁用和加载态;hover 仅用于支持 hover 的设备,加载时阻止重复提交并保留可读状态。地图与业务控件沿用共享按压反馈,Agent 控件保留本地 shadcn/ui 交互状态;具有专用资源交互的 `AgentPersona` 保留原版悬停,不额外缩放或变暗。 地图与业务表单字段复用 `.form-field`,聚焦只改变原有 1px 边框;组合输入由外层承担焦点边界,无边框输入使用内描边。Agent 字段保留本地 shadcn/ui 输入组件的边框、焦点和错误样式。错误通过文字、`aria-invalid` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。 diff --git a/package.json b/package.json index 3d9a5b2..480cf13 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-resizable-panels": "^4.14.7", + "react-rnd": "10.5.3", "sonner": "^2.0.7", "streamdown": "^2.5.0", "swr": "^2.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a347897..4978f23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: react-resizable-panels: specifier: ^4.14.7 version: 4.14.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-rnd: + specifier: 10.5.3 + version: 10.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -3180,6 +3183,10 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3481,6 +3488,10 @@ packages: nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -3590,6 +3601,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -3603,11 +3617,26 @@ packages: quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + re-resizable@6.11.2: + resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==} + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: react: ^19.2.7 + react-draggable@4.7.2: + resolution: {integrity: sha512-pmqDx72EqhMGoJe/x42ZO2wJ9TIz7qJ5OPnFu6lVWWznEuJumlSjR+J0V4tNyDFhUo6dVvv56FeiTqN+arTgnw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -3637,6 +3666,12 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + react-rnd@10.5.3: + resolution: {integrity: sha512-s/sIT3pGZnQ+57egijkTp9mizjIWrJz68Pq6yd+F/wniFY3IriML18dUXnQe/HP9uMiJ+9MAp44hljG99fZu6Q==} + peerDependencies: + react: '>=16.3.0' + react-dom: '>=16.3.0' + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -3937,6 +3972,9 @@ packages: tslib@2.3.0: resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -7088,6 +7126,10 @@ snapshots: longest-streak@3.1.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -7654,6 +7696,8 @@ snapshots: nwsapi@2.2.24: {} + object-assign@4.1.1: {} + obug@2.1.4: {} oniguruma-parser@0.12.2: {} @@ -7760,6 +7804,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + property-information@7.2.0: {} protocol-buffers-schema@3.6.1: {} @@ -7768,11 +7818,25 @@ snapshots: quickselect@3.0.0: {} + re-resizable@6.11.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 scheduler: 0.27.0 + react-draggable@4.7.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react-is@16.13.1: {} + react-is@17.0.2: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): @@ -7799,6 +7863,14 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + react-rnd@10.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + re-resizable: 6.11.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-draggable: 4.7.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tslib: 2.6.2 + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): dependencies: get-nonce: 1.0.1 @@ -8148,6 +8220,8 @@ snapshots: tslib@2.3.0: {} + tslib@2.6.2: {} + tslib@2.8.1: {} tw-animate-css@1.4.0: {} diff --git a/src/features/agent/answer-ui/answer-display-time.test.ts b/src/features/agent/answer-ui/answer-display-time.test.ts new file mode 100644 index 0000000..d71d6bb --- /dev/null +++ b/src/features/agent/answer-ui/answer-display-time.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { Spec } from "@json-render/core"; +import { withAnswerDisplayTimes } from "./answer-display-time"; + +describe("answer presentation time zone", () => { + it("formats report text, tables, evidence and axes while preserving IDs and source samples", () => { + const spec: Spec = { root: "root", elements: { + root: { type: "Result", props: { title: "诊断", summary: "目标2026-07-16T00:00:00Z,当前08:00(UTC+8)" }, children: [] }, + table: { type: "Table", props: { title: "读数", columns: ["时间"], rows: [["2026-07-16T00:00:00Z"]] }, children: [] }, + evidence: { type: "Evidence", props: { observed_at: "2026-07-16T00:00:00Z", evidence_id: "2026-07-16T00:00:00Z" }, children: [] }, + chart: { type: "Chart", props: { x_axis_name: "时间(UTC0)", x_data: ["00:00 UTC"], + series: [{ name: "压力", data: [20], sample_times: ["2026-07-16T00:00:00Z"] }] }, children: [] } + } }; + const original = JSON.stringify(spec); + const result = withAnswerDisplayTimes(spec); + expect(result.elements.root.props.summary).toBe("目标2026/7/16 08:00:00(UTC+8),当前08:00(UTC+8)"); + expect(result.elements.table.props.rows).toEqual([["2026/7/16 08:00:00(UTC+8)"]]); + expect(result.elements.evidence.props.observed_at).toBe("2026/7/16 08:00:00(UTC+8)"); + expect(result.elements.evidence.props.evidence_id).toBe("2026-07-16T00:00:00Z"); + expect(result.elements.chart.props.x_axis_name).toBe("时间(UTC+8)"); + expect(result.elements.chart.props.x_data).toEqual(["08:00"]); + expect(JSON.stringify(spec)).toBe(original); + expect(withAnswerDisplayTimes(result)).toEqual(result); + }); + + it("converts bare times when their table header or chart axis explicitly declares UTC", () => { + const spec: Spec = { root: "chart", elements: { + chart: { type: "Chart", props: { x_axis_name: "时间(UTC0)", x_data: ["00:00", "2026-12-31 17:00", "08:00+08:00"], + series: [{ name: "压力", data: [20, 21, 22] }] }, children: [] }, + table: { type: "Table", props: { columns: ["观测时间(UTC)", "测点编号"], + rows: [["2026-12-31 17:00", "00:00"]] }, children: [] } + } }; + const result = withAnswerDisplayTimes(spec); + expect(result.elements.chart.props.x_data).toEqual(["08:00", "2027/1/1 01:00:00", "08:00+08:00"]); + expect(result.elements.table.props.columns).toEqual(["观测时间(UTC+8)", "测点编号"]); + expect(result.elements.table.props.rows).toEqual([["2027/1/1 01:00:00(UTC+8)", "00:00"]]); + expect(withAnswerDisplayTimes(result)).toEqual(result); + }); +}); diff --git a/src/features/agent/answer-ui/answer-display-time.ts b/src/features/agent/answer-ui/answer-display-time.ts new file mode 100644 index 0000000..f0debfd --- /dev/null +++ b/src/features/agent/answer-ui/answer-display-time.ts @@ -0,0 +1,37 @@ +import type { Spec } from "@json-render/core"; +import { formatDisplayText } from "@/shared/utils/date-time-text"; + +const textFields = new Set(["title", "description", "summary", "text", "label", "value", "source", "observed_at"]); +const utcLabel = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/; +const timeAxis = (value: string) => formatDisplayText(value).replace(/UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/g, "UTC+8"); +const bareTime = /^(?:\d{4}-\d{2}-\d{2}[T ])?\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/; + +function displayCell(value: string, heading: string): string { + return formatDisplayText(utcLabel.test(heading) && bareTime.test(value) ? `${value} UTC` : value); +} + +/** Render display fields in UTC8 while keeping the stored spec and identifiers intact. */ +export function withAnswerDisplayTimes(spec: Spec): Spec { + return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => { + const props = { ...node.props }; + for (const key of textFields) { + if (typeof props[key] === "string") props[key] = formatDisplayText(props[key]); + } + if (node.type === "Table") { + const columns = props.columns as string[]; + props.rows = (props.rows as string[][]).map((row) => row.map((cell, index) => displayCell(cell, columns[index] ?? ""))); + props.columns = columns.map(timeAxis); + } + if (node.type === "Chart") { + const axis = typeof props.x_axis_name === "string" ? props.x_axis_name : ""; + props.x_data = (props.x_data as string[]).map((label) => { + const displayed = displayCell(label, axis); + return timeAxis(axis).includes("UTC+8") ? displayed.replace(/(UTC\+8)$/, "") : displayed; + }); + if (typeof props.x_axis_name === "string") props.x_axis_name = timeAxis(props.x_axis_name); + props.series = (props.series as Array>).map((series) => ({ ...series, + name: typeof series.name === "string" ? formatDisplayText(series.name) : series.name })); + } + return [id, { ...node, props }]; + })) }; +} diff --git a/src/features/agent/answer-ui/answer-ui-interaction-context.ts b/src/features/agent/answer-ui/answer-ui-interaction-context.ts index 0fe29e2..2afbc93 100644 --- a/src/features/agent/answer-ui/answer-ui-interaction-context.ts +++ b/src/features/agent/answer-ui/answer-ui-interaction-context.ts @@ -11,5 +11,6 @@ export type AnswerUiInteractions = { onSubmit?: (text: string) => Promise | void; evidenceFilter?: string; submitMode?: "send" | "draft"; + chartWindowKey?: string; }; export const AnswerInteractionContext = createContext({ disabled: true }); diff --git a/src/features/agent/answer-ui/answer-ui-view.tsx b/src/features/agent/answer-ui/answer-ui-view.tsx index cb6e441..d9b90e1 100644 --- a/src/features/agent/answer-ui/answer-ui-view.tsx +++ b/src/features/agent/answer-ui/answer-ui-view.tsx @@ -28,6 +28,7 @@ import { AnswerContentNav } from "./answer-content-nav"; import { getAnswerTaskLayout } from "./answer-task-layout"; import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider"; import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state"; +import { withAnswerDisplayTimes } from "./answer-display-time"; const { registry: baseRegistry } = defineRegistry(answerUiCatalog, { actions: {}, @@ -103,7 +104,7 @@ const registry = withAnswerNodeIdentity(baseRegistry); type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial; export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) { return - + ; } @@ -112,6 +113,7 @@ function AnswerUiBody({ disabled = false, onLocate, onSubmit, + chartWindowKey, submitMode = "send" }: { block: AnswerUiBlock } & Partial) { const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState(); @@ -130,7 +132,7 @@ function AnswerUiBody({ const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section"); const renderNodes = (ids: string[]) => ids.map((id) => ); return ( - +
diff --git a/src/features/agent/answer-ui/answer-view-state-provider.tsx b/src/features/agent/answer-ui/answer-view-state-provider.tsx index a5ca46f..aad366d 100644 --- a/src/features/agent/answer-ui/answer-view-state-provider.tsx +++ b/src/features/agent/answer-ui/answer-view-state-provider.tsx @@ -10,6 +10,10 @@ import { const NodeContext = createContext(undefined); const ChangeContext = createContext(undefined); +export function AnswerNodeStateScope({ id, children }: { id: string; children: ReactNode }) { + return {children}; +} + /** The renderer clones props; local element metadata survives that adaptation. Never modify model props. */ export function withAnswerNodeIds(spec: Spec): Spec { return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => [id, { ...node, __answerNodeId: id }])) }; diff --git a/src/features/agent/answer-ui/answer-view-state.test.ts b/src/features/agent/answer-ui/answer-view-state.test.ts index 9b80a04..b87a1e3 100644 --- a/src/features/agent/answer-ui/answer-view-state.test.ts +++ b/src/features/agent/answer-ui/answer-view-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Spec } from "@json-render/core"; -import { answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state"; +import { answerChartTableData, answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state"; function spec(type: string, props: Record): Spec { return { root: "node", elements: { node: { type, props, children: [] } } }; @@ -10,6 +10,15 @@ const table = (rows = [["a", "1"], ["b", "2"]], columns = ["对象", "读数"]) const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields }); describe("answer view state compatibility", () => { + it("uses the same UTC8 sample display in chart tables while preserving stored samples", () => { + const series = [{ name: "压力", data: [20], sample_times: ["2026-07-16T00:00:00Z"] }]; + const current = spec("Chart", { chart_type: "line", x_data: ["08:00"], + series }); + const state = normalizeAnswerViewState(current).nodes.node; + if (state.type !== "Chart") throw new Error("Expected chart state"); + expect(answerChartTableData(current.elements.node, state).rows[0][1]).toBe("20 (2026-07-16 08:00:00+08:00)"); + expect(series[0].sample_times[0]).toBe("2026-07-16T00:00:00Z"); + }); it("ignores legacy row selection, retains search, and clears sort when columns change", () => { const before = table(); const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } }); diff --git a/src/features/agent/answer-ui/answer-view-state.ts b/src/features/agent/answer-ui/answer-view-state.ts index a279055..6203b94 100644 --- a/src/features/agent/answer-ui/answer-view-state.ts +++ b/src/features/agent/answer-ui/answer-view-state.ts @@ -1,4 +1,5 @@ import type { Spec } from "@json-render/core"; +import { chartSampleTime } from "./chart-presentation"; // `selection` is an empty legacy v1 wire field, never an interactive row selection. export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] }; @@ -53,9 +54,12 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN } export function answerChartTableData(node: Node, state: AnswerChartState) { - const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: number[] }>; + const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: Array; sample_times?: Array }>; const visible = series.filter((_, index) => !state.hidden.includes(index)); - return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data }) => String(data[state.range[0] + index]))]) }; + return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data, sample_times }) => { + const offset = state.range[0] + index, time = chartSampleTime(sample_times?.[offset]); + return `${data[offset] == null ? "缺测" : data[offset]}${time ? ` (${time})` : ""}`; + })]) }; } export function normalizeAnswerViewState(spec: Spec, input?: unknown): AnswerViewState { diff --git a/src/features/agent/answer-ui/catalog.ts b/src/features/agent/answer-ui/catalog.ts index 683498a..e6766ec 100644 --- a/src/features/agent/answer-ui/catalog.ts +++ b/src/features/agent/answer-ui/catalog.ts @@ -1,6 +1,7 @@ import { defineCatalog } from "@json-render/core"; import { schema } from "@json-render/react/schema"; import { z } from "zod"; +import { createChartSampleTimeSchema } from "../presentation/command-schema.ts"; const shortText = z.string().max(200); const text = z.string().max(4000); @@ -64,11 +65,15 @@ export const answerUiCatalog = defineCatalog(schema, { .object({ title: shortText, chart_type: z.enum(["line", "bar"]), + x_axis_name: z.string().max(128).optional(), + y_axis_name: z.string().max(128).optional(), x_data: z.array(shortText).min(1).max(100), series: z .array( z - .object({ name: shortText, data: z.array(z.number().finite()).min(1).max(100) }) + .object({ name: shortText, type: z.enum(["line", "bar", "scatter"]).optional(), + data: z.array(z.number().finite().nullable()).min(1).max(100), + sample_times: z.array(createChartSampleTimeSchema().nullable()).min(1).max(100).optional() }) .strict() ) .min(1) diff --git a/src/features/agent/answer-ui/chart-presentation.test.ts b/src/features/agent/answer-ui/chart-presentation.test.ts new file mode 100644 index 0000000..b8bda05 --- /dev/null +++ b/src/features/agent/answer-ui/chart-presentation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation"; + +describe("monitoring chart comparison", () => { + it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => { + expect(chartSampleTime("2026-12-31T16:00:00Z")).toBe("2027-01-01 00:00:00+08:00"); + expect(chartSampleTime("2027-01-01T00:00:00+08:00")).toBe("2027-01-01 00:00:00+08:00"); + expect(chartSampleTime(null)).toBe(""); + }); + it("frames current pressure and all historical points with a nonzero lower bound", () => { + const series = [{ name: "当前", data: [34, 32, 28] }, { name: "前一日", data: [null, 36, 37] }]; + const bounds = chartValueBounds(series, 0, 2); + expect(bounds.min).toBeGreaterThan(0); + expect(bounds.min).toBeLessThan(28); + expect(bounds.max).toBeGreaterThan(37); + expect(chartValueBounds([{ name: "无数据", data: [null] }], 0, 0)).toEqual({}); + expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1)).toEqual({ min: -0.08, max: 0.08 }); + }); + + it("preserves the original historical date in tooltip and marks missing values explicitly", () => { + const series = [{ name: "当前", data: [28], sample_times: ["2026-06-14T12:00:00+08:00"] }, + { name: "前一日", type: "scatter" as const, data: [37], sample_times: ["2026-06-13T12:00:00+08:00"] }, + { name: "缺测日", data: [null], sample_times: [null] }]; + const tooltip = chartPointTooltip("12:00", series, 0); + expect(tooltip).toContain("当前: 28\n采样时间 2026-06-14 12:00:00+08:00"); + expect(tooltip).toContain("前一日: 37\n采样时间 2026-06-13 12:00:00+08:00"); + expect(tooltip).not.toContain("缺测日"); + expect(chartValueLabel(null)).toBe("缺测"); + expect(chartValueLabel(0)).toBe("0"); + }); +}); diff --git a/src/features/agent/answer-ui/chart-presentation.ts b/src/features/agent/answer-ui/chart-presentation.ts new file mode 100644 index 0000000..60ede01 --- /dev/null +++ b/src/features/agent/answer-ui/chart-presentation.ts @@ -0,0 +1,28 @@ +import type { SafeChartSeries } from "../chart-data"; +import { formatDisplayTimestamp } from "@/shared/utils/date-time"; + +export function chartValueLabel(value: number | null | undefined): string { + return value == null ? "缺测" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(value); +} + +export function chartSampleTime(time: string | null | undefined): string { + return time ? formatDisplayTimestamp(time) : ""; +} + +export function chartValueBounds(series: SafeChartSeries[], start: number, end: number) { + const values = series.flatMap((item) => item.data.slice(start, end + 1)) + .filter((value): value is number => value !== null && Number.isFinite(value)); + if (!values.length) return {}; + const min = Math.min(...values), max = Math.max(...values); + const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08; + return { min: min - padding, max: max + padding }; +} + +/** The axis label is a projected comparison time; the sample timestamp remains authoritative. */ +export function chartPointTooltip(label: string, series: SafeChartSeries[], index: number): string { + const rows = series.filter((item) => item.data[index] != null).map((item) => { + const time = chartSampleTime(item.sample_times?.[index]); + return `${item.name}: ${chartValueLabel(item.data[index])}${time ? `\n采样时间 ${time}` : ""}`; + }); + return [label, ...rows].join("\n"); +} diff --git a/src/features/agent/answer-ui/contract.test.ts b/src/features/agent/answer-ui/contract.test.ts index e0c1945..3b2aad5 100644 --- a/src/features/agent/answer-ui/contract.test.ts +++ b/src/features/agent/answer-ui/contract.test.ts @@ -11,6 +11,19 @@ const block = { } }; describe("untrusted answer components", () => { + it("accepts a line with seven sparse history series while enforcing sample timestamp alignment", () => { + const x_data = Array.from({ length: 13 }, (_, index) => String(index)); + const series = [{ name: "当前", type: "line", data: x_data.map(() => 31) }, + ...Array.from({ length: 7 }, (_, index) => ({ name: `前${index + 1}日`, type: "scatter", + data: [...Array(10).fill(null), 33, 34, 35], + sample_times: [...Array(10).fill(null), ...[30, 45, 59].map((minute) => `2026-06-${String(13 - index).padStart(2, "0")}T11:${minute}:00+08:00`)] }))]; + const props = { title: "压力(m)", source: "合成样例", chart_type: "line", x_data, series }; + const make = (value: unknown) => ({ ...block, spec: { root: "chart", elements: { chart: { type: "Chart", props: value, children: [] } } } }); + expect(parseAnswerUiBlock(make(props))).not.toBeNull(); + expect(parseAnswerUiBlock(make({ ...props, series: [...series, series[0]] }))).toBeNull(); + expect(parseAnswerUiBlock(make({ ...props, series: [{ ...series[1], sample_times: [null] }] }))).toBeNull(); + expect(parseAnswerUiBlock(make({ ...props, series: [{ ...series[1], sample_times: Array(13).fill("2026-06-13T12:00:00+08:00") }] }))).toBeNull(); + }); it("keeps explicit UI phases and rejects invalid phase values", () => { expect(parseAnswerUiBlock(block)?.phase).toBeUndefined(); expect(parseAnswerUiBlock({ ...block, phase: "process" })?.phase).toBe("process"); diff --git a/src/features/agent/answer-ui/contract.ts b/src/features/agent/answer-ui/contract.ts index bd89e9d..fdc7f9f 100644 --- a/src/features/agent/answer-ui/contract.ts +++ b/src/features/agent/answer-ui/contract.ts @@ -56,8 +56,10 @@ export function parseAnswerUiBlock(value: unknown): AnswerUiBlock | null { if (!definition?.props.safeParse(node.props).success) return null; if (!["Stack", "Section"].includes(node.type) && node.children.length) return null; if (node.type === "Chart") { - const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> }; - if (data.series.some((series) => series.data.length !== data.x_data.length)) return null; + const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[]; sample_times?: Array }> }; + if (data.series.some((series) => series.data.length !== data.x_data.length || + series.sample_times && (series.sample_times.length !== data.x_data.length || + series.sample_times.some((time, index) => time !== null && series.data[index] === null)))) return null; } if (node.type === "Table") { const data = node.props as { columns: unknown[]; rows: unknown[][] }; diff --git a/src/features/agent/answer-ui/interactive-chart.tsx b/src/features/agent/answer-ui/interactive-chart.tsx index 762b9eb..35edb1d 100644 --- a/src/features/agent/answer-ui/interactive-chart.tsx +++ b/src/features/agent/answer-ui/interactive-chart.tsx @@ -1,23 +1,29 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { memo, useContext, useLayoutEffect, useRef, useState } from "react"; import { motion, useAnimationControls, useReducedMotion } from "motion/react"; -import ReactECharts from "echarts-for-react"; import { BarChart3, X, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react"; +import { FloatingWindow } from "@/shared/ui/floating-window"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Button } from "@/shared/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible"; import { InteractiveDataTable } from "./interactive-data-table"; +import { ResponsiveChart } from "./responsive-chart"; import { agentEase } from "../components/agent-motion"; +import type { SafeChartSeries } from "../chart-data"; +import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation"; +import { AnswerInteractionContext } from "./answer-ui-interaction-context"; export type AnswerChartProps = { title: string; chart_type: "line" | "bar"; x_data: string[]; - series: Array<{ name: string; data: number[] }>; + series: SafeChartSeries[]; + x_axis_name?: string; + y_axis_name?: string; source: string; }; -import { useAnswerNodeState } from "./answer-view-state-provider"; +import { AnswerNodeStateScope, AnswerViewStateProvider, useAnswerNodeState } from "./answer-view-state-provider"; import type { AnswerChartState } from "./answer-view-state"; const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"]; @@ -29,12 +35,34 @@ function PointSelect({ label, value, labels, onChange }: { {labels.map((text, index) => {text})} ; } -export function InteractiveChart(props: AnswerChartProps) { +type ChartPresentation = { expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string }; +const standaloneRegistry = {}; + +/** Legacy chart envelopes use the same controls and chart engine as structured answers. */ +export function StandaloneInteractiveChart(props: AnswerChartProps & ChartPresentation) { + const spec = { root: "chart", elements: { chart: { type: "Chart", props: { + title: props.title, chart_type: props.chart_type, x_data: props.x_data, series: props.series, source: props.source + }, children: [] } } }; + return ; +} + +export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) { + const { chartWindowKey } = useContext(AnswerInteractionContext); const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState("Chart", props); const reducedMotion = useReducedMotion(); const pointer = useRef(false); const plot = useRef(null); const [animateInteraction, setAnimateInteraction] = useState(false); + const [plotWidth, setPlotWidth] = useState(360); + useLayoutEffect(() => { + const element = plot.current; + if (props.expanded || !element || typeof ResizeObserver === "undefined") return; + const resize = () => setPlotWidth(element.clientWidth); + resize(); + const observer = new ResizeObserver(resize); + observer.observe(element); + return () => observer.disconnect(); + }, [view, props.expanded]); const contentMotion = useAnimationControls(); const pendingTransition = useRef(false); useLayoutEffect(() => { @@ -62,111 +90,65 @@ export function InteractiveChart(props: AnswerChartProps) { const pointIndex = Math.min(range[1], Math.max(range[0], point)); const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1; const summaries = visible.map((series) => { - const values = series.data.slice(range[0], range[1] + 1); - return { name: series.name, first: values[0], last: values.at(-1)!, min: Math.min(...values), max: Math.max(...values) }; + const values = series.data.slice(range[0], range[1] + 1).filter((value): value is number => value !== null); + return { name: series.name, first: values[0], last: values.at(-1), + min: values.length ? Math.min(...values) : null, max: values.length ? Math.max(...values) : null }; }); - const option = (type: "line" | "bar") => ({ - animation: animateInteraction && !reducedMotion, - animationDuration: 180, - animationDurationUpdate: 180, - color: colors.filter((_, index) => !hidden.includes(index)), - aria: { enabled: true }, - tooltip: { trigger: "axis", renderMode: "richText" }, - grid: { top: 16, right: 12, bottom: 38, left: 12, containLabel: true }, - xAxis: { type: "category", data: labels, axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: true } }, - yAxis: { type: "value", axisLabel: { fontSize: 11, color: "#64748b" }, splitLine: { lineStyle: { color: "#e2e8f0" } } }, - series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1), - showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } })) - }); - return { pointer.current = true; }} - onKeyDownCapture={() => { - pointer.current = false; - setAnimateInteraction(false); - contentMotion.stop(); - contentMotion.set({ opacity: 1, y: 0 }); - }}> - - {props.title} - - - - - - {migrationNotice ?

{migrationNotice}

: null} - - - - 折线 - 柱状 - 数据 - -
- {props.series.map((series, index) => )} -
- {!fullRange ?

{props.x_data[range[0]]} 至 {props.x_data[range[1]]}

: null} - -
- setRange([start, Math.max(start, range[1])])} /> - 至 - setRange([Math.min(range[0], end), end])} /> -
- -
- - {(["line", "bar"] as const).map((type) => -
{ - if (event.target !== event.currentTarget) return; - if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { - event.preventDefault(); - const next = event.key === "Home" ? range[0] : event.key === "End" ? range[1] - : Math.min(range[1], Math.max(range[0], pointIndex + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0))); - patch({ point: next, inspecting: true }); - } - if (event.key === "Escape") setInspecting(false); - }}> - 点击图中数据点查看详情,也可使用回车和左右方向键。 - { - if (event.componentType === "series" && typeof event.dataIndex === "number") { - patch({ point: range[0] + event.dataIndex, inspecting: true }); - } - } }} /> -
-
)} - - series.name)]} - rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])} - state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} /> - -
-
- {view !== "table" ?
+ const composite = props.series.some((series) => series.type === "scatter"); + const categoryColors = visible.length === 1; + const horizontalBars = props.x_data.some((label) => label.length > 10); + const numberLabel = chartValueLabel; + const option = (type: "line" | "bar") => { + const horizontal = type === "bar" && horizontalBars; + const seriesType = (series: SafeChartSeries) => view === props.chart_type ? series.type ?? type : type; + const includesBars = visible.some((series) => seriesType(series) === "bar"); + const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar", + axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto", + ...(horizontal ? { width: props.expanded ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: 16 } : {}) }, + axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } }; + const valueAxis = { type: "value", scale: !includesBars, + ...(!includesBars ? chartValueBounds(visible, range[0], range[1]) : {}), + axisLabel: { fontSize: 11, color: "#64748b", formatter: numberLabel }, + splitLine: { lineStyle: { color: "#e2e8f0" } } }; + return { + animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180, + textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' }, + color: colors.filter((_, index) => !hidden.includes(index)), aria: { enabled: true }, + tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true, + textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 }, + ...(composite ? { formatter: (params: unknown) => { + const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined; + const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible; + const text = typeof first?.dataIndex === "number" + ? chartPointTooltip(labels[first.dataIndex], focused, range[0] + first.dataIndex) : ""; + // ECharts richText tooltips omit the font family. Plain DOM text keeps CJK + // readable and never interprets model-provided labels as HTML. + const content = document.createElement("div"); + content.textContent = text; + content.style.whiteSpace = "pre-line"; + return content; + } } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } }, + grid: { top: 28, right: 24, bottom: (props.xAxisName ?? props.x_axis_name) ? 48 : 28, left: 12, containLabel: true }, + xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontal ? props.yAxisName ?? props.y_axis_name : props.xAxisName ?? props.x_axis_name, + nameLocation: "middle", nameGap: 30 }, + yAxis: horizontal ? { ...categoryAxis, inverse: true, name: props.xAxisName ?? props.x_axis_name } : { ...valueAxis, name: props.yAxisName ?? props.y_axis_name }, + series: visible.map((series) => ({ name: series.name, type: seriesType(series), + data: series.data.slice(range[0], range[1] + 1).map((value, index) => type === "bar" && categoryColors + ? { value, itemStyle: { color: colors[(range[0] + index) % colors.length] } } : value), + barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false, + showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } })) + }; + }; + const metadata = <> + {!props.embedded && view !== "table" ?
区间摘要 · 本地统计
{summaries.map((item) =>
{item.name} - {item.first} → {item.last} - 最小 {item.min} · 最大 {item.max} + {numberLabel(item.first)} → {numberLabel(item.last)} + 最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)}
)}
: null} @@ -183,13 +165,117 @@ export function InteractiveChart(props: AnswerChartProps) {
{visible.map((series, index) =>
{series.name}
-
{series.data[pointIndex]}
+
{numberLabel(series.data[pointIndex])} + {series.sample_times?.[pointIndex] ? {chartSampleTime(series.sample_times[pointIndex])} : null} +
)}
: null}

{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列
来源:{props.source}

+ ; + return { pointer.current = true; }} + onKeyDownCapture={() => { + pointer.current = false; + setAnimateInteraction(false); + contentMotion.stop(); + contentMotion.set({ opacity: 1, y: 0 }); + }}> + + {props.embedded || props.expanded ? "图形与数据" : props.title} +
+ {!props.embedded && !props.expanded ? + {() => } + : null} + + + +
+
+ + {migrationNotice ?

{migrationNotice}

: null} + + [role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}> + + {composite ? "复合" : "折线"} + 柱状 + 数据 + + +
+ {!fullRange ?

{props.x_data[range[0]]} 至 {props.x_data[range[1]]}

: null} + +
+ setRange([start, Math.max(start, range[1])])} /> + 至 + setRange([Math.min(range[0], end), end])} /> +
+ +
+
+ + {(["line", "bar"] as const).map((type) => +
{ + if (event.target !== event.currentTarget) return; + if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { + event.preventDefault(); + const next = event.key === "Home" ? range[0] : event.key === "End" ? range[1] + : Math.min(range[1], Math.max(range[0], pointIndex + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0))); + patch({ point: next, inspecting: true }); + } + if (event.key === "Escape") setInspecting(false); + }}> + 点击图中数据点查看详情,也可使用回车和左右方向键。 +
+ patch({ point: range[0] + index, inspecting: true })} /> +
+
+
)} + + series.name)]} + rows={labels.map((label, index) => [label, ...visible.map((series) => { + const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]); + return `${series.data[offset] == null ? "缺测" : series.data[offset]}${time ? ` (${time})` : ""}`; + })])} + state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} /> + +
+ {!props.expanded ? metadata : null} +
; -} +}); diff --git a/src/features/agent/answer-ui/interactive-data-table.tsx b/src/features/agent/answer-ui/interactive-data-table.tsx index a0adb22..16b6add 100644 --- a/src/features/agent/answer-ui/interactive-data-table.tsx +++ b/src/features/agent/answer-ui/interactive-data-table.tsx @@ -2,16 +2,18 @@ import { useMemo } from "react"; import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; import { Input } from "@/shared/ui/input"; import { Button } from "@/shared/ui/button"; +import { FloatingWindow } from "@/shared/ui/floating-window"; import { cn } from "@/shared/ui/cn"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table"; import { useAnswerNodeState } from "./answer-view-state-provider"; import type { AnswerTableState } from "./answer-view-state"; -export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange }: { +export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange, expanded = false }: { title: string; columns: string[]; rows: string[][]; state?: Omit; onStateChange?: (patch: Partial>) => void; + expanded?: boolean; }) { const [localState, patchLocal] = useAnswerNodeState("Table", { title, columns, rows }); const state = controlledState ?? localState; @@ -34,6 +36,9 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt { patch({ query: event.currentTarget.value }); }} /> {filtered.length} / {rows.length} 行 + {!expanded ? + {() => } + : null}
diff --git a/src/features/agent/answer-ui/responsive-chart.tsx b/src/features/agent/answer-ui/responsive-chart.tsx new file mode 100644 index 0000000..d72d072 --- /dev/null +++ b/src/features/agent/answer-ui/responsive-chart.tsx @@ -0,0 +1,37 @@ +import { useLayoutEffect, useRef, type ComponentProps } from "react"; +import ReactECharts from "echarts-for-react"; + +/** Resize the existing drawing at most once per frame without replacing its options. */ +export function ResponsiveChart({ option, height, onPointSelect }: { + option: ComponentProps["option"]; + height: number | "100%"; + onPointSelect: (index: number) => void; +}) { + const host = useRef(null); + const chart = useRef(null); + const ready = useRef(false); + useLayoutEffect(() => { + const element = host.current; + if (!element || typeof ResizeObserver === "undefined") return; + let frame = 0; + const observer = new ResizeObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + if (!ready.current || !element.clientWidth || !element.clientHeight) return; + chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } }); + }); + }); + observer.observe(element); + return () => { cancelAnimationFrame(frame); observer.disconnect(); }; + }, []); + return
+ { + ready.current = true; + instance.resize({ width: "auto", height: "auto", animation: { duration: 0 } }); + }} + onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => { + if (event.componentType === "series" && typeof event.dataIndex === "number") onPointSelect(event.dataIndex); + } }} /> +
; +} diff --git a/src/features/agent/chart-data.test.ts b/src/features/agent/chart-data.test.ts index c908a6c..679a2b5 100644 --- a/src/features/agent/chart-data.test.ts +++ b/src/features/agent/chart-data.test.ts @@ -56,4 +56,14 @@ describe("chart data normalization", () => { expect(data?.xData).toHaveLength(5); expect(data?.series[0].data).toEqual([0, 1, 2, 3, 4]); }); + + it("preserves missing slots, real zero and scatter sample dates without shifting indices", () => { + const data = normalizeSafeChartData({ + x_data: ["11:30", "11:45", "12:00", "12:15"], + series: [{ name: "前一日", type: "scatter", data: [null, "bad", 0, 31.5], + sample_times: ["2026-06-13T11:30:00+08:00", null, "2026-06-13T12:00:00+08:00", "2026-06-13T12:15:00+08:00"] }] + }); + expect(data?.series[0]).toEqual({ name: "前一日", type: "scatter", data: [null, null, 0, 31.5], + sample_times: [null, null, "2026-06-13T12:00:00+08:00", "2026-06-13T12:15:00+08:00"] }); + }); }); diff --git a/src/features/agent/chart-data.ts b/src/features/agent/chart-data.ts index 6ac3b6a..a7812d9 100644 --- a/src/features/agent/chart-data.ts +++ b/src/features/agent/chart-data.ts @@ -1,3 +1,7 @@ +import { createChartSampleTimeSchema } from "./presentation/command-schema"; + +const sampleTimeSchema = createChartSampleTimeSchema(); + export type SafeChartSpec = { title?: string; chartType: "line" | "bar"; @@ -7,8 +11,9 @@ export type SafeChartSpec = { export type SafeChartSeries = { name: string; - data: number[]; - type?: "line" | "bar"; + data: Array; + type?: "line" | "bar" | "scatter"; + sample_times?: Array; }; export type SafeChartData = { @@ -34,6 +39,7 @@ type RawChartSeries = { points?: unknown; values?: unknown; type?: unknown; + sample_times?: unknown; }; export function parseChartSpec(value: unknown): SafeChartSpec { @@ -75,12 +81,12 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar .map((point, index) => { const parsed = pointToLabelValue(point as RawChartPoint, xData[index] ?? `${index + 1}`); if (!parsed) { + labelsFromPoints[index] = xData[index] ?? `${index + 1}`; return null; } labelsFromPoints[index] = parsed.label; return parsed.value; - }) - .filter((item): item is number => item !== null); + }); if (!data.length) { return null; @@ -93,7 +99,11 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar return { name: stringValue(item.name) ?? `系列 ${seriesIndex + 1}`, data, - type: item.type === "line" || item.type === "bar" ? item.type : undefined + type: item.type === "line" || item.type === "bar" || item.type === "scatter" ? item.type : undefined, + ...(Array.isArray(item.sample_times) ? { sample_times: data.map((value, index) => { + const time = item.sample_times as unknown[]; + return value !== null && sampleTimeSchema.safeParse(time[index]).success ? time[index] as string : null; + }) } : {}) }; }) .filter((item): item is SafeChartSeries => Boolean(item)); @@ -101,7 +111,8 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar const clippedXData = xData.slice(0, maxPoints); const clippedSeries = normalizedSeries.map((series) => ({ ...series, - data: series.data.slice(0, clippedXData.length) + data: series.data.slice(0, clippedXData.length), + ...(series.sample_times ? { sample_times: series.sample_times.slice(0, clippedXData.length) } : {}) })); return clippedXData.length > 0 && clippedSeries.length > 0 diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx index 2833101..fcbdc91 100644 --- a/src/features/agent/components/agent-command-panel.tsx +++ b/src/features/agent/components/agent-command-panel.tsx @@ -557,7 +557,6 @@ export function AgentCommandPanel({ {centeredComposer && !promptReference ? (

有什么可以帮你?

-

询问供水问题,或从最近的任务继续。

) : null} >; embedded?: boolean }) { - return ; -} +export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false }: { chart: NonNullable>; embedded?: boolean; expanded?: boolean }) { + return ; +}); -function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract; embedded?: boolean }) { +function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract; embedded?: boolean; expanded?: boolean }) { const spec = parseChartSpec(envelope.spec); const data = normalizeSafeChartData(envelope.data, 100); @@ -83,173 +85,17 @@ function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract; } - return ( -
-
- {!embedded ?
-

- {spec.title ?? "Agent 图表"} -

-

- {spec.chartType === "bar" ? "柱状图" : "折线图"} - {spec.yAxisName ? ` · ${spec.yAxisName}` : ""} -

-
: null} -
- {data.series.slice(0, 3).map((series, index) => ( - - - {embedded ? {series.name} : series.name} - - ))} -
-
- - {spec.xAxisName ?

{spec.xAxisName}

: null} -
- ); -} - -function SafeChartSvg({ spec, data }: { spec: SafeChartSpec; data: SafeChartData }) { - const width = 360; - const height = 190; - const padding = { top: 16, right: 18, bottom: 32, left: 38 }; - const plotWidth = width - padding.left - padding.right; - const plotHeight = height - padding.top - padding.bottom; - const values = data.series.flatMap((series) => series.data); - const min = Math.min(0, ...values); - const max = Math.max(...values); - const range = max - min || 1; - const xFor = (index: number) => - padding.left + (data.xData.length === 1 ? plotWidth / 2 : (index / (data.xData.length - 1)) * plotWidth); - const yFor = (value: number) => padding.top + plotHeight - ((value - min) / range) * plotHeight; - const ticks = createTicks(min, max); - - return ( - - - {ticks.map((tick) => { - const y = yFor(tick); - return ( - - - - {formatChartNumber(tick)} - - - ); - })} - - - {data.xData.map((label, index) => ( - - {shortLabel(label)} - - ))} - {data.series.slice(0, 4).map((series, seriesIndex) => - (series.type ?? spec.chartType) === "bar" ? ( - - ) : ( - - ) - )} - - ); -} - -function LineSeries({ - series, - seriesIndex, - xFor, - yFor -}: { - series: SafeChartSeries; - seriesIndex: number; - xFor: (index: number) => number; - yFor: (value: number) => number; -}) { - const points = series.data.map((value, index) => `${xFor(index)},${yFor(value)}`).join(" "); - const stroke = chartStroke(seriesIndex); - - return ( - - - {series.data.map((value, index) => ( - - ))} - - ); -} - -function BarSeries({ - series, - seriesIndex, - seriesCount, - xFor, - yFor, - baselineY -}: { - series: SafeChartSeries; - seriesIndex: number; - seriesCount: number; - xFor: (index: number) => number; - yFor: (value: number) => number; - baselineY: number; -}) { - const fill = chartStroke(seriesIndex); - const barWidth = Math.max(5, 22 / seriesCount); - const offset = (seriesIndex - (seriesCount - 1) / 2) * (barWidth + 2); - - return ( - - {series.data.map((value, index) => { - const y = yFor(value); - return ( - - ); - })} - - ); + return ; } function RegisteredComponentEnvelope({ @@ -377,35 +223,6 @@ function getSurfaceLabel(surface: UIEnvelope["surface"]) { return "地图叠加"; } -function createTicks(min: number, max: number) { - const range = max - min || 1; - return [max, min + range * 0.5, min]; -} - -function chartStroke(index: number) { - return ["#2563eb", "#0aa6a6", "#ff7a45", "#0477bf"][index % 4] ?? "#2563eb"; -} - -function chartColorClass(index: number) { - return ["bg-[var(--action-blue)]", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-[var(--action-blue)]"; -} - -function formatChartNumber(value: number) { - if (Math.abs(value) >= 100) { - return value.toFixed(0); - } - - if (Math.abs(value) >= 10) { - return value.toFixed(1); - } - - return value.toFixed(2); -} - -function shortLabel(value: string) { - return value.length > 8 ? `${value.slice(0, 7)}...` : value; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/src/features/agent/frontend-action/chart.test.ts b/src/features/agent/frontend-action/chart.test.ts index 7532fb3..c73deea 100644 --- a/src/features/agent/frontend-action/chart.test.ts +++ b/src/features/agent/frontend-action/chart.test.ts @@ -10,4 +10,14 @@ describe("frontend chart boundary", () => { expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20, Infinity] }] })).toBeNull(); expect(parseFrontendChart({ ...chart, x_data: Array(101).fill("t") })).toBeNull(); }); + it("accepts sparse history with real timestamp metadata and rejects invented or misaligned timestamps", () => { + const series = { name: "前一天", type: "scatter", data: [null, 31], + sample_times: [null, "2026-06-13T12:00:00+08:00"] }; + expect(parseFrontendChart({ ...chart, y_axis_name: "压力(m)", series: [chart.series[0], series] })).not.toBeNull(); + for (const sample_times of [["2026-06-13T11:45:00+08:00", series.sample_times[1]], + [null], [null, "2026-06-13T12:00:00"], [null, "2026-02-30T12:00:00+08:00"]]) { + expect(parseFrontendChart({ ...chart, series: [{ ...series, sample_times }] })).toBeNull(); + } + expect(parseFrontendChart({ ...chart, series: [{ ...series, type: "pie" }] })).toBeNull(); + }); }); diff --git a/src/features/agent/presentation/chart-sample-time.test.ts b/src/features/agent/presentation/chart-sample-time.test.ts new file mode 100644 index 0000000..dd1ab14 --- /dev/null +++ b/src/features/agent/presentation/chart-sample-time.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from "vitest"; +import { parseFrontendChart } from "../frontend-action/chart"; +import { parseAnswerUiBlock } from "../answer-ui/contract"; +import { normalizeSafeChartData } from "../chart-data"; + +// Keep this matrix in sync with Agent and Server chart timestamp conformance tests. +const cases: Array<[string, boolean]> = [ + ["2026-06-14T12:00:00Z", true], ["2026-06-14T12:00:00+08:00", true], + ["2026-06-14T12:00:00-03:30", true], ["2024-02-29T23:59:59.123456Z", true], + ["2026-06-14T12:00:00+23:59", true], [`2026-06-14T12:00:00.${"1".repeat(43)}Z`, true], + ["2026-06-14 12:00:00+08:00", false], ["2026-W24-7T12:00:00+08:00", false], + ["2026-06-14T12:00+08:00", false], ["2026-06-14T12:00:00+0800", false], + ["2026-02-30T12:00:00Z", false], ["2026-13-14T12:00:00Z", false], + ["2026-06-00T12:00:00Z", false], ["2026-02-29T12:00:00Z", false], + ["2026-06-14T24:00:00Z", false], ["2026-06-14T12:00:60Z", false], + ["2026-06-14T12:00:00+24:00", false], ["2026-06-14T12:00:00+08:60", false], + ["2026-06-14T12:00:00", false], ["2026-06-14T12:00:00z", false], + ["0000-06-14T12:00:00Z", false], [`2026-06-14T12:00:00.${"1".repeat(44)}Z`, false] +]; +const chart = (data: Array, sample_times: Array) => ({ + title: "压力", chart_type: "line", x_data: ["12:00"], series: [{ name: "测点", data, sample_times }] +}); +const block = (value: ReturnType) => ({ + version: "answer-ui@1", block_id: "pressure", fallback_text: "压力数据", spec: { root: "chart", elements: { + chart: { type: "Chart", props: { ...value, source: "实测" }, children: [] } + } } +}); + +it.each(cases)("uses the same timestamp boundary for chart commands, answer blocks and legacy metadata: %s", (time, accepted) => { + const value = chart([1], [time]); + expect(parseFrontendChart(value) !== null).toBe(accepted); + expect(parseAnswerUiBlock(block(value)) !== null).toBe(accepted); + expect(normalizeSafeChartData(value)?.series[0].sample_times).toEqual([accepted ? time : null]); +}); + +it.each([ + [[0], [null], true], [[null], [null], true], [[null], ["2026-06-14T12:00:00Z"], false], [[1], [], false] +] as Array<[Array, Array, boolean]>)("preserves zero, null and sample alignment: %j %j", (data, times, accepted) => { + const value = chart(data, times); + expect(parseFrontendChart(value) !== null).toBe(accepted); + expect(parseAnswerUiBlock(block(value)) !== null).toBe(accepted); +}); diff --git a/src/features/agent/presentation/command-schema.ts b/src/features/agent/presentation/command-schema.ts index a9e234e..947fae1 100644 --- a/src/features/agent/presentation/command-schema.ts +++ b/src/features/agent/presentation/command-schema.ts @@ -1,5 +1,12 @@ import { z } from "zod"; +/** Sample timestamps retain their original spelling within the shared RFC3339 subset. */ +export function createChartSampleTimeSchema(zod: typeof z = z) { + return zod.string().max(64).datetime({ offset: true }).regex( + /^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$/ + ); +} + /** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */ export function createViewSchemas(zod: typeof z = z, publication = false) { const z = zod; @@ -9,13 +16,17 @@ export function createViewSchemas(zod: typeof z = z, publication = false) { .object({ title: z.string().max(200), chart_type: z.enum(["line", "bar"]), + x_axis_name: z.string().max(128).optional(), + y_axis_name: z.string().max(128).optional(), x_data: z.array(z.string().max(128)).min(1).max(100), series: z .array( z .object({ name: z.string().max(128), - data: z.array(z.number().finite()).min(1).max(100) + type: z.enum(["line", "bar", "scatter"]).optional(), + data: z.array(z.number().finite().nullable()).min(1).max(100), + sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional() }) .strict() ) @@ -24,7 +35,9 @@ export function createViewSchemas(zod: typeof z = z, publication = false) { }) .strict() .refine( - (value) => value.series.every((series) => series.data.length === value.x_data.length), + (value) => value.series.every((series) => series.data.length === value.x_data.length && + (!series.sample_times || series.sample_times.length === value.x_data.length && + series.sample_times.every((time, index) => time === null || series.data[index] !== null))), "series must align with x_data" ); const position = z diff --git a/src/features/map/core/components/notice-actions.test.tsx b/src/features/map/core/components/notice-actions.test.tsx new file mode 100644 index 0000000..4a4f454 --- /dev/null +++ b/src/features/map/core/components/notice-actions.test.tsx @@ -0,0 +1,52 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { toast } from "sonner"; +import { dismissMapNotice, showMapNotice } from "./notice-actions"; +import type { MapNoticeTone } from "./notice-types"; + +vi.mock("sonner", () => ({ toast: { custom: vi.fn(), dismiss: vi.fn() } })); +afterEach(() => { cleanup(); vi.clearAllMocks(); }); + +it.each(["info", "success", "warning", "error", "loading"])( + "keeps actionable %s notices until manual dismissal even with an explicit timeout", async (tone) => { + const onAction = vi.fn(); + const id = showMapNotice({ tone, title: "报告已更新", message: "查看分析结果", duration: 10_000, + dismissible: false, actionLabel: "查看报告", onAction }); + await Promise.resolve(); + const [content, options] = vi.mocked(toast.custom).mock.calls[0]; + expect(options).toMatchObject({ duration: Infinity, dismissible: true }); + render(content(id)); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "查看报告" })); + expect(onAction).toHaveBeenCalledOnce(); + expect(toast.dismiss).not.toHaveBeenCalled(); + expect(options?.cancel).toMatchObject({ label: expect.anything(), onClick: expect.any(Function) }); + } +); + +it("preserves transient feedback and the explicit lifecycle of non-actionable notices", async () => { + showMapNotice({ tone: "success", message: "已保存" }); + await Promise.resolve(); + expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ duration: 3200 }); + showMapNotice({ tone: "warning", message: "稍后重试", duration: 8000, actionLabel: "缺少回调" }); + await Promise.resolve(); + expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ duration: 8000 }); + showMapNotice({ tone: "loading", message: "正在加载" }); + await Promise.resolve(); + const [content, options] = vi.mocked(toast.custom).mock.lastCall!; + expect(options).toMatchObject({ duration: Infinity, dismissible: false }); + render(content("loading")); + expect(screen.queryByRole("button", { name: "关闭通知" })).not.toBeInTheDocument(); +}); + +it("publishes after effects and cancels notices dismissed before publication", async () => { + showMapNotice({ id: "source", tone: "loading", message: "加载中" }); + showMapNotice({ id: "source", tone: "success", message: "已完成" }); + const cancelled = showMapNotice({ tone: "info", message: "已取消" }); + dismissMapNotice(cancelled); + expect(toast.custom).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(toast.custom).toHaveBeenCalledOnce(); + expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ id: "source", duration: 3200 }); +}); diff --git a/src/features/map/core/components/notice-actions.tsx b/src/features/map/core/components/notice-actions.tsx index 53c80a6..8dc0419 100644 --- a/src/features/map/core/components/notice-actions.tsx +++ b/src/features/map/core/components/notice-actions.tsx @@ -1,46 +1,30 @@ -import type { ComponentProps } from "react"; -import { toast, Toaster } from "sonner"; +import { toast } from "sonner"; import { cn } from "@/shared/ui/cn"; import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles"; import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation"; import type { MapNoticeOptions, MapNoticePosition } from "./notice-types"; -type SonnerPosition = NonNullable["position"]>; - const DEFAULT_POSITION: MapNoticePosition = "top-center"; - -const positionClassNames: Record = { - "top-center": "left-1/2! right-auto! top-[84px] [translate:-50%_0]", - "top-right": "top-[84px] right-[72px]", - "bottom-left": "bottom-12 left-4", - "bottom-right": "bottom-12 right-4" -}; - -const sonnerPositions: Record = { - "top-center": "top-center", - "top-right": "top-right", - "bottom-left": "bottom-left", - "bottom-right": "bottom-right" -}; +const pendingNotices = new Map void>(); +let nextNoticeId = 0; export function showMapNotice(options: MapNoticeOptions) { + const id = options.id ?? `map-notice:${++nextNoticeId}`; const tone = options.tone ?? "info"; const position = options.position ?? DEFAULT_POSITION; - const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity); - const dismissible = options.dismissible ?? tone !== "loading"; + const hasAction = Boolean(options.actionLabel && options.onAction); + // Actionable notices must remain available even when a caller supplies a timeout. + const duration = hasAction + ? Infinity + : options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity); + const dismissible = hasAction || (options.dismissible ?? tone !== "loading"); - return toast.custom( + const publish = () => toast.custom( () => , { - id: options.id, - duration, - position: sonnerPositions[position], - dismissible, + id, duration, position, dismissible, cancel: dismissible - ? { - label: , - onClick: (event) => event.stopPropagation() - } + ? { label: , onClick: (event) => event.stopPropagation() } : undefined, classNames: { cancelButton: cn( @@ -48,11 +32,22 @@ export function showMapNotice(options: MapNoticeOptions) { MAP_ICON_CELL_RADIUS_CLASS_NAME ) }, - className: cn("fixed! z-[60]! w-[min(420px,calc(100vw-24px))]!", positionClassNames[position]) + className: "w-full" } ); + // Publish after React finishes effects: Sonner renews its subscription during + // those effects and can otherwise miss an alert while map sources update. + pendingNotices.set(id, publish); + queueMicrotask(() => { + if (pendingNotices.get(id) !== publish) return; + pendingNotices.delete(id); + publish(); + }); + return id; } export function dismissMapNotice(id?: string | number) { + if (id === undefined) pendingNotices.clear(); + else pendingNotices.delete(id); toast.dismiss(id); } diff --git a/src/features/map/core/components/notice-presentation.tsx b/src/features/map/core/components/notice-presentation.tsx index c6f4324..19a6764 100644 --- a/src/features/map/core/components/notice-presentation.tsx +++ b/src/features/map/core/components/notice-presentation.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react"; +import { AlertTriangle, ArrowUpRight, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react"; import type { ReactNode } from "react"; import { cn } from "@/shared/ui/cn"; import { Button } from "@/shared/ui/button"; @@ -32,16 +32,16 @@ export function MapNoticeContent({ return ( - - - ) : null - } - /> +
+ + +
+ {actionLabel && onAction ? ( +
+ 保留至手动关闭 + +
+ ) : null}
); } @@ -59,8 +59,11 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea return (
event.stopPropagation()} className={cn( - "glass-transient relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm text-slate-900", + "glass-transient relative w-full overflow-hidden border text-sm text-slate-900", MAP_READABLE_RADIUS_CLASS_NAME, )} > @@ -90,23 +93,20 @@ function MapNoticeIcon({ function MapNoticeBody({ title, - message, - action + message }: { title?: string; message: string; - action?: ReactNode; }) { return ( - +
{title ? ( - {title} + {title} ) : null} - + {message} - {action} - +
); } @@ -118,11 +118,12 @@ function MapNoticeAction({ label, onClick }: { label: string; onClick: () => voi size="sm" variant="outline" className={cn( - "pointer-events-auto mt-2 border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]", + "pointer-events-auto h-9 max-w-full gap-1.5 whitespace-normal border-[var(--surface-border)] bg-[var(--action-soft)] px-2.5 text-xs text-[var(--action-blue-hover)]", MAP_COMPACT_RADIUS_CLASS_NAME )} > {label} +
: null} {action.evidence_refs.length > 0 ? ( diff --git a/src/features/workbench/components/feature-location-panel.test.tsx b/src/features/workbench/components/feature-location-panel.test.tsx new file mode 100644 index 0000000..4cababe --- /dev/null +++ b/src/features/workbench/components/feature-location-panel.test.tsx @@ -0,0 +1,42 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FeatureLocationPanel } from "./feature-location-panel"; + +afterEach(cleanup); + +const state = { pending: false, targets: [], targetResult: null, errorCode: null }; +const createCommands = () => ({ highlight: vi.fn(), zoomToFeature: vi.fn(), locateAndHighlight: vi.fn(), clearHighlight: vi.fn() }); + +describe("FeatureLocationPanel", () => { + it("uses the selected feature's source and ID together", async () => { + const user = userEvent.setup(); + const commands = createCommands(); + const onUseSelectedFeature = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "使用当前地图选中" })); + expect(screen.getByLabelText("要素 ID(可多个)")).toHaveValue("PRESSURE-17"); + await user.click(screen.getByRole("button", { name: "定位并高亮" })); + expect(commands.locateAndHighlight).toHaveBeenCalledWith({ sourceId: "scada", featureIds: ["PRESSURE-17"] }); + expect(onUseSelectedFeature).toHaveBeenCalledOnce(); + }); + + it("keeps map actions disabled until the map is ready and while a request is pending", async () => { + const user = userEvent.setup(); + const commands = createCommands(); + const props = { commands, controllerState: state, detailFeature: null }; + const { rerender } = render(); + await user.type(screen.getByLabelText("要素 ID(可多个)"), "P-17"); + const action = () => screen.getByRole("button", { name: "定位并高亮" }); + expect(action()).toBeDisabled(); + rerender(); + expect(action()).toBeEnabled(); + rerender(); + expect(screen.getByRole("button", { name: "正在定位要素" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "仅高亮" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "缩放到要素" })).toBeDisabled(); + expect(commands.locateAndHighlight).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/workbench/components/feature-location-panel.tsx b/src/features/workbench/components/feature-location-panel.tsx new file mode 100644 index 0000000..d31d5f9 --- /dev/null +++ b/src/features/workbench/components/feature-location-panel.tsx @@ -0,0 +1,102 @@ +import { Crosshair, LocateFixed, MousePointer2, Trash2, ZoomIn } from "lucide-react"; +import { useId, useState } from "react"; +import { Button } from "@/shared/ui/button"; +import { FieldSelect } from "@/shared/ui/field-select"; +import { Textarea } from "@/shared/ui/textarea"; +import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query"; +import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, SUPPLY_LAYER_CATALOG, type AvailableWaterNetworkSourceId } from "../map/sources"; +import type { FeatureBatchTarget, WorkbenchMapCommands, WorkbenchMapControllerState } from "../map/workbench-map-controller"; +import type { DetailFeature } from "../types"; +import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback"; + +type FeatureLocationDraft = { + sourceId: AvailableWaterNetworkSourceId; + featureId: string; +}; + +export type FeatureLocationPanelProps = { + commands: Pick; + controllerState: Pick; + detailFeature: DetailFeature | null; + enabled?: boolean; + value?: FeatureLocationDraft; + onValueChange?: (value: FeatureLocationDraft) => void; + onUseSelectedFeature?: () => void; + showError?: boolean; + contentClassName?: string; +}; + +export function FeatureLocationPanel({ commands, controllerState, detailFeature, enabled = true, + value, onValueChange, onUseSelectedFeature, showError = true, contentClassName }: FeatureLocationPanelProps) { + const inputId = useId(); + const helpId = useId(); + const [draft, setDraft] = useState({ sourceId: "pipes", featureId: "" }); + const { sourceId, featureId } = value ?? draft; + function updateDraft(next: FeatureLocationDraft) { + if (!value) setDraft(next); + onValueChange?.(next); + } + const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))]; + const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH); + const canLocate = enabled && featureIds.length > 0 && !invalidIds && !controllerState.pending; + const canUseSelected = Boolean(detailFeature && AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(detailFeature.layer as AvailableWaterNetworkSourceId)); + const target: FeatureBatchTarget = { sourceId, featureIds }; + const result = controllerState.targetResult; + + return ( +
+ updateDraft({ sourceId: nextSourceId as AvailableWaterNetworkSourceId, featureId: "" })} + options={SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => ({ value: layer.id, label: layer.label }))} + /> +
+
+ + {featureIds.length} / {MAX_MAP_FEATURE_IDS} +
+